Explorar o código

Filterübergabe WV-Staffelung im Dashboard

gc-server3 hai 4 días
pai
achega
d06e9934f3

+ 9 - 7
README.md

@@ -179,13 +179,15 @@ Die Rubriken Altersstaffel, Standort und Verursacher zeigen horizontale Balken m
 Umschalter zwischen Betrag und Anzahl. Die Stapelung lässt sich zwischen Abteilung und
 Wiedervorlage wählen (`stack=abteilung|wiedervorlage`). Negative Beträge liegen links der
 Null. Verursacherüberschreibungen und die effektive Wiedervorlage werden wie in der Liste
-berücksichtigt. Für die Staffelung gilt zuerst `Forderungen_Details.Wiedervorlage`,
-ersatzweise `Beleg_Faellig_Datum`. Maßgeblich ist der Berliner Kalendertag:
-„Überfällig“ liegt vor heute, „In 3 Tagen fällig“ umfasst heute bis heute + 3 Tage,
-„Noch nicht fällig“ heute + 4 bis einschließlich heute + 14 Tage und „zurückgestellt“
-alles danach. Fehlen beide Datumsfelder, bleibt der Beleg separat als „Ohne Datum“ sichtbar.
-Die Wiedervorlagestaffelung beeinflusst derzeit nur die Darstellung im Dashboard;
-beim Öffnen der Liste wird diese Stapelung nicht als Datumsfilter übernommen.
+berücksichtigt. Die Wiedervorlagestapelung übernimmt Schlüssel, Reihenfolge und
+Bezeichnung direkt aus `Forderungen_Liste.Wiedervorlage_Staffel_Nr` und
+`Wiedervorlage_Staffel_Text`. Die Einstufung wird von der Datenquelle geliefert:
+„Überfällig“, heute bis in 3 Tagen, in 4 bis 20 Tagen und ab 21 Tagen „zurückgestellt“.
+Das Dashboard berechnet diese Einstufung nicht zusätzlich aus Datumsfeldern.
+Fehlt die Staffelnummer, erscheint der Beleg unter „Ohne WV-Staffel“.
+Segmentlinks übernehmen die Staffelnummer in den bestehenden Listenfilter `WVStaffel`;
+Gruppenlinks öffnen alle Staffeln der Gruppe. Dadurch stimmen Anzahl und Betrag im
+angeklickten Segment mit der gefilterten Liste überein.
 Alte Dashboardadressen mit `page=Wiedervorlage` wechseln zum Standort mit
 Wiedervorlagestapelung; bestehende Listenlinks bleiben gültig.
 

+ 21 - 53
app/forderung_dashboard.py

@@ -2,7 +2,7 @@
 
 import hashlib
 from collections import Counter
-from datetime import date, datetime, timedelta
+from datetime import datetime
 from decimal import Decimal
 from zoneinfo import ZoneInfo
 
@@ -20,47 +20,17 @@ def _key(value):
     return None if value is None or str(value) == "" else str(value)
 
 
-def _date(value):
-    return (
-        value.date()
-        if isinstance(value, datetime)
-        else value if isinstance(value, date) else date.fromisoformat(str(value)[:10]) if value else None
-    )
-
-
 def color(department):
     hue = int.from_bytes(hashlib.sha256((department or "").encode()).digest()[:2], "big") % 360
     return f"hsl({hue}, 23%, 70%)"
 
 
-def follow_up_bands(today):
-    return [
-        dict(
-            id="overdue",
-            label="Überfällig",
-            color="#d4aaaa",
-            key={"from": None, "to": (today - timedelta(days=1)).isoformat()},
-        ),
-        dict(
-            id="soon",
-            label="In 3 Tagen fällig",
-            color="#e0cd9c",
-            key={"from": today.isoformat(), "to": (today + timedelta(days=3)).isoformat()},
-        ),
-        dict(
-            id="not_due",
-            label="Noch nicht fällig",
-            color="#b4c5b5",
-            key={"from": (today + timedelta(days=4)).isoformat(), "to": (today + timedelta(days=14)).isoformat()},
-        ),
-        dict(
-            id="deferred",
-            label="zurückgestellt",
-            color="#b2bccd",
-            key={"from": (today + timedelta(days=15)).isoformat(), "to": None},
-        ),
-        dict(id="undated", label="Ohne Datum", color="#c5c5c5", key=None),
-    ]
+FOLLOW_UP_COLORS = {
+    "überfällig": "#d4aaaa",
+    "in 3 tagen fällig": "#e0cd9c",
+    "noch nicht fällig": "#b4c5b5",
+    "zurückgestellt": "#b2bccd",
+}
 
 
 def _url(view, key, segment=..., only_invoices=False, stack="abteilung"):
