Quellcode durchsuchen

Füge Unterstützung für die Einreichung von Kommentaren mit Dateianhängen hinzu

- Implementiere die Funktion `submit_comment` in `comment_submission.py`, um Kommentare mit Dateianhängen zu speichern.
- Aktualisiere die Konfiguration in `config.py`, um die maximale Dateigröße für Uploads zu definieren.
- Verbessere die Dateioperationen in `file_storage.py`, um sicherzustellen, dass Dateinamen validiert und Konflikte behandelt werden.
- Aktualisiere die Routen in `routes.py`, um die neue Kommentarfunktionalität zu integrieren.
- Füge JavaScript für die Handhabung von Dateianhängen in `attachments.js` hinzu.
- Aktualisiere die HTML-Templates, um die Benutzeroberfläche für die Kommentarfunktion zu verbessern und die Dateianhänge anzuzeigen.
- Implementiere Tests in `test_uploads.py`, um die neue Funktionalität zu überprüfen und sicherzustellen, dass Fehler ordnungsgemäß behandelt werden.
gc-server3 vor 2 Wochen
Ursprung
Commit
949e2d27e9

+ 1 - 0
.env.example

@@ -20,3 +20,4 @@ LDAP_UPN_SUFFIX=example.local
 LDAP_START_TLS=false
 LDAP_START_TLS=false
 
 
 LOCAL_STORAGE=C:\\Data\\GCEPIC
 LOCAL_STORAGE=C:\\Data\\GCEPIC
+UPLOAD_MAX_FILE_SIZE_MB=50

+ 53 - 0
README.md

@@ -159,3 +159,56 @@ Bei abgelaufener Anmeldung führen HTMX-Anfragen nach dem Login auf die vollstä
 gefilterte Liste beziehungsweise Detailseite. Kommentar- und Buchungsfragmente sowie
 gefilterte Liste beziehungsweise Detailseite. Kommentar- und Buchungsfragmente sowie
 technische Nachladeseiten sind keine Login-Rücksprungziele. Unterbrochene Schreibaktionen
 technische Nachladeseiten sind keine Login-Rücksprungziele. Unterbrochene Schreibaktionen
 werden nicht automatisch wiederholt; ungespeicherte Eingaben werden nicht gesichert.
 werden nicht automatisch wiederholt; ungespeicherte Eingaben werden nicht gesichert.
+
+
+## Dokumente im Kommentarbereich
+
+Über die Büroklammer oder durch Ablegen auf dem Kommentarfeld können mehrere Dateien
+beliebigen Typs ausgewählt werden. Die Auswahl mit Name, Größe und Entfernen-Schaltfläche
+bleibt bis „Abschicken“ ausschließlich im Browser. Kommentartext ist optional; reine
+Datei-Uploads erzeugen keinen Chat-Eintrag. Beim Verlassen oder Neuladen geht eine noch
+nicht abgeschickte Auswahl verloren.
+
+```dotenv
+LOCAL_STORAGE="C:\\Projekte\\Reisacher-Fileserver"
+UPLOAD_MAX_FILE_SIZE_MB=50
+```
+
+Die Größenbegrenzung gilt je Datei, einschließlich leerer Dateien: 50 MB entsprechen
+50 × 1024² = 52.428.800 Bytes. Auswahl und Server prüfen die Grenze. Neue Dateien liegen
+unter `LOCAL_STORAGE/{Client_DB}_{Beleg_Nr}/{Originalname}`. Altdateien unter
+`LOCAL_STORAGE/{Client_DB}/{Beleg_Nr}` bleiben sichtbar und herunterladbar; es gibt keine
+Migration. Unicode-Namen bleiben erhalten. Unter Windows unzulässige Dateinamen werden
+mit einer Fehlermeldung abgelehnt.
+
+Bei Namenskonflikten in beiden Belegordnern wird die ältere Datei in ihrem jeweiligen
+Ordner als `Name_YYYYMMDD_HHMMSS_ffffff.ext` archiviert (Europe/Berlin, bei Bedarf mit
+Zähler). Eine prozessübergreifende Belegsperre unter `.upload-locks` schützt die Ablage
+auch bei mehreren Workern. Dateien werden zuerst unter `.upload-staging` vollständig
+geschrieben und geprüft; danach erfolgt die Übernahme mit Rücknahme der Archivierung,
+wenn diese Datei nicht abgelegt werden kann. Diese internen Ordner sind über die
+Downloadroute nicht erreichbar. Der Ablagepfad darf nicht als öffentliches IIS-Verzeichnis
+oder als statischer Webpfad freigegeben werden.
+
+Das Anwendungskonto benötigt Lesen, Auflisten, Anlegen, Schreiben, Umbenennen und Löschen
+im Ablagestamm, beiden Belegstrukturen sowie den internen Arbeitsordnern. Für diese Ordner
+nur den erforderlichen Betriebskonten Zugriff geben. Alle Worker müssen denselben Stamm
+mit funktionierenden Dateisperren verwenden. Downloads prüfen weiterhin die Belegrechte.
+
+Erfolgreiche Dateien bleiben bei Teilerfolgen oder fehlgeschlagener Kommentarspeicherung
+bestehen. Nur fehlgeschlagene Dateien und nicht gespeicherter Text bleiben zur erneuten
+Übermittlung in der Auswahl. Nach einer erneuten Anmeldung werden Uploads nicht automatisch
+wiederholt. Kommentarhistorie und Dateiliste aktualisieren sich ohne Seitenneuladen.
+
+Bei IIS `system.webServer/security/requestFiltering/requestLimits/maxAllowedContentLength`
+(in Bytes) sowie gegebenenfalls vorgeschaltete Proxy- und Hosting-Limits auf die **gesamte**
+Multipart-Anfrage abstimmen: Anzahl gleichzeitig versandter Dateien × Dateilimit plus
+Kommentar und Multipart-Overhead. Das Dateilimit ist kein Gesamtlimit. Beispielsweise
+brauchen vier Dateien à 50 MB mehr als 209.715.200 Bytes; 220.200.960 Bytes (210 MiB)
+lassen hierfür zusätzlichen Overhead zu. Das übliche IIS-Standardlimit von 30.000.000 Bytes
+reicht bereits für eine 50-MB-Datei nicht aus. Eine serverseitig mit 413 abgewiesene Anfrage
+bleibt in der Auswahl; dann weniger Dateien gleichzeitig senden oder das Betriebslimit
+anpassen. Genügend temporären Speicher auch für den Multipart-Parser des Servers einplanen.
+
+Uploadtests verwenden ausschließlich temporäre Verzeichnisse:
+`python -m unittest discover -s tests -q`.

+ 97 - 0
app/comment_submission.py

