Przeglądaj źródła

Dokumentenerstellung

- Rechnung, Mahnung und Beitreibungsauftrag.
- Aktualisiere die Exportlogik und das Styling im Detailformular.
gc-server3 4 dni temu
rodzic
commit
13ab43a0a3

+ 18 - 0
README.md

@@ -314,6 +314,24 @@ zurückgerollt. Eine SQL-Server-Sperre auf dem Forderungskopf serialisiert beide
 Speicherwege. Reine Datei-Uploads verändern die Flags nicht. Die beiden Flagspalten
 und `Benutzer_Rolle.Rolle_ID` müssen bereits im Datenbankschema vorhanden sein.
 
+## Dokumente aus Vorlagen erstellen
+
+Im Versicherungsbereich stehen „Rechnung erstellen“ und „Mahnung erstellen“
+nebeneinander; im Rechtsanwaltsbereich wird der „Beitreibungsauftrag“ angeboten.
+Die zugehörigen Vorlagen `Rechnung.docx`, `Mahnung.docx` und
+`Beitreibungsauftrag.docx` liegen unter `templates/forderungen/details/docs`.
+Gerendert wird mit den gespeicherten Detaildaten einschließlich Versicherung,
+Selbstbeteiligung, MwSt. und aktuellem Verursacher.
+
+Vor dem Rendern wird nach `<Dokumenttyp>_<Mandant>_<Belegnummer>.docx` im
+Belegordner von `LOCAL_STORAGE` gesucht. Vorhandene Dateien werden unverändert
+ausgegeben, auch aus der älteren Ordnerstruktur `<Mandant>/<Belegnummer>`.
+Neue Dokumente werden unter `<Mandant>_<Belegnummer>` gespeichert und sind damit
+auch in der lokalen Dateiliste verfügbar. Parallelaufrufe erzeugen dieselbe Datei
+nur einmal; fehlgeschlagenes Rendern hinterlässt kein unvollständiges Dokument.
+Ein erneuter Klick aktualisiert ein bereits gespeichertes Dokument nicht automatisch.
+Der bestehende Exportlink ohne Dokumenttyp verwendet weiterhin die Mahnung.
+
 ## Dokumente im Kommentarbereich
 
 Über die Büroklammer oder durch Ablegen auf dem Kommentarfeld können mehrere Dateien

+ 52 - 0
app/document_generation.py

@@ -0,0 +1,52 @@
+"""Render the selected case template once and reuse stored documents."""
+
+from datetime import date, datetime
+from pathlib import Path
+from typing import Literal
+from zoneinfo import ZoneInfo
+
+from docxtpl import DocxTemplate
+from fastapi import HTTPException
+from sqlalchemy import text
+
+from .file_storage import get_or_create_file
+from .jinja_templates import number_format, templates
+
+
+DocumentType = Literal["Mahnung", "Rechnung", "Beitreibungsauftrag"]
+TEMPLATE_DIR = Path(__file__).resolve().parents[1] / "templates/forderungen/details/docs"
+DOCX_MEDIA_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
+
+
+def generated_document(db, client_db: str, document_no: str, document_type: DocumentType) -> Path:
+    key = {"Client_DB": client_db, "Beleg_Nr": document_no}
+    filename = f"{document_type}_{client_db}_{document_no}.docx"
+
+    def create(destination):
+        query = templates.env.get_template("forderungen/details/queries/forderungen_details.sql").render()
+        row = db.execute(text(query), {"client_db": client_db, "beleg_nr": document_no}).mappings().first()
+        if not row:
+            raise HTTPException(404, "Forderung nicht gefunden")
+        context = {
+            name: value.strftime("%d.%m.%Y") if isinstance(value, (date, datetime)) else value if value is not None else ""
+            for name, value in row.items()
+        }
+        for name in ("Beleg_Betrag", "Beleg_Offen", "Selbstbeteiligung_Betrag", "Mwst_Betrag"):
+            if row.get(name) is not None:
+                context[name] = number_format(row[name])
+        context.update(
+            Datum_heute=datetime.now(ZoneInfo("Europe/Berlin")).strftime("%d.%m.%Y"),
+            Betrag_SB=context.get("Selbstbeteiligung_Betrag", ""),
+            Betrag_USt=context.get("Mwst_Betrag", ""),
+            Rechnung_Nr=document_no,
+            Beleg_offen=context.get("Beleg_Offen", ""),
+            Verursacher_Text=context.get("Verursacher_Aktuell_Text") or context.get("Verursacher_Text", ""),
+        )
+        template_path = TEMPLATE_DIR / f"{document_type}.docx"
+        if not template_path.is_file():
+            raise HTTPException(503, f"Die Vorlage {document_type}.docx ist nicht verfügbar.")
+        document = DocxTemplate(template_path)
+        document.render(context, autoescape=True)
+        document.save(destination)
+
+    return get_or_create_file(key, filename, create)

