Selaa lähdekoodia

Projekt an die Reisacher-Datenbank angepasst

gc-server3 1 viikko sitten
vanhempi
commit
82839ae76b

+ 10 - 2
README.md

@@ -92,7 +92,7 @@ Kommentare und Buchungsbelege starten jeweils mit „Diese Rechnung“ und lasse
 unabhängig auf offene Rechnungen, das Fahrzeug oder die gesamte Debitorhistorie erweitern.
 Die Auswahl gilt für die geöffnete Seite und wird beim Neuladen zurückgesetzt.
 Die Fahrzeugansicht bleibt auf denselben Debitor begrenzt und ist ohne Fahrzeugnummer deaktiviert.
-Offen bedeutet `Beleg_offen <> 0`; Zahlungen werden über `Bezug_Beleg_Nr` zugeordnet.
+Offen bedeutet `Beleg_Offen <> 0`; Zahlungen werden über `Bezug_Beleg_Nr` zugeordnet.
 
 Alle Filter bleiben im Mandanten der Ausgangsrechnung. Nach erfolgreicher Rechteprüfung
 dieser Rechnung umfasst die erweiterte Ansicht ausdrücklich auch andere Standorte und
@@ -135,7 +135,7 @@ nicht mehr akzeptiert. Standardmäßig ist `DEBUG_AUTH=false`; außerhalb der Um
 ## Forderungsdashboard
 
 Das Dashboard verwendet dieselbe berechtigte Belegabfrage wie die Liste. Anzahl und
-`Beleg_offen` enthalten auch Gutschriften, negative Beträge und Nullbeträge; jeder Beleg
+`Beleg_Offen` enthalten auch Gutschriften, negative Beträge und Nullbeträge; jeder Beleg
 wird anhand von Mandant und Belegnummer einmal gezählt. Die Berechtigungsprüfung erfolgt
 vor der Aggregation über Benutzer, Hauptbetrieb, Standort und Abteilung. Beträge werden
 mit Dezimalarithmetik summiert. An den Browser gehen nur berechtigte Aggregate.
@@ -341,6 +341,14 @@ den jeweiligen Serverlogs.
 Datenbankfehler haben `event=database_error` mit Operation, Abfrageherkunft,
 `query_fingerprint`, Exceptiontyp, verfügbaren technischen Fehlercodes und Stackframes
 (Dateiname, Funktion, Zeile). Templateabfragen tragen ihren SQL-Dateinamen als Herkunft.
+Für SQL Server werden zusätzlich `db_code` (nativer Fehlercode, etwa 207), `sqlstate`
+und `db_message` (bereinigte Serverbeschreibung) ausgegeben. `db_diagnostics` enthält
+auch weitere ODBC-Meldungen desselben Fehlers mit jeweils Code, SQLSTATE und Beschreibung.
+Diese Angaben bleiben bei `LOG_SQL_STATEMENT=false` verfügbar. Zitierte Inhalte,
+zurückgemeldete Parameterwerte und Wertetupel werden in den Beschreibungen maskiert;
+es wird nicht die um SQL und Parameter ergänzte SQLAlchemy-Exception ausgegeben.
+Ausnahme: Bei SQL-Server-Fehler 207 („Ungültiger Spaltenname“ / „Invalid column name“)
+bleibt der Spaltenname in der Standardmeldung sichtbar, auch bei abgeschaltetem SQL-Text.
 SQL-Parameter, lokale Variablen und ungeprüfte Exceptiontexte werden nicht ausgegeben.
 SQL-Kommentare werden entfernt und Zeichenketten- sowie Zahlenliterale maskiert; der
 SQL-Text wird auf 8192 Zeichen begrenzt. Mit `LOG_SQL_STATEMENT=false` entfällt auch diese

+ 3 - 2
app/db.py

@@ -25,7 +25,8 @@ def install_engine_logging(engine):
         active = db_query.get() or {}
         statement = context.statement if context.statement is not None else active.get('statement')
         log_query_error(error, statement, options.get('query_name') or active.get('query_name'),
-                        db_operation.get() or getattr(context.execution_context, '_gc_operation', 'connect_or_transaction'))
+                        db_operation.get() or getattr(context.execution_context, '_gc_operation', 'connect_or_transaction'),
+                        parameters=context.parameters)
 
 
 class LoggingSession(Session):
@@ -37,7 +38,7 @@ class LoggingSession(Session):
         try:
             return super().execute(statement, params, execution_options=options, **kwargs)
         except Exception as error:
-            log_query_error(error, statement, origin, 'execute')
+            log_query_error(error, statement, origin, 'execute', parameters=params)
             raise
         finally:
             db_query.reset(query_token)

+ 139 - 62
app/forderung_dashboard.py

@@ -1,58 +1,85 @@
 """Aggregate authorized invoices by rubric and selectable stack dimension."""
+
+import hashlib
+from collections import Counter
 from datetime import date, datetime, timedelta
 from decimal import Decimal
-from collections import Counter
-import hashlib
 from urllib.parse import urlencode
 from zoneinfo import ZoneInfo
 
-from .dashboard_segment import pack_segment, DASHBOARD_VIEWS
+from .dashboard_segment import DASHBOARD_VIEWS, pack_segment
 from .forderung_data import read_invoices
 from .jinja_templates import number_format
 
-BERLIN = ZoneInfo('Europe/Berlin')
-STACKS = ('abteilung', 'wiedervorlage')
+BERLIN = ZoneInfo("Europe/Berlin")
+STACKS = ("abteilung", "wiedervorlage")
 
 
 def _key(value):
-    return None if value is None or str(value) == '' else str(value)
+    return None if value is None or str(value) == "" else str(value)
 
 
 def _date(value):
-    return value.date() if isinstance(value, datetime) else value if isinstance(value, date) else date.fromisoformat(str(value)[:10]) if value else None
+    return (
+        value.date()
+        if isinstance(value, datetime)
+        else value if isinstance(value, date) else date.fromisoformat(str(value)[:10]) if value else None
+    )
 
 
 def color(department):
-    hue = int.from_bytes(hashlib.sha256((department or '').encode()).digest()[:2], 'big') % 360
-    return f'hsl({hue}, 23%, 70%)'
+    hue = int.from_bytes(hashlib.sha256((department or "").encode()).digest()[:2], "big") % 360
+    return f"hsl({hue}, 23%, 70%)"
 
 
 def follow_up_bands(today):
     return [
-        dict(id='overdue', label='Überfällig', color='#d4aaaa', key={'from': None, 'to': (today-timedelta(days=1)).isoformat()}),
-        dict(id='soon', label='In 3 Tagen fällig', color='#e0cd9c', key={'from': today.isoformat(), 'to': (today+timedelta(days=3)).isoformat()}),
-        dict(id='not_due', label='Noch nicht fällig', color='#b4c5b5', key={'from': (today+timedelta(days=4)).isoformat(), 'to': (today+timedelta(days=14)).isoformat()}),
-        dict(id='deferred', label='zurückgestellt', color='#b2bccd', key={'from': (today+timedelta(days=15)).isoformat(), 'to': None}),
-        dict(id='undated', label='Ohne Datum', color='#c5c5c5', key=None),
+        dict(
+            id="overdue",
+            label="Überfällig",
+            color="#d4aaaa",
+            key={"from": None, "to": (today - timedelta(days=1)).isoformat()},
+        ),
+        dict(
+            id="soon",
+            label="In 3 Tagen fällig",
+            color="#e0cd9c",
+            key={"from": today.isoformat(), "to": (today + timedelta(days=3)).isoformat()},
+        ),
+        dict(
+            id="not_due",
+            label="Noch nicht fällig",
+            color="#b4c5b5",
+            key={"from": (today + timedelta(days=4)).isoformat(), "to": (today + timedelta(days=14)).isoformat()},
+        ),
+        dict(
+            id="deferred",
+            label="zurückgestellt",
+            color="#b2bccd",
+            key={"from": (today + timedelta(days=15)).isoformat(), "to": None},
+        ),
+        dict(id="undated", label="Ohne Datum", color="#c5c5c5", key=None),
     ]
 
 
-def _url(view, key, segment=..., only_invoices=False, stack='abteilung'):
-    selection = {'view': view, 'key': key}
+def _url(view, key, segment=..., only_invoices=False, stack="abteilung"):
+    selection = {"view": view, "key": key}
     if segment is not ...:
-        selection['department' if stack == 'abteilung' else 'follow_up'] = segment
+        selection["department" if stack == "abteilung" else "follow_up"] = segment
     if only_invoices:
-        selection['only_invoices'] = True
-    return '/app/forderungen/liste?' + urlencode({'dashboard_segment': pack_segment(selection)})
+        selection["only_invoices"] = True
+    return "/app/forderungen/liste?" + urlencode({"dashboard_segment": pack_segment(selection)})
 
 
-def build_dashboard(db, username, now=None, only_invoices=False, stack='abteilung'):
+def build_dashboard(db, username, now=None, only_invoices=False, stack="abteilung"):
     if stack not in STACKS:
-        raise ValueError('Ungültige Stapelung')
+        raise ValueError("Ungültige Stapelung")
     now = now or datetime.now(BERLIN)
     today = now.astimezone(BERLIN).date()
     if only_invoices:
-        columns, source = read_invoices(db, username, ['F.[Rechnung_Gutschrift] = :dashboard_invoice_type'], {'dashboard_invoice_type': 'Rechnung'})
+        columns, source = read_invoices(
+            db, username, ["F.[Rechnung_Gutschrift] = :dashboard_invoice_type"], {"dashboard_invoice_type": "Rechnung"}
+        )
     else:
         columns, source = read_invoices(db, username)
     groups = {view: {} for view in DASHBOARD_VIEWS}