@@ -78,6 +48,8 @@ def _url(view, key, segment=..., only_invoices=False, stack="abteilung"):
         select("Verursacher", key["text"])
     if segment is not ... and stack == "abteilung":
         select("Abteilung", segment)
+    if segment is not ... and stack == "wiedervorlage":
+        select("WVStaffel", segment)
     if only_invoices:
         select("RechnungGutschrift", "Rechnung")
     return list_url(filters)
@@ -87,7 +59,6 @@ def build_dashboard(db, username, now=None, only_invoices=False, stack="abteilun
     if stack not in STACKS:
         raise ValueError("Ungültige Stapelung")
     now = now or datetime.now(BERLIN)
-    today = now.astimezone(BERLIN).date()
     if only_invoices:
         columns, source = read_invoices(
             db, username, ["F.[Rechnung_Gutschrift] = :dashboard_invoice_type"], {"dashboard_invoice_type": "Rechnung"}
@@ -95,8 +66,7 @@ def build_dashboard(db, username, now=None, only_invoices=False, stack="abteilun
     else:
         columns, source = read_invoices(db, username)
     groups = {view: {} for view in DASHBOARD_VIEWS}
-    total, departments, observed = Decimal(0), set(), set()
-    bands = follow_up_bands(today)
+    total, departments, stages = Decimal(0), set(), {}
     for values in source:
         row = dict(zip(columns, values))
         department = _key(row.get("Abteilung"))
@@ -104,18 +74,16 @@ def build_dashboard(db, username, now=None, only_invoices=False, stack="abteilun
         if stack == "abteilung":
             segment_id = department
         else:
-            when = _date(row.get("Wiedervorlage") or row.get("Beleg_Faellig_Datum"))
-            bucket = (
-                4
-                if when is None
-                else (
-                    0
-                    if when < today
-                    else 1 if when <= today + timedelta(days=3) else 2 if when <= today + timedelta(days=14) else 3
-                )
-            )
-            segment_id = bands[bucket]["id"]
-        observed.add(segment_id)
+            # The shared list projection owns the classification, including the
+            # 21-day deferred boundary. Use its key for an exact WVStaffel link.
+            segment_id = _key(row["Wiedervorlage_Staffel_Nr"])
+            label = _key(row["Wiedervorlage_Staffel_Text"])
+            stages.setdefault(segment_id, dict(
+                id=segment_id,
+                label=label or (f"WV-Staffel {segment_id}" if segment_id is not None else "Ohne WV-Staffel"),
+                color=FOLLOW_UP_COLORS.get((label or "").casefold(), color(segment_id)) if segment_id is not None else "#c5c5c5",
+                key=segment_id,
+            ))
         amount = Decimal(str(row["Beleg_Offen"] or 0))
         total += amount
         age = _key(row.get("Staffel_Text"))
@@ -154,7 +122,7 @@ def build_dashboard(db, username, now=None, only_invoices=False, stack="abteilun
             for dep in sorted(departments, key=lambda value: (value is None, value or ""))
         ]
         if stack == "abteilung"
-        else [band for band in bands if band["id"] != "undated" or "undated" in observed]
+        else sorted(stages.values(), key=lambda item: (item["key"] is None, Decimal(item["key"] or "0")))
     )
 
     def serialized(amount):

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

@@ -44,7 +44,7 @@
     </div>
     <div><label for="dashboard-search">Suchen</label><input class="form-control" type="search" id="dashboard-search" placeholder="Name der Gruppe …"></div>
   </div>
-  <p class="gc-dashboard-description">{% if dashboard_stack == 'wiedervorlage' %}Die Farben zeigen die Wiedervorlagestaffeln: überfällig, heute bis in 3 Tagen, in 4 bis 14 Tagen und danach zurückgestellt. Ohne Wiedervorlage gilt das Beleg-Fälligkeitsdatum. Die Listenlinks öffnen die gesamte Gruppe; die Wiedervorlagestaffel wird dabei nicht als Filter übernommen.{% else %}Die Farben zeigen die Abteilungen.{% endif %} Bewegen Sie den Mauszeiger auf ein Segment oder tippen Sie es an. Die Datentabelle bietet dieselben Werte und Listenlinks.</p>
+  <p class="gc-dashboard-description">{% if dashboard_stack == 'wiedervorlage' %}Die Farben zeigen die Wiedervorlagestaffeln aus der Forderungsliste: überfällig, heute bis in 3 Tagen, in 4 bis 20 Tagen und ab 21 Tagen zurückgestellt. Die Listenlinks der Segmente übernehmen die jeweilige WV-Staffel als Filter; der Gruppenlink öffnet die gesamte Gruppe.{% else %}Die Farben zeigen die Abteilungen.{% endif %} Bewegen Sie den Mauszeiger auf ein Segment oder tippen Sie es an. Die Datentabelle bietet dieselben Werte und Listenlinks.</p>
   <ul class="gc-dashboard-legend" aria-label="{{ 'Wiedervorlagestaffeln' if dashboard_stack == 'wiedervorlage' else 'Abteilungen' }}"></ul>
   <p class="gc-dashboard-status" role="status" aria-live="polite"></p>
   <div class="gc-dashboard-chart"><canvas id="dashboard-chart" role="img" aria-label="Forderungen nach Abteilung; vollständige Werte in der Datentabelle"></canvas></div>

+ 45 - 21
tests/test_dashboard.py

@@ -31,7 +31,7 @@ class DashboardTests(unittest.TestCase):
         for name in needed - existing:
             kind = (
                 "INTEGER"
-                if name == "Beleg_Tage_offen"
+                if name in {"Beleg_Tage_offen", "Wiedervorlage_Staffel_Nr"}
                 else "REAL" if name in {"Beleg_Betrag", "Kunde_offen_gesamt"} else "TEXT"
             )
             self.db.connection.execute(f"ALTER TABLE dbo.Forderungen_Kopf ADD [{name}] {kind}")
@@ -66,6 +66,13 @@ class DashboardTests(unittest.TestCase):
         self.db.connection.execute(
             "UPDATE dbo.Forderungen_Kopf SET Rechnung_Gutschrift='Gutschrift', Abteilung='', Staffel_Text=NULL, Verursacher_Benutzer_ID=NULL, Verursacher_Text=NULL, Beleg_Faellig_Datum=NULL WHERE Beleg_Nr='CR'"
         )
+        # Model source-owned classifications independently of the dashboard clock.
+        self.db.connection.executescript("""
+            UPDATE dbo.Forderungen_Kopf SET Wiedervorlage_Staffel_Nr=2, Wiedervorlage_Staffel_Text='In 3 Tagen fällig';
+            UPDATE dbo.Forderungen_Kopf SET Wiedervorlage_Staffel_Nr=1, Wiedervorlage_Staffel_Text='Überfällig' WHERE Beleg_Nr='R1';
+            UPDATE dbo.Forderungen_Kopf SET Wiedervorlage_Staffel_Nr=3, Wiedervorlage_Staffel_Text='Noch nicht fällig' WHERE Beleg_Nr IN ('X1','NV');
+            UPDATE dbo.Forderungen_Kopf SET Wiedervorlage_Staffel_Nr=NULL, Wiedervorlage_Staffel_Text=NULL WHERE Beleg_Nr='CR';
+        """)
         # The production query now reads a DWH view. Model that view in SQLite;
         # keep the detail overlays live so update/authorization tests still run real SQL.
         overlays = {
@@ -201,50 +208,64 @@ class DashboardTests(unittest.TestCase):
 
                         self.assertIn(number_format(Decimal(segment["amount"])) + " €", response.text)
 
-    def test_follow_up_boundaries_and_null_group(self):
+    def test_follow_up_source_staffels_and_21_day_boundary(self):
         cases = [
-            ("2026-11-01", "overdue"),
-            ("2026-11-02", "soon"),
-            ("2026-11-05 23:30:00", "soon"),
-            ("2026-11-06", "not_due"),
-            ("2026-11-16 23:30:00", "not_due"),
-            ("2026-11-17", "deferred"),
+            ("2026-11-01", "1", "Überfällig"),
+            ("2026-11-02", "2", "In 3 Tagen fällig"),
+            ("2026-11-05 23:30:00", "2", "In 3 Tagen fällig"),
+            ("2026-11-06", "3", "Noch nicht fällig"),
+            ("2026-11-17", "3", "Noch nicht fällig"),
+            ("2026-11-22 23:30:00", "3", "Noch nicht fällig"),
+            ("2026-11-23", "4", "zurückgestellt"),
         ]
-        for value, expected in cases:
+        for value, expected, label in cases:
             with self.subTest(date=value):
                 self.db.connection.execute(
                     "UPDATE dbo.Forderungen_Details SET Wiedervorlage=? WHERE Beleg_Nr='R1'", (value,)
                 )
+                self.db.connection.execute(
+                    "UPDATE dbo.Forderungen_Kopf SET Wiedervorlage_Staffel_Nr=?, Wiedervorlage_Staffel_Text=? WHERE Beleg_Nr='R1'",
+                    (expected, label),
+                )
                 data = build_dashboard(self.db, "tester", self.now, stack="wiedervorlage")
                 group = next(group for group in data["views"]["Altersstaffel"] if group["label"] == "< 4 Wochen")
-                self.assertEqual(group["segments"][0]["id"], expected)
-                response = self.client.get(group["segments"][0]["url"])
+                segment = group['segments'][0]
+                self.assertEqual(segment["id"], expected)
+                self.assertEqual(segment["label"], label)
+                self.assertEqual(parse_qs(urlsplit(segment['url']).query)['WVStaffel'], [expected])
+                response = self.client.get(segment["url"])
                 self.assertIn('id="list-count">1', response.text)
+                self.assertIn(f'aria-label="{label} entfernen"', response.text)
         ids = [item["id"] for item in data["stack_items"]]
-        self.assertEqual(ids, ["overdue", "soon", "not_due", "deferred", "undated"])
+        self.assertEqual(ids, ["2", "3", "4", None])
         self.assertNotIn("Wiedervorlage", data["views"])
 
-    def test_follow_up_falls_back_to_document_due_date(self):
+    def test_follow_up_uses_source_staffel_even_when_dates_disagree(self):
         self.db.connection.execute("UPDATE dbo.Forderungen_Details SET Wiedervorlage=NULL WHERE Beleg_Nr='R1'")
         self.db.connection.execute(
-            "UPDATE dbo.Forderungen_Kopf SET Beleg_Faellig_Datum='2026-11-17' WHERE Client_DB='1' AND Beleg_Nr='R1'"
+            "UPDATE dbo.Forderungen_Kopf SET Beleg_Faellig_Datum='2099-01-01', "
+            "Wiedervorlage_Staffel_Nr=10, Wiedervorlage_Staffel_Text='Bezeichnung aus Datenquelle' "
+            "WHERE Client_DB='1' AND Beleg_Nr='R1'"
         )
         data = build_dashboard(self.db, "tester", self.now, stack="wiedervorlage")
         group = next(group for group in data["views"]["Altersstaffel"] if group["label"] == "< 4 Wochen")
-        self.assertEqual(group["segments"][0]["id"], "deferred")
+        self.assertEqual(group["segments"][0]["id"], "10")
+        self.assertEqual(group["segments"][0]["label"], "Bezeichnung aus Datenquelle")
+        self.assertEqual([item['id'] for item in data['stack_items']], ['2', '3', '10', None])
         self.assertIn('id="list-count">1', self.client.get(group["segments"][0]["url"]).text)
 
-    def test_follow_up_year_boundary_and_berlin_calendar_day(self):
+    def test_follow_up_source_staffels_are_independent_of_dashboard_clock(self):
         self.db.connection.execute("UPDATE dbo.Forderungen_Details SET Wiedervorlage='2027-01-01' WHERE Beleg_Nr='R1'")
         now = datetime.fromisoformat("2026-12-28T23:30:00+00:00")
         groups = build_dashboard(self.db, "tester", now, stack="wiedervorlage")["views"]["Altersstaffel"]
         group = next(group for group in groups if group["label"] == "< 4 Wochen")
-        self.assertEqual(group['segments'][0]['url'], group['url'])
-        params = parse_qs(urlsplit(group['url']).query)
+        params = parse_qs(urlsplit(group['segments'][0]['url']).query)
         self.assertEqual(decode_filter_values(params['Staffel'][0]), ['< 4 Wochen'])
+        self.assertEqual(params['WVStaffel'], ['1'])
+        self.assertNotIn('WVStaffel', parse_qs(urlsplit(group['url']).query))
         self.assertNotIn('WiedervorlageVon', params)
         self.assertNotIn('WiedervorlageBis', params)
-        self.assertEqual(group["segments"][0]["id"], "soon")
+        self.assertEqual(group["segments"][0]["id"], "1")
 
     def test_follow_up_stack_roundtrips_for_all_rubrics_and_invoice_scope(self):
         for only in [False, True]:
@@ -259,10 +280,13 @@ class DashboardTests(unittest.TestCase):
                         self.assertNotIn('WiedervorlageVon', params)
                         self.assertNotIn('WiedervorlageBis', params)
                         self.assertNotIn('Abteilung', params)
-                        self.assertEqual(segment['url'], group['url'])
+                        self.assertEqual(params['WVStaffel'], [segment['id'] or '__empty__'])
+                        self.assertNotIn('WVStaffel', parse_qs(urlsplit(group['url']).query))
                         response = self.client.get(segment["url"])
                         self.assertEqual(response.status_code, 200)
-                        self.assertIn('id="list-count">' + str(group["count"]), response.text)
+                        self.assertIn('id="list-count">' + str(segment["count"]), response.text)
+                        from app.jinja_templates import number_format
+                        self.assertIn(number_format(Decimal(segment['amount'])) + ' €', response.text)
 
     def test_stack_selection_and_legacy_follow_up_links(self):
         response = self.client.get("/app/forderungen/dashboard?stack=wiedervorlage&metric=anzahl&only_invoices=true")