+ 32 - 0
app/file_storage.py

@@ -134,6 +134,38 @@ def get_file(name: str) -> Path:
         raise FileNotFoundError(name) from error
 
 
+def get_or_create_file(primary_key: dict[str, str], name: str, create) -> Path:
+    """Reuse a canonical or legacy document; publish new files atomically."""
+    root = _root()
+    folders = _folders(root, primary_key)
+    targets = [_checked(root, *folder.relative_to(root).parts, name) for folder in folders]
+
+    def existing():
+        for target in targets:
+            if target.is_file():
+                return target
+            if target.exists():
+                raise UploadError("Unter diesem Dateinamen besteht bereits ein Ordner.")
+        return None
+
+    if cached := existing():
+        return cached
+    with _beleg_lock(root, folders[0]):
+        if cached := existing():
+            return cached
+        staging = _checked(root, ".upload-staging")
+        staging.mkdir(parents=True, exist_ok=True)
+        with tempfile.NamedTemporaryFile(dir=staging, suffix=Path(name).suffix, delete=False) as output:
+            temporary = Path(output.name)
+        try:
+            create(temporary)
+            folders[0].mkdir(parents=True, exist_ok=True)
+            os.replace(temporary, targets[0])
+        finally:
+            temporary.unlink(missing_ok=True)
+    return targets[0]
+
+
 @contextmanager
 def _beleg_lock(root: Path, folder: Path):
     lock_dir = _checked(root, ".upload-locks")

+ 17 - 42
app/routes.py

@@ -4,13 +4,10 @@ import re
 import secrets
 from datetime import datetime, timedelta
 from decimal import Decimal
-from pathlib import Path
 from typing import Annotated
 from urllib.parse import urlencode
 from zoneinfo import ZoneInfo
-from zoneinfo import ZoneInfo
 
