Procházet zdrojové kódy

Füge Dokumentenverwaltung hinzu

- Implementiere JavaScript für das Laden und Verwalten von Dokumenten, einschließlich Modal-Interaktionen und Fehlerbehandlung.
- Füge HTML-Vorlagen für die Dokumentenliste und Empfänger hinzu, um die Benutzeroberfläche zu verbessern.
- Implementiere Tests für die Dokumentenfunktionen, einschließlich Filterung, Fehlerbehandlung und E-Mail-Versand.
gc-server3 před 6 dny
rodič
revize
b9ea3fe284

+ 9 - 0
.env.example

@@ -28,3 +28,12 @@ LOG_TO_FILE=true
 LOG_TO_CONSOLE=true
 LOG_RETENTION_DAYS=30
 LOG_SQL_STATEMENT=true
+
+# live or mock
+DOCUWARE_MODE=live
+DOCUWARE_MOCK_PATH=data/docuware-sample
+DW_URL=http://localhost/
+DW_USERNAME=user
+DW_PASSWORD=password
+DW_ORG=Firma GmbH
+DW_TIMEOUT=15

+ 3 - 0
.gitignore

@@ -11,3 +11,6 @@ wheels/
 .uv-cache/
 .env
 logs/
+
+# DocuWare production exports
+data/docuware-exports/

+ 50 - 0
README.md

@@ -392,3 +392,53 @@ ein Import allein führt sie nicht aus. Nicht zu Logging-Testzwecken produktiv s
 Loggingtests nutzen ausschließlich temporäre Verzeichnisse und isolierte SQLite-Engines:
 `python -m unittest tests.test_logging -q`. Die vollständige Prüfung erfolgt mit
 `python -m unittest discover -s tests -q`.
+
+
+### Gemeinsame Dokumentliste / DocuWare offline testen
+
+Der Belegnummer-Button öffnet eine Liste aus DocuWare und lokaler Ablage.
+Die Detailseite bietet zusätzlich „Offene Belege“, „Kunde“ und „Fahrzeug“.
+Kunde und Fahrzeug durchsuchen DocuWare direkt; beim Fahrzeug wird die VIN aus
+`dbo.Fahrzeuge` verwendet. Lokale Dateien werden aus den passenden aktuellen und
+historischen Belegen ergänzt, beim Fahrzeug auch über Kundenwechsel hinweg.
+Die Zuordnung bleibt im Mandanten der geöffneten Forderung.
+Fehlende Metadaten bleiben leer. Lokale PDFs öffnen als Vorschau, andere Dateien
+werden heruntergeladen. DocuWare-Ausfälle blenden lokale Dateien nicht aus.
+
+In `.env` explizit einschalten:
+
+```ini
+DOCUWARE_MODE=mock
+DOCUWARE_MOCK_PATH=data/docuware-sample
+```
+
+Die mitgelieferten Testmetadaten verwenden Mandant `1`, Belege `R1`/`R2`, Kunde
+`D1` und VIN `VIN1`/`VIN2`. Sie verwenden die bisherige Beispiel-PDF ausschließlich
+als bewusst aktivierte Mockdatei. Für andere Entwicklungsbelege können die
+Metadaten in einer Kopie angepasst werden. Eine nicht vorhandene Belegnummer
+simuliert eine leere Suche; `"unavailable": true` auf Manifestebene simuliert einen
+Ausfall. Fehlende `file`-Einträge simulieren einen Fehler beim Dokumentabruf.
+Der Livemodus greift niemals automatisch auf diese Beispiele zurück.
+
+Auf einem System mit DocuWare-Zugang und bestehender `DW_*`-Konfiguration:
+
+```powershell
+.venv/Scripts/python.exe -m app.docuware_export --client-db 1 --document-no R1 --document-no R2 --output data/docuware-exports/testlauf
+# Optional: --customer-no D1 oder --vehicle-no VIN1 (zusätzliche Suchläufe)
+```
+
+Den gesamten Ausgabeordner in die Entwicklungsumgebung kopieren und
+`DOCUWARE_MOCK_PATH` darauf setzen. Ein bestehendes Manifest wird nicht überschrieben.
+`manifest.json` enthält Formatversion 1, Metadaten, relative PDF-Pfade und einen
+Fehlerbericht. Der Exitcode ist bei Teilfehlern 1. Dateinamen werden aus Archiv und
+Dokument-ID abgeleitet. Zugangsdaten und Sitzungstokens werden nicht exportiert.
+Produktivexporte unter `data/docuware-exports/` sind von Git ausgeschlossen.
+
+„Weiterleiten“ erzeugt eine `.eml`-Datei mit genau einem Anhang und `X-Unsent: 1`.
+Empfänger stammen aus der geöffneten Forderung; fehlende Adressen deaktivieren das
+jeweilige Ziel. Der Verursacher berücksichtigt die manuelle Zuordnung und wird
+über AD aufgelöst. Im Debuglogin ist die eigene Adresse leer. Die Anwendung
+versendet keine E-Mails. Die Bearbeitbarkeit des Entwurfs muss im eingesetzten
+Mailprogramm geprüft werden; sie hängt vom Client und dessen Dateizuordnung ab.
+Vor Produktivfreigabe sind außerdem Suchfelder und PDF-Ausgabe am echten Archiv
+zu prüfen. `DW_TIMEOUT` begrenzt die einzelnen DocuWare-Anfragen.

+ 1 - 0
app/comment_submission.py

@@ -114,6 +114,7 @@ async def submit_comment(request, pkey, db, scope):
         messages.append(
             "Die Dateiliste konnte nicht aktualisiert werden. Bereits gespeicherte Dateien bleiben erhalten."
         )
