Files
dashboard/backend/src/database/migrations/004_create_einsaetze.sql
Matthias Hochmeister da4a56ba6b fix backend
2026-02-27 21:08:52 +01:00

272 lines
11 KiB
PL/PgSQL

-- Migration: 004_create_einsaetze.sql
-- Feature: Einsatzprotokoll (Incident Management)
-- Depends on: 001_create_users_table.sql
-- Rollback instructions at bottom of file
-- ---------------------------------------------------------------------------
-- ENUM-LIKE CHECK CONSTRAINTS (as VARCHAR + CHECK for easy extension)
-- ---------------------------------------------------------------------------
-- ---------------------------------------------------------------------------
-- SEQUENCE SUPPORT TABLE for year-based Einsatz-Nr (YYYY-NNN)
-- One row per year; nextval equivalent done via UPDATE...RETURNING
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS einsatz_nr_sequence (
year INTEGER PRIMARY KEY,
last_nr INTEGER NOT NULL DEFAULT 0
);
-- ---------------------------------------------------------------------------
-- FAHRZEUGE TABLE (vehicles — stub so junction table has a valid FK target)
-- A full Fahrzeuge feature is covered by 005_create_fahrzeuge.sql.
-- We do NOT create the table here; migration 005 owns it.
-- The FK in einsatz_fahrzeuge will resolve correctly when 005 runs first
-- in the standard sort order (004 runs before 005, so we use DEFERRABLE or
-- we simply add the FK as a separate ALTER TABLE at the end of this file,
-- after we know 005 may not have run yet).
--
-- Resolution strategy: einsatz_fahrzeuge.fahrzeug_id FK is defined as
-- DEFERRABLE INITIALLY DEFERRED so it is checked at transaction commit time
-- rather than at statement time, allowing this migration to run without
-- requiring fahrzeuge to exist yet.
-- ---------------------------------------------------------------------------
-- ---------------------------------------------------------------------------
-- MAIN EINSAETZE TABLE
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS einsaetze (
-- Identity
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
einsatz_nr VARCHAR(12) NOT NULL, -- Format: 2026-001
-- Core timestamps (all TIMESTAMPTZ — never null for alarm_time)
alarm_time TIMESTAMPTZ NOT NULL,
ausrueck_time TIMESTAMPTZ,
ankunft_time TIMESTAMPTZ,
einrueck_time TIMESTAMPTZ,
-- Classification
einsatz_art VARCHAR(30) NOT NULL
CONSTRAINT chk_einsatz_art CHECK (einsatz_art IN (
'Brand',
'THL',
'ABC',
'BMA',
'Hilfeleistung',
'Fehlalarm',
'Brandsicherheitswache'
)),
einsatz_stichwort VARCHAR(30), -- B1/B2/B3/B4, THL 1/2/3, etc.
-- Location
strasse VARCHAR(150),
hausnummer VARCHAR(20),
ort VARCHAR(100),
koordinaten POINT, -- (longitude, latitude)
-- Narrative
bericht_kurz VARCHAR(255), -- short description, visible to all
bericht_text TEXT, -- full narrative, restricted to Kommandant+
-- References
einsatzleiter_id UUID
CONSTRAINT fk_einsaetze_einsatzleiter REFERENCES users(id) ON DELETE SET NULL,
-- Operational metadata
alarmierung_art VARCHAR(30) NOT NULL DEFAULT 'ILS'
CONSTRAINT chk_alarmierung_art CHECK (alarmierung_art IN (
'ILS',
'DME',
'Telefon',
'Vor_Ort',
'Sonstiges'
)),
status VARCHAR(20) NOT NULL DEFAULT 'aktiv'
CONSTRAINT chk_einsatz_status CHECK (status IN (
'aktiv',
'abgeschlossen',
'archiviert'
)),
-- Audit
created_by UUID
CONSTRAINT fk_einsaetze_created_by REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Uniqueness: einsatz_nr is globally unique (year+seq guarantees it)
CONSTRAINT uq_einsatz_nr UNIQUE (einsatz_nr)
);
-- Performance indexes
CREATE INDEX IF NOT EXISTS idx_einsaetze_alarm_time ON einsaetze(alarm_time DESC);
CREATE INDEX IF NOT EXISTS idx_einsaetze_einsatz_art ON einsaetze(einsatz_art);
CREATE INDEX IF NOT EXISTS idx_einsaetze_status ON einsaetze(status);
CREATE INDEX IF NOT EXISTS idx_einsaetze_einsatzleiter ON einsaetze(einsatzleiter_id);
-- Note: EXTRACT(YEAR FROM timestamptz) is STABLE (timezone-dependent), not IMMUTABLE,
-- so it cannot be used in expression indexes. Use alarm_time range scans for year filtering.
CREATE INDEX IF NOT EXISTS idx_einsaetze_alarm_art ON einsaetze(einsatz_art, alarm_time DESC);
-- Auto-update updated_at
CREATE TRIGGER update_einsaetze_updated_at
BEFORE UPDATE ON einsaetze
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ---------------------------------------------------------------------------
-- JUNCTION TABLE: einsatz_fahrzeuge
-- NOTE: fahrzeug_id FK references fahrzeuge which is created in 005.
-- We define the FK as DEFERRABLE INITIALLY DEFERRED so the constraint is
-- validated at transaction commit, not statement time. This means the
-- migration can run before 005 as long as both run in the same session.
-- In production where 005 was already run, the FK resolves immediately.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS einsatz_fahrzeuge (
einsatz_id UUID NOT NULL
CONSTRAINT fk_ef_einsatz REFERENCES einsaetze(id) ON DELETE CASCADE,
fahrzeug_id UUID NOT NULL,
-- FK added separately below via ALTER TABLE to handle cross-migration dependency
-- Vehicle-level timestamps (may differ from main einsatz times)
ausrueck_time TIMESTAMPTZ,
einrueck_time TIMESTAMPTZ,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pk_einsatz_fahrzeuge PRIMARY KEY (einsatz_id, fahrzeug_id)
);
-- Add the FK to fahrzeuge only if the fahrzeuge table already exists
-- (handles the case where 004 runs after 005 in a fresh deployment).
-- If fahrzeuge does not exist yet, the FK will be added by migration 005
-- via an ALTER TABLE at the end of that migration.
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'fahrzeuge'
) THEN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE table_name = 'einsatz_fahrzeuge'
AND constraint_name = 'fk_ef_fahrzeug'
) THEN
ALTER TABLE einsatz_fahrzeuge
ADD CONSTRAINT fk_ef_fahrzeug
FOREIGN KEY (fahrzeug_id) REFERENCES fahrzeuge(id) ON DELETE CASCADE;
END IF;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_ef_fahrzeug_id ON einsatz_fahrzeuge(fahrzeug_id);
-- ---------------------------------------------------------------------------
-- JUNCTION TABLE: einsatz_personal
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS einsatz_personal (
einsatz_id UUID NOT NULL
CONSTRAINT fk_ep_einsatz REFERENCES einsaetze(id) ON DELETE CASCADE,
user_id UUID NOT NULL
CONSTRAINT fk_ep_user REFERENCES users(id) ON DELETE CASCADE,
funktion VARCHAR(50) NOT NULL DEFAULT 'Mannschaft'
CONSTRAINT chk_ep_funktion CHECK (funktion IN (
'Einsatzleiter',
'Gruppenführer',
'Maschinist',
'Atemschutz',
'Sicherheitstrupp',
'Melder',
'Wassertrupp',
'Angriffstrupp',
'Mannschaft',
'Sonstiges'
)),
-- Personal-level timestamps
alarm_time TIMESTAMPTZ,
ankunft_time TIMESTAMPTZ,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pk_einsatz_personal PRIMARY KEY (einsatz_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_ep_user_id ON einsatz_personal(user_id);
CREATE INDEX IF NOT EXISTS idx_ep_funktion ON einsatz_personal(funktion);
-- ---------------------------------------------------------------------------
-- FUNCTION: generate_einsatz_nr(alarm_time)
-- Atomically generates the next Einsatz-Nr for a given year.
-- Uses an advisory lock to serialise concurrent inserts for the same year.
-- Returns format: '2026-001'
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION generate_einsatz_nr(p_alarm_time TIMESTAMPTZ)
RETURNS VARCHAR AS $$
DECLARE
v_year INTEGER;
v_nr INTEGER;
BEGIN
v_year := EXTRACT(YEAR FROM p_alarm_time)::INTEGER;
-- Upsert the sequence row, then atomically increment and return
INSERT INTO einsatz_nr_sequence (year, last_nr)
VALUES (v_year, 1)
ON CONFLICT (year) DO UPDATE
SET last_nr = einsatz_nr_sequence.last_nr + 1
RETURNING last_nr INTO v_nr;
RETURN v_year::TEXT || '-' || LPAD(v_nr::TEXT, 3, '0');
END;
$$ LANGUAGE plpgsql;
-- ---------------------------------------------------------------------------
-- MATERIALIZED VIEW: einsatz_statistik
-- Pre-aggregated stats used by the dashboard and annual KBI reports.
-- Refresh manually after inserts/updates via: REFRESH MATERIALIZED VIEW einsatz_statistik;
-- ---------------------------------------------------------------------------
CREATE MATERIALIZED VIEW IF NOT EXISTS einsatz_statistik AS
SELECT
EXTRACT(YEAR FROM alarm_time)::INTEGER AS jahr,
EXTRACT(MONTH FROM alarm_time)::INTEGER AS monat,
einsatz_art,
COUNT(*) AS anzahl,
-- Hilfsfrist: median minutes from alarm to arrival (only where ankunft_time is set)
ROUND(
AVG(
EXTRACT(EPOCH FROM (ankunft_time - alarm_time)) / 60.0
) FILTER (WHERE ankunft_time IS NOT NULL)
)::INTEGER AS avg_hilfsfrist_min,
-- Total duration (alarm to einrueck)
ROUND(
AVG(
EXTRACT(EPOCH FROM (einrueck_time - alarm_time)) / 60.0
) FILTER (WHERE einrueck_time IS NOT NULL)
)::INTEGER AS avg_dauer_min,
COUNT(*) FILTER (WHERE status = 'abgeschlossen') AS anzahl_abgeschlossen,
COUNT(*) FILTER (WHERE status = 'aktiv') AS anzahl_aktiv
FROM einsaetze
WHERE status != 'archiviert'
GROUP BY
EXTRACT(YEAR FROM alarm_time),
EXTRACT(MONTH FROM alarm_time),
einsatz_art
WITH DATA;
CREATE UNIQUE INDEX IF NOT EXISTS idx_einsatz_statistik_pk
ON einsatz_statistik(jahr, monat, einsatz_art);
CREATE INDEX IF NOT EXISTS idx_einsatz_statistik_jahr
ON einsatz_statistik(jahr);
-- ---------------------------------------------------------------------------
-- ROLLBACK INSTRUCTIONS (run in reverse order to undo):
--
-- DROP MATERIALIZED VIEW IF EXISTS einsatz_statistik;
-- DROP FUNCTION IF EXISTS generate_einsatz_nr(TIMESTAMPTZ);
-- DROP TABLE IF EXISTS einsatz_personal;
-- DROP TABLE IF EXISTS einsatz_fahrzeuge;
-- DROP TABLE IF EXISTS einsaetze;
-- DROP TABLE IF EXISTS fahrzeuge;
-- DROP TABLE IF EXISTS einsatz_nr_sequence;
-- ---------------------------------------------------------------------------