@@ -0,0 +1,97 @@
+"""Independent file and comment results for the existing comment POST."""
+from datetime import datetime
+import json
+import logging
+import re
+
+from fastapi import HTTPException
+from sqlalchemy import text
+from starlette.concurrency import run_in_threadpool
+from starlette.datastructures import UploadFile
+
+from .config import settings
+from .file_storage import UploadError, get_files, save_upload
+from .forderung_history import history_context
+from .jinja_templates import templates
+from .permissions import current_principal
+
+logger = logging.getLogger(__name__)
+
+
+def _rollback(db):
+    try:
+        db.rollback()
+    except Exception:
+        logger.exception('Rollback nach fehlgeschlagenem Kommentar nicht möglich')
+
+
+async def submit_comment(request, pkey, db, scope):
+    results, messages = [], []
+    comment_saved = False
+    async with request.form() as form:
+        uploads = form.getlist('attachments')
+        comment = form.get('Kommentar', '')
+        try:
+            ids = json.loads(form.get('attachment_ids', '[]'))
+        except (ValueError, TypeError):
+            raise HTTPException(400, 'Die Dateiauswahl ist ungültig.')
+        if (not isinstance(comment, str) or not isinstance(ids, list) or len(ids) != len(uploads)
+                or any(not isinstance(item, UploadFile) for item in uploads)
+                or any(not isinstance(item, str) or not re.fullmatch(r'[A-Za-z0-9_-]{1,80}', item) for item in ids)
+                or len(set(ids)) != len(ids)):
+            raise HTTPException(400, 'Die Dateiauswahl ist ungültig.')
+
+        for identity, upload in zip(ids, uploads):
+            result = {'id': identity, 'name': upload.filename or '', 'success': False}
+            try:
+                await run_in_threadpool(save_upload, pkey, upload.filename or '', upload.file,
+                                        settings.UPLOAD_MAX_FILE_SIZE_MB * 1024 * 1024)
+                result['success'] = True
+            except UploadError as error:
+                result['error'] = str(error)
+            except OSError:
+                logger.exception('Datei konnte für %s nicht gespeichert werden', pkey)
+                result['error'] = 'Speichern fehlgeschlagen. Bitte Speicherplatz, Dateisperren und Zugriffsrechte prüfen.'
+            results.append(result)
+
+        if comment.strip():
+            data = dict(pkey, Benutzer_ID=current_principal(request).username,
+                        Timestamp=datetime.now().strftime('%Y-%m-%dT%H:%M:%S'), Kommentar=comment)
+            try:
+                query = templates.env.get_template('forderungen/details/queries/post/post_comments.sql').render(data=data)
+                for statement in query.split(';'):
+                    if statement.strip():
+                        db.execute(text(statement), data)
+                db.commit()
+                comment_saved = True
+            except Exception:
+                _rollback(db)
+                logger.exception('Kommentar konnte für %s nicht gespeichert werden', pkey)
+                messages.append('Der Kommentar konnte nicht gespeichert werden. Der Text bleibt im Eingabefeld.')
+        elif not uploads:
+            messages.append('Bitte einen Kommentar oder Dateien hinzufügen.')
+
+    context = {}
+    history_available = True
+    try:
+        context.update(history_context(db, pkey, 'kommentare', scope))
+    except Exception:
+        history_available = False
+        _rollback(db)
+        logger.exception('Kommentarhistorie konnte nicht aktualisiert werden')
+        messages.append('Die Kommentare konnten nicht aktualisiert werden. Bereits gespeicherte Ergebnisse bleiben erhalten.')
+    files_available = True
+    try:
+        context['files'] = get_files(pkey)
+    except OSError:
+        files_available = False
+        logger.exception('Dateiliste konnte nicht aktualisiert werden')
+        messages.append('Die Dateiliste konnte nicht aktualisiert werden. Bereits gespeicherte Dateien bleiben erhalten.')
+    context.update(
+        history_available=history_available, files_available=files_available, files_oob=True,
+        send_result={'files': results, 'comment_saved': comment_saved, 'messages': messages},
+    )
+    return templates.TemplateResponse(
+        request, 'forderungen/details/comment_submission.html', context,
+        headers={} if history_available else {'HX-Reswap': 'none'},
+    )

+ 1 - 0
app/config.py

@@ -46,6 +46,7 @@ class Settings(BaseSettings):
     LDAP_UPN_SUFFIX: str | None = None
     LDAP_UPN_SUFFIX: str | None = None
     LDAP_START_TLS: bool = False
     LDAP_START_TLS: bool = False
     LOCAL_STORAGE: str | None = None
     LOCAL_STORAGE: str | None = None
+    UPLOAD_MAX_FILE_SIZE_MB: int = Field(default=50, gt=0)
 
 
     @model_validator(mode="after")
     @model_validator(mode="after")
     def validate_security_settings(self) -> "Settings":
     def validate_security_settings(self) -> "Settings":

+ 217 - 19
app/file_storage.py

@@ -1,30 +1,228 @@
+"""Document storage, including legacy folders and collision-safe uploads."""
+from contextlib import contextmanager
+from dataclasses import dataclass
+from datetime import datetime
+import hashlib
+import logging
+import os
 from pathlib import Path
 from pathlib import Path
-from urllib.parse import unquote
+import re
+import tempfile
+import threading
+import time
+from weakref import WeakValueDictionary
+from zoneinfo import ZoneInfo
 
 
 from .config import settings
 from .config import settings
 
 
