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>
126 lines
5.0 KiB
PL/PgSQL
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;
|
|
$$;
|