Просмотр исходного кода

Füge Unterstützung für Schaden_Details hinzu und aktualisiere Audit-Werte in der Detailverarbeitung

gc-server3 4 дней назад
Родитель
Сommit
2dd08d6c6a

+ 17 - 0
app/routes.py

@@ -7,6 +7,8 @@ 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
@@ -411,6 +413,7 @@ async def post_forderungen_details(
         "Vers_Adresse_ID": "",
         "Versicherung_Typ": "",
         "Schadennummer": "",
+        "Schaden_Details": "",
         "Reklamation_Begruendung": "",
         "Rechtsanwalt_Begruendung": "",
         "Rechtsanwalt_Aktenzeichen": "",
@@ -435,6 +438,20 @@ async def post_forderungen_details(
     except ValueError as error:
         raise HTTPException(422, "Bitte ein gültiges Wiedervorlagedatum auswählen.") from error
 
+    # Set audit values after reading the form so clients cannot override them.
+    # SQL Server datetime stores local wall time without a timezone offset.
+    data.update(
+        Bearbeitung_Benutzer_ID=principal.username,
+        Bearbeitung_Timestamp=datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None),
+    )
+
+    # Set audit values after reading the form so clients cannot override them.
+    # SQL Server datetime stores local wall time without a timezone offset.
+    data.update(
+        Bearbeitung_Benutzer_ID=principal.username,
+        Bearbeitung_Timestamp=datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None),
+    )
+
     if source not in {"comments", "details"}:
         raise HTTPException(status_code=400, detail="Ungültige Datenquelle")
     query = templates.TemplateResponse(

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

@@ -102,6 +102,11 @@ body:has(.gc-detail-page) { max-width: 1840px; }
 .gc-workflow-form .card-body .row, .gc-workflow-form .gc-follow-up .row { display: flex; flex-wrap: wrap; gap: .5rem; margin: 0 0 .85rem; }
 .gc-workflow-form .row > .col-form-label { flex: 0 0 100%; padding: 0; font-size: 1rem; color: #52616f; }
 .gc-workflow-form .row > div { flex: 1 1 7rem; padding: 0; }
+.gc-workflow-form .card-body .gc-insurance-amount-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; }
+.gc-workflow-form .gc-insurance-amount-row > label { grid-column: 1 / -1; width: auto; }
+.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 .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=4" rel="stylesheet">
+  <link href="/static/assets/css/details.css?v=5" 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>

+ 10 - 3
templates/forderungen/details/details_formular.html

@@ -52,6 +52,12 @@
         </div>
       </div>
       <div class="row mb-3">
+        <label for="Schaden_Details" class="col-sm-3 col-form-label">Schaden Beschreibung</label>
+        <div class="col-sm-9">
+          <textarea class="form-control" id="Schaden_Details" name="Schaden_Details" rows="3">{{ row.Schaden_Details|default('', true) }}</textarea>
+        </div>
+      </div>
+      <div class="row mb-3 gc-insurance-amount-row">
         <label for="Selbstbeteiligung" class="col-sm-3 col-form-label">Selbstbeteiligung</label>
         <div class="col-sm-3">
           <select id="Selbstbeteiligung" name="Selbstbeteiligung" class="form-select">
@@ -66,7 +72,7 @@
             aria-label="Selbstbeteiligung Betrag" placeholder="Betrag" value="{{ row.Selbstbeteiligung_Betrag }}">
         </div>
       </div>
-      <div class="row mb-3">
+      <div class="row mb-3 gc-insurance-amount-row">
         <label for="MwSt" class="col-sm-3 col-form-label">MwSt</label>
         <div class="col-sm-3">
           <select id="MwSt" name="Mwst" class="form-select">
@@ -80,10 +86,11 @@
           <input type="text" class="form-control" id="Mwst_Betrag" name="Mwst_Betrag" aria-label="Mehrwertsteuer Betrag" placeholder="Betrag"
             value="{{ row.Mwst_Betrag|number_format }}">
         </div>
-        <div class="col-sm-3">
+        <div class="col-sm-3 gc-amount-suggestion">
           <button type="button" class="btn btn-outline-secondary" hx-disable="true"
+            aria-label="Berechnete Mehrwertsteuer übernehmen"
             onclick="document.getElementById('Mwst_Betrag').value='{{ row.Mwst_Betrag_berechnet|number_format }}';">
-            <i class="cil-arrow-thick-left"></i>
+            <i class="cil-arrow-thick-top" aria-hidden="true"></i>
             {{ row.Mwst_Betrag_berechnet|number_format }}</button>
         </div>
       </div>

+ 1 - 0
templates/forderungen/details/queries/forderungen_details.sql

@@ -29,6 +29,7 @@ SELECT [F].*
      , isnull([FD].[Vers_Adresse_ID], '') AS [Vers_Adresse_ID]
      , isnull([FD].[Versicherung_Typ], '') AS [Versicherung_Typ]
      , isnull([FD].[Schadennummer], '') AS [Versicherung_Schadennummer]
+     , isnull([FD].[Schaden_Details], '') AS [Schaden_Details]
      , isnull([FD].[Reklamation_Begruendung], '') AS [Reklamation_Begruendung]
      , isnull([FD].[Rechtsanwalt_Begruendung], '') AS [Rechtsanwalt_Begruendung]
      , isnull([FD].[Rechtsanwalt_Aktenzeichen], '') AS [Rechtsanwalt_Aktenzeichen]

+ 3 - 0
templates/forderungen/details/queries/post/post_details.sql

@@ -10,6 +10,8 @@ IF NOT exists (SELECT 1
 
 UPDATE [dbo].[Forderungen_Details]
 SET [Bearbeitet] = 'J'
+  , [Bearbeitung_Benutzer_ID] = :Bearbeitung_Benutzer_ID
+  , [Bearbeitung_Timestamp] = :Bearbeitung_Timestamp
   , [Versicherung] = :Versicherung
   , [Rechtsanwalt] = :Rechtsanwalt
   , [Reklamation] = :Reklamation
@@ -22,6 +24,7 @@ SET [Bearbeitet] = 'J'
   , [Vers_Adresse_ID] = :Vers_Adresse_ID
   , [Versicherung_Typ] = :Versicherung_Typ
   , [Schadennummer] = :Schadennummer
+  , [Schaden_Details] = :Schaden_Details
   , [Reklamation_Begruendung] = :Reklamation_Begruendung
   , [Rechtsanwalt_Begruendung] = :Rechtsanwalt_Begruendung
   , [Rechtsanwalt_Aktenzeichen] = :Rechtsanwalt_Aktenzeichen

+ 44 - 1
tests/test_navigation.py

@@ -1,8 +1,9 @@
 import unittest
-from datetime import date
+from datetime import date, datetime
 from types import SimpleNamespace
 from unittest.mock import MagicMock, patch
 from urllib.parse import parse_qs, urlencode, urlsplit
+from zoneinfo import ZoneInfo
 
 from fastapi import FastAPI
 from fastapi.testclient import TestClient
@@ -249,6 +250,7 @@ class DetailReturnTests(unittest.TestCase):
                 super().__init__()
                 self.values = {}
                 self.save_back = None
+                self.textarea = None
 
             def handle_starttag(self, tag, attrs):
                 attrs = dict(attrs)
@@ -261,12 +263,22 @@ class DetailReturnTests(unittest.TestCase):
                         self.values[attrs["name"]] = attrs.get("value", "on")
                 if tag == "button" and "hx-vals" in attrs:
                     self.save_back = attrs
+                if tag == "textarea" and attrs.get("name"):
+                    self.textarea = attrs["name"]
+                    self.values[self.textarea] = ''
+
+            def handle_data(self, data):
+                if self.textarea:
+                    self.values[self.textarea] += data
 
             def handle_endtag(self, tag):
+                if tag == "textarea":
+                    self.textarea = None
                 if tag == "form":
                     self.active = False
 
         self.row['Versicherung_Schadennummer'] = 'SCH-123,45'
+        self.row['Schaden_Details'] = 'Stoßfänger beschädigt <vorne> & rechts.\nLackierung nötig, 2 Teile.'
         response = self.client.get('/app/forderungen/details/1_R1', params={'return_to': self.return_url})
         self.assertEqual(response.status_code, 200)
         form = DetailForm()
@@ -289,8 +301,39 @@ class DetailReturnTests(unittest.TestCase):
         self.assertEqual(response.headers['HX-Redirect'], self.return_url)
         update = next(call for call in self.db.execute.call_args_list if str(call.args[0]).lstrip().startswith('UPDATE'))
         self.assertEqual(update.args[1]['Schadennummer'], 'SCH-123,45')
+        self.assertEqual(update.args[1]['Schaden_Details'], self.row['Schaden_Details'])
+        self.assertIn('[Schaden_Details] = :Schaden_Details', str(update.args[0]))
         self.assertEqual(update.args[1]['Wiedervorlage'], date(2026, 11, 18))
 
+    def test_form_saves_stamp_authenticated_user_and_server_time(self):
+        for redirect in ('', 'list'):
+            with self.subTest(redirect=redirect):
+                self.db.reset_mock()
+                before = datetime.now(ZoneInfo('Europe/Berlin')).replace(tzinfo=None)
+                response = self.client.post('/app/forderungen/details/1_R1', data={
+                    'source': 'details', 'redirect': redirect, 'return_to': self.return_url,
+                    'Schaden_Details': 'Neue Schadenbeschreibung',
+                    'Bearbeitung_Benutzer_ID': 'forged-user',
+                    'Bearbeitung_Timestamp': '2000-01-01T00:00:00',
+                }, headers={'HX-Request': 'true'})
+                after = datetime.now(ZoneInfo('Europe/Berlin')).replace(tzinfo=None)
+                self.assertEqual(response.status_code, 200)
+                update = next(call for call in self.db.execute.call_args_list
+                              if str(call.args[0]).lstrip().startswith('UPDATE'))
+                statement, values = update.args
+                statement.compile().construct_params(values)
+                self.assertIn('[Bearbeitung_Benutzer_ID] = :Bearbeitung_Benutzer_ID', str(statement))
+                self.assertIn('[Bearbeitung_Timestamp] = :Bearbeitung_Timestamp', str(statement))
+                self.assertEqual(values['Bearbeitung_Benutzer_ID'], 'tester')
+                self.assertEqual(values['Schaden_Details'], 'Neue Schadenbeschreibung')
+                self.assertIs(type(values['Bearbeitung_Timestamp']), datetime)
+                self.assertIsNone(values['Bearbeitung_Timestamp'].tzinfo)
+                self.assertLessEqual(before, values['Bearbeitung_Timestamp'])
+                self.assertLessEqual(values['Bearbeitung_Timestamp'], after)
+                self.db.commit.assert_called_once()
+                if redirect:
+                    self.assertEqual(response.headers['HX-Redirect'], self.return_url)
+
     def test_failed_save_does_not_return_and_external_target_is_ignored(self):
         self.db.commit.side_effect = RuntimeError("test database unavailable")
         response = self.client.post(