Files
alpenwerk-hr/supabase/migrations/20260805130000_update_position.sql
Maximilian Stubhan a87c688c0a Show positions that do not exist yet, and let them be corrected
Two gaps in the positions view, both reported from use.

A position dated into the future was invisible. loadOpenPositions required
valid_from <= today, so a position decided now and effective at the quarter
boundary appeared nowhere until the day it began. The database already held
one — 60000824 "Neue Position", effective 01.09. — created through the
application and shown on no screen since.

Future positions now have their own section rather than joining the vacancy
list. They are a different statement: "nobody is here" and "this does not
exist yet" should not be counted together, and a position starting 01.10.
read as a vacancy nobody was filling.

Positions could only be created and deleted. Fixing a typo in the job title
meant deleting and recreating — with a new position number, which appears in
job postings, budgets and audit entries, and whose trail then breaks.
update_position keeps the number and records old and new values per field,
using the audit detail added earlier today.

Three things it refuses, as guards rather than remarks:

  - Moving an occupied position to another unit. That is a transfer, with
    history and reporting line, and belongs to the person — otherwise
    someone changes department silently.
  - Ending an occupied position, which would leave an assignment without
    one.
  - A second chief position in a unit, or an end before the start.

Verified against the live database, all rolled back: each guard fires with
its own message, the permitted edits go through, the audit entry carries the
changed fields. Open positions stay at 9 and the future one now appears in
its own section.

ESLint caught me priming the dialog's fields from an effect. Replaced by a
key on the component, so React rebuilds it per position and the fields
initialise from props — which also removes the flash of the previous
position's values on second open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 12:26:12 +02:00

126 lines
5.0 KiB
PL/PgSQL

-- 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);
if jsonb_array_length(v_changes) = 0 then
return;
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;
$$;