Files
Plugin_SN_Verfahrensgebiet/functions/eigentuemer_suche.py
T
DanielandClaude Opus 5.5 b105894241 PER_RECHTSPERSON als Rohwert in der Trefferliste zeigen (#56)
PER_RECHTSPERSON kennt die Werte 0, 1, 5 und 9 und ist kein Ja/Nein-Feld.
Die Trefferliste bekommt dafür eine eigene Spalte mit dem unveränderten
Wert; abweichende Werte einer Person werden gesammelt. Die Entschlüsselung
folgt später. „verstorben“ (PER_VERSTORBEN 0/1) bleibt als Hinweis.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:13:50 +02:00

230 lines
8.4 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""sn_verfahrensgebiet/functions/eigentuemer_suche.py – Eigentümersuche in einer CSV (#56).
Die Eigentümerdaten kommen als CSV-Export (Serienbriefexport, Kopfzeile siehe
:data:`SPALTEN`), den der Anwender von Hand als Layer ins Projekt lädt. Das
Plugin speichert davon nichts, es liest nur den Layer.
Gesucht wird über Nachname und Vorname, ohne Rücksicht auf Groß- und
Kleinschreibung und Umlautschreibweise (``Müller`` findet ``Mueller``). Ein
leeres Feld schränkt nicht ein. Zeilen mit gleichem Namen und gleicher Anschrift
werden zu einem :class:`Eigentuemer` zusammengefasst; dieselbe Person mit einer
anderen Anschrift bleibt ein eigener Eintrag.
**Datenschutz:** Das Modul schreibt keine Personendaten in Ausgaben oder
Meldungen. Es zeigt keine Dialoge; Fehler kommen als
:class:`EigentuemerSucheFehler` mit einer Meldung ohne Personendaten zurück.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, List, Optional, Tuple
from sn_basis.functions import QgsProject
#: Spalten des Serienbriefexports, die das Plugin auswertet.
SPALTEN = {
"konto": "EIG_KONTO",
"per_id": "PER_ID",
"verstorben": "PER_VERSTORBEN",
"anrede": "Anrede",
"titel": "Titel",
"vorname": "Vorname",
"nachname": "Nachname",
"ortsteil": "OT",
"strasse": "STRASSE",
"plz": "PLZ",
"wohnort": "WOHNORT",
"email": "Email",
"rechtsperson": "PER_RECHTSPERSON",
}
PFLICHTSPALTEN = ("Nachname", "Vorname")
_NEIN_WERTE = {"", "0", "n", "nein", "f", "false", "falsch", "null"}
_UMLAUTE = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue"})
class EigentuemerSucheFehler(Exception):
"""Eingabe- oder Layerfehler; der Text enthält keine Personendaten."""
@dataclass(frozen=True)
class Eigentuemer:
"""Ein Treffer der Eigentümersuche, zusammengefasst über gleiche Zeilen."""
anrede: str = ""
titel: str = ""
vorname: str = ""
nachname: str = ""
strasse: str = ""
plz: str = ""
wohnort: str = ""
ortsteil: str = ""
emails: Tuple[str, ...] = ()
verstorben: bool = False
rechtsperson: Tuple[str, ...] = ()
per_ids: Tuple[str, ...] = ()
konten: Tuple[str, ...] = ()
feature_ids: Tuple[int, ...] = ()
@property
def name(self) -> str:
"""``Nachname, Titel Vorname`` für Listen."""
vorne = " ".join(t for t in (self.titel, self.vorname) if t)
return ", ".join(t for t in (self.nachname, vorne) if t)
@property
def anschrift(self) -> str:
"""Einzeilige Anschrift, z. B. ``Hauptstraße 1, 01234 Musterstadt, OT Beispiel``."""
ort = " ".join(t for t in (self.plz, self.wohnort) if t)
ortsteil = self.ortsteil
if ortsteil and not ortsteil.upper().startswith("OT"):
ortsteil = f"OT {ortsteil}"
return ", ".join(t for t in (self.strasse, ort, ortsteil) if t)
@property
def hinweise(self) -> str:
"""Kennzeichen für die Trefferliste (nicht für das Anzeigefenster)."""
return "verstorben" if self.verstorben else ""
@property
def rechtsperson_text(self) -> str:
"""Rohwert(e) von ``PER_RECHTSPERSON`` (bekannt: 0, 1, 5, 9), noch nicht entschlüsselt."""
return ", ".join(self.rechtsperson)
def _text(wert) -> str:
"""Attributwert als bereinigter Text; QGIS-NULL und ``None`` werden leer."""
if wert is None:
return ""
ist_null = getattr(wert, "isNull", None)
if callable(ist_null):
try:
if ist_null():
return ""
except Exception:
pass
text = " ".join(str(wert).split())
return "" if text == "NULL" else text
def _plz(text: str) -> str:
"""Stellt führende Nullen wieder her.
QGIS erkennt beim Laden einer CSV Feldtypen und liest ``01067`` dann als
Ganzzahl ``1067`` – in Sachsen beginnt fast jede PLZ mit 0.
"""
if text.endswith(".0") and text[:-2].isdigit():
text = text[:-2]
return text.zfill(5) if text.isdigit() and len(text) < 5 else text
def _ist_gesetzt(wert) -> bool:
return _text(wert).casefold() not in _NEIN_WERTE
def _vergleichstext(text: str) -> str:
"""Für die Suche: Groß-/Kleinschreibung, ß und Umlautschreibweise angleichen."""
return " ".join(text.split()).casefold().translate(_UMLAUTE)
def _spalten_des_layers(layer) -> Dict[str, str]:
"""Ordnet die Spaltennamen des Layers den Schlüsseln aus :data:`SPALTEN` zu.
Verglichen wird ohne Groß-/Kleinschreibung und ohne Byte-Order-Mark, das
beim Laden einer UTF-8-CSV an der ersten Spalte hängen bleiben kann.
"""
vorhanden = {}
for name in layer.fields().names():
vorhanden[str(name).replace("", "").strip().casefold()] = name
return {
schluessel: vorhanden[spalte.casefold()]
for schluessel, spalte in SPALTEN.items()
if spalte.casefold() in vorhanden
}
def ist_eigentuemerlayer(layer) -> bool:
"""``True``, wenn der Layer die Spalten ``Nachname`` und ``Vorname`` hat."""
try:
spalten = _spalten_des_layers(layer)
except Exception:
return False
return "nachname" in spalten and "vorname" in spalten
def eigentuemerlayer_im_projekt() -> List[object]:
"""Alle Layer des Projekts, die als Eigentümerdatei taugen, nach Namen sortiert."""
layer = [lyr for lyr in QgsProject.instance().mapLayers().values() if ist_eigentuemerlayer(lyr)]
return sorted(layer, key=lambda lyr: lyr.name().casefold())
def _gruppenschluessel(werte: Dict[str, str]) -> Tuple[str, ...]:
return tuple(werte[k].casefold() for k in ("nachname", "vorname", "strasse", "plz", "wohnort", "ortsteil"))
def suche_eigentuemer(layer, nachname: str, vorname: str) -> List[Eigentuemer]:
"""Sucht Eigentümer, deren Nach- und Vorname die Eingaben enthalten.
Raises
------
EigentuemerSucheFehler
Beide Felder sind leer oder dem Layer fehlen die Pflichtspalten.
"""
such_nachname = _vergleichstext(nachname or "")
such_vorname = _vergleichstext(vorname or "")
if not such_nachname and not such_vorname:
raise EigentuemerSucheFehler("Bitte einen Namen oder Vornamen eingeben.")
spalten = _spalten_des_layers(layer)
if "nachname" not in spalten or "vorname" not in spalten:
raise EigentuemerSucheFehler(
f"Der Layer „{layer.name()}“ hat nicht die Spalten "
f"{' und '.join(PFLICHTSPALTEN)}. Ist es der Serienbriefexport?"
)
gruppen: Dict[Tuple[str, ...], dict] = {}
for feature in layer.getFeatures():
werte = {k: _text(feature[spalte]) for k, spalte in spalten.items()}
for k in SPALTEN:
werte.setdefault(k, "")
werte["plz"] = _plz(werte["plz"])
if such_nachname and such_nachname not in _vergleichstext(werte["nachname"]):
continue
if such_vorname and such_vorname not in _vergleichstext(werte["vorname"]):
continue
gruppe = gruppen.setdefault(_gruppenschluessel(werte), {
"werte": werte, "emails": [], "per_ids": [], "konten": [], "feature_ids": [],
"verstorben": False, "rechtsperson": [],
})
for k in ("anrede", "titel"):
if not gruppe["werte"][k] and werte[k]:
gruppe["werte"][k] = werte[k]
for liste, wert in (("emails", werte["email"]), ("per_ids", werte["per_id"]),
("konten", werte["konto"]), ("rechtsperson", werte["rechtsperson"])):
if wert and wert not in gruppe[liste]:
gruppe[liste].append(wert)
gruppe["feature_ids"].append(feature.id())
gruppe["verstorben"] = gruppe["verstorben"] or _ist_gesetzt(werte["verstorben"])
treffer = []
for gruppe in gruppen.values():
w = gruppe["werte"]
treffer.append(Eigentuemer(
anrede=w["anrede"], titel=w["titel"], vorname=w["vorname"], nachname=w["nachname"],
strasse=w["strasse"], plz=w["plz"], wohnort=w["wohnort"], ortsteil=w["ortsteil"],
emails=tuple(gruppe["emails"]),
verstorben=gruppe["verstorben"], rechtsperson=tuple(gruppe["rechtsperson"]),
per_ids=tuple(gruppe["per_ids"]), konten=tuple(gruppe["konten"]),
feature_ids=tuple(gruppe["feature_ids"]),
))
return sorted(treffer, key=lambda e: (e.nachname.casefold(), e.vorname.casefold(), e.wohnort.casefold()))
def hole_layer(layer_id: Optional[str]):
"""Layer zur ID aus dem Projekt oder ``None``."""
if not layer_id:
return None
return QgsProject.instance().mapLayer(layer_id)