Remove Supabase
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m40s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled

The database moved to a container of our own; the platform is gone.
This takes out what was left of it — and, where the leftovers were load
bearing, moves rather than deletes.

Moved, not deleted:

  supabase/migrations/  -> db/migrations/      the schema's source of truth
  supabase/build-org.ts -> scripts/build-org.ts
  lib/supabase/types.ts -> lib/types.ts        52 import sites repointed

The bookkeeping needed care. It lived in `supabase_migrations.schema_migrations`,
and simply renaming the schema would have left the runner facing an empty
table: it would have called all 67 migrations pending and replayed them
against a database that is long since current. So the runner now creates
`migrationen.schema_migrations` and, once, copies the old rows across —
guarded so a second run does nothing and a fresh database skips it entirely.
Only then does migration 20260907100000 drop the old schema.

Deleted: the CLI config, the seed, the historical schema/function dumps
(nothing read them), scripts/umzug-von-supabase.sh (the move is done), and
both Supabase packages plus the CLI. Nothing in the application imported
them — the build now succeeds with no environment variables at all, which
is the proof.

Integration tests: six of them signed in through Supabase Auth and asserted
against the anon key and the service role. That model is gone, so the tests
were not portable — they are deleted. session-context and
employee-status-filter already ran on pg and are untouched; om-reporting is
ported to a direct connection because it guards a real risk (the reporting
line rule exists twice, once in SQL and once in TypeScript).

CI: the integration job started a Supabase stack. It now runs a postgres
service, applies deploy/db-init and every migration to an empty database —
that was the valuable part, and it still holds — then checks that a second
run is a no-op, which is what proves the bookkeeping works.

Docs: security-review.md audited a service-role key, a cookie adapter and
auth.users, none of which exist. Restating findings about removed components
would suggest today's system had been reviewed; it has not. It now records
what was removed and says a fresh review is due. data-model.md was already
marked obsolete and described the pre-OM schema; azure-migration.md was a
plan for a route not taken. Both deleted.

Verified: npm ci, typecheck, lint, 445 tests, build — all clean without the
packages. Integration tests skip cleanly with no database. Migration SQL and
the runner are reviewed but NOT executed: no Docker here, and the old
instance no longer resolves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-07 10:43:22 +02:00
parent 5c310c3a58
commit b87c8ad64c
155 changed files with 386 additions and 4014 deletions

View File

@@ -0,0 +1,360 @@
-- Alpenwerk HR — initial schema
--
-- Based on spec §3, with the following corrections
-- (see the Phase 1 plan for the full rationale):
-- - gender_type restricted to m/w (spec's domain rules exclude "divers")
-- - employees.location replaced by a locations reference table + location_id FK
-- (the spec's own CHECK listed Wien/Linz/Graz, which contradicts §2's real site list)
-- - nationality constrained to the picklist named in §2
-- - added: locations, profiles (role-based access), employees_directory (salary-masked view)
-- - added: address/address_country/contract_end_date columns (required by §4.5's "Daten ändern"
-- panel and the befristet/"Befristet bis" rule, but missing from §3's literal table)
-- - added: 'Stammdatenänderung' history event type (required by §4.5, missing from §3's enum)
-- - added: reorg_scenarios.undo_snapshot jsonb (required by §4.7's undo feature)
-- - added: position number generation + org-unit auto-derivation as real functions/triggers
create extension if not exists "pgcrypto";
-- ── Org units ────────────────────────────────────────────────
create table divisions ( -- "Bereich", numbers 20xxxxxx
id uuid primary key default gen_random_uuid(),
org_number text not null unique check (org_number ~ '^20\d{6}$'),
name text not null unique
);
create table departments ( -- "Abteilung", numbers 21xxxxxx
id uuid primary key default gen_random_uuid(),
org_number text not null unique check (org_number ~ '^21\d{6}$'),
name text not null,
division_id uuid not null references divisions(id)
);
create table teams ( -- "Team", numbers 22xxxxxx
id uuid primary key default gen_random_uuid(),
org_number text not null unique check (org_number ~ '^22\d{6}$'),
name text not null,
department_id uuid not null references departments(id)
);
-- ── Locations (site ties to a country; country picklist drives the UI's
-- "select country auto-selects its locations" rule from §2) ─────────
create table locations (
id uuid primary key default gen_random_uuid(),
name text not null unique, -- Wien-Hernals, Wolkersdorf, Köln, Brünn, Ljubljana
country text not null check (country in ('Österreich', 'Deutschland', 'Tschechien', 'Slowenien'))
);
-- ── Profiles (role-based access per §6) ─────────────────────
create table profiles (
id uuid primary key references auth.users(id) on delete cascade,
email text not null,
full_name text,
role text not null default 'manager' check (role in ('hr_admin', 'manager')),
created_at timestamptz not null default now()
);
-- ── Employees ────────────────────────────────────────────────
create type employment_status as enum ('Aktiv', 'Karenz', 'Geplant', 'Ausgetreten');
create type employment_type as enum ('Vollzeit', 'Teilzeit');
create type contract_type as enum ('unbefristet', 'befristet');
create type paygrade_type as enum ('A', 'B', 'C', 'D', 'E', 'F');
create type source_type as enum ('Intern', 'Extern');
create type gender_type as enum ('m', 'w');
create table employees (
id uuid primary key default gen_random_uuid(),
personnel_number int generated always as identity (start with 1001), -- Pers.-Nr.
first_name text not null,
last_name text not null,
gender gender_type not null,
birth_date date not null,
sv_nummer text,
nationality text not null default 'Österreich' check (nationality in (
'Österreich', 'Deutschland', 'Tschechien', 'Slowenien', 'Türkei',
'Serbien', 'Kroatien', 'Bosnien', 'Ungarn', 'Andere'
)),
address text,
address_country text check (address_country in ('Österreich', 'Deutschland', 'Tschechien', 'Slowenien', 'Andere')),
email text not null unique,
phone text,
team_id uuid references teams(id), -- nullable only for CEO / division heads without a team
division_id uuid not null references divisions(id), -- auto-derived from team_id by trigger when team_id is set
job_title text not null,
location_id uuid not null references locations(id),
manager_id uuid references employees(id),
org_level int not null default 3 check (org_level between 0 and 3), -- 0=CEO,1=division head,2=team lead,3=IC
is_lead boolean not null default false,
employment_type employment_type not null default 'Vollzeit',
weekly_hours numeric(4,1) not null default 38.5,
monthly_salary_gross numeric(10,2) not null check (monthly_salary_gross > 0), -- 14x/year convention
contract_type contract_type not null default 'unbefristet',
contract_end_date date,
paygrade paygrade_type not null default 'B',
source source_type not null default 'Extern',
status employment_status not null default 'Aktiv',
entry_date date not null,
exit_date date,
exit_reason text,
karenz_return_date date,
avatar_color text, -- hex, for initials badge; app falls back to a deterministic hash if null
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint chk_exit_after_entry check (exit_date is null or exit_date >= entry_date),
constraint chk_karenz_return_after_entry check (karenz_return_date is null or karenz_return_date >= entry_date),
constraint chk_befristet_end check (contract_type <> 'befristet' or contract_end_date is not null),
constraint chk_weekly_hours check (
(employment_type = 'Vollzeit' and weekly_hours = 38.5) or
(employment_type = 'Teilzeit' and weekly_hours > 0 and weekly_hours < 38.5)
)
);
create index on employees (team_id);
create index on employees (division_id);
create index on employees (manager_id);
create index on employees (status);
-- ── Employee history (append-only audit trail per person) ───
create type history_event_type as enum (
'Eintritt', 'Beförderung', 'Versetzung', 'Karenz', 'Vertragsänderung', 'Stammdatenänderung',
'Austritt', 'Wiedereintritt', 'Reorganisation', 'Gehaltsanpassung', 'Rückkehr'
);
create table employee_history (
id uuid primary key default gen_random_uuid(),
employee_id uuid not null references employees(id) on delete cascade,
event_date date not null,
event_type history_event_type not null,
description text not null,
created_at timestamptz not null default now()
);
create index on employee_history (employee_id, event_date desc);
-- ── Positions (Planstellen) ──────────────────────────────────
create table positions (
id uuid primary key default gen_random_uuid(),
position_number text not null unique check (position_number ~ '^6\d{7}$'),
title text not null,
team_id uuid not null references teams(id),
division_id uuid not null references divisions(id), -- auto-derived from team_id by trigger
is_lead boolean not null default false,
reports_to_employee_id uuid references employees(id), -- the superior manager chosen at creation
status text not null default 'open' check (status in ('open', 'filled')),
created_at timestamptz not null default now(),
filled_at timestamptz,
filled_by_employee_id uuid references employees(id)
);
create index on positions (team_id);
create index on positions (status);
-- ── Hire drafts (resumable wizard state) ─────────────────────
create table hire_drafts (
id uuid primary key default gen_random_uuid(),
created_by uuid references auth.users(id),
step int not null default 0,
payload jsonb not null, -- full wizard form state
updated_at timestamptz not null default now()
);
-- ── Saved reports ────────────────────────────────────────────
create table saved_reports (
id uuid primary key default gen_random_uuid(),
created_by uuid references auth.users(id),
name text not null,
config jsonb not null, -- { measure, group, split, filters... }
created_at timestamptz not null default now()
);
-- ── Audit log (system-wide, immutable) ───────────────────────
create table audit_log (
id uuid primary key default gen_random_uuid(),
occurred_at timestamptz not null default now(),
actor_user_id uuid references auth.users(id),
actor_name text not null,
action text not null, -- e.g. 'Neueinstellung','Austritt','Versetzung','Beförderung','Karenz',
-- 'Vertragsänderung','Stammdatenänderung','Wiedereinstellung','Ausschreibung',
-- 'Interne Besetzung','Reorganisation','Reorganisation rückgängig','Rückkehr',
-- 'Gehaltsanpassung'
target_label text not null, -- human-readable name of what changed
target_employee_id uuid references employees(id),
details text
);
create index on audit_log (occurred_at desc);
-- ── Reorg scenarios (persistence of in-progress/applied reorg plans) ─
create table reorg_scenarios (
id uuid primary key default gen_random_uuid(),
name text not null,
effective_date date not null,
created_by uuid references auth.users(id),
applied boolean not null default false,
applied_at timestamptz,
undo_snapshot jsonb, -- pre-change employee state + history/audit high-water marks, for undo
created_at timestamptz not null default now()
);
create table reorg_moves (
id uuid primary key default gen_random_uuid(),
scenario_id uuid not null references reorg_scenarios(id) on delete cascade,
kind text not null check (kind in ('emp', 'team', 'abt', 'dept')),
payload jsonb not null -- employee ids / team id / dept id / target division, counts, labels
);
-- ── Functions & triggers ─────────────────────────────────────
-- Auto-derive division_id from team_id (keeps the denormalized division in sync
-- with the team's real parent chain; §3's closing instruction).
create or replace function fn_set_employee_org_unit()
returns trigger
language plpgsql
as $$
begin
if new.team_id is not null then
select dep.division_id into new.division_id
from teams t
join departments dep on dep.id = t.department_id
where t.id = new.team_id;
end if;
return new;
end;
$$;
create trigger trg_employees_set_org_unit
before insert or update of team_id on employees
for each row execute function fn_set_employee_org_unit();
create or replace function fn_set_position_org_unit()
returns trigger
language plpgsql
as $$
begin
select dep.division_id into new.division_id
from teams t
join departments dep on dep.id = t.department_id
where t.id = new.team_id;
return new;
end;
$$;
create trigger trg_positions_set_org_unit
before insert or update of team_id on positions
for each row execute function fn_set_position_org_unit();
create or replace function fn_touch_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at = now();
return new;
end;
$$;
create trigger trg_employees_touch_updated_at
before update on employees
for each row execute function fn_touch_updated_at();
-- Unique 8-digit position numbers starting with '6' (§2).
create or replace function generate_position_number()
returns text
language plpgsql
as $$
declare
candidate text;
begin
loop
candidate := '6' || lpad(floor(random() * 10000000)::text, 7, '0');
exit when not exists (select 1 from positions where position_number = candidate);
end loop;
return candidate;
end;
$$;
alter table positions alter column position_number set default generate_position_number();
-- ── Role helper (SECURITY DEFINER avoids RLS recursion on profiles) ──
create or replace function is_hr_admin()
returns boolean
language sql
security definer
set search_path = public
stable
as $$
select exists (
select 1 from profiles p where p.id = auth.uid() and p.role = 'hr_admin'
);
$$;
-- ── Salary-masked read view for the manager role (§6) ────────
-- Owned by the migration role (postgres), which bypasses RLS on the base
-- table, so this view is reachable by both roles while column-masking
-- salary per session via is_hr_admin().
create view employees_directory as
select
e.id, e.personnel_number, e.first_name, e.last_name, e.gender, e.birth_date, e.sv_nummer,
e.nationality, e.address, e.address_country, e.email, e.phone, e.team_id, e.division_id,
e.job_title, e.location_id, e.manager_id, e.org_level, e.is_lead, e.employment_type,
e.weekly_hours,
case when is_hr_admin() then e.monthly_salary_gross else null end as monthly_salary_gross,
e.contract_type, e.contract_end_date, e.paygrade, e.source, e.status, e.entry_date, e.exit_date,
e.exit_reason, e.karenz_return_date, e.avatar_color, e.created_at, e.updated_at
from employees e;
grant select on employees_directory to authenticated;
-- ── Row Level Security ───────────────────────────────────────
alter table divisions enable row level security;
alter table departments enable row level security;
alter table teams enable row level security;
alter table locations enable row level security;
alter table profiles enable row level security;
alter table employees enable row level security;
alter table employee_history enable row level security;
alter table positions enable row level security;
alter table hire_drafts enable row level security;
alter table saved_reports enable row level security;
alter table audit_log enable row level security;
alter table reorg_scenarios enable row level security;
alter table reorg_moves enable row level security;
-- Org reference data: readable by any authenticated user, writable by hr_admin only.
create policy "org_read" on divisions for select using (auth.role() = 'authenticated');
create policy "org_write" on divisions for all using (is_hr_admin()) with check (is_hr_admin());
create policy "org_read" on departments for select using (auth.role() = 'authenticated');
create policy "org_write" on departments for all using (is_hr_admin()) with check (is_hr_admin());
create policy "org_read" on teams for select using (auth.role() = 'authenticated');
create policy "org_write" on teams for all using (is_hr_admin()) with check (is_hr_admin());
create policy "org_read" on locations for select using (auth.role() = 'authenticated');
create policy "org_write" on locations for all using (is_hr_admin()) with check (is_hr_admin());
-- Profiles: users read their own row; hr_admin reads/writes all.
create policy "profiles_select_own" on profiles for select using (auth.uid() = id);
create policy "profiles_select_admin" on profiles for select using (is_hr_admin());
create policy "profiles_write_admin" on profiles for insert with check (is_hr_admin());
create policy "profiles_update_admin" on profiles for update using (is_hr_admin()) with check (is_hr_admin());
-- Employees: only hr_admin reads/writes the base table directly. The manager
-- role reads through employees_directory instead (salary masked there).
create policy "employees_admin_all" on employees for all using (is_hr_admin()) with check (is_hr_admin());
-- Employee history: any authenticated user can read; only hr_admin can append; immutable otherwise.
create policy "history_read" on employee_history for select using (auth.role() = 'authenticated');
create policy "history_insert_admin" on employee_history for insert with check (is_hr_admin());
-- Positions: any authenticated user can browse open positions; hr_admin manages them.
create policy "positions_read" on positions for select using (auth.role() = 'authenticated');
create policy "positions_write_admin" on positions for all using (is_hr_admin()) with check (is_hr_admin());
-- Hire drafts: scoped to their creator.
create policy "hire_drafts_owner" on hire_drafts for all
using (created_by = auth.uid()) with check (created_by = auth.uid());
-- Saved reports: scoped to their creator.
create policy "saved_reports_owner" on saved_reports for all
using (created_by = auth.uid()) with check (created_by = auth.uid());
-- Audit log: any authenticated user can read; only hr_admin can append; immutable (no update/delete policy).
create policy "audit_read" on audit_log for select using (auth.role() = 'authenticated');
create policy "audit_insert_admin" on audit_log for insert with check (is_hr_admin());
-- Reorg scenarios/moves: any authenticated user can see the (small) recent list; hr_admin manages them.
create policy "reorg_scenarios_read" on reorg_scenarios for select using (auth.role() = 'authenticated');
create policy "reorg_scenarios_write_admin" on reorg_scenarios for all using (is_hr_admin()) with check (is_hr_admin());
create policy "reorg_moves_read" on reorg_moves for select using (auth.role() = 'authenticated');
create policy "reorg_moves_write_admin" on reorg_moves for all using (is_hr_admin()) with check (is_hr_admin());

View File

@@ -0,0 +1,11 @@
-- Addendum to supabase/schema.sql — run after that file.
--
-- Staatsbürgerschaft and Wohnland now use a searchable picker over the
-- full UN member states list (193 countries, see lib/countries.ts)
-- instead of the original ~9/5-value picklists. The old CHECK constraints
-- would reject nearly all of those values, so they're dropped here. The
-- app is the source of truth for valid values (same approach the rest of
-- the app already relies on for large open-ended pickers); the columns
-- stay plain text (nationality keeps its NOT NULL).
alter table employees drop constraint if exists employees_nationality_check;
alter table employees drop constraint if exists employees_address_country_check;

View File

@@ -0,0 +1,555 @@
-- Alpenwerk HR — mutation RPCs (Phase 2/3)
--
-- One Postgres function per business mutation from the spec
-- §4.4/§4.5/§4.6/§4.7. Each function runs as SECURITY INVOKER (the caller's own
-- session), so the existing RLS policy on `employees` (hr_admin only) is the
-- real authorization gate; the is_hr_admin() check at the top of each function
-- just produces a clearer error message than a bare RLS violation.
--
-- A single function call is one Postgres transaction: if any statement raises,
-- everything in that call rolls back automatically. Run this whole file in the
-- Supabase SQL Editor after supabase/schema.sql.
alter table employee_history add column if not exists reorg_scenario_id uuid references reorg_scenarios(id);
create or replace function current_actor_name()
returns text language sql stable as $$
select coalesce(p.full_name, p.email, 'Unbekannt') from profiles p where p.id = auth.uid();
$$;
create or replace function require_hr_admin()
returns void language plpgsql as $$
begin
if not is_hr_admin() then
raise exception 'Nicht berechtigt: nur HR-Admin darf diese Aktion ausführen.';
end if;
end;
$$;
-- Manager derivation (§2's "reports-to" rule), used by every mutation that
-- changes an employee's team/leadership status.
create or replace function resolve_manager_for(p_team_id uuid, p_is_lead boolean, p_division_id uuid)
returns uuid language plpgsql as $$
declare
v_manager uuid;
begin
if p_team_id is not null and not p_is_lead then
select id into v_manager from employees
where team_id = p_team_id and is_lead = true and status <> 'Ausgetreten' limit 1;
elsif p_team_id is not null and p_is_lead then
select id into v_manager from employees
where division_id = p_division_id and team_id is null and org_level = 1 and status <> 'Ausgetreten' limit 1;
else
select id into v_manager from employees where org_level = 0 and status <> 'Ausgetreten' limit 1;
end if;
return v_manager;
end;
$$;
create or replace function generate_company_email(p_first_name text, p_last_name text)
returns text language plpgsql as $$
declare
base text;
candidate text;
n int := 1;
translit text;
begin
translit := lower(p_first_name || '.' || p_last_name);
translit := replace(replace(replace(replace(translit, 'ä','ae'), 'ö','oe'), 'ü','ue'), 'ß','ss');
base := regexp_replace(translit, '[^a-z0-9.]', '', 'g');
candidate := base || '@test.manner.at';
while exists (select 1 from employees where email = candidate) loop
n := n + 1;
candidate := base || n::text || '@test.manner.at';
end loop;
return candidate;
end;
$$;
-- ── Hire (§4.4) ───────────────────────────────────────────────
create or replace function hire_employee(payload jsonb)
returns uuid language plpgsql as $$
declare
v_id uuid;
v_team_id uuid;
v_division_id uuid;
v_position record;
v_job_title text;
v_email text;
v_manager uuid;
begin
perform require_hr_admin();
if payload->>'position_id' is not null then
select * into v_position from positions where id = (payload->>'position_id')::uuid and status = 'open';
if not found then
raise exception 'Position ist nicht mehr offen.';
end if;
v_team_id := v_position.team_id;
v_division_id := v_position.division_id;
v_job_title := coalesce(payload->>'job_title', v_position.title);
else
v_team_id := (payload->>'team_id')::uuid;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id where t.id = v_team_id;
v_job_title := payload->>'job_title';
end if;
v_manager := resolve_manager_for(v_team_id, false, v_division_id);
v_email := generate_company_email(payload->>'first_name', payload->>'last_name');
insert into employees (
first_name, last_name, gender, birth_date, sv_nummer, nationality, email, phone,
team_id, division_id, job_title, location_id, manager_id, org_level, is_lead,
employment_type, weekly_hours, monthly_salary_gross, contract_type, contract_end_date,
paygrade, source, status, entry_date
) values (
payload->>'first_name', payload->>'last_name', (payload->>'gender')::gender_type,
(payload->>'birth_date')::date, payload->>'sv_nummer', coalesce(payload->>'nationality', 'Österreich'),
v_email, payload->>'phone',
v_team_id, v_division_id, v_job_title, (payload->>'location_id')::uuid,
v_manager, 3, false,
coalesce((payload->>'employment_type')::employment_type, 'Vollzeit'),
coalesce((payload->>'weekly_hours')::numeric, 38.5),
(payload->>'monthly_salary_gross')::numeric,
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 (payload->>'entry_date')::date > current_date then 'Geplant' else 'Aktiv' end)::employment_status,
(payload->>'entry_date')::date
) returning id into v_id;
if payload->>'position_id' is not null then
update positions set status = 'filled', filled_at = now(), filled_by_employee_id = v_id
where id = (payload->>'position_id')::uuid;
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_id, (payload->>'entry_date')::date, 'Eintritt', 'Eintritt als ' || v_job_title);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Neueinstellung', (payload->>'first_name') || ' ' || (payload->>'last_name'), v_id, 'Eintritt am ' || (payload->>'entry_date'));
return v_id;
end;
$$;
-- ── Austritt (§4.5) ───────────────────────────────────────────
create or replace function terminate_employee(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_manager uuid;
v_name text;
begin
perform require_hr_admin();
select manager_id, first_name || ' ' || last_name into v_manager, v_name from employees where id = v_employee_id;
update employees set manager_id = v_manager where manager_id = v_employee_id and status <> 'Ausgetreten';
update employees set
status = 'Ausgetreten',
exit_date = (payload->>'exit_date')::date,
exit_reason = payload->>'exit_reason'
where id = v_employee_id;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, (payload->>'exit_date')::date, 'Austritt',
'Austritt (' || (payload->>'exit_reason') || ')' || case when payload->>'note' is not null and payload->>'note' <> '' then ' — ' || (payload->>'note') else '' end);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Austritt', v_name, v_employee_id, payload->>'exit_reason');
end;
$$;
-- ── Versetzung (§4.5) ─────────────────────────────────────────
create or replace function transfer_employee(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_new_team_id uuid := (payload->>'new_team_id')::uuid;
v_division_id uuid;
v_manager uuid;
v_name text;
v_is_lead boolean;
begin
perform require_hr_admin();
select division_id into v_division_id from teams t join departments d on d.id = t.department_id where t.id = v_new_team_id;
select is_lead, first_name || ' ' || last_name into v_is_lead, v_name from employees where id = v_employee_id;
v_manager := resolve_manager_for(v_new_team_id, v_is_lead, v_division_id);
update employees set
team_id = v_new_team_id,
job_title = coalesce(nullif(payload->>'new_title', ''), job_title),
manager_id = v_manager
where id = v_employee_id;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, (payload->>'effective_date')::date, 'Versetzung',
'Versetzung, wirksam ab ' || (payload->>'effective_date') ||
case when payload->>'new_title' is not null and payload->>'new_title' <> '' then ', neue Position: ' || (payload->>'new_title') else '' end);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Versetzung', v_name, v_employee_id, 'Wirksam ab ' || (payload->>'effective_date'));
end;
$$;
-- ── Beförderung (§4.5) ────────────────────────────────────────
create or replace function promote_employee(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_old_paygrade paygrade_type;
v_name text;
v_details 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;
update employees set
job_title = payload->>'new_title',
monthly_salary_gross = (payload->>'new_salary')::numeric,
paygrade = coalesce((payload->>'new_paygrade')::paygrade_type, paygrade)
where id = v_employee_id;
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 Paygrade: ' || (payload->>'new_paygrade');
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, (payload->>'effective_date')::date, 'Beförderung', v_details);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Beförderung', v_name, v_employee_id, v_details);
end;
$$;
-- ── Karenz verwalten (§4.5) — adjust return date or record actual return ──
create or replace function adjust_karenz_return(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_name text;
begin
perform require_hr_admin();
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
update employees set karenz_return_date = (payload->>'new_return_date')::date where id = v_employee_id;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, current_date, 'Karenz',
'Rückkehrdatum angepasst auf ' || (payload->>'new_return_date') ||
case when payload->>'note' is not null and payload->>'note' <> '' then ' — ' || (payload->>'note') else '' end);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Karenz', v_name, v_employee_id, 'Neues Rückkehrdatum: ' || (payload->>'new_return_date'));
end;
$$;
create or replace function record_karenz_return(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_return_date date := (payload->>'return_date')::date;
v_name text;
v_team_id uuid;
v_division_id uuid;
v_is_lead boolean;
v_manager uuid;
v_employment_type employment_type;
v_weekly_hours numeric;
begin
perform require_hr_admin();
select first_name || ' ' || last_name, team_id, division_id, is_lead
into v_name, v_team_id, v_division_id, v_is_lead
from employees where id = v_employee_id;
if payload->>'employment_mode' = 'Vollzeit' then
v_employment_type := 'Vollzeit'; v_weekly_hours := 38.5;
elsif payload->>'employment_mode' = 'Teilzeit' then
v_employment_type := 'Teilzeit'; v_weekly_hours := (payload->>'weekly_hours')::numeric;
end if;
if v_return_date <= current_date then
v_manager := resolve_manager_for(v_team_id, v_is_lead, v_division_id);
update employees set
status = 'Aktiv',
karenz_return_date = null,
manager_id = v_manager,
employment_type = coalesce(v_employment_type, employment_type),
weekly_hours = coalesce(v_weekly_hours, weekly_hours)
where id = v_employee_id;
else
update employees set karenz_return_date = v_return_date,
employment_type = coalesce(v_employment_type, employment_type),
weekly_hours = coalesce(v_weekly_hours, weekly_hours)
where id = v_employee_id;
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_return_date, 'Rückkehr', 'Wiedereintritt aus Karenz am ' || (payload->>'return_date'));
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Rückkehr', v_name, v_employee_id, 'Rückkehr am ' || (payload->>'return_date'));
end;
$$;
-- ── Daten ändern (§4.5) — diffs person vs. contract fields ────
create or replace function change_employee_data(payload jsonb)
returns void language plpgsql as $$
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 text[] := '{}';
v_contract_changes text[] := '{}';
v_person jsonb := payload->'person';
v_contract jsonb := payload->'contract';
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;
if v_person ? 'first_name' and (v_person->>'first_name') <> v_old.first_name then v_person_changes := array_append(v_person_changes, 'Vorname'); end if;
if v_person ? 'last_name' and (v_person->>'last_name') <> v_old.last_name then v_person_changes := array_append(v_person_changes, 'Nachname'); end if;
if v_person ? 'gender' and (v_person->>'gender') <> v_old.gender::text then v_person_changes := array_append(v_person_changes, 'Geschlecht'); end if;
if v_person ? 'birth_date' and (v_person->>'birth_date')::date <> v_old.birth_date then v_person_changes := array_append(v_person_changes, 'Geburtsdatum'); end if;
if v_person ? 'sv_nummer' and coalesce(v_person->>'sv_nummer','') <> coalesce(v_old.sv_nummer,'') then v_person_changes := array_append(v_person_changes, 'SV-Nummer'); end if;
if v_person ? 'nationality' and (v_person->>'nationality') <> v_old.nationality then v_person_changes := array_append(v_person_changes, 'Staatsbürgerschaft'); end if;
if v_person ? 'address' and coalesce(v_person->>'address','') <> coalesce(v_old.address,'') then v_person_changes := array_append(v_person_changes, 'Adresse'); end if;
if v_person ? 'address_country' and coalesce(v_person->>'address_country','') <> coalesce(v_old.address_country,'') then v_person_changes := array_append(v_person_changes, 'Land'); end if;
if v_person ? 'email' and (v_person->>'email') <> v_old.email then v_person_changes := array_append(v_person_changes, 'E-Mail'); end if;
if v_person ? 'phone' and coalesce(v_person->>'phone','') <> coalesce(v_old.phone,'') then v_person_changes := array_append(v_person_changes, 'Telefon'); end if;
if v_contract ? 'employment_type' and (v_contract->>'employment_type') <> v_old.employment_type::text then v_contract_changes := array_append(v_contract_changes, 'Beschäftigungsausmaß'); end if;
if v_contract ? 'weekly_hours' and (v_contract->>'weekly_hours')::numeric <> v_old.weekly_hours then v_contract_changes := array_append(v_contract_changes, 'Wochenstunden'); end if;
if v_contract ? 'contract_type' and (v_contract->>'contract_type') <> v_old.contract_type::text then v_contract_changes := array_append(v_contract_changes, 'Vertragsart'); end if;
if v_contract ? 'contract_end_date' and coalesce(nullif(v_contract->>'contract_end_date','')::date::text,'') <> coalesce(v_old.contract_end_date::text,'') then v_contract_changes := array_append(v_contract_changes, 'Befristet bis'); end if;
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 = coalesce(v_person->>'sv_nummer', sv_nummer),
nationality = coalesce(v_person->>'nationality', nationality),
address = coalesce(v_person->>'address', address),
address_country = coalesce(v_person->>'address_country', address_country),
email = coalesce(v_person->>'email', email),
phone = coalesce(v_person->>'phone', phone),
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
where id = v_employee_id;
if array_length(v_person_changes, 1) > 0 then
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_effective_date, 'Stammdatenänderung', 'Geänderte Felder: ' || array_to_string(v_person_changes, ', ') || ', wirksam ab ' || v_effective_date);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Stammdatenänderung', v_name, v_employee_id, array_to_string(v_person_changes, ', ') || ', wirksam ab ' || v_effective_date);
end if;
if array_length(v_contract_changes, 1) > 0 then
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_effective_date, 'Vertragsänderung', 'Geänderte Felder: ' || array_to_string(v_contract_changes, ', ') || ', wirksam ab ' || v_effective_date);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Vertragsänderung', v_name, v_employee_id, array_to_string(v_contract_changes, ', ') || ', wirksam ab ' || v_effective_date);
end if;
end;
$$;
-- ── Wiedereinstellung (§4.5) ──────────────────────────────────
create or replace function rehire_employee(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_rehire_date date := (payload->>'rehire_date')::date;
v_team_id uuid;
v_division_id uuid;
v_is_lead boolean;
v_manager uuid;
v_name text;
begin
perform require_hr_admin();
select team_id, division_id, is_lead, first_name || ' ' || last_name
into v_team_id, v_division_id, v_is_lead, v_name
from employees where id = v_employee_id;
v_manager := resolve_manager_for(v_team_id, v_is_lead, v_division_id);
update employees set
status = (case when v_rehire_date > current_date then 'Geplant' else 'Aktiv' end)::employment_status,
entry_date = v_rehire_date,
exit_date = null,
exit_reason = null,
manager_id = v_manager
where id = v_employee_id;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_rehire_date, 'Wiedereintritt', 'Wiedereinstellung zum ' || (payload->>'rehire_date'));
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Wiedereinstellung', v_name, v_employee_id, 'Wiedereintritt am ' || (payload->>'rehire_date'));
end;
$$;
-- ── Position ausschreiben (§4.6) ──────────────────────────────
create or replace function create_position(payload jsonb)
returns uuid language plpgsql as $$
declare
v_id uuid;
v_superior_id uuid := (payload->>'superior_employee_id')::uuid;
v_is_lead boolean := coalesce((payload->>'is_lead')::boolean, false);
v_team_id uuid;
begin
perform require_hr_admin();
if v_is_lead then
v_team_id := (payload->>'team_id')::uuid;
else
select team_id into v_team_id from employees where id = v_superior_id;
end if;
insert into positions (title, team_id, is_lead, reports_to_employee_id)
values (payload->>'title', v_team_id, v_is_lead, v_superior_id)
returning id into v_id;
insert into audit_log (actor_user_id, actor_name, action, target_label, details)
values (auth.uid(), current_actor_name(), 'Ausschreibung', payload->>'title', 'Position ausgeschrieben');
return v_id;
end;
$$;
-- ── Intern besetzen (§4.6) ────────────────────────────────────
create or replace function staff_position_internally(payload jsonb)
returns void language plpgsql as $$
declare
v_position record;
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_manager uuid;
v_name text;
begin
perform require_hr_admin();
select * into v_position from positions where id = (payload->>'position_id')::uuid and status = 'open';
if not found then
raise exception 'Position ist nicht mehr offen.';
end if;
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
v_manager := resolve_manager_for(v_position.team_id, v_position.is_lead, v_position.division_id);
update employees set
team_id = v_position.team_id,
job_title = v_position.title,
source = 'Intern',
is_lead = case when v_position.is_lead then true else is_lead end,
org_level = case when v_position.is_lead then 2 else org_level end,
manager_id = v_manager
where id = v_employee_id;
if v_position.is_lead then
update employees set manager_id = v_employee_id
where team_id = v_position.team_id and id <> v_employee_id and is_lead = false and status <> 'Ausgetreten';
end if;
update positions set status = 'filled', filled_at = now(), filled_by_employee_id = v_employee_id
where id = v_position.id;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, current_date, 'Versetzung', 'Interne Besetzung: ' || v_position.title);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Interne Besetzung', v_name, v_employee_id, v_position.title);
end;
$$;
-- ── Reorganisation (§4.7) ─────────────────────────────────────
-- payload: { name, effective_date, moves: [{ kind, label, employee_ids: uuid[], target_team_id }] }
create or replace function apply_reorg(payload jsonb)
returns uuid language plpgsql as $$
declare
v_scenario_id uuid;
v_move jsonb;
v_employee_id text;
v_target_team_id uuid;
v_division_id uuid;
v_is_lead boolean;
v_manager uuid;
v_snapshot jsonb := '{}'::jsonb;
v_old record;
v_total_moves int := 0;
begin
perform require_hr_admin();
insert into reorg_scenarios (name, effective_date, created_by, applied, applied_at)
values (payload->>'name', (payload->>'effective_date')::date, auth.uid(), true, now())
returning id into v_scenario_id;
for v_move in select * from jsonb_array_elements(payload->'moves')
loop
v_target_team_id := (v_move->>'target_team_id')::uuid;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id where t.id = v_target_team_id;
insert into reorg_moves (scenario_id, kind, payload) values (v_scenario_id, v_move->>'kind', v_move);
for v_employee_id in select jsonb_array_elements_text(v_move->'employee_ids')
loop
select * into v_old from employees where id = v_employee_id::uuid;
v_snapshot := v_snapshot || jsonb_build_object(v_employee_id, jsonb_build_object(
'team_id', v_old.team_id, 'division_id', v_old.division_id, 'manager_id', v_old.manager_id
));
v_is_lead := v_old.is_lead;
v_manager := resolve_manager_for(v_target_team_id, v_is_lead, v_division_id);
update employees set team_id = v_target_team_id, manager_id = v_manager where id = v_employee_id::uuid;
insert into employee_history (employee_id, event_date, event_type, description, reorg_scenario_id)
values (v_employee_id::uuid, (payload->>'effective_date')::date, 'Reorganisation',
'Reorganisation "' || (payload->>'name') || '": neues Team zugewiesen', v_scenario_id);
v_total_moves := v_total_moves + 1;
end loop;
end loop;
update reorg_scenarios set undo_snapshot = v_snapshot where id = v_scenario_id;
insert into audit_log (actor_user_id, actor_name, action, target_label, details)
values (auth.uid(), current_actor_name(), 'Reorganisation', payload->>'name', v_total_moves || ' Mitarbeiter:innen betroffen');
return v_scenario_id;
end;
$$;
create or replace function undo_reorg(payload jsonb)
returns void language plpgsql as $$
declare
v_scenario record;
v_key text;
v_val jsonb;
begin
perform require_hr_admin();
select * into v_scenario from reorg_scenarios where id = (payload->>'scenario_id')::uuid and applied = true;
if not found or v_scenario.undo_snapshot is null then
raise exception 'Reorganisation kann nicht rückgängig gemacht werden (kein Snapshot vorhanden).';
end if;
for v_key, v_val in select * from jsonb_each(v_scenario.undo_snapshot)
loop
update employees set
team_id = nullif(v_val->>'team_id','')::uuid,
division_id = (v_val->>'division_id')::uuid,
manager_id = nullif(v_val->>'manager_id','')::uuid
where id = v_key::uuid;
end loop;
delete from employee_history where reorg_scenario_id = v_scenario.id;
update reorg_scenarios set applied = false where id = v_scenario.id;
insert into audit_log (actor_user_id, actor_name, action, target_label, details)
values (auth.uid(), current_actor_name(), 'Reorganisation rückgängig', v_scenario.name, 'Reorganisation zurückgesetzt');
end;
$$;

View File

@@ -0,0 +1,29 @@
-- Addendum to supabase/functions.sql — run after that file.
--
-- The spec's "Karenz verwalten" panel (§4.5) only covers employees already on
-- Karenz (adjust return date / record return). It doesn't specify the fields
-- for *starting* a Karenz period from Aktiv, even though §4.3 clearly shows a
-- "Karenz" button for that case. This fills that gap with a reasonable,
-- minimal form: start date + planned return date + optional note.
create or replace function start_karenz(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_name text;
begin
perform require_hr_admin();
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
update employees set status = 'Karenz', karenz_return_date = (payload->>'planned_return_date')::date
where id = v_employee_id;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, (payload->>'karenz_start_date')::date, 'Karenz',
'Karenzantritt, geplante Rückkehr am ' || (payload->>'planned_return_date') ||
case when payload->>'note' is not null and payload->>'note' <> '' then ' — ' || (payload->>'note') else '' end);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Karenz', v_name, v_employee_id, 'Karenzantritt, geplante Rückkehr ' || (payload->>'planned_return_date'));
end;
$$;

View File

@@ -0,0 +1,15 @@
-- Addendum to supabase/schema.sql + functions.sql — run after those.
--
-- employee_history intentionally has no UPDATE/DELETE policy (§4.9's
-- "unveraenderbar" / append-only requirement). But undo_reorg needs to
-- remove the specific history rows a reorg created — found via live
-- testing: the DELETE inside undo_reorg silently matched 0 rows under RLS
-- (no error, since RLS just filters DELETE-eligible rows to none), leaving
-- Reorganisation entries behind after an otherwise-successful undo.
--
-- Scope the exception as narrowly as possible: hr_admin may delete a
-- history row only if it carries a reorg_scenario_id, i.e. only rows
-- apply_reorg created. Eintritt/Austritt/Beförderung/etc. rows (always
-- reorg_scenario_id IS NULL) remain fully immutable.
create policy "history_delete_admin_reorg_undo" on employee_history for delete
using (is_hr_admin() and reorg_scenario_id is not null);

View File

@@ -0,0 +1,71 @@
-- Addendum to supabase/functions.sql — run after that file (and functions_2/3.sql).
--
-- "Daten ändern" had no "Wirksam ab" field, unlike Versetzung/Beförderung/
-- Karenz — every change was silently logged with today's date regardless
-- of when it should actually take effect. Adds an effective_date input
-- (defaults to today if omitted) used for both the history event_date and
-- noted in the change description.
create or replace function change_employee_data(payload jsonb)
returns void language plpgsql as $$
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 text[] := '{}';
v_contract_changes text[] := '{}';
v_person jsonb := payload->'person';
v_contract jsonb := payload->'contract';
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;
if v_person ? 'first_name' and (v_person->>'first_name') <> v_old.first_name then v_person_changes := array_append(v_person_changes, 'Vorname'); end if;
if v_person ? 'last_name' and (v_person->>'last_name') <> v_old.last_name then v_person_changes := array_append(v_person_changes, 'Nachname'); end if;
if v_person ? 'gender' and (v_person->>'gender') <> v_old.gender::text then v_person_changes := array_append(v_person_changes, 'Geschlecht'); end if;
if v_person ? 'birth_date' and (v_person->>'birth_date')::date <> v_old.birth_date then v_person_changes := array_append(v_person_changes, 'Geburtsdatum'); end if;
if v_person ? 'sv_nummer' and coalesce(v_person->>'sv_nummer','') <> coalesce(v_old.sv_nummer,'') then v_person_changes := array_append(v_person_changes, 'SV-Nummer'); end if;
if v_person ? 'nationality' and (v_person->>'nationality') <> v_old.nationality then v_person_changes := array_append(v_person_changes, 'Staatsbürgerschaft'); end if;
if v_person ? 'address' and coalesce(v_person->>'address','') <> coalesce(v_old.address,'') then v_person_changes := array_append(v_person_changes, 'Adresse'); end if;
if v_person ? 'address_country' and coalesce(v_person->>'address_country','') <> coalesce(v_old.address_country,'') then v_person_changes := array_append(v_person_changes, 'Land'); end if;
if v_person ? 'email' and (v_person->>'email') <> v_old.email then v_person_changes := array_append(v_person_changes, 'E-Mail'); end if;
if v_person ? 'phone' and coalesce(v_person->>'phone','') <> coalesce(v_old.phone,'') then v_person_changes := array_append(v_person_changes, 'Telefon'); end if;
if v_contract ? 'employment_type' and (v_contract->>'employment_type') <> v_old.employment_type::text then v_contract_changes := array_append(v_contract_changes, 'Beschäftigungsausmaß'); end if;
if v_contract ? 'weekly_hours' and (v_contract->>'weekly_hours')::numeric <> v_old.weekly_hours then v_contract_changes := array_append(v_contract_changes, 'Wochenstunden'); end if;
if v_contract ? 'contract_type' and (v_contract->>'contract_type') <> v_old.contract_type::text then v_contract_changes := array_append(v_contract_changes, 'Vertragsart'); end if;
if v_contract ? 'contract_end_date' and coalesce(nullif(v_contract->>'contract_end_date','')::date::text,'') <> coalesce(v_old.contract_end_date::text,'') then v_contract_changes := array_append(v_contract_changes, 'Befristet bis'); end if;
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 = coalesce(v_person->>'sv_nummer', sv_nummer),
nationality = coalesce(v_person->>'nationality', nationality),
address = coalesce(v_person->>'address', address),
address_country = coalesce(v_person->>'address_country', address_country),
email = coalesce(v_person->>'email', email),
phone = coalesce(v_person->>'phone', phone),
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
where id = v_employee_id;
if array_length(v_person_changes, 1) > 0 then
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_effective_date, 'Stammdatenänderung', 'Geänderte Felder: ' || array_to_string(v_person_changes, ', ') || ', wirksam ab ' || v_effective_date);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Stammdatenänderung', v_name, v_employee_id, array_to_string(v_person_changes, ', ') || ', wirksam ab ' || v_effective_date);
end if;
if array_length(v_contract_changes, 1) > 0 then
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_effective_date, 'Vertragsänderung', 'Geänderte Felder: ' || array_to_string(v_contract_changes, ', ') || ', wirksam ab ' || v_effective_date);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Vertragsänderung', v_name, v_employee_id, array_to_string(v_contract_changes, ', ') || ', wirksam ab ' || v_effective_date);
end if;
end;
$$;

View File

@@ -0,0 +1,172 @@
-- HR-only access model (spec §2)
--
-- Rationale: the app previously had two profile roles (hr_admin / manager),
-- with "manager" intended as a read-only, salary-masked role. The revised
-- scope is HR-only: nobody except an active, explicitly-provisioned HR user
-- may open the app at all. This migration:
-- 1. Collapses profiles.role to the single allowed value 'hr'.
-- 2. Adds profiles.is_active (default false — new users get zero access
-- until an existing HR user explicitly activates them; see §2.3).
-- 3. Renames the authorization gate from is_hr_admin() to is_hr_user()
-- (checks role='hr' AND is_active=true) to match §2.4's naming and
-- semantics, and repoints every RLS policy at it.
-- 4. Fixes a real gap: several tables (divisions/departments/teams/
-- locations, employee_history, positions, audit_log, reorg_scenarios/
-- reorg_moves) had read policies scoped to `auth.role() = 'authenticated'`
-- — i.e. ANY signed-in Supabase Auth user, not just HR. Every one of
-- those is tightened to is_hr_user().
-- 5. Drops the employees_directory salary-masking view: with the
-- "manager" role gone and salary out of MVP scope (see the salary
-- deprecation migration), there is nothing left to mask and no second
-- role to mask it from. All reads now go through the base `employees`
-- table, gated by the same is_hr_user()-only policy as writes.
--
-- The application layer (app/(app)/layout.tsx) previously let any
-- authenticated Supabase user reach the shell (it only checked for a
-- session, not profiles.role/is_active) and merely hid the edit UI for
-- non-admins. That check is being replaced in the app code alongside this
-- migration — this migration is what makes that enforceable at the data
-- layer regardless of what the UI does.
-- ── profiles: single role, explicit activation ──────────────────────
alter table profiles drop constraint if exists profiles_role_check;
update profiles set role = 'hr' where role <> 'hr';
alter table profiles add constraint profiles_role_check check (role = 'hr');
alter table profiles alter column role set default 'hr';
alter table profiles add column if not exists is_active boolean not null default false;
alter table profiles add column if not exists created_by uuid references auth.users(id);
alter table profiles add column if not exists updated_at timestamptz not null default now();
-- Any *existing* profile row (i.e. someone already explicitly provisioned
-- before this migration) keeps working — activation is only "off by
-- default" for rows created from here on.
update profiles set is_active = true where is_active = false;
create or replace function fn_touch_profiles_updated_at()
returns trigger language plpgsql as $$
begin
new.updated_at = now();
return new;
end;
$$;
drop trigger if exists trg_profiles_touch_updated_at on profiles;
create trigger trg_profiles_touch_updated_at
before update on profiles
for each row execute function fn_touch_profiles_updated_at();
-- ── Authorization gate: is_hr_user() replaces is_hr_admin() ─────────
create or replace function is_hr_user()
returns boolean
language sql
security definer
set search_path = public
stable
as $$
select exists (
select 1 from profiles p where p.id = auth.uid() and p.role = 'hr' and p.is_active = true
);
$$;
create or replace function current_hr_user_id()
returns uuid
language sql
security definer
set search_path = public
stable
as $$
select p.id from profiles p where p.id = auth.uid() and p.role = 'hr' and p.is_active = true;
$$;
-- Back-compat shim so any not-yet-migrated call site (or a function defined
-- in an older addendum file not touched by this migration) keeps working;
-- new code should call is_hr_user() directly. Safe to drop once nothing
-- references is_hr_admin() anymore (tracked in docs/decisions).
create or replace function is_hr_admin()
returns boolean
language sql
stable
as $$
select is_hr_user();
$$;
create or replace function require_hr_admin()
returns void language plpgsql as $$
begin
if not is_hr_user() then
raise exception 'Nicht berechtigt: nur aktive HR-Benutzer:innen dürfen diese Aktion ausführen.';
end if;
end;
$$;
-- ── Re-scope every "any authenticated user" read policy to HR-only ──
drop policy if exists "org_read" on divisions;
create policy "org_read" on divisions for select using (is_hr_user());
drop policy if exists "org_write" on divisions;
create policy "org_write" on divisions for all using (is_hr_user()) with check (is_hr_user());
drop policy if exists "org_read" on departments;
create policy "org_read" on departments for select using (is_hr_user());
drop policy if exists "org_write" on departments;
create policy "org_write" on departments for all using (is_hr_user()) with check (is_hr_user());
drop policy if exists "org_read" on teams;
create policy "org_read" on teams for select using (is_hr_user());
drop policy if exists "org_write" on teams;
create policy "org_write" on teams for all using (is_hr_user()) with check (is_hr_user());
drop policy if exists "org_read" on locations;
create policy "org_read" on locations for select using (is_hr_user());
drop policy if exists "org_write" on locations;
create policy "org_write" on locations for all using (is_hr_user()) with check (is_hr_user());
drop policy if exists "history_read" on employee_history;
create policy "history_read" on employee_history for select using (is_hr_user());
drop policy if exists "history_insert_admin" on employee_history;
create policy "history_insert_admin" on employee_history for insert with check (is_hr_user());
drop policy if exists "positions_read" on positions;
create policy "positions_read" on positions for select using (is_hr_user());
drop policy if exists "positions_write_admin" on positions;
create policy "positions_write_admin" on positions for all using (is_hr_user()) with check (is_hr_user());
drop policy if exists "audit_read" on audit_log;
create policy "audit_read" on audit_log for select using (is_hr_user());
drop policy if exists "audit_insert_admin" on audit_log;
create policy "audit_insert_admin" on audit_log for insert with check (is_hr_user());
drop policy if exists "reorg_scenarios_read" on reorg_scenarios;
create policy "reorg_scenarios_read" on reorg_scenarios for select using (is_hr_user());
drop policy if exists "reorg_scenarios_write_admin" on reorg_scenarios;
create policy "reorg_scenarios_write_admin" on reorg_scenarios for all using (is_hr_user()) with check (is_hr_user());
drop policy if exists "reorg_moves_read" on reorg_moves;
create policy "reorg_moves_read" on reorg_moves for select using (is_hr_user());
drop policy if exists "reorg_moves_write_admin" on reorg_moves;
create policy "reorg_moves_write_admin" on reorg_moves for all using (is_hr_user()) with check (is_hr_user());
drop policy if exists "employees_admin_all" on employees;
create policy "employees_hr_all" on employees for all using (is_hr_user()) with check (is_hr_user());
-- profiles: users may always read their own row (needed to determine their
-- own HR status before is_hr_user() would otherwise apply); HR manages all.
drop policy if exists "profiles_select_admin" on profiles;
create policy "profiles_select_admin" on profiles for select using (is_hr_user());
drop policy if exists "profiles_write_admin" on profiles;
create policy "profiles_write_admin" on profiles for insert with check (is_hr_user());
drop policy if exists "profiles_update_admin" on profiles;
create policy "profiles_update_admin" on profiles for update using (is_hr_user()) with check (is_hr_user());
-- hire_drafts / saved_reports stay owner-scoped (unchanged) — but an owner
-- who is no longer an active HR user should not retain access either.
drop policy if exists "hire_drafts_owner" on hire_drafts;
create policy "hire_drafts_owner" on hire_drafts for all
using (created_by = auth.uid() and is_hr_user()) with check (created_by = auth.uid() and is_hr_user());
drop policy if exists "saved_reports_owner" on saved_reports;
create policy "saved_reports_owner" on saved_reports for all
using (created_by = auth.uid() and is_hr_user()) with check (created_by = auth.uid() and is_hr_user());
-- ── Drop the salary-masking view: no second role left to mask from ──
drop view if exists employees_directory;

View File

@@ -0,0 +1,38 @@
-- Deferred/effective-dated changes (spec §3.3)
--
-- Finding from the consolidation review: transfer_employee, promote_employee,
-- start_karenz, change_employee_data, and apply_reorg all accepted a
-- "Wirksam ab" / effective date, but only ever used it as metadata for the
-- employee_history/audit_log rows — the actual `update employees` always
-- ran immediately regardless of that date. A transfer or promotion dated
-- months in the future silently overwrote the *current* live record today.
-- This table backs the fix: when an effective date is in the future, the
-- mutating RPC stores the intended change here instead of writing it to
-- `employees` right away; a scheduled job (apply_due_pending_changes(),
-- see the following migration, invoked by a Vercel Cron route handler)
-- applies it once its date arrives. The employee_history/audit_log rows are
-- written immediately either way (dated with the effective date), which is
-- what already drives the "zukünftig" badge in the Historie tab.
create table pending_org_changes (
id uuid primary key default gen_random_uuid(),
employee_id uuid not null references employees(id) on delete cascade,
change_type text not null check (change_type in (
'transfer', 'promotion', 'karenz_start', 'karenz_return', 'contract_change', 'reorg'
)),
effective_date date not null,
payload jsonb not null,
reorg_scenario_id uuid references reorg_scenarios(id) on delete cascade,
status text not null default 'pending' check (status in ('pending', 'applied', 'cancelled')),
created_by uuid references auth.users(id),
created_at timestamptz not null default now(),
applied_at timestamptz
);
create index on pending_org_changes (employee_id);
create index on pending_org_changes (status, effective_date);
create index on pending_org_changes (reorg_scenario_id);
alter table pending_org_changes enable row level security;
create policy "pending_org_changes_hr_all" on pending_org_changes for all
using (is_hr_user()) with check (is_hr_user());

View File

@@ -0,0 +1,125 @@
-- Salary out of MVP scope (spec §4)
--
-- Decision: do NOT drop employees.monthly_salary_gross. This is a live
-- Supabase project that may already hold seeded/real rows with values in
-- this column; a destructive drop is unrecoverable and unnecessary to
-- achieve the actual goal (removing salary from the product surface).
-- Instead: relax the column so the app can stop supplying it, mark it
-- deprecated, and stop every RPC from reading/writing it. A future
-- migration MAY drop the column outright once it's confirmed nothing in
-- any environment still depends on it (tracked in docs/decisions).
alter table employees alter column monthly_salary_gross drop not null;
alter table employees drop constraint if exists employees_monthly_salary_gross_check;
comment on column employees.monthly_salary_gross is
'DEPRECATED (2026 consolidation): salary is out of MVP scope. Column kept '
'only because it may hold pre-existing data; the application no longer '
'reads or writes it (see hire_employee/promote_employee). Candidate for '
'a future DROP COLUMN once confirmed unused across all environments.';
-- hire_employee: stop requiring/writing salary.
create or replace function hire_employee(payload jsonb)
returns uuid language plpgsql as $$
declare
v_id uuid;
v_team_id uuid;
v_division_id uuid;
v_position record;
v_job_title text;
v_email text;
v_manager uuid;
begin
perform require_hr_admin();
if payload->>'position_id' is not null then
select * into v_position from positions where id = (payload->>'position_id')::uuid and status = 'open';
if not found then
raise exception 'Position ist nicht mehr offen.';
end if;
v_team_id := v_position.team_id;
v_division_id := v_position.division_id;
v_job_title := coalesce(payload->>'job_title', v_position.title);
else
v_team_id := (payload->>'team_id')::uuid;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id where t.id = v_team_id;
v_job_title := payload->>'job_title';
end if;
v_manager := resolve_manager_for(v_team_id, false, v_division_id);
v_email := generate_company_email(payload->>'first_name', payload->>'last_name');
insert into employees (
first_name, last_name, gender, birth_date, sv_nummer, nationality, email, phone,
team_id, division_id, job_title, location_id, manager_id, org_level, is_lead,
employment_type, weekly_hours, contract_type, contract_end_date,
paygrade, source, status, entry_date
) values (
payload->>'first_name', payload->>'last_name', (payload->>'gender')::gender_type,
(payload->>'birth_date')::date, payload->>'sv_nummer', coalesce(payload->>'nationality', 'Österreich'),
v_email, payload->>'phone',
v_team_id, v_division_id, v_job_title, (payload->>'location_id')::uuid,
v_manager, 3, false,
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 (payload->>'entry_date')::date > current_date then 'Geplant' else 'Aktiv' end)::employment_status,
(payload->>'entry_date')::date
) returning id into v_id;
if payload->>'position_id' is not null then
update positions set status = 'filled', filled_at = now(), filled_by_employee_id = v_id
where id = (payload->>'position_id')::uuid;
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_id, (payload->>'entry_date')::date, 'Eintritt', 'Eintritt als ' || v_job_title);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Neueinstellung', (payload->>'first_name') || ' ' || (payload->>'last_name'), v_id, 'Eintritt am ' || (payload->>'entry_date'));
return v_id;
end;
$$;
-- promote_employee: no longer accepts/writes new_salary; paygrade remains
-- (it's an organizational/functional classification, not a derived salary
-- figure — see §4 point 7).
create or replace function promote_employee(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date);
v_old_paygrade paygrade_type;
v_name text;
v_details 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;
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 Paygrade: ' || (payload->>'new_paygrade');
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;
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'));
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 (auth.uid(), current_actor_name(), 'Beförderung', v_name, v_employee_id, v_details);
end;
$$;

View File

@@ -0,0 +1,387 @@
-- Fix effective-dating for Versetzung, Karenz (start + return), Daten
-- ändern, and Reorganisation (spec §3.3, §7.14).
--
-- Pattern used throughout: if the effective/return date is today or in the
-- past, behave exactly as before (immediate write). If it's in the future,
-- skip the `update employees` and instead record the intended change in
-- pending_org_changes; employee_history/audit_log are written immediately
-- either way, dated with the effective date (this is what already drives
-- the "zukünftig" badge in the Historie tab — unchanged). A separate
-- apply_due_pending_changes() function (called by a scheduled job) applies
-- due rows once their date arrives, re-resolving manager_id fresh at apply
-- time rather than trusting a value computed when the change was requested.
-- ── Versetzung ───────────────────────────────────────────────────
create or replace function transfer_employee(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_new_team_id uuid := (payload->>'new_team_id')::uuid;
v_effective_date date := (payload->>'effective_date')::date;
v_division_id uuid;
v_manager uuid;
v_name text;
v_is_lead boolean;
begin
perform require_hr_admin();
select division_id into v_division_id from teams t join departments d on d.id = t.department_id where t.id = v_new_team_id;
select is_lead, first_name || ' ' || last_name into v_is_lead, v_name from employees where id = v_employee_id;
if v_effective_date <= current_date then
v_manager := resolve_manager_for(v_new_team_id, v_is_lead, v_division_id);
update employees set
team_id = v_new_team_id,
job_title = coalesce(nullif(payload->>'new_title', ''), job_title),
manager_id = v_manager
where id = v_employee_id;
else
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'transfer', v_effective_date,
jsonb_build_object('new_team_id', v_new_team_id, 'new_title', payload->>'new_title'));
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_effective_date, 'Versetzung',
'Versetzung, wirksam ab ' || (payload->>'effective_date') ||
case when payload->>'new_title' is not null and payload->>'new_title' <> '' then ', neue Position: ' || (payload->>'new_title') else '' end);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Versetzung', v_name, v_employee_id, 'Wirksam ab ' || (payload->>'effective_date'));
end;
$$;
-- ── Karenz antreten ──────────────────────────────────────────────
create or replace function start_karenz(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_start_date date := (payload->>'karenz_start_date')::date;
v_name text;
begin
perform require_hr_admin();
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
if v_start_date <= current_date then
update employees set status = 'Karenz', karenz_return_date = (payload->>'planned_return_date')::date
where id = v_employee_id;
else
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'karenz_start', v_start_date,
jsonb_build_object('planned_return_date', payload->>'planned_return_date'));
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_start_date, 'Karenz',
'Karenzantritt, geplante Rückkehr am ' || (payload->>'planned_return_date') ||
case when payload->>'note' is not null and payload->>'note' <> '' then ' — ' || (payload->>'note') else '' end);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Karenz', v_name, v_employee_id, 'Karenzantritt, geplante Rückkehr ' || (payload->>'planned_return_date'));
end;
$$;
-- ── Rückkehr aus Karenz ──────────────────────────────────────────
-- status/manager_id/karenz_return_date already correctly waited for the
-- return date; employment_type/weekly_hours did not (fixed here).
create or replace function record_karenz_return(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_return_date date := (payload->>'return_date')::date;
v_name text;
v_team_id uuid;
v_division_id uuid;
v_is_lead boolean;
v_manager uuid;
v_employment_type employment_type;
v_weekly_hours numeric;
begin
perform require_hr_admin();
select first_name || ' ' || last_name, team_id, division_id, is_lead
into v_name, v_team_id, v_division_id, v_is_lead
from employees where id = v_employee_id;
if payload->>'employment_mode' = 'Vollzeit' then
v_employment_type := 'Vollzeit'; v_weekly_hours := 38.5;
elsif payload->>'employment_mode' = 'Teilzeit' then
v_employment_type := 'Teilzeit'; v_weekly_hours := (payload->>'weekly_hours')::numeric;
end if;
if v_return_date <= current_date then
v_manager := resolve_manager_for(v_team_id, v_is_lead, v_division_id);
update employees set
status = 'Aktiv',
karenz_return_date = null,
manager_id = v_manager,
employment_type = coalesce(v_employment_type, employment_type),
weekly_hours = coalesce(v_weekly_hours, weekly_hours)
where id = v_employee_id;
else
-- Only record the planned date now; the employment-mode change itself
-- (and the status/manager flip) waits for record_karenz_return's
-- effective date, applied later by apply_due_pending_changes().
update employees set karenz_return_date = v_return_date where id = v_employee_id;
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'karenz_return', v_return_date,
jsonb_build_object('employment_type', v_employment_type, 'weekly_hours', v_weekly_hours));
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_return_date, 'Rückkehr', 'Wiedereintritt aus Karenz am ' || (payload->>'return_date'));
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Rückkehr', v_name, v_employee_id, 'Rückkehr am ' || (payload->>'return_date'));
end;
$$;
-- ── Daten ändern ─────────────────────────────────────────────────
create or replace function change_employee_data(payload jsonb)
returns void language plpgsql as $$
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 text[] := '{}';
v_contract_changes text[] := '{}';
v_person jsonb := payload->'person';
v_contract jsonb := payload->'contract';
v_immediate boolean;
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;
if v_person ? 'first_name' and (v_person->>'first_name') <> v_old.first_name then v_person_changes := array_append(v_person_changes, 'Vorname'); end if;
if v_person ? 'last_name' and (v_person->>'last_name') <> v_old.last_name then v_person_changes := array_append(v_person_changes, 'Nachname'); end if;
if v_person ? 'gender' and (v_person->>'gender') <> v_old.gender::text then v_person_changes := array_append(v_person_changes, 'Geschlecht'); end if;
if v_person ? 'birth_date' and (v_person->>'birth_date')::date <> v_old.birth_date then v_person_changes := array_append(v_person_changes, 'Geburtsdatum'); end if;
if v_person ? 'sv_nummer' and coalesce(v_person->>'sv_nummer','') <> coalesce(v_old.sv_nummer,'') then v_person_changes := array_append(v_person_changes, 'SV-Nummer'); end if;
if v_person ? 'nationality' and (v_person->>'nationality') <> v_old.nationality then v_person_changes := array_append(v_person_changes, 'Staatsbürgerschaft'); end if;
if v_person ? 'address' and coalesce(v_person->>'address','') <> coalesce(v_old.address,'') then v_person_changes := array_append(v_person_changes, 'Adresse'); end if;
if v_person ? 'address_country' and coalesce(v_person->>'address_country','') <> coalesce(v_old.address_country,'') then v_person_changes := array_append(v_person_changes, 'Land'); end if;
if v_person ? 'email' and (v_person->>'email') <> v_old.email then v_person_changes := array_append(v_person_changes, 'E-Mail'); end if;
if v_person ? 'phone' and coalesce(v_person->>'phone','') <> coalesce(v_old.phone,'') then v_person_changes := array_append(v_person_changes, 'Telefon'); end if;
if v_contract ? 'employment_type' and (v_contract->>'employment_type') <> v_old.employment_type::text then v_contract_changes := array_append(v_contract_changes, 'Beschäftigungsausmaß'); end if;
if v_contract ? 'weekly_hours' and (v_contract->>'weekly_hours')::numeric <> v_old.weekly_hours then v_contract_changes := array_append(v_contract_changes, 'Wochenstunden'); end if;
if v_contract ? 'contract_type' and (v_contract->>'contract_type') <> v_old.contract_type::text then v_contract_changes := array_append(v_contract_changes, 'Vertragsart'); end if;
if v_contract ? 'contract_end_date' and coalesce(nullif(v_contract->>'contract_end_date','')::date::text,'') <> coalesce(v_old.contract_end_date::text,'') then v_contract_changes := array_append(v_contract_changes, 'Befristet bis'); 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 = coalesce(v_person->>'sv_nummer', sv_nummer),
nationality = coalesce(v_person->>'nationality', nationality),
address = coalesce(v_person->>'address', address),
address_country = coalesce(v_person->>'address_country', address_country),
email = coalesce(v_person->>'email', email),
phone = coalesce(v_person->>'phone', phone),
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
where id = v_employee_id;
elsif array_length(v_person_changes, 1) > 0 or array_length(v_contract_changes, 1) > 0 then
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'contract_change', v_effective_date, payload);
end if;
if array_length(v_person_changes, 1) > 0 then
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_effective_date, 'Stammdatenänderung', 'Geänderte Felder: ' || array_to_string(v_person_changes, ', ') || ', wirksam ab ' || v_effective_date);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Stammdatenänderung', v_name, v_employee_id, array_to_string(v_person_changes, ', ') || ', wirksam ab ' || v_effective_date);
end if;
if array_length(v_contract_changes, 1) > 0 then
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_effective_date, 'Vertragsänderung', 'Geänderte Felder: ' || array_to_string(v_contract_changes, ', ') || ', wirksam ab ' || v_effective_date);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Vertragsänderung', v_name, v_employee_id, array_to_string(v_contract_changes, ', ') || ', wirksam ab ' || v_effective_date);
end if;
end;
$$;
-- ── Reorganisation ───────────────────────────────────────────────
-- Immediate moves (effective_date <= today) behave exactly as before.
-- Future-dated scenarios write no employee mutations at all yet; one
-- pending_org_changes row per affected employee is queued instead, and
-- reorg_scenarios.applied stays false until every one of them is applied.
create or replace function apply_reorg(payload jsonb)
returns uuid language plpgsql as $$
declare
v_scenario_id uuid;
v_effective_date date := (payload->>'effective_date')::date;
v_immediate boolean := (payload->>'effective_date')::date <= current_date;
v_move jsonb;
v_employee_id text;
v_target_team_id uuid;
v_division_id uuid;
v_is_lead boolean;
v_manager uuid;
v_snapshot jsonb := '{}'::jsonb;
v_old record;
v_total_moves int := 0;
begin
perform require_hr_admin();
insert into reorg_scenarios (name, effective_date, created_by, applied, applied_at)
values (payload->>'name', v_effective_date, auth.uid(), v_immediate, case when v_immediate then now() else null end)
returning id into v_scenario_id;
for v_move in select * from jsonb_array_elements(payload->'moves')
loop
v_target_team_id := (v_move->>'target_team_id')::uuid;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id where t.id = v_target_team_id;
insert into reorg_moves (scenario_id, kind, payload) values (v_scenario_id, v_move->>'kind', v_move);
for v_employee_id in select jsonb_array_elements_text(v_move->'employee_ids')
loop
select * into v_old from employees where id = v_employee_id::uuid;
v_snapshot := v_snapshot || jsonb_build_object(v_employee_id, jsonb_build_object(
'team_id', v_old.team_id, 'division_id', v_old.division_id, 'manager_id', v_old.manager_id
));
if v_immediate then
v_is_lead := v_old.is_lead;
v_manager := resolve_manager_for(v_target_team_id, v_is_lead, v_division_id);
update employees set team_id = v_target_team_id, manager_id = v_manager where id = v_employee_id::uuid;
else
insert into pending_org_changes (employee_id, change_type, effective_date, payload, reorg_scenario_id)
values (v_employee_id::uuid, 'reorg', v_effective_date,
jsonb_build_object('target_team_id', v_target_team_id), v_scenario_id);
end if;
insert into employee_history (employee_id, event_date, event_type, description, reorg_scenario_id)
values (v_employee_id::uuid, v_effective_date, 'Reorganisation',
'Reorganisation "' || (payload->>'name') || '": neues Team zugewiesen', v_scenario_id);
v_total_moves := v_total_moves + 1;
end loop;
end loop;
update reorg_scenarios set undo_snapshot = v_snapshot where id = v_scenario_id;
insert into audit_log (actor_user_id, actor_name, action, target_label, details)
values (auth.uid(), current_actor_name(), 'Reorganisation', payload->>'name', v_total_moves || ' Mitarbeiter:innen betroffen');
return v_scenario_id;
end;
$$;
-- ── Applies every due pending change ─────────────────────────────
-- Invoked by the /api/cron/apply-pending-changes route handler (Vercel
-- Cron, daily) using the service-role client — this is a system process,
-- not a user action, so it does not go through require_hr_admin(); it is
-- SECURITY DEFINER precisely so it can run outside any HR user's session.
create or replace function apply_due_pending_changes()
returns int
language plpgsql
security definer
set search_path = public
as $$
declare
v_rec record;
v_team_id uuid;
v_division_id uuid;
v_is_lead boolean;
v_manager uuid;
v_remaining int;
v_applied_count int := 0;
begin
for v_rec in
select * from pending_org_changes
where status = 'pending' and effective_date <= current_date
order by created_at
loop
if v_rec.change_type = 'transfer' then
select is_lead into v_is_lead from employees where id = v_rec.employee_id;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id
where t.id = (v_rec.payload->>'new_team_id')::uuid;
v_manager := resolve_manager_for((v_rec.payload->>'new_team_id')::uuid, v_is_lead, v_division_id);
update employees set
team_id = (v_rec.payload->>'new_team_id')::uuid,
job_title = coalesce(nullif(v_rec.payload->>'new_title', ''), job_title),
manager_id = v_manager
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;
elsif v_rec.change_type = 'karenz_start' then
update employees set status = 'Karenz', karenz_return_date = (v_rec.payload->>'planned_return_date')::date
where id = v_rec.employee_id;
elsif v_rec.change_type = 'karenz_return' then
select team_id, division_id, is_lead into v_team_id, v_division_id, v_is_lead
from employees where id = v_rec.employee_id;
v_manager := resolve_manager_for(v_team_id, v_is_lead, v_division_id);
update employees set
status = 'Aktiv',
karenz_return_date = null,
manager_id = v_manager,
employment_type = coalesce((v_rec.payload->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_rec.payload->>'weekly_hours')::numeric, weekly_hours)
where id = v_rec.employee_id;
elsif v_rec.change_type = 'contract_change' then
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 = coalesce(v_rec.payload->'person'->>'sv_nummer', sv_nummer),
nationality = coalesce(v_rec.payload->'person'->>'nationality', nationality),
address = coalesce(v_rec.payload->'person'->>'address', address),
address_country = coalesce(v_rec.payload->'person'->>'address_country', address_country),
email = coalesce(v_rec.payload->'person'->>'email', email),
phone = coalesce(v_rec.payload->'person'->>'phone', phone),
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
where id = v_rec.employee_id;
elsif v_rec.change_type = 'reorg' then
select is_lead into v_is_lead from employees where id = v_rec.employee_id;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id
where t.id = (v_rec.payload->>'target_team_id')::uuid;
v_manager := resolve_manager_for((v_rec.payload->>'target_team_id')::uuid, v_is_lead, v_division_id);
update employees set team_id = (v_rec.payload->>'target_team_id')::uuid, manager_id = v_manager
where id = v_rec.employee_id;
end if;
update pending_org_changes set status = 'applied', applied_at = now() where id = v_rec.id;
v_applied_count := v_applied_count + 1;
if v_rec.reorg_scenario_id is not null then
select count(*) into v_remaining from pending_org_changes
where reorg_scenario_id = v_rec.reorg_scenario_id and status = 'pending';
if v_remaining = 0 then
update reorg_scenarios set applied = true, applied_at = now() where id = v_rec.reorg_scenario_id;
end if;
end if;
end loop;
return v_applied_count;
end;
$$;
-- This bypasses RLS (SECURITY DEFINER) by design so the daily cron job can
-- run it without any HR user session — which means it must NOT be callable
-- by ordinary app roles (an authenticated HR user calling it early would
-- force-apply not-yet-due changes ahead of their effective date).
revoke execute on function apply_due_pending_changes() from public;
revoke execute on function apply_due_pending_changes() from anon;
revoke execute on function apply_due_pending_changes() from authenticated;
grant execute on function apply_due_pending_changes() to service_role;

View File

@@ -0,0 +1,59 @@
-- Make reorg undo respect employee_history's append-only contract
-- (spec §6.1: "append-only, keine normale
-- Update-/Delete-Funktion").
--
-- The previous undo_reorg deleted the employee_history rows a reorg had
-- created (functions.sql:549), which required a narrow RLS carve-out
-- (functions_3.sql's "history_delete_admin_reorg_undo" policy) allowing
-- hr_admin to delete reorg-tagged history rows. That is the one place in
-- the whole schema where history was not actually immutable. Fixed by
-- appending a compensating "Reorganisation rückgängig" history entry per
-- affected employee instead of deleting anything — the original
-- Reorganisation rows stay in the record, exactly like every other history
-- event type. The now-unused delete policy is dropped.
drop policy if exists "history_delete_admin_reorg_undo" on employee_history;
create or replace function undo_reorg(payload jsonb)
returns void language plpgsql as $$
declare
v_scenario record;
v_key text;
v_val jsonb;
v_name text;
v_count int := 0;
begin
perform require_hr_admin();
select * into v_scenario from reorg_scenarios where id = (payload->>'scenario_id')::uuid and applied = true;
if not found or v_scenario.undo_snapshot is null then
raise exception 'Reorganisation kann nicht rückgängig gemacht werden (kein Snapshot vorhanden).';
end if;
-- Any pending (not-yet-applied) deferred moves belonging to this scenario
-- are cancelled rather than left to fire later against a since-reverted
-- state.
update pending_org_changes set status = 'cancelled'
where reorg_scenario_id = v_scenario.id and status = 'pending';
for v_key, v_val in select * from jsonb_each(v_scenario.undo_snapshot)
loop
update employees set
team_id = nullif(v_val->>'team_id','')::uuid,
division_id = (v_val->>'division_id')::uuid,
manager_id = nullif(v_val->>'manager_id','')::uuid
where id = v_key::uuid;
select first_name || ' ' || last_name into v_name from employees where id = v_key::uuid;
insert into employee_history (employee_id, event_date, event_type, description, reorg_scenario_id)
values (v_key::uuid, current_date, 'Reorganisation',
'Reorganisation "' || v_scenario.name || '" rückgängig gemacht — vorheriges Team wiederhergestellt', v_scenario.id);
v_count := v_count + 1;
end loop;
update reorg_scenarios set applied = false where id = v_scenario.id;
insert into audit_log (actor_user_id, actor_name, action, target_label, details)
values (auth.uid(), current_actor_name(), 'Reorganisation rückgängig', v_scenario.name, v_count || ' Mitarbeiter:innen zurückgesetzt');
end;
$$;

View File

@@ -0,0 +1,33 @@
-- Performance indexes (spec §15).
--
-- Existing indexes already cover employees(team_id/division_id/manager_id/
-- status), employee_history(employee_id, event_date desc), positions(team_id/
-- status), audit_log(occurred_at desc), and the new pending_org_changes
-- table's own indexes (see its migration). This adds the gaps: location-based
-- filtering, entry/exit date range queries (dashboard "upcoming" widget +
-- Eintritte/Austritte reports), personnel-number lookup (also enforces the
-- uniqueness a personnel number should have, which the identity column
-- alone did not), reorg/audit foreign-key lookups, and trigram search
-- support for the employee list's free-text search box.
create unique index if not exists idx_employees_personnel_number on employees (personnel_number);
create index if not exists idx_employees_location_id on employees (location_id);
create index if not exists idx_employees_entry_date on employees (entry_date);
create index if not exists idx_employees_exit_date on employees (exit_date) where exit_date is not null;
create index if not exists idx_employees_karenz_return_date on employees (karenz_return_date) where karenz_return_date is not null;
create index if not exists idx_audit_log_target_employee_id on audit_log (target_employee_id) where target_employee_id is not null;
create index if not exists idx_employee_history_reorg_scenario_id on employee_history (reorg_scenario_id) where reorg_scenario_id is not null;
create index if not exists idx_employee_history_event_type on employee_history (event_type);
create index if not exists idx_reorg_scenarios_applied on reorg_scenarios (applied, applied_at desc);
create index if not exists idx_positions_division_id on positions (division_id);
-- Free-text search over name/title (Mitarbeiter:innen list search box) —
-- trigram index so ILIKE '%term%' queries can use an index instead of a
-- sequential scan.
create extension if not exists pg_trgm;
create index if not exists idx_employees_name_trgm
on employees using gin ((first_name || ' ' || last_name) gin_trgm_ops);
create index if not exists idx_employees_job_title_trgm
on employees using gin (job_title gin_trgm_ops);

View File

@@ -0,0 +1,25 @@
-- Explicit schema/table/sequence/routine grants for anon/authenticated/
-- service_role.
--
-- Found while standing up a local Supabase instance to run the integration
-- test suite (spec §17): every table in this schema
-- relies on RLS policies to restrict access, but RLS only ever *narrows*
-- what an already-GRANTed role may do — Postgres still checks ordinary
-- object privileges first, independent of a role's BYPASSRLS flag. On
-- Supabase's hosted platform this GRANT setup is applied automatically
-- during project provisioning, so it was invisible here: every previous
-- migration worked fine against the already-provisioned hosted project, but
-- the exact same migrations against a *fresh* Postgres (a new local dev
-- instance, a disaster-recovery restore, or CI) fail with "permission
-- denied for table X" even for the service role, since that grant was never
-- actually part of this repo's own migrations. Making it explicit here
-- makes the schema fully self-contained and reprovisionable from scratch.
grant usage on schema public to anon, authenticated, service_role;
grant all on all tables in schema public to anon, authenticated, service_role;
grant all on all sequences in schema public to anon, authenticated, service_role;
grant all on all routines in schema public to anon, authenticated, service_role;
alter default privileges in schema public grant all on tables to anon, authenticated, service_role;
alter default privileges in schema public grant all on sequences to anon, authenticated, service_role;
alter default privileges in schema public grant all on routines to anon, authenticated, service_role;

View File

@@ -0,0 +1,249 @@
-- Two data-integrity rules named explicitly in the spec (§3.7, §11 test
-- scenarios #14 and #17) that had no enforcement
-- anywhere — not in the UI, not in a Server Action, not as a DB constraint:
--
-- 1. "Rückkehr darf nicht vor Beginn der Karenz liegen" — there was no
-- column tracking when a Karenz period actually started (only a
-- free-text history row), so this could not be validated at all.
-- 2. "Historieneinträge dürfen nicht vor dem Eintritt liegen" — nothing
-- stopped an employee_history row from being inserted with an
-- event_date earlier than that employee's entry_date.
-- ── 1. Track karenz_start_date; validate return against it ──────────
alter table employees add column if not exists karenz_start_date date;
create or replace function start_karenz(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_start_date date := (payload->>'karenz_start_date')::date;
v_name text;
begin
perform require_hr_admin();
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
if v_start_date <= current_date then
update employees set status = 'Karenz', karenz_start_date = v_start_date,
karenz_return_date = (payload->>'planned_return_date')::date
where id = v_employee_id;
else
update employees set karenz_start_date = v_start_date where id = v_employee_id;
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'karenz_start', v_start_date,
jsonb_build_object('planned_return_date', payload->>'planned_return_date'));
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_start_date, 'Karenz',
'Karenzantritt, geplante Rückkehr am ' || (payload->>'planned_return_date') ||
case when payload->>'note' is not null and payload->>'note' <> '' then ' — ' || (payload->>'note') else '' end);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Karenz', v_name, v_employee_id, 'Karenzantritt, geplante Rückkehr ' || (payload->>'planned_return_date'));
end;
$$;
create or replace function adjust_karenz_return(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_new_return_date date := (payload->>'new_return_date')::date;
v_karenz_start date;
v_name text;
begin
perform require_hr_admin();
select first_name || ' ' || last_name, karenz_start_date into v_name, v_karenz_start from employees where id = v_employee_id;
if v_karenz_start is not null and v_new_return_date <= v_karenz_start then
raise exception 'Das Rückkehrdatum muss nach dem Karenzbeginn (%) liegen.', v_karenz_start;
end if;
update employees set karenz_return_date = v_new_return_date where id = v_employee_id;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, current_date, 'Karenz',
'Rückkehrdatum angepasst auf ' || (payload->>'new_return_date') ||
case when payload->>'note' is not null and payload->>'note' <> '' then ' — ' || (payload->>'note') else '' end);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Karenz', v_name, v_employee_id, 'Neues Rückkehrdatum: ' || (payload->>'new_return_date'));
end;
$$;
create or replace function record_karenz_return(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_return_date date := (payload->>'return_date')::date;
v_name text;
v_team_id uuid;
v_division_id uuid;
v_is_lead boolean;
v_manager uuid;
v_employment_type employment_type;
v_weekly_hours numeric;
v_karenz_start date;
begin
perform require_hr_admin();
select first_name || ' ' || last_name, team_id, division_id, is_lead, karenz_start_date
into v_name, v_team_id, v_division_id, v_is_lead, v_karenz_start
from employees where id = v_employee_id;
if v_karenz_start is not null and v_return_date <= v_karenz_start then
raise exception 'Das Rückkehrdatum muss nach dem Karenzbeginn (%) liegen.', v_karenz_start;
end if;
if payload->>'employment_mode' = 'Vollzeit' then
v_employment_type := 'Vollzeit'; v_weekly_hours := 38.5;
elsif payload->>'employment_mode' = 'Teilzeit' then
v_employment_type := 'Teilzeit'; v_weekly_hours := (payload->>'weekly_hours')::numeric;
end if;
if v_return_date <= current_date then
v_manager := resolve_manager_for(v_team_id, v_is_lead, v_division_id);
update employees set
status = 'Aktiv',
karenz_return_date = null,
karenz_start_date = null,
manager_id = v_manager,
employment_type = coalesce(v_employment_type, employment_type),
weekly_hours = coalesce(v_weekly_hours, weekly_hours)
where id = v_employee_id;
else
update employees set karenz_return_date = v_return_date where id = v_employee_id;
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'karenz_return', v_return_date,
jsonb_build_object('employment_type', v_employment_type, 'weekly_hours', v_weekly_hours));
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_return_date, 'Rückkehr', 'Wiedereintritt aus Karenz am ' || (payload->>'return_date'));
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Rückkehr', v_name, v_employee_id, 'Rückkehr am ' || (payload->>'return_date'));
end;
$$;
-- apply_due_pending_changes: clear karenz_start_date once the deferred
-- return actually applies (mirrors the immediate branch above).
create or replace function apply_due_pending_changes()
returns int
language plpgsql
security definer
set search_path = public
as $$
declare
v_rec record;
v_team_id uuid;
v_division_id uuid;
v_is_lead boolean;
v_manager uuid;
v_remaining int;
v_applied_count int := 0;
begin
for v_rec in
select * from pending_org_changes
where status = 'pending' and effective_date <= current_date
order by created_at
loop
if v_rec.change_type = 'transfer' then
select is_lead into v_is_lead from employees where id = v_rec.employee_id;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id
where t.id = (v_rec.payload->>'new_team_id')::uuid;
v_manager := resolve_manager_for((v_rec.payload->>'new_team_id')::uuid, v_is_lead, v_division_id);
update employees set
team_id = (v_rec.payload->>'new_team_id')::uuid,
job_title = coalesce(nullif(v_rec.payload->>'new_title', ''), job_title),
manager_id = v_manager
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;
elsif v_rec.change_type = 'karenz_start' then
update employees set status = 'Karenz', karenz_return_date = (v_rec.payload->>'planned_return_date')::date
where id = v_rec.employee_id;
elsif v_rec.change_type = 'karenz_return' then
select team_id, division_id, is_lead into v_team_id, v_division_id, v_is_lead
from employees where id = v_rec.employee_id;
v_manager := resolve_manager_for(v_team_id, v_is_lead, v_division_id);
update employees set
status = 'Aktiv',
karenz_return_date = null,
karenz_start_date = null,
manager_id = v_manager,
employment_type = coalesce((v_rec.payload->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_rec.payload->>'weekly_hours')::numeric, weekly_hours)
where id = v_rec.employee_id;
elsif v_rec.change_type = 'contract_change' then
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 = coalesce(v_rec.payload->'person'->>'sv_nummer', sv_nummer),
nationality = coalesce(v_rec.payload->'person'->>'nationality', nationality),
address = coalesce(v_rec.payload->'person'->>'address', address),
address_country = coalesce(v_rec.payload->'person'->>'address_country', address_country),
email = coalesce(v_rec.payload->'person'->>'email', email),
phone = coalesce(v_rec.payload->'person'->>'phone', phone),
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
where id = v_rec.employee_id;
elsif v_rec.change_type = 'reorg' then
select is_lead into v_is_lead from employees where id = v_rec.employee_id;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id
where t.id = (v_rec.payload->>'target_team_id')::uuid;
v_manager := resolve_manager_for((v_rec.payload->>'target_team_id')::uuid, v_is_lead, v_division_id);
update employees set team_id = (v_rec.payload->>'target_team_id')::uuid, manager_id = v_manager
where id = v_rec.employee_id;
end if;
update pending_org_changes set status = 'applied', applied_at = now() where id = v_rec.id;
v_applied_count := v_applied_count + 1;
if v_rec.reorg_scenario_id is not null then
select count(*) into v_remaining from pending_org_changes
where reorg_scenario_id = v_rec.reorg_scenario_id and status = 'pending';
if v_remaining = 0 then
update reorg_scenarios set applied = true, applied_at = now() where id = v_rec.reorg_scenario_id;
end if;
end if;
end loop;
return v_applied_count;
end;
$$;
revoke execute on function apply_due_pending_changes() from public, anon, authenticated;
grant execute on function apply_due_pending_changes() to service_role;
-- ── 2. History entries may never predate the employee's entry date ───
create or replace function fn_check_history_not_before_entry()
returns trigger
language plpgsql
as $$
declare
v_entry_date date;
begin
select entry_date into v_entry_date from employees where id = new.employee_id;
if v_entry_date is not null and new.event_date < v_entry_date then
raise exception 'Historieneintrag (%) darf nicht vor dem Eintrittsdatum (%) liegen.', new.event_date, v_entry_date;
end if;
return new;
end;
$$;
drop trigger if exists trg_history_not_before_entry on employee_history;
create trigger trg_history_not_before_entry
before insert on employee_history
for each row execute function fn_check_history_not_before_entry();

View File

@@ -0,0 +1,202 @@
-- Split the combined "Straße Nr, PLZ Ort" address string into three fields.
--
-- employees.address now holds only Straße + Hausnummer (still a single
-- free-text line); postal_code and city are their own columns so the UI
-- can offer them as separate inputs and reports/exports can filter or
-- group by them independently.
alter table employees add column if not exists postal_code text;
alter table employees add column if not exists city text;
-- Best-effort backfill for existing rows seeded in the old combined format
-- (supabase/seed.ts previously wrote "Straße Nr, PLZ Ort"). Only rewrites
-- rows that actually match that exact "<street>, <postal> <city>" shape;
-- anything else (already-split rows, hand-edited free text, no comma) is
-- left untouched for HR to fill in via "Daten ändern".
update employees
set
postal_code = trim(split_part(split_part(address, ', ', 2), ' ', 1)),
city = trim(substring(split_part(address, ', ', 2) from '\S+\s+(.*)$')),
address = trim(split_part(address, ', ', 1))
where address ~ '^[^,]+,\s*\d{3,6}\s*[[:alpha:]].*$';
-- ── change_employee_data: recognize postal_code/city as person fields ──
create or replace function change_employee_data(payload jsonb)
returns void language plpgsql as $$
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 text[] := '{}';
v_contract_changes text[] := '{}';
v_person jsonb := payload->'person';
v_contract jsonb := payload->'contract';
v_immediate boolean;
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;
if v_person ? 'first_name' and (v_person->>'first_name') <> v_old.first_name then v_person_changes := array_append(v_person_changes, 'Vorname'); end if;
if v_person ? 'last_name' and (v_person->>'last_name') <> v_old.last_name then v_person_changes := array_append(v_person_changes, 'Nachname'); end if;
if v_person ? 'gender' and (v_person->>'gender') <> v_old.gender::text then v_person_changes := array_append(v_person_changes, 'Geschlecht'); end if;
if v_person ? 'birth_date' and (v_person->>'birth_date')::date <> v_old.birth_date then v_person_changes := array_append(v_person_changes, 'Geburtsdatum'); end if;
if v_person ? 'sv_nummer' and coalesce(v_person->>'sv_nummer','') <> coalesce(v_old.sv_nummer,'') then v_person_changes := array_append(v_person_changes, 'SV-Nummer'); end if;
if v_person ? 'nationality' and (v_person->>'nationality') <> v_old.nationality then v_person_changes := array_append(v_person_changes, 'Staatsbürgerschaft'); end if;
if v_person ? 'address' and coalesce(v_person->>'address','') <> coalesce(v_old.address,'') then v_person_changes := array_append(v_person_changes, 'Adresse'); end if;
if v_person ? 'postal_code' and coalesce(v_person->>'postal_code','') <> coalesce(v_old.postal_code,'') then v_person_changes := array_append(v_person_changes, 'Postleitzahl'); end if;
if v_person ? 'city' and coalesce(v_person->>'city','') <> coalesce(v_old.city,'') then v_person_changes := array_append(v_person_changes, 'Ort'); end if;
if v_person ? 'address_country' and coalesce(v_person->>'address_country','') <> coalesce(v_old.address_country,'') then v_person_changes := array_append(v_person_changes, 'Land'); end if;
if v_person ? 'email' and (v_person->>'email') <> v_old.email then v_person_changes := array_append(v_person_changes, 'E-Mail'); end if;
if v_person ? 'phone' and coalesce(v_person->>'phone','') <> coalesce(v_old.phone,'') then v_person_changes := array_append(v_person_changes, 'Telefon'); end if;
if v_contract ? 'employment_type' and (v_contract->>'employment_type') <> v_old.employment_type::text then v_contract_changes := array_append(v_contract_changes, 'Beschäftigungsausmaß'); end if;
if v_contract ? 'weekly_hours' and (v_contract->>'weekly_hours')::numeric <> v_old.weekly_hours then v_contract_changes := array_append(v_contract_changes, 'Wochenstunden'); end if;
if v_contract ? 'contract_type' and (v_contract->>'contract_type') <> v_old.contract_type::text then v_contract_changes := array_append(v_contract_changes, 'Vertragsart'); end if;
if v_contract ? 'contract_end_date' and coalesce(nullif(v_contract->>'contract_end_date','')::date::text,'') <> coalesce(v_old.contract_end_date::text,'') then v_contract_changes := array_append(v_contract_changes, 'Befristet bis'); 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 = coalesce(v_person->>'sv_nummer', sv_nummer),
nationality = coalesce(v_person->>'nationality', nationality),
address = coalesce(v_person->>'address', address),
postal_code = coalesce(v_person->>'postal_code', postal_code),
city = coalesce(v_person->>'city', city),
address_country = coalesce(v_person->>'address_country', address_country),
email = coalesce(v_person->>'email', email),
phone = coalesce(v_person->>'phone', phone),
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
where id = v_employee_id;
elsif array_length(v_person_changes, 1) > 0 or array_length(v_contract_changes, 1) > 0 then
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'contract_change', v_effective_date, payload);
end if;
if array_length(v_person_changes, 1) > 0 then
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_effective_date, 'Stammdatenänderung', 'Geänderte Felder: ' || array_to_string(v_person_changes, ', ') || ', wirksam ab ' || v_effective_date);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Stammdatenänderung', v_name, v_employee_id, array_to_string(v_person_changes, ', ') || ', wirksam ab ' || v_effective_date);
end if;
if array_length(v_contract_changes, 1) > 0 then
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_effective_date, 'Vertragsänderung', 'Geänderte Felder: ' || array_to_string(v_contract_changes, ', ') || ', wirksam ab ' || v_effective_date);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Vertragsänderung', v_name, v_employee_id, array_to_string(v_contract_changes, ', ') || ', wirksam ab ' || v_effective_date);
end if;
end;
$$;
-- ── apply_due_pending_changes: mirror the same postal_code/city handling
-- in the deferred contract_change branch ──
create or replace function apply_due_pending_changes()
returns int
language plpgsql
security definer
set search_path = public
as $$
declare
v_rec record;
v_team_id uuid;
v_division_id uuid;
v_is_lead boolean;
v_manager uuid;
v_remaining int;
v_applied_count int := 0;
begin
for v_rec in
select * from pending_org_changes
where status = 'pending' and effective_date <= current_date
order by created_at
loop
if v_rec.change_type = 'transfer' then
select is_lead into v_is_lead from employees where id = v_rec.employee_id;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id
where t.id = (v_rec.payload->>'new_team_id')::uuid;
v_manager := resolve_manager_for((v_rec.payload->>'new_team_id')::uuid, v_is_lead, v_division_id);
update employees set
team_id = (v_rec.payload->>'new_team_id')::uuid,
job_title = coalesce(nullif(v_rec.payload->>'new_title', ''), job_title),
manager_id = v_manager
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;
elsif v_rec.change_type = 'karenz_start' then
update employees set status = 'Karenz', karenz_return_date = (v_rec.payload->>'planned_return_date')::date
where id = v_rec.employee_id;
elsif v_rec.change_type = 'karenz_return' then
select team_id, division_id, is_lead into v_team_id, v_division_id, v_is_lead
from employees where id = v_rec.employee_id;
v_manager := resolve_manager_for(v_team_id, v_is_lead, v_division_id);
update employees set
status = 'Aktiv',
karenz_return_date = null,
karenz_start_date = null,
manager_id = v_manager,
employment_type = coalesce((v_rec.payload->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_rec.payload->>'weekly_hours')::numeric, weekly_hours)
where id = v_rec.employee_id;
elsif v_rec.change_type = 'contract_change' then
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 = coalesce(v_rec.payload->'person'->>'sv_nummer', sv_nummer),
nationality = coalesce(v_rec.payload->'person'->>'nationality', nationality),
address = coalesce(v_rec.payload->'person'->>'address', address),
postal_code = coalesce(v_rec.payload->'person'->>'postal_code', postal_code),
city = coalesce(v_rec.payload->'person'->>'city', city),
address_country = coalesce(v_rec.payload->'person'->>'address_country', address_country),
email = coalesce(v_rec.payload->'person'->>'email', email),
phone = coalesce(v_rec.payload->'person'->>'phone', phone),
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
where id = v_rec.employee_id;
elsif v_rec.change_type = 'reorg' then
select is_lead into v_is_lead from employees where id = v_rec.employee_id;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id
where t.id = (v_rec.payload->>'target_team_id')::uuid;
v_manager := resolve_manager_for((v_rec.payload->>'target_team_id')::uuid, v_is_lead, v_division_id);
update employees set team_id = (v_rec.payload->>'target_team_id')::uuid, manager_id = v_manager
where id = v_rec.employee_id;
end if;
update pending_org_changes set status = 'applied', applied_at = now() where id = v_rec.id;
v_applied_count := v_applied_count + 1;
if v_rec.reorg_scenario_id is not null then
select count(*) into v_remaining from pending_org_changes
where reorg_scenario_id = v_rec.reorg_scenario_id and status = 'pending';
if v_remaining = 0 then
update reorg_scenarios set applied = true, applied_at = now() where id = v_rec.reorg_scenario_id;
end if;
end if;
end loop;
return v_applied_count;
end;
$$;
revoke execute on function apply_due_pending_changes() from public, anon, authenticated;
grant execute on function apply_due_pending_changes() to service_role;

View File

@@ -0,0 +1,183 @@
-- Position validity window + delete capability.
--
-- 1. Positions had no "gültig ab" (valid_from) date — nothing recorded
-- since when a position is actually meant to be active, so a position
-- created today for a future need could immediately be staffed/hired
-- into.
-- 2. There was no way to remove a position again once created (no UI, no
-- Server Action, no RPC) — a mis-created or no-longer-needed open
-- requisition was stuck forever.
-- 3. Neither staff_position_internally nor hire_employee checked the
-- position's validity window before assigning an employee to it.
alter table positions add column if not exists valid_from date not null default current_date;
-- ── Position ausschreiben: now records valid_from ────────────────
create or replace function create_position(payload jsonb)
returns uuid language plpgsql as $$
declare
v_id uuid;
v_superior_id uuid := (payload->>'superior_employee_id')::uuid;
v_is_lead boolean := coalesce((payload->>'is_lead')::boolean, false);
v_team_id uuid;
v_valid_from date := coalesce(nullif(payload->>'valid_from', '')::date, current_date);
begin
perform require_hr_admin();
if v_is_lead then
v_team_id := (payload->>'team_id')::uuid;
else
select team_id into v_team_id from employees where id = v_superior_id;
end if;
insert into positions (title, team_id, is_lead, reports_to_employee_id, valid_from)
values (payload->>'title', v_team_id, v_is_lead, v_superior_id, v_valid_from)
returning id into v_id;
insert into audit_log (actor_user_id, actor_name, action, target_label, details)
values (auth.uid(), current_actor_name(), 'Ausschreibung', payload->>'title', 'Position ausgeschrieben, gültig ab ' || v_valid_from);
return v_id;
end;
$$;
-- ── Position löschen ───────────────────────────────────────────────
-- Only open (unfilled) positions can be deleted — a filled position is
-- already tied to an employee's history/audit trail and to whoever holds it.
create or replace function delete_position(payload jsonb)
returns void language plpgsql as $$
declare
v_position record;
begin
perform require_hr_admin();
select * into v_position from positions where id = (payload->>'position_id')::uuid;
if not found then
raise exception 'Position nicht gefunden.';
end if;
if v_position.status <> 'open' then
raise exception 'Nur offene Positionen können gelöscht werden.';
end if;
delete from positions where id = v_position.id;
insert into audit_log (actor_user_id, actor_name, action, target_label, details)
values (auth.uid(), current_actor_name(), 'Position gelöscht', v_position.title, 'Position ' || v_position.position_number || ' gelöscht');
end;
$$;
-- ── Intern besetzen: reject if the position isn't valid yet ─────────
create or replace function staff_position_internally(payload jsonb)
returns void language plpgsql as $$
declare
v_position record;
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_manager uuid;
v_name text;
begin
perform require_hr_admin();
select * into v_position from positions where id = (payload->>'position_id')::uuid and status = 'open';
if not found then
raise exception 'Position ist nicht mehr offen.';
end if;
if v_position.valid_from > current_date then
raise exception 'Die Position ist erst ab % gültig.', to_char(v_position.valid_from, 'DD.MM.YYYY');
end if;
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
v_manager := resolve_manager_for(v_position.team_id, v_position.is_lead, v_position.division_id);
update employees set
team_id = v_position.team_id,
job_title = v_position.title,
source = 'Intern',
is_lead = case when v_position.is_lead then true else is_lead end,
org_level = case when v_position.is_lead then 2 else org_level end,
manager_id = v_manager
where id = v_employee_id;
if v_position.is_lead then
update employees set manager_id = v_employee_id
where team_id = v_position.team_id and id <> v_employee_id and is_lead = false and status <> 'Ausgetreten';
end if;
update positions set status = 'filled', filled_at = now(), filled_by_employee_id = v_employee_id
where id = v_position.id;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, current_date, 'Versetzung', 'Interne Besetzung: ' || v_position.title);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Interne Besetzung', v_name, v_employee_id, v_position.title);
end;
$$;
-- ── Neueinstellung: reject if entry_date precedes the position's validity ──
create or replace function hire_employee(payload jsonb)
returns uuid language plpgsql as $$
declare
v_id uuid;
v_team_id uuid;
v_division_id uuid;
v_position record;
v_job_title text;
v_email text;
v_manager uuid;
begin
perform require_hr_admin();
if payload->>'position_id' is not null then
select * into v_position from positions where id = (payload->>'position_id')::uuid and status = 'open';
if not found then
raise exception 'Position ist nicht mehr offen.';
end if;
if (payload->>'entry_date')::date < v_position.valid_from then
raise exception 'Das Eintrittsdatum darf nicht vor dem Gültigkeitsbeginn der Position (%) liegen.', to_char(v_position.valid_from, 'DD.MM.YYYY');
end if;
v_team_id := v_position.team_id;
v_division_id := v_position.division_id;
v_job_title := coalesce(payload->>'job_title', v_position.title);
else
v_team_id := (payload->>'team_id')::uuid;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id where t.id = v_team_id;
v_job_title := payload->>'job_title';
end if;
v_manager := resolve_manager_for(v_team_id, false, v_division_id);
v_email := generate_company_email(payload->>'first_name', payload->>'last_name');
insert into employees (
first_name, last_name, gender, birth_date, sv_nummer, nationality, email, phone,
team_id, division_id, job_title, location_id, manager_id, org_level, is_lead,
employment_type, weekly_hours, contract_type, contract_end_date,
paygrade, source, status, entry_date
) values (
payload->>'first_name', payload->>'last_name', (payload->>'gender')::gender_type,
(payload->>'birth_date')::date, payload->>'sv_nummer', coalesce(payload->>'nationality', 'Österreich'),
v_email, payload->>'phone',
v_team_id, v_division_id, v_job_title, (payload->>'location_id')::uuid,
v_manager, 3, false,
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 (payload->>'entry_date')::date > current_date then 'Geplant' else 'Aktiv' end)::employment_status,
(payload->>'entry_date')::date
) returning id into v_id;
if payload->>'position_id' is not null then
update positions set status = 'filled', filled_at = now(), filled_by_employee_id = v_id
where id = (payload->>'position_id')::uuid;
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_id, (payload->>'entry_date')::date, 'Eintritt', 'Eintritt als ' || v_job_title);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Neueinstellung', (payload->>'first_name') || ' ' || (payload->>'last_name'), v_id, 'Eintritt am ' || (payload->>'entry_date'));
return v_id;
end;
$$;

View File

@@ -0,0 +1,309 @@
-- "Rolle & Anstellung" — role/employment classification that HR tracked
-- outside the system until now: worker category (Angestellte:r vs.
-- Arbeiter:in), collective agreement, contracted work days, and a handful
-- of eligibility/reporting flags (Betriebsrat, Dienstwagen, laterale
-- Führung, C-Level).
create type worker_type as enum ('Angestellte:r', 'Arbeiter:in');
create type collective_agreement as enum ('Handel', 'Süßwaren');
alter table employees add column if not exists worker_type worker_type not null default 'Angestellte:r';
alter table employees add column if not exists collective_agreement collective_agreement not null default 'Handel';
alter table employees add column if not exists work_days text[] not null default '{Mo,Di,Mi,Do,Fr}';
alter table employees add column if not exists is_betriebsrat boolean not null default false;
alter table employees add column if not exists has_dienstwagen boolean not null default false;
alter table employees add column if not exists is_laterale_fuehrung boolean not null default false;
alter table employees add column if not exists is_c_level boolean not null default false;
alter table employees add constraint chk_work_days_valid check (
work_days <@ array['Mo','Di','Mi','Do','Fr','Sa','So']::text[] and cardinality(work_days) > 0
);
-- ── Neueinstellung: accepts the new role/employment fields ─────────────
create or replace function hire_employee(payload jsonb)
returns uuid language plpgsql as $$
declare
v_id uuid;
v_team_id uuid;
v_division_id uuid;
v_position record;
v_job_title text;
v_email text;
v_manager uuid;
begin
perform require_hr_admin();
if payload->>'position_id' is not null then
select * into v_position from positions where id = (payload->>'position_id')::uuid and status = 'open';
if not found then
raise exception 'Position ist nicht mehr offen.';
end if;
if (payload->>'entry_date')::date < v_position.valid_from then
raise exception 'Das Eintrittsdatum darf nicht vor dem Gültigkeitsbeginn der Position (%) liegen.', to_char(v_position.valid_from, 'DD.MM.YYYY');
end if;
v_team_id := v_position.team_id;
v_division_id := v_position.division_id;
v_job_title := coalesce(payload->>'job_title', v_position.title);
else
v_team_id := (payload->>'team_id')::uuid;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id where t.id = v_team_id;
v_job_title := payload->>'job_title';
end if;
v_manager := resolve_manager_for(v_team_id, false, v_division_id);
v_email := generate_company_email(payload->>'first_name', payload->>'last_name');
insert into employees (
first_name, last_name, gender, birth_date, sv_nummer, nationality, email, phone,
team_id, division_id, job_title, location_id, manager_id, org_level, is_lead,
employment_type, weekly_hours, contract_type, contract_end_date,
paygrade, source, status, entry_date,
worker_type, collective_agreement, work_days,
is_betriebsrat, has_dienstwagen, is_laterale_fuehrung, is_c_level
) values (
payload->>'first_name', payload->>'last_name', (payload->>'gender')::gender_type,
(payload->>'birth_date')::date, payload->>'sv_nummer', coalesce(payload->>'nationality', 'Österreich'),
v_email, payload->>'phone',
v_team_id, v_division_id, v_job_title, (payload->>'location_id')::uuid,
v_manager, 3, false,
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 (payload->>'entry_date')::date > current_date then 'Geplant' else 'Aktiv' end)::employment_status,
(payload->>'entry_date')::date,
coalesce((payload->>'worker_type')::worker_type, 'Angestellte:r'),
coalesce((payload->>'collective_agreement')::collective_agreement, 'Handel'),
case when payload ? 'work_days' then coalesce((select array_agg(elem) from jsonb_array_elements_text(payload->'work_days') elem), '{}') else '{Mo,Di,Mi,Do,Fr}' end,
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)
) returning id into v_id;
if payload->>'position_id' is not null then
update positions set status = 'filled', filled_at = now(), filled_by_employee_id = v_id
where id = (payload->>'position_id')::uuid;
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_id, (payload->>'entry_date')::date, 'Eintritt', 'Eintritt als ' || v_job_title);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Neueinstellung', (payload->>'first_name') || ' ' || (payload->>'last_name'), v_id, 'Eintritt am ' || (payload->>'entry_date'));
return v_id;
end;
$$;
-- ── Daten ändern: recognize a "role" section alongside person/contract ──
create or replace function change_employee_data(payload jsonb)
returns void language plpgsql as $$
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 text[] := '{}';
v_contract_changes text[] := '{}';
v_person jsonb := payload->'person';
v_contract jsonb := payload->'contract';
v_role jsonb := payload->'role';
v_immediate boolean;
v_new_work_days text[];
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;
if v_person ? 'first_name' and (v_person->>'first_name') <> v_old.first_name then v_person_changes := array_append(v_person_changes, 'Vorname'); end if;
if v_person ? 'last_name' and (v_person->>'last_name') <> v_old.last_name then v_person_changes := array_append(v_person_changes, 'Nachname'); end if;
if v_person ? 'gender' and (v_person->>'gender') <> v_old.gender::text then v_person_changes := array_append(v_person_changes, 'Geschlecht'); end if;
if v_person ? 'birth_date' and (v_person->>'birth_date')::date <> v_old.birth_date then v_person_changes := array_append(v_person_changes, 'Geburtsdatum'); end if;
if v_person ? 'sv_nummer' and coalesce(v_person->>'sv_nummer','') <> coalesce(v_old.sv_nummer,'') then v_person_changes := array_append(v_person_changes, 'SV-Nummer'); end if;
if v_person ? 'nationality' and (v_person->>'nationality') <> v_old.nationality then v_person_changes := array_append(v_person_changes, 'Staatsbürgerschaft'); end if;
if v_person ? 'address' and coalesce(v_person->>'address','') <> coalesce(v_old.address,'') then v_person_changes := array_append(v_person_changes, 'Adresse'); end if;
if v_person ? 'postal_code' and coalesce(v_person->>'postal_code','') <> coalesce(v_old.postal_code,'') then v_person_changes := array_append(v_person_changes, 'Postleitzahl'); end if;
if v_person ? 'city' and coalesce(v_person->>'city','') <> coalesce(v_old.city,'') then v_person_changes := array_append(v_person_changes, 'Ort'); end if;
if v_person ? 'address_country' and coalesce(v_person->>'address_country','') <> coalesce(v_old.address_country,'') then v_person_changes := array_append(v_person_changes, 'Land'); end if;
if v_person ? 'email' and (v_person->>'email') <> v_old.email then v_person_changes := array_append(v_person_changes, 'E-Mail'); end if;
if v_person ? 'phone' and coalesce(v_person->>'phone','') <> coalesce(v_old.phone,'') then v_person_changes := array_append(v_person_changes, 'Telefon'); end if;
if v_contract ? 'employment_type' and (v_contract->>'employment_type') <> v_old.employment_type::text then v_contract_changes := array_append(v_contract_changes, 'Beschäftigungsausmaß'); end if;
if v_contract ? 'weekly_hours' and (v_contract->>'weekly_hours')::numeric <> v_old.weekly_hours then v_contract_changes := array_append(v_contract_changes, 'Wochenstunden'); end if;
if v_contract ? 'contract_type' and (v_contract->>'contract_type') <> v_old.contract_type::text then v_contract_changes := array_append(v_contract_changes, 'Vertragsart'); end if;
if v_contract ? 'contract_end_date' and coalesce(nullif(v_contract->>'contract_end_date','')::date::text,'') <> coalesce(v_old.contract_end_date::text,'') then v_contract_changes := array_append(v_contract_changes, 'Befristet bis'); end if;
if v_role ? 'worker_type' and (v_role->>'worker_type') <> v_old.worker_type::text then v_contract_changes := array_append(v_contract_changes, 'Angestellte:r/Arbeiter:in'); end if;
if v_role ? 'collective_agreement' and (v_role->>'collective_agreement') <> v_old.collective_agreement::text then v_contract_changes := array_append(v_contract_changes, 'Kollektivvertrag'); 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), '{}');
if v_new_work_days is distinct from v_old.work_days then v_contract_changes := array_append(v_contract_changes, 'Arbeitstage'); end if;
end if;
if v_role ? 'is_betriebsrat' and (v_role->>'is_betriebsrat')::boolean <> v_old.is_betriebsrat then v_contract_changes := array_append(v_contract_changes, 'Betriebsrat'); end if;
if v_role ? 'has_dienstwagen' and (v_role->>'has_dienstwagen')::boolean <> v_old.has_dienstwagen then v_contract_changes := array_append(v_contract_changes, 'Dienstwagen'); end if;
if v_role ? 'is_laterale_fuehrung' and (v_role->>'is_laterale_fuehrung')::boolean <> v_old.is_laterale_fuehrung then v_contract_changes := array_append(v_contract_changes, 'Laterale Führung'); end if;
if v_role ? 'is_c_level' and (v_role->>'is_c_level')::boolean <> v_old.is_c_level then v_contract_changes := array_append(v_contract_changes, 'C-Level'); 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 = coalesce(v_person->>'sv_nummer', sv_nummer),
nationality = coalesce(v_person->>'nationality', nationality),
address = coalesce(v_person->>'address', address),
postal_code = coalesce(v_person->>'postal_code', postal_code),
city = coalesce(v_person->>'city', city),
address_country = coalesce(v_person->>'address_country', address_country),
email = coalesce(v_person->>'email', email),
phone = coalesce(v_person->>'phone', phone),
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),
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)
where id = v_employee_id;
elsif array_length(v_person_changes, 1) > 0 or array_length(v_contract_changes, 1) > 0 then
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'contract_change', v_effective_date, payload);
end if;
if array_length(v_person_changes, 1) > 0 then
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_effective_date, 'Stammdatenänderung', 'Geänderte Felder: ' || array_to_string(v_person_changes, ', ') || ', wirksam ab ' || v_effective_date);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Stammdatenänderung', v_name, v_employee_id, array_to_string(v_person_changes, ', ') || ', wirksam ab ' || v_effective_date);
end if;
if array_length(v_contract_changes, 1) > 0 then
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_effective_date, 'Vertragsänderung', 'Geänderte Felder: ' || array_to_string(v_contract_changes, ', ') || ', wirksam ab ' || v_effective_date);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Vertragsänderung', v_name, v_employee_id, array_to_string(v_contract_changes, ', ') || ', wirksam ab ' || v_effective_date);
end if;
end;
$$;
-- ── apply_due_pending_changes: mirror the same role-field handling in the
-- deferred contract_change branch ──
create or replace function apply_due_pending_changes()
returns int
language plpgsql
security definer
set search_path = public
as $$
declare
v_rec record;
v_team_id uuid;
v_division_id uuid;
v_is_lead boolean;
v_manager uuid;
v_remaining int;
v_applied_count int := 0;
begin
for v_rec in
select * from pending_org_changes
where status = 'pending' and effective_date <= current_date
order by created_at
loop
if v_rec.change_type = 'transfer' then
select is_lead into v_is_lead from employees where id = v_rec.employee_id;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id
where t.id = (v_rec.payload->>'new_team_id')::uuid;
v_manager := resolve_manager_for((v_rec.payload->>'new_team_id')::uuid, v_is_lead, v_division_id);
update employees set
team_id = (v_rec.payload->>'new_team_id')::uuid,
job_title = coalesce(nullif(v_rec.payload->>'new_title', ''), job_title),
manager_id = v_manager
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;
elsif v_rec.change_type = 'karenz_start' then
update employees set status = 'Karenz', karenz_return_date = (v_rec.payload->>'planned_return_date')::date
where id = v_rec.employee_id;
elsif v_rec.change_type = 'karenz_return' then
select team_id, division_id, is_lead into v_team_id, v_division_id, v_is_lead
from employees where id = v_rec.employee_id;
v_manager := resolve_manager_for(v_team_id, v_is_lead, v_division_id);
update employees set
status = 'Aktiv',
karenz_return_date = null,
karenz_start_date = null,
manager_id = v_manager,
employment_type = coalesce((v_rec.payload->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_rec.payload->>'weekly_hours')::numeric, weekly_hours)
where id = v_rec.employee_id;
elsif v_rec.change_type = 'contract_change' then
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 = coalesce(v_rec.payload->'person'->>'sv_nummer', sv_nummer),
nationality = coalesce(v_rec.payload->'person'->>'nationality', nationality),
address = coalesce(v_rec.payload->'person'->>'address', address),
postal_code = coalesce(v_rec.payload->'person'->>'postal_code', postal_code),
city = coalesce(v_rec.payload->'person'->>'city', city),
address_country = coalesce(v_rec.payload->'person'->>'address_country', address_country),
email = coalesce(v_rec.payload->'person'->>'email', email),
phone = coalesce(v_rec.payload->'person'->>'phone', phone),
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_rec.payload->'role'->>'worker_type')::worker_type, worker_type),
collective_agreement = coalesce((v_rec.payload->'role'->>'collective_agreement')::collective_agreement, collective_agreement),
work_days = case when v_rec.payload->'role' ? 'work_days'
then coalesce((select array_agg(elem) from jsonb_array_elements_text(v_rec.payload->'role'->'work_days') elem), '{}') else work_days end,
is_betriebsrat = coalesce((v_rec.payload->'role'->>'is_betriebsrat')::boolean, is_betriebsrat),
has_dienstwagen = coalesce((v_rec.payload->'role'->>'has_dienstwagen')::boolean, has_dienstwagen),
is_laterale_fuehrung = coalesce((v_rec.payload->'role'->>'is_laterale_fuehrung')::boolean, is_laterale_fuehrung),
is_c_level = coalesce((v_rec.payload->'role'->>'is_c_level')::boolean, is_c_level)
where id = v_rec.employee_id;
elsif v_rec.change_type = 'reorg' then
select is_lead into v_is_lead from employees where id = v_rec.employee_id;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id
where t.id = (v_rec.payload->>'target_team_id')::uuid;
v_manager := resolve_manager_for((v_rec.payload->>'target_team_id')::uuid, v_is_lead, v_division_id);
update employees set team_id = (v_rec.payload->>'target_team_id')::uuid, manager_id = v_manager
where id = v_rec.employee_id;
end if;
update pending_org_changes set status = 'applied', applied_at = now() where id = v_rec.id;
v_applied_count := v_applied_count + 1;
if v_rec.reorg_scenario_id is not null then
select count(*) into v_remaining from pending_org_changes
where reorg_scenario_id = v_rec.reorg_scenario_id and status = 'pending';
if v_remaining = 0 then
update reorg_scenarios set applied = true, applied_at = now() where id = v_rec.reorg_scenario_id;
end if;
end if;
end loop;
return v_applied_count;
end;
$$;
revoke execute on function apply_due_pending_changes() from public, anon, authenticated;
grant execute on function apply_due_pending_changes() to service_role;

View File

@@ -0,0 +1,78 @@
-- "Angehörige" — dependents/family members tracked per employee (spouse,
-- children, ...), relevant for payroll/insurance. One employee can have
-- several. Editing is add/remove only — fix a mistaken entry by deleting
-- and re-adding it, same as "Position löschen" — no in-place edit RPC.
create table employee_dependents (
id uuid primary key default gen_random_uuid(),
employee_id uuid not null references employees(id) on delete cascade,
first_name text not null,
last_name text not null,
relationship text not null check (relationship in ('Ehepartner:in', 'Lebenspartner:in', 'Kind', 'Sonstige')),
sv_nummer text,
birth_date date not null,
created_at timestamptz not null default now()
);
create index on employee_dependents (employee_id);
-- RLS only narrows what an already-GRANTed role may do; the "grant all ...
-- to anon, authenticated, service_role" default privilege (see
-- 20260714120500_default_grants.sql) already covers this new table.
alter table employee_dependents enable row level security;
create policy "employee_dependents_hr_all" on employee_dependents for all
using (is_hr_user()) with check (is_hr_user());
-- ── Angehörige:n hinzufügen ───────────────────────────────────────────
create or replace function add_employee_dependent(payload jsonb)
returns uuid language plpgsql as $$
declare
v_id uuid;
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_employee_name text;
begin
perform require_hr_admin();
select first_name || ' ' || last_name into v_employee_name from employees where id = v_employee_id;
if not found then
raise exception 'Mitarbeiter:in nicht gefunden.';
end if;
insert into employee_dependents (employee_id, first_name, last_name, relationship, sv_nummer, birth_date)
values (
v_employee_id, payload->>'first_name', payload->>'last_name', payload->>'relationship',
nullif(payload->>'sv_nummer', ''), (payload->>'birth_date')::date
)
returning id into v_id;
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (
auth.uid(), current_actor_name(), 'Angehörige:r hinzugefügt', v_employee_name, v_employee_id,
(payload->>'first_name') || ' ' || (payload->>'last_name') || ' (' || (payload->>'relationship') || ')'
);
return v_id;
end;
$$;
-- ── Angehörige:n entfernen ────────────────────────────────────────────
create or replace function delete_employee_dependent(payload jsonb)
returns void language plpgsql as $$
declare
v_dep employee_dependents%rowtype;
v_employee_name text;
begin
perform require_hr_admin();
select * into v_dep from employee_dependents where id = (payload->>'dependent_id')::uuid;
if not found then
raise exception 'Angehörige:r nicht gefunden.';
end if;
select first_name || ' ' || last_name into v_employee_name from employees where id = v_dep.employee_id;
delete from employee_dependents where id = v_dep.id;
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (
auth.uid(), current_actor_name(), 'Angehörige:r entfernt', v_employee_name, v_dep.employee_id,
v_dep.first_name || ' ' || v_dep.last_name || ' (' || v_dep.relationship || ')'
);
end;
$$;

View File

@@ -0,0 +1,321 @@
-- Vor- und nachgestellte akademische Titel (Bundeskanzleramt/ELDA-Standardliste,
-- see lib/titles.ts) — e.g. "Dr. Max Mustermann, MSc". A person can hold
-- several of either, so both are text[] like work_days.
alter table employees add column if not exists title_prefix text[] not null default '{}';
alter table employees add column if not exists title_suffix text[] not null default '{}';
alter table employees add constraint chk_title_prefix_valid check (
title_prefix <@ array['Dr.','DDr.','Dipl.-Ing.','Ing.','Mag.','Mag. (FH)','MMag.','Dkfm.','Priv.-Doz.','Prof.']::text[]
);
alter table employees add constraint chk_title_suffix_valid check (
title_suffix <@ array['BA','BSc','BEd','BBA','LLB','MA','MSc','MBA','MEd','LLM','PhD','MBL']::text[]
);
-- ── Neueinstellung: accepts title_prefix/title_suffix ───────────────────
create or replace function hire_employee(payload jsonb)
returns uuid language plpgsql as $$
declare
v_id uuid;
v_team_id uuid;
v_division_id uuid;
v_position record;
v_job_title text;
v_email text;
v_manager uuid;
begin
perform require_hr_admin();
if payload->>'position_id' is not null then
select * into v_position from positions where id = (payload->>'position_id')::uuid and status = 'open';
if not found then
raise exception 'Position ist nicht mehr offen.';
end if;
if (payload->>'entry_date')::date < v_position.valid_from then
raise exception 'Das Eintrittsdatum darf nicht vor dem Gültigkeitsbeginn der Position (%) liegen.', to_char(v_position.valid_from, 'DD.MM.YYYY');
end if;
v_team_id := v_position.team_id;
v_division_id := v_position.division_id;
v_job_title := coalesce(payload->>'job_title', v_position.title);
else
v_team_id := (payload->>'team_id')::uuid;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id where t.id = v_team_id;
v_job_title := payload->>'job_title';
end if;
v_manager := resolve_manager_for(v_team_id, false, v_division_id);
v_email := generate_company_email(payload->>'first_name', payload->>'last_name');
insert into employees (
first_name, last_name, gender, birth_date, sv_nummer, nationality, email, phone,
team_id, division_id, job_title, location_id, manager_id, org_level, is_lead,
employment_type, weekly_hours, contract_type, contract_end_date,
paygrade, source, status, entry_date,
worker_type, collective_agreement, work_days,
is_betriebsrat, has_dienstwagen, is_laterale_fuehrung, is_c_level,
title_prefix, title_suffix
) values (
payload->>'first_name', payload->>'last_name', (payload->>'gender')::gender_type,
(payload->>'birth_date')::date, payload->>'sv_nummer', coalesce(payload->>'nationality', 'Österreich'),
v_email, payload->>'phone',
v_team_id, v_division_id, v_job_title, (payload->>'location_id')::uuid,
v_manager, 3, false,
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 (payload->>'entry_date')::date > current_date then 'Geplant' else 'Aktiv' end)::employment_status,
(payload->>'entry_date')::date,
coalesce((payload->>'worker_type')::worker_type, 'Angestellte:r'),
coalesce((payload->>'collective_agreement')::collective_agreement, 'Handel'),
case when payload ? 'work_days' then coalesce((select array_agg(elem) from jsonb_array_elements_text(payload->'work_days') elem), '{}') else '{Mo,Di,Mi,Do,Fr}' end,
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),
case when payload ? 'title_prefix' then coalesce((select array_agg(elem) from jsonb_array_elements_text(payload->'title_prefix') elem), '{}') else '{}' end,
case when payload ? 'title_suffix' then coalesce((select array_agg(elem) from jsonb_array_elements_text(payload->'title_suffix') elem), '{}') else '{}' end
) returning id into v_id;
if payload->>'position_id' is not null then
update positions set status = 'filled', filled_at = now(), filled_by_employee_id = v_id
where id = (payload->>'position_id')::uuid;
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_id, (payload->>'entry_date')::date, 'Eintritt', 'Eintritt als ' || v_job_title);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Neueinstellung', (payload->>'first_name') || ' ' || (payload->>'last_name'), v_id, 'Eintritt am ' || (payload->>'entry_date'));
return v_id;
end;
$$;
-- ── Daten ändern: person section recognizes title_prefix/title_suffix ──
create or replace function change_employee_data(payload jsonb)
returns void language plpgsql as $$
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 text[] := '{}';
v_contract_changes text[] := '{}';
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[];
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;
if v_person ? 'first_name' and (v_person->>'first_name') <> v_old.first_name then v_person_changes := array_append(v_person_changes, 'Vorname'); end if;
if v_person ? 'last_name' and (v_person->>'last_name') <> v_old.last_name then v_person_changes := array_append(v_person_changes, 'Nachname'); end if;
if v_person ? 'gender' and (v_person->>'gender') <> v_old.gender::text then v_person_changes := array_append(v_person_changes, 'Geschlecht'); end if;
if v_person ? 'birth_date' and (v_person->>'birth_date')::date <> v_old.birth_date then v_person_changes := array_append(v_person_changes, 'Geburtsdatum'); end if;
if v_person ? 'sv_nummer' and coalesce(v_person->>'sv_nummer','') <> coalesce(v_old.sv_nummer,'') then v_person_changes := array_append(v_person_changes, 'SV-Nummer'); end if;
if v_person ? 'nationality' and (v_person->>'nationality') <> v_old.nationality then v_person_changes := array_append(v_person_changes, 'Staatsbürgerschaft'); end if;
if v_person ? 'address' and coalesce(v_person->>'address','') <> coalesce(v_old.address,'') then v_person_changes := array_append(v_person_changes, 'Adresse'); end if;
if v_person ? 'postal_code' and coalesce(v_person->>'postal_code','') <> coalesce(v_old.postal_code,'') then v_person_changes := array_append(v_person_changes, 'Postleitzahl'); end if;
if v_person ? 'city' and coalesce(v_person->>'city','') <> coalesce(v_old.city,'') then v_person_changes := array_append(v_person_changes, 'Ort'); end if;
if v_person ? 'address_country' and coalesce(v_person->>'address_country','') <> coalesce(v_old.address_country,'') then v_person_changes := array_append(v_person_changes, 'Land'); end if;
if v_person ? 'email' and (v_person->>'email') <> v_old.email then v_person_changes := array_append(v_person_changes, 'E-Mail'); end if;
if v_person ? 'phone' and coalesce(v_person->>'phone','') <> coalesce(v_old.phone,'') then v_person_changes := array_append(v_person_changes, 'Telefon'); 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), '{}');
if v_new_title_prefix is distinct from v_old.title_prefix then v_person_changes := array_append(v_person_changes, 'Titel (vorangestellt)'); end if;
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), '{}');
if v_new_title_suffix is distinct from v_old.title_suffix then v_person_changes := array_append(v_person_changes, 'Titel (nachgestellt)'); end if;
end if;
if v_contract ? 'employment_type' and (v_contract->>'employment_type') <> v_old.employment_type::text then v_contract_changes := array_append(v_contract_changes, 'Beschäftigungsausmaß'); end if;
if v_contract ? 'weekly_hours' and (v_contract->>'weekly_hours')::numeric <> v_old.weekly_hours then v_contract_changes := array_append(v_contract_changes, 'Wochenstunden'); end if;
if v_contract ? 'contract_type' and (v_contract->>'contract_type') <> v_old.contract_type::text then v_contract_changes := array_append(v_contract_changes, 'Vertragsart'); end if;
if v_contract ? 'contract_end_date' and coalesce(nullif(v_contract->>'contract_end_date','')::date::text,'') <> coalesce(v_old.contract_end_date::text,'') then v_contract_changes := array_append(v_contract_changes, 'Befristet bis'); end if;
if v_role ? 'worker_type' and (v_role->>'worker_type') <> v_old.worker_type::text then v_contract_changes := array_append(v_contract_changes, 'Angestellte:r/Arbeiter:in'); end if;
if v_role ? 'collective_agreement' and (v_role->>'collective_agreement') <> v_old.collective_agreement::text then v_contract_changes := array_append(v_contract_changes, 'Kollektivvertrag'); 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), '{}');
if v_new_work_days is distinct from v_old.work_days then v_contract_changes := array_append(v_contract_changes, 'Arbeitstage'); end if;
end if;
if v_role ? 'is_betriebsrat' and (v_role->>'is_betriebsrat')::boolean <> v_old.is_betriebsrat then v_contract_changes := array_append(v_contract_changes, 'Betriebsrat'); end if;
if v_role ? 'has_dienstwagen' and (v_role->>'has_dienstwagen')::boolean <> v_old.has_dienstwagen then v_contract_changes := array_append(v_contract_changes, 'Dienstwagen'); end if;
if v_role ? 'is_laterale_fuehrung' and (v_role->>'is_laterale_fuehrung')::boolean <> v_old.is_laterale_fuehrung then v_contract_changes := array_append(v_contract_changes, 'Laterale Führung'); end if;
if v_role ? 'is_c_level' and (v_role->>'is_c_level')::boolean <> v_old.is_c_level then v_contract_changes := array_append(v_contract_changes, 'C-Level'); 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 = coalesce(v_person->>'sv_nummer', sv_nummer),
nationality = coalesce(v_person->>'nationality', nationality),
address = coalesce(v_person->>'address', address),
postal_code = coalesce(v_person->>'postal_code', postal_code),
city = coalesce(v_person->>'city', city),
address_country = coalesce(v_person->>'address_country', address_country),
email = coalesce(v_person->>'email', email),
phone = coalesce(v_person->>'phone', phone),
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),
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)
where id = v_employee_id;
elsif array_length(v_person_changes, 1) > 0 or array_length(v_contract_changes, 1) > 0 then
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'contract_change', v_effective_date, payload);
end if;
if array_length(v_person_changes, 1) > 0 then
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_effective_date, 'Stammdatenänderung', 'Geänderte Felder: ' || array_to_string(v_person_changes, ', ') || ', wirksam ab ' || v_effective_date);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Stammdatenänderung', v_name, v_employee_id, array_to_string(v_person_changes, ', ') || ', wirksam ab ' || v_effective_date);
end if;
if array_length(v_contract_changes, 1) > 0 then
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_effective_date, 'Vertragsänderung', 'Geänderte Felder: ' || array_to_string(v_contract_changes, ', ') || ', wirksam ab ' || v_effective_date);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Vertragsänderung', v_name, v_employee_id, array_to_string(v_contract_changes, ', ') || ', wirksam ab ' || v_effective_date);
end if;
end;
$$;
-- ── apply_due_pending_changes: mirror title_prefix/title_suffix handling
-- in the deferred contract_change branch ──
create or replace function apply_due_pending_changes()
returns int
language plpgsql
security definer
set search_path = public
as $$
declare
v_rec record;
v_team_id uuid;
v_division_id uuid;
v_is_lead boolean;
v_manager uuid;
v_remaining int;
v_applied_count int := 0;
begin
for v_rec in
select * from pending_org_changes
where status = 'pending' and effective_date <= current_date
order by created_at
loop
if v_rec.change_type = 'transfer' then
select is_lead into v_is_lead from employees where id = v_rec.employee_id;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id
where t.id = (v_rec.payload->>'new_team_id')::uuid;
v_manager := resolve_manager_for((v_rec.payload->>'new_team_id')::uuid, v_is_lead, v_division_id);
update employees set
team_id = (v_rec.payload->>'new_team_id')::uuid,
job_title = coalesce(nullif(v_rec.payload->>'new_title', ''), job_title),
manager_id = v_manager
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;
elsif v_rec.change_type = 'karenz_start' then
update employees set status = 'Karenz', karenz_return_date = (v_rec.payload->>'planned_return_date')::date
where id = v_rec.employee_id;
elsif v_rec.change_type = 'karenz_return' then
select team_id, division_id, is_lead into v_team_id, v_division_id, v_is_lead
from employees where id = v_rec.employee_id;
v_manager := resolve_manager_for(v_team_id, v_is_lead, v_division_id);
update employees set
status = 'Aktiv',
karenz_return_date = null,
karenz_start_date = null,
manager_id = v_manager,
employment_type = coalesce((v_rec.payload->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_rec.payload->>'weekly_hours')::numeric, weekly_hours)
where id = v_rec.employee_id;
elsif v_rec.change_type = 'contract_change' then
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 = coalesce(v_rec.payload->'person'->>'sv_nummer', sv_nummer),
nationality = coalesce(v_rec.payload->'person'->>'nationality', nationality),
address = coalesce(v_rec.payload->'person'->>'address', address),
postal_code = coalesce(v_rec.payload->'person'->>'postal_code', postal_code),
city = coalesce(v_rec.payload->'person'->>'city', city),
address_country = coalesce(v_rec.payload->'person'->>'address_country', address_country),
email = coalesce(v_rec.payload->'person'->>'email', email),
phone = coalesce(v_rec.payload->'person'->>'phone', phone),
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_rec.payload->'role'->>'worker_type')::worker_type, worker_type),
collective_agreement = coalesce((v_rec.payload->'role'->>'collective_agreement')::collective_agreement, collective_agreement),
work_days = case when v_rec.payload->'role' ? 'work_days'
then coalesce((select array_agg(elem) from jsonb_array_elements_text(v_rec.payload->'role'->'work_days') elem), '{}') else work_days end,
is_betriebsrat = coalesce((v_rec.payload->'role'->>'is_betriebsrat')::boolean, is_betriebsrat),
has_dienstwagen = coalesce((v_rec.payload->'role'->>'has_dienstwagen')::boolean, has_dienstwagen),
is_laterale_fuehrung = coalesce((v_rec.payload->'role'->>'is_laterale_fuehrung')::boolean, is_laterale_fuehrung),
is_c_level = coalesce((v_rec.payload->'role'->>'is_c_level')::boolean, is_c_level)
where id = v_rec.employee_id;
elsif v_rec.change_type = 'reorg' then
select is_lead into v_is_lead from employees where id = v_rec.employee_id;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id
where t.id = (v_rec.payload->>'target_team_id')::uuid;
v_manager := resolve_manager_for((v_rec.payload->>'target_team_id')::uuid, v_is_lead, v_division_id);
update employees set team_id = (v_rec.payload->>'target_team_id')::uuid, manager_id = v_manager
where id = v_rec.employee_id;
end if;
update pending_org_changes set status = 'applied', applied_at = now() where id = v_rec.id;
v_applied_count := v_applied_count + 1;
if v_rec.reorg_scenario_id is not null then
select count(*) into v_remaining from pending_org_changes
where reorg_scenario_id = v_rec.reorg_scenario_id and status = 'pending';
if v_remaining = 0 then
update reorg_scenarios set applied = true, applied_at = now() where id = v_rec.reorg_scenario_id;
end if;
end if;
end loop;
return v_applied_count;
end;
$$;
revoke execute on function apply_due_pending_changes() from public, anon, authenticated;
grant execute on function apply_due_pending_changes() to service_role;

View File

@@ -0,0 +1,214 @@
-- Angehörige add/remove now take a "Wirksam ab" like every other mutation
-- in Daten ändern — a future-dated add/remove is queued the same way
-- transfer/promotion/contract changes already are (see
-- 20260714120050_pending_org_changes.sql) and picked up by the existing
-- apply_due_pending_changes() cron job once due.
alter table pending_org_changes drop constraint pending_org_changes_change_type_check;
alter table pending_org_changes add constraint pending_org_changes_change_type_check check (change_type in (
'transfer', 'promotion', 'karenz_start', 'karenz_return', 'contract_change', 'reorg', 'dependent_add', 'dependent_remove'
));
-- ── Angehörige:n hinzufügen: now effective-dated ─────────────────────
-- Return type changes uuid -> void (a deferred add has no row yet to
-- return an id for), which CREATE OR REPLACE can't do in place.
drop function if exists add_employee_dependent(jsonb);
create function add_employee_dependent(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_employee_name text;
v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date);
v_dep_name text := (payload->>'first_name') || ' ' || (payload->>'last_name');
begin
perform require_hr_admin();
select first_name || ' ' || last_name into v_employee_name from employees where id = v_employee_id;
if not found then
raise exception 'Mitarbeiter:in nicht gefunden.';
end if;
if v_effective_date <= current_date then
insert into employee_dependents (employee_id, first_name, last_name, relationship, sv_nummer, birth_date)
values (
v_employee_id, payload->>'first_name', payload->>'last_name', payload->>'relationship',
nullif(payload->>'sv_nummer', ''), (payload->>'birth_date')::date
);
else
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'dependent_add', v_effective_date, payload);
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (
v_employee_id, v_effective_date, 'Stammdatenänderung',
'Angehörige:r hinzugefügt: ' || v_dep_name || ' (' || (payload->>'relationship') || '), wirksam ab ' || v_effective_date
);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (
auth.uid(), current_actor_name(), 'Angehörige:r hinzugefügt', v_employee_name, v_employee_id,
v_dep_name || ' (' || (payload->>'relationship') || '), wirksam ab ' || v_effective_date
);
end;
$$;
-- ── Angehörige:n entfernen: now effective-dated ──────────────────────
create or replace function delete_employee_dependent(payload jsonb)
returns void language plpgsql as $$
declare
v_dep employee_dependents%rowtype;
v_employee_name text;
v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date);
begin
perform require_hr_admin();
select * into v_dep from employee_dependents where id = (payload->>'dependent_id')::uuid;
if not found then
raise exception 'Angehörige:r nicht gefunden.';
end if;
select first_name || ' ' || last_name into v_employee_name from employees where id = v_dep.employee_id;
if v_effective_date <= current_date then
delete from employee_dependents where id = v_dep.id;
else
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_dep.employee_id, 'dependent_remove', v_effective_date, jsonb_build_object('dependent_id', v_dep.id));
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (
v_dep.employee_id, v_effective_date, 'Stammdatenänderung',
'Angehörige:r entfernt: ' || v_dep.first_name || ' ' || v_dep.last_name || ' (' || v_dep.relationship || '), wirksam ab ' || v_effective_date
);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (
auth.uid(), current_actor_name(), 'Angehörige:r entfernt', v_employee_name, v_dep.employee_id,
v_dep.first_name || ' ' || v_dep.last_name || ' (' || v_dep.relationship || '), wirksam ab ' || v_effective_date
);
end;
$$;
-- ── apply_due_pending_changes: dependent_add/dependent_remove branches ──
create or replace function apply_due_pending_changes()
returns int
language plpgsql
security definer
set search_path = public
as $$
declare
v_rec record;
v_team_id uuid;
v_division_id uuid;
v_is_lead boolean;
v_manager uuid;
v_remaining int;
v_applied_count int := 0;
begin
for v_rec in
select * from pending_org_changes
where status = 'pending' and effective_date <= current_date
order by created_at
loop
if v_rec.change_type = 'transfer' then
select is_lead into v_is_lead from employees where id = v_rec.employee_id;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id
where t.id = (v_rec.payload->>'new_team_id')::uuid;
v_manager := resolve_manager_for((v_rec.payload->>'new_team_id')::uuid, v_is_lead, v_division_id);
update employees set
team_id = (v_rec.payload->>'new_team_id')::uuid,
job_title = coalesce(nullif(v_rec.payload->>'new_title', ''), job_title),
manager_id = v_manager
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;
elsif v_rec.change_type = 'karenz_start' then
update employees set status = 'Karenz', karenz_return_date = (v_rec.payload->>'planned_return_date')::date
where id = v_rec.employee_id;
elsif v_rec.change_type = 'karenz_return' then
select team_id, division_id, is_lead into v_team_id, v_division_id, v_is_lead
from employees where id = v_rec.employee_id;
v_manager := resolve_manager_for(v_team_id, v_is_lead, v_division_id);
update employees set
status = 'Aktiv',
karenz_return_date = null,
karenz_start_date = null,
manager_id = v_manager,
employment_type = coalesce((v_rec.payload->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_rec.payload->>'weekly_hours')::numeric, weekly_hours)
where id = v_rec.employee_id;
elsif v_rec.change_type = 'contract_change' then
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 = coalesce(v_rec.payload->'person'->>'sv_nummer', sv_nummer),
nationality = coalesce(v_rec.payload->'person'->>'nationality', nationality),
address = coalesce(v_rec.payload->'person'->>'address', address),
postal_code = coalesce(v_rec.payload->'person'->>'postal_code', postal_code),
city = coalesce(v_rec.payload->'person'->>'city', city),
address_country = coalesce(v_rec.payload->'person'->>'address_country', address_country),
email = coalesce(v_rec.payload->'person'->>'email', email),
phone = coalesce(v_rec.payload->'person'->>'phone', phone),
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_rec.payload->'role'->>'worker_type')::worker_type, worker_type),
collective_agreement = coalesce((v_rec.payload->'role'->>'collective_agreement')::collective_agreement, collective_agreement),
work_days = case when v_rec.payload->'role' ? 'work_days'
then coalesce((select array_agg(elem) from jsonb_array_elements_text(v_rec.payload->'role'->'work_days') elem), '{}') else work_days end,
is_betriebsrat = coalesce((v_rec.payload->'role'->>'is_betriebsrat')::boolean, is_betriebsrat),
has_dienstwagen = coalesce((v_rec.payload->'role'->>'has_dienstwagen')::boolean, has_dienstwagen),
is_laterale_fuehrung = coalesce((v_rec.payload->'role'->>'is_laterale_fuehrung')::boolean, is_laterale_fuehrung),
is_c_level = coalesce((v_rec.payload->'role'->>'is_c_level')::boolean, is_c_level)
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',
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;
elsif v_rec.change_type = 'reorg' then
select is_lead into v_is_lead from employees where id = v_rec.employee_id;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id
where t.id = (v_rec.payload->>'target_team_id')::uuid;
v_manager := resolve_manager_for((v_rec.payload->>'target_team_id')::uuid, v_is_lead, v_division_id);
update employees set team_id = (v_rec.payload->>'target_team_id')::uuid, manager_id = v_manager
where id = v_rec.employee_id;
end if;
update pending_org_changes set status = 'applied', applied_at = now() where id = v_rec.id;
v_applied_count := v_applied_count + 1;
if v_rec.reorg_scenario_id is not null then
select count(*) into v_remaining from pending_org_changes
where reorg_scenario_id = v_rec.reorg_scenario_id and status = 'pending';
if v_remaining = 0 then
update reorg_scenarios set applied = true, applied_at = now() where id = v_rec.reorg_scenario_id;
end if;
end if;
end loop;
return v_applied_count;
end;
$$;
revoke execute on function apply_due_pending_changes() from public, anon, authenticated;
grant execute on function apply_due_pending_changes() to service_role;

View File

@@ -0,0 +1,107 @@
-- HR-Notizen: add-only Notizen zu einem Mitarbeiter, mit optionalem
-- "Wiedervorlage am"-Datum. Kein Bearbeiten/Löschen — ein Fehler wird nicht
-- korrigiert, sondern bleibt sichtbar (ggf. per neuer Notiz richtiggestellt),
-- gleicher Append-only-Geist wie employee_history/audit_log.
--
-- Bewusst NICHT nach Autor gescoped und NICHT effective-dated: anders als
-- employee_dependents sieht jede aktive HR-Person jede offene Notiz,
-- unabhängig davon wer sie geschrieben hat oder zu wem sie gehört ("Meine
-- Notizen" ist trotz des Namens ein geteiltes Team-Postfach), und eine
-- Notiz hat keinen "existiert erst ab einem künftigen Datum"-Zustand wie
-- eine Versetzung/Beförderung.
--
-- Kein Eintrag in employee_history: history_event_type ist ein fixer Enum
-- (siehe 20260601000000_initial_schema.sql) ohne passenden Wert, und
-- Historie ist explizit eine Beschäftigungsereignis-Timeline. Jede Mutation
-- schreibt stattdessen nur einen audit_log-Eintrag.
create table employee_notes (
id uuid primary key default gen_random_uuid(),
employee_id uuid not null references employees(id) on delete cascade,
author_user_id uuid references auth.users(id),
author_name text not null,
category text not null default 'Allgemein' check (category in (
'Allgemein', 'Vertraulich', 'Personalgespräch', 'Wiedervorlage', 'Lob / Anerkennung'
)),
note_text text not null,
due_date date,
done boolean not null default false,
done_at timestamptz,
done_by uuid references auth.users(id),
created_at timestamptz not null default now()
);
create index on employee_notes (employee_id);
-- Deckt die "Meine Notizen"-Postfach-Query (loadOpenNotes) ab, die immer
-- auf done = false filtert.
create index on employee_notes (created_at desc) where not done;
-- RLS narrows only what an already-GRANTed role may do; die
-- "grant all ... to anon, authenticated, service_role"-Default-Privilegien
-- (20260714120500_default_grants.sql) decken die neue Tabelle bereits ab.
alter table employee_notes enable row level security;
-- Eine einzige Blanket-Policy, gleiches Muster wie `positions`: offen vs.
-- erledigt ist ein reiner App-Filter, nie eine RLS-Unterscheidung — jede
-- aktive HR-Person darf jede Notiz lesen/schreiben.
create policy "employee_notes_hr_all" on employee_notes for all
using (is_hr_user()) with check (is_hr_user());
-- ── HR-Notiz hinzufügen ───────────────────────────────────────────────
create or replace function add_employee_note(payload jsonb)
returns uuid language plpgsql as $$
declare
v_id uuid;
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_employee_name text;
v_category text := coalesce(nullif(payload->>'category', ''), 'Allgemein');
v_note_text text := payload->>'note_text';
v_due_date date := nullif(payload->>'due_date', '')::date;
begin
perform require_hr_admin();
select first_name || ' ' || last_name into v_employee_name from employees where id = v_employee_id;
if not found then
raise exception 'Mitarbeiter:in nicht gefunden.';
end if;
if coalesce(btrim(v_note_text), '') = '' then
raise exception 'Notiztext darf nicht leer sein.';
end if;
insert into employee_notes (employee_id, author_user_id, author_name, category, note_text, due_date)
values (v_employee_id, auth.uid(), current_actor_name(), v_category, v_note_text, v_due_date)
returning id into v_id;
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (
auth.uid(), current_actor_name(), 'HR-Notiz hinzugefügt', v_employee_name, v_employee_id,
'[' || v_category || '] ' || left(v_note_text, 200) ||
case when v_due_date is not null then ', Wiedervorlage am ' || v_due_date else '' end
);
return v_id;
end;
$$;
-- ── HR-Notiz als erledigt markieren ───────────────────────────────────
create or replace function complete_employee_note(payload jsonb)
returns void language plpgsql as $$
declare
v_note employee_notes%rowtype;
v_employee_name text;
begin
perform require_hr_admin();
select * into v_note from employee_notes where id = (payload->>'note_id')::uuid;
if not found then
raise exception 'Notiz nicht gefunden.';
end if;
if v_note.done then
raise exception 'Notiz ist bereits erledigt.';
end if;
select first_name || ' ' || last_name into v_employee_name from employees where id = v_note.employee_id;
update employee_notes set done = true, done_at = now(), done_by = auth.uid() where id = v_note.id;
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (
auth.uid(), current_actor_name(), 'HR-Notiz erledigt', v_employee_name, v_note.employee_id,
'[' || v_note.category || '] ' || left(v_note.note_text, 200)
);
end;
$$;

View File

@@ -0,0 +1,120 @@
-- Org-assignment history, so the Organigramm can be shown as of any date.
--
-- Until now `employees` carried only the *current* placement (manager_id,
-- team_id, division_id, job_title, is_lead, org_level) and every mutation
-- overwrote it in place. employee_history recorded that something happened,
-- but only as free text — no old/new values — so a past reporting line was
-- gone for good. Forward-looking dates already worked (pending_org_changes
-- holds not-yet-due changes structurally); it was the past that could not be
-- reconstructed. This migration adds the missing timeline.
--
-- Captured by a trigger rather than by editing the mutating RPCs: there are
-- ~70 `update employees` statements spread over fifteen migrations (several
-- of which redefine the same function repeatedly), so per-RPC bookkeeping
-- would miss paths today and again with every future RPC. A trigger on the
-- table catches all of them, including ones not written yet.
create table employee_assignments (
id uuid primary key default gen_random_uuid(),
employee_id uuid not null references employees(id) on delete cascade,
manager_id uuid references employees(id) on delete set null,
team_id uuid references teams(id),
division_id uuid not null references divisions(id),
job_title text not null,
is_lead boolean not null default false,
org_level int not null,
valid_from date not null,
-- Exclusive upper bound; null means "still in force". Note this tracks
-- *placement*, not employment: the row of someone who has left stays open,
-- because whether they were employed on a given date is derived separately
-- from entry/exit/karenz dates. Keeping the two apart is what lets a
-- rehire reuse the same open row instead of needing it reopened.
valid_to date,
created_at timestamptz not null default now(),
constraint chk_assignment_range check (valid_to is null or valid_to > valid_from)
);
create index on employee_assignments (employee_id, valid_from desc);
create index on employee_assignments (valid_from);
-- At most one open interval per employee — the invariant the trigger relies
-- on when it looks up "the current row" to close.
create unique index employee_assignments_one_open on employee_assignments (employee_id) where valid_to is null;
alter table employee_assignments enable row level security;
create policy "employee_assignments_hr_read" on employee_assignments for select using (is_hr_user());
-- ── Backfill ───────────────────────────────────────────────────────
-- One open interval per employee, starting at their entry date, holding
-- today's placement. Changes made *before* this migration are not
-- recoverable — employee_history never stored structured old values — so a
-- Stichtag before today's date shows the placement as it stands now for
-- anyone whose assignment predates this table. Everything from here on is
-- exact.
insert into employee_assignments (employee_id, manager_id, team_id, division_id, job_title, is_lead, org_level, valid_from)
select id, manager_id, team_id, division_id, job_title, is_lead, org_level, entry_date
from employees;
-- ── Trigger ────────────────────────────────────────────────────────
-- The date a change takes effect is normally the day it is written: an RPC
-- with a future effective date does not touch `employees` at all (it queues
-- into pending_org_changes, and apply_due_pending_changes writes it on the
-- due date), and one dated today writes immediately. A *backdated* change is
-- the exception — it writes immediately although it should take effect
-- earlier — so callers may set `app.effective_date` for the transaction to
-- say so explicitly; unset, it falls back to the current date.
create or replace function fn_track_employee_assignment()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
declare
v_date date := coalesce(nullif(current_setting('app.effective_date', true), '')::date, current_date);
v_open_from date;
begin
if tg_op = 'INSERT' then
insert into employee_assignments (employee_id, manager_id, team_id, division_id, job_title, is_lead, org_level, valid_from)
values (new.id, new.manager_id, new.team_id, new.division_id, new.job_title, new.is_lead, new.org_level,
coalesce(new.entry_date, v_date));
return new;
end if;
if new.manager_id is not distinct from old.manager_id
and new.team_id is not distinct from old.team_id
and new.division_id is not distinct from old.division_id
and new.job_title is not distinct from old.job_title
and new.is_lead is not distinct from old.is_lead
and new.org_level is not distinct from old.org_level then
return new;
end if;
select valid_from into v_open_from
from employee_assignments where employee_id = new.id and valid_to is null;
if v_open_from is null then
insert into employee_assignments (employee_id, manager_id, team_id, division_id, job_title, is_lead, org_level, valid_from)
values (new.id, new.manager_id, new.team_id, new.division_id, new.job_title, new.is_lead, new.org_level, v_date);
elsif v_date <= v_open_from then
-- Two changes on the same day (or a backdate landing inside the open
-- interval): overwrite in place, so no zero-length or inverted interval
-- is ever stored.
update employee_assignments
set manager_id = new.manager_id, team_id = new.team_id, division_id = new.division_id,
job_title = new.job_title, is_lead = new.is_lead, org_level = new.org_level
where employee_id = new.id and valid_to is null;
else
update employee_assignments set valid_to = v_date where employee_id = new.id and valid_to is null;
insert into employee_assignments (employee_id, manager_id, team_id, division_id, job_title, is_lead, org_level, valid_from)
values (new.id, new.manager_id, new.team_id, new.division_id, new.job_title, new.is_lead, new.org_level, v_date);
end if;
return new;
end;
$$;
drop trigger if exists trg_track_employee_assignment on employees;
create trigger trg_track_employee_assignment
after insert or update on employees
for each row execute function fn_track_employee_assignment();
grant all on table employee_assignments to anon, authenticated, service_role;

View File

@@ -0,0 +1,99 @@
-- Validation of the Austrian Sozialversicherungsnummer (SVNR).
--
-- `sv_nummer` has been free text since the initial schema. For employees at
-- an Austrian location it follows a fixed standard — three-digit serial,
-- check digit, then TTMMJJ — and the check digit is verifiable, so typos
-- that would otherwise surface at the payroll interface can be caught on
-- entry. Employees at the German/Czech/Slovenian locations keep the field
-- free-form; their national equivalents have different formats.
--
-- Enforced by a trigger rather than inside hire_employee/change_employee_data
-- for the same reason as the assignment history: both functions have been
-- redefined by half a dozen migrations, and a check in the table catches
-- every write path including ones added later.
create or replace function is_valid_svnr(p_svnr text, p_birth_date date default null)
returns boolean
language plpgsql
immutable
as $$
declare
v text := regexp_replace(coalesce(p_svnr, ''), '[\s./-]', '', 'g');
v_weights int[] := array[3, 7, 9, 0, 5, 8, 4, 2, 1, 6];
v_days_in_month int[] := array[31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
v_sum int := 0;
v_day int;
v_month int;
v_check int;
begin
if v !~ '^\d{10}$' then return false; end if;
-- 000 is never issued as a serial.
if substr(v, 1, 3) = '000' then return false; end if;
v_day := substr(v, 5, 2)::int;
v_month := substr(v, 7, 2)::int;
if v_month < 1 or v_month > 12 or v_day < 1 then return false; end if;
-- February is allowed 29 days: a two-digit year cannot tell us whether the
-- year was a leap year, so the generous bound is the correct one here.
if v_day > v_days_in_month[v_month] then return false; end if;
for i in 1..10 loop
if i <> 4 then
v_sum := v_sum + substr(v, i, 1)::int * v_weights[i];
end if;
end loop;
v_check := v_sum % 11;
-- A serial whose weighted sum lands on 11 leaves no single digit to use,
-- so that serial is skipped rather than wrapped around.
if v_check = 10 then return false; end if;
if v_check <> substr(v, 4, 1)::int then return false; end if;
if p_birth_date is not null and to_char(p_birth_date, 'DDMMYY') <> substr(v, 5, 6) then
return false;
end if;
return true;
end;
$$;
comment on function is_valid_svnr(text, date) is
'Prüft eine österreichische SV-Nummer (10 Ziffern, Prüfziffer mod 11). Mit p_birth_date wird zusätzlich der TTMMJJ-Teil gegen das Geburtsdatum geprüft.';
create or replace function fn_validate_employee_svnr()
returns trigger
language plpgsql
as $$
declare
v_country text;
begin
if new.sv_nummer is null or btrim(new.sv_nummer) = '' then
return new;
end if;
-- Only a *newly written* value is checked. Rows that predate this
-- migration keep whatever they hold, so an unrelated edit — a transfer, a
-- promotion, an address change — is never blocked by a legacy value the
-- user is not touching.
if tg_op = 'UPDATE' and new.sv_nummer is not distinct from old.sv_nummer then
return new;
end if;
select country into v_country from locations where id = new.location_id;
if v_country is distinct from 'Österreich' then
return new;
end if;
if not is_valid_svnr(new.sv_nummer, new.birth_date) then
raise exception 'Ungültige SV-Nummer: %. Erwartet werden 10 Ziffern (laufende Nummer, Prüfziffer, TTMMJJ) mit gültiger Prüfziffer und dem Geburtsdatum des/der Mitarbeiter:in.', new.sv_nummer
using errcode = '23514';
end if;
return new;
end;
$$;
drop trigger if exists trg_validate_employee_svnr on employees;
create trigger trg_validate_employee_svnr
before insert or update on employees
for each row execute function fn_validate_employee_svnr();

View File

@@ -0,0 +1,266 @@
-- Long-term absence gets a type.
--
-- "Karenz" was being used as the name for every kind of extended absence,
-- but the cases behave differently in payroll and reporting: Wochenhilfe,
-- Präsenz-/Zivildienst, a long sick leave and a sabbatical are not the same
-- thing. The UI now calls the concept "Langzeitabwesenheit" and asks which
-- kind it is.
--
-- Deliberately NOT renaming the 'Karenz' enum value in employment_status or
-- history_event_type. Postgres can rename an enum value in place, but every
-- stored function body that spells 'Karenz' would then reference a value
-- that no longer exists — that is a dozen functions across fifteen
-- migrations, all of them rewritten for a label change. The name is a
-- presentation concern, so it is mapped to "Langzeitabwesenheit" in one
-- place in the UI (lib/absence.ts) and the stored value stays put.
alter table employees add column if not exists absence_type text;
alter table employees drop constraint if exists chk_absence_type;
alter table employees add constraint chk_absence_type check (
absence_type is null or absence_type in (
'Wochenhilfe (Mutterschutz)',
'Elternkarenz (inkl. Väterkarenz)',
'Papamonat',
'Bildungskarenz',
'Bildungsteilzeit',
'Präsenzdienst',
'Zivildienst',
'Langer Krankenstand',
'Wiedereingliederungsteilzeit',
'Pflegekarenz',
'Pflegeteilzeit',
'Familienhospizkarenz',
'Sabbatical'
)
);
comment on column employees.absence_type is
'Art der laufenden Langzeitabwesenheit; null, wenn keine besteht. Wird bei der Rückkehr geleert.';
-- Existing absences predate the field and their kind was never recorded, so
-- they stay null rather than being guessed at. The UI shows them as
-- "Langzeitabwesenheit" without a type until someone sets one.
-- ── start_karenz ───────────────────────────────────────────────────
-- Unchanged apart from carrying absence_type through both paths: written
-- straight away when the absence has already begun, or parked in the
-- pending_org_changes payload when it starts later.
create or replace function start_karenz(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_start_date date := (payload->>'karenz_start_date')::date;
v_absence_type text := nullif(payload->>'absence_type', '');
v_name text;
begin
perform require_hr_admin();
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
if v_start_date <= current_date then
update employees set status = 'Karenz', karenz_start_date = v_start_date,
karenz_return_date = (payload->>'planned_return_date')::date,
absence_type = v_absence_type
where id = v_employee_id;
else
update employees set karenz_start_date = v_start_date where id = v_employee_id;
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'karenz_start', v_start_date,
jsonb_build_object('planned_return_date', payload->>'planned_return_date', 'absence_type', v_absence_type));
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_start_date, 'Karenz',
coalesce(v_absence_type, 'Langzeitabwesenheit') || ', geplante Rückkehr am ' || (payload->>'planned_return_date') ||
case when payload->>'note' is not null and payload->>'note' <> '' then ' — ' || (payload->>'note') else '' end);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Karenz', v_name, v_employee_id,
coalesce(v_absence_type, 'Langzeitabwesenheit') || ', geplante Rückkehr ' || (payload->>'planned_return_date'));
end;
$$;
-- ── record_karenz_return ───────────────────────────────────────────
-- Clears absence_type alongside the dates: the absence is over, so leaving
-- its kind behind would make a returned employee look like they were still
-- on one.
create or replace function record_karenz_return(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_return_date date := (payload->>'return_date')::date;
v_name text;
v_team_id uuid;
v_division_id uuid;
v_is_lead boolean;
v_manager uuid;
v_employment_type employment_type;
v_weekly_hours numeric;
v_karenz_start date;
v_absence_type text;
begin
perform require_hr_admin();
select first_name || ' ' || last_name, team_id, division_id, is_lead, karenz_start_date, absence_type
into v_name, v_team_id, v_division_id, v_is_lead, v_karenz_start, v_absence_type
from employees where id = v_employee_id;
if v_karenz_start is not null and v_return_date <= v_karenz_start then
raise exception 'Das Rückkehrdatum muss nach dem Beginn der Langzeitabwesenheit (%) liegen.', v_karenz_start;
end if;
if payload->>'employment_mode' = 'Vollzeit' then
v_employment_type := 'Vollzeit'; v_weekly_hours := 38.5;
elsif payload->>'employment_mode' = 'Teilzeit' then
v_employment_type := 'Teilzeit'; v_weekly_hours := (payload->>'weekly_hours')::numeric;
end if;
if v_return_date <= current_date then
v_manager := resolve_manager_for(v_team_id, v_is_lead, v_division_id);
update employees set
status = 'Aktiv',
karenz_return_date = null,
karenz_start_date = null,
absence_type = null,
manager_id = v_manager,
employment_type = coalesce(v_employment_type, employment_type),
weekly_hours = coalesce(v_weekly_hours, weekly_hours)
where id = v_employee_id;
else
update employees set karenz_return_date = v_return_date where id = v_employee_id;
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'karenz_return', v_return_date,
jsonb_build_object('employment_type', v_employment_type, 'weekly_hours', v_weekly_hours));
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_return_date, 'Rückkehr',
'Rückkehr aus ' || coalesce(v_absence_type, 'Langzeitabwesenheit') || ' am ' || (payload->>'return_date'));
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Rückkehr', v_name, v_employee_id, 'Rückkehr am ' || (payload->>'return_date'));
end;
$$;
-- ── apply_due_pending_changes ──────────────────────────────────────
-- Only the two karenz branches change; the rest of the body is carried over
-- unchanged from 20260718160000_dependents_effective_dating.sql.
create or replace function apply_due_pending_changes()
returns int language plpgsql security definer set search_path = public as $$
declare
v_rec record;
v_count int := 0;
v_manager uuid;
v_team_id uuid;
v_division_id uuid;
v_is_lead boolean;
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
select is_lead into v_is_lead from employees where id = v_rec.employee_id;
select division_id into v_division_id from teams t
join departments d on d.id = t.department_id
where t.id = (v_rec.payload->>'new_team_id')::uuid;
v_manager := resolve_manager_for((v_rec.payload->>'new_team_id')::uuid, v_is_lead, v_division_id);
update employees set
team_id = (v_rec.payload->>'new_team_id')::uuid,
manager_id = v_manager,
job_title = coalesce(v_rec.payload->>'new_title', job_title)
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;
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
select team_id, division_id, is_lead into v_team_id, v_division_id, v_is_lead
from employees where id = v_rec.employee_id;
v_manager := resolve_manager_for(v_team_id, v_is_lead, v_division_id);
update employees set
status = 'Aktiv',
karenz_return_date = null,
karenz_start_date = null,
absence_type = null,
manager_id = v_manager,
employment_type = coalesce((v_rec.payload->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_rec.payload->>'weekly_hours')::numeric, weekly_hours)
where id = v_rec.employee_id;
elsif v_rec.change_type = 'contract_change' then
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 = coalesce(v_rec.payload->'person'->>'sv_nummer', sv_nummer),
nationality = coalesce(v_rec.payload->'person'->>'nationality', nationality),
address = coalesce(v_rec.payload->'person'->>'address', address),
postal_code = coalesce(v_rec.payload->'person'->>'postal_code', postal_code),
city = coalesce(v_rec.payload->'person'->>'city', city),
address_country = coalesce(v_rec.payload->'person'->>'address_country', address_country),
email = coalesce(v_rec.payload->'person'->>'email', email),
phone = coalesce(v_rec.payload->'person'->>'phone', phone),
title_prefix = coalesce(
case when v_rec.payload->'person' ? 'title_prefix'
then array(select jsonb_array_elements_text(v_rec.payload->'person'->'title_prefix')) end, title_prefix),
title_suffix = coalesce(
case when v_rec.payload->'person' ? 'title_suffix'
then array(select jsonb_array_elements_text(v_rec.payload->'person'->'title_suffix')) end, title_suffix),
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_rec.payload->'role'->>'worker_type')::worker_type, worker_type),
collective_agreement = coalesce((v_rec.payload->'role'->>'collective_agreement')::collective_agreement, collective_agreement),
work_days = coalesce(
case when v_rec.payload->'role' ? 'work_days'
then array(select jsonb_array_elements_text(v_rec.payload->'role'->'work_days'))::weekday[] end, work_days),
is_betriebsrat = coalesce((v_rec.payload->'role'->>'is_betriebsrat')::boolean, is_betriebsrat),
has_dienstwagen = coalesce((v_rec.payload->'role'->>'has_dienstwagen')::boolean, has_dienstwagen),
is_laterale_fuehrung = coalesce((v_rec.payload->'role'->>'is_laterale_fuehrung')::boolean, is_laterale_fuehrung),
is_c_level = coalesce((v_rec.payload->'role'->>'is_c_level')::boolean, is_c_level)
where id = v_rec.employee_id;
elsif v_rec.change_type = 'reorg' then
select is_lead into v_is_lead from employees where id = v_rec.employee_id;
select division_id into v_division_id from teams t
join departments d on d.id = t.department_id
where t.id = (v_rec.payload->>'target_team_id')::uuid;
v_manager := resolve_manager_for((v_rec.payload->>'target_team_id')::uuid, v_is_lead, v_division_id);
update employees set team_id = (v_rec.payload->>'target_team_id')::uuid, manager_id = v_manager
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')::relationship_type,
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;
$$;

View File

@@ -0,0 +1,123 @@
-- Organisationsmanagement nach SAP-OM-Vorbild.
--
-- Bisher: drei feste Tabellen (divisions -> departments -> teams) und
-- Personen, die direkt daran hängen (employees.division_id/team_id) mit
-- einer frei gepflegten manager_id. Damit ist die Hierarchie in ihrer Tiefe
-- fest verdrahtet — eine Abteilungsleitung liess sich nicht abbilden, ohne
-- das Schema zu ändern, und ein Team direkt unter einem Bereich gar nicht.
--
-- SAP OM löst das über wenige Objekttypen und Verknüpfungen dazwischen:
--
-- O Organisationseinheit org_units (rekursiv über parent_id)
-- C Stelle / Job jobs (Katalog)
-- S Planstelle positions (gehört zu genau einer O)
-- P Person employees (besetzt eine S)
--
-- A003 "gehört zu" positions.org_unit_id
-- A012 "ist Leiter von" positions.is_chief
-- A008 "Inhaber ist" position_assignments
--
-- Die Berichtslinie wird daraus abgeleitet statt gepflegt, siehe die
-- folgende Migration. Ebenen sind nur noch ein Etikett (unit_type), keine
-- Struktur — eine fünfte Ebene ist damit eine Datenfrage, keine Migration.
-- ── O: Organisationseinheit ────────────────────────────────────────
create type org_unit_type as enum ('Gesellschaft', 'Bereich', 'Abteilung', 'Team');
create table org_units (
id uuid primary key default gen_random_uuid(),
org_number text not null unique,
name text not null,
-- Die Hierarchie selbst. Null nur für die Wurzel.
parent_id uuid references org_units(id),
-- Nur Beschriftung und Nummernkreis-Konvention; die Struktur steckt in
-- parent_id. Eine Abteilung unter einer Abteilung wäre technisch möglich
-- und ist bewusst nicht verboten.
unit_type org_unit_type not null,
valid_from date not null default current_date,
valid_to date,
created_at timestamptz not null default now(),
constraint chk_org_unit_range check (valid_to is null or valid_to > valid_from),
constraint chk_org_unit_not_own_parent check (parent_id is null or parent_id <> id)
);
create index on org_units (parent_id);
create index on org_units (unit_type);
-- Genau eine Wurzel: ohne das kann ein Fehlgriff beim Import einen zweiten
-- Baum aufmachen, und die Ableitung der Berichtslinie liefe ins Leere.
create unique index org_units_single_root on org_units ((parent_id is null)) where parent_id is null;
comment on table org_units is 'SAP-OM-Objekttyp O. Rekursiv über parent_id; unit_type ist nur ein Etikett.';
-- ── C: Stelle / Job-Katalog ────────────────────────────────────────
-- Trennt die Tätigkeitsbeschreibung von der einzelnen Planstelle: viele
-- Planstellen teilen sich einen Job. Bisher war job_title Freitext je
-- Person, weshalb "Schlosser:in" und "Schlosser" nebeneinander existieren
-- konnten und keine Auswertung über Tätigkeiten möglich war.
create table jobs (
id uuid primary key default gen_random_uuid(),
code text not null unique,
title text not null unique,
created_at timestamptz not null default now()
);
comment on table jobs is 'SAP-OM-Objekttyp C. Katalog der Tätigkeiten; Planstellen verweisen darauf.';
-- ── S: Planstelle ──────────────────────────────────────────────────
-- Anders als die bisherige positions-Tabelle, die nur *offene* Stellen
-- führte: hier bekommt jede Person eine Planstelle. Eine offene Stelle ist
-- schlicht eine Planstelle ohne laufende Besetzung — Vakanz ist damit eine
-- Eigenschaft der Planstelle, kein eigenes Objekt.
create table om_positions (
id uuid primary key default gen_random_uuid(),
position_number text not null unique,
org_unit_id uuid not null references org_units(id),
job_id uuid not null references jobs(id),
-- A012 "ist Leiter von": diese Planstelle führt ihre Organisationseinheit.
is_chief boolean not null default false,
valid_from date not null default current_date,
valid_to date,
created_at timestamptz not null default now(),
constraint chk_om_position_range check (valid_to is null or valid_to > valid_from)
);
create index on om_positions (org_unit_id);
create index on om_positions (job_id);
-- Höchstens eine Leitungsplanstelle je Einheit, solange sie gültig ist.
create unique index om_positions_one_chief on om_positions (org_unit_id) where is_chief and valid_to is null;
comment on table om_positions is 'SAP-OM-Objekttyp S. is_chief entspricht der Verknüpfung A012 "ist Leiter von".';
-- ── A008: Person besetzt Planstelle ────────────────────────────────
create table position_assignments (
id uuid primary key default gen_random_uuid(),
position_id uuid not null references om_positions(id) on delete cascade,
employee_id uuid not null references employees(id) on delete cascade,
valid_from date not null,
valid_to date,
created_at timestamptz not null default now(),
constraint chk_assignment_range check (valid_to is null or valid_to > valid_from)
);
create index on position_assignments (position_id);
create index on position_assignments (employee_id);
-- Eine Planstelle ist zu einem Zeitpunkt von höchstens einer Person besetzt,
-- und eine Person hat höchstens eine laufende Planstelle. Beides sind die
-- Invarianten, auf die sich die Ableitung der Berichtslinie stützt.
create unique index position_assignments_one_holder on position_assignments (position_id) where valid_to is null;
create unique index position_assignments_one_position on position_assignments (employee_id) where valid_to is null;
comment on table position_assignments is 'SAP-OM-Verknüpfung A008 "Inhaber ist", zeitabhängig.';
-- ── RLS, wie bei allen anderen Tabellen ────────────────────────────
alter table org_units enable row level security;
alter table jobs enable row level security;
alter table om_positions enable row level security;
alter table position_assignments enable row level security;
create policy "org_units_hr_all" on org_units for all using (is_hr_user()) with check (is_hr_user());
create policy "jobs_hr_all" on jobs for all using (is_hr_user()) with check (is_hr_user());
create policy "om_positions_hr_all" on om_positions for all using (is_hr_user()) with check (is_hr_user());
create policy "position_assignments_hr_all" on position_assignments for all using (is_hr_user()) with check (is_hr_user());
grant all on table org_units, jobs, om_positions, position_assignments to anon, authenticated, service_role;

View File

@@ -0,0 +1,101 @@
-- Die Berichtslinie wird abgeleitet, nicht gepflegt.
--
-- Bisher stand sie als employees.manager_id in der Tabelle und wurde von
-- resolve_manager_for() bei jeder Mutation neu geraten. Damit konnte sie von
-- der Organisationsstruktur abweichen, und tat es auch.
--
-- SAP-OM-Regel, hier eins zu eins:
--
-- Wer eine gewöhnliche Planstelle innehat, berichtet an die Leitung der
-- eigenen Organisationseinheit. Wer selbst die Leitung innehat, berichtet
-- an die Leitung der übergeordneten Einheit.
--
-- Dazu kommt die Aufwärtsregel: Ist diese Leitungsplanstelle unbesetzt oder
-- ihre Inhaberin langzeitabwesend, geht es weiter nach oben, bis eine
-- besetzte und anwesende Leitung gefunden ist. Genau deshalb braucht eine
-- unbesetzte Abteilungsleitung keine Sonderbehandlung — sie wird schlicht
-- übersprungen.
--
-- Beides wird zurückgegeben: die formale Leitung (auch wenn abwesend) und
-- die tatsächliche. Nur so lässt sich in der Oberfläche zeigen, dass eine
-- Vertretung im Spiel ist, statt sie stillschweigend als die echte
-- Führungskraft auszugeben.
create or replace function om_reporting_lines(p_as_of date default current_date)
returns table (
employee_id uuid,
position_id uuid,
org_unit_id uuid,
is_chief boolean,
formal_manager_id uuid,
acting_manager_id uuid
)
language sql
stable
as $$
with recursive
-- Laufende Besetzungen: Planstelle und Zuordnung müssen beide am Stichtag
-- gültig sein.
holder as (
select pa.employee_id, pa.position_id, p.org_unit_id, p.is_chief
from position_assignments pa
join om_positions p on p.id = pa.position_id
where pa.valid_from <= p_as_of and (pa.valid_to is null or pa.valid_to > p_as_of)
and p.valid_from <= p_as_of and (p.valid_to is null or p.valid_to > p_as_of)
),
-- Leitung je Einheit, samt Abwesenheit am Stichtag. Die Ableitung ist
-- dieselbe wie in deriveStatusAsOf() auf der Anwendungsseite.
chief as (
select h.org_unit_id, h.employee_id,
(e.karenz_start_date is not null
and e.karenz_start_date <= p_as_of
and (e.karenz_return_date is null or p_as_of < e.karenz_return_date)) as absent
from holder h
join employees e on e.id = h.employee_id
where h.is_chief
),
-- Vorfahrenkette je Einheit; Tiefe 0 ist die Einheit selbst. Bei rund
-- sechzig Einheiten ist das billig, und es macht die Suche nach der
-- nächsten geeigneten Leitung zu einem einfachen "erster Treffer".
ancestry as (
select u.id as unit_id, u.id as ancestor_id, u.parent_id, 0 as depth
from org_units u
union all
select a.unit_id, p.id, p.parent_id, a.depth + 1
from ancestry a
join org_units p on p.id = a.parent_id
),
-- Die Einheit, ab der gesucht wird: für eine Leitung die übergeordnete,
-- sonst die eigene.
base as (
select h.employee_id, h.position_id, h.org_unit_id, h.is_chief,
case when h.is_chief then u.parent_id else h.org_unit_id end as base_unit_id
from holder h
join org_units u on u.id = h.org_unit_id
)
select
b.employee_id,
b.position_id,
b.org_unit_id,
b.is_chief,
-- Formale Leitung: die der Ausgangseinheit, unabhängig von Abwesenheit.
(select c.employee_id from chief c where c.org_unit_id = b.base_unit_id) as formal_manager_id,
-- Tatsächliche Leitung: die nächste besetzte und anwesende oberhalb,
-- die Ausgangseinheit eingeschlossen.
(
select c.employee_id
from ancestry a
join chief c on c.org_unit_id = a.ancestor_id
where a.unit_id = b.base_unit_id
and not c.absent
and c.employee_id <> b.employee_id
order by a.depth
limit 1
) as acting_manager_id
from base b;
$$;
comment on function om_reporting_lines(date) is
'Leitet die Berichtslinie zum Stichtag aus dem Organisationsbaum ab. formal_manager_id ist die zuständige Leitung, acting_manager_id die nächste besetzte und anwesende darüber.';
grant execute on function om_reporting_lines(date) to anon, authenticated, service_role;

View File

@@ -0,0 +1,539 @@
-- Umstieg auf das SAP-OM-Modell: Altbestand überführen, Altmodell entfernen.
--
-- Läuft nach 20260727120000 (Tabellen) und 20260727120100 (Berichtslinie).
--
-- Warum ein Schnitt und keine schrittweise Migration: Sobald eine
-- Abteilungsleitung besetzt ist, liefert das alte resolve_manager_for()
-- falsche Ergebnisse. Es sucht die Bereichsleitung über
-- "division_id = X and team_id is null and org_level = 1" — eine
-- Abteilungsleitung erfüllt dieselbe Bedingung, und das LIMIT 1 greift dann
-- willkürlich eine von beiden.
--
-- Der Bestand wird überführt, nicht gelöscht. Direkt nach dem Ausführen ist
-- das Organigramm gefüllt.
--
-- Die Abteilungsleitungen entstehen als *unbesetzte* Planstellen: es gibt
-- niemanden, der sie innehat, und erfundene Zuordnungen wären schlechter als
-- eine sichtbare Lücke. Die Aufwärtsregel überspringt sie, bis sie besetzt
-- sind — die Berichtslinie bleibt durchgängig.
begin;
-- ═══ 1. Organisationseinheiten ═══════════════════════════════════
-- Wurzel. Die bisherige Pseudo-Division "Geschäftsführung" wird sie, damit
-- die Personen, die daran hingen, ihre Einheit behalten.
insert into org_units (id, org_number, name, parent_id, unit_type, valid_from)
select id, '10000000', 'Alpenwerk Industrie GmbH', null, 'Gesellschaft', '2000-01-01'
from divisions where name = 'Geschäftsführung';
-- Falls es sie nicht gab, eine neue Wurzel anlegen.
insert into org_units (org_number, name, parent_id, unit_type, valid_from)
select '10000000', 'Alpenwerk Industrie GmbH', null, 'Gesellschaft', '2000-01-01'
where not exists (select 1 from org_units where unit_type = 'Gesellschaft');
insert into org_units (id, org_number, name, parent_id, unit_type, valid_from)
select d.id, d.org_number, d.name,
(select id from org_units where unit_type = 'Gesellschaft'),
'Bereich', '2000-01-01'
from divisions d
where d.name <> 'Geschäftsführung';
insert into org_units (id, org_number, name, parent_id, unit_type, valid_from)
select dep.id, dep.org_number, dep.name,
coalesce((select u.id from org_units u where u.id = dep.division_id),
(select id from org_units where unit_type = 'Gesellschaft')),
'Abteilung', '2000-01-01'
from departments dep;
insert into org_units (id, org_number, name, parent_id, unit_type, valid_from)
select t.id, t.org_number, t.name, t.department_id, 'Team', '2000-01-01'
from teams t;
-- ═══ 2. Job-Katalog ══════════════════════════════════════════════
-- Vollständig *vor* den Planstellen, die darauf verweisen. Enthält auch die
-- Titel der neuen Abteilungsleitungen.
insert into jobs (code, title)
select 'J' || lpad(row_number() over (order by title)::text, 4, '0'), title
from (
select distinct job_title as title from employees where job_title is not null
union
select distinct title from positions where title is not null
union
select distinct 'Abteilungsleitung ' || name from org_units where unit_type = 'Abteilung'
) t
on conflict (title) do nothing;
-- ═══ 3. Planstellen und Besetzungen ══════════════════════════════
-- Die Zuordnung Person -> Planstelle wird einmal festgelegt und dann von
-- beiden Inserts benutzt. Zwei unabhängig berechnete Fensterfunktionen
-- wären hier die klassische Fehlerquelle: sie sehen gleich aus und ordnen
-- doch verschieden.
-- Kein "on commit drop": im SQL-Editor hängt es vom Transaktionsverhalten
-- ab, wann das greift, und eine zu früh verschwundene Zuordnungstabelle
-- wäre schwer zu diagnostizieren. Wird am Ende explizit entfernt.
create temporary table om_pos_map (
employee_id uuid primary key,
position_id uuid not null default gen_random_uuid(),
seq bigint
);
insert into om_pos_map (employee_id, seq)
select id, row_number() over (order by org_level, personnel_number) from employees;
insert into om_positions (id, position_number, org_unit_id, job_id, is_chief, valid_from)
select
m.position_id,
'6' || lpad(m.seq::text, 7, '0'),
case
when e.org_level = 0 then (select id from org_units where unit_type = 'Gesellschaft')
when e.org_level = 1 then coalesce(e.division_id, (select id from org_units where unit_type = 'Gesellschaft'))
else coalesce(e.team_id, (select id from org_units where unit_type = 'Gesellschaft'))
end,
(select j.id from jobs j where j.title = e.job_title),
(e.org_level <= 1 or (e.org_level = 2 and e.is_lead)),
e.entry_date
from employees e
join om_pos_map m on m.employee_id = e.id;
-- Wer ausgetreten ist, hat eine beendete Besetzung: die Planstelle ist
-- wieder frei, die Historie bleibt.
--
-- greatest(): employees erlaubt exit_date = entry_date, die Prüfregel auf
-- position_assignments verlangt aber valid_to > valid_from. Ein
-- gleichtägiger Ein- und Austritt würde die Migration sonst abbrechen.
insert into position_assignments (position_id, employee_id, valid_from, valid_to)
select m.position_id, e.id, e.entry_date,
case when e.exit_date is null then null else greatest(e.exit_date, e.entry_date + 1) end
from employees e
join om_pos_map m on m.employee_id = e.id;
-- Unbesetzte Abteilungsleitungen — die Ebene, die im Altmodell fehlte.
insert into om_positions (position_number, org_unit_id, job_id, is_chief, valid_from)
select '69' || lpad(row_number() over (order by u.org_number)::text, 6, '0'),
u.id,
(select id from jobs where title = 'Abteilungsleitung ' || u.name),
true,
current_date
from org_units u
where u.unit_type = 'Abteilung'
and not exists (select 1 from om_positions p where p.org_unit_id = u.id and p.is_chief);
-- Bisher offene Stellen werden unbesetzte Planstellen. is_chief nur, wenn
-- die Einheit noch keine Leitung hat — der Unique-Index liesse es sonst
-- ohnehin nicht zu.
insert into om_positions (position_number, org_unit_id, job_id, is_chief, valid_from)
select p.position_number, p.team_id,
(select id from jobs j where j.title = p.title),
p.is_lead and not exists (select 1 from om_positions o where o.org_unit_id = p.team_id and o.is_chief),
p.valid_from
from positions p
where p.status = 'open'
and exists (select 1 from org_units u where u.id = p.team_id);
-- Abbruch, bevor das Altmodell fällt: lieber eine gescheiterte Migration
-- als ein halb überführter Bestand ohne Rückweg.
do $$
declare v_fehlend int;
begin
select count(*) into v_fehlend
from employees e
where not exists (select 1 from position_assignments pa where pa.employee_id = e.id);
if v_fehlend > 0 then
raise exception 'Abbruch: % Mitarbeitende ohne Planstelle.', v_fehlend;
end if;
select count(*) into v_fehlend from om_positions where job_id is null;
if v_fehlend > 0 then
raise exception 'Abbruch: % Planstellen ohne Job.', v_fehlend;
end if;
select count(*) into v_fehlend
from org_units u
where u.parent_id is null and u.unit_type <> 'Gesellschaft';
if v_fehlend > 0 then
raise exception 'Abbruch: % Einheiten ohne Elternteil.', v_fehlend;
end if;
end $$;
drop table om_pos_map;
-- ═══ 4. Altmodell entfernen ══════════════════════════════════════
-- Vorgemerkte Änderungen verweisen über team_id auf das Altmodell. Sie sind
-- transient; halb übersetzt wären sie schlimmer als verworfen.
delete from pending_org_changes where status = 'pending';
drop trigger if exists trg_track_employee_assignment on employees;
drop function if exists fn_track_employee_assignment();
drop table if exists employee_assignments;
-- Leitet division_id aus team_id ab und haengt damit an einer Spalte, die
-- gleich faellt. Im neuen Modell uebernimmt die Einheit der Planstelle
-- diese Rolle, der Trigger wird ersatzlos entfernt.
drop trigger if exists trg_employees_set_org_unit on employees;
drop function if exists fn_set_employee_org_unit();
-- Die View selektiert team_id/division_id/manager_id/org_level/is_lead und
-- blockiert damit das Entfernen dieser Spalten. Sie wird von der Anwendung
-- nirgends benutzt und ersatzlos entfernt; die Ableitung über
-- om_reporting_lines() tritt an ihre Stelle.
drop view if exists employees_directory;
-- Funktionen auf den Alt-Spalten. Die weiterhin benötigten werden in
-- Abschnitt 5 neu angelegt; Reorganisation und Ausschreibung folgen mit der
-- Umstellung der Oberfläche.
drop function if exists resolve_manager_for(uuid, boolean, uuid);
drop function if exists staff_position_internally(jsonb);
drop function if exists create_position(jsonb);
drop function if exists delete_position(uuid);
drop function if exists apply_reorg(jsonb);
drop function if exists undo_reorg(uuid);
drop function if exists hire_employee(jsonb);
drop function if exists terminate_employee(jsonb);
drop function if exists transfer_employee(jsonb);
drop function if exists rehire_employee(jsonb);
drop function if exists record_karenz_return(jsonb);
drop function if exists apply_due_pending_changes();
alter table employees
drop column if exists division_id,
drop column if exists team_id,
drop column if exists manager_id,
drop column if exists org_level,
drop column if exists is_lead;
drop table if exists positions;
drop table if exists teams;
drop table if exists departments;
drop table if exists divisions;
-- Mit der positions-Tabelle verschwindet ihr Trigger, nicht aber dessen
-- Funktion und die Nummernvergabe, die nur als Spaltenvorgabe dort benutzt
-- wurde. om_positions vergibt seine Nummern selbst.
drop function if exists fn_set_position_org_unit();
drop function if exists generate_position_number();
-- ═══ 5. Mutationen im neuen Modell ═══════════════════════════════
-- Die Berichtslinie wird nicht mehr mitgeschrieben, sondern abgeleitet.
-- Das entfernt aus jeder dieser Funktionen die Manager-Nachführung — beim
-- Austritt etwa entfällt das Umhängen der direkten Berichte vollständig,
-- weil sie ohnehin auf die nächste besetzte Ebene hochrutschen.
create or replace function hire_employee(payload jsonb)
returns uuid language plpgsql as $$
declare
v_id uuid;
v_position_id uuid := (payload->>'position_id')::uuid;
v_entry date := (payload->>'entry_date')::date;
v_besetzt uuid;
begin
perform require_hr_admin();
if v_position_id is null then
raise exception 'Es muss eine Planstelle angegeben werden.';
end if;
select pa.employee_id into v_besetzt
from position_assignments pa
where pa.position_id = v_position_id and pa.valid_to is null;
if v_besetzt is not null then
raise exception 'Diese Planstelle ist bereits besetzt.';
end if;
insert into employees (
first_name, last_name, gender, birth_date, sv_nummer, nationality, email, 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, collective_agreement, work_days,
is_betriebsrat, has_dienstwagen, is_laterale_fuehrung, is_c_level
)
values (
payload->>'first_name', payload->>'last_name', (payload->>'gender')::gender_type,
(payload->>'birth_date')::date, payload->>'sv_nummer',
coalesce(payload->>'nationality', 'Österreich'), payload->>'email', 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((payload->>'collective_agreement')::collective_agreement, 'Süßwaren'),
coalesce(array(select jsonb_array_elements_text(payload->'work_days'))::weekday[], '{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)
)
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 (auth.uid(), current_actor_name(), 'Neueinstellung',
payload->>'first_name' || ' ' || payload->>'last_name', v_id, 'Eintritt am ' || v_entry);
return v_id;
end;
$$;
create or replace function terminate_employee(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_exit date := (payload->>'exit_date')::date;
v_name text;
begin
perform require_hr_admin();
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
update employees set
status = case when v_exit <= current_date then 'Ausgetreten' else status end,
exit_date = v_exit,
exit_reason = payload->>'exit_reason'
where id = v_employee_id;
-- Die Planstelle wird frei. Direkte Berichte müssen nicht umgehängt
-- werden: die Berichtslinie wird abgeleitet und rutscht von selbst auf
-- die nächste besetzte Ebene.
update position_assignments set valid_to = v_exit
where employee_id = v_employee_id and valid_to is null;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_exit, 'Austritt', 'Austritt (' || coalesce(payload->>'exit_reason', '-') || ')');
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Austritt', v_name, v_employee_id, 'Austritt am ' || v_exit);
end;
$$;
-- Versetzung ist im OM-Modell ein Wechsel der Planstelle: die alte
-- Besetzung endet, die neue beginnt. Bereich, Abteilung und Team ergeben
-- sich aus der Einheit der Zielplanstelle und werden nicht mehr mitgeführt.
create or replace function transfer_employee(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_target_position uuid := (payload->>'target_position_id')::uuid;
v_effective date := coalesce(nullif(payload->>'effective_date','')::date, current_date);
v_name text;
v_besetzt uuid;
begin
perform require_hr_admin();
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
select pa.employee_id into v_besetzt
from position_assignments pa
where pa.position_id = v_target_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;
if v_effective <= current_date then
update position_assignments set valid_to = v_effective
where employee_id = v_employee_id and valid_to is null;
insert into position_assignments (position_id, employee_id, valid_from)
values (v_target_position, v_employee_id, v_effective);
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_target_position)
where id = v_employee_id;
else
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'transfer', v_effective,
jsonb_build_object('target_position_id', v_target_position));
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_effective, 'Versetzung', 'Versetzung auf Planstelle ' ||
(select position_number from om_positions where id = v_target_position));
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Versetzung', v_name, v_employee_id, 'Wirksam ab ' || v_effective);
end;
$$;
create or replace function rehire_employee(payload jsonb)
returns void language plpgsql as $$
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;
begin
perform require_hr_admin();
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
if v_position_id is null then
raise exception 'Für die Wiedereinstellung muss eine Planstelle angegeben werden.';
end if;
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
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);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Wiedereinstellung', v_name, v_employee_id, 'Wiedereintritt am ' || v_date);
end;
$$;
create or replace function record_karenz_return(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_return_date date := (payload->>'return_date')::date;
v_name text;
v_employment_type employment_type;
v_weekly_hours numeric;
v_karenz_start date;
v_absence_type text;
begin
perform require_hr_admin();
select first_name || ' ' || last_name, karenz_start_date, absence_type
into v_name, v_karenz_start, v_absence_type
from employees where id = v_employee_id;
if v_karenz_start is not null and v_return_date <= v_karenz_start then
raise exception 'Das Rückkehrdatum muss nach dem Beginn der Langzeitabwesenheit (%) liegen.', v_karenz_start;
end if;
if payload->>'employment_mode' = 'Vollzeit' then
v_employment_type := 'Vollzeit'; v_weekly_hours := 38.5;
elsif payload->>'employment_mode' = 'Teilzeit' then
v_employment_type := 'Teilzeit'; v_weekly_hours := (payload->>'weekly_hours')::numeric;
end if;
if v_return_date <= current_date then
-- Keine Manager-Nachführung mehr nötig: wer aus der Abwesenheit
-- zurückkehrt, ist wieder anwesend, und die abgeleitete Berichtslinie
-- fällt automatisch von der Vertretung auf ihn zurück.
update employees set
status = 'Aktiv',
karenz_return_date = null,
karenz_start_date = null,
absence_type = null,
employment_type = coalesce(v_employment_type, employment_type),
weekly_hours = coalesce(v_weekly_hours, weekly_hours)
where id = v_employee_id;
else
update employees set karenz_return_date = v_return_date where id = v_employee_id;
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'karenz_return', v_return_date,
jsonb_build_object('employment_type', v_employment_type, 'weekly_hours', v_weekly_hours));
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_return_date, 'Rückkehr',
'Rückkehr aus ' || coalesce(v_absence_type, 'Langzeitabwesenheit') || ' am ' || v_return_date);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Rückkehr', v_name, v_employee_id, 'Rückkehr am ' || v_return_date);
end;
$$;
create or replace function apply_due_pending_changes()
returns int language plpgsql security definer set search_path = public as $$
declare
v_rec record;
v_count int := 0;
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
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;
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)
where id = v_rec.employee_id;
elsif v_rec.change_type = 'contract_change' then
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 = coalesce(v_rec.payload->'person'->>'sv_nummer', sv_nummer),
nationality = coalesce(v_rec.payload->'person'->>'nationality', nationality),
address = coalesce(v_rec.payload->'person'->>'address', address),
postal_code = coalesce(v_rec.payload->'person'->>'postal_code', postal_code),
city = coalesce(v_rec.payload->'person'->>'city', city),
address_country = coalesce(v_rec.payload->'person'->>'address_country', address_country),
email = coalesce(v_rec.payload->'person'->>'email', email),
phone = coalesce(v_rec.payload->'person'->>'phone', phone),
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)
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')::relationship_type,
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;
$$;
commit;

View File

@@ -0,0 +1,150 @@
-- Reste des Altmodells entfernen und die Planstellenpflege im OM-Modell
-- nachziehen.
--
-- Die Cut-over-Migration hat die Funktionen des Altmodells mit ihren damals
-- bekannten Signaturen entfernt. Ein Teil davon existierte zusätzlich in
-- einer jsonb-Variante und ist deshalb stehen geblieben — sichtbar daran,
-- dass delete_position und undo_reorg weiterhin in der PostgREST-Schnittstelle
-- auftauchen, obwohl die Tabellen, auf denen sie arbeiten, weg sind. Ein
-- Aufruf würde erst zur Laufzeit scheitern.
-- ═══ 1. Übriggebliebene Funktionen des Altmodells ════════════════
drop function if exists create_position(jsonb);
drop function if exists delete_position(jsonb);
drop function if exists delete_position(uuid);
drop function if exists staff_position_internally(jsonb);
drop function if exists apply_reorg(jsonb);
drop function if exists undo_reorg(jsonb);
drop function if exists undo_reorg(uuid);
-- ═══ 2. Reorganisations-Werkbank ═════════════════════════════════
-- Sie hat Teams und Abteilungen zwischen Bereichen verschoben — Objekte, die
-- es nicht mehr gibt. Im OM-Modell ist eine Reorganisation das Umhängen von
-- org_units.parent_id und braucht kein eigenes Szenario-Modell mehr.
alter table employee_history drop column if exists reorg_scenario_id;
alter table pending_org_changes drop column if exists reorg_scenario_id;
drop table if exists reorg_moves;
drop table if exists reorg_scenarios;
-- ═══ 3. Planstellen pflegen ══════════════════════════════════════
-- Die alte positions-Tabelle führte nur *offene* Stellen und war damit ein
-- eigenes Objekt neben der Person. Im OM-Modell hat jede Person eine
-- Planstelle, und eine offene Stelle ist schlicht eine unbesetzte. Anlegen
-- und Schliessen sind deshalb Operationen auf om_positions.
-- set search_path bei jeder Funktion: siehe die folgende Migration, dort steht
-- warum. Kurz: heute sind das INVOKER-Funktionen und der Pfad ist harmlos,
-- aber sobald eine davon einmal SECURITY DEFINER wird, wäre er es nicht mehr —
-- und daran denkt dann niemand.
create or replace function next_position_number()
returns text language sql stable
set search_path = public, pg_temp
as $$
select '6' || lpad((coalesce(max(substring(position_number from 2)::bigint), 0) + 1)::text, 7, '0')
from om_positions
where position_number ~ '^6[0-9]{7}$';
$$;
comment on function next_position_number() is
'Nächste freie Planstellennummer im Nummernkreis 6xxxxxxx.';
create or replace function create_position(payload jsonb)
returns uuid language plpgsql
set search_path = public, pg_temp
as $$
declare
v_org_unit_id uuid := (payload->>'org_unit_id')::uuid;
v_job_title text := nullif(trim(payload->>'job_title'), '');
v_is_chief boolean := coalesce((payload->>'is_chief')::boolean, false);
v_valid_from date := coalesce(nullif(payload->>'valid_from','')::date, current_date);
v_job_id uuid;
v_position_id uuid;
v_unit_name text;
begin
perform require_hr_admin();
select name into v_unit_name from org_units where id = v_org_unit_id;
if v_unit_name is null then
raise exception 'Die Organisationseinheit existiert nicht.';
end if;
if v_job_title is null then
raise exception 'Es muss eine Tätigkeit angegeben werden.';
end if;
-- Der Unique-Index würde das ebenfalls abfangen, aber mit einer Meldung,
-- die in der Oberfläche nichts erklärt.
if v_is_chief and exists (
select 1 from om_positions
where org_unit_id = v_org_unit_id and is_chief and valid_to is null
) then
raise exception 'Für % besteht bereits eine Leitungsplanstelle.', v_unit_name;
end if;
-- Gleiche Tätigkeit, ein Katalogeintrag: sonst stehen "Schlosser:in" und
-- "Schlosser" nebeneinander und jede Auswertung nach Tätigkeit ist wertlos.
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;
insert into om_positions (position_number, org_unit_id, job_id, is_chief, valid_from)
values (next_position_number(), v_org_unit_id, v_job_id, v_is_chief, v_valid_from)
returning id into v_position_id;
insert into audit_log (actor_user_id, actor_name, action, target_label, details)
values (auth.uid(), current_actor_name(), 'Planstelle angelegt',
v_job_title || ' (' || v_unit_name || ')',
'Gültig ab ' || v_valid_from || case when v_is_chief then ', Leitung' else '' end);
return v_position_id;
end;
$$;
create or replace function delete_position(payload jsonb)
returns void language plpgsql
set search_path = public, pg_temp
as $$
declare
v_position_id uuid := (payload->>'position_id')::uuid;
v_label text;
v_hat_historie boolean;
begin
perform require_hr_admin();
select j.title || ' (' || u.name || ')' into v_label
from om_positions p
join jobs j on j.id = p.job_id
join org_units u on u.id = p.org_unit_id
where p.id = v_position_id;
if v_label is null then
raise exception 'Die Planstelle existiert nicht.';
end if;
if exists (select 1 from position_assignments where position_id = v_position_id and valid_to is null) then
raise exception 'Die Planstelle ist besetzt und kann nicht entfernt werden.';
end if;
select exists (select 1 from position_assignments where position_id = v_position_id)
into v_hat_historie;
-- Eine Planstelle, auf der einmal jemand sass, wird geschlossen statt
-- gelöscht: sonst verschwindet mit ihr die Besetzungshistorie, und in der
-- Personalakte klafft eine Lücke.
if v_hat_historie then
update om_positions set valid_to = current_date where id = v_position_id;
else
delete from om_positions where id = v_position_id;
end if;
insert into audit_log (actor_user_id, actor_name, action, target_label, details)
values (auth.uid(), current_actor_name(),
case when v_hat_historie then 'Planstelle geschlossen' else 'Planstelle gelöscht' end,
v_label, null);
end;
$$;
grant execute on function next_position_number() to anon, authenticated, service_role;
grant execute on function create_position(jsonb) to anon, authenticated, service_role;
grant execute on function delete_position(jsonb) to anon, authenticated, service_role;

View File

@@ -0,0 +1,77 @@
-- search_path für alle eigenen Funktionen festnageln.
--
-- Der Supabase-Linter meldet 34 Funktionen mit „Function Search Path Mutable".
-- Nachgezählt sind das alles SECURITY-INVOKER-Funktionen; die vier
-- SECURITY-DEFINER-Funktionen (is_hr_user, current_hr_user_id,
-- apply_due_pending_changes, fn_track_employee_assignment) setzen den Pfad
-- längst. Deshalb steht im Advisor auch 0 errors.
--
-- Warum das trotzdem behoben wird:
--
-- Der Angriff braucht SECURITY DEFINER. Wer in einem Schema, das im
-- search_path früher liegt, eine eigene Tabelle `employees` anlegt, bringt
-- eine unqualifiziert schreibende Funktion dazu, auf die untergeschobene
-- zuzugreifen — mit den Rechten der Eigentümerin der Funktion. Bei INVOKER
-- läuft alles mit den Rechten der aufrufenden Person, es gibt also nichts zu
-- gewinnen, und die RLS-Policies greifen unverändert.
--
-- Zur Lücke wird die Warnung erst, wenn eine dieser Funktionen später auf
-- SECURITY DEFINER umgestellt wird, etwa weil eine Mutation an RLS vorbei
-- schreiben muss. In dem Moment denkt niemand mehr an den search_path.
-- Einmal festnageln räumt die Falle weg und ändert kein Verhalten.
--
-- `pg_temp` steht ausdrücklich am Ende: ohne die Angabe durchsucht Postgres
-- das temporäre Schema *zuerst*, und dort darf jede Sitzung anlegen, was sie
-- will.
do $$
declare
v_func record;
v_count int := 0;
begin
for v_func in
select p.oid::regprocedure as signature
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public'
-- Nur Funktionen, keine Prozeduren oder Aggregate.
and p.prokind = 'f'
-- Erweiterungen gehören uns nicht: pg_trgm legt show_trgm und show_limit
-- in public ab. Daran zu drehen bricht bei der nächsten Aktualisierung
-- der Erweiterung oder wird stillschweigend zurückgesetzt.
and not exists (
select 1 from pg_depend d where d.objid = p.oid and d.deptype = 'e'
)
-- Bereits gesetzte nicht anfassen: die vier DEFINER-Funktionen stehen
-- auf `search_path = public` und sollen so bleiben.
and not exists (
select 1 from unnest(coalesce(p.proconfig, '{}')) c where c like 'search_path=%'
)
loop
execute format('alter function %s set search_path = public, pg_temp', v_func.signature);
v_count := v_count + 1;
end loop;
raise notice 'search_path festgenagelt für % Funktion(en)', v_count;
end;
$$;
-- Gegenprobe: danach darf in public keine eigene Funktion ohne search_path
-- mehr stehen. Schlägt das an, hat die Schleife oben etwas übersehen — besser
-- hier, als es im Advisor stehen zu lassen.
do $$
declare v_offen int;
begin
select count(*) into v_offen
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public'
and p.prokind = 'f'
and not exists (select 1 from pg_depend d where d.objid = p.oid and d.deptype = 'e')
and not exists (select 1 from unnest(coalesce(p.proconfig, '{}')) c where c like 'search_path=%');
if v_offen > 0 then
raise exception 'Es stehen noch % Funktion(en) ohne search_path in public.', v_offen;
end if;
end;
$$;

View File

@@ -0,0 +1,72 @@
-- Ausführungsrechte auf den SECURITY-DEFINER-Funktionen zurechtrücken.
--
-- Der Advisor meldet alle vier als „Public Can Execute" und „Signed-In Users
-- Can Execute". Das ist nicht bei allen vieren dasselbe Problem — nachgemessen
-- mit dem anon-Schlüssel gegen die laufende Datenbank:
--
-- anon.rpc(is_hr_user) -> false
-- anon.rpc(current_hr_user_id) -> null
-- anon.rpc(apply_due_pending_changes) -> 0 ← das ist der Befund
--
-- Nur der dritte ist einer.
-- ── Bleibt offen, und zwar mit Absicht ───────────────────────────
--
-- is_hr_user() und current_hr_user_id() werden *aus den RLS-Policies heraus*
-- aufgerufen. Ein Policy-Ausdruck wird mit den Rechten der abfragenden Rolle
-- ausgewertet; ohne EXECUTE für anon und authenticated scheitert damit jede
-- Abfrage auf jeder Tabelle mit „permission denied for function". Der Entzug
-- würde die Anwendung vollständig lahmlegen.
--
-- Preisgegeben wird dabei nichts: beide nehmen keine Argumente und beantworten
-- ausschliesslich eine Frage über die aufrufende Person selbst. Wer nicht
-- angemeldet ist, bekommt false beziehungsweise null — siehe Messung oben.
-- ── Wird entzogen ────────────────────────────────────────────────
--
-- apply_due_pending_changes() wendet vorgemerkte Versetzungen, Beförderungen
-- und Abwesenheiten an, sobald ihr Datum erreicht ist. Es ist SECURITY
-- DEFINER, umgeht also RLS, und war bis hierher ohne Anmeldung aufrufbar — der
-- anon-Schlüssel steht im ausgelieferten Browser-Bündel.
--
-- Der Schaden wäre begrenzt, weil nur ohnehin fällige Änderungen angewandt
-- werden. Aber es ist ein Schreibpfad, den Fremde auslösen können, und er
-- macht das Geheimnis der Cron-Route (app/api/cron/apply-pending-changes)
-- wirkungslos.
--
-- Diese Route ist der einzige Aufrufer und benutzt createAdminClient(), also
-- die service_role — der Entzug für anon und authenticated bricht sie nicht.
revoke execute on function apply_due_pending_changes() from anon, authenticated;
-- rls_auto_enable() stammt nicht aus diesen Migrationen und wird von der
-- Anwendung nirgends aufgerufen. Was sie tut, ist von hier aus nicht
-- feststellbar; eine Funktion, die RLS umschaltet und ohne Anmeldung
-- aufrufbar ist, wäre allerdings ernst. Der Entzug ist risikolos, weil kein
-- Aufrufer existiert — und falls doch jemand sie braucht, meldet er sich mit
-- einer klaren Fehlermeldung statt still etwas zu verstellen.
do $$
begin
if exists (
select 1 from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'rls_auto_enable'
) then
execute 'revoke execute on function public.rls_auto_enable() from anon, authenticated';
end if;
end;
$$;
-- ── Was bewusst *nicht* passiert ─────────────────────────────────
--
-- „Extension in Public" (pg_trgm) bleibt stehen. Die Erweiterung trägt die
-- Operatorklasse gin_trgm_ops, auf der zwei GIN-Indizes auf employees liegen
-- (20260714120400_performance_indexes.sql). Ein Schemawechsel müsste die
-- Indizes und jeden search_path mitziehen, der sie erreichen soll — gerade
-- jetzt, wo jede Funktion auf `public, pg_temp` festgenagelt ist. Das ist
-- Aufwand und Risiko für einen Hinweis, der keine Rechteausweitung beschreibt,
-- sondern eine Konvention.
--
-- „Leaked Password Protection Disabled" ist gegenstandslos: die
-- Passwort-Anmeldung ist abgeschaltet. Eine Anmeldung mit E-Mail und Passwort
-- gegen die API antwortet mit `email_provider_disabled` (422). Es gibt kein
-- Passwort, dessen Kompromittierung geprüft werden könnte.

View File

@@ -0,0 +1,191 @@
-- Schritt 1 auf dem Weg weg von Supabase: eigene Benutzertabelle und ein
-- eigener Sitzungskontext.
--
-- Ziel ist ein Schema, das auf jedem PostgreSQL ab 15 läuft — Azure Flexible
-- Server, RDS, Cloud SQL, eigenes Blech. Heute hängt genau eine Sache an
-- Supabase: `auth.uid()`, die Kennung der angemeldeten Person. Sie steckt in
-- 72 Zeilen SQL, aber für die Absicherung zählt nur eine Stelle —
-- is_hr_user(), das alle 58 RLS-Policies aufrufen.
--
-- Diese Migration ist bewusst **additiv und beidseitig lauffähig**: die
-- Anwendung läuft danach unverändert auf Supabase weiter, während die neue
-- Zugriffsschicht daneben entsteht. Ein Umbau, der beide Enden gleichzeitig
-- bewegt, lässt sich nicht testen.
-- ═══ 1. Sitzungskontext ══════════════════════════════════════════
-- Wer gerade angemeldet ist, kommt künftig aus einer Sitzungsvariablen, die
-- die Zugriffsschicht **transaktionslokal** setzt (siehe lib/db).
--
-- Warum plpgsql und nicht `language sql`: eine SQL-Funktion wird beim Anlegen
-- geparst, und `auth.uid()` existiert auf einem gewöhnlichen PostgreSQL
-- nicht — die Funktion liesse sich dort gar nicht erst erzeugen. plpgsql löst
-- den Aufruf erst zur Laufzeit auf, und der Ausnahmeblock fängt die fehlende
-- Funktion ab. Genau das macht diese Migration auf beiden Systemen anwendbar.
create or replace function app_current_user_id()
returns uuid
language plpgsql
stable
security definer
set search_path = public, pg_temp
as $$
declare
v_id uuid;
begin
-- Vorrang hat der eigene Kontext. `true` als zweites Argument heisst:
-- fehlt die Variable, kommt null statt eines Fehlers.
v_id := nullif(current_setting('app.user_id', true), '')::uuid;
if v_id is not null then
return v_id;
end if;
-- Übergangsweise: solange die Anmeldung noch über GoTrue läuft. Fällt in
-- der Abschlussmigration weg, zusammen mit den Fremdschlüsseln auf
-- auth.users.
begin
execute 'select auth.uid()' into v_id;
exception
when undefined_function or invalid_schema_name or undefined_table then
v_id := null;
end;
return v_id;
end;
$$;
comment on function app_current_user_id() is
'Kennung der angemeldeten Person: erst app.user_id aus der Sitzung, ersatzweise auth.uid(). Der zweite Zweig ist Übergang.';
-- Zugeteilt wird nur an Rollen, die es auch gibt. Auf einem gewöhnlichen
-- PostgreSQL existieren anon/authenticated/service_role nicht, und ein
-- `grant` auf eine unbekannte Rolle bricht die Migration ab — dieselbe Datei
-- liefe dort also nicht. Genau das soll sie aber.
do $$
declare r text;
begin
foreach r in array array['anon', 'authenticated', 'service_role'] loop
if exists (select 1 from pg_roles where rolname = r) then
execute format('grant execute on function app_current_user_id() to %I', r);
end if;
end loop;
end;
$$;
-- ═══ 2. Benutzertabelle ══════════════════════════════════════════
-- Tritt an die Stelle von auth.users. Die neun Fremdschlüssel, die heute
-- dorthin zeigen, wandern in der Abschlussmigration hierher.
create table if not exists app_users (
id uuid primary key default gen_random_uuid(),
-- Die `oid` aus dem Entra-Token. Unveränderlich, anders als die E-Mail:
-- eine Namensänderung darf nicht zu einem neuen Konto führen.
external_id text not null unique,
email text not null,
full_name text,
created_at timestamptz not null default now(),
last_seen_at timestamptz
);
comment on table app_users is
'Ersetzt auth.users. external_id ist die oid des Identitätsanbieters, nicht die E-Mail.';
create index if not exists app_users_email_idx on app_users (lower(email));
alter table app_users enable row level security;
-- Sich selbst sehen darf jede:r Angemeldete; alles andere ist HR-Sache.
-- Ohne diese Policy käme die Anmeldung nicht an die eigene Zeile.
drop policy if exists "app_users_select_own" on app_users;
create policy "app_users_select_own" on app_users
for select using (id = app_current_user_id() or is_hr_user());
do $$
begin
if exists (select 1 from pg_roles where rolname = 'anon') then
execute 'grant select on table app_users to anon';
end if;
if exists (select 1 from pg_roles where rolname = 'authenticated') then
execute 'grant select on table app_users to authenticated';
end if;
if exists (select 1 from pg_roles where rolname = 'service_role') then
execute 'grant all on table app_users to service_role';
end if;
end;
$$;
-- ═══ 3. Die eine Brücke umlegen ══════════════════════════════════
-- Ab hier fragt die Absicherung nicht mehr Supabase, sondern den eigenen
-- Kontext. Die 58 Policies bleiben Wort für Wort unverändert — sie rufen
-- weiterhin is_hr_user() auf und merken davon nichts.
create or replace function is_hr_user()
returns boolean
language sql
security definer
set search_path = public, pg_temp
stable
as $$
select exists (
select 1 from profiles p
where p.id = app_current_user_id() and p.role = 'hr' and p.is_active = true
);
$$;
create or replace function current_hr_user_id()
returns uuid
language sql
security definer
set search_path = public, pg_temp
stable
as $$
select p.id from profiles p
where p.id = app_current_user_id() and p.role = 'hr' and p.is_active = true;
$$;
create or replace function current_actor_name()
returns text
language sql
stable
set search_path = public, pg_temp
as $$
select coalesce(p.full_name, p.email, 'Unbekannt')
from profiles p where p.id = app_current_user_id();
$$;
-- ═══ 4. Die fünf Policies mit direktem auth.uid() ════════════════
-- Die übrigen 53 laufen über is_hr_user() und brauchen nichts.
drop policy if exists "profiles_select_own" on profiles;
create policy "profiles_select_own" on profiles
for select using (app_current_user_id() = id);
-- Die Namen stammen aus 20260714120000_hr_only_access.sql: „_owner", nicht
-- „_own". Mit dem falschen Namen bricht die Migration bei create policy ab.
drop policy if exists "hire_drafts_owner" on hire_drafts;
create policy "hire_drafts_owner" on hire_drafts
for all
using (created_by = app_current_user_id() and is_hr_user())
with check (created_by = app_current_user_id() and is_hr_user());
drop policy if exists "saved_reports_owner" on saved_reports;
create policy "saved_reports_owner" on saved_reports
for all
using (created_by = app_current_user_id() and is_hr_user())
with check (created_by = app_current_user_id() and is_hr_user());
-- ═══ 5. Gegenprobe ═══════════════════════════════════════════════
-- Ohne Kontext und ohne Anmeldung darf is_hr_user() nicht wahr sein. Das
-- klingt selbstverständlich und ist genau der Fehler, der eine ganze
-- Datenbank öffnet.
do $$
begin
perform set_config('app.user_id', '', true);
if is_hr_user() then
raise exception 'is_hr_user() liefert ohne Sitzungskontext true — Abbruch.';
end if;
perform set_config('app.user_id', gen_random_uuid()::text, true);
if is_hr_user() then
raise exception 'is_hr_user() liefert für eine unbekannte Kennung true — Abbruch.';
end if;
-- Aufräumen: die Einstellung gilt bis zum Ende dieser Transaktion, und
-- was danach in derselben Sitzung läuft, soll sie nicht erben.
perform set_config('app.user_id', '', true);
end;
$$;

View File

@@ -0,0 +1,117 @@
-- Schritt 2: die Anmeldung braucht einen Weg, ihre Zeile in app_users
-- anzulegen — bevor es einen Sitzungskontext gibt.
--
-- Das ist das Henne-Ei-Problem jeder eigenen Anmeldung: app.user_id kann erst
-- gesetzt werden, wenn die Kennung feststeht, und die entsteht genau hier.
-- Bisher löste ein Dienstschlüssel mit BYPASSRLS solche Fälle. Den gibt es
-- nicht mehr, und er soll auch nicht zurückkommen — eine Verbindung, die
-- alles darf, ist für einen einzigen Schreibvorgang ein zu grosser Hebel.
--
-- Stattdessen: eine SECURITY-DEFINER-Funktion mit genau einer Befugnis.
-- Sie schreibt in app_users und liest lesend in profiles — sonst nichts. Wer
-- sie aufruft, bekommt eine UUID zurück und sonst keine Auskunft.
create or replace function app_upsert_user(
p_external_id text,
p_email text,
p_full_name text
)
returns uuid
language plpgsql
security definer
set search_path = public, pg_temp
as $$
declare
v_id uuid;
begin
if p_external_id is null or btrim(p_external_id) = '' then
raise exception 'Externe Kennung fehlt.';
end if;
if p_email is null or btrim(p_email) = '' then
raise exception 'E-Mail-Adresse fehlt.';
end if;
-- Bekanntes Konto: nur nachziehen, was sich beim Anbieter geändert haben
-- kann. Die Kennung bleibt, auch wenn Name oder Adresse wechseln — daran
-- hängen Notizen, Entwürfe und Protokolleinträge.
update app_users
set email = p_email,
full_name = coalesce(p_full_name, full_name),
last_seen_at = now()
where external_id = p_external_id
returning id into v_id;
if v_id is not null then
return v_id;
end if;
-- Erstanmeldung. Gibt es zu dieser Adresse bereits ein Profil, wird dessen
-- Kennung übernommen statt einer neuen: profiles.id ist heute die
-- auth.users.id, und neun Fremdschlüssel zeigen darauf. Eine frisch
-- vergebene UUID würde die Person von ihrer eigenen Vorgeschichte trennen
-- — sie wäre angemeldet, hätte aber weder Rolle noch Freischaltung.
--
-- Der Abgleich über die Adresse ist hier vertretbar und sonst nirgends:
-- die Adresse kommt aus einem von Entra ausgestellten Token, nicht aus
-- einem Formular. Wer sie behauptet, hat sie bereits bewiesen.
select p.id into v_id
from profiles p
where lower(p.email) = lower(p_email)
limit 1;
v_id := coalesce(v_id, gen_random_uuid());
begin
insert into app_users (id, external_id, email, full_name, last_seen_at)
values (v_id, p_external_id, p_email, p_full_name, now());
exception
when unique_violation then
-- Zwei gleichzeitige Erstanmeldungen desselben Kontos. Die zweite
-- findet die Zeile, die die erste gerade angelegt hat.
select id into v_id from app_users where external_id = p_external_id;
if v_id is null then
raise;
end if;
end;
return v_id;
end;
$$;
comment on function app_upsert_user(text, text, text) is
'Legt die app_users-Zeile zur Erstanmeldung an und liefert die Kennung. Übernimmt bei bekannter E-Mail die vorhandene profiles.id.';
-- app_users trägt RLS und hat bewusst keine Schreib-Policy: die Anwendung
-- kommt an die Tabelle nur durch diese Funktion. Ein Fehler im Anwendungscode
-- kann dort also nichts anlegen, ändern oder löschen.
do $$
declare r text;
begin
foreach r in array array['anon', 'authenticated', 'service_role'] loop
if exists (select 1 from pg_roles where rolname = r) then
execute format('grant execute on function app_upsert_user(text, text, text) to %I', r);
end if;
end loop;
end;
$$;
-- ═══ Gegenprobe ══════════════════════════════════════════════════
-- Zweimal dieselbe externe Kennung muss dieselbe UUID ergeben. Wäre es nicht
-- so, bekäme jede Anmeldung ein neues Konto und niemand behielte seine
-- Rolle — ein Fehler, der sich erst Wochen später als „meine Notizen sind
-- weg" zeigt.
do $$
declare
v_first uuid;
v_second uuid;
v_probe text := 'probe-' || gen_random_uuid()::text;
begin
v_first := app_upsert_user(v_probe, v_probe || '@example.invalid', 'Probe');
v_second := app_upsert_user(v_probe, v_probe || '@example.invalid', 'Probe');
if v_first is distinct from v_second then
raise exception 'app_upsert_user() vergibt bei zweiter Anmeldung eine neue Kennung — Abbruch.';
end if;
delete from app_users where id = v_first;
end;
$$;

View File

@@ -0,0 +1,42 @@
-- Die Anwendungsrolle muss die Personalnummern-Sequenz fortschreiben dürfen.
--
-- Hintergrund: employees.personnel_number ist GENERATED ALWAYS AS IDENTITY.
-- Der Massenimport übernimmt die Nummern aus der Quelldatei — sie stehen auf
-- Lohnzetteln, in Akten und auf Ausweisen, ein Import darf sie nicht neu
-- vergeben — und schreibt danach den Zähler auf das neue Maximum.
--
-- Ohne diesen Schritt vergäbe die Datenbank bei der nächsten Neueinstellung
-- eine Nummer, die der Import bereits verbraucht hat. Der eindeutige Index
-- weist sie ab, und zwar erst Wochen später beim ersten Eintritt nach der
-- Übernahme — weit weg von der Ursache.
--
-- `setval()` verlangt UPDATE auf der Sequenz. `usage, select` reicht nicht;
-- genau daran ist der erste Durchstich gescheitert.
do $$
declare
v_sequenz text := pg_get_serial_sequence('public.employees', 'personnel_number');
r text;
begin
if v_sequenz is null then
raise exception 'Sequenz zu employees.personnel_number nicht gefunden.';
end if;
-- Nur an Rollen, die es gibt: dieselbe Datei soll auf einem gewöhnlichen
-- PostgreSQL ohne die Supabase-Rollen laufen.
foreach r in array array['alpenwerk_app', 'authenticated', 'service_role'] loop
if exists (select 1 from pg_roles where rolname = r) then
execute format('grant usage, select, update on sequence %s to %I', v_sequenz, r);
end if;
end loop;
end;
$$;
-- Damit künftige Sequenzen in diesem Schema dieselben Rechte bekommen und
-- der nächste Import nicht an derselben Stelle stehenbleibt.
do $$
begin
if exists (select 1 from pg_roles where rolname = 'alpenwerk_app') then
execute 'alter default privileges in schema public grant usage, select, update on sequences to alpenwerk_app';
end if;
end;
$$;

View File

@@ -0,0 +1,193 @@
-- Das Protokoll soll sagen, *was* sich geändert hat, nicht nur *welches Feld*.
--
-- Bisher stand im Audit-Log „Adresse, wirksam ab 30.07.2026". Damit lässt
-- sich nicht nachvollziehen, was vorher dort stand — und genau das ist die
-- Frage, die man einem Personalprotokoll stellt.
--
-- Die Werte sind im Moment der Änderung beide vorhanden: v_old trägt den
-- alten Datensatz, die Nutzlast den neuen. Sie wurden nur nicht behalten.
--
-- **Rückwirkend geht das nicht.** Für die bestehenden Einträge wurde
-- Vorher/Nachher nie erfasst; sie bleiben, wie sie sind.
alter table audit_log add column if not exists changes jsonb;
comment on column audit_log.changes is
'Feldweise Änderungen als [{feld, vorher, nachher}]. Null bei Einträgen von vor dieser Migration.';
-- ═══ Hilfsfunktion ═══════════════════════════════════════════════
-- Hängt eine Änderung an, wenn sich der Wert wirklich unterscheidet.
--
-- Verglichen wird über coalesce auf Leerstring: null und '' sind in diesem
-- Schema beide „nicht gesetzt", und ein Wechsel zwischen beiden ist keine
-- Änderung, die jemanden interessiert.
create or replace function app_aenderung(p_liste jsonb, p_feld text, p_vorher text, p_nachher text)
returns jsonb
language sql
immutable
set search_path = public, pg_temp
as $$
select case
when coalesce(p_vorher, '') = coalesce(p_nachher, '') then p_liste
else p_liste || jsonb_build_object('feld', p_feld, 'vorher', p_vorher, 'nachher', p_nachher)
end;
$$;
-- Die Feldnamen aus einer Änderungsliste — für die Kurzfassung in `details`,
-- damit bestehende Ansichten unverändert weiterlaufen.
create or replace function app_aenderungsfelder(p_liste jsonb)
returns text
language sql
immutable
set search_path = public, pg_temp
as $$
select string_agg(x->>'feld', ', ') from jsonb_array_elements(coalesce(p_liste, '[]'::jsonb)) x;
$$;
-- ═══ Stammdaten- und Vertragsänderung ════════════════════════════
create or replace function change_employee_data(payload jsonb)
returns void
language plpgsql
set search_path = 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[];
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 ? '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 ? '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_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 = coalesce(v_person->>'sv_nummer', sv_nummer),
nationality = coalesce(v_person->>'nationality', nationality),
address = coalesce(v_person->>'address', address),
postal_code = coalesce(v_person->>'postal_code', postal_code),
city = coalesce(v_person->>'city', city),
address_country = coalesce(v_person->>'address_country', address_country),
email = coalesce(v_person->>'email', email),
phone = coalesce(v_person->>'phone', phone),
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),
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)
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);
end if;
if jsonb_array_length(v_person_changes) > 0 then
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_effective_date, 'Stammdatenänderung',
'Geänderte Felder: ' || app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date);
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)
values (v_employee_id, v_effective_date, 'Vertragsänderung',
'Geänderte Felder: ' || app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date);
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;
end;
$function$;
-- ═══ Gegenprobe ══════════════════════════════════════════════════
-- Die Hilfsfunktion muss Gleiches übergehen und Ungleiches behalten —
-- inklusive des Falls null gegen Leerstring, der sonst als Änderung im
-- Protokoll landet und niemandem etwas sagt.
do $$
declare v jsonb;
begin
v := app_aenderung('[]'::jsonb, 'Ort', 'Wien', 'Wien');
if jsonb_array_length(v) <> 0 then raise exception 'app_aenderung() meldet eine Änderung, wo keine ist.'; end if;
v := app_aenderung('[]'::jsonb, 'Ort', null, '');
if jsonb_array_length(v) <> 0 then raise exception 'app_aenderung() wertet null gegen Leerstring als Änderung.'; end if;
v := app_aenderung('[]'::jsonb, 'Ort', 'Wien', 'Graz');
if jsonb_array_length(v) <> 1 or v->0->>'vorher' <> 'Wien' or v->0->>'nachher' <> 'Graz' then
raise exception 'app_aenderung() hält Vorher/Nachher nicht fest.';
end if;
if app_aenderungsfelder(v) <> 'Ort' then raise exception 'app_aenderungsfelder() liefert die Feldnamen nicht.'; end if;
end;
$$;

View File

@@ -0,0 +1,84 @@
-- Zwei Funktionen casten auf Typen, die es nicht mehr gibt.
--
-- hire_employee ::weekday[]
-- apply_due_pending_changes ::relationship_type
--
-- Beide Typen waren einmal Aufzählungen und wurden später durch `text` mit
-- einer CHECK-Bedingung ersetzt (chk_work_days_valid). Die Funktionen wurden
-- dabei nicht nachgezogen.
--
-- PL/pgSQL löst Typen in eingebetteten SQL-Anweisungen erst beim **Ausführen**
-- auf. Die Funktionen liessen sich deshalb anlegen und scheitern erst im
-- Betrieb — jede Neueinstellung mit „type weekday[] does not exist", und der
-- nächtliche Lauf, sobald eine fällige Angehörigen-Änderung darin vorkommt.
-- Genau deshalb hat es niemand beim Einspielen gemerkt.
--
-- Ersetzt wird gezielt der Cast an der **aktuellen** Definition, statt beide
-- Funktionen abzuschreiben: 165 Zeilen fremden Code neu zu tippen, um zwei
-- Wörter zu ändern, ist die grössere Fehlerquelle.
create or replace function app_cast_ersetzen(p_funktion text, p_alt text, p_neu text)
returns void
language plpgsql
set search_path = public, pg_temp
as $$
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 = p_funktion
limit 1;
if v_def is null then
raise exception 'Funktion %() nicht gefunden.', p_funktion;
end if;
if position(p_alt in v_def) = 0 then
raise notice '%(): % kommt nicht (mehr) vor — nichts zu tun.', p_funktion, p_alt;
return;
end if;
execute replace(v_def, p_alt, p_neu);
raise notice '%(): % -> %', p_funktion, p_alt, p_neu;
end;
$$;
select app_cast_ersetzen('hire_employee', '::weekday[]', '::text[]');
select app_cast_ersetzen('apply_due_pending_changes', '::relationship_type', '::text');
-- Das Werkzeug wird nicht aufbewahrt: eine Funktion, die beliebigen Text in
-- eine Funktionsdefinition schreibt und ausführt, soll nicht dauerhaft im
-- Schema stehen.
drop function app_cast_ersetzen(text, text, text);
-- ═══ Gegenprobe ══════════════════════════════════════════════════
-- Keine Funktion im Schema darf mehr auf einen Typ casten, den es nicht
-- gibt. Das prüft nicht nur die zwei bekannten Stellen, sondern schliesst
-- aus, dass beim Ersetzen eine dritte übersehen wurde.
do $$
declare
r record;
v_treffer text;
begin
for r in
select p.proname, pg_get_functiondef(p.oid) as def
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.prokind = 'f'
loop
for v_treffer in
select m[1] from regexp_matches(r.def, '::\s*([a-z_][a-z0-9_]*)\s*(?:\[\])?', 'gi') m
loop
if not exists (select 1 from pg_type where typname = lower(v_treffer))
and lower(v_treffer) not in (
'text', 'int', 'integer', 'boolean', 'bool', 'date', 'uuid', 'jsonb', 'json',
'numeric', 'timestamptz', 'varchar', 'bigint', 'smallint', 'real', 'interval', 'time'
) then
raise exception 'Funktion %() castet auf unbekannten Typ „%".', r.proname, v_treffer;
end if;
end loop;
end loop;
end;
$$;

View File

@@ -0,0 +1,113 @@
-- auth.uid() aus den Geschäftsfunktionen entfernen.
--
-- Die Anwendung verbindet sich als eigene Rolle ohne BYPASSRLS. Diese Rolle
-- hat kein Recht auf das Schema `auth` — und jede Funktion, die dort etwas
-- aufruft, scheitert mit „permission denied for schema auth".
--
-- Das trifft **jede schreibende Aktion**: Einstellen, Versetzen, Befördern,
-- Austritt, Notizen, Planstellen. Aufgefallen ist es beim Nachstellen einer
-- Neueinstellung; zuvor lief alles über eine Rolle, die das Schema sehen
-- durfte.
--
-- app_current_user_id() liefert dasselbe und funktioniert auf jedem
-- PostgreSQL — es liest den transaktionslokalen Sitzungskontext, den lib/db
-- setzt.
create or replace function app_uid_ersetzen(p_funktion text)
returns void
language plpgsql
set search_path = public, pg_temp
as $$
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 = p_funktion
limit 1;
if v_def is null then
raise exception 'Funktion %() nicht gefunden.', p_funktion;
end if;
if position('auth.uid()' in v_def) = 0 then
raise notice '%(): bereits umgestellt.', p_funktion;
return;
end if;
execute replace(v_def, 'auth.uid()', 'app_current_user_id()');
raise notice '%(): umgestellt.', p_funktion;
end;
$$;
select app_uid_ersetzen('add_employee_dependent');
select app_uid_ersetzen('add_employee_note');
select app_uid_ersetzen('adjust_karenz_return');
select app_uid_ersetzen('complete_employee_note');
select app_uid_ersetzen('create_position');
select app_uid_ersetzen('delete_employee_dependent');
select app_uid_ersetzen('delete_position');
select app_uid_ersetzen('hire_employee');
select app_uid_ersetzen('promote_employee');
select app_uid_ersetzen('record_karenz_return');
select app_uid_ersetzen('rehire_employee');
select app_uid_ersetzen('start_karenz');
select app_uid_ersetzen('terminate_employee');
select app_uid_ersetzen('transfer_employee');
drop function app_uid_ersetzen(text);
-- ═══ Den Rückfall selbst absichern ═══════════════════════════════
-- app_current_user_id() behält seinen Übergangszweig auf auth.uid(), fängt
-- aber bisher nur „Funktion fehlt". Für eine Rolle ohne Recht auf das Schema
-- kommt stattdessen insufficient_privilege — und die Funktion warf, statt
-- null zu liefern. Das fiel nicht auf, weil der Zweig nur ohne
-- Sitzungskontext erreicht wird; genau dann soll aber „niemand angemeldet"
-- herauskommen und kein Fehler.
create or replace function app_current_user_id()
returns uuid
language plpgsql
stable
security definer
set search_path = public, pg_temp
as $$
declare
v_id uuid;
begin
v_id := nullif(current_setting('app.user_id', true), '')::uuid;
if v_id is not null then
return v_id;
end if;
begin
execute 'select auth.uid()' into v_id;
exception
when undefined_function or invalid_schema_name or undefined_table or insufficient_privilege then
v_id := null;
end;
return v_id;
end;
$$;
-- ═══ Gegenprobe ══════════════════════════════════════════════════
do $$
declare r record;
begin
for r in
select p.proname
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.prokind = 'f'
and p.proname <> 'app_current_user_id'
and pg_get_functiondef(p.oid) like '%auth.uid()%'
loop
raise exception 'Funktion %() ruft weiterhin auth.uid() auf.', r.proname;
end loop;
-- Ohne Kontext muss die Kennung null sein und darf nicht werfen.
perform set_config('app.user_id', '', true);
if app_current_user_id() is not null then
raise exception 'app_current_user_id() liefert ohne Kontext eine Kennung.';
end if;
end;
$$;

View File

@@ -0,0 +1,66 @@
-- Klammern in hire_employee.
--
-- Der Protokolleintrag baute den Namen so:
--
-- payload->>'first_name' || ' ' || payload->>'last_name'
--
-- In PostgreSQL bindet `||` **stärker** als `->>`. Gelesen wird also
--
-- payload ->> ('first_name' || ' ' || payload) ->> 'last_name'
--
-- und das endet in „operator does not exist: text ->> unknown". Jede
-- Neueinstellung scheiterte daran.
--
-- Warum es niemandem auffiel: davor stand in derselben Anweisung ein Aufruf
-- von auth.uid(). Die Rechteprüfung auf das Schema `auth` schlug schon
-- während der Analyse fehl, und der Parser kam nie bis zu diesem Ausdruck.
-- Ein Fehler hat den anderen verdeckt — beide mussten weg, damit eine
-- Einstellung durchläuft.
do $$
declare
v_def text;
v_alt constant text := 'payload->>''first_name'' || '' '' || payload->>''last_name''';
v_neu constant text := '(payload->>''first_name'') || '' '' || (payload->>''last_name'')';
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'
limit 1;
if v_def is null then
raise exception 'hire_employee() nicht gefunden.';
end if;
if position(v_alt in v_def) = 0 then
raise notice 'hire_employee(): bereits geklammert.';
else
execute replace(v_def, v_alt, v_neu);
raise notice 'hire_employee(): geklammert.';
end if;
end;
$$;
-- ═══ Gegenprobe ══════════════════════════════════════════════════
-- Der Ausdruck selbst, in beiden Lesarten. Ohne Klammern wirft er; mit
-- Klammern kommt der Name heraus. Das hält die Regel fest, damit sie beim
-- nächsten Mal nicht neu entdeckt werden muss.
do $$
declare
v_payload jsonb := '{"first_name": "Anna", "last_name": "Berger"}'::jsonb;
v_name text;
begin
v_name := (v_payload->>'first_name') || ' ' || (v_payload->>'last_name');
if v_name <> 'Anna Berger' then
raise exception 'Geklammert ergibt „%" statt „Anna Berger".', v_name;
end if;
begin
execute $probe$ select ('{"a":"x"}'::jsonb)->>'a' || ' ' || ('{"a":"x"}'::jsonb)->>'a' $probe$;
raise exception 'Ungeklammert wirft nicht mehr — die Vorrangregel hat sich geändert, die Prüfung ist wertlos geworden.';
exception
when undefined_function then null; -- erwartet: text ->> unknown
end;
end;
$$;

View File

@@ -0,0 +1,132 @@
-- Eine Planstelle ändern.
--
-- Bisher liess sie sich nur anlegen und löschen. Ein Tippfehler in der
-- Tätigkeit oder ein falsches Gültigkeitsdatum bedeutete: löschen und neu —
-- mit neuer Planstellennummer. Die Nummer steht aber in Stellenausschreibungen
-- und Budgets, und die Protokollspur reisst ab.
--
-- Was hier bewusst **nicht** geht, steht als Sperre drin, nicht als
-- Anmerkung — siehe die drei Prüfungen unten.
create or replace function update_position(payload jsonb)
returns void
language plpgsql
set search_path = public, pg_temp
as $function$
declare
v_id uuid := (payload->>'position_id')::uuid;
v_alt om_positions%rowtype;
v_alt_titel text;
v_alt_einheit text;
v_org_unit_id uuid;
v_job_title text := nullif(trim(payload->>'job_title'), '');
v_is_chief boolean;
v_valid_from date;
v_valid_to date;
v_job_id uuid;
v_unit_name text;
v_besetzt boolean;
v_changes jsonb := '[]'::jsonb;
begin
perform require_hr_admin();
select * into v_alt from om_positions where id = v_id;
if v_alt.id is null then
raise exception 'Die Planstelle existiert nicht.';
end if;
select title into v_alt_titel from jobs where id = v_alt.job_id;
select name into v_alt_einheit from org_units where id = v_alt.org_unit_id;
-- Fehlende Schlüssel heissen „unverändert", nicht „leeren".
v_org_unit_id := coalesce(nullif(payload->>'org_unit_id','')::uuid, v_alt.org_unit_id);
v_job_title := coalesce(v_job_title, v_alt_titel);
v_is_chief := coalesce((payload->>'is_chief')::boolean, v_alt.is_chief);
v_valid_from := coalesce(nullif(payload->>'valid_from','')::date, v_alt.valid_from);
v_valid_to := case when payload ? 'valid_to' then nullif(payload->>'valid_to','')::date else v_alt.valid_to end;
select name into v_unit_name from org_units where id = v_org_unit_id;
if v_unit_name is null then
raise exception 'Die Organisationseinheit existiert nicht.';
end if;
if v_valid_to is not null and v_valid_to < v_valid_from then
raise exception 'Das Ende der Gültigkeit liegt vor ihrem Beginn.';
end if;
select exists (
select 1 from position_assignments
where position_id = v_id and (valid_to is null or valid_to > current_date)
) into v_besetzt;
-- (1) Die Einheit einer vergebenen Planstelle zu wechseln wäre eine
-- Versetzung — mit allem, was dazugehört: Historie, Berichtslinie,
-- Protokoll. Das gehört in transfer_employee und nicht hierher, sonst
-- wandert jemand lautlos in eine andere Abteilung.
if v_besetzt and v_org_unit_id is distinct from v_alt.org_unit_id then
raise exception 'Diese Planstelle ist vergeben. Für einen Wechsel der Einheit die Versetzung benutzen.';
end if;
-- (2) Ein Ende, während noch jemand darauf sitzt, hinterlässt eine
-- Besetzung ohne Planstelle.
if v_besetzt and v_valid_to is not null then
raise exception 'Diese Planstelle ist vergeben und kann kein Ende der Gültigkeit bekommen.';
end if;
-- (3) Je Einheit nur eine gültige Leitung. Der Unique-Index fängt das auch,
-- aber mit einer Meldung, die in der Oberfläche nichts erklärt.
if v_is_chief and exists (
select 1 from om_positions
where org_unit_id = v_org_unit_id and is_chief and valid_to is null and id <> v_id
) then
raise exception 'Für % besteht bereits eine Leitungsplanstelle.', v_unit_name;
end if;
-- Gleiche Tätigkeit, ein Katalogeintrag — dieselbe Regel wie beim Anlegen.
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;
v_changes := app_aenderung(v_changes, 'Tätigkeit', v_alt_titel, v_job_title);
v_changes := app_aenderung(v_changes, 'Organisationseinheit', v_alt_einheit, v_unit_name);
v_changes := app_aenderung(v_changes, 'Leitungsplanstelle', v_alt.is_chief::text, v_is_chief::text);
v_changes := app_aenderung(v_changes, 'Gültig ab', v_alt.valid_from::text, v_valid_from::text);
v_changes := app_aenderung(v_changes, 'Gültig bis', v_alt.valid_to::text, v_valid_to::text);
-- Nichts geändert ist ein Ergebnis, kein Erfolg.
--
-- Vorher kehrte die Funktion hier stumm zurück, und die Oberfläche meldete
-- „Planstelle geändert." Wer etwas eingetragen hatte, das unterwegs
-- verworfen wurde — etwa das Leitungshäkchen, das bei bereits vergebener
-- Leitung nicht durchkommt —, sah eine Erfolgsmeldung und eine unveränderte
-- Liste. Das ist genau die Rückmeldung, die einen suchen lässt.
if jsonb_array_length(v_changes) = 0 then
raise exception 'Es wurde nichts geändert.';
end if;
update om_positions
set org_unit_id = v_org_unit_id,
job_id = v_job_id,
is_chief = v_is_chief,
valid_from = v_valid_from,
valid_to = v_valid_to
where id = v_id;
insert into audit_log (actor_user_id, actor_name, action, target_label, details, changes)
values (app_current_user_id(), current_actor_name(), 'Planstelle geändert',
v_job_title || ' (' || v_unit_name || ')',
app_aenderungsfelder(v_changes), v_changes);
end;
$function$;
do $$
declare r text;
begin
foreach r in array array['anon', 'authenticated', 'service_role', 'alpenwerk_app'] loop
if exists (select 1 from pg_roles where rolname = r) then
execute format('grant execute on function update_position(jsonb) to %I', r);
end if;
end loop;
end;
$$;

View File

@@ -0,0 +1,104 @@
-- Die Fremdschlüssel von auth.users auf app_users umhängen.
--
-- Solange profiles.id auf auth.users zeigt, lässt sich **keine zweite Person
-- freischalten**. Die Anmeldung läuft über Auth.js und legt dort nichts mehr
-- an; wer sich neu anmeldet, bekommt eine app_users-Zeile, aber die dazu
-- nötige profiles-Zeile scheitert am Fremdschlüssel. Das Ergebnis wäre
-- „Kein HR-Zugriff" ohne Möglichkeit, es zu ändern.
--
-- Damit ist das hier keine Aufräumarbeit, sondern die Voraussetzung dafür,
-- dass die Anwendung mehr als eine Person bedienen kann.
--
-- Vorher geprüft: jeder referenzierte Wert steht bereits in app_users. Die
-- Umhängung ändert also keine Daten, nur die Zusicherung.
-- ═══ 1. Sicherheitsnetz ══════════════════════════════════════════
-- Was in app_users fehlt, wird aus profiles ergänzt. Im geprüften Bestand
-- ist das leer; die Migration soll aber auch auf einer Kopie laufen, in der
-- jemand zwischenzeitlich etwas angelegt hat.
insert into app_users (id, external_id, email, full_name)
select p.id, 'legacy:' || p.id::text, p.email, p.full_name
from profiles p
where not exists (select 1 from app_users a where a.id = p.id)
on conflict (id) do nothing;
-- ═══ 2. Umhängen ═════════════════════════════════════════════════
-- Über den Katalog statt acht handgeschriebene Anweisungen: die Namen der
-- Zwänge stammen aus verschiedenen Migrationen, und einer davon von Hand
-- falsch abgeschrieben hiesse, dass er stehen bleibt.
do $$
declare
r record;
v_delete text;
begin
for r in
select k.conname, c.relname as tabelle, a.attname as spalte, k.confdeltype
from pg_constraint k
join pg_class c on c.oid = k.conrelid
join pg_namespace n on n.oid = c.relnamespace
join pg_attribute a on a.attrelid = k.conrelid and a.attnum = any(k.conkey)
where k.contype = 'f'
and n.nspname = 'public'
and k.confrelid = (
select oid from pg_class
where relname = 'users'
and relnamespace = (select oid from pg_namespace where nspname = 'auth')
)
loop
-- Das Löschverhalten bleibt, wie es war: profiles hängt kaskadierend am
-- Konto, die Protokoll- und Notizfelder nicht — dort soll ein Eintrag
-- gerade nicht verschwinden, weil ein Konto entfernt wird.
v_delete := case r.confdeltype when 'c' then ' on delete cascade' else '' end;
execute format('alter table %I drop constraint %I', r.tabelle, r.conname);
execute format('alter table %I add constraint %I foreign key (%I) references app_users(id)%s',
r.tabelle, r.conname, r.spalte, v_delete);
raise notice '%.% -> app_users%', r.tabelle, r.spalte, v_delete;
end loop;
end;
$$;
-- ═══ 3. Gegenprobe ═══════════════════════════════════════════════
do $$
declare
v_offen int;
v_neu int;
begin
select count(*) into v_offen
from pg_constraint k
join pg_class c on c.oid = k.conrelid
join pg_namespace n on n.oid = c.relnamespace
where k.contype = 'f' and n.nspname = 'public'
and k.confrelid = (
select oid from pg_class
where relname = 'users'
and relnamespace = (select oid from pg_namespace where nspname = 'auth')
);
if v_offen > 0 then
raise exception '% Fremdschlüssel zeigen weiterhin auf auth.users.', v_offen;
end if;
select count(*) into v_neu
from pg_constraint k
join pg_class c on c.oid = k.conrelid
join pg_namespace n on n.oid = c.relnamespace
where k.contype = 'f' and n.nspname = 'public'
and k.confrelid = 'app_users'::regclass;
if v_neu < 8 then
raise exception 'Nur % Fremdschlüssel zeigen auf app_users — erwartet mindestens 8.', v_neu;
end if;
-- Und die eigentliche Frage: lässt sich jetzt eine zweite Person anlegen?
-- Geprüft und wieder entfernt, damit die Migration keine Daten hinterlässt.
declare
v_id uuid := gen_random_uuid();
begin
insert into app_users (id, external_id, email, full_name)
values (v_id, 'probe:' || v_id::text, 'probe@example.invalid', 'Probe');
insert into profiles (id, email, full_name, role, is_active)
values (v_id, 'probe@example.invalid', 'Probe', 'hr', false);
delete from profiles where id = v_id;
delete from app_users where id = v_id;
end;
end;
$$;

View File

@@ -0,0 +1,119 @@
-- Auf eine Planstelle darf nur besetzt werden, solange sie gilt.
--
-- Bisher prüften hire_employee und transfer_employee nur, ob die Stelle frei
-- ist — nicht, ob es sie zum fraglichen Zeitpunkt überhaupt gibt. Damit liess
-- sich heute jemand auf eine Planstelle einstellen, die erst im Oktober
-- entsteht, oder auf eine, die im Frühjahr ausgelaufen ist. Die Besetzung
-- stand dann in der Datenbank, die Stelle im Organigramm aber nicht, und die
-- Person hing an einer Struktur, die es zu ihrem Eintrittsdatum nicht gab.
--
-- Seit die Oberfläche künftige Planstellen anzeigt, ist das kein
-- theoretischer Fall mehr: sie stehen in derselben Auswahl.
--
-- Die Regel: das Datum der Besetzung — Eintritt bzw. Wirksamkeit der
-- Versetzung — muss in [valid_from, valid_to) liegen. `valid_to` ist wie
-- überall im Modell ausschliessend; eine Planstelle mit valid_to = heute gilt
-- heute nicht mehr (siehe lib/positions.ts).
--
-- Zweite Korrektur im selben Zug: die Belegungsprüfung sah nur Zuordnungen
-- mit offenem Ende. Eine, die erst später endet, blieb unsichtbar — dieselbe
-- Lücke, die die Übersicht der unbesetzten Planstellen hatte.
create or replace function app_funktion_ersetzen(p_funktion text, p_muster text, p_neu text)
returns void
language plpgsql
set search_path = public, pg_temp
as $$
declare
v_def text;
v_neu 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 = p_funktion
limit 1;
if v_def is null then
raise exception 'Funktion %() nicht gefunden.', p_funktion;
end if;
-- Über ein Muster statt über festen Text, weil die Rümpfe je nach Herkunft
-- CRLF oder LF enthalten. Ein wörtlicher Vergleich fände dann nichts und
-- die Migration liefe erfolgreich durch, ohne etwas zu ändern.
v_neu := regexp_replace(v_def, p_muster, p_neu, 'g');
if v_neu = v_def then
raise exception 'In %() passte das Muster auf nichts — nichts geändert.', p_funktion;
end if;
execute v_neu;
end;
$$;
-- ═══ Eintritt ════════════════════════════════════════════════════
select app_funktion_ersetzen(
'hire_employee',
'select\s+pa\.employee_id\s+into\s+v_besetzt\s+from\s+position_assignments\s+pa\s+where\s+pa\.position_id\s*=\s*v_position_id\s+and\s+pa\.valid_to\s+is\s+null;',
$neu$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);$neu$
);
-- ═══ Versetzung ══════════════════════════════════════════════════
select app_funktion_ersetzen(
'transfer_employee',
'select\s+pa\.employee_id\s+into\s+v_besetzt\s+from\s+position_assignments\s+pa\s+where\s+pa\.position_id\s*=\s*v_target_position\s+and\s+pa\.valid_to\s+is\s+null;',
$neu$declare
v_ab date;
v_bis date;
begin
select valid_from, valid_to into v_ab, v_bis from om_positions where id = v_target_position;
if v_ab is null then
raise exception 'Die Zielplanstelle existiert nicht.';
end if;
if v_effective < v_ab then
raise exception 'Die Zielplanstelle gilt erst ab %. Eine Versetzung zum % ist darauf nicht möglich.', v_ab, v_effective;
end if;
if v_bis is not null and v_effective >= v_bis then
raise exception 'Die Zielplanstelle gilt nur bis %. Eine Versetzung zum % ist darauf nicht möglich.', v_bis, v_effective;
end if;
end;
select pa.employee_id into v_besetzt
from position_assignments pa
where pa.position_id = v_target_position
and (pa.valid_to is null or pa.valid_to > v_effective);$neu$
);
drop function app_funktion_ersetzen(text, text, text);
-- ═══ Gegenprobe ══════════════════════════════════════════════════
do $$
declare r text;
begin
foreach r in array array['hire_employee', 'transfer_employee'] loop
if (select pg_get_functiondef(p.oid) from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = r limit 1) not like '%gilt erst ab%' then
raise exception '%() enthält die Gültigkeitsprüfung nicht.', r;
end if;
end loop;
end;
$$;

View File

@@ -0,0 +1,64 @@
-- Der Vorgabewert für die Arbeitstage in hire_employee greift nie.
--
-- coalesce(array(select jsonb_array_elements_text(payload->'work_days'))::text[],
-- '{Mo,Di,Mi,Do,Fr}')
--
-- Fehlt der Schlüssel, liefert die Unterabfrage keine Zeilen, und `array(…)`
-- macht daraus ein **leeres** Array — nicht NULL. `coalesce` sieht also
-- keinen fehlenden Wert und lässt `{}` stehen. Die Bedingung
-- chk_work_days_valid verlangt aber mindestens einen Tag, und die
-- Einstellung bricht ab.
--
-- Sichtbar ist das bisher nicht, weil der Assistent die Arbeitstage immer
-- mitschickt und ohne sie gar nicht weiterlässt. Es ist eine Falle für jeden
-- anderen Aufrufer — und ein Vorgabewert, der nichts vorgibt, ist schlimmer
-- als keiner: er sieht aus, als wäre der Fall bedacht.
--
-- `nullif(…, '{}')` macht aus dem leeren Array wieder ein fehlendes.
do $$
declare
v_def text;
v_neu 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 = 'hire_employee'
limit 1;
v_neu := regexp_replace(
v_def,
'coalesce\(\s*array\(\s*select\s+jsonb_array_elements_text\(payload->''work_days''\)\s*\)\s*::text\[\]\s*,',
'coalesce(nullif(array(select jsonb_array_elements_text(payload->''work_days''))::text[], ''{}''),',
'g'
);
if v_neu = v_def then
raise exception 'Das Muster für die Arbeitstage passte nicht — hire_employee blieb unverändert.';
end if;
execute v_neu;
end;
$$;
-- ═══ Gegenprobe ══════════════════════════════════════════════════
-- Der Ausdruck in beiden Formen, damit die Regel festgehalten ist und nicht
-- beim nächsten Mal neu entdeckt werden muss.
do $$
begin
if array(select jsonb_array_elements_text('{}'::jsonb->'work_days')) is null then
raise exception 'array() über einen fehlenden Schlüssel liefert null — die Annahme dieser Migration stimmt nicht mehr.';
end if;
if coalesce(nullif(array(select jsonb_array_elements_text('{}'::jsonb->'work_days'))::text[], '{}'), '{Mo,Di,Mi,Do,Fr}')
<> '{Mo,Di,Mi,Do,Fr}'::text[] then
raise exception 'Der korrigierte Ausdruck liefert nicht die Vorgabe.';
end if;
if coalesce(nullif(array(select jsonb_array_elements_text('{"work_days":["Mo","Di"]}'::jsonb->'work_days'))::text[], '{}'), '{Mo,Di,Mi,Do,Fr}')
<> '{Mo,Di}'::text[] then
raise exception 'Der korrigierte Ausdruck überschreibt einen mitgegebenen Wert.';
end if;
end;
$$;

View File

@@ -0,0 +1,82 @@
-- Die Wiedereinstellung prüft die Zielplanstelle wie Eintritt und Versetzung.
--
-- rehire_employee verlangte zwar eine Planstelle, setzte die Besetzung dann
-- aber ungeprüft: weder ob die Stelle zum Wiedereintritt gilt noch ob sie
-- frei ist. Eine wiedereingestellte Person konnte damit auf einer bereits
-- besetzten Stelle landen — abgefangen erst vom Teilindex, mit einer Meldung,
-- die in der Oberfläche nichts erklärt — oder auf einer, die es zu dem Datum
-- gar nicht gibt.
--
-- Dieselbe Regel wie in 20260810100000: das Datum muss in
-- [valid_from, valid_to) liegen, und es darf keine Zuordnung geben, die zu
-- diesem Zeitpunkt noch gilt.
do $$
declare
v_def text;
v_neu 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'
limit 1;
if v_def is null then
raise exception 'rehire_employee() nicht gefunden.';
end if;
-- Über ein Muster, weil der Rumpf CRLF enthalten kann; ein wörtlicher
-- Vergleich fände nichts und die Migration meldete trotzdem Erfolg.
v_neu := regexp_replace(
v_def,
'if\s+v_position_id\s+is\s+null\s+then\s+raise\s+exception\s+''Für die Wiedereinstellung muss eine Planstelle angegeben werden\.'';\s+end\s+if;',
$neu$if v_position_id is null then
raise exception 'Für die Wiedereinstellung muss eine Planstelle angegeben werden.';
end if;
declare
v_ab date;
v_bis date;
v_besetzt uuid;
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 Wiedereintritt 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 Wiedereintritt am % ist darauf nicht möglich.', v_bis, v_date;
end if;
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;
end;$neu$,
'g'
);
if v_neu = v_def then
raise exception 'Das Muster passte nicht — rehire_employee blieb unverändert.';
end if;
execute v_neu;
end;
$$;
-- ═══ Gegenprobe ══════════════════════════════════════════════════
do $$
begin
if (select pg_get_functiondef(p.oid) from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.proname = 'rehire_employee' limit 1) not like '%gilt erst ab%' then
raise exception 'rehire_employee() enthält die Gültigkeitsprüfung nicht.';
end if;
end;
$$;

View File

@@ -0,0 +1,65 @@
-- rehire_employee funktionierte nie.
--
-- status = case when v_date <= current_date then 'Aktiv' else 'Geplant' end
--
-- Der case-Ausdruck ist `text`, die Spalte ist `employment_status`. Postgres
-- weist das ab:
--
-- column "status" is of type employment_status but expression is of type text
--
-- Sichtbar wurde es erst jetzt. Davor brach die Funktion eine Zeile früher ab,
-- weil das Formular nie eine Planstelle mitschickte — ein Fehler verdeckte den
-- anderen, wie schon bei hire_employee.
--
-- Bei den beiden anderen Zuweisungen im selben update (exit_date, exit_reason)
-- stellt sich die Frage nicht: `null` ist typunabhängig.
do $$
declare
v_def text;
v_neu 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'
limit 1;
v_neu := regexp_replace(
v_def,
'(case\s+when\s+v_date\s*<=\s*current_date\s+then\s+''Aktiv''\s+else\s+''Geplant''\s+end)',
'(\1)::employment_status',
'g'
);
if v_neu = v_def then
raise exception 'Das Muster für den Status passte nicht — rehire_employee blieb unverändert.';
end if;
execute v_neu;
end;
$$;
-- ═══ Gegenprobe ══════════════════════════════════════════════════
-- Nachgestellt wird die **Spaltenzuweisung**, nicht die an eine Variable.
-- Das ist der Unterschied, an dem mein erster Prüfausdruck vorbeiging:
-- plpgsql wandelt bei einer Variablenzuweisung stillschweigend um, ein
-- UPDATE auf eine Spalte nicht. Nur die zweite Form entspricht dem Fehler.
do $$
begin
create temp table probe_status (s employment_status) on commit drop;
insert into probe_status values ('Aktiv');
begin
execute $probe$ update probe_status set s = (case when true then 'Aktiv' else 'Geplant' end)::text $probe$;
raise exception 'Eine text-Zuweisung an eine employment_status-Spalte wirft nicht mehr — die Prüfung ist wertlos geworden.';
exception
when datatype_mismatch then null; -- erwartet
end;
execute $probe$ update probe_status set s = (case when true then 'Aktiv' else 'Geplant' end)::employment_status $probe$;
if (select s from probe_status) <> 'Aktiv'::employment_status then
raise exception 'Die umgewandelte Form liefert nicht Aktiv.';
end if;
end;
$$;

View File

@@ -0,0 +1,120 @@
-- Die Personalnummer wird eingegeben, nicht vergeben.
--
-- Sie muss mit Loga und Interflex übereinstimmen. Eine von dieser Anwendung
-- selbst gezogene Nummer ist dort unbekannt, und die Person hätte in drei
-- Systemen zwei Nummern.
--
-- Zwei Dinge ändern sich dadurch:
--
-- 1. Die Identität fällt weg. `GENERATED ALWAYS` weist eigene Werte
-- ausdrücklich ab — deshalb brauchte der Import bisher OVERRIDING SYSTEM
-- VALUE.
-- 2. Die Eindeutigkeit muss ausdrücklich her. Bisher gab es **keine**: die
-- Identität verhinderte Doppelte nur als Nebenwirkung. Sobald der Wert von
-- aussen kommt, ist das die eigentliche Zusicherung — und sie fehlte.
-- Gegenprobe vor dem Umbau: was jetzt doppelt ist, liesse sich danach nicht
-- mehr eindeutig machen.
do $$
declare v_doppelt int;
begin
select count(*) into v_doppelt from (
select personnel_number from employees group by 1 having count(*) > 1
) x;
if v_doppelt > 0 then
raise exception '% Personalnummern kommen mehrfach vor — vor der Umstellung bereinigen.', v_doppelt;
end if;
end;
$$;
alter table employees alter column personnel_number drop identity if exists;
alter table employees add constraint employees_personnel_number_key unique (personnel_number);
comment on column employees.personnel_number is
'Wird eingegeben und muss mit Loga/Interflex übereinstimmen. Nicht automatisch vergeben.';
-- ═══ hire_employee nimmt die Nummer entgegen ═════════════════════
do $$
declare
v_def text;
v_neu 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 = 'hire_employee' limit 1;
-- Pflichtprüfung direkt nach der Planstellenprüfung einhängen.
v_neu := regexp_replace(
v_def,
'(if\s+v_position_id\s+is\s+null\s+then\s+raise\s+exception\s+''Es muss eine Planstelle angegeben werden\.'';\s+end\s+if;)',
$neu$\1
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;$neu$,
'g'
);
if v_neu = v_def then
raise exception 'Die Pflichtprüfung liess sich nicht einhängen — hire_employee blieb unverändert.';
end if;
v_def := v_neu;
-- Und in die Einfügung aufnehmen. Die Spaltenliste beginnt mit
-- first_name; davor kommt personnel_number, in beiden Listen an gleicher
-- Stelle.
v_neu := regexp_replace(v_def, 'insert\s+into\s+employees\s*\(\s*first_name,', 'insert into employees (' || chr(10) || ' personnel_number, first_name,', 'g');
if v_neu = v_def then
raise exception 'Die Spaltenliste liess sich nicht ergänzen.';
end if;
v_def := v_neu;
v_neu := regexp_replace(v_def, 'values\s*\(\s*payload->>''first_name'',', 'values (' || chr(10) || ' (payload->>''personnel_number'')::int, payload->>''first_name'',', 'g');
if v_neu = v_def then
raise exception 'Die Werteliste liess sich nicht ergänzen.';
end if;
-- OVERRIDING SYSTEM VALUE gibt es nur für Identitätsspalten; nach dem
-- Wegfall wäre es ein Fehler. Der Import setzt es selbst nicht mehr, hier
-- steht es vorsorglich, falls eine ältere Fassung es doch trägt.
v_neu := regexp_replace(v_neu, '\s+overriding\s+system\s+value', '', 'gi');
execute v_neu;
end;
$$;
-- ═══ Gegenprobe ══════════════════════════════════════════════════
do $$
declare
v_ist_identitaet text;
v_def text;
begin
select is_identity into v_ist_identitaet
from information_schema.columns
where table_name = 'employees' and column_name = 'personnel_number';
if v_ist_identitaet <> 'NO' then
raise exception 'personnel_number ist weiterhin eine Identitätsspalte.';
end if;
if not exists (
select 1 from pg_constraint
where conrelid = 'employees'::regclass and contype = 'u'
and pg_get_constraintdef(oid) = 'UNIQUE (personnel_number)'
) then
raise exception 'Die Eindeutigkeit auf personnel_number fehlt.';
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' limit 1;
if v_def not like '%bereits vergeben%' then
raise exception 'hire_employee() prüft die Personalnummer nicht.';
end if;
if v_def ilike '%overriding system value%' then
raise exception 'hire_employee() enthält weiterhin OVERRIDING SYSTEM VALUE.';
end if;
end;
$$;

View File

@@ -0,0 +1,96 @@
-- Notfallkontakt und Antriebsart des Dienstwagens.
-- ═══ Notfallkontakt ══════════════════════════════════════════════
-- Als Spalten und nicht als eigene Tabelle: gefragt war *der* Notfallkontakt,
-- einer je Person. Eine Tabelle wäre die richtige Antwort auf „mehrere", und
-- die Frage stellt sich hier nicht.
--
-- Das Verhältnis bleibt Freitext. Die Beispiele — Gattin/Gatte,
-- Schwester/Bruder, Freund — sind keine Aufzählung, die sich schliessen
-- lässt, ohne jemandem die eigene Lebensform abzusprechen.
alter table employees add column if not exists emergency_contact_name text;
alter table employees add column if not exists emergency_contact_phone text;
alter table employees add column if not exists emergency_contact_relation text;
comment on column employees.emergency_contact_name is
'Notfallkontakt: Name. Daten einer dritten Person — nur für den Notfall erhoben.';
-- Alles oder nichts: ein Name ohne Nummer nützt im Notfall nichts, eine
-- Nummer ohne Namen sagt nicht, wen man da anruft.
alter table employees drop constraint if exists chk_emergency_contact;
alter table employees add constraint chk_emergency_contact check (
(emergency_contact_name is null and emergency_contact_phone is null)
or (btrim(coalesce(emergency_contact_name, '')) <> '' and btrim(coalesce(emergency_contact_phone, '')) <> '')
);
-- ═══ Antriebsart des Dienstwagens ════════════════════════════════
-- Eine zweite Spalte statt eines Umbaus von has_dienstwagen: die bestehende
-- Angabe bleibt gültig, und alle Auswertungen darauf ebenfalls.
alter table employees add column if not exists dienstwagen_art text;
comment on column employees.dienstwagen_art is
'Antriebsart des Dienstwagens: Verbrenner oder Elektro. Null, wenn keiner vorhanden.';
-- Der CHECK bindet die beiden Angaben aneinander. Ohne ihn stünde irgendwann
-- „E-KFZ" bei jemandem ohne Dienstwagen, und niemand wüsste, welche der
-- beiden Angaben stimmt.
-- Zuerst den Bestand füllen, dann prüfen — andersherum weist die Bedingung
-- jede vorhandene Zeile mit Dienstwagen ab. Bestehende gelten als
-- Verbrenner, bis jemand es besser weiss; das ist eine Annahme, aber eine
-- sichtbare: „Elektro" steht nirgends, wo es niemand bestätigt hat.
update employees set dienstwagen_art = 'Verbrenner'
where has_dienstwagen and dienstwagen_art is null;
-- `is not null and` steht bewusst davor: `dienstwagen_art in (…)` ergibt bei
-- NULL nicht `false`, sondern NULL — und ein CHECK gilt als erfüllt, wenn er
-- NULL liefert. Ohne die ausdrückliche Prüfung hätte diese Bedingung genau
-- den Fall durchgelassen, gegen den sie geschrieben ist. Aufgefallen ist das
-- der Gegenprobe am Ende dieser Datei, nicht mir.
alter table employees drop constraint if exists chk_dienstwagen_art;
alter table employees add constraint chk_dienstwagen_art check (
(has_dienstwagen and dienstwagen_art is not null and dienstwagen_art in ('Verbrenner', 'Elektro'))
or (not has_dienstwagen and dienstwagen_art is null)
);
-- ═══ Gegenprobe ══════════════════════════════════════════════════
do $$
begin
create temp table probe_emp (
has_dienstwagen boolean not null default false,
dienstwagen_art text,
emergency_contact_name text,
emergency_contact_phone text,
constraint p_art check (
(has_dienstwagen and dienstwagen_art is not null and dienstwagen_art in ('Verbrenner','Elektro'))
or (not has_dienstwagen and dienstwagen_art is null)),
constraint p_kontakt check (
(emergency_contact_name is null and emergency_contact_phone is null)
or (btrim(coalesce(emergency_contact_name,'')) <> '' and btrim(coalesce(emergency_contact_phone,'')) <> ''))
) on commit drop;
-- E-KFZ ohne Dienstwagen muss abgewiesen werden.
begin
insert into probe_emp (has_dienstwagen, dienstwagen_art) values (false, 'Elektro');
raise exception 'Antriebsart ohne Dienstwagen wird angenommen — der CHECK greift nicht.';
exception when check_violation then null;
end;
-- Dienstwagen ohne Antriebsart ebenso.
begin
insert into probe_emp (has_dienstwagen, dienstwagen_art) values (true, null);
raise exception 'Dienstwagen ohne Antriebsart wird angenommen — der CHECK greift nicht.';
exception when check_violation then null;
end;
insert into probe_emp (has_dienstwagen, dienstwagen_art) values (true, 'Elektro');
-- Name ohne Nummer muss abgewiesen werden.
begin
insert into probe_emp (emergency_contact_name) values ('Maria Muster');
raise exception 'Notfallkontakt ohne Nummer wird angenommen — der CHECK greift nicht.';
exception when check_violation then null;
end;
insert into probe_emp (emergency_contact_name, emergency_contact_phone) values ('Maria Muster', '+43 660 1234567');
end;
$$;

View File

@@ -0,0 +1,91 @@
-- change_employee_data kennt Notfallkontakt und Antriebsart.
--
-- Ohne diesen Schritt liessen sich die neuen Felder anlegen, aber nie
-- ändern: die Funktion vergleicht und schreibt namentlich aufgezählte
-- Spalten, und was dort fehlt, wird stillschweigend übergangen. Das ist die
-- unangenehme Sorte Lücke — die Oberfläche zeigt ein Eingabefeld, das
-- Speichern meldet Erfolg, und der Wert bleibt stehen.
--
-- Beide Ergänzungen laufen über app_aenderung(), landen also mit Vorher und
-- Nachher im Protokoll wie alles andere auch.
do $$
declare
v_def text;
v_neu 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' limit 1;
-- ── Vergleiche: Notfallkontakt hinter Telefon, Antriebsart hinter C-Level
v_neu := regexp_replace(
v_def,
'(if\s+v_person\s+\?\s+''phone''\s+then\s+v_person_changes\s*:=\s*app_aenderung\(v_person_changes,\s*''Telefon'',\s*v_old\.phone,\s*v_person->>''phone''\);\s*end\s+if;)',
$neu$\1
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;$neu$,
'g'
);
if v_neu = v_def then raise exception 'Der Vergleichsblock für den Notfallkontakt liess sich nicht einhängen.'; end if;
v_def := v_neu;
v_neu := regexp_replace(
v_def,
'(if\s+v_role\s+\?\s+''is_c_level''\s+then\s+v_contract_changes\s*:=\s*app_aenderung\(v_contract_changes,\s*''C-Level'',\s*v_old\.is_c_level::text,\s*v_role->>''is_c_level''\);\s*end\s+if;)',
$neu$\1
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;$neu$,
'g'
);
if v_neu = v_def then raise exception 'Der Vergleich für die Antriebsart liess sich nicht einhängen.'; end if;
v_def := v_neu;
-- ── Schreiben
v_neu := regexp_replace(
v_def,
'(phone\s*=\s*coalesce\(v_person->>''phone'',\s*phone\),)',
$neu$\1
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,$neu$,
'g'
);
if v_neu = v_def then raise exception 'Der Schreibblock für den Notfallkontakt liess sich nicht einhängen.'; end if;
v_def := v_neu;
-- Die Antriebsart hängt an has_dienstwagen: wird der Dienstwagen
-- abgemeldet, muss sie mit, sonst weist der CHECK die Zeile ab.
v_neu := regexp_replace(
v_def,
'(is_c_level\s*=\s*coalesce\(\(v_role->>''is_c_level''\)::boolean,\s*is_c_level\))',
$neu$\1,
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$neu$,
'g'
);
if v_neu = v_def then raise exception 'Der Schreibblock für die Antriebsart liess sich nicht einhängen.'; end if;
execute v_neu;
end;
$$;
-- ═══ Gegenprobe ══════════════════════════════════════════════════
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' limit 1;
if v_def not like '%Notfallkontakt Verhältnis%' then
raise exception 'change_employee_data() vergleicht den Notfallkontakt nicht.';
end if;
if v_def not like '%dienstwagen_art = case%' then
raise exception 'change_employee_data() schreibt die Antriebsart nicht.';
end if;
end;
$$;

View File

@@ -0,0 +1,62 @@
-- hire_employee schreibt Notfallkontakt und Antriebsart mit.
--
-- Die Spalten gibt es seit 20260811110000, aber die Einstellung zählt ihre
-- Spalten namentlich auf — was dort fehlt, wird beim Anlegen verworfen. Der
-- Assistent hätte die Felder erhoben und stillschweigend weggeworfen; genau
-- der Fall, der heute schon einmal mit der E-Mail passiert ist.
do $$
declare
v_def text;
v_neu 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 = 'hire_employee' limit 1;
v_neu := regexp_replace(
v_def,
'(is_betriebsrat,\s*has_dienstwagen,\s*is_laterale_fuehrung,\s*is_c_level\s*\))',
'is_betriebsrat, has_dienstwagen, is_laterale_fuehrung, is_c_level,'
|| chr(10) || ' dienstwagen_art, emergency_contact_name, emergency_contact_phone, emergency_contact_relation'
|| chr(10) || ' )',
'g'
);
if v_neu = v_def then raise exception 'Die Spaltenliste liess sich nicht ergänzen.'; end if;
v_def := v_neu;
-- Die Werteliste endet mit is_c_level; davor steht der Abschluss der
-- values-Klammer.
v_neu := regexp_replace(
v_def,
'(coalesce\(\(payload->>''is_c_level''\)::boolean,\s*false\))',
E'\\1,\n'
-- Antrieb nur, wenn es einen Dienstwagen gibt — sonst weist der CHECK
-- die Zeile ab. Ohne Angabe gilt Verbrenner, wie im Bestand.
|| ' case when coalesce((payload->>''has_dienstwagen'')::boolean, false)'
|| ' then coalesce(nullif(payload->>''dienstwagen_art'', ''''), ''Verbrenner'') else null end,' || chr(10)
|| ' nullif(payload->>''emergency_contact_name'', ''''),' || chr(10)
|| ' nullif(payload->>''emergency_contact_phone'', ''''),' || chr(10)
|| ' nullif(payload->>''emergency_contact_relation'', '''')',
'g'
);
if v_neu = v_def then raise exception 'Die Werteliste liess sich nicht ergänzen.'; end if;
execute v_neu;
end;
$$;
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 = 'hire_employee' limit 1;
if v_def not like '%emergency_contact_relation%' then
raise exception 'hire_employee() schreibt den Notfallkontakt nicht.';
end if;
if v_def not like '%dienstwagen_art%' then
raise exception 'hire_employee() schreibt die Antriebsart nicht.';
end if;
end;
$$;

View File

@@ -0,0 +1,37 @@
-- Die E-Mail-Adresse ist privat und freiwillig.
--
-- Sie war NOT NULL, obwohl es sich um die *private* Adresse handelt — nicht
-- um eine Firmenadresse, die mit dem Eintritt entsteht. Wer keine angeben
-- will oder keine hat, muss trotzdem angelegt werden können. Bisher zwang
-- die Spalte dazu, etwas zu erfinden, und erfundene Daten in einer
-- Personalakte sind schlimmer als fehlende.
--
-- Die Eindeutigkeit bleibt: sie verhindert weiterhin, dass dieselbe Adresse
-- zweimal vorkommt. Mehrere NULL-Werte stören sie nicht — in PostgreSQL
-- gelten sie in einem UNIQUE-Index als voneinander verschieden, und genau
-- das ist hier gewollt.
alter table employees alter column email drop not null;
comment on column employees.email is
'Private E-Mail-Adresse. Freiwillig; eindeutig, wenn angegeben.';
comment on column employees.phone is
'Private Telefonnummer. Freiwillig.';
-- ═══ Gegenprobe ══════════════════════════════════════════════════
-- Zwei Personen ohne Adresse müssen nebeneinander bestehen können, zwei mit
-- derselben nicht.
do $$
begin
create temp table probe_mail (email text unique) on commit drop;
insert into probe_mail (email) values (null), (null);
insert into probe_mail (email) values ('a@example.invalid');
begin
insert into probe_mail (email) values ('a@example.invalid');
raise exception 'Doppelte Adressen werden angenommen — die Eindeutigkeit ist verloren.';
exception when unique_violation then null;
end;
end;
$$;

View File

@@ -0,0 +1,179 @@
-- Die Historie trägt die Werte mit, nicht nur die Feldnamen.
--
-- Beim Testen fiel auf: ändert jemand eine Adresse, steht in der Historie der
-- Person nur "Geänderte Felder: Adresse, Ort". Die alte Adresse ist nirgends
-- zu sehen — sie steckt allein im Audit-Log, und das ist eine andere Seite,
-- nach Zeitpunkt und handelnder Person sortiert statt nach Person. Wer wissen
-- will, ob eine Anschrift je geändert wurde und wie sie vorher lautete, kommt
-- also nicht hin, obwohl die Anwendung es weiß.
--
-- Die Feldliste wird beim Ändern ohnehin gebaut (app_aenderung) und ins
-- Audit-Log geschrieben. Sie wandert jetzt zusätzlich in die Historienzeile.
-- Das ist bewusst redundant: die Historie ist die Geschichte *einer Person*
-- und soll für sich allein lesbar sein — auch dann noch, wenn das Protokoll
-- irgendwann nach Aufbewahrungsfrist ausgedünnt wird.
--
-- Alte Zeilen bleiben ohne Werte. Nachliefern ließe sich das nur aus dem
-- Audit-Log, und die Zuordnung dorthin ist nicht eindeutig (kein Schlüssel,
-- nur Zeitpunkt und Person). Lieber ehrlich leer als falsch verknüpft.
alter table employee_history add column if not exists changes jsonb;
comment on column employee_history.changes is
'Feldweise Änderungen als [{feld, vorher, nachher}] — dieselbe Form wie audit_log.changes. Null bei Ereignissen ohne Einzelfelder (Eintritt, Austritt, Import) und bei Zeilen von vor dieser Migration.';
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[];
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 ? '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 ? '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 ? '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 ? '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 = coalesce(v_person->>'sv_nummer', sv_nummer),
nationality = coalesce(v_person->>'nationality', nationality),
address = coalesce(v_person->>'address', address),
postal_code = coalesce(v_person->>'postal_code', postal_code),
city = coalesce(v_person->>'city', city),
address_country = coalesce(v_person->>'address_country', address_country),
email = coalesce(v_person->>'email', email),
phone = coalesce(v_person->>'phone', phone),
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,
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),
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),
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);
end if;
if jsonb_array_length(v_person_changes) > 0 then
insert into employee_history (employee_id, event_date, event_type, description, changes)
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);
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)
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);
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;
end;
$function$;
-- Selbstprüfung: schlägt die Migration fehl, statt still nichts zu tun.
do $$
declare
v_def text := pg_get_functiondef('public.change_employee_data(jsonb)'::regprocedure);
begin
if not exists (
select 1 from information_schema.columns
where table_schema = 'public' and table_name = 'employee_history' and column_name = 'changes'
) then
raise exception 'employee_history.changes fehlt';
end if;
if (length(v_def) - length(replace(v_def, 'description, changes)', ''))) / length('description, changes)') <> 2 then
raise exception 'change_employee_data schreibt changes nicht in beide Historien-Einträge';
end if;
end
$$;

View File

@@ -0,0 +1,209 @@
-- Einen Historieneintrag zurücknehmen — samt seiner Wirkung.
--
-- Die Historie ist bewusst fortschreibend: employee_history hat nur Policies
-- für select und insert, es gibt kein update und kein delete. Das bleibt so.
-- Was hier entsteht, ist ein einzelner kontrollierter Weg daran vorbei, und
-- er ist eng: nur eine irrtümlich erfasste Stammdaten- oder Vertragsänderung,
-- nur mit Feldwerten, nur wenn sie bereits wirksam ist.
--
-- ═══ Warum überhaupt löschen ═══
--
-- Eine Adresse, die versehentlich geändert wurde, steht sonst für immer als
-- Änderung in der Akte — und die Person wohnt an der falschen Anschrift, bis
-- jemand sie von Hand zurücksetzt. Dieses Zurücksetzen erzeugt dann eine
-- *zweite* Änderung, und in der Historie stehen zwei Einträge, von denen
-- keiner je stattgefunden hat. Genau das soll der Vorgang hier ersparen.
--
-- ═══ „Die letztgültige Änderung ist die schlagende" ═══
--
-- Beim Zurücknehmen wird je Feld einzeln entschieden:
--
-- * Hat ein **späterer** Eintrag dasselbe Feld angefasst, bleibt der
-- heutige Wert stehen — die spätere Änderung ist die gültige.
-- * Sonst wird der Wert auf das „vorher" des gelöschten Eintrags gesetzt.
--
-- Deshalb lässt sich auch der mittlere von drei Einträgen entfernen, ohne
-- dass ein alter Wert einen neueren überschreibt.
--
-- ═══ Was nicht geht, und warum ═══
--
-- * **Eintritt** — der Anker der Zeitleiste. Ohne ihn hat die Person keinen
-- Anfang, und ein Trigger verbietet ohnehin Ereignisse davor.
-- * **Zukünftiges** — dazu gehört eine Zeile in pending_org_changes, und
-- die lässt sich einem Historieneintrag nicht zuverlässig zuordnen: es
-- gibt keinen Schlüssel zwischen beiden, nur Person und Datum. In den
-- Daten hängt bereits ein „Eintritt" und eine Vertragsänderung am selben
-- Tag. Eine Zuordnung über das Datum träfe irgendwann die falsche Zeile,
-- und dann verschwände eine geplante Änderung, die niemand gemeint hat.
-- * **Versetzung, Beförderung, Karenz, Rückkehr, Austritt, Wiedereintritt,
-- Reorganisation** — die haben Planstellen und Zuordnungen bewegt.
-- Dafür gibt es die fachlichen Vorgänge, die das sauber fortschreiben,
-- statt rückwärts zu raten.
-- * **Einträge ohne Feldwerte** — alles vor der Erweiterung der Historie.
-- Es gibt nichts, worauf zurückgesetzt werden könnte.
--
-- ═══ Der Nachweis bleibt ═══
--
-- Gelöscht wird die Historienzeile, nicht die Spur: das Audit-Log bekommt
-- einen Eintrag mit den Werten der gelöschten Zeile. Das Protokoll ist selbst
-- fortschreibend, dort kann nichts verschwinden.
create or replace function delete_history_entry(payload jsonb)
returns void
language plpgsql
security definer
set search_path to 'public', 'pg_temp'
as $function$
declare
v_id uuid := (payload->>'history_id')::uuid;
v_eintrag employee_history%rowtype;
v_name text;
v_aenderung jsonb;
v_feld text;
v_wert text;
v_spalte text;
v_typ text;
v_spaeter boolean;
v_zurueckgesetzt jsonb := '[]'::jsonb;
-- Feldbeschriftung → Spalte und Typ. Geschlossene Liste: was
-- change_employee_data schreiben kann, steht hier, sonst nichts. Der
-- Spaltenname geht in dynamisches SQL, deshalb darf er nur von hier kommen.
v_karte constant jsonb := jsonb_build_object(
'Vorname', jsonb_build_array('first_name', 'text'),
'Nachname', jsonb_build_array('last_name', 'text'),
'Geschlecht', jsonb_build_array('gender', 'gender_type'),
'Geburtsdatum', jsonb_build_array('birth_date', 'date'),
'SV-Nummer', jsonb_build_array('sv_nummer', 'text'),
'Staatsbürgerschaft', jsonb_build_array('nationality', 'text'),
'Adresse', jsonb_build_array('address', 'text'),
'Postleitzahl', jsonb_build_array('postal_code', 'text'),
'Ort', jsonb_build_array('city', 'text'),
'Land', jsonb_build_array('address_country', 'text'),
'E-Mail', jsonb_build_array('email', 'text'),
'Telefon', jsonb_build_array('phone', 'text'),
'Notfallkontakt', jsonb_build_array('emergency_contact_name', 'text'),
'Notfallkontakt Telefon', jsonb_build_array('emergency_contact_phone', 'text'),
'Notfallkontakt Verhältnis', jsonb_build_array('emergency_contact_relation', 'text'),
'Titel (vorangestellt)', jsonb_build_array('title_prefix', 'liste'),
'Titel (nachgestellt)', jsonb_build_array('title_suffix', 'liste'),
'Beschäftigungsausmaß', jsonb_build_array('employment_type', 'employment_type'),
'Wochenstunden', jsonb_build_array('weekly_hours', 'numeric'),
'Vertragsart', jsonb_build_array('contract_type', 'contract_type'),
'Befristet bis', jsonb_build_array('contract_end_date', 'date'),
'Angestellte:r/Arbeiter:in', jsonb_build_array('worker_type', 'worker_type'),
'Kollektivvertrag', jsonb_build_array('collective_agreement', 'collective_agreement'),
'Arbeitstage', jsonb_build_array('work_days', 'liste'),
'Betriebsrat', jsonb_build_array('is_betriebsrat', 'boolean'),
'Dienstwagen', jsonb_build_array('has_dienstwagen', 'boolean'),
'Laterale Führung', jsonb_build_array('is_laterale_fuehrung', 'boolean'),
'C-Level', jsonb_build_array('is_c_level', 'boolean'),
'Dienstwagen Antrieb', jsonb_build_array('dienstwagen_art', 'text')
);
begin
perform require_hr_admin();
select * into v_eintrag from employee_history where id = v_id;
if not found then
raise exception 'Historieneintrag nicht gefunden.';
end if;
if v_eintrag.event_type = 'Eintritt' then
raise exception 'Der Eintritt lässt sich nicht löschen — er ist der Anfang der Zeitleiste.';
end if;
if v_eintrag.event_type not in ('Stammdatenänderung', 'Vertragsänderung') then
raise exception 'Nur Stammdaten- und Vertragsänderungen lassen sich hier zurücknehmen. Für % gibt es den passenden Vorgang.', v_eintrag.event_type;
end if;
if v_eintrag.event_date > current_date then
raise exception 'Diese Änderung ist noch nicht wirksam und hängt an einem geplanten Vorgang. Sie muss dort abgebrochen werden.';
end if;
if v_eintrag.changes is null or jsonb_array_length(v_eintrag.changes) = 0 then
raise exception 'Zu diesem Eintrag sind keine Feldwerte erfasst — es gibt nichts, worauf zurückgesetzt werden könnte.';
end if;
select first_name || ' ' || last_name into v_name from employees where id = v_eintrag.employee_id;
-- Je Feld: nur zurücksetzen, wenn kein späterer Eintrag dasselbe Feld
-- angefasst hat. Sonst gilt der spätere Wert weiter.
for v_aenderung in select * from jsonb_array_elements(v_eintrag.changes) loop
v_feld := v_aenderung->>'feld';
if not v_karte ? v_feld then
continue; -- unbekannte Beschriftung: nichts anfassen
end if;
select exists (
select 1
from employee_history h,
lateral jsonb_array_elements(coalesce(h.changes, '[]'::jsonb)) a
where h.employee_id = v_eintrag.employee_id
and h.id <> v_eintrag.id
and a->>'feld' = v_feld
and (h.event_date, h.created_at) > (v_eintrag.event_date, v_eintrag.created_at)
) into v_spaeter;
if v_spaeter then
continue;
end if;
v_spalte := v_karte->v_feld->>0;
v_typ := v_karte->v_feld->>1;
v_wert := v_aenderung->>'vorher';
if v_typ = 'liste' then
execute format('update employees set %I = coalesce(string_to_array(%L, '', ''), ''{}'') where id = %L',
v_spalte, nullif(v_wert, ''), v_eintrag.employee_id);
else
execute format('update employees set %I = %L::%s where id = %L',
v_spalte, nullif(v_wert, ''), v_typ, v_eintrag.employee_id);
end if;
v_zurueckgesetzt := v_zurueckgesetzt || jsonb_build_object(
'feld', v_feld,
'vorher', v_aenderung->>'nachher',
'nachher', v_wert
);
end loop;
delete from employee_history where id = v_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(), 'Historieneintrag gelöscht', v_name, v_eintrag.employee_id,
v_eintrag.event_type || ' vom ' || v_eintrag.event_date ||
case when jsonb_array_length(v_zurueckgesetzt) = 0
then ' gelöscht; keine Werte zurückgesetzt (spätere Änderungen gelten)'
else ' gelöscht und zurückgesetzt: ' || app_aenderungsfelder(v_zurueckgesetzt) end,
v_zurueckgesetzt);
end;
$function$;
comment on function delete_history_entry(jsonb) is
'Nimmt eine irrtümliche Stammdaten- oder Vertragsänderung zurück: setzt je Feld auf den Wert davor, sofern kein späterer Eintrag dasselbe Feld geändert hat, und entfernt die Historienzeile. Der Vorgang selbst wird im Audit-Log festgehalten. SECURITY DEFINER, weil employee_history absichtlich keine delete-Policy hat.';
-- Selbstprüfung: lieber laut scheitern als still nichts tun.
do $$
declare
v_def text;
begin
select pg_get_functiondef('public.delete_history_entry(jsonb)'::regprocedure) into v_def;
if not (select prosecdef from pg_proc where oid = 'public.delete_history_entry(jsonb)'::regprocedure) then
raise exception 'delete_history_entry muss SECURITY DEFINER sein, sonst greift die fehlende delete-Policy';
end if;
if v_def not like '%require_hr_admin%' then
raise exception 'delete_history_entry prüft die Berechtigung nicht';
end if;
-- Die delete-Policy darf es weiterhin nicht geben: der Weg hier ist der
-- einzige, und er ist geprüft.
if exists (
select 1 from pg_policy p join pg_class c on c.oid = p.polrelid
where c.relname = 'employee_history' and p.polcmd = 'd'
) then
raise exception 'employee_history hat eine delete-Policy bekommen — das war nicht beabsichtigt';
end if;
end
$$;

View File

@@ -0,0 +1,185 @@
-- Zurücksetzen in einem Zug, nicht Feld für Feld.
--
-- Die erste Fassung schrieb je Feld ein eigenes UPDATE. Das ist bei
-- unabhängigen Feldern harmlos und bei gekoppelten falsch: chk_weekly_hours
-- verlangt, dass Beschäftigungsausmaß und Wochenstunden zueinander passen
-- (Vollzeit genau 38,5; Teilzeit dazwischen). Wird zuerst das Ausmaß auf
-- „Vollzeit" zurückgesetzt, während noch 37 Stunden dastehen, verbietet die
-- Bedingung genau diesen Zwischenstand — und das Löschen scheiterte mit
-- „new row for relation employees violates check constraint".
--
-- Es traf jede Rücknahme einer Vertragsänderung, die beide Felder betraf,
-- also praktisch jede: die Oberfläche ändert Ausmaß und Stunden zusammen.
--
-- Jetzt werden die Zuweisungen gesammelt und in einer einzigen Anweisung
-- geschrieben. Damit entsteht der verbotene Zwischenstand gar nicht — der
-- Zustand von davor war ja gültig, sonst stünde er nicht in der Historie.
--
-- Bleibt danach doch eine Verletzung, ist sie echt: dann hat eine spätere
-- Änderung eines der gekoppelten Felder einzeln angefasst, und der alte Wert
-- passt nicht mehr zum heutigen Stand. Dieser Fall wird abgefangen und als
-- Satz gemeldet, statt als Datenbankfehler durchzuschlagen.
CREATE OR REPLACE FUNCTION public.delete_history_entry(payload jsonb)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public', 'pg_temp'
AS $function$
declare
v_id uuid := (payload->>'history_id')::uuid;
v_eintrag employee_history%rowtype;
v_name text;
v_aenderung jsonb;
v_feld text;
v_wert text;
v_spalte text;
v_typ text;
v_spaeter boolean;
v_zurueckgesetzt jsonb := '[]'::jsonb;
v_setz text[] := '{}';
-- Feldbeschriftung → Spalte und Typ. Geschlossene Liste: was
-- change_employee_data schreiben kann, steht hier, sonst nichts. Der
-- Spaltenname geht in dynamisches SQL, deshalb darf er nur von hier kommen.
v_karte constant jsonb := jsonb_build_object(
'Vorname', jsonb_build_array('first_name', 'text'),
'Nachname', jsonb_build_array('last_name', 'text'),
'Geschlecht', jsonb_build_array('gender', 'gender_type'),
'Geburtsdatum', jsonb_build_array('birth_date', 'date'),
'SV-Nummer', jsonb_build_array('sv_nummer', 'text'),
'Staatsbürgerschaft', jsonb_build_array('nationality', 'text'),
'Adresse', jsonb_build_array('address', 'text'),
'Postleitzahl', jsonb_build_array('postal_code', 'text'),
'Ort', jsonb_build_array('city', 'text'),
'Land', jsonb_build_array('address_country', 'text'),
'E-Mail', jsonb_build_array('email', 'text'),
'Telefon', jsonb_build_array('phone', 'text'),
'Notfallkontakt', jsonb_build_array('emergency_contact_name', 'text'),
'Notfallkontakt Telefon', jsonb_build_array('emergency_contact_phone', 'text'),
'Notfallkontakt Verhältnis', jsonb_build_array('emergency_contact_relation', 'text'),
'Titel (vorangestellt)', jsonb_build_array('title_prefix', 'liste'),
'Titel (nachgestellt)', jsonb_build_array('title_suffix', 'liste'),
'Beschäftigungsausmaß', jsonb_build_array('employment_type', 'employment_type'),
'Wochenstunden', jsonb_build_array('weekly_hours', 'numeric'),
'Vertragsart', jsonb_build_array('contract_type', 'contract_type'),
'Befristet bis', jsonb_build_array('contract_end_date', 'date'),
'Angestellte:r/Arbeiter:in', jsonb_build_array('worker_type', 'worker_type'),
'Kollektivvertrag', jsonb_build_array('collective_agreement', 'collective_agreement'),
'Arbeitstage', jsonb_build_array('work_days', 'liste'),
'Betriebsrat', jsonb_build_array('is_betriebsrat', 'boolean'),
'Dienstwagen', jsonb_build_array('has_dienstwagen', 'boolean'),
'Laterale Führung', jsonb_build_array('is_laterale_fuehrung', 'boolean'),
'C-Level', jsonb_build_array('is_c_level', 'boolean'),
'Dienstwagen Antrieb', jsonb_build_array('dienstwagen_art', 'text')
);
begin
perform require_hr_admin();
select * into v_eintrag from employee_history where id = v_id;
if not found then
raise exception 'Historieneintrag nicht gefunden.';
end if;
if v_eintrag.event_type = 'Eintritt' then
raise exception 'Der Eintritt lässt sich nicht löschen — er ist der Anfang der Zeitleiste.';
end if;
if v_eintrag.event_type not in ('Stammdatenänderung', 'Vertragsänderung') then
raise exception 'Nur Stammdaten- und Vertragsänderungen lassen sich hier zurücknehmen. Für % gibt es den passenden Vorgang.', v_eintrag.event_type;
end if;
if v_eintrag.event_date > current_date then
raise exception 'Diese Änderung ist noch nicht wirksam und hängt an einem geplanten Vorgang. Sie muss dort abgebrochen werden.';
end if;
if v_eintrag.changes is null or jsonb_array_length(v_eintrag.changes) = 0 then
raise exception 'Zu diesem Eintrag sind keine Feldwerte erfasst — es gibt nichts, worauf zurückgesetzt werden könnte.';
end if;
select first_name || ' ' || last_name into v_name from employees where id = v_eintrag.employee_id;
-- Je Feld: nur zurücksetzen, wenn kein späterer Eintrag dasselbe Feld
-- angefasst hat. Sonst gilt der spätere Wert weiter.
for v_aenderung in select * from jsonb_array_elements(v_eintrag.changes) loop
v_feld := v_aenderung->>'feld';
if not v_karte ? v_feld then
continue; -- unbekannte Beschriftung: nichts anfassen
end if;
select exists (
select 1
from employee_history h,
lateral jsonb_array_elements(coalesce(h.changes, '[]'::jsonb)) a
where h.employee_id = v_eintrag.employee_id
and h.id <> v_eintrag.id
and a->>'feld' = v_feld
and (h.event_date, h.created_at) > (v_eintrag.event_date, v_eintrag.created_at)
) into v_spaeter;
if v_spaeter then
continue;
end if;
v_spalte := v_karte->v_feld->>0;
v_typ := v_karte->v_feld->>1;
v_wert := v_aenderung->>'vorher';
if v_typ = 'liste' then
v_setz := v_setz || format('%I = coalesce(string_to_array(%L, '', ''), ''{}'')', v_spalte, nullif(v_wert, ''));
else
v_setz := v_setz || format('%I = %L::%s', v_spalte, nullif(v_wert, ''), v_typ);
end if;
v_zurueckgesetzt := v_zurueckgesetzt || jsonb_build_object(
'feld', v_feld,
'vorher', v_aenderung->>'nachher',
'nachher', v_wert
);
end loop;
-- Alle Felder in *einem* UPDATE. Einzeln nacheinander zu schreiben war der
-- Fehler der ersten Fassung: chk_weekly_hours verknüpft Beschäftigungsausmaß
-- und Wochenstunden, und zwischen zwei getrennten Anweisungen steht
-- zwangsläufig ein Zwischenstand, den die Bedingung verbietet — „Vollzeit
-- mit 37 Stunden". Gemeinsam gesetzt gibt es diesen Zwischenstand nicht.
if array_length(v_setz, 1) > 0 then
begin
execute format('update employees set %s where id = %L', array_to_string(v_setz, ', '), v_eintrag.employee_id);
exception when check_violation then
-- Bleibt trotzdem etwas übrig: dann wurde eines von zwei zusammen-
-- gehörenden Feldern später einzeln geändert, und der alte Wert passt
-- nicht mehr zum heutigen Stand. Lieber verständlich abweisen.
raise exception 'Zurücksetzen nicht möglich: die Werte von damals passen nicht mehr zum heutigen Stand (%). Vermutlich wurde ein zusammengehörendes Feld später einzeln geändert.', sqlerrm;
end;
end if;
delete from employee_history where id = v_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(), 'Historieneintrag gelöscht', v_name, v_eintrag.employee_id,
v_eintrag.event_type || ' vom ' || v_eintrag.event_date ||
case when jsonb_array_length(v_zurueckgesetzt) = 0
then ' gelöscht; keine Werte zurückgesetzt (spätere Änderungen gelten)'
else ' gelöscht und zurückgesetzt: ' || app_aenderungsfelder(v_zurueckgesetzt) end,
v_zurueckgesetzt);
end;
$function$;
-- Selbstprüfung.
do $$
declare
v_def text := pg_get_functiondef('public.delete_history_entry(jsonb)'::regprocedure);
begin
if v_def like '%update employees set %I%' then
raise exception 'Es wird noch je Feld einzeln geschrieben';
end if;
if v_def not like '%array_to_string(v_setz%' then
raise exception 'Das gesammelte UPDATE fehlt';
end if;
if v_def not like '%check_violation%' then
raise exception 'Die Verletzung wird nicht abgefangen';
end if;
end
$$;

View File

@@ -0,0 +1,366 @@
-- Historieneinträge berichtigen — und die Feldtabelle nur noch einmal führen.
--
-- Drei Teile: die gemeinsame Feldtabelle, die bisher im Rumpf der
-- Löschfunktion stand; dieselbe Löschfunktion, nun auf die gemeinsame
-- Tabelle umgestellt; und das Berichtigen als neue Funktion.
-- Die Feldtabelle einmal, für alle, die sie brauchen.
--
-- Beschriftung → Spalte und Typ. Sie stand bisher im Rumpf von
-- delete_history_entry; mit dem Bearbeiten kam eine zweite Stelle dazu, die
-- sie genauso braucht. Zwei Kopien einer solchen Liste laufen auseinander,
-- sobald ein Feld hinzukommt — und dann lässt sich ein Feld löschen, aber
-- nicht korrigieren, ohne dass es jemandem auffällt.
--
-- Geschlossene Liste: was change_employee_data schreiben kann, steht hier,
-- sonst nichts. Die Spaltennamen gehen in dynamisches SQL und dürfen nur
-- von hier kommen.
create or replace function 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'),
'Nachname', jsonb_build_array('last_name', 'text'),
'Geschlecht', jsonb_build_array('gender', 'gender_type'),
'Geburtsdatum', jsonb_build_array('birth_date', 'date'),
'SV-Nummer', jsonb_build_array('sv_nummer', 'text'),
'Staatsbürgerschaft', jsonb_build_array('nationality', 'text'),
'Adresse', jsonb_build_array('address', 'text'),
'Postleitzahl', jsonb_build_array('postal_code', 'text'),
'Ort', jsonb_build_array('city', 'text'),
'Land', jsonb_build_array('address_country', 'text'),
'E-Mail', jsonb_build_array('email', 'text'),
'Telefon', jsonb_build_array('phone', 'text'),
'Notfallkontakt', jsonb_build_array('emergency_contact_name', 'text'),
'Notfallkontakt Telefon', jsonb_build_array('emergency_contact_phone', 'text'),
'Notfallkontakt Verhältnis', jsonb_build_array('emergency_contact_relation', 'text'),
'Titel (vorangestellt)', jsonb_build_array('title_prefix', 'liste'),
'Titel (nachgestellt)', jsonb_build_array('title_suffix', 'liste'),
'Beschäftigungsausmaß', jsonb_build_array('employment_type', 'employment_type'),
'Wochenstunden', jsonb_build_array('weekly_hours', 'numeric'),
'Vertragsart', jsonb_build_array('contract_type', 'contract_type'),
'Befristet bis', jsonb_build_array('contract_end_date', 'date'),
'Angestellte:r/Arbeiter:in', jsonb_build_array('worker_type', 'worker_type'),
'Kollektivvertrag', jsonb_build_array('collective_agreement', 'collective_agreement'),
'Arbeitstage', jsonb_build_array('work_days', 'liste'),
'Betriebsrat', jsonb_build_array('is_betriebsrat', 'boolean'),
'Dienstwagen', jsonb_build_array('has_dienstwagen', 'boolean'),
'Laterale Führung', jsonb_build_array('is_laterale_fuehrung', 'boolean'),
'C-Level', jsonb_build_array('is_c_level', 'boolean'),
'Dienstwagen Antrieb', jsonb_build_array('dienstwagen_art', 'text')
);
$function$;
comment on function app_feld_karte() is 'Beschriftung eines Feldes → [Spalte, Typ]. Quelle für das Zurücksetzen und Korrigieren von Historieneinträgen.';
CREATE OR REPLACE FUNCTION public.delete_history_entry(payload jsonb)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public', 'pg_temp'
AS $function$
declare
v_id uuid := (payload->>'history_id')::uuid;
v_eintrag employee_history%rowtype;
v_name text;
v_aenderung jsonb;
v_feld text;
v_wert text;
v_spalte text;
v_typ text;
v_spaeter boolean;
v_zurueckgesetzt jsonb := '[]'::jsonb;
v_setz text[] := '{}';
-- Feldbeschriftung → Spalte und Typ. Geschlossene Liste: was
-- change_employee_data schreiben kann, steht hier, sonst nichts. Der
-- Spaltenname geht in dynamisches SQL, deshalb darf er nur von hier kommen.
v_karte constant jsonb := app_feld_karte();
begin
perform require_hr_admin();
select * into v_eintrag from employee_history where id = v_id;
if not found then
raise exception 'Historieneintrag nicht gefunden.';
end if;
if v_eintrag.event_type = 'Eintritt' then
raise exception 'Der Eintritt lässt sich nicht löschen — er ist der Anfang der Zeitleiste.';
end if;
if v_eintrag.event_type not in ('Stammdatenänderung', 'Vertragsänderung') then
raise exception 'Nur Stammdaten- und Vertragsänderungen lassen sich hier zurücknehmen. Für % gibt es den passenden Vorgang.', v_eintrag.event_type;
end if;
if v_eintrag.event_date > current_date then
raise exception 'Diese Änderung ist noch nicht wirksam und hängt an einem geplanten Vorgang. Sie muss dort abgebrochen werden.';
end if;
if v_eintrag.changes is null or jsonb_array_length(v_eintrag.changes) = 0 then
raise exception 'Zu diesem Eintrag sind keine Feldwerte erfasst — es gibt nichts, worauf zurückgesetzt werden könnte.';
end if;
select first_name || ' ' || last_name into v_name from employees where id = v_eintrag.employee_id;
-- Je Feld: nur zurücksetzen, wenn kein späterer Eintrag dasselbe Feld
-- angefasst hat. Sonst gilt der spätere Wert weiter.
for v_aenderung in select * from jsonb_array_elements(v_eintrag.changes) loop
v_feld := v_aenderung->>'feld';
if not v_karte ? v_feld then
continue; -- unbekannte Beschriftung: nichts anfassen
end if;
select exists (
select 1
from employee_history h,
lateral jsonb_array_elements(coalesce(h.changes, '[]'::jsonb)) a
where h.employee_id = v_eintrag.employee_id
and h.id <> v_eintrag.id
and a->>'feld' = v_feld
and (h.event_date, h.created_at) > (v_eintrag.event_date, v_eintrag.created_at)
) into v_spaeter;
if v_spaeter then
continue;
end if;
v_spalte := v_karte->v_feld->>0;
v_typ := v_karte->v_feld->>1;
v_wert := v_aenderung->>'vorher';
if v_typ = 'liste' then
v_setz := v_setz || format('%I = coalesce(string_to_array(%L, '', ''), ''{}'')', v_spalte, nullif(v_wert, ''));
else
v_setz := v_setz || format('%I = %L::%s', v_spalte, nullif(v_wert, ''), v_typ);
end if;
v_zurueckgesetzt := v_zurueckgesetzt || jsonb_build_object(
'feld', v_feld,
'vorher', v_aenderung->>'nachher',
'nachher', v_wert
);
end loop;
-- Alle Felder in *einem* UPDATE. Einzeln nacheinander zu schreiben war der
-- Fehler der ersten Fassung: chk_weekly_hours verknüpft Beschäftigungsausmaß
-- und Wochenstunden, und zwischen zwei getrennten Anweisungen steht
-- zwangsläufig ein Zwischenstand, den die Bedingung verbietet — „Vollzeit
-- mit 37 Stunden". Gemeinsam gesetzt gibt es diesen Zwischenstand nicht.
if array_length(v_setz, 1) > 0 then
begin
execute format('update employees set %s where id = %L', array_to_string(v_setz, ', '), v_eintrag.employee_id);
exception when check_violation then
-- Bleibt trotzdem etwas übrig: dann wurde eines von zwei zusammen-
-- gehörenden Feldern später einzeln geändert, und der alte Wert passt
-- nicht mehr zum heutigen Stand. Lieber verständlich abweisen.
raise exception 'Zurücksetzen nicht möglich: die Werte von damals passen nicht mehr zum heutigen Stand (%). Vermutlich wurde ein zusammengehörendes Feld später einzeln geändert.', sqlerrm;
end;
end if;
delete from employee_history where id = v_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(), 'Historieneintrag gelöscht', v_name, v_eintrag.employee_id,
v_eintrag.event_type || ' vom ' || v_eintrag.event_date ||
case when jsonb_array_length(v_zurueckgesetzt) = 0
then ' gelöscht; keine Werte zurückgesetzt (spätere Änderungen gelten)'
else ' gelöscht und zurückgesetzt: ' || app_aenderungsfelder(v_zurueckgesetzt) end,
v_zurueckgesetzt);
end;
$function$;
-- Einen Historieneintrag berichtigen.
--
-- Löschen nimmt einen Eintrag zurück, der nie hätte entstehen dürfen. Hier
-- geht es um den anderen Fall: der Vorgang stimmt, aber der erfasste Wert
-- oder das Datum nicht. Ohne diesen Weg bliebe nur „löschen und neu
-- erfassen" — und dann stünden in der Akte zwei Einträge für eine Änderung,
-- von denen der erste nie stattgefunden hat.
--
-- Geändert werden darf das **Nachher** und das **Datum**. Das Vorher bleibt:
-- es beschreibt, was vor der Änderung galt, und das lässt sich nachträglich
-- nicht anders beschliessen.
--
-- ═══ Wie der heutige Stand danach zustande kommt ═══
--
-- Nicht durch Zurückrechnen, sondern durch Nachsehen: für jedes betroffene
-- Feld gewinnt der **jüngste** Historieneintrag, der es trägt. Das ist
-- dieselbe Regel wie beim Löschen — „die letztgültige Änderung ist die
-- schlagende" — nur von der anderen Seite gelesen, und sie trägt hier
-- zusätzlich den Fall, dass sich durch ein neues Datum die Reihenfolge
-- verschiebt.
--
-- ═══ Was nicht geht ═══
--
-- Dieselben Grenzen wie beim Löschen: kein Eintritt, nur Stammdaten- und
-- Vertragsänderungen, nichts Zukünftiges, nichts ohne Feldwerte. Ein Datum
-- in der Zukunft würde aus dem Eintrag eine geplante Änderung machen, und
-- die lebt in pending_org_changes — dorthin führt kein verlässlicher Weg
-- zurück (kein Schlüssel zwischen beiden Tabellen).
create or replace function update_history_entry(payload jsonb)
returns void
language plpgsql
security definer
set search_path to 'public', 'pg_temp'
as $function$
declare
v_id uuid := (payload->>'history_id')::uuid;
v_eintrag employee_history%rowtype;
v_datum date;
v_name text;
v_karte constant jsonb := app_feld_karte();
v_alt jsonb;
v_feld text;
v_neuer_wert text;
v_neu jsonb := '[]'::jsonb;
v_korrektur jsonb := '[]'::jsonb;
v_setz text[] := '{}';
v_spalte text;
v_typ text;
v_gueltig text;
begin
perform require_hr_admin();
select * into v_eintrag from employee_history where id = v_id;
if not found then
raise exception 'Historieneintrag nicht gefunden.';
end if;
if v_eintrag.event_type = 'Eintritt' then
raise exception 'Der Eintritt lässt sich hier nicht berichtigen.';
end if;
if v_eintrag.event_type not in ('Stammdatenänderung', 'Vertragsänderung') then
raise exception 'Nur Stammdaten- und Vertragsänderungen lassen sich hier berichtigen. Für % gibt es den passenden Vorgang.', v_eintrag.event_type;
end if;
if v_eintrag.event_date > current_date then
raise exception 'Diese Änderung ist noch nicht wirksam und hängt an einem geplanten Vorgang. Sie muss dort berichtigt werden.';
end if;
if v_eintrag.changes is null or jsonb_array_length(v_eintrag.changes) = 0 then
raise exception 'Zu diesem Eintrag sind keine Feldwerte erfasst — es gibt nichts zu berichtigen.';
end if;
v_datum := coalesce(nullif(payload->>'event_date', '')::date, v_eintrag.event_date);
if v_datum > current_date then
raise exception 'Ein Datum in der Zukunft macht daraus eine geplante Änderung. Dafür ist dieser Weg nicht gedacht.';
end if;
select first_name || ' ' || last_name into v_name from employees where id = v_eintrag.employee_id;
-- Neue Werteliste bauen: Vorher bleibt, Nachher darf ersetzt werden.
for v_alt in select * from jsonb_array_elements(v_eintrag.changes) loop
v_feld := v_alt->>'feld';
select w->>'nachher' into v_neuer_wert
from jsonb_array_elements(coalesce(payload->'werte', '[]'::jsonb)) w
where w->>'feld' = v_feld;
if v_neuer_wert is null then
v_neu := v_neu || v_alt;
else
v_neu := v_neu || jsonb_build_object('feld', v_feld, 'vorher', v_alt->>'vorher', 'nachher', nullif(v_neuer_wert, ''));
if coalesce(v_alt->>'nachher', '') is distinct from coalesce(nullif(v_neuer_wert, ''), '') then
v_korrektur := v_korrektur || jsonb_build_object('feld', v_feld, 'vorher', v_alt->>'nachher', 'nachher', nullif(v_neuer_wert, ''));
end if;
end if;
end loop;
if jsonb_array_length(v_korrektur) = 0 and v_datum = v_eintrag.event_date then
raise exception 'Nichts geändert.';
end if;
update employee_history
set changes = v_neu,
event_date = v_datum,
description = 'Geänderte Felder: ' || app_aenderungsfelder(v_neu) || ', wirksam ab ' || v_datum
where id = v_id;
-- Für jedes betroffene Feld den jüngsten Eintrag suchen, der es trägt, und
-- dessen Nachher setzen. Das schliesst den eben berichtigten Eintrag ein
-- und berücksichtigt ein verschobenes Datum von selbst.
for v_feld in select distinct e->>'feld' from jsonb_array_elements(v_neu) e loop
if not v_karte ? v_feld then
continue;
end if;
select a->>'nachher' into v_gueltig
from employee_history h,
lateral jsonb_array_elements(coalesce(h.changes, '[]'::jsonb)) a
where h.employee_id = v_eintrag.employee_id
and a->>'feld' = v_feld
order by h.event_date desc, h.created_at desc
limit 1;
v_spalte := v_karte->v_feld->>0;
v_typ := v_karte->v_feld->>1;
if v_typ = 'liste' then
v_setz := v_setz || format('%I = coalesce(string_to_array(%L, '', ''), ''{}'')', v_spalte, nullif(v_gueltig, ''));
else
v_setz := v_setz || format('%I = %L::%s', v_spalte, nullif(v_gueltig, ''), v_typ);
end if;
end loop;
-- Alles in einem UPDATE: chk_weekly_hours koppelt Beschäftigungsausmaß und
-- Wochenstunden, und zwischen zwei getrennten Anweisungen stünde ein
-- Zwischenstand, den die Bedingung verbietet.
if array_length(v_setz, 1) > 0 then
begin
execute format('update employees set %s where id = %L', array_to_string(v_setz, ', '), v_eintrag.employee_id);
exception when check_violation then
raise exception 'Der berichtigte Wert passt nicht zum übrigen Stand (%). Zusammengehörende Felder — etwa Beschäftigungsausmaß und Wochenstunden — müssen gemeinsam stimmen.', sqlerrm;
end;
end if;
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(), 'Historieneintrag berichtigt', v_name, v_eintrag.employee_id,
v_eintrag.event_type || ' vom ' || v_eintrag.event_date ||
case when v_datum <> v_eintrag.event_date then ' auf ' || v_datum || ' umdatiert' else '' end ||
case when jsonb_array_length(v_korrektur) > 0
then '; berichtigt: ' || app_aenderungsfelder(v_korrektur) else '' end,
v_korrektur);
end;
$function$;
comment on function update_history_entry(jsonb) is
'Berichtigt Wert und/oder Datum einer Stammdaten- oder Vertragsänderung. Der heutige Stand wird je Feld aus dem jüngsten Eintrag abgeleitet, der es trägt. SECURITY DEFINER, weil employee_history absichtlich keine update-Policy hat.';
-- Selbstprüfung.
do $$
declare
v_del text := pg_get_functiondef('public.delete_history_entry(jsonb)'::regprocedure);
v_upd text := pg_get_functiondef('public.update_history_entry(jsonb)'::regprocedure);
begin
if jsonb_typeof(app_feld_karte()) <> 'object' then
raise exception 'app_feld_karte liefert kein Objekt';
end if;
if not (app_feld_karte() ? 'Adresse' and app_feld_karte() ? 'Wochenstunden') then
raise exception 'Die Feldtabelle ist unvollständig';
end if;
if v_del not like '%app_feld_karte()%' then
raise exception 'delete_history_entry nutzt die gemeinsame Feldtabelle nicht';
end if;
if v_del like '%jsonb_build_array(''first_name''%' then
raise exception 'delete_history_entry trägt noch eine eigene Kopie der Feldtabelle';
end if;
if not (select prosecdef from pg_proc where oid = 'public.update_history_entry(jsonb)'::regprocedure) then
raise exception 'update_history_entry muss SECURITY DEFINER sein';
end if;
if v_upd not like '%require_hr_admin%' then
raise exception 'update_history_entry prüft die Berechtigung nicht';
end if;
-- Die Policies bleiben, wie sie sind.
if exists (
select 1 from pg_policy p join pg_class c on c.oid = p.polrelid
where c.relname = 'employee_history' and p.polcmd in ('d', 'w')
) then
raise exception 'employee_history hat eine update- oder delete-Policy bekommen';
end if;
end
$$;

View File

@@ -0,0 +1,681 @@
-- Auch geplante Änderungen lassen sich zurücknehmen und berichtigen.
--
-- Bisher endete beides an der Gegenwart: was noch nicht wirksam war, blieb
-- stehen. Der Grund war kein Prinzip, sondern eine fehlende Verbindung — eine
-- noch nicht wirksame Änderung lebt als payload in pending_org_changes, und
-- zwischen ihr und der Historienzeile gab es keinen Schlüssel, nur Person und
-- Datum. Darüber zu raten hätte irgendwann die falsche Zeile getroffen: in
-- den Daten liegen bereits ein Eintritt und eine Vertragsänderung am selben
-- Tag.
--
-- employee_history bekommt deshalb pending_id. change_employee_data setzt es,
-- wenn es eine geplante Änderung anlegt; für alles Wirksame bleibt es null.
--
-- Eine geplante Änderung kann **zwei** Historienzeilen tragen — Stammdaten
-- und Vertrag werden getrennt geführt, hängen aber am selben Vorgang. Deshalb
-- entfernt das Zurücknehmen nur die Felder der betroffenen Gruppe aus dem
-- payload und bricht den Vorgang nur ab, wenn danach nichts übrig bleibt.
--
-- Bestehende Zeilen werden verknüpft, wo es eindeutig ist: genau ein
-- laufender Vorgang der Person am selben Stichtag, den nicht schon eine
-- andere Zeile beansprucht. Alles andere bleibt ohne Bezug — und damit
-- weiterhin unantastbar, mit einer Meldung, die das sagt.
alter table employee_history
add column if not exists pending_id uuid references pending_org_changes(id) on delete set null;
comment on column employee_history.pending_id is
'Der geplante Vorgang, zu dem diese Zeile gehört; null, sobald die Änderung wirksam ist oder es nie einen Vorgang gab. Ohne diesen Bezug lässt sich eine geplante Änderung nicht zurücknehmen — Person und Datum allein sind nicht eindeutig.';
create index if not exists idx_employee_history_pending on employee_history (pending_id) where pending_id is not null;
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 ? '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 ? '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 ? '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 ? '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 = coalesce(v_person->>'sv_nummer', sv_nummer),
nationality = coalesce(v_person->>'nationality', nationality),
address = coalesce(v_person->>'address', address),
postal_code = coalesce(v_person->>'postal_code', postal_code),
city = coalesce(v_person->>'city', city),
address_country = coalesce(v_person->>'address_country', address_country),
email = coalesce(v_person->>'email', email),
phone = coalesce(v_person->>'phone', phone),
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,
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),
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),
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;
end;
$function$;
-- Die Feldtabelle: Beschriftung → Spalte, Typ und Gruppe.
--
-- Die Gruppe ist neu. Eine noch nicht wirksame Änderung liegt als payload in
-- pending_org_changes, und dort sind die Felder nach person / contract / role
-- sortiert — so, wie change_employee_data sie entgegennimmt. Wer eine
-- geplante Änderung berichtigen oder zurücknehmen will, muss wissen, unter
-- welchem Schlüssel ein Feld dort steht.
create or replace function 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'),
'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'),
'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'),
'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')
);
$function$;
comment on function app_feld_karte() is
'Beschriftung eines Feldes → [Spalte, Typ, Gruppe im payload]. Quelle für das Zurücksetzen, Berichtigen und Abbrechen von Historieneinträgen.';
-- Einen Historieneintrag zurücknehmen — samt seiner Wirkung.
--
-- Zwei Fälle, und sie sind grundverschieden:
--
-- **Bereits wirksam.** Die Änderung steht in den Stammdaten. Je Feld wird auf
-- den Wert davor zurückgesetzt — aber nur, wenn kein späterer Eintrag
-- dasselbe Feld angefasst hat; sonst gilt der spätere weiter. Das ist „die
-- letztgültige Änderung ist die schlagende".
--
-- **Noch nicht wirksam.** Es gibt nichts zurückzusetzen; die Änderung wartet
-- als payload in pending_org_changes. Zurückgenommen wird sie, indem ihre
-- Felder aus dem payload verschwinden. Bleibt danach nichts übrig, wird die
-- geplante Änderung abgebrochen — bleibt etwas, läuft sie mit dem Rest.
--
-- Der zweite Fall braucht einen verlässlichen Bezug zwischen Historienzeile
-- und geplanter Änderung. Den gab es nicht, und über Person und Datum zu
-- raten hätte irgendwann die falsche Zeile getroffen: in den Daten liegen
-- bereits ein Eintritt und eine Vertragsänderung am selben Tag. Deshalb
-- trägt employee_history jetzt pending_id.
--
-- Eine geplante Änderung kann **zwei** Historienzeilen haben — Stammdaten und
-- Vertrag werden getrennt geführt. Deshalb wird immer nur die Gruppe des
-- betroffenen Eintrags entfernt, nie der ganze payload.
create or replace function delete_history_entry(payload jsonb)
returns void
language plpgsql
security definer
set search_path to 'public', 'pg_temp'
as $function$
declare
v_id uuid := (payload->>'history_id')::uuid;
v_eintrag employee_history%rowtype;
v_name text;
v_karte constant jsonb := app_feld_karte();
v_aenderung jsonb;
v_feld text;
v_wert text;
v_spalte text;
v_typ text;
v_gruppe text;
v_spaeter boolean;
v_zurueckgesetzt jsonb := '[]'::jsonb;
v_setz text[] := '{}';
v_plan pending_org_changes%rowtype;
v_neuer_payload jsonb;
v_leer boolean;
begin
perform require_hr_admin();
select * into v_eintrag from employee_history where id = v_id;
if not found then
raise exception 'Historieneintrag nicht gefunden.';
end if;
if v_eintrag.event_type = 'Eintritt' then
raise exception 'Der Eintritt lässt sich nicht löschen — er ist der Anfang der Zeitleiste.';
end if;
if v_eintrag.event_type not in ('Stammdatenänderung', 'Vertragsänderung') then
raise exception 'Nur Stammdaten- und Vertragsänderungen lassen sich hier zurücknehmen. Für % gibt es den passenden Vorgang.', v_eintrag.event_type;
end if;
if v_eintrag.changes is null or jsonb_array_length(v_eintrag.changes) = 0 then
raise exception 'Zu diesem Eintrag sind keine Feldwerte erfasst — es gibt nichts, worauf zurückgesetzt werden könnte.';
end if;
select first_name || ' ' || last_name into v_name from employees where id = v_eintrag.employee_id;
-- ── Noch nicht wirksam: die geplante Änderung entschärfen ──────────
if v_eintrag.event_date > current_date then
if v_eintrag.pending_id is null then
raise exception 'Zu dieser geplanten Änderung ist kein Vorgang hinterlegt. Sie stammt aus der Zeit vor dieser Verknüpfung und lässt sich hier nicht abbrechen.';
end if;
select * into v_plan from pending_org_changes where id = v_eintrag.pending_id for update;
if not found or v_plan.status <> 'pending' then
raise exception 'Der geplante Vorgang läuft nicht mehr — er wurde bereits angewendet oder abgebrochen.';
end if;
v_neuer_payload := v_plan.payload;
for v_aenderung in select * from jsonb_array_elements(v_eintrag.changes) loop
v_feld := v_aenderung->>'feld';
if not v_karte ? v_feld then
continue;
end if;
v_spalte := v_karte->v_feld->>0;
v_gruppe := v_karte->v_feld->>2;
if v_neuer_payload ? v_gruppe then
v_neuer_payload := jsonb_set(v_neuer_payload, array[v_gruppe], (v_neuer_payload->v_gruppe) - v_spalte);
end if;
end loop;
v_leer := coalesce(jsonb_array_length(
(select jsonb_agg(k) from jsonb_object_keys(coalesce(v_neuer_payload->'person', '{}'::jsonb)) k)), 0) = 0
and coalesce(jsonb_array_length(
(select jsonb_agg(k) from jsonb_object_keys(coalesce(v_neuer_payload->'contract', '{}'::jsonb)) k)), 0) = 0
and coalesce(jsonb_array_length(
(select jsonb_agg(k) from jsonb_object_keys(coalesce(v_neuer_payload->'role', '{}'::jsonb)) k)), 0) = 0;
if v_leer then
update pending_org_changes set status = 'cancelled' where id = v_plan.id;
else
update pending_org_changes set payload = v_neuer_payload where id = v_plan.id;
end if;
delete from employee_history where id = v_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(), 'Geplante Änderung abgebrochen', v_name, v_eintrag.employee_id,
v_eintrag.event_type || ' zum ' || v_eintrag.event_date || ' abgebrochen: ' || app_aenderungsfelder(v_eintrag.changes) ||
case when v_leer then ' (der Vorgang entfällt ganz)' else ' (der Vorgang läuft mit den übrigen Feldern weiter)' end,
v_eintrag.changes);
return;
end if;
-- ── Bereits wirksam: Feld für Feld zurücksetzen ────────────────────
for v_aenderung in select * from jsonb_array_elements(v_eintrag.changes) loop
v_feld := v_aenderung->>'feld';
if not v_karte ? v_feld then
continue;
end if;
select exists (
select 1
from employee_history h,
lateral jsonb_array_elements(coalesce(h.changes, '[]'::jsonb)) a
where h.employee_id = v_eintrag.employee_id
and h.id <> v_eintrag.id
and a->>'feld' = v_feld
and (h.event_date, h.created_at) > (v_eintrag.event_date, v_eintrag.created_at)
) into v_spaeter;
if v_spaeter then
continue;
end if;
v_spalte := v_karte->v_feld->>0;
v_typ := v_karte->v_feld->>1;
v_wert := v_aenderung->>'vorher';
if v_typ = 'liste' then
v_setz := v_setz || format('%I = coalesce(string_to_array(%L, '', ''), ''{}'')', v_spalte, nullif(v_wert, ''));
else
v_setz := v_setz || format('%I = %L::%s', v_spalte, nullif(v_wert, ''), v_typ);
end if;
v_zurueckgesetzt := v_zurueckgesetzt || jsonb_build_object(
'feld', v_feld,
'vorher', v_aenderung->>'nachher',
'nachher', v_wert
);
end loop;
-- Alles in einem UPDATE: chk_weekly_hours verknüpft Beschäftigungsausmaß
-- und Wochenstunden, und zwischen zwei getrennten Anweisungen stünde
-- zwangsläufig ein Zwischenstand, den die Bedingung verbietet.
if array_length(v_setz, 1) > 0 then
begin
execute format('update employees set %s where id = %L', array_to_string(v_setz, ', '), v_eintrag.employee_id);
exception when check_violation then
raise exception 'Zurücksetzen nicht möglich: die Werte von damals passen nicht mehr zum heutigen Stand (%). Vermutlich wurde ein zusammengehörendes Feld später einzeln geändert.', sqlerrm;
end;
end if;
delete from employee_history where id = v_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(), 'Historieneintrag gelöscht', v_name, v_eintrag.employee_id,
v_eintrag.event_type || ' vom ' || v_eintrag.event_date ||
case when jsonb_array_length(v_zurueckgesetzt) = 0
then ' gelöscht; keine Werte zurückgesetzt (spätere Änderungen gelten)'
else ' gelöscht und zurückgesetzt: ' || app_aenderungsfelder(v_zurueckgesetzt) end,
v_zurueckgesetzt);
end;
$function$;
comment on function delete_history_entry(jsonb) is
'Nimmt eine Stammdaten- oder Vertragsänderung zurück. Bereits wirksam: setzt je Feld auf den Wert davor, sofern kein späterer Eintrag dasselbe Feld geändert hat. Noch nicht wirksam: entfernt die Felder aus dem geplanten Vorgang und bricht ihn ab, wenn nichts übrig bleibt. SECURITY DEFINER, weil employee_history absichtlich keine delete-Policy hat.';
-- Einen Historieneintrag berichtigen.
--
-- Löschen nimmt einen Eintrag zurück, der nie hätte entstehen dürfen. Hier
-- geht es um den anderen Fall: der Vorgang stimmt, aber der erfasste Wert
-- oder das Datum nicht. Ohne diesen Weg bliebe nur „löschen und neu
-- erfassen" — und dann stünden in der Akte zwei Einträge für eine Änderung,
-- von denen der erste nie stattgefunden hat.
--
-- Geändert wird das **Nachher** und das **Datum**. Das Vorher bleibt: es
-- beschreibt, was vor der Änderung galt, und das lässt sich nachträglich
-- nicht anders beschliessen.
--
-- Bereits wirksam: die Stammdaten werden nachgezogen, wobei je Feld der
-- jüngste Eintrag gewinnt, der es trägt — dieselbe Regel wie beim Löschen,
-- von der anderen Seite gelesen, und sie trägt zusätzlich den Fall, dass ein
-- neues Datum die Reihenfolge verschiebt.
--
-- Noch nicht wirksam: geändert wird der payload des geplanten Vorgangs und
-- sein Stichtag. An den Stammdaten passiert nichts — dort steht die Änderung
-- ja noch nicht.
create or replace function update_history_entry(payload jsonb)
returns void
language plpgsql
security definer
set search_path to 'public', 'pg_temp'
as $function$
declare
v_id uuid := (payload->>'history_id')::uuid;
v_eintrag employee_history%rowtype;
v_datum date;
v_name text;
v_karte constant jsonb := app_feld_karte();
v_alt jsonb;
v_feld text;
v_neuer_wert text;
v_neu jsonb := '[]'::jsonb;
v_korrektur jsonb := '[]'::jsonb;
v_setz text[] := '{}';
v_spalte text;
v_typ text;
v_gruppe text;
v_gueltig text;
v_plan pending_org_changes%rowtype;
v_neuer_payload jsonb;
v_war_zukunft boolean;
begin
perform require_hr_admin();
select * into v_eintrag from employee_history where id = v_id;
if not found then
raise exception 'Historieneintrag nicht gefunden.';
end if;
if v_eintrag.event_type = 'Eintritt' then
raise exception 'Der Eintritt lässt sich hier nicht berichtigen.';
end if;
if v_eintrag.event_type not in ('Stammdatenänderung', 'Vertragsänderung') then
raise exception 'Nur Stammdaten- und Vertragsänderungen lassen sich hier berichtigen. Für % gibt es den passenden Vorgang.', v_eintrag.event_type;
end if;
if v_eintrag.changes is null or jsonb_array_length(v_eintrag.changes) = 0 then
raise exception 'Zu diesem Eintrag sind keine Feldwerte erfasst — es gibt nichts zu berichtigen.';
end if;
v_war_zukunft := v_eintrag.event_date > current_date;
v_datum := coalesce(nullif(payload->>'event_date', '')::date, v_eintrag.event_date);
-- Ein Eintrag bleibt auf seiner Seite der Gegenwart. Beides zu erlauben
-- hiesse, eine gelaufene Änderung in eine geplante zu verwandeln (oder
-- umgekehrt) — dann müssten Stammdaten und payload gegenläufig angepasst
-- werden, und dafür gibt es die fachlichen Vorgänge.
if v_war_zukunft and v_datum <= current_date then
raise exception 'Eine geplante Änderung lässt sich hier nicht vorziehen. Dafür ist „Daten ändern" der richtige Weg.';
end if;
if not v_war_zukunft and v_datum > current_date then
raise exception 'Eine bereits wirksame Änderung lässt sich nicht in die Zukunft verschieben.';
end if;
select first_name || ' ' || last_name into v_name from employees where id = v_eintrag.employee_id;
-- Neue Werteliste bauen: Vorher bleibt, Nachher darf ersetzt werden.
for v_alt in select * from jsonb_array_elements(v_eintrag.changes) loop
v_feld := v_alt->>'feld';
select w->>'nachher' into v_neuer_wert
from jsonb_array_elements(coalesce(payload->'werte', '[]'::jsonb)) w
where w->>'feld' = v_feld;
if v_neuer_wert is null then
v_neu := v_neu || v_alt;
else
v_neu := v_neu || jsonb_build_object('feld', v_feld, 'vorher', v_alt->>'vorher', 'nachher', nullif(v_neuer_wert, ''));
if coalesce(v_alt->>'nachher', '') is distinct from coalesce(nullif(v_neuer_wert, ''), '') then
v_korrektur := v_korrektur || jsonb_build_object('feld', v_feld, 'vorher', v_alt->>'nachher', 'nachher', nullif(v_neuer_wert, ''));
end if;
end if;
end loop;
if jsonb_array_length(v_korrektur) = 0 and v_datum = v_eintrag.event_date then
raise exception 'Nichts geändert.';
end if;
update employee_history
set changes = v_neu,
event_date = v_datum,
description = 'Geänderte Felder: ' || app_aenderungsfelder(v_neu) || ', wirksam ab ' || v_datum
where id = v_id;
-- ── Noch nicht wirksam: den geplanten Vorgang nachziehen ───────────
if v_war_zukunft then
if v_eintrag.pending_id is null then
raise exception 'Zu dieser geplanten Änderung ist kein Vorgang hinterlegt. Sie stammt aus der Zeit vor dieser Verknüpfung und lässt sich hier nicht berichtigen.';
end if;
select * into v_plan from pending_org_changes where id = v_eintrag.pending_id for update;
if not found or v_plan.status <> 'pending' then
raise exception 'Der geplante Vorgang läuft nicht mehr — er wurde bereits angewendet oder abgebrochen.';
end if;
v_neuer_payload := jsonb_set(v_plan.payload, '{effective_date}', to_jsonb(v_datum::text));
for v_alt in select * from jsonb_array_elements(v_neu) loop
v_feld := v_alt->>'feld';
if not v_karte ? v_feld then
continue;
end if;
v_spalte := v_karte->v_feld->>0;
v_typ := v_karte->v_feld->>1;
v_gruppe := v_karte->v_feld->>2;
if not v_neuer_payload ? v_gruppe then
v_neuer_payload := jsonb_set(v_neuer_payload, array[v_gruppe], '{}'::jsonb);
end if;
v_neuer_payload := jsonb_set(
v_neuer_payload,
array[v_gruppe, v_spalte],
case
when v_alt->>'nachher' is null then 'null'::jsonb
when v_typ = 'liste' then to_jsonb(string_to_array(v_alt->>'nachher', ', '))
when v_typ = 'boolean' then to_jsonb((v_alt->>'nachher')::boolean)
when v_typ = 'numeric' then to_jsonb((v_alt->>'nachher')::numeric)
else to_jsonb(v_alt->>'nachher')
end,
true);
end loop;
update pending_org_changes
set payload = v_neuer_payload, effective_date = v_datum
where id = v_plan.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(), 'Geplante Änderung berichtigt', v_name, v_eintrag.employee_id,
v_eintrag.event_type || ' zum ' || v_eintrag.event_date ||
case when v_datum <> v_eintrag.event_date then ' auf ' || v_datum || ' verschoben' else '' end ||
case when jsonb_array_length(v_korrektur) > 0 then '; berichtigt: ' || app_aenderungsfelder(v_korrektur) else '' end,
v_korrektur);
return;
end if;
-- ── Bereits wirksam: Stammdaten nachziehen ─────────────────────────
for v_feld in select distinct e->>'feld' from jsonb_array_elements(v_neu) e loop
if not v_karte ? v_feld then
continue;
end if;
select a->>'nachher' into v_gueltig
from employee_history h,
lateral jsonb_array_elements(coalesce(h.changes, '[]'::jsonb)) a
where h.employee_id = v_eintrag.employee_id
and a->>'feld' = v_feld
and h.event_date <= current_date
order by h.event_date desc, h.created_at desc
limit 1;
v_spalte := v_karte->v_feld->>0;
v_typ := v_karte->v_feld->>1;
if v_typ = 'liste' then
v_setz := v_setz || format('%I = coalesce(string_to_array(%L, '', ''), ''{}'')', v_spalte, nullif(v_gueltig, ''));
else
v_setz := v_setz || format('%I = %L::%s', v_spalte, nullif(v_gueltig, ''), v_typ);
end if;
end loop;
if array_length(v_setz, 1) > 0 then
begin
execute format('update employees set %s where id = %L', array_to_string(v_setz, ', '), v_eintrag.employee_id);
exception when check_violation then
raise exception 'Der berichtigte Wert passt nicht zum übrigen Stand (%). Zusammengehörende Felder — etwa Beschäftigungsausmaß und Wochenstunden — müssen gemeinsam stimmen.', sqlerrm;
end;
end if;
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(), 'Historieneintrag berichtigt', v_name, v_eintrag.employee_id,
v_eintrag.event_type || ' vom ' || v_eintrag.event_date ||
case when v_datum <> v_eintrag.event_date then ' auf ' || v_datum || ' umdatiert' else '' end ||
case when jsonb_array_length(v_korrektur) > 0
then '; berichtigt: ' || app_aenderungsfelder(v_korrektur) else '' end,
v_korrektur);
end;
$function$;
comment on function update_history_entry(jsonb) is
'Berichtigt Wert und/oder Datum einer Stammdaten- oder Vertragsänderung. Bereits wirksam: die Stammdaten werden je Feld aus dem jüngsten wirksamen Eintrag abgeleitet. Noch nicht wirksam: payload und Stichtag des geplanten Vorgangs werden nachgezogen. SECURITY DEFINER, weil employee_history absichtlich keine update-Policy hat.';
-- Bestehende Zeilen verknüpfen, wo genau ein Vorgang in Frage kommt.
with kandidat as (
select h.id as history_id,
(select p.id
from pending_org_changes p
where p.employee_id = h.employee_id
and p.effective_date = h.event_date
and p.status = 'pending'
and p.change_type = 'contract_change'
and not exists (
select 1 from employee_history x
where x.pending_id = p.id and x.event_type = h.event_type
)
limit 2) as plan_id,
(select count(*)
from pending_org_changes p
where p.employee_id = h.employee_id
and p.effective_date = h.event_date
and p.status = 'pending'
and p.change_type = 'contract_change') as anzahl
from employee_history h
where h.event_date > current_date
and h.pending_id is null
and h.event_type in ('Stammdatenänderung', 'Vertragsänderung')
)
update employee_history h
set pending_id = k.plan_id
from kandidat k
where h.id = k.history_id
and k.anzahl = 1
and k.plan_id is not null;
-- Selbstprüfung.
do $$
declare
v_ced text := pg_get_functiondef('public.change_employee_data(jsonb)'::regprocedure);
v_del text := pg_get_functiondef('public.delete_history_entry(jsonb)'::regprocedure);
v_upd text := pg_get_functiondef('public.update_history_entry(jsonb)'::regprocedure);
begin
if not exists (
select 1 from information_schema.columns
where table_schema = 'public' and table_name = 'employee_history' and column_name = 'pending_id'
) then
raise exception 'employee_history.pending_id fehlt';
end if;
if (length(v_ced) - length(replace(v_ced, 'v_pending_id)', ''))) / length('v_pending_id)') <> 2 then
raise exception 'change_employee_data schreibt pending_id nicht in beide Historien-Einträge';
end if;
if v_ced not like '%returning id into v_pending_id%' then
raise exception 'change_employee_data merkt sich den angelegten Vorgang nicht';
end if;
if jsonb_array_length(app_feld_karte()->'Adresse') <> 3 then
raise exception 'Die Feldtabelle nennt die Gruppe nicht';
end if;
if app_feld_karte()->'Adresse'->>2 <> 'person' or app_feld_karte()->'Wochenstunden'->>2 <> 'contract' then
raise exception 'Die Gruppen in der Feldtabelle stimmen nicht';
end if;
if v_del not like '%pending_id is null%' or v_upd not like '%pending_id is null%' then
raise exception 'Der Zukunftsfall wird nicht behandelt';
end if;
-- Die Policies bleiben, wie sie sind.
if exists (
select 1 from pg_policy p join pg_class c on c.oid = p.polrelid
where c.relname = 'employee_history' and p.polcmd in ('d', 'w')
) then
raise exception 'employee_history hat eine update- oder delete-Policy bekommen';
end if;
end
$$;

View File

@@ -0,0 +1,113 @@
-- Wer nie angetreten ist: Austrittsgrund „No Show".
--
-- Der Fall gibt es, und bisher liess er sich nicht erfassen. Ein Austritt am
-- Eintrittstag scheiterte an chk_assignment_range: die Besetzung wurde auf
-- valid_to = valid_from geschlossen, und ein leeres Intervall ist dort
-- verboten. Ausweichen auf den Folgetag hätte bedeutet, einen Tag
-- Beschäftigung zu behaupten, den es nie gab — mit allem, was daran hängt:
-- Kopfzahl, Zugehörigkeit, Auswertungen zum Stichtag.
--
-- Drei Dinge macht dieser Grund deshalb anders:
--
-- * Das Austrittsdatum ist **immer** der Eintrittstag, unabhängig davon,
-- was übergeben wurde. Daraus folgt „nie aktiv" von selbst: als
-- beschäftigt gilt, wessen exit_date *nach* dem Stichtag liegt, und das
-- ist hier an keinem Tag der Fall.
-- * Die Planstellenzuordnung wird **entfernt**, nicht geschlossen. Die
-- Stelle war nie besetzt und ist wieder frei.
-- * Der Status springt sofort auf „Ausgetreten", auch bei einem Eintritt in
-- der Zukunft. Sonst bliebe in der Spalte „Geplant" stehen — es gibt
-- keinen Lauf, der das später nachzieht.
--
-- Die Bedingung unten hält das fest, egal auf welchem Weg jemand schreibt —
-- auch über den Import.
alter table employees drop constraint if exists chk_no_show_am_eintritt;
alter table employees add constraint chk_no_show_am_eintritt
check (exit_reason is distinct from 'No Show' or exit_date = entry_date);
comment on constraint chk_no_show_am_eintritt on employees is
'Ein Nichtantritt endet am Eintrittstag. Sonst gäbe es Tage, an denen die Person als beschäftigt zählte, obwohl sie nie da war. „is distinct from" statt „<>", damit ein leerer Grund nicht zu null auswertet und die Bedingung durchrutschen lässt.';
CREATE OR REPLACE FUNCTION public.terminate_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_exit date := (payload->>'exit_date')::date;
v_name text;
-- „No Show" ist kein gewöhnlicher Austritt: die Person hat nie
-- angefangen. Deshalb hängt an diesem einen Grund anderes Verhalten.
v_no_show boolean := coalesce(payload->>'exit_reason', '') = 'No Show';
v_entry date;
begin
perform require_hr_admin();
select first_name || ' ' || last_name, entry_date into v_name, v_entry
from employees where id = v_employee_id;
-- Wer nie angetreten ist, tritt am Tag seines Eintritts wieder aus.
-- Damit gibt es keinen einzigen Tag, an dem die Person beschäftigt war:
-- die Statusableitung verlangt exit_date > Stichtag, um jemanden als
-- beschäftigt zu zählen, und das ist hier nie erfüllt. „Nie aktiv" ist
-- damit keine zusätzliche Regel, sondern folgt aus dem Datum.
if v_no_show then
v_exit := v_entry;
end if;
update employees set
-- Bei einem Nichtantritt sofort, auch wenn der Eintritt noch in der
-- Zukunft lag: sonst bliebe in der Spalte auf Dauer „Geplant" stehen,
-- denn es gibt keinen Lauf, der sie später nachzieht.
status = case when v_no_show or v_exit <= current_date then 'Ausgetreten' else status end,
exit_date = v_exit,
exit_reason = payload->>'exit_reason'
where id = v_employee_id;
-- Die Planstelle wird frei. Direkte Berichte müssen nicht umgehängt
-- werden: die Berichtslinie wird abgeleitet und rutscht von selbst auf
-- die nächste besetzte Ebene.
if v_no_show then
-- Die Planstelle war nie besetzt. Sie auf [Eintritt, Eintritt) zu
-- schliessen ginge nicht — chk_assignment_range verlangt ein echtes
-- Intervall, und genau daran scheiterte ein Austritt am Eintrittstag
-- bisher. Die Zuordnung wird deshalb entfernt: die Stelle ist wieder
-- frei, und es steht nirgends, jemand hätte sie je innegehabt.
delete from position_assignments
where employee_id = v_employee_id and valid_to is null;
else
update position_assignments set valid_to = v_exit
where employee_id = v_employee_id and valid_to is null;
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_exit, 'Austritt',
case when v_no_show
then 'Kein Antritt am ' || v_entry || ' (No Show)'
else 'Austritt (' || coalesce(payload->>'exit_reason', '-') || ')' end);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (app_current_user_id(), current_actor_name(), 'Austritt', v_name, v_employee_id, case when v_no_show then 'Kein Antritt am ' || v_entry || ' (No Show)' else 'Austritt am ' || v_exit end);
end;
$function$;
-- Selbstprüfung.
do $$
declare
v_def text := pg_get_functiondef('public.terminate_employee(jsonb)'::regprocedure);
begin
if v_def not like '%v_no_show%' then
raise exception 'terminate_employee kennt den Nichtantritt nicht';
end if;
if v_def not like '%delete from position_assignments%' then
raise exception 'Die Zuordnung wird bei einem Nichtantritt nicht entfernt';
end if;
if not exists (
select 1 from pg_constraint where conname = 'chk_no_show_am_eintritt'
) then
raise exception 'Die Bedingung fehlt';
end if;
end
$$;

View File

@@ -0,0 +1,357 @@
-- Besonderer Kündigungsschutz.
--
-- Betriebsratsmitglieder, Schwangere, Eltern in Karenz, begünstigte
-- Behinderte, Lehrlinge, Präsenzdiener — für sie gelten eigene Regeln, bevor
-- ein Dienstverhältnis beendet werden darf. Das Werkzeug entscheidet das
-- nicht, aber es soll niemanden einen Austritt erfassen lassen, ohne es zu
-- erwähnen.
--
-- Zwei Spalten, wie beim Dienstwagen: ein Kennzeichen und eine Angabe, die
-- nur mit ihm zusammen Sinn ergibt. Das Datum ist **freiwillig** — bei einem
-- Betriebsratsmandat steht das Ende oft fest, bei einer Schwangerschaft
-- nicht, und ein Pflichtfeld zwänge dann zu einer erfundenen Zahl.
--
-- Die Bedingung sagt nur, was ohne das Kennzeichen nicht sein darf. Sie
-- verlangt umgekehrt kein Datum.
alter table employees
add column if not exists has_kuendigungsschutz boolean not null default false,
add column if not exists kuendigungsschutz_bis date;
comment on column employees.has_kuendigungsschutz is
'Besonderer Kündigungsschutz — Betriebsrat, Mutterschutz, Karenz, begünstigte Behinderung, Lehrverhältnis. Löst beim Austritt eine Warnung aus.';
comment on column employees.kuendigungsschutz_bis is
'Ende des Schutzes, falls bekannt. Freiwillig: bei einer Schwangerschaft steht es nicht fest, bei einem Mandat schon.';
alter table employees drop constraint if exists chk_kuendigungsschutz_bis;
alter table employees add constraint chk_kuendigungsschutz_bis
check (has_kuendigungsschutz or kuendigungsschutz_bis is null);
comment on constraint chk_kuendigungsschutz_bis on employees is
'Ein Enddatum ohne Schutz wäre ein Rest, den niemand mehr deuten kann. has_kuendigungsschutz ist NOT NULL, deshalb genügt hier die einfache Oder-Form — anders als bei chk_dienstwagen_art, wo eine nullbare Spalte die Bedingung sonst durchrutschen liesse.';
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'),
'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'),
'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'),
'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 bis', jsonb_build_array('kuendigungsschutz_bis', 'date', 'role')
);
$function$;
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;
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, 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, collective_agreement, work_days,
is_betriebsrat, has_dienstwagen, is_laterale_fuehrung, is_c_level,
has_kuendigungsschutz, kuendigungsschutz_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->>'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((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),
coalesce((payload->>'has_kuendigungsschutz')::boolean, false),
-- Das Datum nur, wenn der Schutz überhaupt gesetzt ist: sonst bliebe
-- ein Enddatum ohne Schutz stehen, und chk_kuendigungsschutz_bis
-- würde es zu Recht abweisen.
case when coalesce((payload->>'has_kuendigungsschutz')::boolean, false)
then nullif(payload->>'kuendigungsschutz_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$;
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 ? '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 ? '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 ? '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_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 ? '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 = coalesce(v_person->>'sv_nummer', sv_nummer),
nationality = coalesce(v_person->>'nationality', nationality),
address = coalesce(v_person->>'address', address),
postal_code = coalesce(v_person->>'postal_code', postal_code),
city = coalesce(v_person->>'city', city),
address_country = coalesce(v_person->>'address_country', address_country),
email = coalesce(v_person->>'email', email),
phone = coalesce(v_person->>'phone', phone),
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,
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),
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),
-- Fällt der Schutz weg, fällt das Datum mit. Andernfalls bliebe ein
-- Enddatum ohne Schutz stehen — die Bedingung verbietet das, und der
-- Vorgang schlüge fehl, statt das Offensichtliche zu tun.
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,
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;
end;
$function$;
-- Selbstprüfung.
do $$
declare
v_hire text := pg_get_functiondef('public.hire_employee(jsonb)'::regprocedure);
v_chg text := pg_get_functiondef('public.change_employee_data(jsonb)'::regprocedure);
begin
if v_hire not like '%has_kuendigungsschutz%' then
raise exception 'hire_employee nimmt den Kündigungsschutz nicht entgegen';
end if;
if v_chg not like '%Besonderer Kündigungsschutz%' then
raise exception 'change_employee_data protokolliert den Kündigungsschutz nicht';
end if;
if not (app_feld_karte() ? 'Besonderer Kündigungsschutz' and app_feld_karte() ? 'Kündigungsschutz bis') then
raise exception 'Die Feldtabelle kennt den Kündigungsschutz nicht — dann liesse sich ein Eintrag dazu nicht berichtigen';
end if;
if app_feld_karte()->'Kündigungsschutz bis'->>2 <> 'role' then
raise exception 'Die Gruppe im payload stimmt nicht';
end if;
end
$$;

View File

@@ -0,0 +1,268 @@
-- Teilzeiten sind keine Abwesenheiten.
--
-- Bildungsteilzeit, Elternteilzeit, Pflegeteilzeit und Wiedereingliederungs-
-- teilzeit standen in der Liste der Langzeitabwesenheiten. Wer so erfasst
-- wurde, galt als abwesend: die Person verschwand aus dem Bestand, ihre
-- Berichtslinie fiel an eine Vertretung, und in Auswertungen zählte sie nicht
-- mehr mit — obwohl sie jede Woche im Haus war, nur kürzer.
--
-- Sie wandern deshalb dorthin, wo sie hingehören:
--
-- * **Wiedereingliederungs- und Elternteilzeit** an die Rückkehr aus einer
-- Abwesenheit. Beide beginnen typischerweise genau dann, wenn die
-- Abwesenheit endet, und beide sind der Grund dafür, dass jemand mit
-- weniger Stunden zurückkommt.
-- * **Bildungs- und Pflegeteilzeit** an die Stundenänderung unter „Daten
-- ändern", neben der gewöhnlichen vertraglichen Änderung.
--
-- Der Grund wird **mit der Änderung** festgehalten, nicht als Zustand an der
-- Person. Ein Zustand müsste gepflegt werden — es gibt aber niemanden, der
-- nachträgt, wann eine Bildungsteilzeit endet, und ein Feld, das schleichend
-- veraltet, ist schlimmer als keines. In der Historie steht der Grund dort,
-- wo auch der geänderte Wert steht, und bleibt dort dauerhaft lesbar.
--
-- **Die Prüfbedingung auf absence_type bleibt unverändert.** Drei Personen
-- tragen die Werte gerade (Pflegeteilzeit, Wiedereingliederungsteilzeit); sie
-- zu verbieten hiesse, bestehende Zeilen ungültig zu machen. Aus der Auswahl
-- verschwinden sie, die Geschichte bleibt lesbar.
CREATE OR REPLACE FUNCTION public.record_karenz_return(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_return_date date := (payload->>'return_date')::date;
v_name text;
v_employment_type employment_type;
v_weekly_hours numeric;
v_karenz_start date;
v_absence_type text;
-- Warum jemand mit weniger Stunden zurückkommt: Wiedereingliederungs-
-- oder Elternteilzeit. Nur bedeutsam, wenn überhaupt reduziert wird.
v_grund text := nullif(payload->>'reduction_reason', '');
begin
perform require_hr_admin();
select first_name || ' ' || last_name, karenz_start_date, absence_type
into v_name, v_karenz_start, v_absence_type
from employees where id = v_employee_id;
if v_karenz_start is not null and v_return_date <= v_karenz_start then
raise exception 'Das Rückkehrdatum muss nach dem Beginn der Langzeitabwesenheit (%) liegen.', v_karenz_start;
end if;
if payload->>'employment_mode' = 'Vollzeit' then
v_employment_type := 'Vollzeit'; v_weekly_hours := 38.5;
elsif payload->>'employment_mode' = 'Teilzeit' then
v_employment_type := 'Teilzeit'; v_weekly_hours := (payload->>'weekly_hours')::numeric;
end if;
if v_return_date <= current_date then
-- Keine Manager-Nachführung mehr nötig: wer aus der Abwesenheit
-- zurückkehrt, ist wieder anwesend, und die abgeleitete Berichtslinie
-- fällt automatisch von der Vertretung auf ihn zurück.
update employees set
status = 'Aktiv',
karenz_return_date = null,
karenz_start_date = null,
absence_type = null,
employment_type = coalesce(v_employment_type, employment_type),
weekly_hours = coalesce(v_weekly_hours, weekly_hours)
where id = v_employee_id;
else
update employees set karenz_return_date = v_return_date where id = v_employee_id;
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'karenz_return', v_return_date,
jsonb_build_object('employment_type', v_employment_type, 'weekly_hours', v_weekly_hours));
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_return_date, 'Rückkehr',
'Rückkehr aus ' || coalesce(v_absence_type, 'Langzeitabwesenheit') || ' am ' || v_return_date
|| case when payload->>'employment_mode' = 'Teilzeit'
then ', reduziert auf ' || (payload->>'weekly_hours') || ' h'
|| coalesce(' (' || v_grund || ')', '')
else '' end);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (app_current_user_id(), current_actor_name(), 'Rückkehr', v_name, v_employee_id, 'Rückkehr am ' || v_return_date || coalesce(' — ' || v_grund, ''));
end;
$function$;
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[];
-- Warum sich die Stunden ändern. Kein Feld an der Person, sondern eine
-- Eigenschaft *dieser* Änderung — es gibt niemanden, der später
-- nachträgt, wann eine Bildungsteilzeit endet. In der Historie steht
-- der Grund damit dort, wo auch der Wert steht.
v_stunden_grund text := nullif(payload->>'hours_reason', '');
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 ? '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 ? '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 ? '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_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 ? '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 = coalesce(v_person->>'sv_nummer', sv_nummer),
nationality = coalesce(v_person->>'nationality', nationality),
address = coalesce(v_person->>'address', address),
postal_code = coalesce(v_person->>'postal_code', postal_code),
city = coalesce(v_person->>'city', city),
address_country = coalesce(v_person->>'address_country', address_country),
email = coalesce(v_person->>'email', email),
phone = coalesce(v_person->>'phone', phone),
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,
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),
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),
-- Fällt der Schutz weg, fällt das Datum mit. Andernfalls bliebe ein
-- Enddatum ohne Schutz stehen — die Bedingung verbietet das, und der
-- Vorgang schlüge fehl, statt das Offensichtliche zu tun.
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,
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
|| case when v_stunden_grund is not null and v_contract ? 'weekly_hours'
then ' — ' || v_stunden_grund else '' end,
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;
end;
$function$;
-- Selbstprüfung.
do $$
declare
v_ret text := pg_get_functiondef('public.record_karenz_return(jsonb)'::regprocedure);
v_chg text := pg_get_functiondef('public.change_employee_data(jsonb)'::regprocedure);
begin
if v_ret not like '%reduction_reason%' then
raise exception 'record_karenz_return nimmt den Grund nicht entgegen';
end if;
if v_chg not like '%hours_reason%' then
raise exception 'change_employee_data nimmt den Grund der Stundenänderung nicht entgegen';
end if;
end
$$;

View File

@@ -0,0 +1,350 @@
-- Die Teilzeitvariante als Zustand, nicht nur als Notiz.
--
-- Beim letzten Schritt sind die vier Teilzeiten aus der Abwesenheitsliste
-- gewandert und wurden mit der Änderung festgehalten — im Beschreibungstext
-- der Historie. Damit liess sich nachlesen, *dass* jemand in Bildungsteilzeit
-- ging, aber nicht auswerten, wer gerade in einer ist, und am Profil stand es
-- nirgends.
--
-- Also ein richtiges Feld: teilzeit_art, dazu ein freiwilliges Enddatum.
--
-- Der Einwand von damals — ein Zustand veraltet, weil niemand nachträgt, wann
-- eine Bildungsteilzeit endet — bleibt richtig und ist der Grund für
-- teilzeit_bis. Mit einem Enddatum kann eine Auswertung selbst entscheiden,
-- was noch läuft, statt sich auf gepflegte Daten zu verlassen. Bleibt das
-- Datum leer, heisst das „Ende offen", und das ist eine ehrliche Aussage.
--
-- Geführt wird das Feld über den gewöhnlichen Weg der Änderungen: es steht in
-- app_feld_karte, taucht in der Historie als Feld mit Vorher/Nachher auf und
-- lässt sich dort berichtigen wie jedes andere. Der Anhang am
-- Beschreibungstext aus dem letzten Schritt entfällt dafür — zweimal
-- dasselbe zu schreiben lädt nur dazu ein, dass die zwei Fassungen
-- auseinanderlaufen.
alter table employees
add column if not exists teilzeit_art text,
add column if not exists teilzeit_bis date;
comment on column employees.teilzeit_art is
'Bildungs-, Eltern-, Pflege- oder Wiedereingliederungsteilzeit; null bei einer gewöhnlichen vertraglichen Stundenregelung. Keine Abwesenheit — die Person arbeitet, nur kürzer.';
comment on column employees.teilzeit_bis is
'Ende der Teilzeit, falls bekannt. Freiwillig; leer heisst „Ende offen". Erlaubt Auswertungen darüber, was noch läuft, ohne auf nachgepflegte Daten angewiesen zu sein.';
alter table employees drop constraint if exists chk_teilzeit_art;
alter table employees add constraint chk_teilzeit_art
check (teilzeit_art is null or teilzeit_art in
('Bildungsteilzeit', 'Elternteilzeit', 'Pflegeteilzeit', 'Wiedereingliederungsteilzeit'));
alter table employees drop constraint if exists chk_teilzeit_bis;
alter table employees add constraint chk_teilzeit_bis
check (teilzeit_bis is null or teilzeit_art is not null);
comment on constraint chk_teilzeit_bis on employees is
'Ein Enddatum ohne Variante wäre ein Rest ohne Bezug. Umgekehrt ist eine Variante ohne Enddatum ausdrücklich erlaubt — nicht jede Teilzeit hat ein bekanntes Ende.';
create index if not exists idx_employees_teilzeit on employees (teilzeit_art) where teilzeit_art is not null;
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'),
'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'),
'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'),
'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 bis', jsonb_build_array('kuendigungsschutz_bis', 'date', 'role'),
'Teilzeitvariante', jsonb_build_array('teilzeit_art', 'text', 'role'),
'Teilzeit bis', jsonb_build_array('teilzeit_bis', 'date', 'role')
);
$function$;
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[];
-- Die Teilzeitvariante ist jetzt ein Feld an der Person (teilzeit_art)
-- und läuft über die gewöhnliche Änderungsliste. Der frühere Anhang am
-- Beschreibungstext ist damit weg — zweimal dasselbe zu schreiben lädt
-- nur dazu ein, dass die zwei Fassungen auseinanderlaufen.
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 ? '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 ? '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 ? '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_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 ? '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 = coalesce(v_person->>'sv_nummer', sv_nummer),
nationality = coalesce(v_person->>'nationality', nationality),
address = coalesce(v_person->>'address', address),
postal_code = coalesce(v_person->>'postal_code', postal_code),
city = coalesce(v_person->>'city', city),
address_country = coalesce(v_person->>'address_country', address_country),
email = coalesce(v_person->>'email', email),
phone = coalesce(v_person->>'phone', phone),
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,
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),
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),
-- Fällt der Schutz weg, fällt das Datum mit. Andernfalls bliebe ein
-- Enddatum ohne Schutz stehen — die Bedingung verbietet das, und der
-- Vorgang schlüge fehl, statt das Offensichtliche zu tun.
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,
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,
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;
end;
$function$;
CREATE OR REPLACE FUNCTION public.record_karenz_return(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_return_date date := (payload->>'return_date')::date;
v_name text;
v_employment_type employment_type;
v_weekly_hours numeric;
v_karenz_start date;
v_absence_type text;
-- Warum jemand mit weniger Stunden zurückkommt: Wiedereingliederungs-
-- oder Elternteilzeit. Nur bedeutsam, wenn überhaupt reduziert wird.
v_grund text := nullif(payload->>'reduction_reason', '');
begin
perform require_hr_admin();
select first_name || ' ' || last_name, karenz_start_date, absence_type
into v_name, v_karenz_start, v_absence_type
from employees where id = v_employee_id;
if v_karenz_start is not null and v_return_date <= v_karenz_start then
raise exception 'Das Rückkehrdatum muss nach dem Beginn der Langzeitabwesenheit (%) liegen.', v_karenz_start;
end if;
if payload->>'employment_mode' = 'Vollzeit' then
v_employment_type := 'Vollzeit'; v_weekly_hours := 38.5;
elsif payload->>'employment_mode' = 'Teilzeit' then
v_employment_type := 'Teilzeit'; v_weekly_hours := (payload->>'weekly_hours')::numeric;
end if;
if v_return_date <= current_date then
-- Keine Manager-Nachführung mehr nötig: wer aus der Abwesenheit
-- zurückkehrt, ist wieder anwesend, und die abgeleitete Berichtslinie
-- fällt automatisch von der Vertretung auf ihn zurück.
update employees set
status = 'Aktiv',
karenz_return_date = null,
karenz_start_date = null,
absence_type = null,
employment_type = coalesce(v_employment_type, employment_type),
weekly_hours = coalesce(v_weekly_hours, weekly_hours),
-- Kehrt jemand reduziert zurück, ist der Grund dafür ein Zustand,
-- kein Einmalereignis: danach lässt sich auswerten, wer gerade in
-- Eltern- oder Wiedereingliederungsteilzeit ist.
teilzeit_art = case when payload->>'employment_mode' = 'Teilzeit' then v_grund else teilzeit_art end,
teilzeit_bis = case
when payload->>'employment_mode' = 'Teilzeit' and v_grund is not null
then nullif(payload->>'teilzeit_bis', '')::date
when payload->>'employment_mode' = 'Teilzeit' then null
else teilzeit_bis end
where id = v_employee_id;
else
update employees set karenz_return_date = v_return_date where id = v_employee_id;
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'karenz_return', v_return_date,
jsonb_build_object('employment_type', v_employment_type, 'weekly_hours', v_weekly_hours,
'teilzeit_art', v_grund, 'teilzeit_bis', nullif(payload->>'teilzeit_bis', '')));
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, v_return_date, 'Rückkehr',
'Rückkehr aus ' || coalesce(v_absence_type, 'Langzeitabwesenheit') || ' am ' || v_return_date
|| case when payload->>'employment_mode' = 'Teilzeit'
then ', reduziert auf ' || (payload->>'weekly_hours') || ' h'
|| coalesce(' (' || v_grund || ')', '')
else '' end);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (app_current_user_id(), current_actor_name(), 'Rückkehr', v_name, v_employee_id, 'Rückkehr am ' || v_return_date || coalesce(' — ' || v_grund, ''));
end;
$function$;
-- Selbstprüfung.
do $$
declare
v_chg text := pg_get_functiondef('public.change_employee_data(jsonb)'::regprocedure);
v_ret text := pg_get_functiondef('public.record_karenz_return(jsonb)'::regprocedure);
begin
if not (app_feld_karte() ? 'Teilzeitvariante' and app_feld_karte() ? 'Teilzeit bis') then
raise exception 'Die Feldtabelle kennt die Teilzeitvariante nicht';
end if;
if v_chg not like '%teilzeit_art%' then
raise exception 'change_employee_data schreibt die Teilzeitvariante nicht';
end if;
if v_chg like '%hours_reason%' then
raise exception 'Der alte Anhang am Beschreibungstext steht noch drin';
end if;
if v_ret not like '%teilzeit_art%' then
raise exception 'record_karenz_return schreibt die Teilzeitvariante nicht';
end if;
end
$$;

View File

@@ -0,0 +1,119 @@
-- Die Teilzeitvariante übersteht auch eine geplante Rückkehr.
--
-- record_karenz_return legt für ein Rückkehrdatum in der Zukunft eine Zeile
-- in pending_org_changes an; der Tageslauf wendet sie an. Diese Zeile trug
-- bisher nur Beschäftigungsausmaß und Stunden — die Variante wäre am Stichtag
-- verlorengegangen, und jemand käme in Elternteilzeit zurück, ohne dass es
-- irgendwo stünde ausser im Beschreibungstext der Historie.
--
-- Aufgefallen beim Proben: die Probe hatte ein Rückkehrdatum in der Zukunft
-- gewählt und lief deshalb in genau diesen Zweig.
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 int := 0;
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
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;
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. Der Nachweis stünde nur in der Historie,
-- auswerten liesse sich nichts.
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
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 = coalesce(v_rec.payload->'person'->>'sv_nummer', sv_nummer),
nationality = coalesce(v_rec.payload->'person'->>'nationality', nationality),
address = coalesce(v_rec.payload->'person'->>'address', address),
postal_code = coalesce(v_rec.payload->'person'->>'postal_code', postal_code),
city = coalesce(v_rec.payload->'person'->>'city', city),
address_country = coalesce(v_rec.payload->'person'->>'address_country', address_country),
email = coalesce(v_rec.payload->'person'->>'email', email),
phone = coalesce(v_rec.payload->'person'->>'phone', phone),
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)
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_apply text := pg_get_functiondef('public.apply_due_pending_changes()'::regprocedure);
v_ret text := pg_get_functiondef('public.record_karenz_return(jsonb)'::regprocedure);
begin
if v_apply not like '%teilzeit_art%' then
raise exception 'Der Tageslauf überträgt die Teilzeitvariante nicht';
end if;
if v_ret not like '%teilzeit_art%' then
raise exception 'record_karenz_return legt die Variante nicht in den Vorgang';
end if;
end
$$;

View File

@@ -0,0 +1,607 @@
-- Abwesenheit und Rückkehr lassen sich zurücknehmen und berichtigen.
--
-- Bisher endete beides bei Stammdaten- und Vertragsänderungen. Eine
-- versehentlich erfasste Langzeitabwesenheit liess sich nur durch eine zweite
-- Buchung wieder loswerden — und dann standen zwei Einträge in der Akte, von
-- denen der erste nie stattgefunden hat.
--
-- Damit das Zurücknehmen den Stand wirklich wiederherstellt, halten
-- start_karenz und record_karenz_return jetzt ihre Vorher-Werte fest, so wie
-- change_employee_data es tut: Status, Art der Abwesenheit, Beginn, geplante
-- Rückkehr — bei der Rückkehr zusätzlich Beschäftigungsausmaß, Stunden und
-- Teilzeitvariante. Erst damit hat die vorhandene Rücknahme etwas, worauf sie
-- zurücksetzen kann.
--
-- ═══ Die Reihenfolge ═══
--
-- Eine Rückkehr setzt eine Abwesenheit voraus. Bliebe sie stehen, während die
-- Abwesenheit verschwindet, stünde in der Akte eine Rückkehr aus dem Nichts,
-- und der Status ergäbe sich aus einem Eintrag, dessen Ausgangslage gelöscht
-- ist. Deshalb: **eine Abwesenheit lässt sich erst löschen, wenn ihre
-- Rückkehr gelöscht ist.** Die Prüfung liegt in der Datenbank, nicht nur in
-- der Oberfläche.
--
-- Zeilen von vor dieser Migration tragen keine Vorher-Werte und bleiben
-- deshalb unantastbar — mit derselben Meldung wie bisher.
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'),
'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'),
'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'),
'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 bis', jsonb_build_array('kuendigungsschutz_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. Wer sie in einer
-- geplanten Änderung setzen wollte, hätte keinen Ort dafür — deshalb
-- weisen delete_/update_history_entry solche Einträge in der Zukunft
-- ab, statt an einer fehlenden Gruppe zu scheitern.
'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$;
CREATE OR REPLACE FUNCTION public.start_karenz(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_start_date date := (payload->>'karenz_start_date')::date;
v_absence_type text := nullif(payload->>'absence_type', '');
v_name text;
v_old employees%rowtype;
-- Ohne Vorher-Werte liesse sich eine irrtümlich erfasste Abwesenheit
-- nicht zurücknehmen: es stünde nirgends, was vorher galt.
v_changes jsonb := '[]'::jsonb;
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_changes := app_aenderung(v_changes, 'Status', v_old.status::text,
case when v_start_date <= current_date then 'Karenz' else v_old.status::text end);
v_changes := app_aenderung(v_changes, 'Art der Abwesenheit', v_old.absence_type, v_absence_type);
v_changes := app_aenderung(v_changes, 'Abwesend ab', v_old.karenz_start_date::text, v_start_date::text);
v_changes := app_aenderung(v_changes, 'Geplante Rückkehr', v_old.karenz_return_date::text, payload->>'planned_return_date');
if v_start_date <= current_date then
update employees set status = 'Karenz', karenz_start_date = v_start_date,
karenz_return_date = (payload->>'planned_return_date')::date,
absence_type = v_absence_type
where id = v_employee_id;
else
update employees set karenz_start_date = v_start_date where id = v_employee_id;
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'karenz_start', v_start_date,
jsonb_build_object('planned_return_date', payload->>'planned_return_date', 'absence_type', v_absence_type));
end if;
insert into employee_history (employee_id, event_date, event_type, description, changes)
values (v_employee_id, v_start_date, 'Karenz',
coalesce(v_absence_type, 'Langzeitabwesenheit') || ', geplante Rückkehr am ' || (payload->>'planned_return_date') ||
case when payload->>'note' is not null and payload->>'note' <> '' then ' — ' || (payload->>'note') else '' end,
v_changes);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (app_current_user_id(), current_actor_name(), 'Karenz', v_name, v_employee_id,
coalesce(v_absence_type, 'Langzeitabwesenheit') || ', geplante Rückkehr ' || (payload->>'planned_return_date'));
end;
$function$;
CREATE OR REPLACE FUNCTION public.record_karenz_return(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_return_date date := (payload->>'return_date')::date;
v_name text;
v_employment_type employment_type;
v_weekly_hours numeric;
v_karenz_start date;
v_absence_type text;
v_old employees%rowtype;
-- Wie bei der Abwesenheit: ohne Vorher-Werte liesse sich eine
-- irrtümlich erfasste Rückkehr nicht zurücknehmen.
v_changes jsonb := '[]'::jsonb;
-- Warum jemand mit weniger Stunden zurückkommt: Wiedereingliederungs-
-- oder Elternteilzeit. Nur bedeutsam, wenn überhaupt reduziert wird.
v_grund text := nullif(payload->>'reduction_reason', '');
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_karenz_start := v_old.karenz_start_date;
v_absence_type := v_old.absence_type;
if v_karenz_start is not null and v_return_date <= v_karenz_start then
raise exception 'Das Rückkehrdatum muss nach dem Beginn der Langzeitabwesenheit (%) liegen.', v_karenz_start;
end if;
if payload->>'employment_mode' = 'Vollzeit' then
v_employment_type := 'Vollzeit'; v_weekly_hours := 38.5;
elsif payload->>'employment_mode' = 'Teilzeit' then
v_employment_type := 'Teilzeit'; v_weekly_hours := (payload->>'weekly_hours')::numeric;
end if;
if v_return_date <= current_date then
-- Keine Manager-Nachführung mehr nötig: wer aus der Abwesenheit
-- zurückkehrt, ist wieder anwesend, und die abgeleitete Berichtslinie
-- fällt automatisch von der Vertretung auf ihn zurück.
update employees set
status = 'Aktiv',
karenz_return_date = null,
karenz_start_date = null,
absence_type = null,
employment_type = coalesce(v_employment_type, employment_type),
weekly_hours = coalesce(v_weekly_hours, weekly_hours),
-- Kehrt jemand reduziert zurück, ist der Grund dafür ein Zustand,
-- kein Einmalereignis: danach lässt sich auswerten, wer gerade in
-- Eltern- oder Wiedereingliederungsteilzeit ist.
teilzeit_art = case when payload->>'employment_mode' = 'Teilzeit' then v_grund else teilzeit_art end,
teilzeit_bis = case
when payload->>'employment_mode' = 'Teilzeit' and v_grund is not null
then nullif(payload->>'teilzeit_bis', '')::date
when payload->>'employment_mode' = 'Teilzeit' then null
else teilzeit_bis end
where id = v_employee_id;
else
update employees set karenz_return_date = v_return_date where id = v_employee_id;
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'karenz_return', v_return_date,
jsonb_build_object('employment_type', v_employment_type, 'weekly_hours', v_weekly_hours,
'teilzeit_art', v_grund, 'teilzeit_bis', nullif(payload->>'teilzeit_bis', '')));
end if;
if v_return_date <= current_date then
v_changes := app_aenderung(v_changes, 'Status', v_old.status::text, 'Aktiv');
v_changes := app_aenderung(v_changes, 'Art der Abwesenheit', v_old.absence_type, null);
v_changes := app_aenderung(v_changes, 'Abwesend ab', v_old.karenz_start_date::text, null);
v_changes := app_aenderung(v_changes, 'Geplante Rückkehr', v_old.karenz_return_date::text, null);
if v_employment_type is not null then
v_changes := app_aenderung(v_changes, 'Beschäftigungsausmaß', v_old.employment_type::text, v_employment_type::text);
v_changes := app_aenderung(v_changes, 'Wochenstunden', v_old.weekly_hours::text, v_weekly_hours::text);
end if;
if payload->>'employment_mode' = 'Teilzeit' then
v_changes := app_aenderung(v_changes, 'Teilzeitvariante', v_old.teilzeit_art, v_grund);
v_changes := app_aenderung(v_changes, 'Teilzeit bis', v_old.teilzeit_bis::text,
case when v_grund is not null then nullif(payload->>'teilzeit_bis', '') else null end);
end if;
end if;
insert into employee_history (employee_id, event_date, event_type, description, changes)
values (v_employee_id, v_return_date, 'Rückkehr',
'Rückkehr aus ' || coalesce(v_absence_type, 'Langzeitabwesenheit') || ' am ' || v_return_date
|| case when payload->>'employment_mode' = 'Teilzeit'
then ', reduziert auf ' || (payload->>'weekly_hours') || ' h'
|| coalesce(' (' || v_grund || ')', '')
else '' end,
v_changes);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (app_current_user_id(), current_actor_name(), 'Rückkehr', v_name, v_employee_id, 'Rückkehr am ' || v_return_date || coalesce(' — ' || v_grund, ''));
end;
$function$;
CREATE OR REPLACE FUNCTION public.delete_history_entry(payload jsonb)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public', 'pg_temp'
AS $function$
declare
v_id uuid := (payload->>'history_id')::uuid;
v_eintrag employee_history%rowtype;
v_name text;
v_karte constant jsonb := app_feld_karte();
v_aenderung jsonb;
v_feld text;
v_wert text;
v_spalte text;
v_typ text;
v_gruppe text;
v_spaeter boolean;
v_zurueckgesetzt jsonb := '[]'::jsonb;
v_setz text[] := '{}';
v_plan pending_org_changes%rowtype;
v_neuer_payload jsonb;
v_leer boolean;
begin
perform require_hr_admin();
select * into v_eintrag from employee_history where id = v_id;
if not found then
raise exception 'Historieneintrag nicht gefunden.';
end if;
if v_eintrag.event_type = 'Eintritt' then
raise exception 'Der Eintritt lässt sich nicht löschen — er ist der Anfang der Zeitleiste.';
end if;
if v_eintrag.event_type not in ('Stammdatenänderung', 'Vertragsänderung', 'Karenz', 'Rückkehr') then
raise exception 'Dieser Vorgang lässt sich hier nicht zurücknehmen. Für % gibt es den passenden Weg.', v_eintrag.event_type;
end if;
-- Die Reihenfolge zählt: eine Rückkehr setzt eine Abwesenheit voraus.
-- Bliebe sie stehen, während die Abwesenheit verschwindet, stünde in der
-- Akte eine Rückkehr aus dem Nichts — und der Status ergäbe sich aus
-- einem Eintrag, dessen Ausgangslage gelöscht ist.
if v_eintrag.event_type = 'Karenz' and exists (
select 1 from employee_history h
where h.employee_id = v_eintrag.employee_id
and h.event_type = 'Rückkehr'
and (h.event_date, h.created_at) > (v_eintrag.event_date, v_eintrag.created_at)
) then
raise exception 'Zu dieser Abwesenheit gibt es eine Rückkehr. Sie muss zuerst gelöscht werden.';
end if;
-- Abwesenheit und Rückkehr haben eigene Vorgänge; in einer geplanten
-- Änderung haben ihre Felder keinen Ort (siehe app_feld_karte).
if v_eintrag.event_type in ('Karenz', 'Rückkehr') and v_eintrag.event_date > current_date then
raise exception 'Diese Abwesenheit ist noch nicht wirksam. Sie muss über den Vorgang selbst abgebrochen werden.';
end if;
if v_eintrag.changes is null or jsonb_array_length(v_eintrag.changes) = 0 then
raise exception 'Zu diesem Eintrag sind keine Feldwerte erfasst — es gibt nichts, worauf zurückgesetzt werden könnte.';
end if;
select first_name || ' ' || last_name into v_name from employees where id = v_eintrag.employee_id;
-- ── Noch nicht wirksam: die geplante Änderung entschärfen ──────────
if v_eintrag.event_date > current_date then
if v_eintrag.pending_id is null then
raise exception 'Zu dieser geplanten Änderung ist kein Vorgang hinterlegt. Sie stammt aus der Zeit vor dieser Verknüpfung und lässt sich hier nicht abbrechen.';
end if;
select * into v_plan from pending_org_changes where id = v_eintrag.pending_id for update;
if not found or v_plan.status <> 'pending' then
raise exception 'Der geplante Vorgang läuft nicht mehr — er wurde bereits angewendet oder abgebrochen.';
end if;
v_neuer_payload := v_plan.payload;
for v_aenderung in select * from jsonb_array_elements(v_eintrag.changes) loop
v_feld := v_aenderung->>'feld';
if not v_karte ? v_feld then
continue;
end if;
v_spalte := v_karte->v_feld->>0;
v_gruppe := v_karte->v_feld->>2;
if v_neuer_payload ? v_gruppe then
v_neuer_payload := jsonb_set(v_neuer_payload, array[v_gruppe], (v_neuer_payload->v_gruppe) - v_spalte);
end if;
end loop;
v_leer := coalesce(jsonb_array_length(
(select jsonb_agg(k) from jsonb_object_keys(coalesce(v_neuer_payload->'person', '{}'::jsonb)) k)), 0) = 0
and coalesce(jsonb_array_length(
(select jsonb_agg(k) from jsonb_object_keys(coalesce(v_neuer_payload->'contract', '{}'::jsonb)) k)), 0) = 0
and coalesce(jsonb_array_length(
(select jsonb_agg(k) from jsonb_object_keys(coalesce(v_neuer_payload->'role', '{}'::jsonb)) k)), 0) = 0;
if v_leer then
update pending_org_changes set status = 'cancelled' where id = v_plan.id;
else
update pending_org_changes set payload = v_neuer_payload where id = v_plan.id;
end if;
delete from employee_history where id = v_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(), 'Geplante Änderung abgebrochen', v_name, v_eintrag.employee_id,
v_eintrag.event_type || ' zum ' || v_eintrag.event_date || ' abgebrochen: ' || app_aenderungsfelder(v_eintrag.changes) ||
case when v_leer then ' (der Vorgang entfällt ganz)' else ' (der Vorgang läuft mit den übrigen Feldern weiter)' end,
v_eintrag.changes);
return;
end if;
-- ── Bereits wirksam: Feld für Feld zurücksetzen ────────────────────
for v_aenderung in select * from jsonb_array_elements(v_eintrag.changes) loop
v_feld := v_aenderung->>'feld';
if not v_karte ? v_feld then
continue;
end if;
select exists (
select 1
from employee_history h,
lateral jsonb_array_elements(coalesce(h.changes, '[]'::jsonb)) a
where h.employee_id = v_eintrag.employee_id
and h.id <> v_eintrag.id
and a->>'feld' = v_feld
and (h.event_date, h.created_at) > (v_eintrag.event_date, v_eintrag.created_at)
) into v_spaeter;
if v_spaeter then
continue;
end if;
v_spalte := v_karte->v_feld->>0;
v_typ := v_karte->v_feld->>1;
v_wert := v_aenderung->>'vorher';
if v_typ = 'liste' then
v_setz := v_setz || format('%I = coalesce(string_to_array(%L, '', ''), ''{}'')', v_spalte, nullif(v_wert, ''));
else
v_setz := v_setz || format('%I = %L::%s', v_spalte, nullif(v_wert, ''), v_typ);
end if;
v_zurueckgesetzt := v_zurueckgesetzt || jsonb_build_object(
'feld', v_feld,
'vorher', v_aenderung->>'nachher',
'nachher', v_wert
);
end loop;
-- Alles in einem UPDATE: chk_weekly_hours verknüpft Beschäftigungsausmaß
-- und Wochenstunden, und zwischen zwei getrennten Anweisungen stünde
-- zwangsläufig ein Zwischenstand, den die Bedingung verbietet.
if array_length(v_setz, 1) > 0 then
begin
execute format('update employees set %s where id = %L', array_to_string(v_setz, ', '), v_eintrag.employee_id);
exception when check_violation then
raise exception 'Zurücksetzen nicht möglich: die Werte von damals passen nicht mehr zum heutigen Stand (%). Vermutlich wurde ein zusammengehörendes Feld später einzeln geändert.', sqlerrm;
end;
end if;
delete from employee_history where id = v_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(), 'Historieneintrag gelöscht', v_name, v_eintrag.employee_id,
v_eintrag.event_type || ' vom ' || v_eintrag.event_date ||
case when jsonb_array_length(v_zurueckgesetzt) = 0
then ' gelöscht; keine Werte zurückgesetzt (spätere Änderungen gelten)'
else ' gelöscht und zurückgesetzt: ' || app_aenderungsfelder(v_zurueckgesetzt) end,
v_zurueckgesetzt);
end;
$function$;
CREATE OR REPLACE FUNCTION public.update_history_entry(payload jsonb)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public', 'pg_temp'
AS $function$
declare
v_id uuid := (payload->>'history_id')::uuid;
v_eintrag employee_history%rowtype;
v_datum date;
v_name text;
v_karte constant jsonb := app_feld_karte();
v_alt jsonb;
v_feld text;
v_neuer_wert text;
v_neu jsonb := '[]'::jsonb;
v_korrektur jsonb := '[]'::jsonb;
v_setz text[] := '{}';
v_spalte text;
v_typ text;
v_gruppe text;
v_gueltig text;
v_plan pending_org_changes%rowtype;
v_neuer_payload jsonb;
v_war_zukunft boolean;
begin
perform require_hr_admin();
select * into v_eintrag from employee_history where id = v_id;
if not found then
raise exception 'Historieneintrag nicht gefunden.';
end if;
if v_eintrag.event_type = 'Eintritt' then
raise exception 'Der Eintritt lässt sich hier nicht berichtigen.';
end if;
if v_eintrag.event_type not in ('Stammdatenänderung', 'Vertragsänderung', 'Karenz', 'Rückkehr') then
raise exception 'Dieser Vorgang lässt sich hier nicht berichtigen. Für % gibt es den passenden Weg.', v_eintrag.event_type;
end if;
if v_eintrag.event_type in ('Karenz', 'Rückkehr') and v_eintrag.event_date > current_date then
raise exception 'Diese Abwesenheit ist noch nicht wirksam. Sie muss über den Vorgang selbst berichtigt werden.';
end if;
if v_eintrag.changes is null or jsonb_array_length(v_eintrag.changes) = 0 then
raise exception 'Zu diesem Eintrag sind keine Feldwerte erfasst — es gibt nichts zu berichtigen.';
end if;
v_war_zukunft := v_eintrag.event_date > current_date;
v_datum := coalesce(nullif(payload->>'event_date', '')::date, v_eintrag.event_date);
-- Ein Eintrag bleibt auf seiner Seite der Gegenwart. Beides zu erlauben
-- hiesse, eine gelaufene Änderung in eine geplante zu verwandeln (oder
-- umgekehrt) — dann müssten Stammdaten und payload gegenläufig angepasst
-- werden, und dafür gibt es die fachlichen Vorgänge.
if v_war_zukunft and v_datum <= current_date then
raise exception 'Eine geplante Änderung lässt sich hier nicht vorziehen. Dafür ist „Daten ändern" der richtige Weg.';
end if;
if not v_war_zukunft and v_datum > current_date then
raise exception 'Eine bereits wirksame Änderung lässt sich nicht in die Zukunft verschieben.';
end if;
select first_name || ' ' || last_name into v_name from employees where id = v_eintrag.employee_id;
-- Neue Werteliste bauen: Vorher bleibt, Nachher darf ersetzt werden.
for v_alt in select * from jsonb_array_elements(v_eintrag.changes) loop
v_feld := v_alt->>'feld';
select w->>'nachher' into v_neuer_wert
from jsonb_array_elements(coalesce(payload->'werte', '[]'::jsonb)) w
where w->>'feld' = v_feld;
if v_neuer_wert is null then
v_neu := v_neu || v_alt;
else
v_neu := v_neu || jsonb_build_object('feld', v_feld, 'vorher', v_alt->>'vorher', 'nachher', nullif(v_neuer_wert, ''));
if coalesce(v_alt->>'nachher', '') is distinct from coalesce(nullif(v_neuer_wert, ''), '') then
v_korrektur := v_korrektur || jsonb_build_object('feld', v_feld, 'vorher', v_alt->>'nachher', 'nachher', nullif(v_neuer_wert, ''));
end if;
end if;
end loop;
if jsonb_array_length(v_korrektur) = 0 and v_datum = v_eintrag.event_date then
raise exception 'Nichts geändert.';
end if;
update employee_history
set changes = v_neu,
event_date = v_datum,
description = 'Geänderte Felder: ' || app_aenderungsfelder(v_neu) || ', wirksam ab ' || v_datum
where id = v_id;
-- ── Noch nicht wirksam: den geplanten Vorgang nachziehen ───────────
if v_war_zukunft then
if v_eintrag.pending_id is null then
raise exception 'Zu dieser geplanten Änderung ist kein Vorgang hinterlegt. Sie stammt aus der Zeit vor dieser Verknüpfung und lässt sich hier nicht berichtigen.';
end if;
select * into v_plan from pending_org_changes where id = v_eintrag.pending_id for update;
if not found or v_plan.status <> 'pending' then
raise exception 'Der geplante Vorgang läuft nicht mehr — er wurde bereits angewendet oder abgebrochen.';
end if;
v_neuer_payload := jsonb_set(v_plan.payload, '{effective_date}', to_jsonb(v_datum::text));
for v_alt in select * from jsonb_array_elements(v_neu) loop
v_feld := v_alt->>'feld';
if not v_karte ? v_feld then
continue;
end if;
v_spalte := v_karte->v_feld->>0;
v_typ := v_karte->v_feld->>1;
v_gruppe := v_karte->v_feld->>2;
if not v_neuer_payload ? v_gruppe then
v_neuer_payload := jsonb_set(v_neuer_payload, array[v_gruppe], '{}'::jsonb);
end if;
v_neuer_payload := jsonb_set(
v_neuer_payload,
array[v_gruppe, v_spalte],
case
when v_alt->>'nachher' is null then 'null'::jsonb
when v_typ = 'liste' then to_jsonb(string_to_array(v_alt->>'nachher', ', '))
when v_typ = 'boolean' then to_jsonb((v_alt->>'nachher')::boolean)
when v_typ = 'numeric' then to_jsonb((v_alt->>'nachher')::numeric)
else to_jsonb(v_alt->>'nachher')
end,
true);
end loop;
update pending_org_changes
set payload = v_neuer_payload, effective_date = v_datum
where id = v_plan.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(), 'Geplante Änderung berichtigt', v_name, v_eintrag.employee_id,
v_eintrag.event_type || ' zum ' || v_eintrag.event_date ||
case when v_datum <> v_eintrag.event_date then ' auf ' || v_datum || ' verschoben' else '' end ||
case when jsonb_array_length(v_korrektur) > 0 then '; berichtigt: ' || app_aenderungsfelder(v_korrektur) else '' end,
v_korrektur);
return;
end if;
-- ── Bereits wirksam: Stammdaten nachziehen ─────────────────────────
for v_feld in select distinct e->>'feld' from jsonb_array_elements(v_neu) e loop
if not v_karte ? v_feld then
continue;
end if;
select a->>'nachher' into v_gueltig
from employee_history h,
lateral jsonb_array_elements(coalesce(h.changes, '[]'::jsonb)) a
where h.employee_id = v_eintrag.employee_id
and a->>'feld' = v_feld
and h.event_date <= current_date
order by h.event_date desc, h.created_at desc
limit 1;
v_spalte := v_karte->v_feld->>0;
v_typ := v_karte->v_feld->>1;
if v_typ = 'liste' then
v_setz := v_setz || format('%I = coalesce(string_to_array(%L, '', ''), ''{}'')', v_spalte, nullif(v_gueltig, ''));
else
v_setz := v_setz || format('%I = %L::%s', v_spalte, nullif(v_gueltig, ''), v_typ);
end if;
end loop;
if array_length(v_setz, 1) > 0 then
begin
execute format('update employees set %s where id = %L', array_to_string(v_setz, ', '), v_eintrag.employee_id);
exception when check_violation then
raise exception 'Der berichtigte Wert passt nicht zum übrigen Stand (%). Zusammengehörende Felder — etwa Beschäftigungsausmaß und Wochenstunden — müssen gemeinsam stimmen.', sqlerrm;
end;
end if;
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(), 'Historieneintrag berichtigt', v_name, v_eintrag.employee_id,
v_eintrag.event_type || ' vom ' || v_eintrag.event_date ||
case when v_datum <> v_eintrag.event_date then ' auf ' || v_datum || ' umdatiert' else '' end ||
case when jsonb_array_length(v_korrektur) > 0
then '; berichtigt: ' || app_aenderungsfelder(v_korrektur) else '' end,
v_korrektur);
end;
$function$;
-- Selbstprüfung.
do $$
declare
v_start text := pg_get_functiondef('public.start_karenz(jsonb)'::regprocedure);
v_ret text := pg_get_functiondef('public.record_karenz_return(jsonb)'::regprocedure);
v_del text := pg_get_functiondef('public.delete_history_entry(jsonb)'::regprocedure);
v_upd text := pg_get_functiondef('public.update_history_entry(jsonb)'::regprocedure);
begin
if v_start not like '%description, changes%' then
raise exception 'start_karenz hält keine Vorher-Werte fest';
end if;
if v_ret not like '%description, changes%' then
raise exception 'record_karenz_return hält keine Vorher-Werte fest';
end if;
if v_del not like '%muss zuerst gelöscht werden%' then
raise exception 'Die Reihenfolgeregel fehlt';
end if;
if v_upd not like '%''Karenz'', ''Rückkehr''%' then
raise exception 'update_history_entry lässt die zwei Typen nicht zu';
end if;
if not (app_feld_karte() ? 'Art der Abwesenheit' and app_feld_karte() ? 'Status') then
raise exception 'Die Feldtabelle kennt die Felder der Abwesenheit nicht';
end if;
end
$$;

View File

@@ -0,0 +1,414 @@
-- Aufenthaltstitel — für alle ohne Freizügigkeit.
--
-- EU-, EWR- und Schweizer Staatsangehörige brauchen keinen; für alle übrigen
-- ist er Voraussetzung der Beschäftigung, und dann will die Personalabteilung
-- wissen, bis wann er läuft. Rund achtzig Personen im Bestand betrifft das.
--
-- Zwei Spalten, dasselbe Muster wie beim Kündigungsschutz: ein Kennzeichen
-- und ein Datum, das nur mit ihm zusammen Sinn ergibt. Das Datum ist
-- freiwillig — ein unbefristeter Titel hat keines, und ein Pflichtfeld zwänge
-- dort zu einer erfundenen Zahl.
--
-- ═══ Was die Datenbank *nicht* prüft ═══
--
-- Die Kopplung an die Staatsbürgerschaft steht bewusst nicht als Bedingung
-- hier drin. Sie hätte zwei Nachteile, die schwerer wiegen als der Gewinn:
--
-- * Die Länderliste müsste doppelt geführt werden — einmal in SQL, einmal
-- in lib/countries.ts, wo die Oberfläche sie ohnehin braucht. Zwei Listen
-- laufen auseinander, und ein EU-Beitritt wäre dann eine Migration statt
-- einer Zeile.
-- * Eine Korrektur der Staatsbürgerschaft würde an der Bedingung scheitern,
-- solange der Titel noch dransteht — also genau in dem Moment, in dem
-- jemand einen Fehler geradebiegt.
--
-- Stattdessen entscheidet die Oberfläche anhand der Liste, ob die Felder
-- überhaupt erscheinen, und räumt sie weg, sobald die Staatsbürgerschaft in
-- den Freizügigkeitsraum wechselt.
alter table employees
add column if not exists hat_aufenthaltstitel boolean not null default false,
add column if not exists aufenthaltstitel_bis date;
comment on column employees.hat_aufenthaltstitel is
'Aufenthaltstitel vorhanden. Nur bei Staatsangehörigkeiten ausserhalb von EU, EWR und Schweiz erhoben — siehe lib/countries.ts.';
comment on column employees.aufenthaltstitel_bis is
'Gültig bis, falls befristet. Freiwillig; leer heisst unbefristet oder nicht erfasst.';
alter table employees drop constraint if exists chk_aufenthaltstitel_bis;
alter table employees add constraint chk_aufenthaltstitel_bis
check (hat_aufenthaltstitel or aufenthaltstitel_bis is null);
comment on constraint chk_aufenthaltstitel_bis on employees is
'Ein Ablaufdatum ohne Titel wäre ein Rest ohne Bezug. hat_aufenthaltstitel ist NOT NULL, deshalb genügt die einfache Oder-Form.';
create index if not exists idx_employees_aufenthaltstitel_bis
on employees (aufenthaltstitel_bis) where aufenthaltstitel_bis is not null;
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'),
'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'),
'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 bis', jsonb_build_array('kuendigungsschutz_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. Wer sie in einer
-- geplanten Änderung setzen wollte, hätte keinen Ort dafür — deshalb
-- weisen delete_/update_history_entry solche Einträge in der Zukunft
-- ab, statt an einer fehlenden Gruppe zu scheitern.
'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$;
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;
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, 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, collective_agreement, work_days,
is_betriebsrat, has_dienstwagen, is_laterale_fuehrung, is_c_level,
has_kuendigungsschutz, kuendigungsschutz_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->>'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((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),
coalesce((payload->>'has_kuendigungsschutz')::boolean, false),
-- Das Datum nur, wenn der Schutz überhaupt gesetzt ist: sonst bliebe
-- ein Enddatum ohne Schutz stehen, und chk_kuendigungsschutz_bis
-- würde es zu Recht abweisen.
case when coalesce((payload->>'has_kuendigungsschutz')::boolean, false)
then nullif(payload->>'kuendigungsschutz_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$;
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[];
-- Die Teilzeitvariante ist jetzt ein Feld an der Person (teilzeit_art)
-- und läuft über die gewöhnliche Änderungsliste. Der frühere Anhang am
-- Beschreibungstext ist damit weg — zweimal dasselbe zu schreiben lädt
-- nur dazu ein, dass die zwei Fassungen auseinanderlaufen.
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 ? '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 ? '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_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 ? '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 = coalesce(v_person->>'sv_nummer', sv_nummer),
nationality = coalesce(v_person->>'nationality', nationality),
address = coalesce(v_person->>'address', address),
postal_code = coalesce(v_person->>'postal_code', postal_code),
city = coalesce(v_person->>'city', city),
address_country = coalesce(v_person->>'address_country', address_country),
email = coalesce(v_person->>'email', email),
phone = coalesce(v_person->>'phone', phone),
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),
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),
-- Fällt der Schutz weg, fällt das Datum mit. Andernfalls bliebe ein
-- Enddatum ohne Schutz stehen — die Bedingung verbietet das, und der
-- Vorgang schlüge fehl, statt das Offensichtliche zu tun.
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,
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,
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;
end;
$function$;
-- Selbstprüfung.
do $$
declare
v_hire text := pg_get_functiondef('public.hire_employee(jsonb)'::regprocedure);
v_chg text := pg_get_functiondef('public.change_employee_data(jsonb)'::regprocedure);
begin
if v_hire not like '%hat_aufenthaltstitel%' then
raise exception 'hire_employee nimmt den Aufenthaltstitel nicht entgegen';
end if;
if v_chg not like '%Aufenthaltstitel bis%' then
raise exception 'change_employee_data protokolliert den Aufenthaltstitel nicht';
end if;
if app_feld_karte()->'Aufenthaltstitel'->>2 <> 'person' then
raise exception 'Die Gruppe im payload stimmt nicht';
end if;
end
$$;

View File

@@ -0,0 +1,413 @@
-- Das Eintrittsdatum berichtigen — und eine Rückkehr nur aus einer Abwesenheit.
--
-- ═══ Eine Rückkehr setzt eine Abwesenheit voraus ═══
--
-- Diese Prüfung fehlte. In den Daten steht eine Person mit **zwei** Rückkehren
-- zu einer Abwesenheit: die zweite wurde erfasst, als sie längst wieder aktiv
-- war, und eine dritte war noch geplant. Der Status ergäbe sich danach aus
-- einem Ereignis, das nie stattgefunden hat.
--
-- Zwei Bedingungen also: die Person muss abwesend sein, und es darf nicht
-- schon eine Rückkehr geplant sein — eine zweite würde die erste am Stichtag
-- stillschweigend überschreiben.
--
-- ═══ Das Eintrittsdatum ═══
--
-- Der Eintritt ist der Anfang der Zeitleiste und lässt sich nicht löschen.
-- Sein Datum kann aber falsch erfasst sein, und dann hängt daran mehr als
-- eine Zahl. Beim Ändern wird deshalb geprüft:
--
-- * Kein anderes Ereignis darf davor liegen — ein Trigger verbietet es
-- ohnehin, hier steht der Grund lesbar statt als Auslösermeldung.
-- * Austritt und Abwesenheitsbeginn dürfen nicht davor rutschen.
-- * Die erste Planstellenbesetzung wandert mit. Bliebe sie stehen, gäbe es
-- Tage mit Beschäftigung ohne Stelle oder umgekehrt.
--
-- Anders als die übrigen Einträge braucht der Eintritt dafür keine in
-- `changes` hinterlegten Vorher-Werte: der alte Wert steht in employees.
-- Damit funktioniert das auch für Zeilen, die lange vor dieser Erweiterung
-- entstanden sind — und das ist der Fall, um den es geht.
CREATE OR REPLACE FUNCTION public.record_karenz_return(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_return_date date := (payload->>'return_date')::date;
v_name text;
v_employment_type employment_type;
v_weekly_hours numeric;
v_karenz_start date;
v_absence_type text;
v_old employees%rowtype;
-- Wie bei der Abwesenheit: ohne Vorher-Werte liesse sich eine
-- irrtümlich erfasste Rückkehr nicht zurücknehmen.
v_changes jsonb := '[]'::jsonb;
-- Warum jemand mit weniger Stunden zurückkommt: Wiedereingliederungs-
-- oder Elternteilzeit. Nur bedeutsam, wenn überhaupt reduziert wird.
v_grund text := nullif(payload->>'reduction_reason', '');
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_karenz_start := v_old.karenz_start_date;
v_absence_type := v_old.absence_type;
-- Ohne Abwesenheit keine Rückkehr. Die Prüfung fehlte, und in den Daten
-- steht eine Person mit zwei Rückkehren zu einer Abwesenheit: die zweite
-- wurde erfasst, als sie längst wieder aktiv war. Der Status wäre danach
-- aus einem Ereignis abgeleitet, das nie stattgefunden hat.
if v_old.status <> 'Karenz' and v_old.karenz_start_date is null then
raise exception 'Diese Person ist nicht abwesend — eine Rückkehr gibt es nur aus einer Abwesenheit.';
end if;
-- Und nur eine: eine zweite geplante Rückkehr würde die erste am
-- Stichtag stillschweigend überschreiben.
if exists (
select 1 from pending_org_changes p
where p.employee_id = v_employee_id and p.change_type = 'karenz_return' and p.status = 'pending'
) then
raise exception 'Für diese Person ist bereits eine Rückkehr geplant. Sie muss zuerst zurückgenommen werden.';
end if;
if v_karenz_start is not null and v_return_date <= v_karenz_start then
raise exception 'Das Rückkehrdatum muss nach dem Beginn der Langzeitabwesenheit (%) liegen.', v_karenz_start;
end if;
if payload->>'employment_mode' = 'Vollzeit' then
v_employment_type := 'Vollzeit'; v_weekly_hours := 38.5;
elsif payload->>'employment_mode' = 'Teilzeit' then
v_employment_type := 'Teilzeit'; v_weekly_hours := (payload->>'weekly_hours')::numeric;
end if;
if v_return_date <= current_date then
-- Keine Manager-Nachführung mehr nötig: wer aus der Abwesenheit
-- zurückkehrt, ist wieder anwesend, und die abgeleitete Berichtslinie
-- fällt automatisch von der Vertretung auf ihn zurück.
update employees set
status = 'Aktiv',
karenz_return_date = null,
karenz_start_date = null,
absence_type = null,
employment_type = coalesce(v_employment_type, employment_type),
weekly_hours = coalesce(v_weekly_hours, weekly_hours),
-- Kehrt jemand reduziert zurück, ist der Grund dafür ein Zustand,
-- kein Einmalereignis: danach lässt sich auswerten, wer gerade in
-- Eltern- oder Wiedereingliederungsteilzeit ist.
teilzeit_art = case when payload->>'employment_mode' = 'Teilzeit' then v_grund else teilzeit_art end,
teilzeit_bis = case
when payload->>'employment_mode' = 'Teilzeit' and v_grund is not null
then nullif(payload->>'teilzeit_bis', '')::date
when payload->>'employment_mode' = 'Teilzeit' then null
else teilzeit_bis end
where id = v_employee_id;
else
update employees set karenz_return_date = v_return_date where id = v_employee_id;
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'karenz_return', v_return_date,
jsonb_build_object('employment_type', v_employment_type, 'weekly_hours', v_weekly_hours,
'teilzeit_art', v_grund, 'teilzeit_bis', nullif(payload->>'teilzeit_bis', '')));
end if;
if v_return_date <= current_date then
v_changes := app_aenderung(v_changes, 'Status', v_old.status::text, 'Aktiv');
v_changes := app_aenderung(v_changes, 'Art der Abwesenheit', v_old.absence_type, null);
v_changes := app_aenderung(v_changes, 'Abwesend ab', v_old.karenz_start_date::text, null);
v_changes := app_aenderung(v_changes, 'Geplante Rückkehr', v_old.karenz_return_date::text, null);
if v_employment_type is not null then
v_changes := app_aenderung(v_changes, 'Beschäftigungsausmaß', v_old.employment_type::text, v_employment_type::text);
v_changes := app_aenderung(v_changes, 'Wochenstunden', v_old.weekly_hours::text, v_weekly_hours::text);
end if;
if payload->>'employment_mode' = 'Teilzeit' then
v_changes := app_aenderung(v_changes, 'Teilzeitvariante', v_old.teilzeit_art, v_grund);
v_changes := app_aenderung(v_changes, 'Teilzeit bis', v_old.teilzeit_bis::text,
case when v_grund is not null then nullif(payload->>'teilzeit_bis', '') else null end);
end if;
end if;
insert into employee_history (employee_id, event_date, event_type, description, changes)
values (v_employee_id, v_return_date, 'Rückkehr',
'Rückkehr aus ' || coalesce(v_absence_type, 'Langzeitabwesenheit') || ' am ' || v_return_date
|| case when payload->>'employment_mode' = 'Teilzeit'
then ', reduziert auf ' || (payload->>'weekly_hours') || ' h'
|| coalesce(' (' || v_grund || ')', '')
else '' end,
v_changes);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (app_current_user_id(), current_actor_name(), 'Rückkehr', v_name, v_employee_id, 'Rückkehr am ' || v_return_date || coalesce(' — ' || v_grund, ''));
end;
$function$;
CREATE OR REPLACE FUNCTION public.update_history_entry(payload jsonb)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public', 'pg_temp'
AS $function$
declare
v_id uuid := (payload->>'history_id')::uuid;
v_eintrag employee_history%rowtype;
v_datum date;
v_name text;
v_karte constant jsonb := app_feld_karte();
v_alt jsonb;
v_feld text;
v_neuer_wert text;
v_neu jsonb := '[]'::jsonb;
v_korrektur jsonb := '[]'::jsonb;
v_setz text[] := '{}';
v_spalte text;
v_typ text;
v_gruppe text;
v_gueltig text;
v_plan pending_org_changes%rowtype;
v_neuer_payload jsonb;
v_war_zukunft boolean;
v_person employees%rowtype;
v_fruehestes date;
begin
perform require_hr_admin();
select * into v_eintrag from employee_history where id = v_id;
if not found then
raise exception 'Historieneintrag nicht gefunden.';
end if;
-- ── Der Eintritt: nur das Datum, dafür ohne Vorher-Werte ────────
--
-- Er ist der Anfang der Zeitleiste und hat keine Felder, die sich
-- zurücknehmen liessen — wohl aber ein Datum, das falsch erfasst sein
-- kann. Anders als die übrigen Einträge braucht er dafür keine in
-- changes hinterlegten Werte: der alte Wert steht in employees.
-- Deshalb funktioniert das auch für Zeilen, die lange vor dieser
-- Erweiterung entstanden sind.
if v_eintrag.event_type = 'Eintritt' then
v_datum := coalesce(nullif(payload->>'event_date', '')::date, v_eintrag.event_date);
select * into v_person from employees where id = v_eintrag.employee_id;
if v_datum = v_person.entry_date then
raise exception 'Nichts geändert.';
end if;
-- Nichts darf vor dem Eintritt liegen. Ein Trigger verbietet es
-- ohnehin; hier steht der Grund lesbar statt als Auslösermeldung.
select min(h.event_date) into v_fruehestes
from employee_history h
where h.employee_id = v_eintrag.employee_id and h.id <> v_eintrag.id;
if v_fruehestes is not null and v_datum > v_fruehestes then
raise exception 'Am % steht bereits ein Ereignis. Der Eintritt kann nicht danach liegen.', v_fruehestes;
end if;
-- Wer schon angefangen hat, kann nicht künftig anfangen. Ohne diese
-- Prüfung liesse sich eine aktive Person durch ein Datum in der Zukunft
-- rückwirkend in einen geplanten Eintritt verwandeln — die Ableitung
-- sagt dann „Geplant", obwohl die Person seit Jahren da ist. Bei jemandem
-- ohne weitere Ereignisse greift sonst überhaupt nichts.
if v_person.status in ('Aktiv', 'Karenz') and v_datum > current_date then
raise exception 'Diese Person arbeitet bereits. Der Eintritt kann nicht in der Zukunft liegen.';
end if;
if v_person.exit_date is not null and v_datum > v_person.exit_date then
raise exception 'Der Austritt am % läge dann vor dem Eintritt.', v_person.exit_date;
end if;
if v_person.karenz_start_date is not null and v_datum > v_person.karenz_start_date then
raise exception 'Die Abwesenheit ab % läge dann vor dem Eintritt.', v_person.karenz_start_date;
end if;
-- Die erste Planstellenbesetzung beginnt mit dem Eintritt und wandert
-- mit. Bliebe sie stehen, gäbe es Tage mit Beschäftigung ohne Stelle
-- oder umgekehrt.
update position_assignments
set valid_from = v_datum
where employee_id = v_eintrag.employee_id
and valid_from = v_person.entry_date
and (valid_to is null or valid_to > v_datum);
update employees set entry_date = v_datum where id = v_eintrag.employee_id;
update employee_history
set event_date = v_datum,
description = regexp_replace(description, '^Eintritt', 'Eintritt')
where id = v_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(), 'Eintrittsdatum berichtigt',
v_person.first_name || ' ' || v_person.last_name, v_eintrag.employee_id,
'Eintritt vom ' || v_eintrag.event_date || ' auf ' || v_datum || ' berichtigt',
jsonb_build_array(jsonb_build_object('feld', 'Eintrittsdatum',
'vorher', v_eintrag.event_date::text, 'nachher', v_datum::text)));
return;
end if;
if v_eintrag.event_type not in ('Stammdatenänderung', 'Vertragsänderung', 'Karenz', 'Rückkehr') then
raise exception 'Dieser Vorgang lässt sich hier nicht berichtigen. Für % gibt es den passenden Weg.', v_eintrag.event_type;
end if;
if v_eintrag.event_type in ('Karenz', 'Rückkehr') and v_eintrag.event_date > current_date then
raise exception 'Diese Abwesenheit ist noch nicht wirksam. Sie muss über den Vorgang selbst berichtigt werden.';
end if;
if v_eintrag.changes is null or jsonb_array_length(v_eintrag.changes) = 0 then
raise exception 'Zu diesem Eintrag sind keine Feldwerte erfasst — es gibt nichts zu berichtigen.';
end if;
v_war_zukunft := v_eintrag.event_date > current_date;
v_datum := coalesce(nullif(payload->>'event_date', '')::date, v_eintrag.event_date);
-- Ein Eintrag bleibt auf seiner Seite der Gegenwart. Beides zu erlauben
-- hiesse, eine gelaufene Änderung in eine geplante zu verwandeln (oder
-- umgekehrt) — dann müssten Stammdaten und payload gegenläufig angepasst
-- werden, und dafür gibt es die fachlichen Vorgänge.
if v_war_zukunft and v_datum <= current_date then
raise exception 'Eine geplante Änderung lässt sich hier nicht vorziehen. Dafür ist „Daten ändern" der richtige Weg.';
end if;
if not v_war_zukunft and v_datum > current_date then
raise exception 'Eine bereits wirksame Änderung lässt sich nicht in die Zukunft verschieben.';
end if;
select first_name || ' ' || last_name into v_name from employees where id = v_eintrag.employee_id;
-- Neue Werteliste bauen: Vorher bleibt, Nachher darf ersetzt werden.
for v_alt in select * from jsonb_array_elements(v_eintrag.changes) loop
v_feld := v_alt->>'feld';
select w->>'nachher' into v_neuer_wert
from jsonb_array_elements(coalesce(payload->'werte', '[]'::jsonb)) w
where w->>'feld' = v_feld;
if v_neuer_wert is null then
v_neu := v_neu || v_alt;
else
v_neu := v_neu || jsonb_build_object('feld', v_feld, 'vorher', v_alt->>'vorher', 'nachher', nullif(v_neuer_wert, ''));
if coalesce(v_alt->>'nachher', '') is distinct from coalesce(nullif(v_neuer_wert, ''), '') then
v_korrektur := v_korrektur || jsonb_build_object('feld', v_feld, 'vorher', v_alt->>'nachher', 'nachher', nullif(v_neuer_wert, ''));
end if;
end if;
end loop;
if jsonb_array_length(v_korrektur) = 0 and v_datum = v_eintrag.event_date then
raise exception 'Nichts geändert.';
end if;
update employee_history
set changes = v_neu,
event_date = v_datum,
description = 'Geänderte Felder: ' || app_aenderungsfelder(v_neu) || ', wirksam ab ' || v_datum
where id = v_id;
-- ── Noch nicht wirksam: den geplanten Vorgang nachziehen ───────────
if v_war_zukunft then
if v_eintrag.pending_id is null then
raise exception 'Zu dieser geplanten Änderung ist kein Vorgang hinterlegt. Sie stammt aus der Zeit vor dieser Verknüpfung und lässt sich hier nicht berichtigen.';
end if;
select * into v_plan from pending_org_changes where id = v_eintrag.pending_id for update;
if not found or v_plan.status <> 'pending' then
raise exception 'Der geplante Vorgang läuft nicht mehr — er wurde bereits angewendet oder abgebrochen.';
end if;
v_neuer_payload := jsonb_set(v_plan.payload, '{effective_date}', to_jsonb(v_datum::text));
for v_alt in select * from jsonb_array_elements(v_neu) loop
v_feld := v_alt->>'feld';
if not v_karte ? v_feld then
continue;
end if;
v_spalte := v_karte->v_feld->>0;
v_typ := v_karte->v_feld->>1;
v_gruppe := v_karte->v_feld->>2;
if not v_neuer_payload ? v_gruppe then
v_neuer_payload := jsonb_set(v_neuer_payload, array[v_gruppe], '{}'::jsonb);
end if;
v_neuer_payload := jsonb_set(
v_neuer_payload,
array[v_gruppe, v_spalte],
case
when v_alt->>'nachher' is null then 'null'::jsonb
when v_typ = 'liste' then to_jsonb(string_to_array(v_alt->>'nachher', ', '))
when v_typ = 'boolean' then to_jsonb((v_alt->>'nachher')::boolean)
when v_typ = 'numeric' then to_jsonb((v_alt->>'nachher')::numeric)
else to_jsonb(v_alt->>'nachher')
end,
true);
end loop;
update pending_org_changes
set payload = v_neuer_payload, effective_date = v_datum
where id = v_plan.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(), 'Geplante Änderung berichtigt', v_name, v_eintrag.employee_id,
v_eintrag.event_type || ' zum ' || v_eintrag.event_date ||
case when v_datum <> v_eintrag.event_date then ' auf ' || v_datum || ' verschoben' else '' end ||
case when jsonb_array_length(v_korrektur) > 0 then '; berichtigt: ' || app_aenderungsfelder(v_korrektur) else '' end,
v_korrektur);
return;
end if;
-- ── Bereits wirksam: Stammdaten nachziehen ─────────────────────────
for v_feld in select distinct e->>'feld' from jsonb_array_elements(v_neu) e loop
if not v_karte ? v_feld then
continue;
end if;
select a->>'nachher' into v_gueltig
from employee_history h,
lateral jsonb_array_elements(coalesce(h.changes, '[]'::jsonb)) a
where h.employee_id = v_eintrag.employee_id
and a->>'feld' = v_feld
and h.event_date <= current_date
order by h.event_date desc, h.created_at desc
limit 1;
v_spalte := v_karte->v_feld->>0;
v_typ := v_karte->v_feld->>1;
if v_typ = 'liste' then
v_setz := v_setz || format('%I = coalesce(string_to_array(%L, '', ''), ''{}'')', v_spalte, nullif(v_gueltig, ''));
else
v_setz := v_setz || format('%I = %L::%s', v_spalte, nullif(v_gueltig, ''), v_typ);
end if;
end loop;
if array_length(v_setz, 1) > 0 then
begin
execute format('update employees set %s where id = %L', array_to_string(v_setz, ', '), v_eintrag.employee_id);
exception when check_violation then
raise exception 'Der berichtigte Wert passt nicht zum übrigen Stand (%). Zusammengehörende Felder — etwa Beschäftigungsausmaß und Wochenstunden — müssen gemeinsam stimmen.', sqlerrm;
end;
end if;
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(), 'Historieneintrag berichtigt', v_name, v_eintrag.employee_id,
v_eintrag.event_type || ' vom ' || v_eintrag.event_date ||
case when v_datum <> v_eintrag.event_date then ' auf ' || v_datum || ' umdatiert' else '' end ||
case when jsonb_array_length(v_korrektur) > 0
then '; berichtigt: ' || app_aenderungsfelder(v_korrektur) else '' end,
v_korrektur);
end;
$function$;
-- Selbstprüfung.
do $$
declare
v_ret text := pg_get_functiondef('public.record_karenz_return(jsonb)'::regprocedure);
v_upd text := pg_get_functiondef('public.update_history_entry(jsonb)'::regprocedure);
begin
if v_ret not like '%nicht abwesend%' then
raise exception 'record_karenz_return prüft die Abwesenheit nicht';
end if;
if v_ret not like '%bereits eine Rückkehr geplant%' then
raise exception 'record_karenz_return lässt zwei geplante Rückkehren zu';
end if;
if v_upd not like '%set entry_date = v_datum%' then
raise exception 'update_history_entry ändert das Eintrittsdatum nicht';
end if;
if v_upd not like '%Der Eintritt kann nicht danach liegen%' then
raise exception 'Die Abhängigkeitsprüfung zum Eintritt fehlt';
end if;
end
$$;

View File

@@ -0,0 +1,430 @@
-- Eine geplante Abwesenheit oder Rückkehr zurücknehmen
--
-- Bisher wies das Löschen sie ab: „Sie muss über den Vorgang selbst
-- abgebrochen werden." Nur gab es diesen Weg nirgends — die Zeile stand in
-- der Akte, der Vorgang lief weiter, und niemand konnte beides aufhalten.
-- Genau dieser Fall steht in den Daten: eine Person, die im Juli abwesend
-- wurde, im August zurückkam und für die trotzdem noch eine zweite Rückkehr
-- zum 1. September vorgemerkt ist.
--
-- Drei Teile:
-- 1. start_karenz und record_karenz_return vermerken den geplanten Vorgang
-- an der Historienzeile. Ohne diesen Verweis liesse sich nur über Person
-- und Datum raten, welcher Vorgang gemeint ist.
-- 2. Die vorhandenen Zeilen bekommen den Verweis nachgetragen, aber nur wo
-- er eindeutig ist.
-- 3. delete_history_entry bricht den Vorgang ab, statt abzuweisen.
CREATE OR REPLACE FUNCTION public.start_karenz(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_start_date date := (payload->>'karenz_start_date')::date;
v_absence_type text := nullif(payload->>'absence_type', '');
v_name text;
v_old employees%rowtype;
-- Ohne Vorher-Werte liesse sich eine irrtümlich erfasste Abwesenheit
-- nicht zurücknehmen: es stünde nirgends, was vorher galt.
v_changes jsonb := '[]'::jsonb;
-- Der geplante Vorgang, damit sich die Zeile in der Historie auf ihn
-- beziehen kann: ohne diesen Verweis liesse sich eine irrtümlich
-- erfasste, noch nicht wirksame Abwesenheit nicht mehr abbrechen.
v_plan_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_changes := app_aenderung(v_changes, 'Status', v_old.status::text,
case when v_start_date <= current_date then 'Karenz' else v_old.status::text end);
v_changes := app_aenderung(v_changes, 'Art der Abwesenheit', v_old.absence_type, v_absence_type);
v_changes := app_aenderung(v_changes, 'Abwesend ab', v_old.karenz_start_date::text, v_start_date::text);
v_changes := app_aenderung(v_changes, 'Geplante Rückkehr', v_old.karenz_return_date::text, payload->>'planned_return_date');
if v_start_date <= current_date then
update employees set status = 'Karenz', karenz_start_date = v_start_date,
karenz_return_date = (payload->>'planned_return_date')::date,
absence_type = v_absence_type
where id = v_employee_id;
else
update employees set karenz_start_date = v_start_date where id = v_employee_id;
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'karenz_start', v_start_date,
jsonb_build_object('planned_return_date', payload->>'planned_return_date', 'absence_type', v_absence_type))
returning id into v_plan_id;
end if;
insert into employee_history (employee_id, event_date, event_type, description, changes, pending_id)
values (v_employee_id, v_start_date, 'Karenz',
coalesce(v_absence_type, 'Langzeitabwesenheit') || ', geplante Rückkehr am ' || (payload->>'planned_return_date') ||
case when payload->>'note' is not null and payload->>'note' <> '' then ' — ' || (payload->>'note') else '' end,
v_changes, v_plan_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(), 'Karenz', v_name, v_employee_id,
coalesce(v_absence_type, 'Langzeitabwesenheit') || ', geplante Rückkehr ' || (payload->>'planned_return_date'));
end;
$function$;
CREATE OR REPLACE FUNCTION public.record_karenz_return(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_return_date date := (payload->>'return_date')::date;
v_name text;
v_employment_type employment_type;
v_weekly_hours numeric;
v_karenz_start date;
v_absence_type text;
v_old employees%rowtype;
-- Wie bei der Abwesenheit: ohne Vorher-Werte liesse sich eine
-- irrtümlich erfasste Rückkehr nicht zurücknehmen.
v_changes jsonb := '[]'::jsonb;
-- Warum jemand mit weniger Stunden zurückkommt: Wiedereingliederungs-
-- oder Elternteilzeit. Nur bedeutsam, wenn überhaupt reduziert wird.
v_grund text := nullif(payload->>'reduction_reason', '');
-- Wie bei der Abwesenheit: der Verweis auf den geplanten Vorgang.
v_plan_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_karenz_start := v_old.karenz_start_date;
v_absence_type := v_old.absence_type;
-- Ohne Abwesenheit keine Rückkehr. Die Prüfung fehlte, und in den Daten
-- steht eine Person mit zwei Rückkehren zu einer Abwesenheit: die zweite
-- wurde erfasst, als sie längst wieder aktiv war. Der Status wäre danach
-- aus einem Ereignis abgeleitet, das nie stattgefunden hat.
if v_old.status <> 'Karenz' and v_old.karenz_start_date is null then
raise exception 'Diese Person ist nicht abwesend — eine Rückkehr gibt es nur aus einer Abwesenheit.';
end if;
-- Und nur eine: eine zweite geplante Rückkehr würde die erste am
-- Stichtag stillschweigend überschreiben.
if exists (
select 1 from pending_org_changes p
where p.employee_id = v_employee_id and p.change_type = 'karenz_return' and p.status = 'pending'
) then
raise exception 'Für diese Person ist bereits eine Rückkehr geplant. Sie muss zuerst zurückgenommen werden.';
end if;
if v_karenz_start is not null and v_return_date <= v_karenz_start then
raise exception 'Das Rückkehrdatum muss nach dem Beginn der Langzeitabwesenheit (%) liegen.', v_karenz_start;
end if;
if payload->>'employment_mode' = 'Vollzeit' then
v_employment_type := 'Vollzeit'; v_weekly_hours := 38.5;
elsif payload->>'employment_mode' = 'Teilzeit' then
v_employment_type := 'Teilzeit'; v_weekly_hours := (payload->>'weekly_hours')::numeric;
end if;
if v_return_date <= current_date then
-- Keine Manager-Nachführung mehr nötig: wer aus der Abwesenheit
-- zurückkehrt, ist wieder anwesend, und die abgeleitete Berichtslinie
-- fällt automatisch von der Vertretung auf ihn zurück.
update employees set
status = 'Aktiv',
karenz_return_date = null,
karenz_start_date = null,
absence_type = null,
employment_type = coalesce(v_employment_type, employment_type),
weekly_hours = coalesce(v_weekly_hours, weekly_hours),
-- Kehrt jemand reduziert zurück, ist der Grund dafür ein Zustand,
-- kein Einmalereignis: danach lässt sich auswerten, wer gerade in
-- Eltern- oder Wiedereingliederungsteilzeit ist.
teilzeit_art = case when payload->>'employment_mode' = 'Teilzeit' then v_grund else teilzeit_art end,
teilzeit_bis = case
when payload->>'employment_mode' = 'Teilzeit' and v_grund is not null
then nullif(payload->>'teilzeit_bis', '')::date
when payload->>'employment_mode' = 'Teilzeit' then null
else teilzeit_bis end
where id = v_employee_id;
else
update employees set karenz_return_date = v_return_date where id = v_employee_id;
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'karenz_return', v_return_date,
jsonb_build_object('employment_type', v_employment_type, 'weekly_hours', v_weekly_hours,
'teilzeit_art', v_grund, 'teilzeit_bis', nullif(payload->>'teilzeit_bis', '')))
returning id into v_plan_id;
end if;
if v_return_date <= current_date then
v_changes := app_aenderung(v_changes, 'Status', v_old.status::text, 'Aktiv');
v_changes := app_aenderung(v_changes, 'Art der Abwesenheit', v_old.absence_type, null);
v_changes := app_aenderung(v_changes, 'Abwesend ab', v_old.karenz_start_date::text, null);
v_changes := app_aenderung(v_changes, 'Geplante Rückkehr', v_old.karenz_return_date::text, null);
if v_employment_type is not null then
v_changes := app_aenderung(v_changes, 'Beschäftigungsausmaß', v_old.employment_type::text, v_employment_type::text);
v_changes := app_aenderung(v_changes, 'Wochenstunden', v_old.weekly_hours::text, v_weekly_hours::text);
end if;
if payload->>'employment_mode' = 'Teilzeit' then
v_changes := app_aenderung(v_changes, 'Teilzeitvariante', v_old.teilzeit_art, v_grund);
v_changes := app_aenderung(v_changes, 'Teilzeit bis', v_old.teilzeit_bis::text,
case when v_grund is not null then nullif(payload->>'teilzeit_bis', '') else null end);
end if;
end if;
insert into employee_history (employee_id, event_date, event_type, description, changes, pending_id)
values (v_employee_id, v_return_date, 'Rückkehr',
'Rückkehr aus ' || coalesce(v_absence_type, 'Langzeitabwesenheit') || ' am ' || v_return_date
|| case when payload->>'employment_mode' = 'Teilzeit'
then ', reduziert auf ' || (payload->>'weekly_hours') || ' h'
|| coalesce(' (' || v_grund || ')', '')
else '' end,
v_changes, v_plan_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(), 'Rückkehr', v_name, v_employee_id, 'Rückkehr am ' || v_return_date || coalesce(' — ' || v_grund, ''));
end;
$function$;
-- Nachtrag für die Zeilen, die vor dieser Verknüpfung entstanden sind.
-- Nur wo genau ein laufender Vorgang derselben Person auf denselben Tag
-- fällt: bei zweien wäre die Zuordnung geraten, und geraten wird hier nicht.
update employee_history h
set pending_id = p.id
from pending_org_changes p
where h.pending_id is null
and h.event_type in ('Karenz', 'Rückkehr')
and p.employee_id = h.employee_id
and p.status = 'pending'
and p.effective_date = h.event_date
and p.change_type = case h.event_type when 'Karenz' then 'karenz_start' else 'karenz_return' end
and (select count(*) from pending_org_changes q
where q.employee_id = h.employee_id
and q.status = 'pending'
and q.effective_date = h.event_date
and q.change_type = p.change_type) = 1;
CREATE OR REPLACE FUNCTION public.delete_history_entry(payload jsonb)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public', 'pg_temp'
AS $function$
declare
v_id uuid := (payload->>'history_id')::uuid;
v_eintrag employee_history%rowtype;
v_name text;
v_karte constant jsonb := app_feld_karte();
v_aenderung jsonb;
v_feld text;
v_wert text;
v_spalte text;
v_typ text;
v_gruppe text;
v_spaeter boolean;
v_zurueckgesetzt jsonb := '[]'::jsonb;
v_setz text[] := '{}';
v_plan pending_org_changes%rowtype;
v_neuer_payload jsonb;
v_leer boolean;
-- Das Rückkehrdatum, das beim Beginn der Abwesenheit vorgesehen war.
v_geplant date;
begin
perform require_hr_admin();
select * into v_eintrag from employee_history where id = v_id;
if not found then
raise exception 'Historieneintrag nicht gefunden.';
end if;
if v_eintrag.event_type = 'Eintritt' then
raise exception 'Der Eintritt lässt sich nicht löschen — er ist der Anfang der Zeitleiste.';
end if;
if v_eintrag.event_type not in ('Stammdatenänderung', 'Vertragsänderung', 'Karenz', 'Rückkehr') then
raise exception 'Dieser Vorgang lässt sich hier nicht zurücknehmen. Für % gibt es den passenden Weg.', v_eintrag.event_type;
end if;
-- Die Reihenfolge zählt: eine Rückkehr setzt eine Abwesenheit voraus.
-- Bliebe sie stehen, während die Abwesenheit verschwindet, stünde in der
-- Akte eine Rückkehr aus dem Nichts — und der Status ergäbe sich aus
-- einem Eintrag, dessen Ausgangslage gelöscht ist.
if v_eintrag.event_type = 'Karenz' and exists (
select 1 from employee_history h
where h.employee_id = v_eintrag.employee_id
and h.event_type = 'Rückkehr'
and (h.event_date, h.created_at) > (v_eintrag.event_date, v_eintrag.created_at)
) then
raise exception 'Zu dieser Abwesenheit gibt es eine Rückkehr. Sie muss zuerst gelöscht werden.';
end if;
-- ── Geplante Abwesenheit oder Rückkehr: ganz abbrechen ────────────
--
-- Für die übrigen Vorgänge wird aus dem geplanten Vorgang Feld für Feld
-- herausgenommen, was die Zeile beschreibt. Abwesenheit und Rückkehr
-- gehen so nicht: ihre Felder haben in app_feld_karte keinen Ort, und
-- eine halbe Abwesenheit gibt es fachlich auch nicht. Hier fällt der
-- Vorgang deshalb ganz — was genau die Frage ist, die jemand stellt,
-- der die geplante Zeile löscht.
--
-- Was am Stammsatz schon vermerkt war, geht mit: das Datum, ab dem
-- jemand fehlen sollte, und das Datum, an dem er zurückkommen sollte.
-- Bliebe es stehen, stünde im Profil eine Abwesenheit ohne Ereignis.
if v_eintrag.event_type in ('Karenz', 'Rückkehr') and v_eintrag.event_date > current_date then
if v_eintrag.pending_id is null then
raise exception 'Zu dieser geplanten Abwesenheit ist kein Vorgang hinterlegt. Sie stammt aus der Zeit vor dieser Verknüpfung und lässt sich hier nicht abbrechen.';
end if;
select * into v_plan from pending_org_changes where id = v_eintrag.pending_id for update;
if not found or v_plan.status <> 'pending' then
raise exception 'Der geplante Vorgang läuft nicht mehr — er wurde bereits angewendet oder abgebrochen.';
end if;
select first_name || ' ' || last_name into v_name from employees where id = v_eintrag.employee_id;
update pending_org_changes set status = 'cancelled' where id = v_plan.id;
if v_plan.change_type = 'karenz_start' then
-- Die Abwesenheit hat nie begonnen; sie kann es auch nicht mehr.
update employees set karenz_start_date = null, karenz_return_date = null
where id = v_eintrag.employee_id and status <> 'Karenz';
else
-- Läuft die Abwesenheit noch, gilt wieder das Datum, das bei ihrem
-- Beginn vorgesehen war. Ist die Person längst zurück, war die
-- geplante Rückkehr ohnehin gegenstandslos.
select nullif(a->>'nachher', '')::date into v_geplant
from employee_history h,
lateral jsonb_array_elements(coalesce(h.changes, '[]'::jsonb)) a
where h.employee_id = v_eintrag.employee_id
and h.event_type = 'Karenz'
and a->>'feld' = 'Geplante Rückkehr'
order by h.event_date desc, h.created_at desc
limit 1;
update employees
set karenz_return_date = case when status = 'Karenz' or karenz_start_date is not null then v_geplant end
where id = v_eintrag.employee_id;
end if;
delete from employee_history where id = v_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(), 'Geplante Änderung abgebrochen', v_name, v_eintrag.employee_id,
v_eintrag.event_type || ' zum ' || v_eintrag.event_date || ' abgebrochen (der Vorgang entfällt ganz)',
coalesce(v_eintrag.changes, '[]'::jsonb));
return;
end if;
if v_eintrag.changes is null or jsonb_array_length(v_eintrag.changes) = 0 then
raise exception 'Zu diesem Eintrag sind keine Feldwerte erfasst — es gibt nichts, worauf zurückgesetzt werden könnte.';
end if;
select first_name || ' ' || last_name into v_name from employees where id = v_eintrag.employee_id;
-- ── Noch nicht wirksam: die geplante Änderung entschärfen ──────────
if v_eintrag.event_date > current_date then
if v_eintrag.pending_id is null then
raise exception 'Zu dieser geplanten Änderung ist kein Vorgang hinterlegt. Sie stammt aus der Zeit vor dieser Verknüpfung und lässt sich hier nicht abbrechen.';
end if;
select * into v_plan from pending_org_changes where id = v_eintrag.pending_id for update;
if not found or v_plan.status <> 'pending' then
raise exception 'Der geplante Vorgang läuft nicht mehr — er wurde bereits angewendet oder abgebrochen.';
end if;
v_neuer_payload := v_plan.payload;
for v_aenderung in select * from jsonb_array_elements(v_eintrag.changes) loop
v_feld := v_aenderung->>'feld';
if not v_karte ? v_feld then
continue;
end if;
v_spalte := v_karte->v_feld->>0;
v_gruppe := v_karte->v_feld->>2;
if v_neuer_payload ? v_gruppe then
v_neuer_payload := jsonb_set(v_neuer_payload, array[v_gruppe], (v_neuer_payload->v_gruppe) - v_spalte);
end if;
end loop;
v_leer := coalesce(jsonb_array_length(
(select jsonb_agg(k) from jsonb_object_keys(coalesce(v_neuer_payload->'person', '{}'::jsonb)) k)), 0) = 0
and coalesce(jsonb_array_length(
(select jsonb_agg(k) from jsonb_object_keys(coalesce(v_neuer_payload->'contract', '{}'::jsonb)) k)), 0) = 0
and coalesce(jsonb_array_length(
(select jsonb_agg(k) from jsonb_object_keys(coalesce(v_neuer_payload->'role', '{}'::jsonb)) k)), 0) = 0;
if v_leer then
update pending_org_changes set status = 'cancelled' where id = v_plan.id;
else
update pending_org_changes set payload = v_neuer_payload where id = v_plan.id;
end if;
delete from employee_history where id = v_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(), 'Geplante Änderung abgebrochen', v_name, v_eintrag.employee_id,
v_eintrag.event_type || ' zum ' || v_eintrag.event_date || ' abgebrochen: ' || app_aenderungsfelder(v_eintrag.changes) ||
case when v_leer then ' (der Vorgang entfällt ganz)' else ' (der Vorgang läuft mit den übrigen Feldern weiter)' end,
v_eintrag.changes);
return;
end if;
-- ── Bereits wirksam: Feld für Feld zurücksetzen ────────────────────
for v_aenderung in select * from jsonb_array_elements(v_eintrag.changes) loop
v_feld := v_aenderung->>'feld';
if not v_karte ? v_feld then
continue;
end if;
select exists (
select 1
from employee_history h,
lateral jsonb_array_elements(coalesce(h.changes, '[]'::jsonb)) a
where h.employee_id = v_eintrag.employee_id
and h.id <> v_eintrag.id
and a->>'feld' = v_feld
and (h.event_date, h.created_at) > (v_eintrag.event_date, v_eintrag.created_at)
) into v_spaeter;
if v_spaeter then
continue;
end if;
v_spalte := v_karte->v_feld->>0;
v_typ := v_karte->v_feld->>1;
v_wert := v_aenderung->>'vorher';
if v_typ = 'liste' then
v_setz := v_setz || format('%I = coalesce(string_to_array(%L, '', ''), ''{}'')', v_spalte, nullif(v_wert, ''));
else
v_setz := v_setz || format('%I = %L::%s', v_spalte, nullif(v_wert, ''), v_typ);
end if;
v_zurueckgesetzt := v_zurueckgesetzt || jsonb_build_object(
'feld', v_feld,
'vorher', v_aenderung->>'nachher',
'nachher', v_wert
);
end loop;
-- Alles in einem UPDATE: chk_weekly_hours verknüpft Beschäftigungsausmaß
-- und Wochenstunden, und zwischen zwei getrennten Anweisungen stünde
-- zwangsläufig ein Zwischenstand, den die Bedingung verbietet.
if array_length(v_setz, 1) > 0 then
begin
execute format('update employees set %s where id = %L', array_to_string(v_setz, ', '), v_eintrag.employee_id);
exception when check_violation then
raise exception 'Zurücksetzen nicht möglich: die Werte von damals passen nicht mehr zum heutigen Stand (%). Vermutlich wurde ein zusammengehörendes Feld später einzeln geändert.', sqlerrm;
end;
end if;
delete from employee_history where id = v_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(), 'Historieneintrag gelöscht', v_name, v_eintrag.employee_id,
v_eintrag.event_type || ' vom ' || v_eintrag.event_date ||
case when jsonb_array_length(v_zurueckgesetzt) = 0
then ' gelöscht; keine Werte zurückgesetzt (spätere Änderungen gelten)'
else ' gelöscht und zurückgesetzt: ' || app_aenderungsfelder(v_zurueckgesetzt) end,
v_zurueckgesetzt);
end;
$function$;

View File

@@ -0,0 +1,171 @@
-- Kostenstellen
--
-- Die Kostenstelle hing bisher nirgends. Damit war keine einzige Auswertung
-- nach Personalkosten möglich, und eine offene Planstelle konnte nicht sagen,
-- wessen Budget sie belastet.
--
-- Sie hängt an der **Planstelle**, nicht an der Person: der Sitz kostet Geld,
-- auch wenn niemand darauf sitzt — genau das ist die Frage bei einer Vakanz.
-- Und sie hängt nicht an der Organisationseinheit, obwohl sie sich meistens
-- daraus ergibt: eine einzelne Stelle kann auf eine fremde Kostenstelle
-- kontiert sein (Projekt, Umlage, geteilte Funktion), ohne dass die Einheit
-- wechselt.
--
-- Als eigene Zuordnungstabelle mit Zeitraum, nicht als Spalte. Eine Umkontier-
-- ung ist ein Ereignis mit Stichtag: die Kosten des Vorjahres müssen dort
-- bleiben, wo sie angefallen sind. Als Spalte würde jede Änderung rückwirkend
-- jede alte Auswertung verändern — und das merkt niemand.
--
-- Halboffene Zeiträume [valid_from, valid_to), wie bei position_assignments
-- und om_positions. In SAP OM ist das die Verknüpfung A011 (Kontierung).
create table if not exists cost_centers (
id uuid primary key default gen_random_uuid(),
code text not null unique,
name text not null,
-- Die verantwortliche Einheit. Nur ein Hinweis, keine Ableitung: die
-- Zuordnung einer Planstelle bleibt die der Planstelle.
org_unit_id uuid references org_units(id) on delete set null,
valid_from date not null default current_date,
valid_to date,
created_at timestamptz not null default now(),
constraint chk_cost_center_range check (valid_to is null or valid_to > valid_from)
);
create table if not exists position_cost_centers (
id uuid primary key default gen_random_uuid(),
position_id uuid not null references om_positions(id) on delete cascade,
cost_center_id uuid not null references cost_centers(id),
valid_from date not null,
valid_to date,
created_at timestamptz not null default now(),
constraint chk_pcc_range check (valid_to is null or valid_to > valid_from)
);
create index if not exists position_cost_centers_position_id_idx on position_cost_centers (position_id);
create index if not exists position_cost_centers_cost_center_id_idx on position_cost_centers (cost_center_id);
-- Eine Planstelle wird zu einem Zeitpunkt auf genau eine Kostenstelle
-- kontiert. Eine Kostenteilung wäre etwas anderes und bräuchte einen Anteil.
create unique index if not exists position_cost_centers_one_current
on position_cost_centers (position_id) where valid_to is null;
-- RLS schaltet der Ereignis-Trigger ensure_rls automatisch ein; ohne Policy
-- käme aus beiden Tabellen nichts zurück.
drop policy if exists cost_centers_hr_all on cost_centers;
create policy cost_centers_hr_all on cost_centers for all using (is_hr_user()) with check (is_hr_user());
drop policy if exists position_cost_centers_hr_all on position_cost_centers;
create policy position_cost_centers_hr_all on position_cost_centers
for all using (is_hr_user()) with check (is_hr_user());
-- ── Bestand: Kostenstellen anlegen und alle Planstellen kontieren ──────
--
-- Eine Kostenstelle je Gesellschaft, Bereich und Abteilung — Teams tragen
-- keine eigene, sie kontieren auf ihre Abteilung. Das ist die übliche Tiefe:
-- ein Team ist eine Führungsspanne, kein Budgettopf.
--
-- Die Nummer wird aus der Organisationsnummer abgeleitet (die ersten fünf
-- Stellen), damit sie nachvollziehbar ist und nicht geraten wirkt.
insert into cost_centers (code, name, org_unit_id, valid_from)
select left(o.org_number, 5), o.name, o.id, o.valid_from
from org_units o
where o.unit_type in ('Gesellschaft', 'Bereich', 'Abteilung')
and not exists (select 1 from cost_centers k where k.org_unit_id = o.id)
on conflict (code) do nothing;
-- Jede Planstelle auf die Kostenstelle der nächsten Einheit über ihr, die
-- eine hat — sie selbst zuerst.
with recursive kette as (
select p.id as position_id, p.org_unit_id as unit_id, p.valid_from, 0 as tiefe
from om_positions p
where not exists (select 1 from position_cost_centers z where z.position_id = p.id)
union all
select k.position_id, o.parent_id, k.valid_from, k.tiefe + 1
from kette k
join org_units o on o.id = k.unit_id
where o.parent_id is not null
and not exists (select 1 from cost_centers c where c.org_unit_id = k.unit_id)
),
treffer as (
select distinct on (k.position_id) k.position_id, c.id as cost_center_id, k.valid_from
from kette k
join cost_centers c on c.org_unit_id = k.unit_id
order by k.position_id, k.tiefe
)
insert into position_cost_centers (position_id, cost_center_id, valid_from)
select position_id, cost_center_id, valid_from from treffer;
-- ── Umkontieren ────────────────────────────────────────────────────────
--
-- Zum Stichtag: die laufende Zuordnung wird beendet, die neue beginnt. Kein
-- UPDATE der alten Zeile — sonst wäre die Vergangenheit nachträglich anders.
create or replace function set_position_cost_center(payload jsonb)
returns void
language plpgsql
security definer
set search_path to 'public', 'pg_temp'
as $function$
declare
v_position_id uuid := (payload->>'position_id')::uuid;
v_cost_center_id uuid := (payload->>'cost_center_id')::uuid;
v_ab date := coalesce(nullif(payload->>'valid_from', '')::date, current_date);
v_position om_positions%rowtype;
v_laufend position_cost_centers%rowtype;
v_neu cost_centers%rowtype;
v_alt text;
v_label text;
begin
perform require_hr_admin();
select * into v_position from om_positions where id = v_position_id;
if not found then
raise exception 'Die Planstelle existiert nicht.';
end if;
select * into v_neu from cost_centers where id = v_cost_center_id;
if not found then
raise exception 'Die Kostenstelle existiert nicht.';
end if;
if v_neu.valid_to is not null and v_neu.valid_to <= v_ab then
raise exception 'Die Kostenstelle % ist zum % nicht mehr gültig.', v_neu.code, v_ab;
end if;
-- Vor dem Bestehen der Planstelle gibt es nichts zu kontieren.
if v_ab < v_position.valid_from then
raise exception 'Die Planstelle besteht erst ab %.', v_position.valid_from;
end if;
select * into v_laufend from position_cost_centers
where position_id = v_position_id and valid_to is null for update;
if found then
if v_laufend.cost_center_id = v_cost_center_id then
raise exception 'Diese Planstelle ist bereits auf % kontiert.', v_neu.code;
end if;
-- Ein gleichtägiger Wechsel würde einen Zeitraum der Länge null erzeugen;
-- chk_pcc_range verbietet das, und zu Recht: er hätte nie gegolten.
if v_ab <= v_laufend.valid_from then
raise exception 'Die laufende Kontierung gilt erst ab %. Der Wechsel muss danach liegen.', v_laufend.valid_from;
end if;
select code into v_alt from cost_centers where id = v_laufend.cost_center_id;
update position_cost_centers set valid_to = v_ab where id = v_laufend.id;
end if;
insert into position_cost_centers (position_id, cost_center_id, valid_from)
values (v_position_id, v_cost_center_id, v_ab);
select j.title || ' (' || o.name || ')' into v_label
from om_positions p join jobs j on j.id = p.job_id join org_units o on o.id = p.org_unit_id
where p.id = v_position_id;
insert into audit_log (actor_user_id, actor_name, action, target_label, details, changes)
values (app_current_user_id(), current_actor_name(), 'Kostenstelle geändert', v_label,
coalesce('Von ' || v_alt || ' ', '') || 'auf ' || v_neu.code || ' — ' || v_neu.name || ', ab ' || v_ab,
jsonb_build_array(jsonb_build_object('feld', 'Kostenstelle', 'vorher', v_alt, 'nachher', v_neu.code)));
end;
$function$;
revoke all on function set_position_cost_center(jsonb) from public;
grant execute on function set_position_cost_center(jsonb) to public;

View File

@@ -0,0 +1,171 @@
-- Onboarding-Checkliste je Person
--
-- Bisher lief das über ein Blatt neben der Anwendung: eine Tabelle mit
-- Kästchen, je Eintritt einmal ausgedruckt. Was darauf steht, weiss dann nur,
-- wer das Blatt hat — und ob der Meldezettel schon da ist, lässt sich weder
-- suchen noch auswerten noch vertreten.
--
-- Gespeichert wird **je Person und Punkt eine Zeile**, unter dem Schlüssel des
-- Punktes. Die Punkte selbst stehen in lib/onboarding.ts, nicht hier: eine
-- Checkliste ist ein Firmenprozess und kein Stammdatum. Der Schlüssel als Text
-- statt als Fremdschlüssel hat einen Preis (die Datenbank kennt die gültigen
-- Werte nicht) und einen Grund: ein später gestrichener Punkt lässt die alten
-- Antworten stehen, statt sie mitzureissen. Eine Akte von damals bleibt so
-- lesbar, auch wenn die Liste heute anders aussieht.
--
-- Drei Arten von Antwort in einer Tabelle, weil es dieselbe Sache ist:
-- • Haken → erledigt
-- • Ja/Nein → wert ('ja'/'nein'); offen ist etwas anderes als nein
-- • Text → wert (Grössen)
-- `erledigt` trägt bei allen dreien die Frage „abgehakt?", damit der
-- Fortschritt eine Spalte hat und keine Fallunterscheidung.
create table if not exists onboarding_tasks (
id uuid primary key default gen_random_uuid(),
employee_id uuid not null references employees(id) on delete cascade,
item_key text not null,
erledigt boolean not null default false,
wert text,
kommentar text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
updated_by uuid references profiles(id) on delete set null,
-- Der Name mitgeschrieben, nicht nur die Kennung: wer eine Checkliste von
-- vor zwei Jahren aufschlägt, will lesen, wer abgehakt hat, auch wenn die
-- Person längst nicht mehr im Verzeichnis steht.
updated_by_name text,
constraint onboarding_tasks_eine_zeile unique (employee_id, item_key)
);
create index if not exists onboarding_tasks_employee_id_idx on onboarding_tasks (employee_id);
-- Für „was ist noch offen": der Teilindex liest nur die unerledigten.
create index if not exists onboarding_tasks_offen_idx on onboarding_tasks (employee_id) where not erledigt;
drop policy if exists onboarding_tasks_hr_all on onboarding_tasks;
create policy onboarding_tasks_hr_all on onboarding_tasks
for all using (is_hr_user()) with check (is_hr_user());
-- ── Einen Punkt festhalten ─────────────────────────────────────────────
--
-- Ein Aufruf für alle drei Arten: gesetzt wird, was mitkommt. Wer nur den
-- Kommentar ändert, schickt nur den Kommentar — sonst würde ein Tippfehler im
-- Kommentarfeld den Haken mitlöschen.
create or replace function set_onboarding_task(payload jsonb)
returns void
language plpgsql
security definer
set search_path to 'public', 'pg_temp'
as $function$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_item_key text := nullif(trim(payload->>'item_key'), '');
v_name text;
v_vorher onboarding_tasks%rowtype;
v_erledigt boolean;
v_wert text;
v_kommentar text;
begin
perform require_hr_admin();
if v_item_key is null then
raise exception 'Es wurde kein Punkt angegeben.';
end if;
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
if v_name is null then
raise exception 'Die Person existiert nicht.';
end if;
select * into v_vorher from onboarding_tasks
where employee_id = v_employee_id and item_key = v_item_key for update;
-- Weggelassen heisst „unverändert", nicht „leeren". Der Unterschied ist der
-- Grund, warum hier `payload ? 'feld'` steht und nicht coalesce: eine
-- ausdrückliche null muss löschen können.
v_erledigt := case when payload ? 'erledigt' then (payload->>'erledigt')::boolean
else coalesce(v_vorher.erledigt, false) end;
v_wert := case when payload ? 'wert' then nullif(trim(payload->>'wert'), '') else v_vorher.wert end;
v_kommentar := case when payload ? 'kommentar' then nullif(trim(payload->>'kommentar'), '')
else v_vorher.kommentar end;
insert into onboarding_tasks (employee_id, item_key, erledigt, wert, kommentar, updated_by, updated_by_name)
values (v_employee_id, v_item_key, v_erledigt, v_wert, v_kommentar,
app_current_user_id(), current_actor_name())
on conflict (employee_id, item_key) do update
set erledigt = excluded.erledigt,
wert = excluded.wert,
kommentar = excluded.kommentar,
updated_at = now(),
updated_by = excluded.updated_by,
updated_by_name = excluded.updated_by_name;
-- Kein Eintrag in employee_history: das ist keine Änderung an der Person,
-- sondern der Stand einer Aufgabe. In der Historie stünden sonst
-- fünfundzwanzig Zeilen zwischen Eintritt und Versetzung. Ins Protokoll
-- gehört es trotzdem — es ist eine Handlung mit Urheber.
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (app_current_user_id(), current_actor_name(), 'Onboarding-Punkt', v_name, v_employee_id,
v_item_key || ': ' ||
case when v_wert is not null then v_wert
when v_erledigt then 'erledigt'
else 'offen' end ||
coalesce(' — ' || v_kommentar, ''));
end;
$function$;
revoke all on function set_onboarding_task(jsonb) from public;
grant execute on function set_onboarding_task(jsonb) to public;
-- ── Die Liste anlegen ──────────────────────────────────────────────────
--
-- Eine Checkliste entsteht mit dem Eintritt. Dass sie *existiert*, ist die
-- Aussage „diese Person ist im Onboarding" — deshalb wird sie angelegt und
-- nicht bloss beim ersten Klick nebenbei erzeugt: eine leere Liste, auf der
-- noch nichts steht, ist der eigentliche Anfangszustand und muss sichtbar
-- sein.
--
-- Die Punkte kommen aus dem Aufruf, nicht aus der Datenbank: sie stehen in
-- lib/onboarding.ts, und zwei Listen nebeneinander liefen auseinander.
create or replace function start_onboarding(payload jsonb)
returns integer
language plpgsql
security definer
set search_path to 'public', 'pg_temp'
as $function$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_keys jsonb := coalesce(payload->'item_keys', '[]'::jsonb);
v_name text;
v_angelegt integer;
begin
perform require_hr_admin();
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
if v_name is null then
raise exception 'Die Person existiert nicht.';
end if;
if jsonb_array_length(v_keys) = 0 then
raise exception 'Es wurden keine Punkte übergeben.';
end if;
insert into onboarding_tasks (employee_id, item_key, updated_by, updated_by_name)
select v_employee_id, k, app_current_user_id(), current_actor_name()
from jsonb_array_elements_text(v_keys) k
on conflict (employee_id, item_key) do nothing;
get diagnostics v_angelegt = row_count;
if v_angelegt > 0 then
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (app_current_user_id(), current_actor_name(), 'Onboarding-Checkliste angelegt', v_name, v_employee_id,
v_angelegt || ' Punkte');
end if;
return v_angelegt;
end;
$function$;
revoke all on function start_onboarding(jsonb) from public;
grant execute on function start_onboarding(jsonb) to public;

View File

@@ -0,0 +1,148 @@
-- Offboarding-Checkliste je Person
--
-- Dieselbe Sache wie onboarding_tasks (20260817100000), für den anderen Weg:
-- je Person und Punkt eine Zeile, der Schlüssel als Text statt als Fremd-
-- schlüssel, damit ein später gestrichener Punkt seine alten Antworten
-- stehen lässt statt sie mitzureissen. Die Begründung im Einzelnen steht bei
-- der Onboarding-Migration; hier nur, was abweicht.
--
-- Eine eigene Tabelle statt einer gemeinsamen mit Spalte „Richtung": beide
-- Listen haben eigene Punkte (lib/onboarding.ts, lib/offboarding.ts), einen
-- eigenen Anlass (Ein-, Aus- oder Wiedereintritt) und eigene Protokollzeilen.
-- Eine gemeinsame Tabelle würde das mit einer Fallunterscheidung nachbauen,
-- die schon in zwei Tabellen und zwei Funktionen so nicht existiert.
create table if not exists offboarding_tasks (
id uuid primary key default gen_random_uuid(),
employee_id uuid not null references employees(id) on delete cascade,
item_key text not null,
erledigt boolean not null default false,
wert text,
kommentar text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
updated_by uuid references profiles(id) on delete set null,
updated_by_name text,
constraint offboarding_tasks_eine_zeile unique (employee_id, item_key)
);
create index if not exists offboarding_tasks_employee_id_idx on offboarding_tasks (employee_id);
create index if not exists offboarding_tasks_offen_idx on offboarding_tasks (employee_id) where not erledigt;
drop policy if exists offboarding_tasks_hr_all on offboarding_tasks;
create policy offboarding_tasks_hr_all on offboarding_tasks
for all using (is_hr_user()) with check (is_hr_user());
-- ── Einen Punkt festhalten ─────────────────────────────────────────────
-- Wortgleich mit set_onboarding_task, nur auf die andere Tabelle bezogen.
create or replace function set_offboarding_task(payload jsonb)
returns void
language plpgsql
security definer
set search_path to 'public', 'pg_temp'
as $function$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_item_key text := nullif(trim(payload->>'item_key'), '');
v_name text;
v_vorher offboarding_tasks%rowtype;
v_erledigt boolean;
v_wert text;
v_kommentar text;
begin
perform require_hr_admin();
if v_item_key is null then
raise exception 'Es wurde kein Punkt angegeben.';
end if;
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
if v_name is null then
raise exception 'Die Person existiert nicht.';
end if;
select * into v_vorher from offboarding_tasks
where employee_id = v_employee_id and item_key = v_item_key for update;
v_erledigt := case when payload ? 'erledigt' then (payload->>'erledigt')::boolean
else coalesce(v_vorher.erledigt, false) end;
v_wert := case when payload ? 'wert' then nullif(trim(payload->>'wert'), '') else v_vorher.wert end;
v_kommentar := case when payload ? 'kommentar' then nullif(trim(payload->>'kommentar'), '')
else v_vorher.kommentar end;
insert into offboarding_tasks (employee_id, item_key, erledigt, wert, kommentar, updated_by, updated_by_name)
values (v_employee_id, v_item_key, v_erledigt, v_wert, v_kommentar,
app_current_user_id(), current_actor_name())
on conflict (employee_id, item_key) do update
set erledigt = excluded.erledigt,
wert = excluded.wert,
kommentar = excluded.kommentar,
updated_at = now(),
updated_by = excluded.updated_by,
updated_by_name = excluded.updated_by_name;
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (app_current_user_id(), current_actor_name(), 'Offboarding-Punkt', v_name, v_employee_id,
v_item_key || ': ' ||
case when v_wert is not null then v_wert
when v_erledigt then 'erledigt'
else 'offen' end ||
coalesce(' — ' || v_kommentar, ''));
end;
$function$;
revoke all on function set_offboarding_task(jsonb) from public;
grant execute on function set_offboarding_task(jsonb) to public;
-- ── Die Liste anlegen ──────────────────────────────────────────────────
--
-- Anders als beim Onboarding steht dieser Aufruf nicht für jeden Austritt in
-- derselben Transaktion, die den Status setzt: bei einem No Show — wer nie
-- angetreten ist — gibt es nichts offzuboarden, es war nie ein IT-Zugang
-- eingerichtet, keine GKK-Anmeldung, kein Dienstzettel. Diese Funktion prüft
-- das nicht selbst; die aufrufende Seite (actions/employees.ts) entscheidet,
-- wann sie gerufen wird. Sie existiert trotzdem als eigene, aufrufbare
-- Funktion — für den Nachtrag bei einem Austritt von vor dieser Liste.
create or replace function start_offboarding(payload jsonb)
returns integer
language plpgsql
security definer
set search_path to 'public', 'pg_temp'
as $function$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_keys jsonb := coalesce(payload->'item_keys', '[]'::jsonb);
v_name text;
v_angelegt integer;
begin
perform require_hr_admin();
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
if v_name is null then
raise exception 'Die Person existiert nicht.';
end if;
if jsonb_array_length(v_keys) = 0 then
raise exception 'Es wurden keine Punkte übergeben.';
end if;
insert into offboarding_tasks (employee_id, item_key, updated_by, updated_by_name)
select v_employee_id, k, app_current_user_id(), current_actor_name()
from jsonb_array_elements_text(v_keys) k
on conflict (employee_id, item_key) do nothing;
get diagnostics v_angelegt = row_count;
if v_angelegt > 0 then
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (app_current_user_id(), current_actor_name(), 'Offboarding-Checkliste angelegt', v_name, v_employee_id,
v_angelegt || ' Punkte');
end if;
return v_angelegt;
end;
$function$;
revoke all on function start_offboarding(jsonb) from public;
grant execute on function start_offboarding(jsonb) to public;

View File

@@ -0,0 +1,59 @@
-- Das RLS-Sicherheitsnetz gehört ins Repository, nicht nur in die Datenbank.
--
-- `rls_auto_enable()` und der Ereignis-Trigger `ensure_rls` schalten Row Level
-- Security bei jeder neu angelegten Tabelle in `public` sofort ein. Sie sind
-- der Grund, warum eine vergessene Policy nichts preisgibt: ohne RLS wäre eine
-- neue Tabelle für die Anwendungsrolle offen, mit RLS und ohne Policy ist sie
-- leer. Ein Fehler wird so zu einer fehlenden Anzeige statt zu einem Leck.
--
-- **Gefunden beim Umzug von Supabase auf einen eigenen Container.** Beide
-- Objekte existierten nur in der laufenden Datenbank — angelegt von Hand, in
-- keiner Migration. Ein Nachbau aus den Migrationen (die CI tut das bei jedem
-- Lauf, und ein neuer Server sowieso) hätte das Netz stillschweigend nicht
-- gehabt: alles funktioniert, nur die nächste neue Tabelle wäre ungeschützt.
--
-- Der Text ist der aus der Produktion, unverändert übernommen. Auf einer
-- Datenbank, die ihn schon hat, ändert diese Migration nichts.
create or replace function public.rls_auto_enable()
returns event_trigger
language plpgsql
security definer
set search_path to 'public', 'pg_temp'
as $function$
declare
cmd record;
begin
for cmd in
select *
from pg_event_trigger_ddl_commands()
where command_tag in ('CREATE TABLE', 'CREATE TABLE AS', 'SELECT INTO')
and object_type in ('table', 'partitioned table')
loop
if cmd.schema_name is not null and cmd.schema_name in ('public') then
begin
execute format('alter table if exists %s enable row level security', cmd.object_identity);
raise log 'rls_auto_enable: enabled RLS on %', cmd.object_identity;
exception
-- Bewusst geschluckt: ein Ereignis-Trigger, der wirft, lässt die
-- ganze DDL scheitern. Eine Tabelle, an der das Einschalten nicht
-- klappt, ist ein Fall fürs Protokoll — kein Grund, die Migration
-- abzubrechen, die sie gerade anlegt.
when others then
raise log 'rls_auto_enable: failed to enable RLS on %', cmd.object_identity;
end;
else
raise log 'rls_auto_enable: skip % (Schema %)', cmd.object_identity, cmd.schema_name;
end if;
end loop;
end;
$function$;
-- `create event trigger` kennt kein `if not exists`; deshalb erst weg, dann neu.
-- Auf einer Datenbank ohne den Trigger ist das `drop` folgenlos.
drop event trigger if exists ensure_rls;
create event trigger ensure_rls
on ddl_command_end
when tag in ('CREATE TABLE', 'CREATE TABLE AS', 'SELECT INTO')
execute function public.rls_auto_enable();

View File

@@ -0,0 +1,73 @@
-- Rechte der Anwendungsrolle alpenwerk_app.
--
-- Auf Supabase wurden diese Rechte von Hand im Dashboard vergeben und standen
-- deshalb in keiner Migration. Beim Umzug in einen eigenen Container fiel das
-- auf: das Schema entstand vollstaendig aus den Migrationen, die Rolle hatte
-- aber weder Tabellen- noch Funktionsrechte — die Anwendung scheiterte mit
-- "permission denied for table profiles", bevor die Anmeldeseite erschien.
--
-- Ohne diese Datei laesst sich das Projekt auf einer leeren Datenbank nicht
-- in Betrieb nehmen. Genau das ist aber das Ziel: ein Container, den der
-- Kunde selbst hochfaehrt.
--
-- Die Rolle bleibt **ohne BYPASSRLS**. Diese Rechte sagen nur, welche Objekte
-- sie ueberhaupt anfassen darf; welche Zeilen sie sieht, entscheiden weiterhin
-- die 58 RLS-Policies.
-- ── Tabellen und Sequenzen ────────────────────────────────────────────
grant select, insert, update, delete on all tables in schema public to alpenwerk_app;
grant usage, select, update on all sequences in schema public to alpenwerk_app;
-- Damit kuenftige Migrationen mit neuen Tabellen nicht dieselbe Panne
-- ausloesen.
alter default privileges in schema public grant select, insert, update, delete on tables to alpenwerk_app;
alter default privileges in schema public grant usage, select, update on sequences to alpenwerk_app;
-- ── Funktionen ────────────────────────────────────────────────────────
-- Bewusst die einzeln aufgezaehlte Liste aus dem Supabase-Bestand und kein
-- pauschales "on all routines": 20260727150000 hat EXECUTE bei einem Teil der
-- SECURITY-DEFINER-Funktionen absichtlich entzogen. Eine Pauschalvergabe
-- machte das rueckgaengig.
grant execute on function public.add_employee_dependent(payload jsonb) to alpenwerk_app;
grant execute on function public.add_employee_note(payload jsonb) to alpenwerk_app;
grant execute on function public.adjust_karenz_return(payload jsonb) to alpenwerk_app;
grant execute on function public.app_aenderung(p_liste jsonb, p_feld text, p_vorher text, p_nachher text) to alpenwerk_app;
grant execute on function public.app_aenderungsfelder(p_liste jsonb) to alpenwerk_app;
grant execute on function public.app_current_user_id() to alpenwerk_app;
grant execute on function public.app_feld_karte() to alpenwerk_app;
grant execute on function public.app_upsert_user(p_external_id text, p_email text, p_full_name text) to alpenwerk_app;
grant execute on function public.apply_due_pending_changes() to alpenwerk_app;
grant execute on function public.change_employee_data(payload jsonb) to alpenwerk_app;
grant execute on function public.complete_employee_note(payload jsonb) to alpenwerk_app;
grant execute on function public.create_position(payload jsonb) to alpenwerk_app;
grant execute on function public.current_actor_name() to alpenwerk_app;
grant execute on function public.current_hr_user_id() to alpenwerk_app;
grant execute on function public.delete_employee_dependent(payload jsonb) to alpenwerk_app;
grant execute on function public.delete_history_entry(payload jsonb) to alpenwerk_app;
grant execute on function public.delete_position(payload jsonb) to alpenwerk_app;
grant execute on function public.fn_check_history_not_before_entry() to alpenwerk_app;
grant execute on function public.fn_touch_profiles_updated_at() to alpenwerk_app;
grant execute on function public.fn_touch_updated_at() to alpenwerk_app;
grant execute on function public.fn_validate_employee_svnr() to alpenwerk_app;
grant execute on function public.generate_company_email(p_first_name text, p_last_name text) to alpenwerk_app;
grant execute on function public.hire_employee(payload jsonb) to alpenwerk_app;
grant execute on function public.is_hr_admin() to alpenwerk_app;
grant execute on function public.is_hr_user() to alpenwerk_app;
grant execute on function public.is_valid_svnr(p_svnr text, p_birth_date date) to alpenwerk_app;
grant execute on function public.next_position_number() to alpenwerk_app;
grant execute on function public.om_reporting_lines(p_as_of date) to alpenwerk_app;
grant execute on function public.promote_employee(payload jsonb) to alpenwerk_app;
grant execute on function public.record_karenz_return(payload jsonb) to alpenwerk_app;
grant execute on function public.rehire_employee(payload jsonb) to alpenwerk_app;
grant execute on function public.require_hr_admin() to alpenwerk_app;
grant execute on function public.rls_auto_enable() to alpenwerk_app;
grant execute on function public.set_offboarding_task(payload jsonb) to alpenwerk_app;
grant execute on function public.set_onboarding_task(payload jsonb) to alpenwerk_app;
grant execute on function public.set_position_cost_center(payload jsonb) to alpenwerk_app;
grant execute on function public.start_karenz(payload jsonb) to alpenwerk_app;
grant execute on function public.start_offboarding(payload jsonb) to alpenwerk_app;
grant execute on function public.start_onboarding(payload jsonb) to alpenwerk_app;
grant execute on function public.terminate_employee(payload jsonb) to alpenwerk_app;
grant execute on function public.transfer_employee(payload jsonb) to alpenwerk_app;
grant execute on function public.update_history_entry(payload jsonb) to alpenwerk_app;
grant execute on function public.update_position(payload jsonb) to alpenwerk_app;

View File

@@ -0,0 +1,27 @@
-- Die Buchführung der Migrationen heisst jetzt `migrationen`, nicht mehr
-- `supabase_migrations`. Hier wird das alte Schema abgeräumt.
--
-- ═══ Warum das gefahrlos ist ═══
--
-- Die Reihenfolge trägt die ganze Sicherheit:
--
-- 1. scripts/migrate.mjs legt `migrationen.schema_migrations` an und
-- übernimmt einmalig alle Einträge aus `supabase_migrations`, falls es
-- die Tabelle gibt und die neue noch leer ist.
-- 2. Erst danach sucht er nach ausstehenden Migrationen — und findet diese
-- hier.
-- 3. Diese Datei löscht das alte Schema.
--
-- Zum Zeitpunkt von Schritt 3 stehen die Einträge also bereits doppelt. Wer
-- die Datei einzeln und von Hand einspielt, ohne Schritt 1, verliert dagegen
-- die Buchführung — dann hielte der Läufer alle Migrationen für ausstehend.
-- Deshalb: nur über `node scripts/migrate.mjs` einspielen, nie direkt.
--
-- Auf einer frischen Datenbank hat es das Schema nie gegeben; `if exists`
-- macht die Migration dort zu einem Nichts.
--
-- `cascade` statt `restrict`: das Schema enthält ausschliesslich die eine
-- Tabelle, deren Inhalt eine Zeile weiter oben schon übernommen wurde. Ohne
-- cascade bliebe ein leeres Schema stehen, das niemand mehr anfasst.
drop schema if exists supabase_migrations cascade;