-from docxtpl import DocxTemplate
 from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
 from fastapi.responses import (
     FileResponse,
@@ -22,7 +19,7 @@ from fastapi.responses import (
 from sqlalchemy import text
 from sqlalchemy.orm import Session
 
-from app.file_storage import file_primary_key, get_file, get_files
+from app.file_storage import UploadError, file_primary_key, get_file, get_files
 from app.query_filter import filter_clauses, import_filter_config, parse_filter_date
 
 from .auth import clear_session_cookie, safe_next_url, set_session_cookie
@@ -34,6 +31,7 @@ from .dashboard_segment import (
     segment_filters,
 )
 from .db import get_session
+from .document_generation import DOCX_MEDIA_TYPE, DocumentType, generated_document
 from .documents import router as documents_router
 from .docuware_search import get_invoice_pdf_by_document_no
 from .forderung_dashboard import STACKS, build_dashboard
@@ -525,48 +523,25 @@ def forderungen_dashboard(
     )
 
 
-# TODO
 @router.get("/app/forderungen/export/{client_db}_{document_no}/")
-def export_docx2(request: Request, client_db: str, document_no: str, db: Session = Depends(get_session)):
+def export_docx2(
+    request: Request, client_db: str, document_no: str,
+    document_type: DocumentType = "Mahnung", db: Session = Depends(get_session),
+):
     principal = current_principal(request)
     ensure_forderung_access(db, principal, client_db, document_no)
-    q = (
-        db.execute(
-            text("""SELECT TOP 1 F.*, K.Kunde_Name, K.Kunde_Adresse, K.Kunde_PLZ, K.Kunde_Ort
-               FROM dbo.Forderungen_Kopf AS F
-               LEFT JOIN dbo.Kunden AS K ON K.Client_DB = F.Client_DB AND K.Kunde_Nr = F.Kunde_Nr
-               WHERE F.Client_DB = :client_db AND F.Beleg_Nr = :beleg_nr"""),
-            {"client_db": client_db, "beleg_nr": document_no},
-        )
-        .mappings()
-        .first()
+    if not settings.LOCAL_STORAGE:
+        raise HTTPException(503, "Dokumentablage ist nicht konfiguriert")
+    try:
+        filename = generated_document(db, client_db, document_no, document_type)
+    except UploadError as error:
+        raise HTTPException(400, str(error)) from error
+    except OSError as error:
+        logger.exception("Dokument konnte nicht erstellt oder aus der Ablage geladen werden")
+        raise HTTPException(503, "Dokument konnte nicht bereitgestellt werden. Bitte erneut versuchen.") from error
+    return FileResponse(
+        filename, media_type=DOCX_MEDIA_TYPE, filename=filename.name, headers={"Cache-Control": "no-store"}
     )
-    if not q or not settings.LOCAL_STORAGE:
-        raise HTTPException(status_code=503, detail="Dokumentablage ist nicht konfiguriert")
-
-    doc = DocxTemplate("templates\\forderungen\\details\\docs\\Mahnung_AHR.docx")
-    storage_root = Path(settings.LOCAL_STORAGE).resolve()
-    filename = (storage_root / client_db / document_no / f"Mahnung_{client_db}_{document_no}.docx").resolve()
-    if storage_root not in filename.parents:
-        raise HTTPException(status_code=400, detail="Ungültiger Dokumentpfad")
-    filename.parent.mkdir(parents=True, exist_ok=True)
-
-    context = {
-        "Kunde_Name": q["Kunde_Name"] or "",
-        "Kunde_Adresse": q["Kunde_Adresse"] or "",
-        "Kunde_PLZ": q["Kunde_PLZ"] or "",
-        "Kunde_Ort": q["Kunde_Ort"] or "",
-        "Datum_heute": datetime.now().strftime("%d.%m.%Y"),
-        "Kunde_Nr": q["Kunde_Nr"] or "",
-        "Fahrzeug_Kennzeichen": q.get("Fahrzeug_Kennzeichen", "") or "",
-        "Rechnung_Nr": q["Beleg_Nr"],
-        "Betrag_SB": q.get("Selbstbeteiligung_Betrag", "") or "",
-        "Betrag_USt": q.get("Mwst_Betrag", "") or "",
-    }
-    doc.render(context)
-    doc.save(filename)
-
-    return FileResponse(filename, media_type="application/octet-stream", filename=filename.name)
 
 
 @router.get("/files/")

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

@@ -107,6 +107,7 @@ body:has(.gc-detail-page) { max-width: 1840px; }
 .gc-workflow-form .gc-insurance-amount-row > div { width: auto; }
 .gc-workflow-form .gc-insurance-amount-row > .gc-amount-suggestion { grid-column: 2; }
 .gc-workflow-form #Schaden_Details { resize: vertical; }
+.gc-workflow-form .gc-document-actions { display: flex; flex-wrap: wrap; gap: .5rem; }
 .gc-workflow-form .card-body > .col-3 { width: auto; }
 .gc-workflow-form :is(.form-control, .form-select) { width: 100%; font-size: 1.0625rem; min-height: 38px; border-color: #b1bcc7; }
 .gc-detail-page input[type="date"]::-webkit-datetime-edit-year-field,

+ 1 - 1
templates/base/base.html

@@ -23,7 +23,7 @@
   <link href="/static/assets/css/main.css" rel="stylesheet">
   <link href="/static/assets/css/app.css" rel="stylesheet">
 
-  <link href="/static/assets/css/details.css?v=5" rel="stylesheet">
+  <link href="/static/assets/css/details.css?v=6" rel="stylesheet">
   <link href="/static/assets/css/list.css?v=2" rel="stylesheet">
   <link href="/static/assets/css/dashboard.css" rel="stylesheet">
   <script src="/static/assets/js/dashboard.js" defer></script>

+ 7 - 6
templates/forderungen/details/details_formular.html

@@ -94,10 +94,11 @@
             {{ row.Mwst_Betrag_berechnet|number_format }}</button>
         </div>
       </div>
-      <div class="col-3">
-        <a class="btn btn-outline-secondary" hx-disable="true"
-          href="/app/forderungen/export/{{row.Client_DB}}_{{row.Beleg_Nr}}">Mahnung
-          erstellen</a>
+      <div class="gc-document-actions">
+        <a class="btn btn-outline-secondary" hx-boost="false"
+          href="/app/forderungen/export/{{ (row.Client_DB|string ~ '_' ~ row.Beleg_Nr|string)|urlencode }}/?document_type=Rechnung">Rechnung erstellen</a>
+        <a class="btn btn-outline-secondary" hx-boost="false"
+          href="/app/forderungen/export/{{ (row.Client_DB|string ~ '_' ~ row.Beleg_Nr|string)|urlencode }}/?document_type=Mahnung">Mahnung erstellen</a>
       </div>
 
     </div>
@@ -161,8 +162,8 @@
         </div>
       </div>
       <div class="col-3">
-        <a class="btn btn-outline-secondary" hx-disable="true"
-          href="/app/forderungen/export/{{row.Client_DB}}_{{row.Beleg_Nr}}">Beitreibungsauftrag</a>
+        <a class="btn btn-outline-secondary" hx-boost="false"
+          href="/app/forderungen/export/{{ (row.Client_DB|string ~ '_' ~ row.Beleg_Nr|string)|urlencode }}/?document_type=Beitreibungsauftrag">Beitreibungsauftrag</a>
       </div>
     </div>
   </div>

BIN
templates/forderungen/details/docs/Beitreibungsauftrag.docx


BIN
templates/forderungen/details/docs/Mahnung.docx


BIN
templates/forderungen/details/docs/Mahnung_AHR.docx


BIN
templates/forderungen/details/docs/Rechnung.docx


+ 185 - 0
tests/test_document_generation.py

@@ -0,0 +1,185 @@
+"""Render the supplied templates and reuse stored case documents through HTTP."""
+
+import io
+import tempfile
+import unittest
+from concurrent.futures import ThreadPoolExecutor
+from datetime import date
+from decimal import Decimal
+from pathlib import Path
+from types import MappingProxyType, SimpleNamespace
+from unittest.mock import MagicMock, patch
+from zipfile import ZipFile
+from xml.etree import ElementTree
+
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+
+from app.config import settings
+from app.db import get_session
+from app.document_generation import DOCX_MEDIA_TYPE, TEMPLATE_DIR
+from app.file_storage import get_or_create_file
+from app.routes import router
+
+
+class DocumentGenerationTests(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)
+        self.db = MagicMock()
+        self.db.execute.return_value.first.return_value = (1,)
+        self.row = dict(
+            Client_DB='1', Beleg_Nr='R1', Kunde_Nr='K1', Kunde_Name='Müller & <Partner>',
+            Kunde_Adresse='Musterstraße 1', Kunde_PLZ='89073', Kunde_Ort='Ulm',
+            Kunde_Email='kunde@example.com', Kunde_Telefon='012345',
+            Beleg_Datum=date(2026, 11, 18), Beleg_Betrag=Decimal('550.48'), Beleg_Offen=Decimal('300.00'),
+            Selbstbeteiligung_Betrag=Decimal('150.00'), Mwst_Betrag=Decimal('0'), Mwst_Betrag_berechnet=Decimal('104.59'),
+            Versicherung_Typ='Vollkasko', Fahrzeug_Kennzeichen='UL-AB 123',
+            Standort_Name='Ulm', Abteilung='SC', Mahnstufe=1,
+            Verursacher_Text='Original', Verursacher_Aktuell_Text='Aktueller Verursacher',
+        )
+        self.db.execute.return_value.mappings.return_value.first.return_value = MappingProxyType(self.row)
+        app = FastAPI()
+        app.include_router(router)
+        app.dependency_overrides[get_session] = lambda: self.db
+
+        @app.middleware('http')
+        async def authenticated(request, call_next):
+            request.state.principal = SimpleNamespace(username='tester', name='Tester', csrf='csrf', role='Mitarbeiter')
+            return await call_next(request)
+
+        self.client = TestClient(app)
+        self.addCleanup(self.client.close)
+        self.url = '/app/forderungen/export/1_R1/'
+
+    def test_each_supplied_template_renders_to_its_own_local_document(self):
+        from app.document_generation import DocxTemplate
+
+        for kind in ('Rechnung', 'Mahnung', 'Beitreibungsauftrag'):
+            with self.subTest(kind=kind), patch('app.document_generation.DocxTemplate', wraps=DocxTemplate) as template:
+                response = self.client.get(self.url, params={'document_type': kind})
+                self.assertEqual(response.status_code, 200)
+                self.assertEqual(response.headers['content-type'], DOCX_MEDIA_TYPE)
+                template.assert_called_once_with(TEMPLATE_DIR / f'{kind}.docx')
+                stored = self.root / '1_R1' / f'{kind}_1_R1.docx'
+                self.assertEqual(stored.read_bytes(), response.content)
+                with ZipFile(io.BytesIO(response.content)) as archive:
+                    document = ElementTree.fromstring(archive.read('word/document.xml'))
+                text = ''.join(document.itertext())
+                self.assertIn('Müller & <Partner>', text)
+                self.assertIn('R1', text)
+                self.assertNotIn('{{', text)
+                if kind != 'Beitreibungsauftrag':
+                    self.assertIn('150,00', text)
+                    self.assertIn('0,00', text)
+                else:
+                    self.assertIn('300,00', text)
+                    self.assertIn('Aktueller Verursacher', text)
+        self.assertEqual(self.row['Beleg_Datum'], date(2026, 11, 18))
+
+    def test_cached_documents_are_returned_unchanged_in_both_storage_layouts(self):
+        for kind in ('Rechnung', 'Mahnung', 'Beitreibungsauftrag'):
+            for legacy in (False, True):
+                with self.subTest(kind=kind, legacy=legacy):
+                    tenant = '2' if legacy else '1'
+                    folder = self.root / tenant / 'R1' if legacy else self.root / f'{tenant}_R1'
+                    folder.mkdir(parents=True, exist_ok=True)
+                    stored = folder / f'{kind}_{tenant}_R1.docx'
+                    original = f'Already edited {kind}'.encode()
+                    stored.write_bytes(original)
+                    modified = stored.stat().st_mtime_ns
+                    self.db.reset_mock()
+                    with patch('app.document_generation.DocxTemplate') as template:
+                        response = self.client.get(f'/app/forderungen/export/{tenant}_R1/', params={'document_type': kind})
+                    self.assertEqual(response.content, original)
+                    self.assertEqual(stored.stat().st_mtime_ns, modified)
+                    template.assert_not_called()
+                    # Reuse still checks access but does not fetch/render new case data.
+                    self.db.execute.assert_called_once()
+
+    def test_old_export_url_defaults_to_mahnung(self):
+        response = self.client.get(self.url)
+        self.assertEqual(response.status_code, 200)
+        self.assertTrue((self.root / '1_R1/Mahnung_1_R1.docx').is_file())
+
+    def test_unsupported_templates_and_unauthorized_cases_are_not_exported(self):
+        response = self.client.get(self.url, params={'document_type': '../Rechnung'})
+        self.assertEqual(response.status_code, 422)
+        stored = self.root / '1_R1/Rechnung_1_R1.docx'
+        stored.parent.mkdir()
+        stored.write_bytes(b'cached')
+        self.db.execute.return_value.first.return_value = None
+        response = self.client.get(self.url, params={'document_type': 'Rechnung'})
+        self.assertEqual(response.status_code, 404)
+        self.assertEqual(stored.read_bytes(), b'cached')
+
+    def test_missing_storage_or_template_has_a_useful_error(self):
+        with patch.object(settings, 'LOCAL_STORAGE', None):
+            self.assertEqual(self.client.get(self.url).status_code, 503)
+        with patch('app.document_generation.TEMPLATE_DIR', self.root / 'missing-templates'):
+            response = self.client.get(self.url)
+        self.assertEqual(response.status_code, 503)
+        self.assertIn('Mahnung.docx', response.json()['detail'])
+        self.assertFalse((self.root / '1_R1/Mahnung_1_R1.docx').exists())
+
+    def test_render_failure_does_not_publish_a_partial_document(self):
+        with patch('app.document_generation.DocxTemplate') as template:
+            template.return_value.render.side_effect = RuntimeError('broken template')
+            with self.assertRaises(RuntimeError):
+                self.client.get(self.url)
+        self.assertFalse((self.root / '1_R1/Mahnung_1_R1.docx').exists())
+        self.assertEqual(list((self.root / '.upload-staging').iterdir()), [])
+
+    def test_concurrent_creation_reuses_one_result(self):
+        created = []
+
+        def create(path):
+            created.append(path)
+            path.write_bytes(b'rendered')
+
+        def load(_):
+            return get_or_create_file({'Client_DB': '1', 'Beleg_Nr': 'R1'}, 'Rechnung_1_R1.docx', create)
+
+        with ThreadPoolExecutor(max_workers=3) as pool:
+            paths = list(pool.map(load, range(3)))
+        self.assertEqual(len(created), 1)
+        self.assertEqual(len(set(paths)), 1)
+        self.assertEqual(paths[0].read_bytes(), b'rendered')
+
+    def test_document_paths_are_validated_before_creating_files(self):
+        for number in ('../escape', 'a/b', 'CON'):
+            with self.subTest(number=number), self.assertRaises(ValueError):
+                get_or_create_file({'Client_DB': '1', 'Beleg_Nr': number}, 'Rechnung.docx', lambda path: None)
+
+    def test_form_buttons_select_the_correct_template_and_allow_downloads(self):
+        from html.parser import HTMLParser
+        from urllib.parse import parse_qs, urlsplit
+        from app.jinja_templates import templates
+
+        class ExportLinks(HTMLParser):
+            def __init__(self):
+                super().__init__()
+                self.links = []
+
+            def handle_starttag(self, tag, attrs):
+                attrs = dict(attrs)
+                if tag == 'a' and '/export/' in attrs.get('href', ''):
+                    self.links.append(attrs)
+
+        html = templates.env.get_template('forderungen/details/details_formular.html').render(
+            forderungen_details=[{**self.row, 'Wiedervorlage': date(2026, 11, 18)}], versicherungen=[],
+            request=SimpleNamespace(state=SimpleNamespace(principal=SimpleNamespace(csrf='csrf'))),
+            return_url='/app/forderungen/liste', detail_url='/app/forderungen/details/1_R1', add7days='2026-11-25',
+        )
+        parser = ExportLinks()
+        parser.feed(html)
+        self.assertEqual(len(parser.links), 3)
+        for link, kind in zip(parser.links, ('Rechnung', 'Mahnung', 'Beitreibungsauftrag')):
+            self.assertEqual(link['hx-boost'], 'false')
+            url = urlsplit(link['href'])
+            self.assertEqual(url.path, self.url)
+            self.assertEqual(parse_qs(url.query), {'document_type': [kind]})