272 lines
11 KiB
PL/PgSQL
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;
|
|
-- ---------------------------------------------------------------------------
|