+logger = logging.getLogger(__name__)
+BERLIN = ZoneInfo('Europe/Berlin')
+_THREAD_LOCKS = WeakValueDictionary()
+_LOCK_GUARD = threading.Lock()
+_RESERVED = {'CON', 'PRN', 'AUX', 'NUL', 'CONIN$', 'CONOUT$'} | {
+    f'{prefix}{digit}' for prefix in ('COM', 'LPT') for digit in '123456789¹²³'
+}
 
 
-def get_files(primary_key: dict[str, str]):
-    fileserver = settings.LOCAL_STORAGE
-    if not fileserver:
+
+class UploadError(ValueError):
+    """An actionable error which can be displayed next to an individual file."""
+
+
+@dataclass(frozen=True)
+class StoredFile:
+    name: str
+    relative_path: str
+    modified_at: datetime
+    size: int
+
+
+def validate_filename(name: str) -> str:
+    if (not name or name in {'.', '..'} or name.endswith(('.', ' '))
+            or re.search(r'[<>:"/\\|?*\x00-\x1f\x7f]', name)
+            or name.split('.')[0].rstrip(' ').upper() in _RESERVED
+            or len(name.encode('utf-16-le', errors='surrogatepass')) // 2 > 255):
+        raise UploadError('Dieser Dateiname kann unter Windows nicht gespeichert werden.')
+    return name
+
+
+def _root() -> Path:
+    if not settings.LOCAL_STORAGE:
+        raise UploadError('Die Dateiablage ist nicht konfiguriert.')
+    return Path(settings.LOCAL_STORAGE).resolve()
+
+
+def _checked(root: Path, *parts: str) -> Path:
+    for part in parts:
+        validate_filename(part)
+    candidate = root.joinpath(*parts)
+    resolved = candidate.resolve()
+    if root not in resolved.parents or resolved != candidate:
+        raise UploadError('Der Ablagepfad ist nicht zulässig.')
+    return candidate
+
+
+def _folders(root: Path, key: dict[str, str]) -> tuple[Path, Path]:
+    tenant, number = str(key['Client_DB']), str(key['Beleg_Nr'])
+    validate_filename(tenant)
+    validate_filename(number)
+    return _checked(root, f'{tenant}_{number}'), _checked(root, tenant, number)
+
+
+def file_primary_key(name: str) -> dict[str, str]:
+    # Query parameters are already URL-decoded by the framework. Do not decode twice.
+    parts = name.replace('\\', '/').split('/')
+    if len(parts) not in (2, 3):
+        raise FileNotFoundError(name)
+    try:
+        for part in parts:
+            validate_filename(part)
+        if len(parts) == 2:
+            tenant, separator, number = parts[0].partition('_')
+            if not separator or not tenant or not number:
+                raise FileNotFoundError(name)
+        else:
+            tenant, number = parts[:2]
+    except UploadError as error:
+        raise FileNotFoundError(name) from error
+    return {'Client_DB': tenant, 'Beleg_Nr': number}
+
+
+def get_files(primary_key: dict[str, str]) -> list[StoredFile]:
+    if not settings.LOCAL_STORAGE:
         return []
         return []
-    root = Path(fileserver).resolve()
-    folder = (root / primary_key["Client_DB"] / primary_key["Beleg_Nr"]).resolve()
-    if root not in folder.parents:
+    root = _root()
+    try:
+        folders = _folders(root, primary_key)
+    except UploadError:
         return []
         return []
-    files = [
-        f.relative_to(root)
-        for f in folder.glob("*")
-        if f.is_file()
-    ]
-    return files
+    files = []
+    for folder in folders:
+        for entry in folder.glob('*'):
+            try:
+                filename = _checked(root, *entry.relative_to(root).parts)
+                if not filename.is_file():
+                    continue
+                stat = filename.stat()
+                files.append(StoredFile(filename.name, filename.relative_to(root).as_posix(),
+                                        datetime.fromtimestamp(stat.st_mtime, BERLIN), stat.st_size))
+            except (FileNotFoundError, UploadError):
+                continue
+    return sorted(files, key=lambda item: (-item.modified_at.timestamp(), item.name.casefold(), item.relative_path))
 
 
 
 
-def get_file(name: str):
+def get_file(name: str) -> Path:
     if not settings.LOCAL_STORAGE:
     if not settings.LOCAL_STORAGE:
-        raise FileNotFoundError("LOCAL_STORAGE is not configured")
-    root = Path(settings.LOCAL_STORAGE).resolve()
-    filename = (root / unquote(name)).resolve()
-    if root not in filename.parents or not filename.is_file():
         raise FileNotFoundError(name)
         raise FileNotFoundError(name)
-    return filename
+    file_primary_key(name)
+    try:
+        filename = _checked(_root(), *name.replace('\\', '/').split('/'))
+        if not filename.is_file():
+            raise FileNotFoundError(name)
+        return filename
+    except UploadError as error:
+        raise FileNotFoundError(name) from error
+
+
+@contextmanager
+def _beleg_lock(root: Path, folder: Path):
+    lock_dir = _checked(root, '.upload-locks')
+    lock_dir.mkdir(parents=True, exist_ok=True)
+    digest = hashlib.sha256(str(folder).casefold().encode('utf-8')).hexdigest()
+    lock_path = _checked(root, '.upload-locks', digest + '.lock')
+    with _LOCK_GUARD:
+        lock = _THREAD_LOCKS.setdefault(str(lock_path), threading.Lock())
+    deadline = time.monotonic() + 30
+    if not lock.acquire(timeout=30):
+        raise UploadError('Die Dateiablage ist gerade belegt. Bitte erneut abschicken.')
+    try:
+        with lock_path.open('a+b') as handle:
+            if handle.tell() == 0:
+                handle.write(b'0')
+                handle.flush()
+            locked = False
+            try:
+                while not locked:
+                    try:
+                        handle.seek(0)
+                        if os.name == 'nt':
+                            import msvcrt
+                            msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
+                        else:
+                            import fcntl
+                            fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
+                        locked = True
+                    except OSError:
+                        if time.monotonic() >= deadline:
+                            raise UploadError('Die Dateiablage ist gerade belegt. Bitte erneut abschicken.')
+                        time.sleep(.05)
+                yield
+            finally:
+                if locked:
+                    handle.seek(0)
+                    if os.name == 'nt':
+                        import msvcrt
+                        msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
+                    else:
+                        import fcntl
+                        fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
+    finally:
+        lock.release()
+
+
+def _archive_name(root: Path, original: Path) -> Path:
+    stamp = datetime.now(BERLIN).strftime('%Y%m%d_%H%M%S_%f')
+    counter = 0
+    while True:
+        suffix = f'_{counter}' if counter else ''
+        name = f'{original.stem}_{stamp}{suffix}{original.suffix}'
+        archive = _checked(root, *original.parent.relative_to(root).parts, name)
+        if not archive.exists():
+            return archive
+        counter += 1
+
+
+def save_upload(primary_key: dict[str, str], name: str, stream, max_bytes: int) -> str:
+    """Stage and finalize one file; keep earlier successful files independent."""
+    validate_filename(name)
+    root = _root()
+    destination, legacy = _folders(root, primary_key)
+    staging = _checked(root, '.upload-staging')
+    staging.mkdir(parents=True, exist_ok=True)
+    temporary = None
+    try:
+        with tempfile.NamedTemporaryFile(dir=staging, suffix='.tmp', delete=False) as output:
+            temporary = Path(output.name)
+            stream.seek(0)
+            size = 0
+            while chunk := stream.read(1024 * 1024):
+                size += len(chunk)
+                if size > max_bytes:
+                    raise UploadError(f'Die Datei überschreitet die erlaubten {settings.UPLOAD_MAX_FILE_SIZE_MB} MB.')
+                output.write(chunk)
+            output.flush()
+            os.fsync(output.fileno())
+        with _beleg_lock(root, destination):
+            destination.mkdir(parents=True, exist_ok=True)
+            target = _checked(root, *destination.relative_to(root).parts, name)
+            previous = []
+            try:
+                for folder in (destination, legacy):
+                    existing = _checked(root, *folder.relative_to(root).parts, name)
+                    if existing.exists():
+                        if not existing.is_file():
+                            raise UploadError('Unter diesem Dateinamen besteht bereits ein Ordner.')
+                        archive = _archive_name(root, existing)
+                        existing.rename(archive)
+                        previous.append((existing, archive))
+                os.replace(temporary, target)
+            except Exception:
+                for existing, archive in reversed(previous):
+                    try:
+                        archive.rename(existing)
+                    except OSError:
+                        logger.exception('Archivierte Datei konnte nicht zurückbenannt werden: %s', archive)
+                raise
+        return target.relative_to(root).as_posix()
+    finally:
+        if temporary and temporary.exists():
+            temporary.unlink()

+ 39 - 37
app/routes.py

@@ -18,11 +18,12 @@ from fastapi.responses import (
 from sqlalchemy import text
 from sqlalchemy import text
 from sqlalchemy.orm import Session
 from sqlalchemy.orm import Session
 
 
-from app.file_storage import get_file, get_files
+from app.file_storage import file_primary_key, get_file, get_files
 from app.query_filter import import_filter_config
 from app.query_filter import import_filter_config
 
 
 from .auth import clear_session_cookie, safe_next_url, set_session_cookie
 from .auth import clear_session_cookie, safe_next_url, set_session_cookie
 from .config import settings
 from .config import settings
+from .comment_submission import submit_comment
 from .db import get_session
 from .db import get_session
 from .docuware_search import get_invoice_pdf_by_document_no
 from .docuware_search import get_invoice_pdf_by_document_no
 from .forderung_history import HistoryScope, history_context, section_context
 from .forderung_history import HistoryScope, history_context, section_context
@@ -290,7 +291,14 @@ def get_template_context(request: Request, pkey: dict[str, str], db: Session) ->
         template_context[filename] = db.execute(text(query), values).fetchall()
         template_context[filename] = db.execute(text(query), values).fetchall()
     for section in ("kommentare", "buchungen"):
     for section in ("kommentare", "buchungen"):
         template_context.update(section_context(db, pkey, template_context["forderungen_details"][0], section))
         template_context.update(section_context(db, pkey, template_context["forderungen_details"][0], section))
-    template_context["files"] = get_files(pkey)
+    try:
+        template_context["files"] = get_files(pkey)
+    except OSError:
+        logger.exception('Dateiliste konnte nicht geladen werden')
+        template_context["files"] = []
+        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")
     template_context["add7days"] = (datetime.now() + timedelta(days=7)).strftime("%Y-%m-%d")
     return template_context
     return template_context
 
 
@@ -330,35 +338,31 @@ async def post_forderungen_details(
     ensure_forderung_access(db, principal, pkey["Client_DB"], pkey["Beleg_Nr"])
     ensure_forderung_access(db, principal, pkey["Client_DB"], pkey["Beleg_Nr"])
 
 
     if source == "comments":
     if source == "comments":
-        data = {
-            "Client_DB": "",
-            "Beleg_Nr": "",
-            "Timestamp": datetime.now().strftime("%Y-%m-%dT%H:%M:%S"),
-            "Benutzer_ID": principal.username,
-            "Kommentar": "",
-        }
-    else:
-        data = {
-            "Client_DB": "",
-            "Beleg_Nr": "",
-            "Bearbeitet": "J",
-            "Versicherung": "N",
-            "Rechtsanwalt": "N",
-            "Reklamation": "N",
-            "Mahnen_aussetzen": "N",
-            "Selbstbeteiligung": "",
-            "Selbstbeteiligung_Betrag": "0.0",
-            "Mwst": "",
-            "Mwst_Betrag": "0.0",
-            "Wiedervorlage": "",
-            "Vers_Adresse_ID": "",
-            "Versicherung_Typ": "",
-            "Reklamation_Begruendung": "",
-            "Rechtsanwalt_Begruendung": "",
-            "Rechtsanwalt_Aktenzeichen": "",
-            "Rechtsanwalt_Stand": "",
-            "Mahnen_Begruendung": "",
-        }
+        return await submit_comment(request, pkey, db, comment_scope)
+    if source != "details":
+        raise HTTPException(status_code=400, detail="Ungültige Datenquelle")
+
+    data = {
+        "Client_DB": "",
+        "Beleg_Nr": "",
+        "Bearbeitet": "J",
+        "Versicherung": "N",
+        "Rechtsanwalt": "N",
+        "Reklamation": "N",
+        "Mahnen_aussetzen": "N",
+        "Selbstbeteiligung": "",
+        "Selbstbeteiligung_Betrag": "0.0",
+        "Mwst": "",
+        "Mwst_Betrag": "0.0",
+        "Wiedervorlage": "",
+        "Vers_Adresse_ID": "",
+        "Versicherung_Typ": "",
+        "Reklamation_Begruendung": "",
+        "Rechtsanwalt_Begruendung": "",
+        "Rechtsanwalt_Aktenzeichen": "",
+        "Rechtsanwalt_Stand": "",
+        "Mahnen_Begruendung": "",
+    }
     data.update(pkey)
     data.update(pkey)
 
 
     async with request.form() as form:
     async with request.form() as form:
@@ -385,9 +389,6 @@ async def post_forderungen_details(
         if request.headers.get('HX-Request', '').lower() == 'true':
         if request.headers.get('HX-Request', '').lower() == 'true':
             return HTMLResponse('', headers={'HX-Redirect': return_url})
             return HTMLResponse('', headers={'HX-Redirect': return_url})
         return RedirectResponse(url=return_url, status_code=303)
         return RedirectResponse(url=return_url, status_code=303)
-    if source == "comments":
-        context = history_context(db, pkey, "kommentare", comment_scope)
-        return templates.TemplateResponse(request, "forderungen/details/details_kommentare.html", context)
     template_context = get_template_context(request, pkey, db)
     template_context = get_template_context(request, pkey, db)
     template_context.update(detail_navigation_context(request, primary_key, return_url))
     template_context.update(detail_navigation_context(request, primary_key, return_url))
     return templates.TemplateResponse(request, "forderungen/details/details_formular.html", template_context)
     return templates.TemplateResponse(request, "forderungen/details/details_formular.html", template_context)
@@ -457,10 +458,11 @@ def export_docx2(request: Request, client_db: str, document_no: str, db: Session
 def get_files2(request: Request, db: Session = Depends(get_session)):
 def get_files2(request: Request, db: Session = Depends(get_session)):
     principal = current_principal(request)
     principal = current_principal(request)
     name = request.query_params.get("name", "")
     name = request.query_params.get("name", "")
-    parts = Path(unquote(name)).parts
-    if len(parts) < 3:
+    try:
+        pkey = file_primary_key(name)
+    except FileNotFoundError:
         raise HTTPException(status_code=404, detail="Datei nicht gefunden")
         raise HTTPException(status_code=404, detail="Datei nicht gefunden")
-    ensure_forderung_access(db, principal, parts[0], parts[1])
+    ensure_forderung_access(db, principal, pkey['Client_DB'], pkey['Beleg_Nr'])
     try:
     try:
         filename = get_file(name)
         filename = get_file(name)
     except FileNotFoundError:
     except FileNotFoundError:

+ 14 - 0
static/assets/css/details.css

@@ -42,6 +42,7 @@ body:has(.gc-detail-page) { max-width: 1840px; }
 .gc-case-grid { display: grid; grid-template-columns: minmax(0, 3fr) minmax(0, 5fr) minmax(0, 4fr); gap: 1.25rem; align-items: start; padding: 0 1.25rem; }
 .gc-case-grid { display: grid; grid-template-columns: minmax(0, 3fr) minmax(0, 5fr) minmax(0, 4fr); gap: 1.25rem; align-items: start; padding: 0 1.25rem; }
 .gc-case-data, .gc-case-conversation, .gc-case-actions { display: flex; flex-direction: column; gap: 1rem; }
 .gc-case-data, .gc-case-conversation, .gc-case-actions { display: flex; flex-direction: column; gap: 1rem; }
 .gc-detail-card, .gc-detail-page .chat-container { background: #fff; border: 1px solid #dce2e7; border-radius: 10px; box-shadow: 0 2px 6px #18263004; }
 .gc-detail-card, .gc-detail-page .chat-container { background: #fff; border: 1px solid #dce2e7; border-radius: 10px; box-shadow: 0 2px 6px #18263004; }
+.gc-detail-page :is(#comments-title, #actions-title) { background: var(--gc-brand-action); color: #fff; }
 .gc-data-fields { margin: 0; padding: .4rem 1rem; }
 .gc-data-fields { margin: 0; padding: .4rem 1rem; }
 .gc-data-field { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr); gap: .65rem; padding: .5rem 0; border-bottom: 1px solid #edf0f2; font-size: 1.0625rem; line-height: 1.5; }
 .gc-data-field { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr); gap: .65rem; padding: .5rem 0; border-bottom: 1px solid #edf0f2; font-size: 1.0625rem; line-height: 1.5; }
 .gc-data-field:last-child { border-bottom: 0; }
 .gc-data-field:last-child { border-bottom: 0; }
@@ -147,3 +148,16 @@ body:has(.gc-detail-page) { max-width: 1840px; }
   .gc-case-grid { padding: 0 .5rem; gap: 1rem; }
   .gc-case-grid { padding: 0 .5rem; gap: 1rem; }
   .gc-case-bookings { margin: 0 .5rem; }
   .gc-case-bookings { margin: 0 .5rem; }
 }
 }
+
+/* Pending attachments stay local until the comment form is submitted. */
+.gc-detail-page #comment-form { flex-direction: column; align-items: stretch; }
+.gc-detail-page .gc-comment-compose { display: flex; gap: .5rem; align-items: center; }
+.gc-detail-page .gc-comment-compose button { flex-shrink: 0; }
+.gc-detail-page .gc-upload-hint { margin: 0; color: #4b6072; font-size: .9rem; }
+.gc-detail-page .gc-upload-queue { list-style: none; padding: 0; margin: 0; max-height: 14rem; overflow: auto; }
+.gc-detail-page .gc-upload-queue li { display: flex; align-items: center; justify-content: space-between; gap: .5rem; border-top: 1px solid #dce2e7; padding: .5rem 0; }
+.gc-detail-page .gc-upload-queue li > span { min-width: 0; overflow-wrap: anywhere; }
+.gc-detail-page .gc-upload-error { display: block; color: #b30808; }
+.gc-detail-page .gc-upload-status { margin: 0; overflow-wrap: anywhere; }
+.gc-detail-page .gc-upload-status:empty, .gc-detail-page .gc-upload-queue:empty { display: none; }
+.gc-detail-page .message-input.gc-drop-active { outline: 3px solid #72889b; background: #edf1f4; }

+ 1 - 1
static/assets/css/list.css

@@ -24,7 +24,7 @@ body:has(.gc-list-page) { max-width: 1840px; }
 .gc-list-table thead { position: static; }
 .gc-list-table thead { position: static; }
 /* A visually hidden bottom caption extends the scroll height by one pixel. */
 /* A visually hidden bottom caption extends the scroll height by one pixel. */
 .gc-list-table caption { caption-side: top; }
 .gc-list-table caption { caption-side: top; }
-.gc-list-table th { background: var(--gc-brand-action); color: #101b24; font-size: 1rem; font-weight: 700; border-bottom: 1px solid #526b7e; line-height: 1.45; hyphens: auto; }
+.gc-list-table th { background: var(--gc-brand-action); color: #fff; font-size: 1rem; font-weight: 700; border-bottom: 1px solid #526b7e; line-height: 1.45; hyphens: auto; }
 .gc-list-table tbody td { background: #fff; border-bottom: 1px solid #e5e9ed; line-height: 1.5; }
 .gc-list-table tbody td { background: #fff; border-bottom: 1px solid #e5e9ed; line-height: 1.5; }
 .gc-list-table .gc-invoice-row:hover td { background: #f6f8fa; }
 .gc-list-table .gc-invoice-row:hover td { background: #f6f8fa; }
 .gc-list-table .gc-invoice-row.gc-return-highlight td { background: #dfeaf1; }
 .gc-list-table .gc-invoice-row.gc-return-highlight td { background: #dfeaf1; }

+ 86 - 0
static/assets/js/attachments.js

@@ -0,0 +1,86 @@
+(() => {
+  const initialized = new WeakSet();
+  function init(root = document) {
+    const form = root.matches?.('#comment-form') ? root : root.querySelector?.('#comment-form');
+    if (!form || initialized.has(form)) return;
+    initialized.add(form);
+    const picker = form.querySelector('[type=file]'), manifest = form.querySelector('[name=attachment_ids]');
+    const text = form.querySelector('[name=Kommentar]'), send = form.querySelector('[type=submit]');
+    const attach = form.querySelector('[data-gc-attach]'), list = form.querySelector('.gc-upload-queue');
+    const status = form.querySelector('.gc-upload-status');
+    const limit = Number(form.dataset.maxFileSize);
+    let queue = [], busy = false;
+    function render() {
+      const transfer = new DataTransfer(), ids = [];
+      list.replaceChildren();
+      for (const item of queue) {
+        if (item.file.size <= limit) { transfer.items.add(item.file); ids.push(item.id); }
+        const row = document.createElement('li'), info = document.createElement('span');
+        info.textContent = `${item.file.name} (${new Intl.NumberFormat('de-DE', {maximumFractionDigits: 2}).format(item.file.size / 1024)} KB)`;
+        if (item.error) {
+          const error = document.createElement('span'); error.className = 'gc-upload-error';
+          error.textContent = item.error; info.append(error);
+        }
+        const remove = document.createElement('button'); remove.type = 'button'; remove.className = 'btn btn-outline-secondary';
+        remove.textContent = 'Entfernen'; remove.setAttribute('aria-label', `${item.file.name} entfernen`); remove.disabled = busy;
+        remove.addEventListener('click', () => { status.textContent = ''; queue = queue.filter(entry => entry.id !== item.id); render(); });
+        row.append(info, remove); list.append(row);
+      }
+      picker.files = transfer.files; manifest.value = JSON.stringify(ids);
+      picker.disabled = attach.disabled = text.disabled = busy;
+      send.disabled = busy || (!text.value.trim() && !ids.length);
+      form.setAttribute('aria-busy', String(busy));
+    }
+    function add(files) {
+      if (busy) return;
+      status.textContent = '';
+      for (const file of files) queue.push({file, id: crypto.randomUUID?.() || `file_${Date.now()}_${Math.random().toString(36).slice(2)}`, error: file.size > limit ? `Maximal ${form.dataset.maxFileSizeMb} MB je Datei.` : ''});
+      render();
+    }
+    attach.addEventListener('click', () => picker.click());
+    picker.addEventListener('change', () => add(Array.from(picker.files)));
+    text.addEventListener('input', () => { status.textContent = ''; render(); });
+    text.addEventListener('dragover', event => {
+      if (Array.from(event.dataTransfer.types).includes('Files')) { event.preventDefault(); text.classList.add('gc-drop-active'); }
+    });
+    text.addEventListener('dragleave', () => text.classList.remove('gc-drop-active'));
+    text.addEventListener('drop', event => {
+      text.classList.remove('gc-drop-active');
+      if (!Array.from(event.dataTransfer.types).includes('Files')) return;
+      event.preventDefault();
+      const entries = Array.from(event.dataTransfer.items).map(item => item.webkitGetAsEntry?.());
+      if (entries.some(entry => entry?.isDirectory)) { status.textContent = 'Bitte einzelne Dateien statt Ordner auswählen.'; return; }
+      add(Array.from(event.dataTransfer.files));
+    });
+    form.addEventListener('submit', event => { if (busy || send.disabled) { event.preventDefault(); event.stopImmediatePropagation(); } }, true);
+    form.addEventListener('htmx:beforeRequest', event => {
+      if (event.detail.elt !== form) return;
+      busy = true; render(); status.textContent = 'Wird gesendet …';
+    });
+    form.addEventListener('htmx:afterRequest', event => {
+      if (event.detail.elt !== form) return;
+      busy = false;
+      const response = new DOMParser().parseFromString(event.detail.xhr.responseText || '', 'text/html');
+      const metadata = response.querySelector('[data-gc-send-result]');
+      if (metadata) {
+        const result = JSON.parse(metadata.textContent);
+        const successes = new Set(result.files.filter(file => file.success).map(file => file.id));
+        queue = queue.filter(item => !successes.has(item.id));
+        for (const file of result.files) {
+          const item = queue.find(item => item.id === file.id); if (item) item.error = file.error || '';
+        }
+        if (result.comment_saved) text.value = '';
+        status.textContent = [result.comment_saved ? 'Kommentar gespeichert.' : '', successes.size ? `${successes.size} Datei(en) gespeichert.` : '', ...result.messages, queue.some(item => item.error) ? 'Bitte die Hinweise bei den Dateien beachten.' : ''].filter(Boolean).join(' ');
+      } else {
+        status.textContent = event.detail.xhr.status === 413 ? 'Die gesamte Anfrage ist zu groß. Bitte weniger Dateien auf einmal abschicken.' : 'Der Versand konnte nicht bestätigt werden. Die Eingabe bleibt erhalten.';
+      }
+      form.closest('#chat')?.querySelectorAll('[data-gc-send-result]').forEach(node => node.remove());
+      render();
+    });
+    render();
+  }
+  document.addEventListener('DOMContentLoaded', () => init());
+  document.addEventListener('htmx:load', event => init(event.detail.elt));
+  window.addEventListener('pageshow', () => init());
+})();
+

+ 1 - 0
templates/base/base.html

@@ -28,6 +28,7 @@
 
 
   <link href="/static/assets/css/details.css" rel="stylesheet">
   <link href="/static/assets/css/details.css" rel="stylesheet">
   <link href="/static/assets/css/list.css" rel="stylesheet">
   <link href="/static/assets/css/list.css" rel="stylesheet">
+  <script src="/static/assets/js/attachments.js" defer></script>
   <script src="/static/assets/js/navigation.js" defer></script>
   <script src="/static/assets/js/navigation.js" defer></script>
   <script src="/static/assets/js/list.js" defer></script>
   <script src="/static/assets/js/list.js" defer></script>
   <script src="/static/assets/js/layout.js" defer></script>
   <script src="/static/assets/js/layout.js" defer></script>

+ 12 - 6
templates/base/chat_container.html

@@ -8,18 +8,24 @@
             <form id="comment-form" method="post" action="{{ detail_post_url|default(detail_url) }}" hx-post="{{ detail_post_url|default(detail_url) }}"
             <form id="comment-form" method="post" action="{{ detail_post_url|default(detail_url) }}" hx-post="{{ detail_post_url|default(detail_url) }}"
                 hx-target="#comments-history" hx-swap="outerHTML" hx-push-url="false"
                 hx-target="#comments-history" hx-swap="outerHTML" hx-push-url="false"
                 hx-sync="#comments-history:replace"
                 hx-sync="#comments-history:replace"
-                hx-disabled-elt="#comment-form input, #comment-form button, #comments-history button"
-                hx-on::after-request="if (event.detail.successful) this.querySelector('[name=Kommentar]').value = ''">
+                hx-disabled-elt="#comments-history button" enctype="multipart/form-data" hx-encoding="multipart/form-data"
+                data-max-file-size="{{ upload_max_bytes|default(50 * 1024 * 1024) }}" data-max-file-size-mb="{{ upload_max_mb|default(50) }}">
                 <input type="hidden" name="_csrf" value="{{ request.state.principal.csrf }}" />
                 <input type="hidden" name="_csrf" value="{{ request.state.principal.csrf }}" />
-                <button type="button" class="btn btn-outline-secondary" aria-label="Dateianhang (noch nicht verfügbar)" disabled>
+                <input type="file" name="attachments" id="comment-attachments" multiple hidden>
+                <input type="hidden" name="attachment_ids" value="[]">
+                <div class="gc-comment-compose">
+                <button type="button" class="btn btn-outline-secondary" data-gc-attach aria-label="Dateien anhängen" title="Dateien anhängen">
                     <i class="icon icon-lg cil-paperclip"></i>
                     <i class="icon icon-lg cil-paperclip"></i>
                 </button>
                 </button>
                 <input type="hidden" name="source" value="comments" />
                 <input type="hidden" name="source" value="comments" />
                 <input type="text" class="message-input" id="comment" name="Kommentar" placeholder="Kommentieren ..."
                 <input type="text" class="message-input" id="comment" name="Kommentar" placeholder="Kommentieren ..."
-                    aria-label="Kommentar" />
-                <button type="submit" class="btn btn-primary" aria-label="Kommentar speichern">
-                    <i class="icon icon-lg cil-chevron-right"></i>
+                    aria-label="Kommentar" aria-describedby="attachment-hint" />
+                <button type="submit" class="btn btn-primary" disabled>Abschicken
                 </button>
                 </button>
+                </div>
+                <p class="gc-upload-hint" id="attachment-hint">Dateien hier ablegen oder über die Büroklammer auswählen. Max. {{ upload_max_mb|default(50) }} MB je Datei.</p>
+                <ul class="gc-upload-queue" aria-label="Ausgewählte Dateien"></ul>
+                <p class="gc-upload-status" role="status" aria-live="polite"></p>
             </form>
             </form>
 
 
 
 

+ 3 - 0
templates/forderungen/details/comment_submission.html

@@ -0,0 +1,3 @@
+{% if history_available %}{% include 'forderungen/details/details_kommentare.html' %}{% endif %}
+{% if files_available %}{% include 'forderungen/details/files_panel.html' %}{% endif %}
+<script type="application/json" data-gc-send-result>{{ send_result|tojson }}</script>

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

@@ -20,14 +20,7 @@
   </aside>
   </aside>
   <div class="gc-case-conversation">
   <div class="gc-case-conversation">
     {% include 'base/chat_container.html' %}
     {% include 'base/chat_container.html' %}
-    {% if files|length > 0 %}
-    <section class="gc-detail-card" aria-labelledby="files-title">
-      <h2 class="gc-section-title" id="files-title"><i class="icon cil-folder-open" aria-hidden="true"></i> Dateien</h2>
-      <div class="gc-table-scroll" tabindex="0" role="region" aria-label="Dateien">
-        {% include 'forderungen/details/details_dateien.html' %}
-      </div>
-    </section>
-    {% endif %}
+    {% include 'forderungen/details/files_panel.html' %}
   </div>
   </div>
   <aside class="gc-case-actions" aria-label="Zusätzliche Informationen">
   <aside class="gc-case-actions" aria-label="Zusätzliche Informationen">
     <section class="gc-detail-card" aria-labelledby="actions-title">
     <section class="gc-detail-card" aria-labelledby="actions-title">

+ 5 - 3
templates/forderungen/details/details_dateien.html

@@ -8,9 +8,11 @@
     <tbody>
     <tbody>
         {% for row in files %}
         {% for row in files %}
         <tr>
         <tr>
-            <td></td>
-            <td><a href="/files/?name={{ row }}" hx-disable="true">{{ row }}</a></td>
+            <td>{{ row.modified_at.strftime('%d.%m.%Y %H:%M') }}</td>
+            <td><a href="/files/?name={{ row.relative_path|urlencode }}" hx-disable="true">{{ row.name }}</a></td>
         </tr>
         </tr>
+        {% else %}
+        <tr><td colspan="2">Keine Dateien vorhanden.</td></tr>
         {% endfor %}
         {% endfor %}
     </tbody>
     </tbody>
-</table>
+</table>

+ 7 - 0
templates/forderungen/details/files_panel.html

@@ -0,0 +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>
+</section>

+ 193 - 0
tests/test_uploads.py

@@ -0,0 +1,193 @@
+import io
+import json
+import multiprocessing
+from pathlib import Path
+import re
+import tempfile
+import unittest
+from concurrent.futures import ThreadPoolExecutor
+from datetime import datetime
+from unittest.mock import patch
+
+from app.config import settings
+from app.file_storage import BERLIN, UploadError, get_file, get_files, save_upload
+from tests import test_forderung_history as history_fixture
+
+KEY = {'Client_DB': '1', 'Beleg_Nr': 'R1'}
+
+def worker(root, value):
+    settings.LOCAL_STORAGE = root
+    save_upload(KEY, 'same.txt', io.BytesIO(value), 100)
+
+class StorageTests(unittest.TestCase):
+    def setUp(self):
+        self.temp = tempfile.TemporaryDirectory()
+        self.addCleanup(self.temp.cleanup)
+        self.root = Path(self.temp.name)
+        config = patch.object(settings, 'LOCAL_STORAGE', self.temp.name)
+        config.start(); self.addCleanup(config.stop)
+
+    def save(self, name='a.txt', data=b'new', limit=100):
+        return save_upload(KEY, name, io.BytesIO(data), limit)
+
+    def test_unicode_empty_and_exact_limit(self):
+        for name, data in [('Rechnung ä + #%20.pdf', b'12345'), ('empty.bin', b'')]:
+            relative = self.save(name, data, 5)
+            self.assertEqual(get_file(relative).read_bytes(), data)
+        self.assertEqual(len(get_files(KEY)), 2)
+
+    def test_invalid_names_and_paths(self):
+        for name in ['', '.', '..', '../escape', 'a/b', 'a\\b', 'CON.txt', 'LPT¹', 'a:', 'x?', 'x ', 'x.', 'a\x00b', 'a'*256]:
+            with self.subTest(name=name), self.assertRaises(UploadError):
+                self.save(name)
+        self.assertFalse(list(self.root.iterdir()))
+        for name in ['../outside', '/1_R1/a', '1_R1/../a', '.upload-staging/a']:
+            with self.assertRaises(FileNotFoundError): get_file(name)
+
+    def test_size_checked_before_archiving(self):
+        self.save(data=b'old')
+        with self.assertRaises(UploadError): self.save(data=b'123456', limit=5)
+        self.assertEqual([f.name for f in get_files(KEY)], ['a.txt'])
+        self.assertEqual(get_file('1_R1/a.txt').read_bytes(), b'old')
+        self.assertEqual(list((self.root/'.upload-staging').iterdir()), [])
+
+    def test_both_layouts_archive_and_timestamp_counter(self):
+        self.save(data=b'first')
+        legacy = self.root/'1'/'R1'; legacy.mkdir(parents=True)
+        (legacy/'a.txt').write_bytes(b'legacy')
+        with patch('app.file_storage.datetime') as clock:
+            clock.now.return_value = datetime(2026, 1, 2, 3, 4, 5, 123456, tzinfo=BERLIN)
+            self.save(data=b'second'); self.save(data=b'third')
+            clock.now.assert_called_with(BERLIN)
+        files = get_files(KEY)
+        self.assertEqual(len(files), 4)
+        self.assertTrue((self.root/'1_R1/a_20260102_030405_123456_1.txt').exists())
+        self.assertTrue((legacy/'a_20260102_030405_123456.txt').exists())
+        self.assertEqual(get_file('1_R1/a.txt').read_bytes(), b'third')
+        self.assertEqual([f.modified_at.timestamp() for f in files], sorted([f.modified_at.timestamp() for f in files], reverse=True))
+
+    def test_failed_final_replace_restores_both_files(self):
+        self.save(data=b'newfolder')
+        legacy = self.root/'1/R1'; legacy.mkdir(parents=True); (legacy/'a.txt').write_bytes(b'legacy')
+        with patch('app.file_storage.os.replace', side_effect=PermissionError('locked')):
+            with self.assertRaises(PermissionError): self.save()
+        self.assertEqual(get_file('1_R1/a.txt').read_bytes(), b'newfolder')
+        self.assertEqual(get_file('1/R1/a.txt').read_bytes(), b'legacy')
+        self.assertEqual(len(get_files(KEY)), 2)
+
+    def test_parallel_threads_preserve_all_versions(self):
+        with ThreadPoolExecutor(max_workers=6) as pool:
+            list(pool.map(lambda n: self.save(data=str(n).encode()), range(10)))
+        self.assertEqual({get_file(f.relative_path).read_bytes() for f in get_files(KEY)}, {str(n).encode() for n in range(10)})
+
+    def test_parallel_processes_preserve_all_versions(self):
+        processes = [multiprocessing.get_context('spawn').Process(target=worker, args=(self.temp.name, str(n).encode())) for n in range(4)]
+        for process in processes: process.start()
+        for process in processes:
+            process.join(20)
+            if process.is_alive(): process.terminate(); self.fail('Upload process timed out')
+            self.assertEqual(process.exitcode, 0)
+        self.assertEqual(len(get_files(KEY)), 4)
+
+class UploadRouteTests(unittest.TestCase):
+    def setUp(self):
+        self.fixture = history_fixture.HistoryTests()
+        self.fixture.setUp()
+        self.addCleanup(self.fixture.doCleanups)
+        self.client, self.db = self.fixture.client, self.fixture.db
+        self.temp = tempfile.TemporaryDirectory(); self.addCleanup(self.temp.cleanup)
+        config = patch.multiple(settings, LOCAL_STORAGE=self.temp.name, UPLOAD_MAX_FILE_SIZE_MB=1)
+        config.start(); self.addCleanup(config.stop)
+
+    def post(self, files=(), comment='', headers=None, key='1_R1', ids=None):
+        return self.client.post('/app/forderungen/details/'+key,
+            data={'source':'comments','Kommentar':comment,'attachment_ids': json.dumps(ids if ids is not None else [str(n) for n in range(len(files))]),'comment_scope':'debtor'},
+            files=[('attachments', (name, body, 'application/octet-stream')) for name, body in files],
+            headers={'X-CSRF-Token':'csrf'} if headers is None else headers)
+
+    def result(self, response):
+        self.assertEqual(response.status_code, 200, response.text)
+        return json.loads(re.search(r'data-gc-send-result>(.*?)</script>', response.text, re.S)[1])
+
+    def test_file_only_no_comment_and_first_list(self):
+        response = self.post([('ä # + %20.txt', b'content')])
+        result = self.result(response)
+        self.assertFalse(result['comment_saved']); self.assertTrue(result['files'][0]['success'])
+        self.assertEqual(self.db.commits, 0)
+        self.assertIn('id="case-files"', response.text)
+        self.assertIn('hx-swap-oob="outerHTML"', response.text)
+        name = get_files(KEY)[0].relative_path
+        download = self.client.get('/files/', params={'name': name})
+        self.assertEqual(download.content, b'content'); self.assertEqual(download.status_code, 200)
+        self.assertIn('filename*=', download.headers['content-disposition'])
+
+    def test_combined_partial_and_comment_saved_once(self):
+        result = self.result(self.post([('a.txt', b'ok'), ('CON.txt', b'bad')], '123,45 <Text>'))
+        self.assertEqual([r['success'] for r in result['files']], [True, False])
+        self.assertTrue(result['comment_saved'])
+        row = self.db.connection.execute('SELECT Kommentar FROM dbo.Forderungen_Kommentar ORDER BY rowid DESC LIMIT 1').fetchone()
+        self.assertEqual(row[0], '123,45 <Text>')
+        retry = self.result(self.post([('CON.txt', b'bad')]))
+        self.assertFalse(retry['comment_saved']); self.assertEqual(self.db.commits, 1)
+        self.assertEqual(len(get_files(KEY)), 1)
+
+    def test_same_filenames_are_matched_by_individual_id(self):
+        original = save_upload
+        calls = 0
+        def save(key, name, stream, limit):
+            nonlocal calls
+            calls += 1
+            if calls == 1: raise UploadError('Please retry this file')
+            return original(key, name, stream, limit)
+        with patch('app.comment_submission.save_upload', side_effect=save):
+            result = self.result(self.post([('same.txt', b'first'), ('same.txt', b'second')], ids=['first', 'second']))
+        self.assertEqual([(f['id'], f['success']) for f in result['files']], [('first', False), ('second', True)])
+        self.assertEqual(get_file('1_R1/same.txt').read_bytes(), b'second')
+
+    def test_comment_failure_keeps_successful_file(self):
+        execute = self.db.execute
+        def failing(query, values=None):
+            if 'INSERT INTO' in str(query): raise RuntimeError('database unavailable')
+            return execute(query, values)
+        with self.assertLogs('app.comment_submission', level='ERROR'), patch.object(self.db, 'execute', side_effect=failing), patch.object(self.db, 'rollback', create=True):
+            result = self.result(self.post([('a.txt', b'ok')], 'retain me'))
+        self.assertTrue(result['files'][0]['success']); self.assertFalse(result['comment_saved'])
+        self.assertTrue(result['messages']); self.assertEqual(len(get_files(KEY)), 1)
+        self.assertTrue(self.result(self.post(comment='retain me'))['comment_saved'])
+
+    def test_size_boundary_partial_success_and_empty(self):
+        result = self.result(self.post([('exact.bin', b'a'*(1024*1024)), ('large.bin', b'a'*(1024*1024+1)), ('empty', b'')]))
+        self.assertEqual([r['success'] for r in result['files']], [True, False, True])
+        self.assertEqual(len(get_files(KEY)), 2)
+        self.assertFalse(self.result(self.post(comment='  '))['comment_saved'])
+        self.assertEqual(self.db.commits, 0)
+
+    def test_refresh_failure_still_reports_persisted_results(self):
+        with self.assertLogs('app.comment_submission', level='ERROR'), patch('app.comment_submission.history_context', side_effect=RuntimeError('history unavailable')), patch.object(self.db, 'rollback', create=True):
+            response = self.post([('a.txt', b'ok')], 'saved text')
+        result = self.result(response)
+        self.assertEqual(response.headers['hx-reswap'], 'none')
+        self.assertTrue(result['comment_saved']); self.assertTrue(result['files'][0]['success'])
+        self.assertIn('case-files', response.text)
+        self.assertTrue(result['messages'])
+
+    def test_storage_permission_error_is_individual(self):
+        with self.assertLogs('app.comment_submission', level='ERROR'), patch('app.comment_submission.save_upload', side_effect=PermissionError('denied')):
+            result = self.result(self.post([('a.txt', b'ok')], 'save comment independently'))
+        self.assertFalse(result['files'][0]['success']); self.assertIn('Zugriffsrechte', result['files'][0]['error'])
+        self.assertTrue(result['comment_saved']); self.assertEqual(get_files(KEY), [])
+
+    def test_security_manifest_and_legacy_download(self):
+        self.assertEqual(self.post([('a.txt', b'x')], headers={}).status_code, 403)
+        self.assertEqual(self.post([('a.txt', b'x')], key='1_R2').status_code, 404)
+        self.assertEqual(self.post([('a.txt', b'x')], ids=[]).status_code, 400)
+        self.assertFalse(list(Path(self.temp.name).iterdir()))
+        legacy = Path(self.temp.name)/'1/R1'; legacy.mkdir(parents=True); (legacy/'old.txt').write_bytes(b'old')
+        self.assertEqual(self.client.get('/files/', params={'name':'1/R1/old.txt'}).content, b'old')
+        self.assertEqual(self.client.get('/files/', params={'name':'1/R2/old.txt'}).status_code, 404)
+        self.client.cookies.clear()
+        response = self.post([('a.txt', b'x')], headers={'HX-Request':'true','HX-Current-URL':'http://testserver/app/forderungen/details/1_R1'})
+        self.assertIn('/login?', response.headers['hx-redirect'])
+        self.assertEqual(len(get_files(KEY)), 1)
+
+if __name__ == '__main__': unittest.main()