add features
This commit is contained in:
270
backend/src/database/migrations/004_create_einsaetze.sql
Normal file
270
backend/src/database/migrations/004_create_einsaetze.sql
Normal file
@@ -0,0 +1,270 @@
|
||||
-- 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);
|
||||
CREATE INDEX IF NOT EXISTS idx_einsaetze_alarm_year ON einsaetze(EXTRACT(YEAR FROM alarm_time));
|
||||
CREATE INDEX IF NOT EXISTS idx_einsaetze_alarm_art_year ON einsaetze(einsatz_art, EXTRACT(YEAR FROM alarm_time));
|
||||
|
||||
-- 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;
|
||||
-- ---------------------------------------------------------------------------
|
||||
Reference in New Issue
Block a user