+    context.setdefault("forderungen_details", [pkey])
     context.update(
         history_available=history_available,
         files_available=files_available,

+ 2 - 0
app/config.py

@@ -46,6 +46,8 @@ class Settings(BaseSettings):
     LDAP_BIND_PASSWORD: str | None = None
     LDAP_UPN_SUFFIX: str | None = None
     LDAP_START_TLS: bool = False
+    DOCUWARE_MODE: Literal["live", "mock"] = "live"
+    DOCUWARE_MOCK_PATH: str = "data/docuware-sample"
     LOCAL_STORAGE: str | None = None
     UPLOAD_MAX_FILE_SIZE_MB: int = Field(default=50, gt=0)
     LOG_LEVEL: Annotated[

+ 257 - 0
app/documents.py

@@ -0,0 +1,257 @@
+"""Shared, case-authorized document collection and mail drafts."""
+
+import hashlib
+import logging
+import mimetypes
+from email.message import EmailMessage
+from email.policy import SMTP
+from typing import Literal
+from urllib.parse import quote
+
+from fastapi import APIRouter, Depends, HTTPException, Request
+from fastapi.responses import HTMLResponse, Response
+from sqlalchemy import text
+
+from .db import get_session
+from .docuware_search import get_adapter
+from .file_storage import get_file, get_files
+from .jinja_templates import templates
+from .ldap import DirectoryUnavailable, lookup_directory_user
+from .permissions import current_principal, ensure_forderung_access
+
+router = APIRouter()
+logger = logging.getLogger(__name__)
+Scope = Literal["invoice", "open", "debtor", "vehicle"]
+BASE = "/app/forderungen/documents"
+
+
+def case_context(request, primary_key, db):
+    from .routes import convert_primary_key
+
+    key = convert_primary_key("forderungen", primary_key)
+    ensure_forderung_access(db, current_principal(request), key["Client_DB"], key["Beleg_Nr"])
+    row = (
+        db.execute(
+            text("SELECT TOP 1 * FROM dbo.Forderungen_Kopf WHERE Client_DB=:Client_DB AND Beleg_Nr=:Beleg_Nr"), key
+        )
+        .mappings()
+        .first()
+    )
+    if not row:
+        raise HTTPException(404, "Forderung nicht gefunden")
+    return key, row
+
+
+def scoped_keys(db, key, row, scope):
+    if scope == "invoice":
+        return [key]
+    column = "Fahrzeug_Nr" if scope == "vehicle" else "Kunde_Nr"
+    value = row.get(column)
+    if not value:
+        return []
+    condition = f"Client_DB=:tenant AND {column}=:value"
+    sql = f"SELECT Client_DB, Beleg_Nr FROM dbo.Forderungen_Kopf WHERE {condition}"
+    if scope == "open":
+        sql += " AND Beleg_Offen <> 0"
+    else:
+        sql += f" UNION SELECT Client_DB, Beleg_Nr FROM dbo.Forderungen_Historie WHERE {condition}"
+    return [
+        dict(item) for item in db.execute(text(sql), {"tenant": key["Client_DB"], "value": value}).mappings().fetchall()
+    ]
+
+
+def identity(source, *parts):
+    return source + "-" + hashlib.sha256("\0".join(map(str, parts)).encode()).hexdigest()
+
+
+def collect(db, key, row, scope):
+    documents, errors = {}, []
+    keys = scoped_keys(db, key, row, scope)
+    adapter = get_adapter()
+    try:
+        if scope in ("invoice", "open"):
+            searches = [dict(document_no=k["Beleg_Nr"]) for k in keys]
+        elif scope == "debtor":
+            searches = [dict(customer_no=str(row["Kunde_Nr"]))] if row.get("Kunde_Nr") else []
+        else:
+            vin = None
+            if row.get("Fahrzeug_Nr"):
+                vehicle = (
+                    db.execute(
+                        text(
+                            "SELECT TOP 1 Fahrzeug_VIN FROM dbo.Fahrzeuge WHERE Client_DB=:tenant AND Fahrzeug_Nr=:vehicle"
+                        ),
+                        {"tenant": key["Client_DB"], "vehicle": row["Fahrzeug_Nr"]},
+                    )
+                    .mappings()
+                    .first()
+                )
+                vin = vehicle["Fahrzeug_VIN"] if vehicle else None
+            searches = [dict(vehicle_no=str(vin))] if vin else []
+        for search in searches:
+            for item in adapter.search(key["Client_DB"], **search):
+                doc = {**item, "source": "DocuWare", "pdf": True}
+                doc["token"] = identity("dw", doc["archive"], doc["id"])
+                documents[doc["token"]] = doc
+    except Exception:
+        logger.exception("DocuWare-Dokumentliste konnte nicht geladen werden")
+        errors.append("Keine Verbindung zu DocuWare")
+    if not errors and not documents:
+        errors.append(
+            f"Keine Dokumente für Beleg {key['Beleg_Nr']} gefunden"
+            if scope == "invoice"
+            else "Keine DocuWare-Dokumente für diesen Filter gefunden"
+        )
+    try:
+        for item_key in keys:
+            for file in get_files(item_key):
+                token = identity("local", file.relative_path)
+                documents[token] = dict(
+                    token=token,
+                    source="Lokal",
+                    number=item_key["Beleg_Nr"],
+                    date="",
+                    kind="",
+                    editor="",
+                    name=file.name,
+                    path=file.relative_path,
+                    pdf=file.name.lower().endswith(".pdf"),
+                )
+    except OSError:
+        logger.exception("Lokale Dokumentliste konnte nicht geladen werden")
+        errors.append("Die lokalen Dateien konnten nicht geladen werden.")
+    docs = sorted(documents.values(), key=lambda d: (d.get("name", "").casefold(), d["token"]))
+    docs.sort(key=lambda d: d.get("date", ""), reverse=True)
+    return docs, errors
+
+
+def context(request, primary_key, db, scope):
+    key, row = case_context(request, primary_key, db)
+    docs, errors = collect(db, key, row, scope)
+    return dict(
+        documents=docs,
+        document_errors=errors,
+        document_scope=scope,
+        document_base=BASE + "/" + quote(primary_key, safe=""),
+        document_key=primary_key,
+        customer_missing=not row.get("Kunde_Nr"),
+        vehicle_missing=not row.get("Fahrzeug_Nr"),
+    )
+
+
+def selected(request, primary_key, db, scope, token):
+    ctx = context(request, primary_key, db, scope)
+    doc = next((d for d in ctx["documents"] if d["token"] == token), None)
+    if doc is None:
+        if token.startswith("dw-") and "Keine Verbindung zu DocuWare" in ctx["document_errors"]:
+            raise HTTPException(503, "Keine Verbindung zu DocuWare")
+        raise HTTPException(404, "Dokument nicht gefunden oder nicht mehr verfügbar")
+    return doc, ctx
+
+
+def content(primary_key, doc):
+    try:
+        if doc["source"] == "Lokal":
+            path = get_file(doc["path"])
+            return path.read_bytes(), mimetypes.guess_type(path.name)[0] or "application/octet-stream", path.name
+        return get_adapter().download(primary_key.split("_", 1)[0], doc)
+    except FileNotFoundError:
+        raise HTTPException(404, "Dokumentdatei nicht gefunden")
+    except Exception:
+        logger.exception("Dokument konnte nicht geöffnet werden")
+        raise HTTPException(503, "Dokument konnte nicht geladen werden. Bitte erneut versuchen.")
+
+
+@router.get(BASE + "/{primary_key}", response_class=HTMLResponse)
+def listing(
+    request: Request, primary_key: str, scope: Scope = "invoice", detail: bool = False, db=Depends(get_session)
+):
+    ctx = context(request, primary_key, db, scope)
+    ctx["document_detail"] = detail
+    return templates.TemplateResponse(
+        request, "forderungen/documents/list.html", ctx, headers={"Cache-Control": "no-store"}
+    )
+
+
+@router.get(BASE + "/{primary_key}/content/{token}")
+def download(request: Request, primary_key: str, token: str, scope: Scope = "invoice", db=Depends(get_session)):
+    doc, _ = selected(request, primary_key, db, scope, token)
+    data, media, name = content(primary_key, doc)
+    disposition = "inline" if doc["pdf"] else "attachment"
+    return Response(
+        data,
+        media_type=media,
+        headers={
+            "Content-Disposition": f"{disposition}; filename*=UTF-8''{quote(name, safe='')}",
+            "Cache-Control": "no-store",
+            "X-Content-Type-Options": "nosniff",
+        },
+    )
+
+
+def recipients(request, primary_key, db):
+    # Always use the opened case, regardless of the selected document's receipt.
+    tenant, number = primary_key.split("_", 1)
+    query = templates.env.get_template("forderungen/details/queries/forderungen_details.sql").render()
+    row = db.execute(text(query), {"client_db": tenant, "beleg_nr": number}).mappings().first()
+    colleague = ""
+    if row and row.get("Verursacher_Aktuell_Benutzer_ID"):
+        try:
+            user = lookup_directory_user(row["Verursacher_Aktuell_Benutzer_ID"])
+            if user and not user.disabled:
+                colleague = user.email
+        except (DirectoryUnavailable, ValueError):
+            pass
+    values = [
+        ("self", "An mich", current_principal(request).email),
+        ("colleague", "Verursacher", colleague),
+        ("customer", "Kunde", row.get("Kunde_Email") if row else ""),
+        ("insurance", "Versicherung", row.get("Vers_Email") if row else ""),
+    ]
+    return [
+        dict(id=id_, label=label, email=address if valid_address(address) else "") for id_, label, address in values
+    ]
+
+
+def valid_address(value):
+    from email.headerregistry import Address
+
+    if not value or any(c in value for c in "\r\n"):
+        return False
+    try:
+        address = Address(addr_spec=value)
+        return bool(address.username and address.domain)
+    except ValueError:
+        return False
+
+
+@router.get(BASE + "/{primary_key}/recipients/{token}", response_class=HTMLResponse)
+def recipient_list(request: Request, primary_key: str, token: str, scope: Scope = "invoice", db=Depends(get_session)):
+    doc, ctx = selected(request, primary_key, db, scope, token)
+    ctx.update(document=doc, recipients=recipients(request, primary_key, db))
+    return templates.TemplateResponse(
+        request, "forderungen/documents/recipients.html", ctx, headers={"Cache-Control": "no-store"}
+    )
+
+
+@router.get(BASE + "/{primary_key}/draft/{token}")
+def draft(
+    request: Request, primary_key: str, token: str, recipient: str, scope: Scope = "invoice", db=Depends(get_session)
+):
+    doc, _ = selected(request, primary_key, db, scope, token)
+    target = next((r for r in recipients(request, primary_key, db) if r["id"] == recipient), None)
+    if not target or not target["email"]:
+        raise HTTPException(422, "Keine gültige hinterlegte E-Mail-Adresse vorhanden")
+    data, media, name = content(primary_key, doc)
+    message = EmailMessage(policy=SMTP)
+    message["To"] = target["email"]
+    message["Subject"] = "Dokument zu Beleg " + primary_key.split("_", 1)[1]
+    message["X-Unsent"] = "1"
+    message.set_content("Guten Tag,\n\nim Anhang finden Sie das ausgewählte Dokument.\n")
+    major, minor = media.split(";", 1)[0].split("/", 1)
+    message.add_attachment(data, maintype=major, subtype=minor, filename=name)
+    return Response(
+        message.as_bytes(),
+        media_type="message/rfc822",
+        headers={"Content-Disposition": "attachment; filename=Dokument.eml", "Cache-Control": "no-store"},
+    )

+ 73 - 0
app/docuware_export.py

@@ -0,0 +1,73 @@
+"""Export a bounded selection into the offline DocuWare manifest format."""
+
+import argparse
+import hashlib
+import json
+import logging
+from pathlib import Path
+
+from .docuware_search import LiveAdapter
+from .logging_setup import run_cli
+
+logger = logging.getLogger(__name__)
+
+
+def export(adapter, tenant, searches, output):
+    output = Path(output)
+    output.mkdir(parents=True, exist_ok=True)
+    if (output / "manifest.json").exists():
+        raise ValueError("Ziel enthält bereits einen Export; bitte neuen Ausgabeordner verwenden")
+    documents, errors = {}, []
+    for search in searches:
+        try:
+            for doc in adapter.search(tenant, **search):
+                documents[(doc["archive"], doc["id"])] = {**doc, "client_db": tenant}
+        except Exception as exc:
+            errors.append(dict(stage="search", error=type(exc).__name__))
+    for (archive, id_), doc in documents.items():
+        # Hash both identifiers so archive labels cannot become paths.
+        stem = hashlib.sha256(f"{archive}\0{id_}".encode()).hexdigest()
+        try:
+            data, media, name = adapter.download(tenant, doc)
+            if media.split(";", 1)[0].lower() != "application/pdf":
+                raise ValueError("PDF erwartet")
+            filename = stem + ".pdf"
+            (output / filename).write_bytes(data)
+            doc.update(file=filename, name=name)
+        except Exception as exc:
+            errors.append(dict(stage="download", archive=archive, id=id_, error=type(exc).__name__))
+    manifest = dict(version=1, documents=list(documents.values()), errors=errors)
+    (output / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
+    return manifest
+
+
+def main():
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument("--client-db", required=True, choices=["1", "2", "3", "4"])
+    parser.add_argument("--document-no", action="append", default=[])
+    parser.add_argument("--customer-no")
+    parser.add_argument("--vehicle-no")
+    parser.add_argument("--output", required=True)
+    args = parser.parse_args()
+    searches = [dict(document_no=n) for n in args.document_no]
+    if args.customer_no:
+        searches.append(dict(customer_no=args.customer_no))
+    if args.vehicle_no:
+        searches.append(dict(vehicle_no=args.vehicle_no))
+    if not searches:
+        parser.error("Mindestens ein Beleg-, Kunden- oder Fahrzeugfilter ist erforderlich")
+    result = export(LiveAdapter(), args.client_db, searches, args.output)
+    logger.info(
+        "Export abgeschlossen: %s Dokumente, %s Fehler. Bericht: manifest.json",
+        len(result["documents"]),
+        len(result["errors"]),
+    )
+    return 1 if result["errors"] else 0
+
+
+if __name__ == "__main__":
+
+    def run():
+        raise SystemExit(main())
+
+    run_cli(run)

+ 120 - 76
app/docuware_search.py

@@ -23,11 +23,11 @@ def main():
     dw = docuware.connect()
 
     for org in dw.organizations:
-        logger.debug("DocuWare-Organisation verfügbar")
+        logger.debug("DocuWare-Organisation verfügbar")
         for fc in org.file_cabinets:
-            logger.debug("DocuWare-Ablage verfügbar")
+            logger.debug("DocuWare-Ablage verfügbar")
         for b in org.baskets:
-            logger.debug("DocuWare-Briefkorb verfügbar")
+            logger.debug("DocuWare-Briefkorb verfügbar")
 
 
 def get_file_cabinet(client_db: str) -> docuware.FileCabinet:
@@ -35,82 +35,126 @@ def get_file_cabinet(client_db: str) -> docuware.FileCabinet:
     return dw.organization("1").file_cabinet(FILE_CABINETS[client_db])
 
 
-def search_files(
-    client_db: str, customer_no: str | None = None, vehicle_no: str | None = None, document_no: str | None = None
-) -> list[list[str]]:
-    search_condition = {}
-    if customer_no:
-        search_condition["KUNDENNUMMER"] = customer_no
-    if vehicle_no:
-        search_condition["FAHRGESTELLNUMMER"] = vehicle_no
-    if document_no:
-        search_condition["BELEGNUMMER"] = document_no
-
-    fc = get_file_cabinet(client_db)
-    dlg = fc.search_dialog()
-
-    res = []
-    for doc in dlg.search(search_condition):
-        # r = doc.document.__dict__
-        # r["file_cabinet"] = None
-        # r["attachments"] = r["attachments"][0].pages
-        # r["modified"] = str(r["modified"])
-        # r["created"] = str(r["created"])
-        # r["fields"] = {f.name: str(f.value) for f in doc.document.fields }
-        # r["endpoints"] = {k: str(v) for k, v in doc.document.endpoints.items() }
-        # res.append(r)
-        fields = {f.name: str(f.value) for f in doc.document.fields}
-        r = {
-            "ID": doc.document.id,
-            "Datei_Typ": fields["File type"],
-            "Datei_Link": doc.document.endpoints["self"],
-            "Beleg_Typ": fields["Dok.-/ Belegtyp"],
-            "Beleg_Nr": fields["Belegnummer"],
-            "Kunde_Nr": fields["Kundennummer"],
-            "Kunde_Name": fields["Name"],
-            "Fahrzeug_Nr": fields["Fahrgestellnummer"],
-            "Scan_Datum": str(doc.document.modified),
-            "Beleg_Datum": fields["Beleg-Datum"],
-            "Bearbeiter": fields["Bearbeitet von:"],
-            "Seiten": doc.document.attachments[0].pages,
-            "Betrag": fields["Rechnungsbetrag"],
-        }
-        res.append(r)
-
-    # Document ID 6809334
-    # File type .pdf
-    # Dok.-/ Belegtyp GA-KAUFVERTRAG MIT UNTERSCHRIFT
-    # Belegnummer 153968
-    # Kundennummer None
-    # Fahrgestellnummer WBA21FZ070FT19353
-    # Modified on 2025-09-29 07:32:23.893000
-    # Beleg-Datum 2025-09-29
-    # Bearbeitet von: SCANENGINE
-    # Rechnungsbetrag None
-
-    with open("docuware.json", "w") as fwh:
-        json.dump(res, fwh, indent=2)
+def search_files(client_db, customer_no=None, vehicle_no=None, document_no=None):
+    """Return normalized metadata without downloading files or writing exports."""
+    return get_adapter().search(client_db, customer_no, vehicle_no, document_no)
 
 
 def get_invoice_pdf_by_document_no(client_db: str, document_no: str) -> bytes:
-    search_condition = {}
-    search_condition["BELEGNUMMER"] = document_no
-
-    fc = get_file_cabinet(client_db)
-    dlg = fc.search_dialog()
-
-    for doc in dlg.search(search_condition):
-        # return os.getenv("DW_URL").strip("/") + doc.document.endpoints["content"]
-        data, content_type, filename = doc.document.download(keep_annotations=True)
-        return data
-    raise ValueError(f"Document with document_no {document_no} not found in client_db {client_db}")
+    adapter = get_adapter()
+    documents = adapter.search(client_db, document_no=document_no)
+    if not documents:
+        raise FileNotFoundError(document_no)
+    return adapter.download(client_db, documents[0])[0]
+
+
+class LiveAdapter:
+    def search(self, client_db, customer_no=None, vehicle_no=None, document_no=None):
+        conditions = {
+            key: value
+            for key, value in (
+                ("KUNDENNUMMER", customer_no),
+                ("FAHRGESTELLNUMMER", vehicle_no),
+                ("BELEGNUMMER", document_no),
+            )
+            if value
+        }
+        if not conditions:
+            return []
+        cabinet = get_file_cabinet(client_db)
+        result = []
+        for hit in cabinet.search_dialog().search(conditions, quote=docuware.QuoteMode.ALL):
+            doc = hit.document
+            fields = {f.name: f.value for f in doc.fields}
+
+            def value(name):
+                return str(fields.get(name) or "")
+
+            # Verify exact matches as archives may configure full-text searches.
+            if any(
+                expected and value(field) != expected
+                for expected, field in (
+                    (document_no, "Belegnummer"),
+                    (customer_no, "Kundennummer"),
+                    (vehicle_no, "Fahrgestellnummer"),
+                )
+            ):
+                continue
+            result.append(
+                dict(
+                    archive=str(cabinet.id),
+                    id=str(doc.id),
+                    number=value("Belegnummer"),
+                    date=value("Beleg-Datum")[:10],
+                    kind=value("Dok.-/ Belegtyp"),
+                    editor=value("Bearbeitet von:"),
+                    customer=value("Kundennummer"),
+                    vehicle=value("Fahrgestellnummer"),
+                    name=str(doc.title or doc.id) + ("" if str(doc.title or "").lower().endswith(".pdf") else ".pdf"),
+                )
+            )
+        return result
+
+    def download(self, client_db, document):
+        cabinet = get_file_cabinet(client_db)
+        if str(cabinet.id) != document["archive"]:
+            raise FileNotFoundError("Archiv nicht gefunden")
+        return cabinet.get_document(document["id"]).download(keep_annotations=True)
+
+
+class MockAdapter:
+    def _load(self):
+        from pathlib import Path
+
+        from .config import settings
+
+        root = Path(settings.DOCUWARE_MOCK_PATH).resolve()
+        data = json.loads((root / "manifest.json").read_text(encoding="utf-8"))
+        if data.get("version") != 1:
+            raise ValueError("Unbekanntes Mockformat")
+        return root, data
+
+    def search(self, client_db, customer_no=None, vehicle_no=None, document_no=None):
+        if not any((customer_no, vehicle_no, document_no)):
+            return []
+        _, data = self._load()
+        if data.get("unavailable"):
+            raise ConnectionError("Mock: DocuWare nicht erreichbar")
+        return [
+            dict(doc)
+            for doc in data["documents"]
+            if doc["client_db"] == client_db
+            and all(
+                not val or doc.get(key) == val
+                for key, val in (("customer", customer_no), ("vehicle", vehicle_no), ("number", document_no))
+            )
+        ]
+
+    def download(self, client_db, document):
+        root, data = self._load()
+        if data.get("unavailable"):
+            raise ConnectionError("Mock: DocuWare nicht erreichbar")
+        doc = next(
+            (
+                d
+                for d in data["documents"]
+                if d["client_db"] == client_db and d["id"] == document["id"] and d["archive"] == document["archive"]
+            ),
+            None,
+        )
+        if not doc or not doc.get("file"):
+            raise FileNotFoundError("Dokumentdatei fehlt")
+        path = (root / doc["file"]).resolve()
+        if root not in path.parents:
+            raise FileNotFoundError("Ungültiger Dokumentpfad")
+        return path.read_bytes(), "application/pdf", doc["name"]
+
+
+def get_adapter():
+    from .config import settings
+
+    return MockAdapter() if settings.DOCUWARE_MODE == "mock" else LiveAdapter()
 
 
 if __name__ == "__main__":
-    # main()
-    # search_files(client_db="1", vehicle_no="WBA21FZ070FT19353")
-    def download_sample():
-        data = get_invoice_pdf_by_document_no("1", "WRG25117581")
-        logger.info("PDF abgerufen", extra={"event": "docuware_pdf_downloaded", "size_bytes": len(data)})
-
-    run_cli(download_sample)
+    run_cli(main)

+ 5 - 1
app/routes.py

@@ -32,6 +32,7 @@ from .dashboard_segment import (
     segment_filters,
 )
 from .db import get_session
+from .documents import router as documents_router
 from .docuware_search import get_invoice_pdf_by_document_no
 from .forderung_dashboard import STACKS, build_dashboard
 from .forderung_data import read_filter_suggestions, read_invoices
@@ -62,6 +63,7 @@ from .verursacher import router as verursacher_router
 from .wiedervorlage import router as wiedervorlage_router
 
 router = APIRouter()
+router.include_router(documents_router)
 router.include_router(verursacher_router)
 router.include_router(wiedervorlage_router)
 logger = logging.getLogger(__name__)
@@ -560,9 +562,11 @@ def forderungen_files(request: Request, primary_key: str, db: Session = Depends(
     try:
         file_content = get_invoice_pdf_by_document_no(client_db=pkey["Client_DB"], document_no=pkey["Beleg_Nr"])
         filestream = io.BytesIO(file_content)
+    except FileNotFoundError:
+        raise HTTPException(404, f"Keine Dokumente für Beleg {pkey['Beleg_Nr']} gefunden")
     except Exception:
         logger.exception("Rechnung konnte nicht aus DocuWare geladen werden")
-        filestream = io.BytesIO(Path("static/assets/pdf/invoice.pdf").read_bytes())
+        raise HTTPException(503, "Keine Verbindung zu DocuWare")
 
     return StreamingResponse(
         filestream,

+ 57 - 0
data/docuware-sample/manifest.json

@@ -0,0 +1,57 @@
+{
+  "version": 1,
+  "documents": [
+    {
+      "client_db": "1",
+      "archive": "sample",
+      "id": "1",
+      "number": "R1",
+      "customer": "D1",
+      "vehicle": "VIN1",
+      "date": "2026-01-02",
+      "kind": "Rechnung",
+      "editor": "TEST",
+      "name": "Testdokument-1.pdf",
+      "file": "sample.pdf"
+    },
+    {
+      "client_db": "1",
+      "archive": "sample",
+      "id": "2",
+      "number": "R1",
+      "customer": "D1",
+      "vehicle": "VIN1",
+      "date": "",
+      "kind": "",
+      "editor": "",
+      "name": "Testdokument-2.pdf",
+      "file": "sample.pdf"
+    },
+    {
+      "client_db": "1",
+      "archive": "sample",
+      "id": "3",
+      "number": "R2",
+      "customer": "D1",
+      "vehicle": "VIN2",
+      "date": "2026-01-02",
+      "kind": "Gutschrift",
+      "editor": "TEST",
+      "name": "Testdokument-3.pdf",
+      "file": "sample.pdf"
+    },
+    {
+      "client_db": "1",
+      "archive": "sample",
+      "id": "4",
+      "number": "",
+      "customer": "D1",
+      "vehicle": "VIN1",
+      "date": "2026-01-02",
+      "kind": "Fahrzeugdokument",
+      "editor": "TEST",
+      "name": "Testdokument-4.pdf",
+      "file": "sample.pdf"
+    }
+  ]
+}

binární
data/docuware-sample/sample.pdf


+ 4 - 0
static/assets/css/documents.css

@@ -0,0 +1,4 @@
+.gc-document-table { min-width: 850px; }
+[data-document-list] .history-tabs { flex-wrap: wrap; margin-bottom: 1rem; }
+#document-pdf-frame { width: 100%; height: 75vh; border: 0; }
+[data-document-mail] .btn { margin: .25rem; }

+ 76 - 0
static/assets/js/documents.js

@@ -0,0 +1,76 @@
+(() => {
+  const initialized = new WeakSet(), scopes = new Map(), requests = new WeakMap();
+  let previewController, previewURL, returnToList = false;
+  const modal = id => coreui.Modal.getOrCreateInstance(document.getElementById(id));
+  async function load(host) {
+    requests.get(host)?.abort();
+    const controller = new AbortController(); requests.set(host, controller);
+    const scope = host.dataset.documentDetail ? (scopes.get(host.dataset.documentKey) || 'invoice') : 'invoice';
+    host.dataset.scope = scope; host.setAttribute('aria-busy', 'true');
+    host.textContent = 'Dokumente werden geladen …';
+    try {
+      const url = `/app/forderungen/documents/${encodeURIComponent(host.dataset.documentKey)}?scope=${scope}&detail=${host.dataset.documentDetail === 'true'}`;
+      const response = await fetch(url, {signal: controller.signal});
+      if (!response.ok) throw new Error();
+      const html = await response.text();
+      if (!controller.signal.aborted && host.isConnected) host.innerHTML = html;
+    } catch (error) {
+      if (error.name !== 'AbortError' && host.isConnected) {
+        host.textContent = 'Die Dokumentliste konnte nicht geladen werden. ';
+        const retry = document.createElement('button'); retry.type = 'button'; retry.textContent = 'Erneut versuchen';
+        retry.addEventListener('click', () => load(host)); host.append(retry);
+      }
+    } finally { if (requests.get(host) === controller) host.removeAttribute('aria-busy'); }
+  }
+  function init(root = document) {
+    const hosts = [...(root.matches?.('[data-document-list]') ? [root] : []), ...root.querySelectorAll('[data-document-list]')];
+    for (const host of hosts) if (!initialized.has(host)) { initialized.add(host); load(host); }
+  }
+  document.addEventListener('click', async event => {
+    const open = event.target.closest('[data-document-open]');
+    if (open) {
+      event.preventDefault();
+      const body = document.getElementById('documents-modal-body');
+      body.querySelectorAll('[data-document-list]').forEach(host => requests.get(host)?.abort());
+      const host = document.createElement('div'); host.dataset.documentList = ''; host.dataset.documentKey = open.dataset.documentOpen;
+      body.replaceChildren(host); document.getElementById('documents-modal-title').textContent = `Dokumente zu Beleg ${open.dataset.documentOpen.split('_').slice(1).join('_')}`;
+      modal('documents-modal').show(); init(host); return;
+    }
+    const filter = event.target.closest('[data-document-scope]');
+    if (filter) { const host = filter.closest('[data-document-list]'); scopes.set(host.dataset.documentKey, filter.dataset.documentScope); load(host); return; }
+    const forward = event.target.closest('[data-document-recipients]');
+    if (forward) {
+      const host = forward.closest('[data-document-list]'), target = host.querySelector('[data-document-mail]');
+      requests.get(target)?.abort(); const controller = new AbortController(); requests.set(target, controller);
+      target.textContent = 'Empfänger werden geladen …';
+      try { const response = await fetch(forward.dataset.documentRecipients, {signal: controller.signal}); if (!response.ok) throw new Error(); const html = await response.text(); if (!controller.signal.aborted && target.isConnected) target.innerHTML = html; }
+      catch (error) { if (error.name !== 'AbortError') target.textContent = 'Empfänger konnten nicht geladen werden. Bitte erneut versuchen.'; }
+      return;
+    }
+    const link = event.target.closest('[data-document-preview]');
+    if (link) {
+      event.preventDefault(); previewController?.abort(); previewController = new AbortController();
+      const controller = previewController, frame = document.getElementById('document-pdf-frame'), status = document.querySelector('[data-preview-status]');
+      frame.removeAttribute('src'); if (previewURL) URL.revokeObjectURL(previewURL);
+      returnToList = document.getElementById('documents-modal').classList.contains('show');
+      if (returnToList) {
+        await new Promise(resolve => {
+          document.getElementById('documents-modal').addEventListener('hidden.coreui.modal', resolve, {once: true});
+          modal('documents-modal').hide();
+        });
+      }
+      if (controller.signal.aborted) return;
+      modal('document-preview-modal').show(); status.textContent = 'Dokument wird geladen …';
+      try { const response = await fetch(link.href, {signal: controller.signal}); if (!response.ok) throw new Error(); const blob = await response.blob(); if (controller.signal.aborted) return; previewURL = URL.createObjectURL(blob); frame.src = previewURL; status.textContent = ''; }
+      catch (error) { if (error.name !== 'AbortError') status.textContent = 'Dokument konnte nicht geladen werden. Bitte zur Liste zurückkehren und erneut versuchen.'; }
+    }
+    if (event.target.closest('[data-document-back]')) modal('document-preview-modal').hide();
+  });
+  document.addEventListener('hidden.coreui.modal', event => {
+    if (event.target.id === 'document-preview-modal') { previewController?.abort(); document.getElementById('document-pdf-frame').removeAttribute('src'); if (previewURL) URL.revokeObjectURL(previewURL); if (returnToList) modal('documents-modal').show(); }
+  });
+  document.addEventListener('DOMContentLoaded', () => init());
+  document.addEventListener('htmx:load', event => init(event.detail.elt));
+  document.addEventListener('htmx:afterSettle', () => init());
+  window.addEventListener('pageshow', () => init());
+})();

+ 10 - 0
templates/base/base.html

@@ -31,6 +31,8 @@
   <link href="/static/assets/css/dashboard.css" rel="stylesheet">
   <script src="/static/assets/js/dashboard.js" defer></script>
   <script src="/static/assets/js/attachments.js" defer></script>
+  <script src="/static/assets/js/documents.js" defer></script>
+  <link href="/static/assets/css/documents.css" rel="stylesheet">
   <script src="/static/assets/js/navigation.js" defer></script>
   <script src="/static/assets/js/list.js" defer></script>
   <script src="/static/assets/js/list-filters.js?v=2" defer></script>
@@ -89,6 +91,14 @@
     <div class="modal-content" id="wiedervorlage-modal-content"></div>
   </div>
 </div>
+<div class="modal" id="documents-modal" tabindex="-1" aria-labelledby="documents-modal-title" aria-hidden="true">
+<div class="modal-dialog modal-xl"><div class="modal-content">
+<div class="modal-header"><h2 class="modal-title fs-5" id="documents-modal-title">Dokumente</h2><button type="button" class="btn-close" data-coreui-dismiss="modal" aria-label="Schließen"></button></div>
+<div class="modal-body" id="documents-modal-body"></div>
+</div></div></div>
+<div class="modal" id="document-preview-modal" tabindex="-1" aria-labelledby="document-preview-title" aria-hidden="true">
+<div class="modal-dialog modal-xl"><div class="modal-content"><div class="modal-header"><h2 class="modal-title fs-5" id="document-preview-title">Dokument</h2><button type="button" class="btn btn-outline-secondary" data-document-back>Zurück zur Liste</button></div>
+<div class="modal-body"><p data-preview-status role="status"></p><iframe title="PDF-Dokument" id="document-pdf-frame"></iframe></div></div></div></div>
 </body>
 
 </html>

+ 0 - 21
templates/base/liste.html

@@ -21,26 +21,5 @@
     </table>
   </div>
 </section>
-<!-- Modal -->
-<div class="modal fade" id="exampleModal" tabindex="-1" aria-labelledby="exampleModalLabel" aria-hidden="true">
-  <div class="modal-dialog">
-    <div class="modal-content">
-      <div class="modal-header">
-        <h5 class="modal-title" id="exampleModalLabel">Rechnung</h5>
-        <button type="button" class="btn-close" data-coreui-dismiss="modal" aria-label="Close"></button>
-      </div>
-      <div class="modal-body" id="exampleModalBody">
-        <iframe src="/static/assets/pdf/invoice.pdf" style="width: 100%; height: 800px;" frameborder="0" id="invoiceIframe">
-          Ihr Browser kann leider keine eingebetteten Frames anzeigen.
-        </iframe>
-      </div>
-      <!--
-      <div class="modal-footer">
-        <button type="button" class="btn btn-secondary" data-coreui-dismiss="modal">Schließen</button>
-      </div>
-      -->
-    </div>
-  </div>
-</div>
 
 {% endblock %}

+ 5 - 5
templates/forderungen/details/files_panel.html

@@ -1,7 +1,7 @@
 <section class="gc-detail-card" id="case-files" aria-labelledby="files-title"{% if files_oob %} hx-swap-oob="outerHTML"{% endif %}>
-  <h2 class="gc-section-title" id="files-title"><i class="icon cil-folder-open" aria-hidden="true"></i> Dateien</h2>
-  {% if files_error %}<p class="gc-section-description" role="status">{{ files_error }}</p>{% endif %}
-  <div class="gc-table-scroll" tabindex="0" role="region" aria-label="Dateien">
-    {% include 'forderungen/details/details_dateien.html' %}
-  </div>
+<h2 class="gc-section-title" id="files-title"><i class="icon cil-folder-open" aria-hidden="true"></i> Dateien</h2>
+{% set file_row = forderungen_details[0] if forderungen_details else none %}
+{% if file_row %}
+<div data-document-list data-document-key="{{ file_row.Client_DB }}_{{ file_row.Beleg_Nr }}" data-document-detail="true" aria-live="polite"><p>Dokumente werden geladen …</p></div>
+{% endif %}
 </section>

+ 19 - 0
templates/forderungen/documents/list.html

@@ -0,0 +1,19 @@
+{% if document_detail %}
+<div class="tabs history-tabs" role="group" aria-label="Dokumente filtern">
+{% for value,label in [('invoice','Diese Rechnung'),('open','Offene Belege'),('debtor','Kunde'),('vehicle','Fahrzeug')] %}
+<button type="button" class="btn btn-outline-secondary{% if document_scope == value %} active{% endif %}" data-document-scope="{{ value }}" aria-pressed="{{ 'true' if document_scope == value else 'false' }}" {% if (value in ['open','debtor'] and customer_missing) or (value == 'vehicle' and vehicle_missing) %}disabled{% endif %}>{{ label }}</button>
+{% endfor %}
+</div>
+{% endif %}
+{% for error in document_errors %}<p role="status">{{ error }}</p>{% endfor %}
+<div class="gc-table-scroll" tabindex="0" role="region" aria-label="Dokumentliste">
+<table class="table table-bordered gc-document-table"><thead><tr>
+{% for title in ['Belegnummer','Beleg-Datum','Dok.-/ Belegtyp','Bearbeitet von:','Dateiname','Quelle','Weiterleiten'] %}<th>{{ title }}</th>{% endfor %}
+</tr></thead><tbody>
+{% for doc in documents %}
+<tr><td>{{ doc.number }}</td><td>{{ doc.date }}</td><td>{{ doc.kind }}</td><td>{{ doc.editor }}</td>
+<td><a href="{{ document_base }}/content/{{ doc.token }}?scope={{ document_scope }}" hx-boost="false" {% if doc.pdf %}data-document-preview{% endif %}>{{ doc.name }}</a></td><td>{{ doc.source }}</td>
+<td><button type="button" class="btn btn-outline-secondary" data-document-recipients="{{ document_base }}/recipients/{{ doc.token }}?scope={{ document_scope }}"><i class="icon cil-envelope-closed" aria-hidden="true"></i> Weiterleiten</button></td></tr>
+{% else %}<tr><td colspan="7">Keine Dateien vorhanden.</td></tr>{% endfor %}
+</tbody></table></div>
+<div data-document-mail role="region" aria-label="Weiterleiten"></div>

+ 6 - 0
templates/forderungen/documents/recipients.html

@@ -0,0 +1,6 @@
+<h3 class="fs-5">{{ document.name }} weiterleiten</h3>
+<p>E-Mail-Entwurf mit Anhang herunterladen und im Mailprogramm öffnen.</p>
+{% for target in recipients %}
+{% if target.email %}<a class="btn btn-outline-secondary" hx-boost="false" href="{{ document_base }}/draft/{{ document.token }}?scope={{ document_scope }}&amp;recipient={{ target.id }}">{{ target.label }}: {{ target.email }}</a>
+{% else %}<button class="btn btn-outline-secondary" disabled title="Keine E-Mail-Adresse hinterlegt oder verfügbar">{{ target.label }} – keine E-Mail-Adresse verfügbar</button>{% endif %}
+{% endfor %}

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

@@ -25,7 +25,7 @@
 
   <td class="text-end align-top">
     <div class="gc-money">{{ row.Beleg_Betrag|number_format }} €</div>
-    <a href="#" hx-boost="false" aria-label="Rechnung {{ row.Beleg_Nr }} als PDF öffnen" data-coreui-toggle="modal" data-coreui-target="#exampleModal" onclick='javascript:document.getElementById("invoiceIframe").src="/app/forderungen/files/{{ row.Client_DB }}_{{ row.Beleg_Nr }}";'>
+    <a href="/app/forderungen/documents/{{ row.Client_DB }}_{{ row.Beleg_Nr }}" hx-boost="false" aria-label="Dokumente zu Beleg {{ row.Beleg_Nr }} öffnen" data-document-open="{{ row.Client_DB }}_{{ row.Beleg_Nr }}">
       <span class="gc-list-badge gc-list-badge-info">{{ row.Beleg_Nr }}</span>
     </a>
   </td>

+ 219 - 0
tests/test_documents.py

@@ -0,0 +1,219 @@
+import io
+import json
+import tempfile
+import unittest
+from email import policy
+from email.parser import BytesParser
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import Mock, patch
+
+from app.config import settings
+from app.documents import collect, recipients
+from app.docuware_export import export
+from app.docuware_search import LiveAdapter, MockAdapter
+from app.file_storage import save_upload
+from tests import test_forderung_history as history_fixture
+
+
+class DocumentTests(unittest.TestCase):
+    setUp = history_fixture.HistoryTests.setUp
+
+    def prepare(self):
+        temp = tempfile.TemporaryDirectory()
+        self.addCleanup(temp.cleanup)
+        self.root = Path(temp.name)
+        self.db.connection.executescript(
+            "CREATE TABLE dbo.Fahrzeuge (Client_DB TEXT, Fahrzeug_Nr TEXT, Fahrzeug_VIN TEXT); INSERT INTO dbo.Fahrzeuge VALUES ('1','V1','VIN1');"
+        )
+        config = patch.multiple(
+            settings, DOCUWARE_MODE="mock", DOCUWARE_MOCK_PATH="data/docuware-sample", LOCAL_STORAGE=str(self.root)
+        )
+        config.start()
+        self.addCleanup(config.stop)
+        self.base = "/app/forderungen/documents/1_R1"
+
+    def test_filters_local_history_and_customer_change(self):
+        self.prepare()
+        for number in ["R1", "R2", "R3", "H1", "H2", "X1"]:
+            save_upload(dict(Client_DB="1", Beleg_Nr=number), number + ".txt", io.BytesIO(b"local"), 100)
+        row = dict(Kunde_Nr="D1", Fahrzeug_Nr="V1")
+        for scope, expected in [
+            ("invoice", {"R1"}),
+            ("open", {"R1", "R2"}),
+            ("debtor", {"R1", "R2", "R3", "H1", "H2"}),
+            ("vehicle", {"R1", "R3", "H1", "X1"}),
+        ]:
+            docs, _ = collect(self.db, self.pkey, row, scope)
+            self.assertEqual({d["number"] for d in docs if d["source"] == "Lokal"}, expected)
+        response = self.client.get(self.base + "?scope=vehicle&detail=true")
+        self.assertEqual(response.status_code, 200)
+        self.assertIn("Fahrzeugdokument", response.text)
+        self.assertIn("data-document-preview", response.text)
+        self.assertIn("X1.txt", response.text)
+
+    def test_empty_connection_failure_and_targeted_pdf(self):
+        self.prepare()
+        adapter = MockAdapter()
+        docs = adapter.search("1", document_no="R1")
+        self.assertEqual(len(docs), 2)
+        self.assertEqual(adapter.search("2", document_no="R1"), [])
+        self.assertEqual(adapter.search("1", document_no="absent"), [])
+        response = self.client.get(self.base)
+        from app.documents import identity
+
+        token = identity("dw", "sample", "2")
+        response = self.client.get(self.base + "/content/" + token)
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.content, Path("data/docuware-sample/sample.pdf").read_bytes())
+        self.assertIn("inline", response.headers["content-disposition"])
+        save_upload(self.pkey, "available.txt", io.BytesIO(b"ok"), 100)
+        with patch("app.documents.get_adapter") as factory:
+            factory.return_value.search.side_effect = ConnectionError()
+            with self.assertLogs("app.documents", level="ERROR"):
+                response = self.client.get(self.base)
+        self.assertIn("Keine Verbindung zu DocuWare", response.text)
+        self.assertIn("available.txt", response.text)
+
+    def test_unauthorized_tokens_scopes_and_local_paths(self):
+        self.prepare()
+        for suffix in ["", "/content/forged", "/recipients/forged", "/draft/forged?recipient=self"]:
+            self.assertEqual(self.client.get("/app/forderungen/documents/1_R2" + suffix).status_code, 404)
+        self.assertEqual(self.client.get(self.base + "/content/forged").status_code, 404)
+        self.assertEqual(self.client.get(self.base + "?scope=invalid").status_code, 422)
+        save_upload(dict(Client_DB="2", Beleg_Nr="R1"), "private.pdf", io.BytesIO(b"secret"), 100)
+        from app.documents import identity
+
+        token = identity("local", "2_R1/private.pdf")
+        self.assertEqual(self.client.get(self.base + "/content/" + token).status_code, 404)
+
+    def test_draft_exact_attachment_and_recipient_validation(self):
+        self.prepare()
+        from app.documents import identity
+
+        token = identity("dw", "sample", "1")
+        targets = [
+            dict(id="self", label="An mich", email="me@example.com"),
+            dict(id="insurance", label="Versicherung", email=""),
+        ]
+        with patch("app.documents.recipients", return_value=targets) as lookup:
+            response = self.client.get(self.base + "/draft/" + token + "?recipient=self")
+            self.assertEqual(response.status_code, 200)
+            self.assertEqual(lookup.call_args.args[1], "1_R1")
+            message = BytesParser(policy=policy.default).parsebytes(response.content)
+            self.assertEqual(message["To"], "me@example.com")
+            self.assertEqual(message["X-Unsent"], "1")
+            attachments = list(message.iter_attachments())
+            self.assertEqual(len(attachments), 1)
+            self.assertEqual(attachments[0].get_filename(), "Testdokument-1.pdf")
+            self.assertEqual(
+                attachments[0].get_payload(decode=True), Path("data/docuware-sample/sample.pdf").read_bytes()
+            )
+            for recipient in ["insurance", "forged", "me@evil.com"]:
+                self.assertEqual(
+                    self.client.get(self.base + "/draft/" + token + "?recipient=" + recipient).status_code, 422
+                )
+            html = self.client.get(self.base + "/recipients/" + token).text
+            self.assertIn("disabled", html)
+
+    def test_empty_metadata_dedup_sort_and_upload_refresh(self):
+        self.prepare()
+        from app.documents import identity
+
+        first = dict(archive="a", id="1", number="R1", date="2026-01-01", name="z.pdf", kind="", editor="")
+        second = dict(first, id="2", date="2026-02-01", name="b.pdf")
+        with patch("app.documents.get_adapter") as factory:
+            factory.return_value.search.return_value = [first, second, first]
+            docs, errors = collect(self.db, self.pkey, dict(Kunde_Nr="D1", Fahrzeug_Nr="V1"), "invoice")
+        self.assertEqual([d["id"] for d in docs], ["2", "1"])
+        self.assertEqual(errors, [])
+        with patch("app.documents.get_adapter") as factory:
+            factory.return_value.search.return_value = []
+            response = self.client.get(self.base)
+        self.assertIn("Keine Dokumente für Beleg R1 gefunden", response.text)
+        save_upload(self.pkey, "local.pdf", io.BytesIO(b"%PDF-local"), 100)
+        token = identity("local", "1_R1/local.pdf")
+        response = self.client.get(self.base + "/content/" + token)
+        self.assertEqual(response.content, b"%PDF-local")
+        self.assertIn("inline", response.headers["content-disposition"])
+        panel = (
+            __import__("app.jinja_templates", fromlist=["templates"])
+            .templates.env.get_template("forderungen/details/files_panel.html")
+            .render(forderungen_details=[self.pkey], files_oob=True)
+        )
+        self.assertIn('hx-swap-oob="outerHTML"', panel)
+        self.assertIn('data-document-key="1_R1"', panel)
+        self.assertIn('data-document-detail="true"', panel)
+
+    def test_missing_file_and_mock_path_escape(self):
+        self.prepare()
+        manifest = dict(
+            version=1,
+            documents=[dict(client_db="1", archive="a", id="1", number="R1", file="../outside.pdf", name="bad.pdf")],
+        )
+        (self.root / "manifest.json").write_text(json.dumps(manifest))
+        with patch.object(settings, "DOCUWARE_MOCK_PATH", str(self.root)):
+            with self.assertRaises(FileNotFoundError):
+                MockAdapter().download("1", manifest["documents"][0])
+            manifest["unavailable"] = True
+            (self.root / "manifest.json").write_text(json.dumps(manifest))
+            with self.assertRaises(ConnectionError):
+                MockAdapter().search("1", document_no="R1")
+
+
+class AdapterTests(unittest.TestCase):
+    def test_live_exact_search_missing_fields_download_and_full_iteration(self):
+        document = SimpleNamespace(id=1, title="Test", fields=[SimpleNamespace(name="Belegnummer", value="R1")])
+        other = SimpleNamespace(id=2, title="Other", fields=[SimpleNamespace(name="Belegnummer", value="R10")])
+        cabinet = Mock(id="archive")
+        cabinet.search_dialog.return_value.search.return_value = iter(
+            [SimpleNamespace(document=d) for d in [document, other, document]]
+        )
+        cabinet.get_document.return_value.download.return_value = (b"%PDF-test", "application/pdf", "test.pdf")
+        with patch("app.docuware_search.get_file_cabinet", return_value=cabinet):
+            adapter = LiveAdapter()
+            docs = adapter.search("1", document_no="R1")
+            self.assertEqual(len(docs), 2)
+            self.assertEqual(docs[0]["date"], "")
+            self.assertEqual(adapter.download("1", docs[0])[0], b"%PDF-test")
+            cabinet.get_document.assert_called_with("1")
+
+    def test_export_roundtrip_dedup_and_failure_report(self):
+        adapter = Mock()
+        doc = dict(archive="archive", id="1", number="R1", name="x.pdf", date="", kind="", editor="")
+        adapter.search.return_value = [doc, doc]
+        adapter.download.return_value = (b"%PDF-test", "application/pdf", "ä.pdf")
+        with tempfile.TemporaryDirectory() as folder:
+            result = export(adapter, "1", [dict(document_no="R1")], folder)
+            self.assertEqual(len(result["documents"]), 1)
+            with patch.object(settings, "DOCUWARE_MOCK_PATH", folder):
+                mock = MockAdapter()
+                docs = mock.search("1", document_no="R1")
+                self.assertEqual(mock.download("1", docs[0])[0], b"%PDF-test")
+            with self.assertRaises(ValueError):
+                export(adapter, "1", [dict(document_no="R1")], folder)
+        adapter.download.side_effect = ConnectionError("sensitive-token")
+        with tempfile.TemporaryDirectory() as folder:
+            result = export(adapter, "1", [dict(document_no="R1")], folder)
+            self.assertEqual(result["errors"][0]["stage"], "download")
+            self.assertNotIn("sensitive-token", json.dumps(result))
+
+    def test_recipients_from_open_case_and_ad_override(self):
+        request = SimpleNamespace(state=SimpleNamespace(principal=SimpleNamespace(email="self@example.com")))
+        db = Mock()
+        db.execute.return_value.mappings.return_value.first.return_value = dict(
+            Verursacher_Aktuell_Benutzer_ID="override",
+            Kunde_Email="customer@example.com",
+            Vers_Email="insurance@example.com",
+        )
+        with patch(
+            "app.documents.lookup_directory_user",
+            return_value=SimpleNamespace(email="colleague@example.com", disabled=False),
+        ) as lookup:
+            targets = recipients(request, "1_R1", db)
+            lookup.assert_called_once_with("override")
+            self.assertEqual(
+                [t["email"] for t in targets],
+                ["self@example.com", "colleague@example.com", "customer@example.com", "insurance@example.com"],
+            )
+        self.assertEqual(db.execute.call_args.args[1], dict(client_db="1", beleg_nr="R1"))