Kartenmodule ergaenzen
Phase 6: alles, was einen Ort hat — Loeschwasser, Objekte mit Feuerwehrplan, die Lagekarte eines Einsatzes und die geplante Foerderstrecke. Keine Kacheln aus dem Netz. Wo eine Wehr ausrueckt und wo ihre Hydranten liegen, ist niemandes Geschaeft; der Kartenserver laeuft selbst (tileserver-gl im Compose-Profil "karte"). Ohne ihn zeigt die Karte einen leeren Grund und sagt das auch — Punkte, Entfernungen und Umkreissuche funktionieren trotzdem. Dasselbe gilt fuer einen eingetragenen, aber nicht erreichbaren Server. Die Umkreissuche laeuft ueber PostGIS: ST_DWithin auf der Geografie mit GiST-Index rechnet in Metern und nutzt den Index. Gefragt wird waehrend der Fahrt, und bei tausend Hydranten waere alles andere ein vollstaendiger Tabellendurchlauf. Der Import aus GeoJSON und Stadtwerke-CSV ist nachsichtig, aber ehrlich: er erkennt "DN 100", deutsche Kommazahlen, Datumsangaben und vertauschte Achsen — raet aber nichts. Fehlt die Position, wird die Zeile uebersprungen; ohne Statusangabe steht "ungeprueft", nicht "einsatzbereit". 22 taktische Zeichen nach FwDV 102 selbst gezeichnet, die Lagekarte gleicht ueber denselben Ereignisstrom ab wie die Atemschutzueberwachung. Die Wasserfoerderung rechnet Reibung, Hoehe und Pumpenzahl; das Ergebnis wird nie gespeichert, sondern bei jeder Anzeige neu gerechnet. Geht eine Strecke nicht auf, sagt die Ausgabe das. Behoben: die Reibungsbeiwerte lagen im ersten Wurf um den Faktor zehn daneben. Aufgefallen ist es nur, weil die Tests gegen die Tabellenwerte der Ausbildung pruefen und nicht gegen die eigene Implementierung. Dazu ein Abruf fuer abgelegte Dateien (/api/dateien/[id]) — bis hierher gab es nur den Upload. 59 neue Tests (48 Unit, 11 E2E je Browser). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e1480c0c3e
commit
1c1e141666
@@ -0,0 +1,273 @@
|
||||
-- Phase 6 — Kartenmodule
|
||||
--
|
||||
-- Von Prisma erzeugt, danach von Hand um die DropIndex-Anweisungen erleichtert:
|
||||
-- die Volltext- und GiST-Indizes aus den fruehereren Phasen stehen nicht im
|
||||
-- Schema, weil Prisma sie nicht abbilden kann. Rechte, Mandantentrennung,
|
||||
-- Koordinaten-Trigger und Umkreisindizes stehen in der Migration daneben.
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "HydrantTyp" AS ENUM ('UNTERFLUR', 'UEBERFLUR', 'SAUGSTELLE', 'ZISTERNE', 'OFFENES_GEWAESSER', 'SONSTIGES');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "HydrantStatus" AS ENUM ('EINSATZBEREIT', 'EINGESCHRAENKT', 'DEFEKT', 'UNBEKANNT');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ObjektKategorie" AS ENUM ('SCHULE', 'KINDERGARTEN', 'PFLEGEHEIM', 'KRANKENHAUS', 'VERSAMMLUNGSSTAETTE', 'HOCHHAUS', 'INDUSTRIE', 'LANDWIRTSCHAFT', 'GEFAHRSTOFFLAGER', 'SONDERBAU', 'SONSTIGES');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PlanArt" AS ENUM ('UEBERSICHTSPLAN', 'GESCHOSSPLAN', 'UMGEBUNGSPLAN', 'SONDERPLAN', 'ANLAGE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "LageelementArt" AS ENUM ('ZEICHEN', 'LINIE', 'FLAECHE', 'TEXT');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "FoerderpunktArt" AS ENUM ('ENTNAHME', 'VERSTAERKER', 'WEGPUNKT', 'ZIEL');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "standort" ADD COLUMN "breitengrad" DECIMAL(9,6),
|
||||
ADD COLUMN "laengengrad" DECIMAL(9,6);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "hydrant" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"bezeichnung" VARCHAR(120),
|
||||
"typ" "HydrantTyp" NOT NULL DEFAULT 'UNTERFLUR',
|
||||
"status" "HydrantStatus" NOT NULL DEFAULT 'UNBEKANNT',
|
||||
"nennweite" INTEGER,
|
||||
"durchfluss" INTEGER,
|
||||
"strasse" VARCHAR(200),
|
||||
"hausnummer" VARCHAR(20),
|
||||
"ortsteil" VARCHAR(120),
|
||||
"breitengrad" DECIMAL(9,6) NOT NULL,
|
||||
"laengengrad" DECIMAL(9,6) NOT NULL,
|
||||
"koordinaten" geography(Point, 4326),
|
||||
"letztePruefung" DATE,
|
||||
"naechstePruefung" DATE,
|
||||
"betreiber" VARCHAR(200),
|
||||
"hinweis" VARCHAR(1000),
|
||||
"fotoDateiId" UUID,
|
||||
"quelle" VARCHAR(40),
|
||||
"externeNummer" VARCHAR(80),
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
"geloeschtAm" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "hydrant_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "objekt" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"name" VARCHAR(200) NOT NULL,
|
||||
"kategorie" "ObjektKategorie" NOT NULL DEFAULT 'SONSTIGES',
|
||||
"strasse" VARCHAR(200),
|
||||
"hausnummer" VARCHAR(20),
|
||||
"plz" VARCHAR(10),
|
||||
"ort" VARCHAR(120),
|
||||
"breitengrad" DECIMAL(9,6),
|
||||
"laengengrad" DECIMAL(9,6),
|
||||
"koordinaten" geography(Point, 4326),
|
||||
"zufahrt" VARCHAR(2000),
|
||||
"gefahren" VARCHAR(2000),
|
||||
"schluesseldepot" VARCHAR(500),
|
||||
"loeschwasser" VARCHAR(1000),
|
||||
"bemerkung" VARCHAR(2000),
|
||||
"personenzahl" INTEGER,
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
"geloeschtAm" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "objekt_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "objektkontakt" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"objektId" UUID NOT NULL,
|
||||
"name" VARCHAR(200) NOT NULL,
|
||||
"funktion" VARCHAR(120),
|
||||
"telefon" VARCHAR(60),
|
||||
"mobil" VARCHAR(60),
|
||||
"jederzeit" BOOLEAN NOT NULL DEFAULT false,
|
||||
"bemerkung" VARCHAR(500),
|
||||
"reihenfolge" INTEGER NOT NULL DEFAULT 0,
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "objektkontakt_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "objektplan" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"objektId" UUID NOT NULL,
|
||||
"dateiId" UUID NOT NULL,
|
||||
"art" "PlanArt" NOT NULL DEFAULT 'UEBERSICHTSPLAN',
|
||||
"bezeichnung" VARCHAR(200) NOT NULL,
|
||||
"standAm" DATE,
|
||||
"reihenfolge" INTEGER NOT NULL DEFAULT 0,
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "objektplan_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "lagekarte" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"einsatzId" UUID NOT NULL,
|
||||
"breitengrad" DECIMAL(9,6),
|
||||
"laengengrad" DECIMAL(9,6),
|
||||
"zoomstufe" INTEGER,
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "lagekarte_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "lageelement" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"lagekarteId" UUID NOT NULL,
|
||||
"art" "LageelementArt" NOT NULL DEFAULT 'ZEICHEN',
|
||||
"zeichen" VARCHAR(60),
|
||||
"text" VARCHAR(300),
|
||||
"farbe" VARCHAR(20),
|
||||
"geometrie" JSONB NOT NULL,
|
||||
"drehung" INTEGER NOT NULL DEFAULT 0,
|
||||
"erstelltVonId" UUID,
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
"geloeschtAm" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "lageelement_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "wasserfoerderung" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"einsatzId" UUID,
|
||||
"bezeichnung" VARCHAR(200) NOT NULL,
|
||||
"durchmesser" INTEGER NOT NULL DEFAULT 75,
|
||||
"foerdermenge" INTEGER NOT NULL DEFAULT 800,
|
||||
"ausgangsdruck" DECIMAL(4,1) NOT NULL DEFAULT 8,
|
||||
"eingangsdruck" DECIMAL(4,1) NOT NULL DEFAULT 1.5,
|
||||
"bemerkung" VARCHAR(2000),
|
||||
"geplantVonId" UUID,
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "wasserfoerderung_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "foerderpunkt" (
|
||||
"id" UUID NOT NULL,
|
||||
"organisationId" UUID NOT NULL,
|
||||
"wasserfoerderungId" UUID NOT NULL,
|
||||
"reihenfolge" INTEGER NOT NULL,
|
||||
"art" "FoerderpunktArt" NOT NULL DEFAULT 'WEGPUNKT',
|
||||
"bezeichnung" VARCHAR(200),
|
||||
"breitengrad" DECIMAL(9,6) NOT NULL,
|
||||
"laengengrad" DECIMAL(9,6) NOT NULL,
|
||||
"hoehe" DECIMAL(7,2),
|
||||
"bemerkung" VARCHAR(500),
|
||||
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "foerderpunkt_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "hydrant_organisationId_status_idx" ON "hydrant"("organisationId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "hydrant_organisationId_typ_idx" ON "hydrant"("organisationId", "typ");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "hydrant_organisationId_externeNummer_key" ON "hydrant"("organisationId", "externeNummer");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "objekt_organisationId_kategorie_idx" ON "objekt"("organisationId", "kategorie");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "objekt_organisationId_name_idx" ON "objekt"("organisationId", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "objektkontakt_organisationId_objektId_idx" ON "objektkontakt"("organisationId", "objektId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "objektplan_organisationId_objektId_idx" ON "objektplan"("organisationId", "objektId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "lagekarte_einsatzId_key" ON "lagekarte"("einsatzId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "lageelement_organisationId_lagekarteId_geloeschtAm_idx" ON "lageelement"("organisationId", "lagekarteId", "geloeschtAm");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "wasserfoerderung_organisationId_einsatzId_idx" ON "wasserfoerderung"("organisationId", "einsatzId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "foerderpunkt_organisationId_wasserfoerderungId_idx" ON "foerderpunkt"("organisationId", "wasserfoerderungId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "foerderpunkt_wasserfoerderungId_reihenfolge_key" ON "foerderpunkt"("wasserfoerderungId", "reihenfolge");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "hydrant" ADD CONSTRAINT "hydrant_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "hydrant" ADD CONSTRAINT "hydrant_fotoDateiId_fkey" FOREIGN KEY ("fotoDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "objekt" ADD CONSTRAINT "objekt_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "objektkontakt" ADD CONSTRAINT "objektkontakt_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "objektkontakt" ADD CONSTRAINT "objektkontakt_objektId_fkey" FOREIGN KEY ("objektId") REFERENCES "objekt"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "objektplan" ADD CONSTRAINT "objektplan_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "objektplan" ADD CONSTRAINT "objektplan_objektId_fkey" FOREIGN KEY ("objektId") REFERENCES "objekt"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "objektplan" ADD CONSTRAINT "objektplan_dateiId_fkey" FOREIGN KEY ("dateiId") REFERENCES "datei"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "lagekarte" ADD CONSTRAINT "lagekarte_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "lagekarte" ADD CONSTRAINT "lagekarte_einsatzId_fkey" FOREIGN KEY ("einsatzId") REFERENCES "einsatz"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "lageelement" ADD CONSTRAINT "lageelement_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "lageelement" ADD CONSTRAINT "lageelement_lagekarteId_fkey" FOREIGN KEY ("lagekarteId") REFERENCES "lagekarte"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "lageelement" ADD CONSTRAINT "lageelement_erstelltVonId_fkey" FOREIGN KEY ("erstelltVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "wasserfoerderung" ADD CONSTRAINT "wasserfoerderung_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "wasserfoerderung" ADD CONSTRAINT "wasserfoerderung_einsatzId_fkey" FOREIGN KEY ("einsatzId") REFERENCES "einsatz"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "wasserfoerderung" ADD CONSTRAINT "wasserfoerderung_geplantVonId_fkey" FOREIGN KEY ("geplantVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "foerderpunkt" ADD CONSTRAINT "foerderpunkt_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "foerderpunkt" ADD CONSTRAINT "foerderpunkt_wasserfoerderungId_fkey" FOREIGN KEY ("wasserfoerderungId") REFERENCES "wasserfoerderung"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,174 @@
|
||||
-- =============================================================================
|
||||
-- Phase 6: Mandantentrennung, Koordinaten und die Indizes für die Karte.
|
||||
--
|
||||
-- Vier Dinge:
|
||||
--
|
||||
-- 1. Row Level Security für die acht neuen Tabellen.
|
||||
--
|
||||
-- 2. Koordinaten doppelt halten. Prisma kann den PostGIS-Typ nicht lesen,
|
||||
-- also stehen Dezimalgrade daneben — und ein Trigger sorgt dafür, dass
|
||||
-- beide nie auseinanderlaufen. Dasselbe Muster wie beim Einsatz aus
|
||||
-- Phase 3, jetzt auch für Standort, Hydrant, Objekt und Foerderpunkt.
|
||||
--
|
||||
-- 3. GiST-Indizes auf den Punkten. Ohne sie ist die Umkreissuche ein
|
||||
-- vollstaendiger Durchlauf der Tabelle — bei tausend Hydranten im
|
||||
-- Ausrueckegebiet auf der Anfahrt zu langsam.
|
||||
--
|
||||
-- 4. Volltextsuche ueber Objekte: der Name allein genuegt nicht, gesucht
|
||||
-- wird auch nach Strasse, Gefahren und Ansprechpartner.
|
||||
-- =============================================================================
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Row Level Security
|
||||
-- -----------------------------------------------------------------------------
|
||||
DO $$
|
||||
DECLARE
|
||||
tabelle text;
|
||||
BEGIN
|
||||
FOREACH tabelle IN ARRAY ARRAY[
|
||||
'hydrant',
|
||||
'objekt',
|
||||
'objektkontakt',
|
||||
'objektplan',
|
||||
'lagekarte',
|
||||
'lageelement',
|
||||
'wasserfoerderung',
|
||||
'foerderpunkt'
|
||||
]
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tabelle);
|
||||
EXECUTE format(
|
||||
'CREATE POLICY %I ON %I FOR ALL '
|
||||
'USING ("organisationId" = aktuelle_organisation()) '
|
||||
'WITH CHECK ("organisationId" = aktuelle_organisation())',
|
||||
tabelle || '_mandant', tabelle
|
||||
);
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Koordinaten: Dezimalgrad und PostGIS-Punkt zusammenhalten
|
||||
--
|
||||
-- Eine Funktion fuer alle Tabellen: sie erwartet die Spalten "breitengrad",
|
||||
-- "laengengrad" und "koordinaten" und laesst sich damit an jede Tabelle
|
||||
-- haengen, die einen Ort hat.
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION koordinaten_setzen()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
breite double precision;
|
||||
laenge double precision;
|
||||
BEGIN
|
||||
breite := (to_jsonb(NEW) ->> 'breitengrad')::double precision;
|
||||
laenge := (to_jsonb(NEW) ->> 'laengengrad')::double precision;
|
||||
|
||||
IF breite IS NULL OR laenge IS NULL THEN
|
||||
NEW := jsonb_populate_record(NEW, jsonb_build_object('koordinaten', NULL));
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Reihenfolge beachten: ST_MakePoint nimmt erst die Laenge, dann die Breite.
|
||||
NEW := jsonb_populate_record(
|
||||
NEW,
|
||||
jsonb_build_object(
|
||||
'koordinaten',
|
||||
ST_AsEWKT(ST_SetSRID(ST_MakePoint(laenge, breite), 4326)::geography)
|
||||
)
|
||||
);
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION koordinaten_setzen() IS
|
||||
'Haelt den PostGIS-Punkt einer Tabelle mit ihren Dezimalgraden im Gleichlauf.';
|
||||
|
||||
CREATE TRIGGER standort_koordinaten
|
||||
BEFORE INSERT OR UPDATE OF "breitengrad", "laengengrad" ON "standort"
|
||||
FOR EACH ROW EXECUTE FUNCTION koordinaten_setzen();
|
||||
|
||||
CREATE TRIGGER hydrant_koordinaten
|
||||
BEFORE INSERT OR UPDATE OF "breitengrad", "laengengrad" ON "hydrant"
|
||||
FOR EACH ROW EXECUTE FUNCTION koordinaten_setzen();
|
||||
|
||||
CREATE TRIGGER objekt_koordinaten
|
||||
BEFORE INSERT OR UPDATE OF "breitengrad", "laengengrad" ON "objekt"
|
||||
FOR EACH ROW EXECUTE FUNCTION koordinaten_setzen();
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Koordinaten, die es auf der Erde nicht gibt, sind ein Tippfehler
|
||||
-- -----------------------------------------------------------------------------
|
||||
ALTER TABLE "hydrant" ADD CONSTRAINT "hydrant_koordinaten_gueltig" CHECK (
|
||||
"breitengrad" >= -90 AND "breitengrad" <= 90
|
||||
AND "laengengrad" >= -180 AND "laengengrad" <= 180
|
||||
);
|
||||
|
||||
ALTER TABLE "objekt" ADD CONSTRAINT "objekt_koordinaten_gueltig" CHECK (
|
||||
("breitengrad" IS NULL OR ("breitengrad" >= -90 AND "breitengrad" <= 90))
|
||||
AND ("laengengrad" IS NULL OR ("laengengrad" >= -180 AND "laengengrad" <= 180))
|
||||
);
|
||||
|
||||
ALTER TABLE "standort" ADD CONSTRAINT "standort_koordinaten_gueltig" CHECK (
|
||||
("breitengrad" IS NULL OR ("breitengrad" >= -90 AND "breitengrad" <= 90))
|
||||
AND ("laengengrad" IS NULL OR ("laengengrad" >= -180 AND "laengengrad" <= 180))
|
||||
);
|
||||
|
||||
ALTER TABLE "foerderpunkt" ADD CONSTRAINT "foerderpunkt_koordinaten_gueltig" CHECK (
|
||||
"breitengrad" >= -90 AND "breitengrad" <= 90
|
||||
AND "laengengrad" >= -180 AND "laengengrad" <= 180
|
||||
);
|
||||
|
||||
-- Eine Foerderstrecke ohne Wasser oder mit einem Schlauch, den es nicht gibt.
|
||||
ALTER TABLE "wasserfoerderung" ADD CONSTRAINT "wasserfoerderung_sinnvoll" CHECK (
|
||||
"durchmesser" IN (52, 75, 110)
|
||||
AND "foerdermenge" > 0 AND "foerdermenge" <= 10000
|
||||
AND "ausgangsdruck" > 0 AND "ausgangsdruck" <= 16
|
||||
AND "eingangsdruck" >= 0 AND "eingangsdruck" < "ausgangsdruck"
|
||||
);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Indizes fuer die Umkreissuche
|
||||
--
|
||||
-- GiST auf der Geografie: damit beantwortet Postgres "was liegt im Umkreis von
|
||||
-- 300 Metern" ueber den Index statt ueber die ganze Tabelle.
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE INDEX "hydrant_umkreis" ON "hydrant" USING GIST ("koordinaten")
|
||||
WHERE "geloeschtAm" IS NULL;
|
||||
|
||||
CREATE INDEX "objekt_umkreis" ON "objekt" USING GIST ("koordinaten")
|
||||
WHERE "geloeschtAm" IS NULL;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Volltextsuche ueber Objekte
|
||||
--
|
||||
-- Trigram wie bei den Geraeten aus Phase 1: gesucht wird mit Tippfehlern und
|
||||
-- Wortfragmenten, nicht mit vollstaendigen Woertern.
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE INDEX "objekt_suche_name" ON "objekt" USING GIN ("name" gin_trgm_ops);
|
||||
CREATE INDEX "objekt_suche_strasse" ON "objekt" USING GIN ("strasse" gin_trgm_ops);
|
||||
CREATE INDEX "objekt_suche_gefahren" ON "objekt" USING GIN ("gefahren" gin_trgm_ops);
|
||||
CREATE INDEX "hydrant_suche_bezeichnung" ON "hydrant" USING GIN ("bezeichnung" gin_trgm_ops);
|
||||
CREATE INDEX "hydrant_suche_strasse" ON "hydrant" USING GIN ("strasse" gin_trgm_ops);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Rechte fuer die Anwendungsrolle
|
||||
-- -----------------------------------------------------------------------------
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'florian_app') THEN
|
||||
EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO florian_app';
|
||||
EXECUTE 'GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO florian_app';
|
||||
|
||||
EXECUTE 'REVOKE INSERT, UPDATE, DELETE ON "berechtigung" FROM florian_app';
|
||||
EXECUTE 'REVOKE UPDATE, DELETE ON "audit_log" FROM florian_app';
|
||||
EXECUTE 'REVOKE UPDATE, DELETE ON "kiosk_vorgang" FROM florian_app';
|
||||
EXECUTE 'REVOKE UPDATE, DELETE ON "druckmessung" FROM florian_app';
|
||||
EXECUTE 'REVOKE UPDATE, DELETE ON "atemschutz_ereignis" FROM florian_app';
|
||||
ELSE
|
||||
RAISE NOTICE 'Rolle florian_app nicht vorhanden — Rechtevergabe uebersprungen.';
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
+394
-3
@@ -208,6 +208,16 @@ model Organisation {
|
||||
g26Untersuchungen G26Untersuchung[]
|
||||
belastungsuebungen Belastungsuebung[]
|
||||
|
||||
// Phase 6 — Kartenmodule
|
||||
hydranten Hydrant[]
|
||||
objekte Objekt[]
|
||||
objektkontakte Objektkontakt[]
|
||||
objektplaene Objektplan[]
|
||||
lagekarten Lagekarte[]
|
||||
lageelemente Lageelement[]
|
||||
wasserfoerderungen Wasserfoerderung[]
|
||||
foerderpunkte Foerderpunkt[]
|
||||
|
||||
@@index([aktiv])
|
||||
@@map("organisation")
|
||||
}
|
||||
@@ -310,6 +320,10 @@ model Person {
|
||||
g26Untersuchungen G26Untersuchung[] @relation("G26Person")
|
||||
belastungsuebungen Belastungsuebung[] @relation("BelastungsuebungPerson")
|
||||
|
||||
// Phase 6 — Kartenmodule
|
||||
gezeichneteLageelemente Lageelement[] @relation("LageelementZeichner")
|
||||
geplanteFoerderstrecken Wasserfoerderung[] @relation("WasserfoerderungPlaner")
|
||||
|
||||
/// E-Mail und OIDC-Subject sind je Wehr eindeutig, nicht global — dieselbe
|
||||
/// Person kann in zwei Wehren Mitglied sein.
|
||||
@@unique([organisationId, email])
|
||||
@@ -548,6 +562,10 @@ model Datei {
|
||||
alsAtemschutzprotokoll Atemschutzeinsatz[] @relation("AtemschutzProtokoll")
|
||||
alsG26Nachweis G26Untersuchung[] @relation("G26Nachweis")
|
||||
|
||||
// Phase 6
|
||||
alsHydrantenfoto Hydrant[] @relation("HydrantFoto")
|
||||
alsFeuerwehrplan Objektplan[] @relation("ObjektplanDatei")
|
||||
|
||||
@@unique([bucket, objektSchluessel])
|
||||
@@index([organisationId, entitaet, entitaetId])
|
||||
@@index([organisationId, kategorie])
|
||||
@@ -646,9 +664,12 @@ model Standort {
|
||||
plz String? @db.VarChar(10)
|
||||
ort String? @db.VarChar(120)
|
||||
|
||||
/// Koordinaten als PostGIS-Punkt (WGS 84). Prisma kennt den Typ nicht,
|
||||
/// deshalb Unsupported — gelesen und geschrieben wird per Rohabfrage.
|
||||
/// Ab Phase 6 zeigt die Karte diese Punkte an.
|
||||
/// Koordinaten doppelt: als Dezimalgrad für die Anwendung und als
|
||||
/// PostGIS-Punkt für die Karte. Prisma kann den PostGIS-Typ nicht lesen —
|
||||
/// ohne die Grade daneben wäre jede Anzeige eine Rohabfrage. Ein Trigger
|
||||
/// hält beides im Gleichlauf (Phase 6).
|
||||
breitengrad Decimal? @db.Decimal(9, 6)
|
||||
laengengrad Decimal? @db.Decimal(9, 6)
|
||||
koordinaten Unsupported("geography(Point, 4326)")?
|
||||
|
||||
bemerkung String? @db.VarChar(1000)
|
||||
@@ -1680,6 +1701,10 @@ model Einsatz {
|
||||
// Phase 5
|
||||
atemschutz Atemschutzeinsatz[]
|
||||
|
||||
// Phase 6
|
||||
lagekarte Lagekarte?
|
||||
wasserfoerderungen Wasserfoerderung[]
|
||||
|
||||
@@unique([organisationId, einsatznummer])
|
||||
@@index([organisationId, alarmiertUm(sort: Desc)])
|
||||
@@index([organisationId, status])
|
||||
@@ -2573,3 +2598,369 @@ model Belastungsuebung {
|
||||
@@index([organisationId, personId, datum(sort: Desc)])
|
||||
@@map("belastungsuebung")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Phase 6 — Kartenmodule
|
||||
//
|
||||
// Alles, was einen Ort hat: Hydranten, Objekte mit Feuerwehrplänen, die
|
||||
// Lagekarte eines Einsatzes und die geplante Wasserförderstrecke.
|
||||
//
|
||||
// Koordinaten liegen überall doppelt — als Dezimalgrad und als PostGIS-Punkt.
|
||||
// Der Grund ist derselbe wie beim Einsatz aus Phase 3: Prisma kann den
|
||||
// PostGIS-Typ nicht lesen, und ohne die Grade daneben wäre jede Anzeige eine
|
||||
// Rohabfrage. Ein Trigger hält beides zusammen; die Umkreissuche läuft über
|
||||
// den PostGIS-Punkt, weil nur der einen Index hat, der Entfernungen kann.
|
||||
// =============================================================================
|
||||
|
||||
/// Woher das Löschwasser kommt.
|
||||
enum HydrantTyp {
|
||||
/// Der Regelfall im Ort: Deckel auf der Straße, Standrohr drauf.
|
||||
UNTERFLUR
|
||||
/// Sichtbare Säule. Im Winter schneller zu finden.
|
||||
UEBERFLUR
|
||||
/// Vorbereitete Entnahmestelle an einem Gewässer.
|
||||
SAUGSTELLE
|
||||
/// Löschwasserbehälter, meist unterirdisch.
|
||||
ZISTERNE
|
||||
/// Bach, Weiher, Fluss — mit allem, was daran hängt (Zufahrt, Jahreszeit).
|
||||
OFFENES_GEWAESSER
|
||||
SONSTIGES
|
||||
}
|
||||
|
||||
/// Ob man sich darauf verlassen kann.
|
||||
enum HydrantStatus {
|
||||
EINSATZBEREIT
|
||||
/// Nutzbar, aber mit Einschränkung — geringe Leistung, schwer zugänglich.
|
||||
EINGESCHRAENKT
|
||||
DEFEKT
|
||||
/// Noch nie geprüft oder aus einem Import ohne Angabe übernommen.
|
||||
UNBEKANNT
|
||||
}
|
||||
|
||||
/// Grobe Einordnung eines Objekts. Bestimmt, was im Einsatz zuerst zählt.
|
||||
enum ObjektKategorie {
|
||||
SCHULE
|
||||
KINDERGARTEN
|
||||
PFLEGEHEIM
|
||||
KRANKENHAUS
|
||||
VERSAMMLUNGSSTAETTE
|
||||
HOCHHAUS
|
||||
INDUSTRIE
|
||||
LANDWIRTSCHAFT
|
||||
GEFAHRSTOFFLAGER
|
||||
SONDERBAU
|
||||
SONSTIGES
|
||||
}
|
||||
|
||||
/// Blattarten eines Feuerwehrplans nach DIN 14095.
|
||||
enum PlanArt {
|
||||
UEBERSICHTSPLAN
|
||||
GESCHOSSPLAN
|
||||
UMGEBUNGSPLAN
|
||||
SONDERPLAN
|
||||
/// Alles, was zusätzlich dazugehört: Betriebsanweisung, Stoffliste.
|
||||
ANLAGE
|
||||
}
|
||||
|
||||
/// Was auf der Lagekarte liegt.
|
||||
enum LageelementArt {
|
||||
/// Taktisches Zeichen nach FwDV 102, siehe lib/karte/taktische-zeichen.ts.
|
||||
ZEICHEN
|
||||
/// Linienzug — Schlauchleitung, Absperrung, Anfahrtsweg.
|
||||
LINIE
|
||||
/// Geschlossene Fläche — Bereitstellungsraum, Gefahrenbereich.
|
||||
FLAECHE
|
||||
/// Freie Beschriftung.
|
||||
TEXT
|
||||
}
|
||||
|
||||
/// Rolle eines Punktes in der Förderstrecke.
|
||||
enum FoerderpunktArt {
|
||||
/// Wo das Wasser herkommt.
|
||||
ENTNAHME
|
||||
/// Verstärkerpumpe in der Strecke.
|
||||
VERSTAERKER
|
||||
/// Zwischenpunkt ohne Pumpe — nur für den Streckenverlauf und die Höhe.
|
||||
WEGPUNKT
|
||||
/// Die Einsatzstelle.
|
||||
ZIEL
|
||||
}
|
||||
|
||||
/// Ein Hydrant oder eine andere Löschwasserstelle.
|
||||
///
|
||||
/// Die Frage, die dieses Modell beantworten muss, wird auf der Anfahrt
|
||||
/// gestellt und lautet: **Was liegt in der Nähe, und was gibt es her?**
|
||||
/// Deshalb stehen Typ, Nennweite und Durchfluss gleichrangig neben dem Ort —
|
||||
/// ein Unterflurhydrant mit 80er Leitung ist etwas anderes als einer mit 150.
|
||||
model Hydrant {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
|
||||
/// Nummer oder Bezeichnung, wie sie die Wehr verwendet.
|
||||
bezeichnung String? @db.VarChar(120)
|
||||
typ HydrantTyp @default(UNTERFLUR)
|
||||
status HydrantStatus @default(UNBEKANNT)
|
||||
|
||||
/// Nennweite der Leitung in Millimetern (80, 100, 150 …).
|
||||
nennweite Int?
|
||||
/// Ergiebigkeit in Litern je Minute, aus der Prüfung oder vom Versorger.
|
||||
durchfluss Int?
|
||||
|
||||
strasse String? @db.VarChar(200)
|
||||
hausnummer String? @db.VarChar(20)
|
||||
ortsteil String? @db.VarChar(120)
|
||||
|
||||
breitengrad Decimal @db.Decimal(9, 6)
|
||||
laengengrad Decimal @db.Decimal(9, 6)
|
||||
koordinaten Unsupported("geography(Point, 4326)")?
|
||||
|
||||
letztePruefung DateTime? @db.Date
|
||||
naechstePruefung DateTime? @db.Date
|
||||
/// Wer die Leitung betreibt — im Zweifel der Ansprechpartner bei Defekt.
|
||||
betreiber String? @db.VarChar(200)
|
||||
/// Was man vor Ort wissen muss: „hinter der Hecke", „im Winter geräumt".
|
||||
hinweis String? @db.VarChar(1000)
|
||||
|
||||
fotoDateiId String? @db.Uuid
|
||||
|
||||
/// Woher der Datensatz stammt: "hand", "geojson", "stadtwerke-csv".
|
||||
quelle String? @db.VarChar(40)
|
||||
/// Kennung beim Versorger, damit ein zweiter Import nichts doppelt anlegt.
|
||||
externeNummer String? @db.VarChar(80)
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||
geloeschtAm DateTime? @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
foto Datei? @relation("HydrantFoto", fields: [fotoDateiId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@unique([organisationId, externeNummer])
|
||||
@@index([organisationId, status])
|
||||
@@index([organisationId, typ])
|
||||
@@map("hydrant")
|
||||
}
|
||||
|
||||
/// Ein Objekt mit Feuerwehrplan.
|
||||
///
|
||||
/// Was hier steht, wird auf der Anfahrt gelesen — oft vom Beifahrersitz aus.
|
||||
/// Deshalb sind Zufahrt, Gefahren und Schlüsseldepot eigene Felder und keine
|
||||
/// Fließtextnotiz: im Einsatz sucht niemand in einem Absatz.
|
||||
model Objekt {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
|
||||
name String @db.VarChar(200)
|
||||
kategorie ObjektKategorie @default(SONSTIGES)
|
||||
|
||||
strasse String? @db.VarChar(200)
|
||||
hausnummer String? @db.VarChar(20)
|
||||
plz String? @db.VarChar(10)
|
||||
ort String? @db.VarChar(120)
|
||||
|
||||
breitengrad Decimal? @db.Decimal(9, 6)
|
||||
laengengrad Decimal? @db.Decimal(9, 6)
|
||||
koordinaten Unsupported("geography(Point, 4326)")?
|
||||
|
||||
/// Wie man hinkommt: Tordurchfahrt, Höhenbegrenzung, Aufstellfläche.
|
||||
zufahrt String? @db.VarChar(2000)
|
||||
/// Was einen erwartet: Photovoltaik, Gasflaschen, Silo, Tiere.
|
||||
gefahren String? @db.VarChar(2000)
|
||||
/// Wo der Schlüssel liegt und wie man drankommt.
|
||||
schluesseldepot String? @db.VarChar(500)
|
||||
/// Löschwasser am Objekt: Wandhydranten, Sprinklerzentrale, Steigleitung.
|
||||
loeschwasser String? @db.VarChar(1000)
|
||||
bemerkung String? @db.VarChar(2000)
|
||||
|
||||
/// Anzahl der Personen, mit denen zu rechnen ist (Schule, Heim, Betrieb).
|
||||
personenzahl Int?
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||
geloeschtAm DateTime? @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
kontakte Objektkontakt[]
|
||||
plaene Objektplan[]
|
||||
|
||||
@@index([organisationId, kategorie])
|
||||
@@index([organisationId, name])
|
||||
@@map("objekt")
|
||||
}
|
||||
|
||||
/// Wen man beim Objekt anruft.
|
||||
model Objektkontakt {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
objektId String @db.Uuid
|
||||
|
||||
name String @db.VarChar(200)
|
||||
funktion String? @db.VarChar(120)
|
||||
telefon String? @db.VarChar(60)
|
||||
mobil String? @db.VarChar(60)
|
||||
/// Erreichbar rund um die Uhr? Im Einsatz die einzige Frage, die zählt.
|
||||
jederzeit Boolean @default(false)
|
||||
bemerkung String? @db.VarChar(500)
|
||||
|
||||
reihenfolge Int @default(0)
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
objekt Objekt @relation(fields: [objektId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([organisationId, objektId])
|
||||
@@map("objektkontakt")
|
||||
}
|
||||
|
||||
/// Ein Blatt des Feuerwehrplans nach DIN 14095.
|
||||
model Objektplan {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
objektId String @db.Uuid
|
||||
dateiId String @db.Uuid
|
||||
|
||||
art PlanArt @default(UEBERSICHTSPLAN)
|
||||
bezeichnung String @db.VarChar(200)
|
||||
/// Stand des Plans — ein Feuerwehrplan von 2009 ist eine Vermutung.
|
||||
standAm DateTime? @db.Date
|
||||
reihenfolge Int @default(0)
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
objekt Objekt @relation(fields: [objektId], references: [id], onDelete: Cascade)
|
||||
datei Datei @relation("ObjektplanDatei", fields: [dateiId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([organisationId, objektId])
|
||||
@@map("objektplan")
|
||||
}
|
||||
|
||||
/// Die Lagekarte eines Einsatzes.
|
||||
///
|
||||
/// Eine je Einsatz. Sie ist der gemeinsame Zeichenblock: was einer einträgt,
|
||||
/// sehen die anderen — verteilt über denselben Ereignisstrom wie die
|
||||
/// Atemschutzüberwachung aus Phase 5.
|
||||
model Lagekarte {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
einsatzId String @unique @db.Uuid
|
||||
|
||||
/// Kartenausschnitt, damit alle denselben Blick haben.
|
||||
breitengrad Decimal? @db.Decimal(9, 6)
|
||||
laengengrad Decimal? @db.Decimal(9, 6)
|
||||
zoomstufe Int?
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
einsatz Einsatz @relation(fields: [einsatzId], references: [id], onDelete: Cascade)
|
||||
elemente Lageelement[]
|
||||
|
||||
@@map("lagekarte")
|
||||
}
|
||||
|
||||
/// Ein Element auf der Lagekarte.
|
||||
///
|
||||
/// Die Geometrie steht als JSON: ein Punkt für ein taktisches Zeichen, eine
|
||||
/// Liste von Punkten für Linie und Fläche. PostGIS wäre hier zu viel —
|
||||
/// gerechnet wird damit nichts, es wird nur gezeichnet.
|
||||
///
|
||||
/// Gelöscht wird weich: eine Lagekarte ist die Mitschrift einer Entscheidung,
|
||||
/// und was jemand um 14:12 eingezeichnet hat, gehört auch dann noch dazu, wenn
|
||||
/// es um 14:20 nicht mehr stimmte.
|
||||
model Lageelement {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
lagekarteId String @db.Uuid
|
||||
|
||||
art LageelementArt @default(ZEICHEN)
|
||||
/// Schlüssel aus dem Zeichensatz, etwa "loeschfahrzeug" oder "brandstelle".
|
||||
zeichen String? @db.VarChar(60)
|
||||
text String? @db.VarChar(300)
|
||||
farbe String? @db.VarChar(20)
|
||||
|
||||
/// [[laenge, breite], …] in WGS 84.
|
||||
geometrie Json @db.JsonB
|
||||
/// Drehung eines Zeichens in Grad — ein Fahrzeug steht selten nach Norden.
|
||||
drehung Int @default(0)
|
||||
|
||||
erstelltVonId String? @db.Uuid
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||
geloeschtAm DateTime? @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
lagekarte Lagekarte @relation(fields: [lagekarteId], references: [id], onDelete: Cascade)
|
||||
erstelltVon Person? @relation("LageelementZeichner", fields: [erstelltVonId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([organisationId, lagekarteId, geloeschtAm])
|
||||
@@map("lageelement")
|
||||
}
|
||||
|
||||
/// Eine geplante Wasserförderstrecke.
|
||||
///
|
||||
/// Die Rechnung dahinter ist Physik mit klaren Formeln (siehe
|
||||
/// lib/karte/wasserfoerderung.ts) — deshalb steht hier nur, woraus gerechnet
|
||||
/// wurde. Das Ergebnis wird nicht gespeichert, sondern bei jeder Anzeige neu
|
||||
/// gerechnet: eine gespeicherte Zahl neben geänderten Eingaben wäre falsch,
|
||||
/// ohne falsch auszusehen.
|
||||
model Wasserfoerderung {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
einsatzId String? @db.Uuid
|
||||
|
||||
bezeichnung String @db.VarChar(200)
|
||||
|
||||
/// Schlauchdurchmesser in Millimetern: 75 (B) oder 110 (A).
|
||||
durchmesser Int @default(75)
|
||||
/// Geplante Fördermenge in Litern je Minute.
|
||||
foerdermenge Int @default(800)
|
||||
/// Ausgangsdruck der ersten Pumpe in bar.
|
||||
ausgangsdruck Decimal @default(8) @db.Decimal(4, 1)
|
||||
/// Mindestdruck am Eingang jeder weiteren Pumpe in bar.
|
||||
eingangsdruck Decimal @default(1.5) @db.Decimal(4, 1)
|
||||
|
||||
bemerkung String? @db.VarChar(2000)
|
||||
|
||||
geplantVonId String? @db.Uuid
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
einsatz Einsatz? @relation(fields: [einsatzId], references: [id], onDelete: SetNull)
|
||||
geplantVon Person? @relation("WasserfoerderungPlaner", fields: [geplantVonId], references: [id], onDelete: SetNull)
|
||||
punkte Foerderpunkt[]
|
||||
|
||||
@@index([organisationId, einsatzId])
|
||||
@@map("wasserfoerderung")
|
||||
}
|
||||
|
||||
/// Ein Punkt der Förderstrecke.
|
||||
model Foerderpunkt {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organisationId String @db.Uuid
|
||||
wasserfoerderungId String @db.Uuid
|
||||
|
||||
reihenfolge Int
|
||||
art FoerderpunktArt @default(WEGPUNKT)
|
||||
bezeichnung String? @db.VarChar(200)
|
||||
|
||||
breitengrad Decimal @db.Decimal(9, 6)
|
||||
laengengrad Decimal @db.Decimal(9, 6)
|
||||
/// Höhe über Normalnull in Metern. Der Höhenunterschied kostet Druck:
|
||||
/// zehn Meter Steigung sind ein bar.
|
||||
hoehe Decimal? @db.Decimal(7, 2)
|
||||
|
||||
bemerkung String? @db.VarChar(500)
|
||||
|
||||
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
wasserfoerderung Wasserfoerderung @relation(fields: [wasserfoerderungId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([wasserfoerderungId, reihenfolge])
|
||||
@@index([organisationId, wasserfoerderungId])
|
||||
@@map("foerderpunkt")
|
||||
}
|
||||
|
||||
+279
-1
@@ -13,7 +13,9 @@
|
||||
* Gerätehaus und Schutzkleidung in der Reinigung — damit die Terminalübersicht
|
||||
* beim ersten Start nicht leer ist. Ab Phase 5 dazu: G26-Untersuchungen und
|
||||
* Belastungsübungen der Atemschutzgeräteträger und eine abgeschlossene
|
||||
* Überwachung mit vollständigem Protokoll.
|
||||
* Überwachung mit vollständigem Protokoll. Ab Phase 6 dazu: Koordinaten für
|
||||
* die Standorte, 24 Löschwasserstellen im Ausrückegebiet und drei Objekte mit
|
||||
* Feuerwehrplan.
|
||||
*
|
||||
* Das Skript läuft über DIRECT_DATABASE_URL, also mit dem Eigentümer-Zugang,
|
||||
* der Row Level Security umgehen darf. Anders ginge es nicht: der Seed legt
|
||||
@@ -52,6 +54,9 @@ import {
|
||||
QualifikationsArt,
|
||||
RolleSchluessel,
|
||||
SignaturRolle,
|
||||
HydrantStatus,
|
||||
HydrantTyp,
|
||||
ObjektKategorie,
|
||||
StandortArt,
|
||||
TruppStatus,
|
||||
UebungsArt,
|
||||
@@ -2479,6 +2484,276 @@ async function atemschutzAnlegen(organisationId: string) {
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Phase 6 — Kartenmodule
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/** Der Ortskern von Musterhausen — frei erfunden, aber in Niederbayern plausibel. */
|
||||
const ORTSMITTE = { breitengrad: 48.5, laengengrad: 12.15 };
|
||||
|
||||
/**
|
||||
* Löschwasser und Objekte.
|
||||
*
|
||||
* Die Hydranten liegen in einem Raster um den Ortskern, mit den Zuständen, die
|
||||
* eine Wehr tatsächlich vorfindet: die meisten in Ordnung, einige ungeprüft,
|
||||
* zwei defekt. Dazu ein Weiher und eine Zisterne am Ortsrand — im Außenbereich
|
||||
* gibt es keine Leitung.
|
||||
*
|
||||
* Die drei Objekte sind die, für die eine Wehr zuerst einen Plan schreibt:
|
||||
* Schule, Pflegeheim, Aussiedlerhof.
|
||||
*/
|
||||
async function kartendatenAnlegen(organisationId: string) {
|
||||
const vorhanden = await prisma.hydrant.count({ where: { organisationId } });
|
||||
if (vorhanden > 0) {
|
||||
log(`Karte: bereits ${vorhanden} Löschwasserstellen vorhanden`);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Standorte auf die Karte holen ---------------------------------------
|
||||
//
|
||||
// Ohne Koordinaten am Gerätehaus wüsste die Karte nicht, wohin sie schauen
|
||||
// soll. Die Standorte gibt es seit Phase 1, die Dezimalgrade seit Phase 6.
|
||||
const standorte = await prisma.standort.findMany({
|
||||
where: { organisationId, geloeschtAm: null },
|
||||
select: { id: true },
|
||||
orderBy: { erstelltAm: "asc" },
|
||||
});
|
||||
|
||||
for (const [index, standort] of standorte.entries()) {
|
||||
await prisma.standort.update({
|
||||
where: { id: standort.id },
|
||||
data: {
|
||||
breitengrad: ORTSMITTE.breitengrad + index * 0.004,
|
||||
laengengrad: ORTSMITTE.laengengrad + index * 0.006,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --- Löschwasserstellen ---------------------------------------------------
|
||||
const STRASSEN = [
|
||||
"Hauptstraße",
|
||||
"Bahnhofstraße",
|
||||
"Schulstraße",
|
||||
"Kirchplatz",
|
||||
"Am Anger",
|
||||
"Industriestraße",
|
||||
];
|
||||
|
||||
const hydranten: Array<{
|
||||
bezeichnung: string;
|
||||
typ: HydrantTyp;
|
||||
status: HydrantStatus;
|
||||
nennweite: number | null;
|
||||
durchfluss: number | null;
|
||||
strasse: string;
|
||||
breitengrad: number;
|
||||
laengengrad: number;
|
||||
letztePruefung: Date | null;
|
||||
hinweis: string | null;
|
||||
}> = [];
|
||||
|
||||
const heute = new Date();
|
||||
|
||||
function vorTagen(tage: number): Date {
|
||||
const datum = new Date(heute);
|
||||
datum.setUTCDate(datum.getUTCDate() - tage);
|
||||
datum.setUTCHours(0, 0, 0, 0);
|
||||
return datum;
|
||||
}
|
||||
|
||||
for (let nummer = 1; nummer <= 20; nummer += 1) {
|
||||
const strasse = STRASSEN[nummer % STRASSEN.length]!;
|
||||
// Ein Raster von rund 120 Metern Kantenlänge um die Ortsmitte.
|
||||
const reihe = Math.floor((nummer - 1) / 5);
|
||||
const spalte = (nummer - 1) % 5;
|
||||
|
||||
const ungeprueft = nummer % 7 === 0;
|
||||
const defekt = nummer === 9 || nummer === 17;
|
||||
|
||||
hydranten.push({
|
||||
bezeichnung: `H-${String(nummer).padStart(4, "0")}`,
|
||||
typ: nummer % 6 === 0 ? HydrantTyp.UEBERFLUR : HydrantTyp.UNTERFLUR,
|
||||
status: defekt
|
||||
? HydrantStatus.DEFEKT
|
||||
: ungeprueft
|
||||
? HydrantStatus.UNBEKANNT
|
||||
: HydrantStatus.EINSATZBEREIT,
|
||||
nennweite: nummer % 4 === 0 ? 150 : nummer % 3 === 0 ? 80 : 100,
|
||||
durchfluss: nummer % 4 === 0 ? 1600 : nummer % 3 === 0 ? 400 : 800,
|
||||
strasse,
|
||||
breitengrad: ORTSMITTE.breitengrad + reihe * 0.0011 - 0.002,
|
||||
laengengrad: ORTSMITTE.laengengrad + spalte * 0.0016 - 0.003,
|
||||
letztePruefung: ungeprueft ? null : vorTagen(60 + nummer * 9),
|
||||
hinweis: defekt
|
||||
? "Deckel verzogen, Standrohr passt nicht. Stadtwerke gemeldet."
|
||||
: nummer === 3
|
||||
? "Im Winter oft zugeschoben — Schneepflug fährt hier durch."
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Im Außenbereich gibt es keine Leitung.
|
||||
hydranten.push(
|
||||
{
|
||||
bezeichnung: "Weiher Kiesgrube",
|
||||
typ: HydrantTyp.OFFENES_GEWAESSER,
|
||||
status: HydrantStatus.EINSATZBEREIT,
|
||||
nennweite: null,
|
||||
durchfluss: 2400,
|
||||
strasse: "Kiesgrubenweg",
|
||||
breitengrad: ORTSMITTE.breitengrad - 0.008,
|
||||
laengengrad: ORTSMITTE.laengengrad + 0.012,
|
||||
letztePruefung: vorTagen(200),
|
||||
hinweis: "Zufahrt für LF geeignet, im Winter vereist. Saughöhe rund 3 m.",
|
||||
},
|
||||
{
|
||||
bezeichnung: "Zisterne Aussiedlerhof",
|
||||
typ: HydrantTyp.ZISTERNE,
|
||||
status: HydrantStatus.EINSATZBEREIT,
|
||||
nennweite: null,
|
||||
durchfluss: 800,
|
||||
strasse: "Hofweg 2",
|
||||
breitengrad: ORTSMITTE.breitengrad + 0.011,
|
||||
laengengrad: ORTSMITTE.laengengrad + 0.018,
|
||||
letztePruefung: vorTagen(120),
|
||||
hinweis: "50 m³, Saugstutzen an der Nordseite der Scheune.",
|
||||
},
|
||||
{
|
||||
bezeichnung: "Saugstelle Mühlbach",
|
||||
typ: HydrantTyp.SAUGSTELLE,
|
||||
status: HydrantStatus.EINGESCHRAENKT,
|
||||
nennweite: null,
|
||||
durchfluss: 600,
|
||||
strasse: "Mühlweg",
|
||||
breitengrad: ORTSMITTE.breitengrad - 0.004,
|
||||
laengengrad: ORTSMITTE.laengengrad - 0.009,
|
||||
letztePruefung: vorTagen(400),
|
||||
hinweis: "Im Sommer zu wenig Wasser. Nur nach Regen brauchbar.",
|
||||
},
|
||||
{
|
||||
bezeichnung: "H-0021",
|
||||
typ: HydrantTyp.UEBERFLUR,
|
||||
status: HydrantStatus.EINSATZBEREIT,
|
||||
nennweite: 150,
|
||||
durchfluss: 1600,
|
||||
strasse: "Industriestraße",
|
||||
breitengrad: ORTSMITTE.breitengrad + 0.006,
|
||||
laengengrad: ORTSMITTE.laengengrad + 0.008,
|
||||
letztePruefung: vorTagen(45),
|
||||
hinweis: "Am Werkstor, ergiebigster Hydrant im Ort.",
|
||||
},
|
||||
);
|
||||
|
||||
for (const hydrant of hydranten) {
|
||||
await prisma.hydrant.create({
|
||||
data: {
|
||||
organisationId,
|
||||
...hydrant,
|
||||
betreiber: "Stadtwerke Musterhausen",
|
||||
quelle: "seed",
|
||||
externeNummer: `SW-${hydrant.bezeichnung}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --- Objekte mit Feuerwehrplan -------------------------------------------
|
||||
const OBJEKTE: Array<{
|
||||
name: string;
|
||||
kategorie: ObjektKategorie;
|
||||
strasse: string;
|
||||
hausnummer: string;
|
||||
breitengrad: number;
|
||||
laengengrad: number;
|
||||
personenzahl: number;
|
||||
zufahrt: string;
|
||||
gefahren: string;
|
||||
schluesseldepot: string;
|
||||
loeschwasser: string;
|
||||
kontakte: Array<{ name: string; funktion: string; telefon: string; jederzeit: boolean }>;
|
||||
}> = [
|
||||
{
|
||||
name: "Grundschule Musterhausen",
|
||||
kategorie: ObjektKategorie.SCHULE,
|
||||
strasse: "Schulstraße",
|
||||
hausnummer: "4",
|
||||
breitengrad: ORTSMITTE.breitengrad + 0.0015,
|
||||
laengengrad: ORTSMITTE.laengengrad + 0.0022,
|
||||
personenzahl: 180,
|
||||
zufahrt:
|
||||
"Aufstellfläche auf dem Pausenhof, Zufahrt über die Schulstraße. Poller mit Feuerwehrschlüssel, Durchfahrt 3,10 m.",
|
||||
gefahren:
|
||||
"Photovoltaik auf dem Süddach (Gleichstrom bis zum Wechselrichter im Keller). Chemieraum im 1. OG Nordseite.",
|
||||
schluesseldepot: "FSD rechts neben dem Haupteingang, Schlüssel für alle Türen.",
|
||||
loeschwasser: "Wandhydranten in jedem Geschoss, Steigleitung trocken im Treppenhaus B.",
|
||||
kontakte: [
|
||||
{ name: "Ursula Berger", funktion: "Schulleitung", telefon: "08721 4711", jederzeit: false },
|
||||
{ name: "Karl Steininger", funktion: "Hausmeister", telefon: "0170 1234567", jederzeit: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Seniorenheim St. Anna",
|
||||
kategorie: ObjektKategorie.PFLEGEHEIM,
|
||||
strasse: "Am Anger",
|
||||
hausnummer: "11",
|
||||
breitengrad: ORTSMITTE.breitengrad - 0.0018,
|
||||
laengengrad: ORTSMITTE.laengengrad + 0.0009,
|
||||
personenzahl: 84,
|
||||
zufahrt:
|
||||
"Anleiterbare Seite nur nach Norden. Aufstellfläche für DL im Innenhof, Einfahrt an der Ostseite.",
|
||||
gefahren:
|
||||
"62 Bewohner, davon 28 bettlägerig. Sauerstoffflaschen in den Zimmern 12, 14 und 31. Sammelplatz auf dem Parkplatz an der Ostseite.",
|
||||
schluesseldepot: "FSD links vom Haupteingang, dazu Objektschlüssel in der Pforte.",
|
||||
loeschwasser: "Wandhydranten je Geschoss, nächster Überflurhydrant 40 m westlich.",
|
||||
kontakte: [
|
||||
{ name: "Pflegedienstleitung", funktion: "Nachtdienst", telefon: "08721 5522", jederzeit: true },
|
||||
{ name: "Hermann Falk", funktion: "Heimleitung", telefon: "0171 9876543", jederzeit: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Aussiedlerhof Huber",
|
||||
kategorie: ObjektKategorie.LANDWIRTSCHAFT,
|
||||
strasse: "Hofweg",
|
||||
hausnummer: "2",
|
||||
breitengrad: ORTSMITTE.breitengrad + 0.0108,
|
||||
laengengrad: ORTSMITTE.laengengrad + 0.0178,
|
||||
personenzahl: 6,
|
||||
zufahrt:
|
||||
"Feldweg ab der Kreisstraße, 600 m unbefestigt. Für TLF befahrbar, bei Nässe nur mit Allrad.",
|
||||
gefahren:
|
||||
"Silo mit Gärgasen, Biogasanlage 80 m nordöstlich. 60 Rinder im Stall, Notausgang Südseite. PV auf allen Dächern.",
|
||||
schluesseldepot: "Kein FSD. Familie wohnt auf dem Hof, Schlüssel beim Betriebsleiter.",
|
||||
loeschwasser: "Zisterne 50 m³ an der Scheune, sonst kein Löschwasser im Umkreis von 1,5 km.",
|
||||
kontakte: [
|
||||
{ name: "Josef Huber", funktion: "Betriebsleiter", telefon: "0175 3344556", jederzeit: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
for (const vorlage of OBJEKTE) {
|
||||
const { kontakte, ...felder } = vorlage;
|
||||
|
||||
await prisma.objekt.create({
|
||||
data: {
|
||||
organisationId,
|
||||
...felder,
|
||||
plz: "84359",
|
||||
ort: "Musterhausen",
|
||||
kontakte: {
|
||||
create: kontakte.map((kontakt, index) => ({
|
||||
organisationId,
|
||||
...kontakt,
|
||||
reihenfolge: index,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
log(
|
||||
`Karte: ${hydranten.length} Löschwasserstellen, ${OBJEKTE.length} Objekte mit Ansprechpartnern, ${standorte.length} Standorte verortet`,
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Ablauf
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -2516,6 +2791,9 @@ async function main(): Promise<void> {
|
||||
// Phase 5 — Atemschutz nach FwDV 7
|
||||
await atemschutzAnlegen(organisation.id);
|
||||
|
||||
// Phase 6 — Kartenmodule
|
||||
await kartendatenAnlegen(organisation.id);
|
||||
|
||||
log("Fertig.");
|
||||
log("");
|
||||
log("Anmeldung für den Erststart:");
|
||||
|
||||
Reference in New Issue
Block a user