@@ -60,60 +87,110 @@ def build_dashboard(db, username, now=None, only_invoices=False, stack='abteilun
     bands = follow_up_bands(today)
     for values in source:
         row = dict(zip(columns, values))
-        department = _key(row.get('Abteilung'))
+        department = _key(row.get("Abteilung"))
         departments.add(department)
-        if stack == 'abteilung':
+        if stack == "abteilung":
             segment_id = department
         else:
-            when = _date(row.get('Wiedervorlage') or row.get('Beleg_Faellig_Datum'))
-            bucket = 4 if when is None else 0 if when < today else 1 if when <= today+timedelta(days=3) else 2 if when <= today+timedelta(days=14) else 3
-            segment_id = bands[bucket]['id']
+            when = _date(row.get("Wiedervorlage") or row.get("Beleg_Faellig_Datum"))
+            bucket = (
+                4
+                if when is None
+                else (
+                    0
+                    if when < today
+                    else 1 if when <= today + timedelta(days=3) else 2 if when <= today + timedelta(days=14) else 3
+                )
+            )
+            segment_id = bands[bucket]["id"]
         observed.add(segment_id)
-        amount = Decimal(str(row['Beleg_offen'] or 0))
+        amount = Decimal(str(row["Beleg_Offen"] or 0))
         total += amount
-        age = _key(row.get('Staffel_Text'))
-        site = {'client': str(row['Client_DB']), 'site': _key(row.get('Standort_ID'))}
-        person_id = _key(row.get('Verursacher_Benutzer_ID'))
-        person = {'id': person_id} if person_id else {'text': _key(row.get('Verursacher_Text'))}
+        age = _key(row.get("Staffel_Text"))
+        site = {"client": str(row["Client_DB"]), "site": _key(row.get("Standort_ID"))}
+        person_id = _key(row.get("Verursacher_Benutzer_ID"))
+        person = {"id": person_id} if person_id else {"text": _key(row.get("Verursacher_Text"))}
         definitions = [
-            ('Altersstaffel', age, age or 'Ohne Altersstaffel', int(row['Beleg_Tage_offen']) if age and row.get('Beleg_Tage_offen') is not None else 10**12),
-            ('Standort', site, f"{row.get('Standort_Name') or site['site'] or 'Ohne Standort'} ({row.get('Hauptbetrieb_Name') or 'Mandant ' + site['client']})", 0),
-            ('Verursacher', person, row.get('Verursacher_Text') or person_id or 'Ohne Verursacher', 0),
+            (
+                "Altersstaffel",
+                age,
+                age or "Ohne Altersstaffel",
+                int(row["Beleg_Tage_offen"]) if age and row.get("Beleg_Tage_offen") is not None else 10**12,
+            ),
+            (
+                "Standort",
+                site,
+                f"{row.get('Standort_Name') or site['site'] or 'Ohne Standort'} ({row.get('Hauptbetrieb_Name') or 'Mandant ' + site['client']})",
+                0,
+            ),
+            ("Verursacher", person, row.get("Verursacher_Text") or person_id or "Ohne Verursacher", 0),
         ]
         for view, key, label, order in definitions:
             identity = pack_segment(key)
-            group = groups[view].setdefault(identity, dict(key=key, label=label, order=order, count=0, amount=Decimal(0), segments={}))
-            group['order'] = min(group['order'], order)
-            group['count'] += 1
-            group['amount'] += amount
-            segment = group['segments'].setdefault(segment_id, dict(count=0, amount=Decimal(0)))
-            segment['count'] += 1
-            segment['amount'] += amount
-    items = [dict(id=dep, label=dep or 'Ohne Abteilung', color=color(dep), key=dep)
-             for dep in sorted(departments, key=lambda value: (value is None, value or ''))] if stack == 'abteilung' else [band for band in bands if band['id'] != 'undated' or 'undated' in observed]
+            group = groups[view].setdefault(
+                identity, dict(key=key, label=label, order=order, count=0, amount=Decimal(0), segments={})
+            )
+            group["order"] = min(group["order"], order)
+            group["count"] += 1
+            group["amount"] += amount
+            segment = group["segments"].setdefault(segment_id, dict(count=0, amount=Decimal(0)))
+            segment["count"] += 1
+            segment["amount"] += amount
+    items = (
+        [
+            dict(id=dep, label=dep or "Ohne Abteilung", color=color(dep), key=dep)
+            for dep in sorted(departments, key=lambda value: (value is None, value or ""))
+        ]
+        if stack == "abteilung"
+        else [band for band in bands if band["id"] != "undated" or "undated" in observed]
+    )
 
     def serialized(amount):
-        return str(amount.quantize(Decimal('0.01')))
+        return str(amount.quantize(Decimal("0.01")))
+
     output = {}
     for view, mapping in groups.items():
         result = []
-        labels = Counter(group['label'].casefold() for group in mapping.values())
+        labels = Counter(group["label"].casefold() for group in mapping.values())
         for group in mapping.values():
-            label = group['label']
+            label = group["label"]
             if labels[label.casefold()] > 1:
-                if view == 'Verursacher':
-                    label += ' (' + (group['key'].get('id') or 'ohne Benutzer-ID') + ')'
-                elif view == 'Standort':
-                    label += ' · Standort-ID ' + (group['key']['site'] or 'ohne ID')
-            segments = [dict(id=item['id'], department=item['id'] if stack == 'abteilung' else None,
-                             label=item['label'], count=group['segments'][item['id']]['count'],
-                             amount=serialized(group['segments'][item['id']]['amount']), color=item['color'],
-                             url=_url(view, group['key'], item['key'], only_invoices, stack))
-                        for item in items if item['id'] in group['segments']]
-            result.append(dict(label=label, order=group['order'], count=group['count'], amount=serialized(group['amount']),
-                               segments=segments, url=_url(view, group['key'], only_invoices=only_invoices)))
+                if view == "Verursacher":
+                    label += " (" + (group["key"].get("id") or "ohne Benutzer-ID") + ")"
+                elif view == "Standort":
+                    label += " · Standort-ID " + (group["key"]["site"] or "ohne ID")
+            segments = [
+                dict(
+                    id=item["id"],
+                    department=item["id"] if stack == "abteilung" else None,
+                    label=item["label"],
+                    count=group["segments"][item["id"]]["count"],
+                    amount=serialized(group["segments"][item["id"]]["amount"]),
+                    color=item["color"],
+                    url=_url(view, group["key"], item["key"], only_invoices, stack),
+                )
+                for item in items
+                if item["id"] in group["segments"]
+            ]
+            result.append(
+                dict(
+                    label=label,
+                    order=group["order"],
+                    count=group["count"],
+                    amount=serialized(group["amount"]),
+                    segments=segments,
+                    url=_url(view, group["key"], only_invoices=only_invoices),
+                )
+            )
         output[view] = result
-    return dict(count=len(source), amount=serialized(total), amount_display=number_format(total), views=output,
-                only_invoices=only_invoices, stack=stack, stack_label='Abteilung' if stack == 'abteilung' else 'Wiedervorlage',
-                stack_items=[{name: value for name, value in item.items() if name != 'key'} for item in items],
-                generated=now.astimezone(BERLIN).strftime('%d.%m.%Y %H:%M'))
+    return dict(
+        count=len(source),
+        amount=serialized(total),
+        amount_display=number_format(total),
+        views=output,
+        only_invoices=only_invoices,
+        stack=stack,
+        stack_label="Abteilung" if stack == "abteilung" else "Wiedervorlage",
+        stack_items=[{name: value for name, value in item.items() if name != "key"} for item in items],
+        generated=now.astimezone(BERLIN).strftime("%d.%m.%Y %H:%M"),
+    )

+ 1 - 2
app/forderung_summary.py

@@ -1,9 +1,8 @@
 from sqlalchemy import text
 from sqlalchemy.orm import Session
 
-
 CURRENT_SUMMARY_SQL = text("""
-    SELECT TOP 1 Beleg_Nr, Beleg_Datum, Kunde_Text, Beleg_Betrag, Beleg_offen
+    SELECT TOP 1 Beleg_Nr, Beleg_Datum, Kunde_Text, Beleg_Betrag, Beleg_Offen
     FROM dbo.Forderungen_Kopf
     WHERE Client_DB = :client_db AND Beleg_Nr = :beleg_nr
     """)

+ 4 - 1
app/jinja_templates.py

@@ -1,11 +1,14 @@
 from datetime import datetime
+from decimal import Decimal
 
 from fastapi.templating import Jinja2Templates
 
 templates = Jinja2Templates(directory="templates")
 
 
-def number_format(input: float) -> str:
+def number_format(input: float | Decimal | None) -> str:
+    if input is None:
+        return "—"
     return format(input, "0,.2f").replace(".", ":").replace(",", ".").replace(":", ",")
 
 

+ 58 - 2
app/logging_setup.py

@@ -24,7 +24,7 @@ PROJECT_ROOT = Path(__file__).resolve().parent.parent
 _lock = threading.RLock()
 _configuration = None
 _SAFE_FIELDS = ('event', 'operation', 'query_name', 'query_fingerprint', 'sql', 'sql_truncated',
-                'sqlstate', 'db_code', 'status', 'duration_ms', 'outcome', 'size_bytes', 'count',
+                'sqlstate', 'db_code', 'db_message', 'db_diagnostics', 'status', 'duration_ms', 'outcome', 'size_bytes', 'count',
                 'reason', 'error_type')
 
 
@@ -228,7 +228,54 @@ def configure_logging(config=None, *, console_stream=None):
         if file_failure: logging.getLogger('app.logging').error('Logverzeichnis nicht verfügbar; Konsolenfallback aktiv', extra={'event': 'log_file_unavailable'})
 
 
-def log_query_error(error, statement=None, query_name=None, operation=None):
+def database_diagnostics(error, statement=None, parameters=None):
+    """Read ODBC diagnostics from the driver, never from SQLAlchemy's formatted exception."""
+    if type(error).__module__ != 'pyodbc':
+        return []
+    state = error.args[0] if error.args and isinstance(error.args[0], str) and re.fullmatch(r'[A-Z0-9]{5}', error.args[0]) else None
+    secrets = set()
+
+    def collect(value):
+        if isinstance(value, str) and value:
+            secrets.add(value)
+        elif isinstance(value, bytes):
+            collect(value.decode('utf-8', errors='replace'))
+        elif isinstance(value, dict):
+            for child in value.values(): collect(child)
+        elif isinstance(value, (tuple, list)):
+            for child in value: collect(child)
+
+    collect(parameters)
+    if statement is not None:
+        for match in re.finditer(r"N?'((?:''|[^'])*)'", str(statement), re.IGNORECASE):
+            collect(match[1].replace("''", "'"))
+
+    diagnostics = []
+    for raw in error.args[1:] if state else error.args:
+        if not isinstance(raw, str): continue
+        # pyodbc concatenates SQLGetDiagRec records, appending native codes and the ODBC function.
+        parts = list(re.finditer(r'(.*?)\s+\((-?\d+)\)(?:\s+\(SQL\w+\))?(?=;\s*|$)', raw, re.DOTALL))
+        records = [(part[1].lstrip('; \t\r\n'), int(part[2])) for part in parts] or [(raw.strip(), None)]
+        for description, code in records:
+            own_state = re.match(r'\[([A-Z0-9]{5})\]', description)
+            description = re.sub(r'^(?:\[[^\]\r\n]*\]\s*)+', '', description)
+            # Error 207 identifies schema metadata, not a parameter value. Only exempt
+            # the canonical English/German message, never arbitrary text with code 207.
+            column_message = description if code == 207 and re.fullmatch(
+                r'''(?:Invalid column name|Ungültiger Spaltenname)\s+(?:'(?:''|[^'])*'|"(?:""|[^"])*"|„[^“]*“|“[^”]*”|‚[^‘]*‘|‘[^’]*’|«[^»]*»)\s*\.?''',
+                description.strip(), re.IGNORECASE,
+            ) else None
+            # Values may also be echoed without quotes (notably duplicate key tuples).
+            for secret in sorted(secrets, key=len, reverse=True):
+                description = description.replace(secret, '?').replace(secret.replace("'", "''"), '?')
+            description = re.sub(r'\([^)]*(?:\)|$)', '(?)', description)
+            description = re.sub(r'(?i)\b(password|pwd|token|secret)\s*[:=]\s*[^;\s]+', r'\1=?', description)
+            description = clean(column_message if column_message is not None else sanitize_sql(description))[:4096]
+            diagnostics.append(dict(sqlstate=own_state[1] if own_state else state, code=code, message=description))
+    return diagnostics[:20]
+
+
+def log_query_error(error, statement=None, query_name=None, operation=None, parameters=None):
     from .config import settings
     try:
         sql = sanitize_sql(statement) if statement is not None else ''
@@ -239,12 +286,21 @@ def log_query_error(error, statement=None, query_name=None, operation=None):
     if settings.LOG_SQL_STATEMENT and sql:
         metadata.update(sql=sql[:8192], sql_truncated=len(sql) > 8192)
     roots = exception_roots(error)
+    diagnostics = []
     for root in roots:
         if not getattr(root, '_gc_log_frames', None): root._gc_log_frames = frames()
         first = root.args[0] if root.args else None
         if isinstance(first, str) and re.fullmatch(r'[A-Z0-9]{5}', first): metadata['sqlstate'] = first
         code = getattr(root, 'sqlite_errorcode', None)
         if isinstance(code, int): metadata['db_code'] = code
+        try:
+            diagnostics.extend(database_diagnostics(root, statement, parameters if parameters is not None else getattr(error, 'params', None)))
+        except Exception:
+            pass  # A malformed driver diagnostic must not replace the original database error.
+    if diagnostics:
+        metadata['db_diagnostics'] = diagnostics[:20]
+        metadata['db_message'] = diagnostics[0]['message']
+        if diagnostics[0]['code'] is not None: metadata['db_code'] = diagnostics[0]['code']
     logging.getLogger('app.db').error('Datenbankoperation fehlgeschlagen', exc_info=(type(error), error, error.__traceback__), extra=metadata)
 
 

+ 88 - 38
app/routes.py

@@ -23,24 +23,40 @@ from app.file_storage import file_primary_key, get_file, get_files
 from app.query_filter import import_filter_config
 
 from .auth import clear_session_cookie, safe_next_url, set_session_cookie
-from .config import settings
 from .comment_submission import submit_comment
+from .config import settings
+from .dashboard_segment import (
+    DASHBOARD_VIEWS,
+    pack_segment,
+    parse_segment,
+    segment_label,
+    segment_sql,
+)
 from .db import get_session
 from .docuware_search import get_invoice_pdf_by_document_no
-from .forderung_history import HistoryScope, history_context, section_context
+from .forderung_dashboard import STACKS, build_dashboard
 from .forderung_data import read_invoices
-from .forderung_dashboard import build_dashboard, STACKS
-from .dashboard_segment import DASHBOARD_VIEWS, parse_segment, pack_segment, segment_sql, segment_label
+from .forderung_history import HistoryScope, history_context, section_context
 from .forderung_summary import get_forderung_summary
 from .jinja_templates import templates
-from .ldap import DirectoryUnavailable, DirectoryUser, ldap_authenticate, normalize_username
-from .navigation import detail_navigation_context, list_url, return_url_for_request, valid_focus
+from .ldap import (
+    DirectoryUnavailable,
+    DirectoryUser,
+    ldap_authenticate,
+    normalize_username,
+)
+from .navigation import (
+    detail_navigation_context,
+    list_url,
+    return_url_for_request,
+    valid_focus,
+)
 from .oauth_token import create_access_token
 from .permissions import (
     current_principal,
     ensure_forderung_access,
-    highest_role,
     has_forderung_access,
+    highest_role,
     load_entitlements,
 )
 from .verursacher import router as verursacher_router
@@ -163,7 +179,9 @@ def select_get(request: Request):
 
 
 @router.get("/app/forderungen/liste", response_class=HTMLResponse)
-def forderungen_liste(request: Request, db: Session = Depends(get_session), limit: int = 100, page: int = 0, focus: str | None = None):
+def forderungen_liste(
+    request: Request, db: Session = Depends(get_session), limit: int = 100, page: int = 0, focus: str | None = None
+):
     principal = current_principal(request)
     forderung_filter_config = import_filter_config("forderungen")
 
@@ -173,16 +191,16 @@ def forderungen_liste(request: Request, db: Session = Depends(get_session), limi
         if request.query_params.get(f.name, "") != ""
     }
     try:
-        selection = parse_segment(request.query_params.get('dashboard_segment'))
+        selection = parse_segment(request.query_params.get("dashboard_segment"))
     except ValueError as error:
-        raise HTTPException(400, 'Ungültiger Dashboardfilter') from error
+        raise HTTPException(400, "Ungültiger Dashboardfilter") from error
     if selection:
-        params['dashboard_segment'] = pack_segment(selection)
+        params["dashboard_segment"] = pack_segment(selection)
     if not valid_focus(focus):
         focus = None
     if page == 0:
         canonical = list_url(params, focus)
-        if request.url.path + ('?' + request.url.query if request.url.query else '') != canonical:
+        if request.url.path + ("?" + request.url.query if request.url.query else "") != canonical:
             return RedirectResponse(url=canonical)
 
     query_clauses = []
@@ -219,20 +237,24 @@ def forderungen_liste(request: Request, db: Session = Depends(get_session), limi
             for row in q
         ]
         if filter_temp:
-            filters[f.name] = {item[1]: item[2] for item in sorted(set(filter_temp), key=lambda item: tuple(str(value or '') for value in item)) if item[1]}
+            filters[f.name] = {
+                item[1]: item[2]
+                for item in sorted(set(filter_temp), key=lambda item: tuple(str(value or "") for value in item))
+                if item[1]
+            }
         else:
             filters[f.name] = {}
         f.options = filters[f.name]
 
     summary = {
-        "Beleg_offen": sum((Decimal(str(r[col_names.index("Beleg_offen")] or 0)) for r in q), Decimal(0)),
+        "Beleg_Offen": sum((Decimal(str(r[col_names.index("Beleg_Offen")] or 0)) for r in q), Decimal(0)),
         "Anzahl": len(q),
     }
 
     end = (page + 1) * limit
     focus_found = False
     if page == 0 and focus:
-        tenant_column, number_column = col_names.index('Client_DB'), col_names.index('Beleg_Nr')
+        tenant_column, number_column = col_names.index("Client_DB"), col_names.index("Beleg_Nr")
         for index, row in enumerate(q):
             if f"{row[tenant_column]}_{row[number_column]}" == focus:
                 end = max(end, (index // limit + 1) * limit)
@@ -269,9 +291,13 @@ def forderungen_liste(request: Request, db: Session = Depends(get_session), limi
             "focus": focus if page == 0 else None,
             "focus_missing": bool(page == 0 and focus and not focus_found),
             "filter_config": forderung_filter_config,
-            "dashboard_segment": params.get('dashboard_segment'),
-            "dashboard_segment_label": segment_label(selection, [dict(zip(col_names, q[0]))] if q else []) if selection else None,
-            "without_dashboard_segment": list_url({key: value for key, value in params.items() if key != 'dashboard_segment'}),
+            "dashboard_segment": params.get("dashboard_segment"),
+            "dashboard_segment_label": (
+                segment_label(selection, [dict(zip(col_names, q[0]))] if q else []) if selection else None
+            ),
+            "without_dashboard_segment": list_url(
+                {key: value for key, value in params.items() if key != "dashboard_segment"}
+            ),
         },
         headers={"Cache-Control": "no-store"},
     )
@@ -302,15 +328,17 @@ def get_template_context(request: Request, pkey: dict[str, str], db: Session) ->
             "utf-8"
         )
         values = {"client_db": pkey["Client_DB"], "beleg_nr": pkey["Beleg_Nr"]}
-        template_context[filename] = db.execute(text(query).execution_options(query_name=f'forderungen/details/queries/{filename}.sql'), values).fetchall()
+        template_context[filename] = db.execute(
+            text(query).execution_options(query_name=f"forderungen/details/queries/{filename}.sql"), values
+        ).fetchall()
     for section in ("kommentare", "buchungen"):
         template_context.update(section_context(db, pkey, template_context["forderungen_details"][0], section))
     try:
         template_context["files"] = get_files(pkey)
     except OSError:
-        logger.exception('Dateiliste konnte nicht geladen werden')
+        logger.exception("Dateiliste konnte nicht geladen werden")
         template_context["files"] = []
-        template_context["files_error"] = 'Die Dateiliste konnte nicht geladen werden.'
+        template_context["files_error"] = "Die Dateiliste konnte nicht geladen werden."
     template_context["upload_max_mb"] = settings.UPLOAD_MAX_FILE_SIZE_MB
     template_context["upload_max_bytes"] = settings.UPLOAD_MAX_FILE_SIZE_MB * 1024 * 1024
     template_context["add7days"] = (datetime.now() + timedelta(days=7)).strftime("%Y-%m-%d")
@@ -395,13 +423,15 @@ async def post_forderungen_details(
     ).body.decode("utf-8")
     for subquery in query.split(";"):
         if subquery.strip():
-            db.execute(text(subquery).execution_options(query_name=f'forderungen/details/queries/post/post_{source}.sql'), data)
+            db.execute(
+                text(subquery).execution_options(query_name=f"forderungen/details/queries/post/post_{source}.sql"), data
+            )
     db.commit()
 
     return_url = return_url_for_request(request, primary_key, return_to if return_to is not None else redirect)
     if redirect:
-        if request.headers.get('HX-Request', '').lower() == 'true':
-            return HTMLResponse('', headers={'HX-Redirect': return_url})
+        if request.headers.get("HX-Request", "").lower() == "true":
+            return HTMLResponse("", headers={"HX-Redirect": return_url})
         return RedirectResponse(url=return_url, status_code=303)
     template_context = get_template_context(request, pkey, db)
     template_context.update(detail_navigation_context(request, primary_key, return_url))
@@ -418,25 +448,45 @@ def convert_primary_key(area: str, primary_key: str) -> dict[str, str]:
 
 
 @router.get("/app/forderungen/dashboard", response_class=HTMLResponse)
-def forderungen_dashboard(request: Request, db: Session = Depends(get_session), page: str = 'Standort', metric: str = 'betrag', only_invoices: bool = False, stack: str = 'abteilung'):
+def forderungen_dashboard(
+    request: Request,
+    db: Session = Depends(get_session),
+    page: str = "Standort",
+    metric: str = "betrag",
+    only_invoices: bool = False,
+    stack: str = "abteilung",
+):
     principal = current_principal(request)
-    if page == 'Wiedervorlage' and metric in {'betrag', 'anzahl'} and stack in STACKS:
-        params = dict(page='Standort', metric=metric, stack='wiedervorlage')
+    if page == "Wiedervorlage" and metric in {"betrag", "anzahl"} and stack in STACKS:
+        params = dict(page="Standort", metric=metric, stack="wiedervorlage")
         if only_invoices:
-            params['only_invoices'] = 'true'
-        return RedirectResponse('/app/forderungen/dashboard?' + urlencode(params), status_code=303)
-    if page not in DASHBOARD_VIEWS or metric not in {'betrag', 'anzahl'} or stack not in STACKS:
-        raise HTTPException(422, 'Ungültige Dashboardansicht')
-    context = dict(summary=False, dashboard_view=page, dashboard_metric=metric, dashboard_stack=stack, dashboard_only_invoices=only_invoices, dashboard=None, dashboard_error=None)
+            params["only_invoices"] = "true"
+        return RedirectResponse("/app/forderungen/dashboard?" + urlencode(params), status_code=303)
+    if page not in DASHBOARD_VIEWS or metric not in {"betrag", "anzahl"} or stack not in STACKS:
+        raise HTTPException(422, "Ungültige Dashboardansicht")
+    context = dict(
+        summary=False,
+        dashboard_view=page,
+        dashboard_metric=metric,
+        dashboard_stack=stack,
+        dashboard_only_invoices=only_invoices,
+        dashboard=None,
+        dashboard_error=None,
+    )
     status_code = 200
     try:
-        context['dashboard'] = build_dashboard(db, principal.username, only_invoices=only_invoices, stack=stack)
+        context["dashboard"] = build_dashboard(db, principal.username, only_invoices=only_invoices, stack=stack)
     except Exception:
-        logger.exception('Forderungsdashboard konnte nicht geladen werden')
-        context['dashboard_error'] = 'Das Dashboard konnte nicht geladen werden. Bitte erneut versuchen.'
+        logger.exception("Forderungsdashboard konnte nicht geladen werden")
+        context["dashboard_error"] = "Das Dashboard konnte nicht geladen werden. Bitte erneut versuchen."
         status_code = 503
-    return templates.TemplateResponse(request, 'forderungen/dashboard/dashboard.html', context,
-                                      status_code=status_code, headers={'Cache-Control': 'no-store'})
+    return templates.TemplateResponse(
+        request,
+        "forderungen/dashboard/dashboard.html",
+        context,
+        status_code=status_code,
+        headers={"Cache-Control": "no-store"},
+    )
 
 
 # TODO
@@ -491,7 +541,7 @@ def get_files2(request: Request, db: Session = Depends(get_session)):
         pkey = file_primary_key(name)
     except FileNotFoundError:
         raise HTTPException(status_code=404, detail="Datei nicht gefunden")
-    ensure_forderung_access(db, principal, pkey['Client_DB'], pkey['Beleg_Nr'])
+    ensure_forderung_access(db, principal, pkey["Client_DB"], pkey["Beleg_Nr"])
     try:
         filename = get_file(name)
     except FileNotFoundError:

+ 4 - 2
server.py

@@ -1,11 +1,13 @@
 import argparse
+
 import uvicorn
 
 from app.logging_setup import run_cli
 
+
 def main():
     parser = argparse.ArgumentParser()
-    parser.add_argument('--no-reload', action='store_true', help='Run without the development reloader')
+    parser.add_argument("--no-reload", action="store_true", help="Run without the development reloader")
     args = parser.parse_args()
     uvicorn.run(
         "app.main:app",
@@ -18,5 +20,5 @@ def main():
     )
 
 
-if __name__ == '__main__':
+if __name__ == "__main__":
     run_cli(main)

+ 1 - 1
templates/forderungen/details/details.html

@@ -10,7 +10,7 @@
     <p>{{ row.Kunde_Name }} <span>· {{ row.Kunde_Nr }}</span></p>
   </div>
   <div class="gc-context-metrics">
-    <div class="gc-metric gc-metric-balance"><span>Offener Betrag</span><strong>{{ row.Beleg_offen|number_format }} €</strong></div>
+    <div class="gc-metric gc-metric-balance"><span>Offener Betrag</span><strong>{{ row.Beleg_Offen|number_format }} €</strong></div>
     <div class="gc-metric gc-metric-level{% if row.Mahnstufe|string == "0" %} gc-metric-clear{% endif %}"><span>Mahnstufe</span><strong>{{ row.Mahnstufe }}</strong></div>
   </div>
 </section>

+ 2 - 2
templates/forderungen/details/details_uebersicht.html

@@ -19,7 +19,7 @@
   <dl class="gc-data-fields">
     <div class="gc-data-field"><dt>Belegart</dt><dd>{{row.Rechnung_Gutschrift }}</dd></div>
     <div class="gc-data-field"><dt>Beleg-Nr</dt><dd>{{row.Beleg_Nr }}</dd></div>
-    <div class="gc-data-field gc-field-money"><dt>Betrag</dt><dd>{{row.Beleg_offen|number_format }} &euro;</dd></div>
+    <div class="gc-data-field gc-field-money"><dt>Betrag</dt><dd>{{row.Beleg_Offen|number_format }} &euro;</dd></div>
     <div class="gc-data-field"><dt>Rechnungsdatum</dt><dd>{{row.Beleg_Datum|date_format }}</dd></div>
   </dl>
   <details class="gc-data-more">
@@ -33,7 +33,7 @@
 <section class="gc-detail-card" aria-labelledby="data-title-2">
   <h2 class="gc-section-title" id="data-title-2"><i class="icon cil-money" aria-hidden="true"></i> Forderung</h2>
   <dl class="gc-data-fields">
-    <div class="gc-data-field gc-field-money"><dt>offen</dt><dd>{{row.Beleg_offen|number_format }} &euro;</dd></div>
+    <div class="gc-data-field gc-field-money"><dt>offen</dt><dd>{{row.Beleg_Offen|number_format }} &euro;</dd></div>
     <div class="gc-data-field gc-field-money gc-field-total"><dt>offen (Kunde gesamt)</dt><dd>{{row.Kunde_offen_gesamt|number_format }} &euro;</dd></div>
     <div class="gc-data-field gc-field-age"><dt>Tage</dt><dd>{{row.Beleg_Tage_offen }}</dd></div>
     <div class="gc-data-field"><dt>Staffel</dt><dd>{{row.Staffel_Text }}</dd></div>

+ 1 - 1
templates/forderungen/details/notice.html

@@ -10,7 +10,7 @@
     <dt class="col-sm-4">Rechnungsbetrag</dt>
     <dd class="col-sm-8">{% if forderung.Beleg_Betrag is not none %}{{ forderung.Beleg_Betrag|number_format }} €{% else %}nicht hinterlegt{% endif %}</dd>
     <dt class="col-sm-4">Offener Betrag</dt>
-    <dd class="col-sm-8">{% if forderung.Beleg_offen is not none %}{{ forderung.Beleg_offen|number_format }} €{% else %}nicht hinterlegt{% endif %}</dd>
+    <dd class="col-sm-8">{% if forderung.Beleg_Offen is not none %}{{ forderung.Beleg_Offen|number_format }} €{% else %}nicht hinterlegt{% endif %}</dd>
   </dl>
   {% elif kind == 'settled' %}
   <p>Forderung {{ forderung.Beleg_Nr }} vom {{ forderung.Beleg_Datum|date_format if forderung.Beleg_Datum is not none else 'nicht hinterlegt' }} wurde am {{ forderung.Auszifferung_Datum|date_format if forderung.Auszifferung_Datum is not none else 'nicht hinterlegt' }} beglichen.</p>

+ 15 - 15
templates/forderungen/details/queries/forderungen_details.sql

@@ -1,7 +1,8 @@
 SELECT [F].*
-     , COALESCE([B].[Name], [FD].[Verursacher_Benutzer_ID], [F].[Verursacher_Text]) AS [Verursacher_Aktuell_Text]
-     , COALESCE([FD].[Verursacher_Benutzer_ID], [F].[Verursacher_Benutzer_ID]) AS [Verursacher_Aktuell_Benutzer_ID]
-     , round([Beleg_offen] * 0.19, 2) AS [Mwst_Betrag_berechnet]
+     , [F].[Kunde_Gesamt_Offen] AS [Kunde_offen_gesamt]
+     , coalesce([B].[Name], [FD].[Verursacher_Benutzer_ID], [F].[Verursacher_Text]) AS [Verursacher_Aktuell_Text]
+     , coalesce([FD].[Verursacher_Benutzer_ID], [F].[Verursacher_Benutzer_ID]) AS [Verursacher_Aktuell_Benutzer_ID]
+     , round([Beleg_Offen] * 0.19, 2) AS [Mwst_Betrag_berechnet]
      , [K].[Kunde_Name]
      , [K].[Kunde_Nachname]
      , [K].[Kunde_Vorname]
@@ -14,8 +15,8 @@ SELECT [F].*
      , [K].[Kunde_Email]
      , [K].[Payment_Terms_Code]
      , [K].[Gen_Bus_Posting_Group]
-     , [K].[Customer_Group_Code]
-     , [K].[Customer_Group_Description]
+     , [K].[Kunde_Kundengruppe_Code] AS [Customer_Group_Code]
+     , [K].[Kunde_Kundengruppe_Text] AS [Customer_Group_Description]
      , isnull([FD].[Bearbeitet], 'N') AS [Bearbeitet]
      , isnull([FD].[Versicherung], 'N') AS [Versicherung]
      , isnull([FD].[Rechtsanwalt], 'N') AS [Rechtsanwalt]
@@ -53,8 +54,8 @@ SELECT [F].*
      , [FZ].[Fahrzeug_Erstzulassung]
      , [FZ].[Fahrzeug_Kaufdatum]
      , [FZ].[Fahrzeug_Vorbesitzer_Anzahl]
-     , [FZ].[Fahrzeug_Vorbesitzer_Kunde_Nr]     
-     , iif(K.Kunde_Nr = K2.Kunde_Nr, 'N', 'J') as Fahrzeug_Leasing
+     , [FZ].[Fahrzeug_Vorbesitzer_Kunde_Nr]
+     , iif([K].[Kunde_Nr] = [K2].[Kunde_Nr], 'N', 'J') AS [Fahrzeug_Leasing]
      , [K2].[Kunde_Nr] AS [Leasing_Kunde_Nr]
      , [K2].[Kunde_Name] AS [Leasing_Kunde_Name]
      , [K2].[Kunde_Nachname] AS [Leasing_Kunde_Nachname]
@@ -65,16 +66,15 @@ SELECT [F].*
      , [K2].[Kunde_Ort] AS [Leasing_Kunde_Ort]
      , [K2].[Kunde_Land] AS [Leasing_Kunde_Land]
      , [K2].[Kunde_Telefon] AS [Leasing_Kunde_Telefon]
-     , [K2].[Kunde_Email] AS [Leasing_Kunde_Email]     
+     , [K2].[Kunde_Email] AS [Leasing_Kunde_Email]
 FROM [dbo].[Forderungen_Kopf] [F]
          LEFT JOIN [dbo].[Kunden] [K] ON [F].[Kunde_Nr] = [K].[Kunde_Nr] AND [F].[Client_DB] = [K].[Client_DB]
-         LEFT JOIN [dbo].[Fahrzeuge] FZ ON F.[Fahrzeug_Nr] = FZ.[Fahrzeug_Nr] AND [F].[Client_DB] = [FZ].[Client_DB]
+         LEFT JOIN [dbo].[Fahrzeuge] [FZ] ON [F].[Fahrzeug_Nr] = [FZ].[Fahrzeug_Nr] AND [F].[Client_DB] = [FZ].[Client_DB]
          LEFT JOIN [dbo].[Kunden] [K2] ON [FZ].[Fahrzeug_Kunde_Nr] = [K2].[Kunde_Nr] AND [FZ].[Client_DB] = [K2].[Client_DB]
-         LEFT JOIN [dbo].[Forderungen_Details] [FD] ON [F].[Beleg_Nr] = [FD].[Beleg_Nr] AND [F].[Client_DB] = [FD].[Client_DB] 
-         LEFT JOIN (
-             SELECT [Benutzer_ID], MAX(NULLIF([Name], '')) AS [Name]
-             FROM [dbo].[Benutzer_Rolle] GROUP BY [Benutzer_ID]
-         ) [B] ON [FD].[Verursacher_Benutzer_ID] = [B].[Benutzer_ID]
+         LEFT JOIN [dbo].[Forderungen_Details] [FD] ON [F].[Beleg_Nr] = [FD].[Beleg_Nr] AND [F].[Client_DB] = [FD].[Client_DB]
+         LEFT JOIN (SELECT [Benutzer_ID], max(nullif([Name], '')) AS [Name]
+                    FROM [dbo].[Benutzer_Rolle]
+                    GROUP BY [Benutzer_ID]) [B] ON [FD].[Verursacher_Benutzer_ID] = [B].[Benutzer_ID]
          LEFT JOIN [dbo].[Versicherungen] [V] ON [FD].[Vers_Adresse_ID] = [V].[Vers_Adresse_ID]
 WHERE [F].[Client_DB] = :client_db
-AND [F].[Beleg_Nr] = :beleg_nr
+  AND [F].[Beleg_Nr] = :beleg_nr

+ 1 - 1
templates/forderungen/details/queries/history_scope.sql

@@ -7,7 +7,7 @@ WITH current_invoice AS (
     SELECT Client_DB, Beleg_Nr FROM current_invoice
     {% elif scope == 'open' %}
     SELECT F.Client_DB, F.Beleg_Nr FROM dbo.Forderungen_Kopf AS F
-    WHERE F.Client_DB = :client_db AND F.Beleg_offen <> 0
+    WHERE F.Client_DB = :client_db AND F.Beleg_Offen <> 0
       AND EXISTS (SELECT 1 FROM current_invoice AS C WHERE C.Kunde_Nr = F.Kunde_Nr)
     {% else %}
     SELECT F.Client_DB, F.Beleg_Nr FROM dbo.Forderungen_Kopf AS F

+ 1 - 1
templates/forderungen/liste/liste_zeile.html

@@ -53,7 +53,7 @@
   </td>
 
   <td class="text-end">
-    <div class="gc-money">{{ row.Beleg_offen|number_format }} €</div>
+    <div class="gc-money">{{ row.Beleg_Offen|number_format }} €</div>
     <div class="gc-subtitle">{{ row.Kunde_gesamt_Bezug }} {{ row.Kunde_offen_gesamt|number_format }} €</div>
   </td>
 

+ 9 - 8
templates/forderungen/liste/queries/forderungen_liste.sql

@@ -7,11 +7,11 @@ SELECT [F].[Client_DB]
      , [F].[Standort_Name]
      , [F].[Beleg_Nr]
      , [F].[Rechnung_Gutschrift]
-     , [F].[Benutzer_ID]
+     , '' AS [Benutzer_ID]
      , [F].[Fahrzeug_Nr]
      , [F].[Beleg_Kommentar]
-     , [F].[Beleg_offen]
-     , [F].[Kunde_offen_gesamt]
+     , [F].[Beleg_Offen]
+     , [F].[Kunde_Gesamt_Offen] AS [Kunde_offen_gesamt]
      , [F].[Beleg_Datum]
      , isnull([FD].[Verursacher_Benutzer_ID], [F].[Verursacher_Benutzer_ID]) AS Verursacher_Benutzer_ID
      , COALESCE([B].[Name], [FD].[Verursacher_Benutzer_ID], [F].[Verursacher_Text]) AS Verursacher_Text
@@ -33,11 +33,12 @@ SELECT [F].[Client_DB]
      , left(isnull([FK].[Kommentar_Abteilung], ''), 50) AS [Kommentar_Abteilung]
      , isnull([FK].[Begründung], '') AS [Begründung]
 
-     , coalesce([FD].[Wiedervorlage], F.[Beleg_Faellig_Datum]) AS [Wiedervorlage]
-     , case 
-        when coalesce([FD].[Wiedervorlage], F.[Beleg_Faellig_Datum]) < getdate() then '2'
-        when coalesce([FD].[Wiedervorlage], F.[Beleg_Faellig_Datum]) < dateadd(day, 4, getdate()) then '1'
-      else '0' end AS [Wiedervorlage_Stufe]
+     , coalesce([FD].[Wiedervorlage], convert(date, [F].[Beleg_Faellig_Datum])) AS [Wiedervorlage]
+     , CASE
+          WHEN coalesce([FD].[Wiedervorlage], convert(date, [F].[Beleg_Faellig_Datum])) < getdate() THEN '2'
+          WHEN coalesce([FD].[Wiedervorlage], convert(date, [F].[Beleg_Faellig_Datum])) < dateadd(DAY, 4, getdate()) THEN '1'
+          ELSE '0'
+       END AS [Wiedervorlage_Stufe]
      , CASE
            WHEN [FD].[Reklamation] = 'J' THEN 'Rekl.'
            WHEN [FD].[Versicherung] = 'J' THEN 'Vers.'

+ 1 - 1
templates/forderungen/liste/summary.html

@@ -24,6 +24,6 @@
         <a href="#liste-filter" hx-boost="false">Zu den Filtern ↑</a>
       </div>
     </details>
-    <div class="gc-summary-total"><span>Offener Gesamtbetrag</span> <strong>{{ summary.Beleg_offen|number_format }} €</strong></div>
+    <div class="gc-summary-total"><span>Offener Gesamtbetrag</span> <strong>{{ summary.Beleg_Offen|number_format }} €</strong></div>
   </div>
 </footer>

+ 222 - 151
tests/test_dashboard.py

@@ -1,15 +1,16 @@
 """Dashboard and exact drill-down contracts on the shared production SQL projection."""
-from datetime import datetime
-from collections import namedtuple
-from decimal import Decimal
+
 import json
 import re
 import unittest
+from collections import namedtuple
+from datetime import datetime
+from decimal import Decimal
 from unittest.mock import patch
-from urllib.parse import parse_qs, urlsplit, urlencode
+from urllib.parse import parse_qs, urlencode, urlsplit
 
-from app.dashboard_segment import parse_segment, pack_segment, segment_sql
-from app.forderung_dashboard import build_dashboard, BERLIN, color
+from app.dashboard_segment import pack_segment, parse_segment, segment_sql
+from app.forderung_dashboard import BERLIN, build_dashboard, color
 from app.navigation import list_return_url, normalize_page_url
 from tests import test_verursacher as fixture
 
@@ -21,12 +22,17 @@ class DashboardTests(unittest.TestCase):
         self.addCleanup(self.fixture.doCleanups)
         self.client, self.db = self.fixture.client, self.fixture.db
         from app.jinja_templates import templates
-        sql = templates.env.get_template('forderungen/liste/queries/forderungen_liste.sql').render()
-        needed = set(re.findall(r'(?:\[F\]|F)\.\[([^]]+)\]', sql))
-        existing = {row[1] for row in self.db.connection.execute('PRAGMA dbo.table_info(Forderungen_Kopf)')}
+
+        sql = templates.env.get_template("forderungen/liste/queries/forderungen_liste.sql").render()
+        needed = set(re.findall(r"(?:\[F\]|F)\.\[([^]]+)\]", sql))
+        existing = {row[1] for row in self.db.connection.execute("PRAGMA dbo.table_info(Forderungen_Kopf)")}
         for name in needed - existing:
-            kind = 'INTEGER' if name == 'Beleg_Tage_offen' else 'REAL' if name in {'Beleg_Betrag','Kunde_offen_gesamt'} else 'TEXT'
-            self.db.connection.execute(f'ALTER TABLE dbo.Forderungen_Kopf ADD [{name}] {kind}')
+            kind = (
+                "INTEGER"
+                if name == "Beleg_Tage_offen"
+                else "REAL" if name in {"Beleg_Betrag", "Kunde_offen_gesamt"} else "TEXT"
+            )
+            self.db.connection.execute(f"ALTER TABLE dbo.Forderungen_Kopf ADD [{name}] {kind}")
         self.db.connection.executescript("""
             ALTER TABLE dbo.Forderungen_Details ADD Rechtsanwalt TEXT;
             ALTER TABLE dbo.Forderungen_Details ADD Reklamation TEXT;
@@ -35,7 +41,7 @@ class DashboardTests(unittest.TestCase):
             CREATE TABLE Garantie2 (Client_DB TEXT, Beleg_Nr TEXT, Antrag_Art TEXT, Antrag_Nr TEXT, Antrag_Datum TEXT, Antrag_Benutzer_ID TEXT, Antrag_Status TEXT, Letzte_Beantragung TEXT);
             UPDATE dbo.Forderungen_Kopf SET Standort_Name='ULM', Hauptbetrieb_Name='AHR',
                 Staffel_Text='> 12 Wochen', Beleg_Tage_offen=100, Rechnung_Gutschrift='Rechnung',
-                Beleg_Faellig_Datum='2026-11-02', Beleg_Betrag=Beleg_offen, Kunde_offen_gesamt=100;
+                Beleg_Faellig_Datum='2026-11-02', Beleg_Betrag=Beleg_Offen, Kunde_offen_gesamt=100;
             UPDATE dbo.Forderungen_Kopf SET Hauptbetrieb_ID='2', Hauptbetrieb_Name='AAM' WHERE Client_DB='2';
             UPDATE dbo.Forderungen_Kopf SET Staffel_Text='< 4 Wochen', Beleg_Tage_offen=10 WHERE Beleg_Nr='R1';
             INSERT INTO dbo.Forderungen_Details (Client_DB,Beleg_Nr,Verursacher_Benutzer_ID,Wiedervorlage) VALUES
@@ -46,250 +52,315 @@ class DashboardTests(unittest.TestCase):
             INSERT INTO dbo.Benutzer_Rechte VALUES ('tester','1','10','Service');
             INSERT INTO dbo.Benutzer_Rechte VALUES ('tester','1','10','');
         """)
-        columns = [row[1] for row in self.db.connection.execute('PRAGMA dbo.table_info(Forderungen_Kopf)')]
-        select = ','.join("'CR'" if name == 'Beleg_Nr' else '-40' if name == 'Beleg_offen' else '['+name+']' for name in columns)
-        self.db.connection.execute('INSERT INTO dbo.Forderungen_Kopf SELECT '+select+" FROM dbo.Forderungen_Kopf WHERE Client_DB='1' AND Beleg_Nr='NV'")
-        self.db.connection.execute("UPDATE dbo.Forderungen_Kopf SET Rechnung_Gutschrift='Gutschrift', Abteilung='', Staffel_Text=NULL, Verursacher_Benutzer_ID=NULL, Verursacher_Text=NULL, Beleg_Faellig_Datum=NULL WHERE Beleg_Nr='CR'")
+        columns = [row[1] for row in self.db.connection.execute("PRAGMA dbo.table_info(Forderungen_Kopf)")]
+        select = ",".join(
+            "'CR'" if name == "Beleg_Nr" else "-40" if name == "Beleg_Offen" else "[" + name + "]" for name in columns
+        )
+        self.db.connection.execute(
+            "INSERT INTO dbo.Forderungen_Kopf SELECT "
+            + select
+            + " FROM dbo.Forderungen_Kopf WHERE Client_DB='1' AND Beleg_Nr='NV'"
+        )
+        self.db.connection.execute(
+            "UPDATE dbo.Forderungen_Kopf SET Rechnung_Gutschrift='Gutschrift', Abteilung='', Staffel_Text=NULL, Verursacher_Benutzer_ID=NULL, Verursacher_Text=NULL, Beleg_Faellig_Datum=NULL WHERE Beleg_Nr='CR'"
+        )
         self.db.connection.commit()
         execute = self.db.execute
+
         def dialect(query, values=None):
             query = str(query)
-            for column in ['Kommentar_Fibu', 'Kommentar_Abteilung']:
-                query = query.replace(f"left(isnull([FK].[{column}], ''), 50)", f"substr(ifnull([FK].[{column}], ''), 1, 50)")
-            query = query.replace('isnull(', 'ifnull(').replace('dateadd(day, 4, getdate())', "'2026-11-06'").replace('getdate()', "'2026-11-02'")
-            if query.strip().startswith('SELECT F.* FROM'):
+            for column in ["Kommentar_Fibu", "Kommentar_Abteilung"]:
+                query = query.replace(
+                    f"left(isnull([FK].[{column}], ''), 50)", f"substr(ifnull([FK].[{column}], ''), 1, 50)"
+                )
+            query = (
+                query.replace("isnull(", "ifnull(")
+                .replace("dateadd(day, 4, getdate())", "'2026-11-06'")
+                .replace("getdate()", "'2026-11-02'")
+            )
+            if query.strip().startswith("SELECT F.* FROM"):
                 self.db.calls.append((query, values))
                 cursor = self.db.connection.execute(query, values or {})
                 columns = [item[0] for item in cursor.description]
-                record = namedtuple('DashboardInvoice', columns)
+                record = namedtuple("DashboardInvoice", columns)
                 rows = []
                 for raw in cursor.fetchall():
                     row = dict(zip(columns, raw))
-                    for name in ['Beleg_Datum','Beleg_Faellig_Datum','Wiedervorlage']:
-                        if row.get(name): row[name] = datetime.fromisoformat(row[name])
+                    for name in ["Beleg_Datum", "Beleg_Faellig_Datum", "Wiedervorlage"]:
+                        if row.get(name):
+                            row[name] = datetime.fromisoformat(row[name])
                     rows.append(record(**row))
+
                 class SQLRows:
-                    def keys(self): return columns
-                    def fetchall(self): return rows
+                    def keys(self):
+                        return columns
+
+                    def fetchall(self):
+                        return rows
+
                 return SQLRows()
             return execute(query, values)
-        adapter = patch.object(self.db, 'execute', side_effect=dialect)
-        adapter.start(); self.addCleanup(adapter.stop)
+
+        adapter = patch.object(self.db, "execute", side_effect=dialect)
+        adapter.start()
+        self.addCleanup(adapter.stop)
         self.now = datetime(2026, 11, 2, 10, tzinfo=BERLIN)
 
     def data(self):
-        return build_dashboard(self.db, 'tester', self.now)
+        return build_dashboard(self.db, "tester", self.now)
 
     def test_totals_rights_duplicate_roles_and_duplicate_join_rows(self):
         data = self.data()
-        self.assertEqual(data['count'], 5)
-        self.assertEqual(data['amount'], '200.00')
-        self.assertEqual(data['amount_display'], '200,00')
-        for groups in data['views'].values():
-            self.assertEqual(sum(group['count'] for group in groups), 5)
-            self.assertEqual(sum(Decimal(group['amount']) for group in groups), Decimal(200))
+        self.assertEqual(data["count"], 5)
+        self.assertEqual(data["amount"], "200.00")
+        self.assertEqual(data["amount_display"], "200,00")
+        for groups in data["views"].values():
+            self.assertEqual(sum(group["count"] for group in groups), 5)
+            self.assertEqual(sum(Decimal(group["amount"]) for group in groups), Decimal(200))
         sql, values = self.db.calls[-1]
-        self.assertIn('WHERE EXISTS', sql)
-        self.assertEqual(values['auth_username'], 'tester')
-        self.assertTrue(all('AAM' not in group['label'] for group in data['views']['Standort']))
+        self.assertIn("WHERE EXISTS", sql)
+        self.assertEqual(values["auth_username"], "tester")
+        self.assertTrue(all("AAM" not in group["label"] for group in data["views"]["Standort"]))
 
     def test_only_invoices_filters_all_aggregates_and_drilldowns(self):
-        data = build_dashboard(self.db, 'tester', self.now, only_invoices=True)
-        self.assertEqual(data['count'], 4)
-        self.assertEqual(data['amount'], '240.00')
-        self.assertEqual(self.data()['count'], 5)
-        for groups in data['views'].values():
-            self.assertEqual(sum(group['count'] for group in groups), 4)
+        data = build_dashboard(self.db, "tester", self.now, only_invoices=True)
+        self.assertEqual(data["count"], 4)
+        self.assertEqual(data["amount"], "240.00")
+        self.assertEqual(self.data()["count"], 5)
+        for groups in data["views"].values():
+            self.assertEqual(sum(group["count"] for group in groups), 4)
             for group in groups:
-                for item in [group, *group['segments']]:
-                    raw = parse_qs(urlsplit(item['url']).query)['dashboard_segment'][0]
-                    self.assertTrue(parse_segment(raw)['only_invoices'])
-                    response = self.client.get(item['url'])
-                    self.assertIn('id="list-count">'+str(item['count']), response.text)
-                    self.assertIn('Nur Rechnungen', response.text)
-        self.db.connection.execute("UPDATE dbo.Forderungen_Kopf SET Rechnung_Gutschrift='Rechnungskorrektur' WHERE Beleg_Nr='CR'")
-        self.db.connection.execute("UPDATE dbo.Forderungen_Kopf SET Beleg_offen=-100 WHERE Client_DB='1' AND Beleg_Nr='R1'")
-        negative = build_dashboard(self.db, 'tester', self.now, only_invoices=True)
-        self.assertEqual(negative['count'], 4)
-        self.assertEqual(negative['amount'], '40.00')
+                for item in [group, *group["segments"]]:
+                    raw = parse_qs(urlsplit(item["url"]).query)["dashboard_segment"][0]
+                    self.assertTrue(parse_segment(raw)["only_invoices"])
+                    response = self.client.get(item["url"])
+                    self.assertIn('id="list-count">' + str(item["count"]), response.text)
+                    self.assertIn("Nur Rechnungen", response.text)
+        self.db.connection.execute(
+            "UPDATE dbo.Forderungen_Kopf SET Rechnung_Gutschrift='Rechnungskorrektur' WHERE Beleg_Nr='CR'"
+        )
+        self.db.connection.execute(
+            "UPDATE dbo.Forderungen_Kopf SET Beleg_Offen=-100 WHERE Client_DB='1' AND Beleg_Nr='R1'"
+        )
+        negative = build_dashboard(self.db, "tester", self.now, only_invoices=True)
+        self.assertEqual(negative["count"], 4)
+        self.assertEqual(negative["amount"], "40.00")
 
     def test_invoice_checkbox_url_state_and_strict_segment_flag(self):
-        response = self.client.get('/app/forderungen/dashboard?page=Verursacher&metric=anzahl&only_invoices=true')
+        response = self.client.get("/app/forderungen/dashboard?page=Verursacher&metric=anzahl&only_invoices=true")
         self.assertEqual(response.status_code, 200)
         self.assertIn('name="only_invoices" value="true" checked', response.text)
         self.assertIn('name="page" value="Verursacher"', response.text)
         self.assertIn('name="metric" value="anzahl"', response.text)
-        self.assertIn('240,00', response.text)
-        unchecked = self.client.get('/app/forderungen/dashboard')
+        self.assertIn("240,00", response.text)
+        unchecked = self.client.get("/app/forderungen/dashboard")
         self.assertNotIn('name="only_invoices" value="true" checked', unchecked.text)
-        self.assertIn('200,00', unchecked.text)
+        self.assertIn("200,00", unchecked.text)
         self.assertIn('data-dashboard-view="Standort" data-dashboard-metric=', unchecked.text)
-        for invalid in ['true', 1, None]:
+        for invalid in ["true", 1, None]:
             with self.assertRaises(ValueError):
-                parse_segment(pack_segment({'view':'Altersstaffel','key':None,'only_invoices':invalid}))
+                parse_segment(pack_segment({"view": "Altersstaffel", "key": None, "only_invoices": invalid}))
 
     def test_all_segments_round_trip_to_exact_list_count_and_amount(self):
-        for view, groups in self.data()['views'].items():
+        for view, groups in self.data()["views"].items():
             for group in groups:
-                for segment in group['segments']:
-                    with self.subTest(view=view, label=group['label'], department=segment['department']):
-                        response = self.client.get(segment['url'])
+                for segment in group["segments"]:
+                    with self.subTest(view=view, label=group["label"], department=segment["department"]):
+                        response = self.client.get(segment["url"])
                         self.assertEqual(response.status_code, 200)
                         count = int(re.search(r'id="list-count">(\d+)', response.text)[1])
-                        self.assertEqual(count, segment['count'])
+                        self.assertEqual(count, segment["count"])
                         self.assertIn('name="dashboard_segment"', response.text)
-                        self.assertIn('Dashboardfilter entfernen', response.text)
-                        self.assertIn('Aktive Filter (1)', response.text)
+                        self.assertIn("Dashboardfilter entfernen", response.text)
+                        self.assertIn("Aktive Filter (1)", response.text)
                         from app.jinja_templates import number_format
-                        self.assertIn(number_format(Decimal(segment['amount']))+' €', response.text)
+
+                        self.assertIn(number_format(Decimal(segment["amount"])) + " €", response.text)
 
     def test_follow_up_boundaries_and_null_group(self):
-        cases = [('2026-11-01', 'overdue'), ('2026-11-02', 'soon'), ('2026-11-05 23:30:00', 'soon'),
-                 ('2026-11-06', 'not_due'), ('2026-11-16 23:30:00', 'not_due'), ('2026-11-17', 'deferred')]
+        cases = [
+            ("2026-11-01", "overdue"),
+            ("2026-11-02", "soon"),
+            ("2026-11-05 23:30:00", "soon"),
+            ("2026-11-06", "not_due"),
+            ("2026-11-16 23:30:00", "not_due"),
+            ("2026-11-17", "deferred"),
+        ]
         for value, expected in cases:
             with self.subTest(date=value):
-                self.db.connection.execute("UPDATE dbo.Forderungen_Details SET Wiedervorlage=? WHERE Beleg_Nr='R1'", (value,))
-                data = build_dashboard(self.db, 'tester', self.now, stack='wiedervorlage')
-                group = next(group for group in data['views']['Altersstaffel'] if group['label']=='< 4 Wochen')
-                self.assertEqual(group['segments'][0]['id'], expected)
-                response = self.client.get(group['segments'][0]['url'])
+                self.db.connection.execute(
+                    "UPDATE dbo.Forderungen_Details SET Wiedervorlage=? WHERE Beleg_Nr='R1'", (value,)
+                )
+                data = build_dashboard(self.db, "tester", self.now, stack="wiedervorlage")
+                group = next(group for group in data["views"]["Altersstaffel"] if group["label"] == "< 4 Wochen")
+                self.assertEqual(group["segments"][0]["id"], expected)
+                response = self.client.get(group["segments"][0]["url"])
                 self.assertIn('id="list-count">1', response.text)
-        ids = [item['id'] for item in data['stack_items']]
-        self.assertEqual(ids, ['overdue','soon','not_due','deferred','undated'])
-        self.assertNotIn('Wiedervorlage', data['views'])
+        ids = [item["id"] for item in data["stack_items"]]
+        self.assertEqual(ids, ["overdue", "soon", "not_due", "deferred", "undated"])
+        self.assertNotIn("Wiedervorlage", data["views"])
 
     def test_follow_up_falls_back_to_document_due_date(self):
         self.db.connection.execute("UPDATE dbo.Forderungen_Details SET Wiedervorlage=NULL WHERE Beleg_Nr='R1'")
-        self.db.connection.execute("UPDATE dbo.Forderungen_Kopf SET Beleg_Faellig_Datum='2026-11-17' WHERE Client_DB='1' AND Beleg_Nr='R1'")
-        data = build_dashboard(self.db, 'tester', self.now, stack='wiedervorlage')
-        group = next(group for group in data['views']['Altersstaffel'] if group['label']=='< 4 Wochen')
-        self.assertEqual(group['segments'][0]['id'], 'deferred')
-        self.assertIn('id="list-count">1', self.client.get(group['segments'][0]['url']).text)
+        self.db.connection.execute(
+            "UPDATE dbo.Forderungen_Kopf SET Beleg_Faellig_Datum='2026-11-17' WHERE Client_DB='1' AND Beleg_Nr='R1'"
+        )
+        data = build_dashboard(self.db, "tester", self.now, stack="wiedervorlage")
+        group = next(group for group in data["views"]["Altersstaffel"] if group["label"] == "< 4 Wochen")
+        self.assertEqual(group["segments"][0]["id"], "deferred")
+        self.assertIn('id="list-count">1', self.client.get(group["segments"][0]["url"]).text)
 
     def test_follow_up_year_boundary_and_berlin_calendar_day(self):
         self.db.connection.execute("UPDATE dbo.Forderungen_Details SET Wiedervorlage='2027-01-01' WHERE Beleg_Nr='R1'")
-        now = datetime.fromisoformat('2026-12-28T23:30:00+00:00')
-        groups = build_dashboard(self.db, 'tester', now, stack='wiedervorlage')['views']['Altersstaffel']
-        group = next(group for group in groups if group['label']=='< 4 Wochen')
-        selection = parse_segment(parse_qs(urlsplit(group['segments'][0]['url']).query)['dashboard_segment'][0])
-        self.assertEqual(selection['follow_up'], {'from':'2026-12-29','to':'2027-01-01'})
-        self.assertEqual(group['segments'][0]['id'], 'soon')
+        now = datetime.fromisoformat("2026-12-28T23:30:00+00:00")
+        groups = build_dashboard(self.db, "tester", now, stack="wiedervorlage")["views"]["Altersstaffel"]
+        group = next(group for group in groups if group["label"] == "< 4 Wochen")
+        selection = parse_segment(parse_qs(urlsplit(group["segments"][0]["url"]).query)["dashboard_segment"][0])
+        self.assertEqual(selection["follow_up"], {"from": "2026-12-29", "to": "2027-01-01"})
+        self.assertEqual(group["segments"][0]["id"], "soon")
 
     def test_follow_up_stack_roundtrips_for_all_rubrics_and_invoice_scope(self):
         for only in [False, True]:
-            data = build_dashboard(self.db, 'tester', self.now, only_invoices=only, stack='wiedervorlage')
-            for groups in data['views'].values():
-                self.assertEqual(sum(group['count'] for group in groups), data['count'])
-                self.assertEqual(sum(Decimal(group['amount']) for group in groups), Decimal(data['amount']))
+            data = build_dashboard(self.db, "tester", self.now, only_invoices=only, stack="wiedervorlage")
+            for groups in data["views"].values():
+                self.assertEqual(sum(group["count"] for group in groups), data["count"])
+                self.assertEqual(sum(Decimal(group["amount"]) for group in groups), Decimal(data["amount"]))
                 for group in groups:
-                    for segment in group['segments']:
-                        selection = parse_segment(parse_qs(urlsplit(segment['url']).query)['dashboard_segment'][0])
-                        self.assertIn('follow_up', selection)
-                        self.assertNotIn('department', selection)
-                        response = self.client.get(segment['url'])
+                    for segment in group["segments"]:
+                        selection = parse_segment(parse_qs(urlsplit(segment["url"]).query)["dashboard_segment"][0])
+                        self.assertIn("follow_up", selection)
+                        self.assertNotIn("department", selection)
+                        response = self.client.get(segment["url"])
                         self.assertEqual(response.status_code, 200)
-                        self.assertIn('id="list-count">'+str(segment['count']), response.text)
+                        self.assertIn('id="list-count">' + str(segment["count"]), response.text)
 
     def test_stack_selection_and_legacy_follow_up_links(self):
-        response = self.client.get('/app/forderungen/dashboard?stack=wiedervorlage&metric=anzahl&only_invoices=true')
+        response = self.client.get("/app/forderungen/dashboard?stack=wiedervorlage&metric=anzahl&only_invoices=true")
         self.assertEqual(response.status_code, 200)
         self.assertNotIn('data-dashboard-view="Wiedervorlage"', response.text)
         self.assertIn('value="wiedervorlage" selected', response.text)
         self.assertIn('name="stack" value="wiedervorlage"', response.text)
-        self.assertEqual(self.client.get('/app/forderungen/dashboard?stack=bad').status_code, 422)
-        old = self.client.get('/app/forderungen/dashboard?page=Wiedervorlage&metric=anzahl&only_invoices=true', follow_redirects=False)
+        self.assertEqual(self.client.get("/app/forderungen/dashboard?stack=bad").status_code, 422)
+        old = self.client.get(
+            "/app/forderungen/dashboard?page=Wiedervorlage&metric=anzahl&only_invoices=true", follow_redirects=False
+        )
         self.assertEqual(old.status_code, 303)
-        params = parse_qs(urlsplit(old.headers['location']).query)
-        self.assertEqual(params, {'page':['Standort'], 'metric':['anzahl'], 'stack':['wiedervorlage'], 'only_invoices':['true']})
-        legacy = pack_segment({'view':'Wiedervorlage','key':{'from':'2026-11-02','to':'2026-11-09'}})
-        response = self.client.get('/app/forderungen/liste', params={'dashboard_segment':legacy})
+        params = parse_qs(urlsplit(old.headers["location"]).query)
+        self.assertEqual(
+            params, {"page": ["Standort"], "metric": ["anzahl"], "stack": ["wiedervorlage"], "only_invoices": ["true"]}
+        )
+        legacy = pack_segment({"view": "Wiedervorlage", "key": {"from": "2026-11-02", "to": "2026-11-09"}})
+        response = self.client.get("/app/forderungen/liste", params={"dashboard_segment": legacy})
         self.assertIn('id="list-count">2', response.text)
-        for bad in [True, 'tomorrow', {'from':'2026-11-02','to':'2026-11-01'}]:
+        for bad in [True, "tomorrow", {"from": "2026-11-02", "to": "2026-11-01"}]:
             with self.assertRaises(ValueError):
-                parse_segment(pack_segment({'view':'Altersstaffel','key':None,'follow_up':bad}))
+                parse_segment(pack_segment({"view": "Altersstaffel", "key": None, "follow_up": bad}))
 
     def test_same_names_different_user_ids_and_clients_remain_separate(self):
-        groups = [group for group in self.data()['views']['Verursacher'] if group['label'].startswith('Alice (')]
+        groups = [group for group in self.data()["views"]["Verursacher"] if group["label"].startswith("Alice (")]
         self.assertEqual(len(groups), 2)
-        self.assertNotEqual(groups[0]['label'], groups[1]['label'])
-        self.assertNotEqual(groups[0]['url'], groups[1]['url'])
+        self.assertNotEqual(groups[0]["label"], groups[1]["label"])
+        self.assertNotEqual(groups[0]["url"], groups[1]["url"])
         self.db.connection.execute("INSERT INTO dbo.Benutzer_Rechte VALUES ('tester','2','10','Service')")
-        groups = self.data()['views']['Standort']
+        groups = self.data()["views"]["Standort"]
         self.assertEqual(len(groups), 2)
         for group in groups:
-            self.assertEqual(self.client.get(group['url']).status_code, 200)
-        self.assertEqual({group['label'] for group in groups}, {'ULM (AHR)', 'ULM (AAM)'})
+            self.assertEqual(self.client.get(group["url"]).status_code, 200)
+        self.assertEqual({group["label"] for group in groups}, {"ULM (AHR)", "ULM (AAM)"})
 
     def test_decimal_sum_and_zero_amount_segments_still_have_links(self):
-        self.db.connection.execute("UPDATE dbo.Forderungen_Kopf SET Beleg_offen=0.1 WHERE Client_DB='1' AND Beleg_Nr='R1'")
-        self.db.connection.execute("UPDATE dbo.Forderungen_Kopf SET Beleg_offen=0.2 WHERE Client_DB='1' AND Beleg_Nr='X1'")
+        self.db.connection.execute(
+            "UPDATE dbo.Forderungen_Kopf SET Beleg_Offen=0.1 WHERE Client_DB='1' AND Beleg_Nr='R1'"
+        )
+        self.db.connection.execute(
+            "UPDATE dbo.Forderungen_Kopf SET Beleg_Offen=0.2 WHERE Client_DB='1' AND Beleg_Nr='X1'"
+        )
         data = self.data()
-        self.assertEqual(data['amount'], '10.30')
-        zero = build_dashboard(self.db, 'tester', self.now, stack='wiedervorlage')
-        self.assertTrue(any(segment['amount']=='0.00' and segment['url'] for group in zero['views']['Verursacher'] for segment in group['segments']))
-        self.assertEqual(color('Service'), color('Service'))
+        self.assertEqual(data["amount"], "10.30")
+        zero = build_dashboard(self.db, "tester", self.now, stack="wiedervorlage")
+        self.assertTrue(
+            any(
+                segment["amount"] == "0.00" and segment["url"]
+                for group in zero["views"]["Verursacher"]
+                for segment in group["segments"]
+            )
+        )
+        self.assertEqual(color("Service"), color("Service"))
 
     def test_exact_department_filter_does_not_match_prefixes(self):
         self.db.connection.execute("UPDATE dbo.Forderungen_Kopf SET Abteilung='Service2' WHERE Beleg_Nr='CR'")
         self.db.connection.execute("INSERT INTO dbo.Benutzer_Rechte VALUES ('tester','1','10','Service2')")
-        group = self.data()['views']['Standort'][0]
-        segment = next(segment for segment in group['segments'] if segment['department']=='Service')
-        exact = self.client.get(segment['url'])
-        partial = self.client.get('/app/forderungen/liste?Abteilung=Service')
+        group = self.data()["views"]["Standort"][0]
+        segment = next(segment for segment in group["segments"] if segment["department"] == "Service")
+        exact = self.client.get(segment["url"])
+        partial = self.client.get("/app/forderungen/liste?Abteilung=Service")
         self.assertIn('id="list-count">4', exact.text)
         self.assertIn('id="list-count">5', partial.text)
 
     def test_forged_segment_cannot_expand_invoice_access(self):
-        raw = pack_segment({'view':'Standort','key':{'client':'2','site':'10'}})
-        response = self.client.get('/app/forderungen/liste', params={'dashboard_segment':raw})
+        raw = pack_segment({"view": "Standort", "key": {"client": "2", "site": "10"}})
+        response = self.client.get("/app/forderungen/liste", params={"dashboard_segment": raw})
         self.assertEqual(response.status_code, 200)
         self.assertIn('id="list-count">0', response.text)
-        self.assertIn('0,00 €', response.text)
+        self.assertIn("0,00 €", response.text)
 
     def test_segment_preserved_through_detail_return_login_and_normal_filters(self):
-        url = self.data()['views']['Standort'][0]['url']
-        selection = parse_qs(urlsplit(url).query)['dashboard_segment'][0]
-        returned = list_return_url(url, '1_R1')
-        self.assertEqual(parse_qs(urlsplit(returned).query)['dashboard_segment'], [selection])
-        detail = '/app/forderungen/details/1_R1?' + urlencode({'return_to': returned})
-        recovered = normalize_page_url('/app/forderungen/details/1_R1/kommentare', current_url='https://proxy.test'+detail)
+        url = self.data()["views"]["Standort"][0]["url"]
+        selection = parse_qs(urlsplit(url).query)["dashboard_segment"][0]
+        returned = list_return_url(url, "1_R1")
+        self.assertEqual(parse_qs(urlsplit(returned).query)["dashboard_segment"], [selection])
+        detail = "/app/forderungen/details/1_R1?" + urlencode({"return_to": returned})
+        recovered = normalize_page_url(
+            "/app/forderungen/details/1_R1/kommentare", current_url="https://proxy.test" + detail
+        )
         self.assertEqual(recovered, detail)
-        response = self.client.get(url+'&Kunde=Test&page=1&limit=1')
+        response = self.client.get(url + "&Kunde=Test&page=1&limit=1")
         self.assertEqual(response.status_code, 200)
         self.assertIn('hx-include="#liste-filter"', response.text)
         sql, values = self.db.calls[-1]
-        self.assertIn('F.[Client_DB] = :ds_client', sql)
-        self.assertEqual(values['filter_kunde'], '%Test%')
+        self.assertIn("F.[Client_DB] = :ds_client", sql)
+        self.assertEqual(values["filter_kunde"], "%Test%")
         self.client.cookies.clear()
-        response = self.client.get(url, headers={'HX-Request':'true'})
-        target = parse_qs(urlsplit(response.headers['HX-Redirect']).query)['next'][0]
+        response = self.client.get(url, headers={"HX-Request": "true"})
+        target = parse_qs(urlsplit(response.headers["HX-Redirect"]).query)["next"][0]
         self.assertEqual(target, url)
 
     def test_bad_segments_are_rejected_before_data_query(self):
-        invalid = ['not-json', '{}', '{"view":"Standort","key":{}}', '{"view":"Wiedervorlage","key":{"from":"x","to":null}}',
-                   '{"view":"Altersstaffel","key":null,"sql":"1=1"}', '{"view":"Verursacher","key":{"id":null}}',
-                   '{"view":"Standort","key":{"client":true,"site":"10"}}']
+        invalid = [
+            "not-json",
+            "{}",
+            '{"view":"Standort","key":{}}',
+            '{"view":"Wiedervorlage","key":{"from":"x","to":null}}',
+            '{"view":"Altersstaffel","key":null,"sql":"1=1"}',
+            '{"view":"Verursacher","key":{"id":null}}',
+            '{"view":"Standort","key":{"client":true,"site":"10"}}',
+        ]
         for value in invalid:
             with self.subTest(value=value):
                 self.db.calls.clear()
-                response = self.client.get('/app/forderungen/liste', params={'dashboard_segment':value})
+                response = self.client.get("/app/forderungen/liste", params={"dashboard_segment": value})
                 self.assertEqual(response.status_code, 400)
                 self.assertFalse(self.db.calls)
 
     def test_empty_error_and_invalid_dashboard_states(self):
-        response = self.client.get('/app/forderungen/dashboard?page=Standort&metric=anzahl')
+        response = self.client.get("/app/forderungen/dashboard?page=Standort&metric=anzahl")
         self.assertEqual(response.status_code, 200)
-        self.assertIn('Forderungen | Dashboard', response.text)
+        self.assertIn("Forderungen | Dashboard", response.text)
         self.assertIn('data-dashboard-metric="anzahl"', response.text)
-        self.assertEqual(self.client.get('/app/forderungen/dashboard?page=bad').status_code, 422)
+        self.assertEqual(self.client.get("/app/forderungen/dashboard?page=bad").status_code, 422)
         self.db.connection.execute("DELETE FROM dbo.Benutzer_Rechte WHERE Benutzer_ID='tester'")
         data = self.data()
-        self.assertEqual(data['count'], 0); self.assertEqual(data['amount'], '0.00')
-        with patch('app.forderung_dashboard.read_invoices', side_effect=RuntimeError('offline')), self.assertLogs('app.routes',level='ERROR'):
-            response = self.client.get('/app/forderungen/dashboard')
+        self.assertEqual(data["count"], 0)
+        self.assertEqual(data["amount"], "0.00")
+        with (
+            patch("app.forderung_dashboard.read_invoices", side_effect=RuntimeError("offline")),
+            self.assertLogs("app.routes", level="ERROR"),
+        ):
+            response = self.client.get("/app/forderungen/dashboard")
         self.assertEqual(response.status_code, 503)
-        self.assertIn('nicht geladen', response.text)
-        self.assertNotIn('Anzahl der Belege', response.text)
+        self.assertIn("nicht geladen", response.text)
+        self.assertNotIn("Anzahl der Belege", response.text)
+
 
-if __name__ == '__main__':
+if __name__ == "__main__":
     unittest.main()

+ 133 - 104
tests/test_forderung_history.py

@@ -2,6 +2,7 @@
 
 This does not replace verification against the deployed SQL Server schema.
 """
+
 import re
 import sqlite3
 import unittest
@@ -81,7 +82,7 @@ class FixtureDB:
 def seed(db):
     db.connection.executescript("""
         CREATE TABLE dbo.Forderungen_Kopf (
-            Client_DB TEXT, Beleg_Nr TEXT, Kunde_Nr TEXT, Fahrzeug_Nr TEXT, Beleg_offen REAL,
+            Client_DB TEXT, Beleg_Nr TEXT, Kunde_Nr TEXT, Fahrzeug_Nr TEXT, Beleg_Offen REAL,
             Hauptbetrieb_ID TEXT, Standort_ID TEXT, Abteilung TEXT, Kunde_Text TEXT, Beleg_Datum TEXT
         );
         CREATE TABLE dbo.Forderungen_Historie (Client_DB TEXT, Beleg_Nr TEXT, Kunde_Nr TEXT, Fahrzeug_Nr TEXT);
@@ -101,37 +102,39 @@ def seed(db):
         INSERT INTO dbo.Benutzer_Rechte VALUES ('tester', '1', '10', 'Service');
     """)
     for tenant, number, customer, vehicle, balance, branch in [
-        ('1', 'R1', 'D1', 'V1', 100, '10'),
-        ('1', 'R2', 'D1', 'V2', 20, '20'),
-        ('1', 'R3', 'D1', 'V1', 0, '10'),
-        ('1', 'X1', 'D2', 'V1', 90, '10'),
-        ('2', 'R1', 'D1', 'V1', 100, '10'),
-        ('1', 'NV', 'D3', None, 50, '10'),
+        ("1", "R1", "D1", "V1", 100, "10"),
+        ("1", "R2", "D1", "V2", 20, "20"),
+        ("1", "R3", "D1", "V1", 0, "10"),
+        ("1", "X1", "D2", "V1", 90, "10"),
+        ("2", "R1", "D1", "V1", 100, "10"),
+        ("1", "NV", "D3", None, 50, "10"),
     ]:
         db.connection.execute(
             "INSERT INTO dbo.Forderungen_Kopf VALUES (?, ?, ?, ?, ?, '1', ?, 'Service', 'Test debtor', '2026-01-01')",
             (tenant, number, customer, vehicle, balance, branch),
         )
-    for number, vehicle in [('H1', 'V1'), ('H2', 'V2'), ('R3', 'V1')]:
+    for number, vehicle in [("H1", "V1"), ("H2", "V2"), ("R3", "V1")]:
         db.connection.execute("INSERT INTO dbo.Forderungen_Historie VALUES ('1', ?, 'D1', ?)", (number, vehicle))
     for tenant, customer, vehicle, number, reference in [
-        ('1', 'D1', 'V1', 'R1', 'R1'),
-        ('1', 'D1', None, 'P1', 'R1'),
-        ('1', 'D1', 'V2', 'R2', None),
-        ('1', 'D1', 'V1', 'R3', None),
-        ('1', 'D1', 'V1', 'H1', None),
-        ('1', 'D1', 'V2', 'H2', None),
-        ('1', 'D1', 'V1', 'LOOSE', None),
-        ('1', 'D2', 'V1', 'X1', 'R1'),
-        ('2', 'D1', 'V1', 'R1', 'R1'),
+        ("1", "D1", "V1", "R1", "R1"),
+        ("1", "D1", None, "P1", "R1"),
+        ("1", "D1", "V2", "R2", None),
+        ("1", "D1", "V1", "R3", None),
+        ("1", "D1", "V1", "H1", None),
+        ("1", "D1", "V2", "H2", None),
+        ("1", "D1", "V1", "LOOSE", None),
+        ("1", "D2", "V1", "X1", "R1"),
+        ("2", "D1", "V1", "R1", "R1"),
     ]:
         db.connection.execute(
             "INSERT INTO dbo.Forderungen_Buchungen VALUES (?, ?, ?, ?, ?, '2026-01-02', ?, 10, 'tester', 'N', 'RE')",
-            (tenant, customer, vehicle, number, reference, 'Booking ' + number),
+            (tenant, customer, vehicle, number, reference, "Booking " + number),
+        )
+    for tenant, number in [("1", "R1"), ("1", "R2"), ("1", "R3"), ("1", "H1"), ("1", "H2"), ("1", "X1"), ("2", "R1")]:
+        db.connection.execute(
+            "INSERT INTO dbo.Forderungen_Kommentar VALUES (?, ?, '2026-01-02T12:00:00', 'tester', ?)",
+            (tenant, number, f"Comment {tenant}/{number}"),
         )
-    for tenant, number in [('1', 'R1'), ('1', 'R2'), ('1', 'R3'), ('1', 'H1'), ('1', 'H2'), ('1', 'X1'), ('2', 'R1')]:
-        db.connection.execute("INSERT INTO dbo.Forderungen_Kommentar VALUES (?, ?, '2026-01-02T12:00:00', 'tester', ?)",
-                              (tenant, number, f'Comment {tenant}/{number}'))
     db.connection.commit()
 
 
@@ -154,10 +157,10 @@ class HistoryTests(unittest.TestCase):
         self.application.include_router(router)
         self.application.dependency_overrides[get_session] = lambda: self.db
         self.application.add_middleware(AuthenticationMiddleware)
-        self.entitlements = (Entitlement('1', '10', 'Service', 'Mitarbeiter', 100),)
+        self.entitlements = (Entitlement("1", "10", "Service", "Mitarbeiter", 100),)
         for target, kwargs in [
-            ('app.auth.SessionLocal', {'return_value': self.db}),
-            ('app.auth.load_entitlements', {'return_value': self.entitlements}),
+            ("app.auth.SessionLocal", {"return_value": self.db}),
+            ("app.auth.load_entitlements", {"return_value": self.entitlements}),
         ]:
             mocked = patch(target, **kwargs)
             mocked.start()
@@ -167,151 +170,177 @@ class HistoryTests(unittest.TestCase):
         self.addCleanup(config.stop)
         self.client = TestClient(self.application)
         self.addCleanup(self.client.close)
-        self.token = create_access_token(username='tester', name='Tester', email='', role='Mitarbeiter', auth_source='ldap', csrf='csrf')
+        self.token = create_access_token(
+            username="tester", name="Tester", email="", role="Mitarbeiter", auth_source="ldap", csrf="csrf"
+        )
         self.client.cookies.set(settings.SESSION_COOKIE_NAME, self.token)
-        self.pkey = {'Client_DB': '1', 'Beleg_Nr': 'R1'}
+        self.pkey = {"Client_DB": "1", "Beleg_Nr": "R1"}
 
     def test_all_scopes_apply_to_real_sql_without_duplicate_bookings(self):
         expected_comments = {
-            'invoice': {'R1'}, 'open': {'R1', 'R2'}, 'vehicle': {'R1', 'R3', 'H1'},
-            'debtor': {'R1', 'R2', 'R3', 'H1', 'H2'},
+            "invoice": {"R1"},
+            "open": {"R1", "R2"},
+            "vehicle": {"R1", "R3", "H1"},
+            "debtor": {"R1", "R2", "R3", "H1", "H2"},
         }
         expected_bookings = {
-            'invoice': {'R1', 'P1'}, 'open': {'R1', 'P1', 'R2'},
-            'vehicle': {'R1', 'P1', 'R3', 'H1', 'LOOSE'},
-            'debtor': {'R1', 'P1', 'R2', 'R3', 'H1', 'H2', 'LOOSE'},
+            "invoice": {"R1", "P1"},
+            "open": {"R1", "P1", "R2"},
+            "vehicle": {"R1", "P1", "R3", "H1", "LOOSE"},
+            "debtor": {"R1", "P1", "R2", "R3", "H1", "H2", "LOOSE"},
         }
-        for section, expectations in [('kommentare', expected_comments), ('buchungen', expected_bookings)]:
+        for section, expectations in [("kommentare", expected_comments), ("buchungen", expected_bookings)]:
             for scope, expected in expectations.items():
                 with self.subTest(section=section, scope=scope):
-                    rows = history_context(self.db, self.pkey, section, scope)['forderungen_' + section]
+                    rows = history_context(self.db, self.pkey, section, scope)["forderungen_" + section]
                     self.assertEqual({r.Beleg_Nr for r in rows}, expected)
                     self.assertEqual(len(rows), len(expected))
-                    if section == 'kommentare':
-                        self.assertTrue(all(r.Kommentar.startswith('Comment 1/') for r in rows))
+                    if section == "kommentare":
+                        self.assertTrue(all(r.Kommentar.startswith("Comment 1/") for r in rows))
                     else:
-                        self.assertTrue(all(r.Client_DB == '1' and r.Kunde_Nr == 'D1' for r in rows))
+                        self.assertTrue(all(r.Client_DB == "1" and r.Kunde_Nr == "D1" for r in rows))
 
     def test_routes_render_isolated_fragments_and_default_to_invoice(self):
-        for section, target in [('kommentare', 'comments-history'), ('buchungen', 'bookings-history')]:
-            for scope in ['invoice', 'open', 'vehicle', 'debtor']:
+        for section, target in [("kommentare", "comments-history"), ("buchungen", "bookings-history")]:
+            for scope in ["invoice", "open", "vehicle", "debtor"]:
                 with self.subTest(section=section, scope=scope):
-                    response = self.client.get(f'/app/forderungen/details/1_R1/{section}?scope={scope}')
+                    response = self.client.get(f"/app/forderungen/details/1_R1/{section}?scope={scope}")
                     self.assertEqual(response.status_code, 200)
                     tags = Tags(response.text).tags
-                    self.assertIn(('div', next(a for t, a in tags if a.get('id') == target)), tags)
-                    buttons = [a for t, a in tags if t == 'button']
+                    self.assertIn(("div", next(a for t, a in tags if a.get("id") == target)), tags)
+                    buttons = [a for t, a in tags if t == "button"]
                     self.assertEqual(len(buttons), 4)
-                    self.assertEqual(sum(b['aria-pressed'] == 'true' for b in buttons), 1)
-                    self.assertTrue(all(b['hx-target'] == '#' + target for b in buttons))
-                    selected = next(b for b in buttons if b['aria-pressed'] == 'true')
-                    self.assertTrue(selected['hx-get'].endswith('scope=' + scope))
-                    self.assertNotIn('<html', response.text)
+                    self.assertEqual(sum(b["aria-pressed"] == "true" for b in buttons), 1)
+                    self.assertTrue(all(b["hx-target"] == "#" + target for b in buttons))
+                    selected = next(b for b in buttons if b["aria-pressed"] == "true")
+                    self.assertTrue(selected["hx-get"].endswith("scope=" + scope))
+                    self.assertNotIn("<html", response.text)
                     self.assertNotIn('id="formular"', response.text)
                     self.assertNotIn('name="Kommentar"', response.text)
-            response = self.client.get(f'/app/forderungen/details/1_R1/{section}')
+            response = self.client.get(f"/app/forderungen/details/1_R1/{section}")
             self.assertIn('aria-pressed="true"', response.text)
-            self.assertNotIn('Booking R2' if section == 'buchungen' else 'Comment 1/R2', response.text)
+            self.assertNotIn("Booking R2" if section == "buchungen" else "Comment 1/R2", response.text)
 
     def test_expansion_permits_other_branch_but_direct_access_remains_denied(self):
-        expanded = self.client.get('/app/forderungen/details/1_R1/kommentare?scope=debtor')
-        self.assertIn('Comment 1/R2', expanded.text)
-        for section in ['kommentare', 'buchungen']:
+        expanded = self.client.get("/app/forderungen/details/1_R1/kommentare?scope=debtor")
+        self.assertIn("Comment 1/R2", expanded.text)
+        for section in ["kommentare", "buchungen"]:
             self.db.calls.clear()
-            response = self.client.get(f'/app/forderungen/details/1_R2/{section}?scope=debtor')
+            response = self.client.get(f"/app/forderungen/details/1_R2/{section}?scope=debtor")
             self.assertEqual(response.status_code, 404)
             self.assertEqual(len(self.db.calls), 1)
 
     def test_invalid_scope_does_not_query_history(self):
-        for section in ['kommentare', 'buchungen']:
+        for section in ["kommentare", "buchungen"]:
             self.db.calls.clear()
-            response = self.client.get(f'/app/forderungen/details/1_R1/{section}?scope=invalid')
+            response = self.client.get(f"/app/forderungen/details/1_R1/{section}?scope=invalid")
             self.assertEqual(response.status_code, 422)
             self.assertFalse(self.db.calls)
 
     def test_missing_vehicle_and_empty_results_leave_filters_available(self):
-        for section in ['kommentare', 'buchungen']:
-            for scope in ['invoice', 'vehicle']:
-                response = self.client.get(f'/app/forderungen/details/1_NV/{section}?scope={scope}')
+        for section in ["kommentare", "buchungen"]:
+            for scope in ["invoice", "vehicle"]:
+                response = self.client.get(f"/app/forderungen/details/1_NV/{section}?scope={scope}")
                 self.assertEqual(response.status_code, 200)
-                buttons = [a for t, a in Tags(response.text).tags if t == 'button']
-                vehicle = next(b for b in buttons if b['hx-get'].endswith('scope=vehicle'))
-                self.assertIn('disabled', vehicle)
-                self.assertIn('Keine Kommentare' if section == 'kommentare' else 'Keine Buchungsbelege', response.text)
-                if scope == 'vehicle':
-                    self.assertIn('kein Fahrzeug hinterlegt', response.text)
+                buttons = [a for t, a in Tags(response.text).tags if t == "button"]
+                vehicle = next(b for b in buttons if b["hx-get"].endswith("scope=vehicle"))
+                self.assertIn("disabled", vehicle)
+                self.assertIn("Keine Kommentare" if section == "kommentare" else "Keine Buchungsbelege", response.text)
+                if scope == "vehicle":
+                    self.assertIn("kein Fahrzeug hinterlegt", response.text)
 
     def test_comment_post_preserves_scope_and_immutable_invoice_identity(self):
-        for scope in ['invoice', 'open', 'vehicle', 'debtor']:
-            response = self.client.post('/app/forderungen/details/1_R1', data={
-                'source': 'comments', '_csrf': 'csrf', 'comment_scope': scope,
-                'Kommentar': 'New comment ' + scope, 'Client_DB': '2', 'Beleg_Nr': 'X1', 'Benutzer_ID': 'intruder',
-            })
+        for scope in ["invoice", "open", "vehicle", "debtor"]:
+            response = self.client.post(
+                "/app/forderungen/details/1_R1",
+                data={
+                    "source": "comments",
+                    "_csrf": "csrf",
+                    "comment_scope": scope,
+                    "Kommentar": "New comment " + scope,
+                    "Client_DB": "2",
+                    "Beleg_Nr": "X1",
+                    "Benutzer_ID": "intruder",
+                },
+            )
             self.assertEqual(response.status_code, 200)
-            self.assertIn('New comment ' + scope, response.text)
+            self.assertIn("New comment " + scope, response.text)
             self.assertIn(f'name="comment_scope" value="{scope}"', response.text)
             self.assertNotIn('id="bookings-history"', response.text)
             self.assertNotIn('id="comment-form"', response.text)
-        rows = self.db.connection.execute("SELECT Client_DB, Beleg_Nr, Benutzer_ID FROM dbo.Forderungen_Kommentar WHERE Kommentar LIKE 'New comment%'").fetchall()
-        self.assertEqual(rows, [('1', 'R1', 'tester')] * 4)
+        rows = self.db.connection.execute(
+            "SELECT Client_DB, Beleg_Nr, Benutzer_ID FROM dbo.Forderungen_Kommentar WHERE Kommentar LIKE 'New comment%'"
+        ).fetchall()
+        self.assertEqual(rows, [("1", "R1", "tester")] * 4)
         self.assertEqual(self.db.commits, 4)
 
     def test_post_rejects_invalid_csrf_scope_and_unauthorized_invoice(self):
         for suffix, extra, expected in [
-            ('1_R1', {'_csrf': 'wrong'}, 403),
-            ('1_R1', {'comment_scope': 'invalid'}, 422),
-            ('1_R2', {}, 404),
+            ("1_R1", {"_csrf": "wrong"}, 403),
+            ("1_R1", {"comment_scope": "invalid"}, 422),
+            ("1_R2", {}, 404),
         ]:
-            data = {'source': 'comments', '_csrf': 'csrf', 'Kommentar': 'Rejected'}
+            data = {"source": "comments", "_csrf": "csrf", "Kommentar": "Rejected"}
             data.update(extra)
-            response = self.client.post('/app/forderungen/details/' + suffix, data=data)
+            response = self.client.post("/app/forderungen/details/" + suffix, data=data)
             self.assertEqual(response.status_code, expected)
         self.assertEqual(self.db.commits, 0)
 
     def test_booking_limit_is_enforced_and_disclosed(self):
         self.db.connection.executemany(
             "INSERT INTO dbo.Forderungen_Buchungen VALUES ('1', 'D1', 'V1', ?, 'R1', '2026-02-01', 'Bulk', 1, 'tester', 'N', 'RE')",
-            [(f'B{i:04}',) for i in range(1001)],
+            [(f"B{i:04}",) for i in range(1001)],
         )
-        rows = history_context(self.db, self.pkey, 'buchungen')['forderungen_buchungen']
+        rows = history_context(self.db, self.pkey, "buchungen")["forderungen_buchungen"]
         self.assertEqual(len(rows), 1000)
-        self.assertEqual(rows[0].Beleg_Nr, 'P1')
-        response = self.client.get('/app/forderungen/details/1_R1/buchungen')
-        self.assertIn('ersten 1.000 Buchungsbelege', response.text)
+        self.assertEqual(rows[0].Beleg_Nr, "P1")
+        response = self.client.get("/app/forderungen/details/1_R1/buchungen")
+        self.assertIn("ersten 1.000 Buchungsbelege", response.text)
 
     def test_comment_order_and_escaping(self):
-        self.db.connection.execute("INSERT INTO dbo.Forderungen_Kommentar VALUES ('1', 'R1', '2025-01-01T12:00:00', 'tester', '<script>old</script>')")
-        response = self.client.get('/app/forderungen/details/1_R1/kommentare?scope=debtor')
-        self.assertIn('&lt;script&gt;old&lt;/script&gt;', response.text)
-        self.assertLess(response.text.index('&lt;script&gt;old'), response.text.index('Comment 1/R1'))
-        self.assertIn('Rechnung H1', response.text)
+        self.db.connection.execute(
+            "INSERT INTO dbo.Forderungen_Kommentar VALUES ('1', 'R1', '2025-01-01T12:00:00', 'tester', '<script>old</script>')"
+        )
+        response = self.client.get("/app/forderungen/details/1_R1/kommentare?scope=debtor")
+        self.assertIn("&lt;script&gt;old&lt;/script&gt;", response.text)
+        self.assertLess(response.text.index("&lt;script&gt;old"), response.text.index("Comment 1/R1"))
+        self.assertIn("Rechnung H1", response.text)
 
     def test_full_detail_page_keeps_filters_and_comment_form_when_lists_are_empty(self):
-        context = history_context(self.db, self.pkey, 'kommentare')
-        context.update(history_context(self.db, self.pkey, 'buchungen'))
-        invoice = dict(context['forderungen_details'][0])
+        context = history_context(self.db, self.pkey, "kommentare")
+        context.update(history_context(self.db, self.pkey, "buchungen"))
+        invoice = dict(context["forderungen_details"][0])
         invoice.update(
-            Kunde_offen_gesamt=120, Mwst_Betrag=0, Mwst_Betrag_berechnet=19,
-            Fahrzeug_Erstzulassung=None, Fahrzeug_Kaufdatum=None, Wiedervorlage=date(2026, 11, 1),
+            Kunde_offen_gesamt=120,
+            Mwst_Betrag=0,
+            Mwst_Betrag_berechnet=19,
+            Fahrzeug_Erstzulassung=None,
+            Fahrzeug_Kaufdatum=None,
+            Wiedervorlage=date(2026, 11, 1),
         )
         context.update(
-            forderungen_details=[invoice], forderungen_kommentare=[], forderungen_buchungen=[],
-            files=[], auftraege_positionen=[], versicherungen=[], summary=False,
+            forderungen_details=[invoice],
+            forderungen_kommentare=[],
+            forderungen_buchungen=[],
+            files=[],
+            auftraege_positionen=[],
+            versicherungen=[],
+            summary=False,
         )
-        with patch('app.routes.get_template_context', return_value=context):
-            response = self.client.get('/app/forderungen/details/1_R1')
+        with patch("app.routes.get_template_context", return_value=context):
+            response = self.client.get("/app/forderungen/details/1_R1")
         self.assertEqual(response.status_code, 200)
         tags = Tags(response.text).tags
-        selected = [a for t, a in tags if t == 'button' and a.get('aria-pressed') == 'true']
+        selected = [a for t, a in tags if t == "button" and a.get("aria-pressed") == "true"]
         self.assertEqual(len(selected), 2)
-        self.assertTrue(all(a['hx-get'].endswith('scope=invoice') for a in selected))
-        form = next(a for t, a in tags if a.get('id') == 'comment-form')
-        self.assertEqual(urlsplit(form['hx-post']).path, '/app/forderungen/details/1_R1')
-        self.assertEqual(parse_qs(urlsplit(form['hx-post']).query)['return_to'], ['/app/forderungen/liste?focus=1_R1'])
-        self.assertEqual(form['hx-target'], '#comments-history')
-        self.assertIn('Keine Kommentare', response.text)
-        self.assertIn('Keine Buchungsbelege', response.text)
+        self.assertTrue(all(a["hx-get"].endswith("scope=invoice") for a in selected))
+        form = next(a for t, a in tags if a.get("id") == "comment-form")
+        self.assertEqual(urlsplit(form["hx-post"]).path, "/app/forderungen/details/1_R1")
+        self.assertEqual(parse_qs(urlsplit(form["hx-post"]).query)["return_to"], ["/app/forderungen/liste?focus=1_R1"])
+        self.assertEqual(form["hx-target"], "#comments-history")
+        self.assertIn("Keine Kommentare", response.text)
+        self.assertIn("Keine Buchungsbelege", response.text)
 
 
-if __name__ == '__main__':
+if __name__ == "__main__":
     unittest.main()

+ 100 - 64
tests/test_forderung_list.py

@@ -1,9 +1,10 @@
 """List response contracts with an isolated query result (no SQL Server needed)."""
+
+import unittest
 from collections import namedtuple
 from datetime import date
 from html.parser import HTMLParser
 from types import SimpleNamespace
-import unittest
 
 from fastapi import FastAPI
 from fastapi.testclient import TestClient
@@ -14,19 +15,41 @@ from app.routes import router
 
 def invoice(number=0, **changes):
     data = dict(
-        Client_DB='1', Hauptbetrieb_Name='AHR', Standort_ID='10', Standort_Name='ULM',
-        Abteilung='SC', Verursacher_Text='dki', Rechnung_Gutschrift='Rechnung',
-        Fahrzeug_Nr='V1', Kunde_Text='Musterkunde', Beleg_Nr=f'R{number}',
-        Beleg_Datum=date(2026, 10, 1), Beleg_Faellig_Datum=date(2026, 10, 15),
-        Staffel_Text='> 12 Wochen', Mahnstufe='1', Bearbeitet='N', Abwarten='',
-        Wiedervorlage=date(2026, 11, 1), Wiedervorlage_Stufe='0',
-        Beleg_Betrag=20, Beleg_offen=10, Kunde_offen_gesamt=50, Kunde_gesamt_Bezug='',
-        Besitzer_Kunde_Text='', Kunde_Email='kunde@example.com', Beleg_Tage_offen=28,
-        Kommentar_Fibu='Fibu Kommentar', Kommentar_Abteilung='Abteilung Kommentar',
-        Antrag_Nr='', Antrag_Art='', Antrag_Datum=None, Antrag_Benutzer_ID='', Antrag_Status='',
+        Client_DB="1",
+        Hauptbetrieb_Name="AHR",
+        Standort_ID="10",
+        Standort_Name="ULM",
+        Abteilung="SC",
+        Verursacher_Text="dki",
+        Rechnung_Gutschrift="Rechnung",
+        Fahrzeug_Nr="V1",
+        Kunde_Text="Musterkunde",
+        Beleg_Nr=f"R{number}",
+        Beleg_Datum=date(2026, 10, 1),
+        Beleg_Faellig_Datum=date(2026, 10, 15),
+        Staffel_Text="> 12 Wochen",
+        Mahnstufe="1",
+        Bearbeitet="N",
+        Abwarten="",
+        Wiedervorlage=date(2026, 11, 1),
+        Wiedervorlage_Stufe="0",
+        Beleg_Betrag=20,
+        Beleg_Offen=10,
+        Kunde_offen_gesamt=50,
+        Kunde_gesamt_Bezug="",
+        Besitzer_Kunde_Text="",
+        Kunde_Email="kunde@example.com",
+        Beleg_Tage_offen=28,
+        Kommentar_Fibu="Fibu Kommentar",
+        Kommentar_Abteilung="Abteilung Kommentar",
+        Antrag_Nr="",
+        Antrag_Art="",
+        Antrag_Datum=None,
+        Antrag_Benutzer_ID="",
+        Antrag_Status="",
     )
     data.update(changes)
-    return namedtuple('Invoice', data)(**data)
+    return namedtuple("Invoice", data)(**data)
 
 
 class QueryResult:
@@ -67,115 +90,128 @@ class ForderungListTests(unittest.TestCase):
         self.application.include_router(router)
         self.application.dependency_overrides[get_session] = lambda: self.db
 
-        @self.application.middleware('http')
+        @self.application.middleware("http")
         async def fixture_principal(request, call_next):
-            request.state.principal = SimpleNamespace(username='tester', name='Tester', role='Buchhaltung', csrf='csrf')
+            request.state.principal = SimpleNamespace(username="tester", name="Tester", role="Buchhaltung", csrf="csrf")
             return await call_next(request)
 
         self.client = TestClient(self.application)
         self.addCleanup(self.client.close)
 
     def test_empty_first_page_retains_all_filters_and_zero_summary(self):
-        response = self.client.get('/app/forderungen/liste')
+        response = self.client.get("/app/forderungen/liste")
         self.assertEqual(response.status_code, 200)
-        self.assertIn('Keine Forderungen', response.text)
-        self.assertIn('0,00 €', response.text)
+        self.assertIn("Keine Forderungen", response.text)
+        self.assertIn("0,00 €", response.text)
         tags = Tags(response.text).tags
-        fields = [a for t, a in tags if t in ('input', 'select') and a.get('id')]
+        fields = [a for t, a in tags if t in ("input", "select") and a.get("id")]
         self.assertEqual(len(fields), 16)
         self.assertIn('id="list-count">0', response.text)
 
     def test_empty_results_preserve_selected_filter_and_query_parameters(self):
-        response = self.client.get('/app/forderungen/liste?Filiale=10&Kunde=Nichtgefunden')
+        response = self.client.get("/app/forderungen/liste?Filiale=10&Kunde=Nichtgefunden")
         self.assertEqual(response.status_code, 200)
         self.assertIn('<option value="10" selected>10</option>', response.text)
         self.assertIn('value="Nichtgefunden"', response.text)
         sql, params = self.db.calls[-1]
-        self.assertEqual(params['auth_username'], 'tester')
-        self.assertEqual(params['filter_filiale'], '%10%')
-        self.assertEqual(params['filter_kunde'], '%Nichtgefunden%')
-        self.assertIn('F.[Standort_ID] LIKE :filter_filiale', sql)
+        self.assertEqual(params["auth_username"], "tester")
+        self.assertEqual(params["filter_filiale"], "%10%")
+        self.assertEqual(params["filter_kunde"], "%Nichtgefunden%")
+        self.assertIn("F.[Standort_ID] LIKE :filter_filiale", sql)
 
     def test_summary_uses_all_matches_and_paging_preserves_filter_form(self):
         self.db.rows = [invoice(n) for n in range(103)]
-        response = self.client.get('/app/forderungen/liste?Filiale=10')
+        response = self.client.get("/app/forderungen/liste?Filiale=10")
         self.assertIn('id="list-count">103', response.text)
-        self.assertIn('1.030,00 €', response.text)
+        self.assertIn("1.030,00 €", response.text)
         tags = Tags(response.text).tags
-        rows = [a for t, a in tags if t == 'tr' and a.get('class') == 'gc-invoice-row']
+        rows = [a for t, a in tags if t == "tr" and a.get("class") == "gc-invoice-row"]
         self.assertEqual(len(rows), 100)
-        loader = next(a for t, a in tags if a.get('class') == 'gc-list-load')
-        self.assertEqual(loader['hx-include'], '#liste-filter')
-        self.assertIn('"page": 1', loader['hx-vals'])
+        loader = next(a for t, a in tags if a.get("class") == "gc-list-load")
+        self.assertEqual(loader["hx-include"], "#liste-filter")
+        self.assertIn('"page": 1', loader["hx-vals"])
 
     def test_last_page_is_a_fragment_and_exhausted_page_is_empty_html(self):
         self.db.rows = [invoice(n) for n in range(103)]
-        response = self.client.get('/app/forderungen/liste?page=1&Filiale=10')
+        response = self.client.get("/app/forderungen/liste?page=1&Filiale=10")
         self.assertEqual(response.status_code, 200)
-        self.assertNotIn('<html', response.text)
-        self.assertNotIn('gc-list-load', response.text)
+        self.assertNotIn("<html", response.text)
+        self.assertNotIn("gc-list-load", response.text)
         self.assertEqual(response.text.count('class="gc-invoice-row"'), 3)
-        self.assertIn('R100', response.text)
-        exhausted = self.client.get('/app/forderungen/liste?page=2&Filiale=10')
+        self.assertIn("R100", response.text)
+        exhausted = self.client.get("/app/forderungen/liste?page=2&Filiale=10")
         self.assertEqual(exhausted.status_code, 200)
-        self.assertEqual(exhausted.text, '')
-        self.assertTrue(exhausted.headers['content-type'].startswith('text/html'))
+        self.assertEqual(exhausted.text, "")
+        self.assertTrue(exhausted.headers["content-type"].startswith("text/html"))
 
     def test_comments_are_escaped_and_unique_across_tenants(self):
-        self.db.rows = [invoice(1, Kommentar_Fibu='<script>unsafe</script>'), invoice(1, Client_DB='2')]
-        response = self.client.get('/app/forderungen/liste')
-        self.assertIn('&lt;script&gt;unsafe&lt;/script&gt;', response.text)
+        self.db.rows = [invoice(1, Kommentar_Fibu="<script>unsafe</script>"), invoice(1, Client_DB="2")]
+        response = self.client.get("/app/forderungen/liste")
+        self.assertIn("&lt;script&gt;unsafe&lt;/script&gt;", response.text)
         tags = Tags(response.text).tags
-        controls = [a['aria-controls'] for t, a in tags if 'data-gc-comments' in a]
+        controls = [a["aria-controls"] for t, a in tags if "data-gc-comments" in a]
         self.assertEqual(len(set(controls)), 2)
         for target in controls:
-            self.assertTrue(any(t == 'tr' and a.get('id') == target and 'hidden' in a for t, a in tags))
+            self.assertTrue(any(t == "tr" and a.get("id") == target and "hidden" in a for t, a in tags))
 
     def test_application_information_precedes_department_comment(self):
-        self.db.rows = [invoice(1, Antrag_Nr='A42', Antrag_Art='Antrag', Antrag_Datum=date(2026, 10, 1),
-                                Antrag_Benutzer_ID='dki', Antrag_Status='Offen'),
-                        invoice(2, Kommentar_Fibu='', Kommentar_Abteilung='')]
-        response = self.client.get('/app/forderungen/liste')
-        self.assertIn('Antrag A42', response.text)
-        self.assertNotIn('Abteilung Kommentar', response.text)
-        self.assertEqual(response.text.count('data-gc-comments'), 1)
+        self.db.rows = [
+            invoice(
+                1,
+                Antrag_Nr="A42",
+                Antrag_Art="Antrag",
+                Antrag_Datum=date(2026, 10, 1),
+                Antrag_Benutzer_ID="dki",
+                Antrag_Status="Offen",
+            ),
+            invoice(2, Kommentar_Fibu="", Kommentar_Abteilung=""),
+        ]
+        response = self.client.get("/app/forderungen/liste")
+        self.assertIn("Antrag A42", response.text)
+        self.assertNotIn("Abteilung Kommentar", response.text)
+        self.assertEqual(response.text.count("data-gc-comments"), 1)
 
     def test_return_focus_loads_through_target_and_continues_at_next_block(self):
         self.db.rows = [invoice(n) for n in range(305)]
-        response = self.client.get('/app/forderungen/liste?Filiale=10&focus=1_R220')
+        response = self.client.get("/app/forderungen/liste?Filiale=10&focus=1_R220")
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.text.count('class="gc-invoice-row"'), 300)
         self.assertIn('data-primary-key="1_R220"', response.text)
         self.assertIn('"page": 3', response.text)
         self.assertIn('id="list-count">305', response.text)
-        self.assertEqual(response.headers['cache-control'], 'no-store')
+        self.assertEqual(response.headers["cache-control"], "no-store")
         sql, parameters = self.db.calls[-1]
-        self.assertNotIn('focus', parameters)
-        self.assertNotIn('focus', sql.lower())
+        self.assertNotIn("focus", parameters)
+        self.assertNotIn("focus", sql.lower())
 
     def test_focus_is_found_at_new_position_and_within_exact_tenant(self):
-        self.db.rows = [invoice(220, Client_DB='2')] + [invoice(n) for n in range(150)] + [invoice(220)]
-        response = self.client.get('/app/forderungen/liste?Filiale=10&focus=1_R220')
+        self.db.rows = [invoice(220, Client_DB="2")] + [invoice(n) for n in range(150)] + [invoice(220)]
+        response = self.client.get("/app/forderungen/liste?Filiale=10&focus=1_R220")
         self.assertEqual(response.text.count('class="gc-invoice-row"'), 152)
-        self.assertNotIn('gc-return-status', response.text)
+        self.assertNotIn("gc-return-status", response.text)
         self.assertIn('data-focus="1_R220"', response.text)
 
     def test_missing_focus_preserves_filters_and_explains_missing_row(self):
-        response = self.client.get('/app/forderungen/liste?Filiale=10&focus=1_R404')
-        self.assertIn('nicht mehr enthalten', response.text)
+        response = self.client.get("/app/forderungen/liste?Filiale=10&focus=1_R404")
+        self.assertIn("nicht mehr enthalten", response.text)
         self.assertIn('<option value="10" selected>10</option>', response.text)
-        self.assertIn('Keine Forderungen', response.text)
+        self.assertIn("Keine Forderungen", response.text)
 
     def test_detail_links_keep_filters_including_on_loaded_fragments(self):
         from urllib.parse import parse_qs, urlsplit
+
         self.db.rows = [invoice(n) for n in range(103)]
-        for url, key in [('/app/forderungen/liste?Filiale=10&Kunde=Muster', '1_R0'),
-                         ('/app/forderungen/liste?page=1&Filiale=10&Kunde=Muster', '1_R100')]:
+        for url, key in [
+            ("/app/forderungen/liste?Filiale=10&Kunde=Muster", "1_R0"),
+            ("/app/forderungen/liste?page=1&Filiale=10&Kunde=Muster", "1_R100"),
+        ]:
             response = self.client.get(url)
-            link = next(a for t, a in Tags(response.text).tags if 'data-gc-detail-link' in a)
-            return_to = parse_qs(urlsplit(link['href']).query)['return_to'][0]
-            self.assertEqual(parse_qs(urlsplit(return_to).query), {'Filiale': ['10'], 'Kunde': ['Muster'], 'focus': [key]})
+            link = next(a for t, a in Tags(response.text).tags if "data-gc-detail-link" in a)
+            return_to = parse_qs(urlsplit(link["href"]).query)["return_to"][0]
+            self.assertEqual(
+                parse_qs(urlsplit(return_to).query), {"Filiale": ["10"], "Kunde": ["Muster"], "focus": [key]}
+            )
 
 
-if __name__ == '__main__':
+if __name__ == "__main__":
     unittest.main()

+ 3 - 5
tests/test_forderung_notice.py

@@ -34,7 +34,7 @@ class ForderungNoticeTests(unittest.TestCase):
             "Beleg_Datum": date(2024, 5, 13),
             "Kunde_Text": "Beispielkunde <script>alert(1)</script>",
             "Beleg_Betrag": Decimal("1234.50"),
-            "Beleg_offen": Decimal("250.00"),
+            "Beleg_Offen": Decimal("250.00"),
         }
 
     def result(self, row=None, access=False):
@@ -44,9 +44,7 @@ class ForderungNoticeTests(unittest.TestCase):
         return result
 
     def get_notice(self, htmx=False):
-        return self.client.get(
-            "/app/forderungen/details/1_WGG24501320", headers={"HX-Request": "true"} if htmx else {}
-        )
+        return self.client.get("/app/forderungen/details/1_WGG24501320", headers={"HX-Request": "true"} if htmx else {})
 
     def assert_notice(self, response):
         self.assertEqual(response.status_code, 200)
@@ -85,7 +83,7 @@ class ForderungNoticeTests(unittest.TestCase):
         self.assertIn("Eintrag nicht gefunden.", response.text)
 
     def test_null_fields_and_zero_amount_render_correctly(self):
-        row = dict(self.current, Beleg_Datum=None, Kunde_Text=None, Beleg_Betrag=None, Beleg_offen=Decimal("0"))
+        row = dict(self.current, Beleg_Datum=None, Kunde_Text=None, Beleg_Betrag=None, Beleg_Offen=Decimal("0"))
         self.db.execute.side_effect = [self.result(), self.result(row)]
         response = self.get_notice()
         self.assert_notice(response)

+ 71 - 0
tests/test_logging.py

@@ -7,6 +7,7 @@ import io
 import json
 import logging
 import multiprocessing
+import pyodbc
 from pathlib import Path
 import sqlite3
 import tempfile
@@ -20,6 +21,7 @@ from fastapi.responses import HTMLResponse, StreamingResponse
 from fastapi.staticfiles import StaticFiles
 from fastapi.testclient import TestClient
 from sqlalchemy import Column, Integer, create_engine, text
+from sqlalchemy.exc import DBAPIError
 from sqlalchemy.orm import declarative_base
 from sqlalchemy.pool import StaticPool
 
@@ -150,6 +152,75 @@ class LoggingTests(unittest.TestCase):
         self.assertEqual(self.records()[-1]['operation'], 'fetch')
         self.assertNotIn(SECRET, json.dumps(self.records())+self.console.getvalue())
 
+    def test_sql_server_diagnostics_include_codes_and_redacted_descriptions(self):
+        driver=pyodbc.ProgrammingError('42S22', "[42S22] [Microsoft][ODBC Driver 18 for SQL Server][SQL Server]Invalid column name 'PrivateColumn'. (207) (SQLExecDirectW); [42000] [Microsoft][ODBC Driver 18 for SQL Server][SQL Server]Statement(s) could not be prepared. (8180)")
+        error=DBAPIError("SELECT :value, 'inline-private' FROM sample", {'value':SECRET}, driver)
+        setup.log_query_error(error,error.statement,'test.sql_server')
+        logging.getLogger('app.service').error('Handled',exc_info=(type(error),error,None))
+        rows=self.records()
+        self.assertEqual(len(rows),1)
+        self.assertEqual(rows[0]['db_code'],207)
+        self.assertEqual(rows[0]['sqlstate'],'42S22')
+        self.assertIn('Invalid column name',rows[0]['db_message'])
+        self.assertIn("'PrivateColumn'",rows[0]['db_message'])
+        self.assertIn("'PrivateColumn'",self.console.getvalue())
+        self.assertEqual([item['code'] for item in rows[0]['db_diagnostics']],[207,8180])
+        self.assertEqual(rows[0]['db_diagnostics'][1]['sqlstate'],'42000')
+        self.assertIn('could not be prepared',self.console.getvalue())
+        for secret in [SECRET,'inline-private','[parameters:']:
+            self.assertNotIn(secret,json.dumps(rows)+self.console.getvalue())
+
+    def test_invalid_column_names_are_visible_only_in_canonical_error_207(self):
+        with patch.object(settings,'LOG_SQL_STATEMENT',False):
+            for description in ["Ungültiger Spaltenname 'Kunde_Größe_2'.", "Invalid column name 'Customer''s (2026)'.", 'Ungültiger Spaltenname "Kunde_Gesamt_Offen".', 'Ungültiger Spaltenname „Kunde_Gesamt_Offen“.']:
+                driver=pyodbc.ProgrammingError('42S22','[Microsoft][ODBC Driver 18 for SQL Server][SQL Server]'+description+' (207) (SQLExecDirectW)')
+                setup.log_query_error(driver,parameters={'value':SECRET})
+                row=self.records()[-1]
+                self.assertEqual(row['db_message'],description)
+                self.assertEqual(row['db_diagnostics'][0]['message'],description)
+                self.assertNotIn('sql',row)
+        for code in [207,245]:
+            description=f"Invalid column name 'PrivateColumn'. Extra value '{SECRET}'. ({code}) (SQLExecDirectW)"
+            setup.log_query_error(pyodbc.ProgrammingError('42S22',description))
+            self.assertNotIn('PrivateColumn',self.records()[-1]['db_message'])
+        self.assertNotIn(SECRET,json.dumps(self.records())+self.console.getvalue())
+
+    def test_german_binding_errors_keep_each_invalid_column_name(self):
+        raw=('\r\n[42S22] [Microsoft][ODBC Driver 18 for SQL Server][SQL Server]Ungültiger Spaltenname "Spalte_A". (207) (SQLExecDirectW);\r\n'
+             '[42S22] [Microsoft][ODBC Driver 18 for SQL Server][SQL Server]Ungültiger Spaltenname "Spalte_B". (207); '
+             '[42S22] [Microsoft][ODBC Driver 18 for SQL Server][SQL Server]Die Sicht oder Funktion "dbo.PrivateView" konnte aufgrund von Bindungsfehlern nicht verwendet werden. (4413); '
+             '[42S22] [Microsoft][ODBC Driver 18 for SQL Server][SQL Server]Anweisung(en) konnte(n) nicht vorbereitet werden. (8180)')
+        setup.log_query_error(pyodbc.ProgrammingError('42S22',raw),parameters={'token':SECRET})
+        row=self.records()[-1]
+        self.assertIn('"Spalte_A"',row['db_message'])
+        self.assertIn('"Spalte_B"',row['db_diagnostics'][1]['message'])
+        self.assertEqual([item['code'] for item in row['db_diagnostics']],[207,207,4413,8180])
+        self.assertIn('Spalte_A',self.console.getvalue())
+        self.assertIn('Spalte_B',self.console.getvalue())
+        self.assertNotIn('PrivateView',json.dumps(row)+self.console.getvalue())
+        self.assertNotIn(SECRET,json.dumps(row)+self.console.getvalue())
+
+    def test_sql_server_diagnostics_redact_echoed_values_and_work_without_sql_logging(self):
+        descriptions=[
+            ('23000',f"Violation of UNIQUE KEY constraint 'key'. The duplicate key value is ({SECRET}, 12345). (2627) (SQLExecDirectW)",2627),
+            ('22018',f"Conversion failed when converting the nvarchar value '{SECRET}' to data type int. (245) (SQLExecDirectW)",245),
+            ('42000',f"Custom validation failed: {SECRET}; inline-private; password=credential-private (50001) (SQLExecDirectW)",50001),
+            ('HYT00',"Login timeout expired (0) (SQLDriverConnect)",0),
+        ]
+        with patch.object(settings,'LOG_SQL_STATEMENT',False):
+            for state,description,code in descriptions:
+                driver=pyodbc.Error(state,'[Microsoft][ODBC Driver 18 for SQL Server][SQL Server]'+description)
+                error=DBAPIError("SELECT 'inline-private'",{'value':SECRET},driver)
+                setup.log_query_error(error,error.statement,'test.redaction')
+                self.assertEqual(self.records()[-1]['db_code'],code)
+                self.assertNotIn('sql',self.records()[-1])
+        combined=json.dumps(self.records())+self.console.getvalue()
+        for secret in [SECRET,'12345','inline-private','credential-private']:
+            self.assertNotIn(secret,combined)
+        self.assertIn('UNIQUE KEY',combined)
+        self.assertIn('Conversion failed',combined)
+        self.assertIn('Login timeout expired',combined)
+
     def test_commit_rollback_and_flush_errors_are_captured(self):
         class Connection(sqlite3.Connection):
             fail_commit=False; fail_rollback=False

+ 182 - 84
tests/test_navigation.py

@@ -1,48 +1,60 @@
-import tests  # Initialize temporary logging before importing the application.
 import unittest
-from unittest.mock import patch, MagicMock
+from datetime import date
+from types import SimpleNamespace
+from unittest.mock import MagicMock, patch
 from urllib.parse import parse_qs, urlencode, urlsplit
 
 from fastapi import FastAPI
 from fastapi.testclient import TestClient
-from types import SimpleNamespace
-from datetime import date
-
-from app.db import get_session
-from app.routes import router
 
+import tests  # Initialize temporary logging before importing the application.
 from app.config import settings
+from app.db import get_session
 from app.ldap import DirectoryUser
 from app.main import app
 from app.navigation import LIST_PATH, list_return_url, normalize_page_url
 from app.permissions import Entitlement
+from app.routes import router
 
 
 class NavigationUrlTests(unittest.TestCase):
     def test_return_url_preserves_filters_and_replaces_transport_and_focus(self):
-        value = LIST_PATH + '?Filiale=10&Kunde=A%26B&page=3&limit=100&scope=debtor&focus=2_OTHER'
-        result = list_return_url(value, '1_R1')
-        self.assertEqual(parse_qs(urlsplit(result).query), {'Filiale': ['10'], 'Kunde': ['A&B'], 'focus': ['1_R1']})
+        value = LIST_PATH + "?Filiale=10&Kunde=A%26B&page=3&limit=100&scope=debtor&focus=2_OTHER"
+        result = list_return_url(value, "1_R1")
+        self.assertEqual(parse_qs(urlsplit(result).query), {"Filiale": ["10"], "Kunde": ["A&B"], "focus": ["1_R1"]})
 
     def test_invalid_return_destinations_fall_back_to_filtered_page_type(self):
-        for value in ['https://evil.test/', '//evil.test', '/\\evil', '/%2f%2fevil', '/select', '/app/forderungen/liste\n']:
+        for value in [
+            "https://evil.test/",
+            "//evil.test",
+            "/\\evil",
+            "/%2f%2fevil",
+            "/select",
+            "/app/forderungen/liste\n",
+        ]:
             with self.subTest(value=value):
-                self.assertEqual(list_return_url(value, '1_R1'), LIST_PATH + '?focus=1_R1')
+                self.assertEqual(list_return_url(value, "1_R1"), LIST_PATH + "?focus=1_R1")
 
     def test_fragment_recovery_uses_only_matching_invoice_context(self):
-        return_to = LIST_PATH + '?Filiale=10&focus=1_R1'
-        current = 'https://external-proxy.test/app/forderungen/details/1_R1?' + urlencode({'return_to': return_to})
-        for fragment in ['kommentare', 'buchungen']:
-            result = normalize_page_url('/app/forderungen/details/1_R1/' + fragment + '?scope=debtor', current_url=current)
-            self.assertEqual(result, '/app/forderungen/details/1_R1?' + urlencode({'return_to': return_to}))
-        self.assertEqual(normalize_page_url('/app/forderungen/details/2_R1/kommentare', current_url=current),
-                         '/app/forderungen/details/2_R1')
+        return_to = LIST_PATH + "?Filiale=10&focus=1_R1"
+        current = "https://external-proxy.test/app/forderungen/details/1_R1?" + urlencode({"return_to": return_to})
+        for fragment in ["kommentare", "buchungen"]:
+            result = normalize_page_url(
+                "/app/forderungen/details/1_R1/" + fragment + "?scope=debtor", current_url=current
+            )
+            self.assertEqual(result, "/app/forderungen/details/1_R1?" + urlencode({"return_to": return_to}))
+        self.assertEqual(
+            normalize_page_url("/app/forderungen/details/2_R1/kommentare", current_url=current),
+            "/app/forderungen/details/2_R1",
+        )
 
     def test_external_current_url_never_becomes_redirect_origin(self):
-        result = normalize_page_url('/app/forderungen/details/1_R1/buchungen',
-                                    current_url='https://evil.test/app/forderungen/details/1_R1?return_to=https://evil.test')
-        self.assertTrue(result.startswith('/app/forderungen/details/1_R1?'))
-        self.assertNotIn('evil', result)
+        result = normalize_page_url(
+            "/app/forderungen/details/1_R1/buchungen",
+            current_url="https://evil.test/app/forderungen/details/1_R1?return_to=https://evil.test",
+        )
+        self.assertTrue(result.startswith("/app/forderungen/details/1_R1?"))
+        self.assertNotIn("evil", result)
 
 
 class LoginRecoveryTests(unittest.TestCase):
@@ -50,7 +62,7 @@ class LoginRecoveryTests(unittest.TestCase):
         config = patch.multiple(settings, DEBUG_AUTH=False, TRUSTED_PROXY_IPS=[], COOKIE_SECURE=False)
         config.start()
         self.addCleanup(config.stop)
-        session = patch('app.auth.SessionLocal', return_value=MagicMock())
+        session = patch("app.auth.SessionLocal", return_value=MagicMock())
         session.start()
         self.addCleanup(session.stop)
         self.client = TestClient(app)
@@ -58,46 +70,63 @@ class LoginRecoveryTests(unittest.TestCase):
 
     def login_target(self, response):
         self.assertEqual(response.status_code, 200)
-        login = response.headers['HX-Redirect']
-        self.assertTrue(login.startswith('/login?next='))
-        return parse_qs(urlsplit(login).query)['next'][0]
+        login = response.headers["HX-Redirect"]
+        self.assertTrue(login.startswith("/login?next="))
+        return parse_qs(urlsplit(login).query)["next"][0]
 
     def test_expired_list_paging_resumes_full_filtered_page(self):
-        response = self.client.get(LIST_PATH + '?page=3&limit=100&Filiale=10', headers={
-            'HX-Request': 'true', 'HX-Current-URL': 'http://testserver' + LIST_PATH + '?Filiale=10&focus=1_R120',
-        }, follow_redirects=False)
-        self.assertEqual(self.login_target(response), LIST_PATH + '?Filiale=10&focus=1_R120')
+        response = self.client.get(
+            LIST_PATH + "?page=3&limit=100&Filiale=10",
+            headers={
+                "HX-Request": "true",
+                "HX-Current-URL": "http://testserver" + LIST_PATH + "?Filiale=10&focus=1_R120",
+            },
+            follow_redirects=False,
+        )
+        self.assertEqual(self.login_target(response), LIST_PATH + "?Filiale=10&focus=1_R120")
 
     def test_fragment_and_save_expiry_resume_detail_without_replaying_post(self):
-        return_to = LIST_PATH + '?Filiale=10&Kunde=Muster&focus=1_R1'
-        current = 'http://testserver/app/forderungen/details/1_R1?' + urlencode({'return_to': return_to})
-        headers = {'HX-Request': 'true', 'HX-Current-URL': current}
-        for suffix in ['/kommentare?scope=debtor', '/buchungen?scope=open', '']:
+        return_to = LIST_PATH + "?Filiale=10&Kunde=Muster&focus=1_R1"
+        current = "http://testserver/app/forderungen/details/1_R1?" + urlencode({"return_to": return_to})
+        headers = {"HX-Request": "true", "HX-Current-URL": current}
+        for suffix in ["/kommentare?scope=debtor", "/buchungen?scope=open", ""]:
             with self.subTest(suffix=suffix):
                 if suffix:
-                    response = self.client.get('/app/forderungen/details/1_R1' + suffix, headers=headers)
+                    response = self.client.get("/app/forderungen/details/1_R1" + suffix, headers=headers)
                 else:
-                    response = self.client.post('/app/forderungen/details/1_R1', headers=headers,
-                                                data={'source': 'details', 'return_to': return_to})
-                self.assertEqual(self.login_target(response), current.removeprefix('http://testserver'))
+                    response = self.client.post(
+                        "/app/forderungen/details/1_R1",
+                        headers=headers,
+                        data={"source": "details", "return_to": return_to},
+                    )
+                self.assertEqual(self.login_target(response), current.removeprefix("http://testserver"))
 
     def test_login_post_normalizes_fragment_next_to_full_get(self):
-        self.client.get('/login')
+        self.client.get("/login")
         csrf = self.client.cookies[settings.LOGIN_CSRF_COOKIE_NAME]
-        user = DirectoryUser('tester', 'CN=tester', 'Tester', '')
-        rights = (Entitlement('1', '10', 'SC', 'Mitarbeiter', 100),)
-        with patch('app.routes.ldap_authenticate', return_value=user), patch('app.routes.load_entitlements', return_value=rights):
-            response = self.client.post('/login', data={
-                'username': 'tester', 'password': 'test', '_csrf': csrf,
-                'next': '/app/forderungen/details/1_R1/kommentare?scope=debtor',
-            }, follow_redirects=False)
+        user = DirectoryUser("tester", "CN=tester", "Tester", "")
+        rights = (Entitlement("1", "10", "SC", "Mitarbeiter", 100),)
+        with (
+            patch("app.routes.ldap_authenticate", return_value=user),
+            patch("app.routes.load_entitlements", return_value=rights),
+        ):
+            response = self.client.post(
+                "/login",
+                data={
+                    "username": "tester",
+                    "password": "test",
+                    "_csrf": csrf,
+                    "next": "/app/forderungen/details/1_R1/kommentare?scope=debtor",
+                },
+                follow_redirects=False,
+            )
         self.assertEqual(response.status_code, 303)
-        self.assertEqual(response.headers['location'], '/app/forderungen/details/1_R1')
+        self.assertEqual(response.headers["location"], "/app/forderungen/details/1_R1")
 
     def test_native_page_request_uses_full_page_redirect(self):
-        response = self.client.get(LIST_PATH + '?Filiale=10', follow_redirects=False)
+        response = self.client.get(LIST_PATH + "?Filiale=10", follow_redirects=False)
         self.assertEqual(response.status_code, 303)
-        self.assertEqual(parse_qs(urlsplit(response.headers['location']).query)['next'], [LIST_PATH + '?Filiale=10'])
+        self.assertEqual(parse_qs(urlsplit(response.headers["location"]).query)["next"], [LIST_PATH + "?Filiale=10"])
 
 
 class DetailReturnTests(unittest.TestCase):
@@ -108,58 +137,127 @@ class DetailReturnTests(unittest.TestCase):
         application.include_router(router)
         application.dependency_overrides[get_session] = lambda: self.db
 
-        @application.middleware('http')
+        @application.middleware("http")
         async def identity(request, call_next):
-            request.state.principal = SimpleNamespace(username='tester', name='Tester', role='Mitarbeiter', csrf='csrf')
+            request.state.principal = SimpleNamespace(username="tester", name="Tester", role="Mitarbeiter", csrf="csrf")
             return await call_next(request)
 
         self.client = TestClient(application, raise_server_exceptions=False)
         self.addCleanup(self.client.close)
-        self.row = dict(Client_DB='1', Beleg_Nr='R1', Beleg_Text='Rechnung R1', Beleg_offen=10,
-                        Kunde_offen_gesamt=20, Beleg_Datum=date(2026, 10, 1),
-                        Fahrzeug_Erstzulassung=None, Fahrzeug_Kaufdatum=None, Wiedervorlage=date(2026, 11, 1),
-                        Mwst_Betrag=0, Mwst_Betrag_berechnet=1.9)
-        context = dict(forderungen_details=[self.row], forderungen_kommentare=[], forderungen_buchungen=[],
-                       auftraege_positionen=[], versicherungen=[], files=[], detail_url='/app/forderungen/details/1_R1',
-                       kommentare_scope='invoice', buchungen_scope='invoice', vehicle_missing=False, summary=False)
-        fixture = patch('app.routes.get_template_context', side_effect=lambda *args: dict(context))
+        self.row = dict(
+            Client_DB="1",
+            Beleg_Nr="R1",
+            Beleg_Text="Rechnung R1",
+            Beleg_Offen=10,
+            Kunde_offen_gesamt=20,
+            Beleg_Datum=date(2026, 10, 1),
+            Fahrzeug_Erstzulassung=None,
+            Fahrzeug_Kaufdatum=None,
+            Wiedervorlage=date(2026, 11, 1),
+            Mwst_Betrag=0,
+            Mwst_Betrag_berechnet=1.9,
+        )
+        context = dict(
+            forderungen_details=[self.row],
+            forderungen_kommentare=[],
+            forderungen_buchungen=[],
+            auftraege_positionen=[],
+            versicherungen=[],
+            files=[],
+            detail_url="/app/forderungen/details/1_R1",
+            kommentare_scope="invoice",
+            buchungen_scope="invoice",
+            vehicle_missing=False,
+            summary=False,
+        )
+        fixture = patch("app.routes.get_template_context", side_effect=lambda *args: dict(context))
         fixture.start()
         self.addCleanup(fixture.stop)
-        self.return_url = LIST_PATH + '?Filiale=10&Kunde=Muster&focus=1_R1'
+        self.return_url = LIST_PATH + "?Filiale=10&Kunde=Muster&focus=1_R1"
+
+    def test_detail_customer_total_uses_database_column_alias(self):
+        from contextlib import closing
+        import sqlite3
+        from app.jinja_templates import templates
+        sql=templates.env.get_template('forderungen/details/queries/forderungen_details.sql').render()
+        projection=next(line.strip().removeprefix(',').strip() for line in sql.splitlines() if 'AS [Kunde_offen_gesamt]' in line)
+        with closing(sqlite3.connect(':memory:')) as connection:
+            result=connection.execute('SELECT '+projection+' FROM (SELECT 12345.67 AS [Kunde_Gesamt_Offen]) AS [F]')
+            self.row.pop('Kunde_offen_gesamt')
+            self.row.update(dict(zip([column[0] for column in result.description],result.fetchone())))
+        response=self.client.get('/app/forderungen/details/1_R1')
+        self.assertEqual(response.status_code,200)
+        self.assertIn('12.345,67',response.text)
+
+    def test_detail_displays_missing_amounts_without_claiming_zero(self):
+        self.row.update(Beleg_Offen=None,Kunde_offen_gesamt=None,Mwst_Betrag=None,Mwst_Betrag_berechnet=None)
+        response=self.client.get('/app/forderungen/details/1_R1')
+        self.assertEqual(response.status_code,200)
+        self.assertIn('—',response.text)
 
     def test_detail_and_saved_form_keep_explicit_return_context(self):
-        response = self.client.get('/app/forderungen/details/1_R1?' + urlencode({'return_to': self.return_url}))
+        response = self.client.get("/app/forderungen/details/1_R1?" + urlencode({"return_to": self.return_url}))
         self.assertEqual(response.status_code, 200)
-        self.assertIn('Filiale=10&amp;Kunde=Muster&amp;focus=1_R1', response.text)
-        response = self.client.post('/app/forderungen/details/1_R1', data={
-            'source': 'details', 'return_to': self.return_url,
-        }, headers={'HX-Request': 'true', 'Referer': 'http://testserver/app/forderungen/details/1_R1'})
+        self.assertIn("Filiale=10&amp;Kunde=Muster&amp;focus=1_R1", response.text)
+        response = self.client.post(
+            "/app/forderungen/details/1_R1",
+            data={
+                "source": "details",
+                "return_to": self.return_url,
+            },
+            headers={"HX-Request": "true", "Referer": "http://testserver/app/forderungen/details/1_R1"},
+        )
         self.assertEqual(response.status_code, 200)
-        self.assertIn('name="return_to" value="/app/forderungen/liste?Filiale=10&amp;Kunde=Muster&amp;focus=1_R1"', response.text)
+        self.assertIn(
+            'name="return_to" value="/app/forderungen/liste?Filiale=10&amp;Kunde=Muster&amp;focus=1_R1"', response.text
+        )
         self.assertEqual(self.db.commit.call_count, 1)
 
     def test_save_and_back_redirects_after_commit_to_full_list(self):
-        response = self.client.post('/app/forderungen/details/1_R1', data={
-            'source': 'details', 'return_to': self.return_url, 'redirect': 'list',
-        }, headers={'HX-Request': 'true'})
+        response = self.client.post(
+            "/app/forderungen/details/1_R1",
+            data={
+                "source": "details",
+                "return_to": self.return_url,
+                "redirect": "list",
+            },
+            headers={"HX-Request": "true"},
+        )
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.headers['HX-Redirect'], self.return_url)
+        self.assertEqual(response.headers["HX-Redirect"], self.return_url)
         self.db.commit.assert_called_once()
-        native = self.client.post('/app/forderungen/details/1_R1', data={
-            'source': 'details', 'return_to': self.return_url, 'redirect': 'list',
-        }, follow_redirects=False)
+        native = self.client.post(
+            "/app/forderungen/details/1_R1",
+            data={
+                "source": "details",
+                "return_to": self.return_url,
+                "redirect": "list",
+            },
+            follow_redirects=False,
+        )
         self.assertEqual(native.status_code, 303)
-        self.assertEqual(native.headers['location'], self.return_url)
+        self.assertEqual(native.headers["location"], self.return_url)
 
     def test_failed_save_does_not_return_and_external_target_is_ignored(self):
-        self.db.commit.side_effect = RuntimeError('test database unavailable')
-        response = self.client.post('/app/forderungen/details/1_R1', data={
-            'source': 'details', 'return_to': self.return_url, 'redirect': 'list',
-        }, headers={'HX-Request': 'true'})
+        self.db.commit.side_effect = RuntimeError("test database unavailable")
+        response = self.client.post(
+            "/app/forderungen/details/1_R1",
+            data={
+                "source": "details",
+                "return_to": self.return_url,
+                "redirect": "list",
+            },
+            headers={"HX-Request": "true"},
+        )
         self.assertEqual(response.status_code, 500)
-        self.assertNotIn('HX-Redirect', response.headers)
+        self.assertNotIn("HX-Redirect", response.headers)
         self.db.commit.side_effect = None
-        response = self.client.post('/app/forderungen/details/1_R1', data={
-            'source': 'details', 'redirect': 'https://evil.test',
-        }, headers={'HX-Request': 'true'})
-        self.assertEqual(response.headers['HX-Redirect'], LIST_PATH + '?focus=1_R1')
+        response = self.client.post(
+            "/app/forderungen/details/1_R1",
+            data={
+                "source": "details",
+                "redirect": "https://evil.test",
+            },
+            headers={"HX-Request": "true"},
+        )
+        self.assertEqual(response.headers["HX-Redirect"], LIST_PATH + "?focus=1_R1")