"""sn_verfahrensgebiet/functions/beteiligung_pruefung.py – Beteiligung prüfen. AGLB-länderspezifisch (Flurstückskennzeichen-Bildung nach sächsischem ALKIS- Schema). Die Länderkennung wird nicht hartcodiert, sondern über :func:`sn_basis.functions.laenderkennung.get_laenderkennung` ermittelt, damit Schwesterplugins für andere Bundesländer dieselbe Logik mit einer anderen Länderkennung wiederverwenden können. Gleicht eine CSV-Exportliste des BI-Servers (Kopfzeile ``VEF_VKZF,FLA_Beteiligung,FLURSTUECK``) mit dem Verfahrensgebiet-Bestand ab. Aus ``FLURSTUECK`` (Format ``///``) wird je Zeile ein zu ALKIS kompatibles Flurstückskennzeichen ``Flurstueck_csv`` gebildet. Kaskade je nach Projektzustand: - **try1**: Ein Layer ``Verfahrensgebiet`` mit befülltem Attribut ``flurstueckskennzeichen`` (Single-Feature-Modell, siehe :mod:`verfahrensgebiet_edit_service`) ist vorhanden → schneller Mengenvergleich ohne WFS-Zugriff. - **try2**: Ein Verfahrensgebiet-Layer ist vorhanden, aber ohne verwertbare Kennzeichen → statt einer automatischen (nutzerlosen) ALKIS-Ladepipeline wird der Nutzer per Meldung aufgefordert, das Verfahrensgebiet zunächst regulär über "Aus ALKIS laden" neu zu laden und die Beteiligungsprüfung danach erneut zu starten (landet dann automatisch bei try1). Ein automatischer Durchlauf hat sich in der Praxis nicht als zuverlässig genug erwiesen (mehrdeutige BROR-Treffer, Flächenabweichungen etc. lassen sich nur im interaktiven Ablauf sinnvoll auflösen). - **try3**: Kein Verfahrensgebiet-Layer im Projekt → Verfahrensnummer aus ``VEF_VKZF`` wird als Projektvariable übernommen, danach wie try2 (Meldung statt automatischem Laden). """ from __future__ import annotations import csv from dataclasses import dataclass, field from typing import Dict, List, Optional, Tuple from sn_basis.functions import QgsVectorLayer, QgsProject, QgsFeature, QgsField from sn_basis.functions.qt_wrapper import QFileDialog, QVariant from sn_basis.functions.variable_wrapper import set_variable from sn_basis.functions.laenderkennung import get_laenderkennung from sn_basis.functions.dialog_wrapper import create_progress_dialog from sn_verfahrensgebiet.functions.flurstueck_alkis import FLST_LAYER_NAME from sn_verfahrensgebiet.functions.verfahrensgebiet_edit_service import ( VERF_LAYER_NAME, get_kennzeichen_menge, ) CSV_SPALTE_FLURSTUECK = "FLURSTUECK" CSV_SPALTE_VEF_VKZF = "VEF_VKZF" CSV_SPALTE_BETEILIGUNG = "FLA_Beteiligung" FELD_FLURSTUECK_CSV = "Flurstueck_csv" FELD_FLURSTUECKSKENNZEICHEN_WFS = "flurstueckskennzeichen" ERGEBNIS_LAYER_VERGLEICH = "Beteiligung – Vergleich" WERT_BEIDE = "beide" WERT_NUR_VERFAHRENSGEBIET = "nur_verfahrensgebiet" WERT_NUR_AGLB = "nur_aglb" class BeteiligungPruefungFehler(Exception): """Erwarteter, dem Nutzer per Messagebox mitzuteilender Fehlerfall.""" @dataclass class BeteiligungErgebnis: """Ergebnis eines Prüflaufs, das die aufrufende UI ins Projekt einfügt.""" modus: str layers: List[Tuple[object, Optional[str]]] = field(default_factory=list) statistik: Dict[str, int] = field(default_factory=dict) statistik_text: str = "" # --------------------------------------------------------------------------- # CSV laden # --------------------------------------------------------------------------- def lade_beteiligung_csv(parent_widget) -> Optional[Tuple[List[dict], List[str]]]: """Öffnet Dateidialog (CSV-Filter) und liest die BI-Server-Exportliste. Returns ------- Optional[Tuple[List[dict], List[str]]] ``(zeilen, spaltennamen)`` oder ``None`` bei Nutzerabbruch. Raises ------ BeteiligungPruefungFehler Bei ungültigem CSV-Format oder fehlendem Attribut ``FLURSTUECK``. """ pfad, _ = QFileDialog.getOpenFileName( parent_widget, "CSV-Export aus BI-Server auswählen", "", "CSV-Dateien (*.csv)", ) if not pfad: return None # Nutzerabbruch try: with open(pfad, "r", encoding="utf-8-sig", newline="") as f: reader = csv.DictReader(f, delimiter=",") zeilen = list(reader) spaltennamen = reader.fieldnames or [] except (OSError, csv.Error, UnicodeDecodeError) as exc: raise BeteiligungPruefungFehler( f"CSV-Format ungültig – Datei konnte nicht gelesen werden: {exc}" ) from exc if not spaltennamen or not zeilen: raise BeteiligungPruefungFehler( "CSV-Format ungültig – die Datei ist leer oder enthält keine Kopfzeile." ) if CSV_SPALTE_FLURSTUECK not in spaltennamen: raise BeteiligungPruefungFehler( f"Die CSV enthält nicht das benötigte Attribut '{CSV_SPALTE_FLURSTUECK}'." ) return zeilen, spaltennamen # --------------------------------------------------------------------------- # Flurstueck_csv-Bildung # --------------------------------------------------------------------------- #: Alte CSV-Exporte führen Buchstaben-Nenner (ALKIS: a, b, c, ...) teils als #: numerischen Platzhalter 9001, 9002, ... statt als Buchstabe. 9001 → "a", #: 9002 → "b" usw. (Basiswert, auf den 1..26 für a..z addiert wird). _NENNER_BUCHSTABEN_BASIS = 9000 def _baue_nenner_kennzeichen(nenner: str) -> str: """Bildet den 4-stelligen Nenner-Teil des Flurstückskennzeichens. Normalfall: numerischer Nenner, 4-stellig mit führenden Nullen aufgefüllt; Nenner 0/leer → Platzhalter ``"____"``. Format-Inkonsistenz alter CSV-Exporte: ein Buchstaben-Nenner (z.B. "a") wird dort als ``9001`` (a), ``9002`` (b), usw. geführt. Wird ein solcher Platzhalterwert erkannt, wird stattdessen der Buchstabe selbst (4-stellig mit führenden Nullen aufgefüllt, z.B. ``"000a"``) verwendet, damit das Kennzeichen mit dem ALKIS-Bestand übereinstimmt. """ nenner = (nenner or "").strip() nenner_ohne_null = nenner.lstrip("0") if not nenner_ohne_null: return "____" if nenner_ohne_null.isdigit(): zahl = int(nenner_ohne_null) if _NENNER_BUCHSTABEN_BASIS < zahl <= _NENNER_BUCHSTABEN_BASIS + 26: buchstabe = chr(ord("a") + (zahl - _NENNER_BUCHSTABEN_BASIS - 1)) return buchstabe.zfill(4) return nenner.zfill(4) def baue_flurstueck_csv(flurstueck_wert: str, laenderkennung: str) -> Optional[str]: """Bildet aus dem CSV-Attribut ``FLURSTUECK`` ein ALKIS-Flurstückskennzeichen. Das Attribut hat das Format ``///``. Die Flurnummer ist in der CSV nicht enthalten und wird als fixer Platzhalter ``___`` geführt (so wie ALKIS unbesetzte Kennzeichen-Bestandteile selbst mit Unterstrichen füllt). #Für Länderanpassung dieses Verhalten ändern, so dass die Flurnummer extrahiert und berücksichtigt wird, wenn angegeben Returns ------- Optional[str] Das zusammengesetzte Kennzeichen, oder ``None`` bei ungültigem Format (nicht genau 4 durch ``/`` getrennte Teile). """ if not flurstueck_wert: return None teile = [t.strip() for t in flurstueck_wert.strip().split("/")] if len(teile) != 4: return None gemarkung, zaehler, nenner, folge = teile gemarkung_kz = gemarkung.zfill(4) flur_kz = "___" zaehler_kz = zaehler.zfill(5) nenner_kz = _baue_nenner_kennzeichen(nenner) folge_kz = folge.zfill(2) return f"{laenderkennung}{gemarkung_kz}{flur_kz}{zaehler_kz}{nenner_kz}{folge_kz}" def baue_flurstueck_csv_liste(zeilen: List[dict], laenderkennung: str) -> List[str]: """Bildet für alle CSV-Zeilen ``Flurstueck_csv`` und gibt die Liste zurück. Schreibt das Ergebnis zusätzlich als Attribut ``Flurstueck_csv`` in jede Zeile zurück und gibt die ersten 20 Einträge als DEBUG-Ausgabe aus. """ ergebnisse: List[str] = [] for zeile in zeilen: wert = zeile.get(CSV_SPALTE_FLURSTUECK, "") fkz = baue_flurstueck_csv(wert, laenderkennung) zeile[FELD_FLURSTUECK_CSV] = fkz or "" if fkz: ergebnisse.append(fkz) print(f">>> Flurstueck_csv – {len(ergebnisse)} gebildet, erste 20:") for wert in ergebnisse[:20]: print(f">>> {wert}") return ergebnisse # --------------------------------------------------------------------------- # Hilfsfunktionen: Layer-Lookup # --------------------------------------------------------------------------- def _hole_verfahrensgebiet_layer(tab_widget) -> Optional[object]: """Liefert den Verfahrensgebiet-Layer, falls vorhanden. Der per Name (``VERF_LAYER_NAME``) identifizierte Layer hat Vorrang vor der Dropdown-Auswahl im Tab: try1/try2/try3 orientieren sich am tatsächlichen "Verfahrensgebiet"-Bestand im Projekt, nicht an einer ggf. anderweitig belegten Nutzerauswahl (z.B. für Grenzpunkte/ Umringstatistik). Die Dropdown-Auswahl dient nur als Fallback, falls kein so benannter Layer existiert. """ treffer = QgsProject.instance().mapLayersByName(VERF_LAYER_NAME) if treffer: return treffer[0] combo = getattr(tab_widget, "verf_layer_combo", None) if combo is not None: layer = combo.currentLayer() if layer is not None and layer.isValid(): return layer return None def _hat_verwendbare_kennzeichen(verf_layer) -> bool: try: return bool(get_kennzeichen_menge(verf_layer)) except Exception: return False def _hole_flurstuecke_layer() -> Optional[object]: treffer = QgsProject.instance().mapLayersByName(FLST_LAYER_NAME) return treffer[0] if treffer else None def _flurstueckskennzeichen_geometrien(flst_layer) -> Dict[str, object]: """Baut ``flurstueckskennzeichen`` → Geometrie-Map aus dem Flurstücke-Layer.""" mapping: Dict[str, object] = {} if flst_layer is None: return mapping if flst_layer.fields().indexFromName(FELD_FLURSTUECKSKENNZEICHEN_WFS) < 0: return mapping for feat in flst_layer.getFeatures(): try: kz = str(feat[FELD_FLURSTUECKSKENNZEICHEN_WFS] or "").strip() except Exception: kz = "" if kz: mapping[kz] = feat.geometry() return mapping # --------------------------------------------------------------------------- # try1: schneller Listenabgleich # --------------------------------------------------------------------------- def _try1_listenabgleich( tab_widget, verf_layer, flurstueck_csv_liste: List[str] ) -> Tuple[object, Dict[str, int]]: progress = create_progress_dialog( 3, "Beteiligung prüfen", "Verfahrensgebiet-Kennzeichen einlesen …" ) progress.set_value(0) progress.raise_to_front() verf_menge = get_kennzeichen_menge(verf_layer) csv_menge = set(flurstueck_csv_liste) progress.set_value(1) progress.set_label("CSV-Liste abgleichen …") beide = sorted(verf_menge & csv_menge) nur_verfahrensgebiet = sorted(verf_menge - csv_menge) nur_aglb = sorted(csv_menge - verf_menge) progress.set_value(2) progress.set_label("Ergebnislayer erstellen …") flst_layer = _hole_flurstuecke_layer() geom_map = _flurstueckskennzeichen_geometrien(flst_layer) if flst_layer is not None and flst_layer.isValid(): crs = flst_layer.crs().authid() elif verf_layer.isValid(): crs = verf_layer.crs().authid() else: crs = "EPSG:25833" layer = QgsVectorLayer(f"Polygon?crs={crs}", ERGEBNIS_LAYER_VERGLEICH, "memory") pr = layer.dataProvider() pr.addAttributes([ QgsField(FELD_FLURSTUECK_CSV, QVariant.String), QgsField("status", QVariant.String), ]) layer.updateFields() fields = layer.fields() neue_features = [] for status, menge in ( (WERT_BEIDE, beide), (WERT_NUR_VERFAHRENSGEBIET, nur_verfahrensgebiet), (WERT_NUR_AGLB, nur_aglb), ): for kz in menge: feat = QgsFeature(fields) geom = geom_map.get(kz) if geom is not None: feat.setGeometry(geom) feat.setAttributes([kz, status]) neue_features.append(feat) pr.addFeatures(neue_features) layer.updateExtents() progress.set_value(3) progress.close() statistik = { WERT_BEIDE: len(beide), WERT_NUR_VERFAHRENSGEBIET: len(nur_verfahrensgebiet), WERT_NUR_AGLB: len(nur_aglb), } return layer, statistik # --------------------------------------------------------------------------- # try2/try3: Aufforderung statt automatischer ALKIS-Pipeline # --------------------------------------------------------------------------- # # Ein automatischer (nutzerloser) Durchlauf der kompletten Ladepipeline # (mehrdeutige BROR-Treffer, Flächenabweichungen, Detailprüfung) hat sich in # der Praxis nicht als zuverlässig genug erwiesen – deshalb hier bewusst # KEIN silent-Aufruf von verfahrensgebiet_alkis_komplett(), sondern eine # Aufforderung an den Nutzer, den regulären interaktiven Ablauf # ("Aus ALKIS laden") selbst zu starten. Ein erneuter Klick auf # "Beteiligung prüfen" landet danach automatisch bei try1, sobald der Layer # verwertbare Kennzeichen hat. def _fordere_manuelles_laden_an(vef_vkzf: Optional[str] = None) -> None: """Bricht try2/try3 mit einer Aufforderung an den Nutzer ab (statt eines automatischen Ladevorgangs).""" hinweis = ( "Bitte laden Sie das Verfahrensgebiet jetzt über 'Aus ALKIS laden' " "und starten Sie die Beteiligungsprüfung danach erneut." ) if vef_vkzf: raise BeteiligungPruefungFehler( f"Es ist noch kein Verfahrensgebiet geladen. Die Verfahrensnummer " f"{vef_vkzf} wurde aus der CSV übernommen. {hinweis}" ) raise BeteiligungPruefungFehler( f"Der vorhandene Verfahrensgebiet-Layer enthält keine verwertbaren " f"Flurstückskennzeichen. {hinweis}" ) # --------------------------------------------------------------------------- # try3: Bootstrap (Verfahrensnummer aus CSV) + Aufforderung wie try2 # --------------------------------------------------------------------------- def _lese_vef_vkzf(zeilen: List[dict], spaltennamen: List[str]) -> str: if CSV_SPALTE_VEF_VKZF not in spaltennamen: raise BeteiligungPruefungFehler( f"Die CSV enthält nicht das benötigte Attribut '{CSV_SPALTE_VEF_VKZF}'." ) for zeile in zeilen: wert = (zeile.get(CSV_SPALTE_VEF_VKZF) or "").strip() if wert: return wert raise BeteiligungPruefungFehler( f"Die CSV enthält keinen befüllten Wert für '{CSV_SPALTE_VEF_VKZF}'." ) def _try3_bootstrap(tab_widget, zeilen: List[dict], spaltennamen: List[str]) -> None: vef_vkzf = _lese_vef_vkzf(zeilen, spaltennamen) set_variable("verfahrensnummer", vef_vkzf, scope="project") _fordere_manuelles_laden_an(vef_vkzf) # --------------------------------------------------------------------------- # Öffentliche API # --------------------------------------------------------------------------- def beteiligung_pruefen(tab_widget) -> Optional[BeteiligungErgebnis]: """Vollständiger Ablauf: CSV laden → Flurstueck_csv bilden → try1/try2/try3. Returns ------- Optional[BeteiligungErgebnis] ``None`` bei Nutzerabbruch des Dateidialogs. Sonst das Ergebnis mit den ins Projekt einzufügenden Layern und der Statistik. Raises ------ BeteiligungPruefungFehler Bei einem der dokumentierten Fehlerfälle (CSV-Format, fehlende Spalten) oder wenn kein Verfahrensgebiet mit verwertbaren Kennzeichen vorliegt (try2/try3 – der Nutzer wird dann aufgefordert, das Verfahrensgebiet zunächst über "Aus ALKIS laden" zu laden und die Beteiligungsprüfung danach erneut zu starten). Der Aufrufer zeigt daraufhin eine Messagebox an. """ geladen = lade_beteiligung_csv(tab_widget) if geladen is None: return None zeilen, spaltennamen = geladen laenderkennung = get_laenderkennung("sn") flurstueck_csv_liste = baue_flurstueck_csv_liste(zeilen, laenderkennung) if not flurstueck_csv_liste: raise BeteiligungPruefungFehler( "Aus der CSV konnten keine gültigen Flurstück-Kennzeichen gebildet werden " "(Attribut 'FLURSTUECK' muss 4 durch '/' getrennte Teile enthalten)." ) verf_layer = _hole_verfahrensgebiet_layer(tab_widget) if verf_layer is not None and _hat_verwendbare_kennzeichen(verf_layer): layer, statistik = _try1_listenabgleich(tab_widget, verf_layer, flurstueck_csv_liste) text = ( f"Übereinstimmend: {statistik[WERT_BEIDE]}\n" f"Nur im Verfahrensgebiet: {statistik[WERT_NUR_VERFAHRENSGEBIET]}\n" f"Nur in der CSV-Liste (AGLB): {statistik[WERT_NUR_AGLB]}" ) return BeteiligungErgebnis("try1", [(layer, "beteiligung_vergleich.qml")], statistik, text) if verf_layer is not None: _fordere_manuelles_laden_an() return None # unreachable – _fordere_manuelles_laden_an() wirft immer _try3_bootstrap(tab_widget, zeilen, spaltennamen) return None # unreachable – _try3_bootstrap() wirft immer