forderung_dashboard.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. """Aggregate authorized invoices by rubric and selectable stack dimension."""
  2. import hashlib
  3. from collections import Counter
  4. from datetime import date, datetime, timedelta
  5. from decimal import Decimal
  6. from urllib.parse import urlencode
  7. from zoneinfo import ZoneInfo
  8. from .dashboard_segment import DASHBOARD_VIEWS, pack_segment
  9. from .forderung_data import read_invoices
  10. from .jinja_templates import number_format
  11. BERLIN = ZoneInfo("Europe/Berlin")
  12. STACKS = ("abteilung", "wiedervorlage")
  13. def _key(value):
  14. return None if value is None or str(value) == "" else str(value)
  15. def _date(value):
  16. return (
  17. value.date()
  18. if isinstance(value, datetime)
  19. else value if isinstance(value, date) else date.fromisoformat(str(value)[:10]) if value else None
  20. )
  21. def color(department):
  22. hue = int.from_bytes(hashlib.sha256((department or "").encode()).digest()[:2], "big") % 360
  23. return f"hsl({hue}, 23%, 70%)"
  24. def follow_up_bands(today):
  25. return [
  26. dict(
  27. id="overdue",
  28. label="Überfällig",
  29. color="#d4aaaa",
  30. key={"from": None, "to": (today - timedelta(days=1)).isoformat()},
  31. ),
  32. dict(
  33. id="soon",
  34. label="In 3 Tagen fällig",
  35. color="#e0cd9c",
  36. key={"from": today.isoformat(), "to": (today + timedelta(days=3)).isoformat()},
  37. ),
  38. dict(
  39. id="not_due",
  40. label="Noch nicht fällig",
  41. color="#b4c5b5",
  42. key={"from": (today + timedelta(days=4)).isoformat(), "to": (today + timedelta(days=14)).isoformat()},
  43. ),
  44. dict(
  45. id="deferred",
  46. label="zurückgestellt",
  47. color="#b2bccd",
  48. key={"from": (today + timedelta(days=15)).isoformat(), "to": None},
  49. ),
  50. dict(id="undated", label="Ohne Datum", color="#c5c5c5", key=None),
  51. ]
  52. def _url(view, key, segment=..., only_invoices=False, stack="abteilung"):
  53. selection = {"view": view, "key": key}
  54. if segment is not ...:
  55. selection["department" if stack == "abteilung" else "follow_up"] = segment
  56. if only_invoices:
  57. selection["only_invoices"] = True
  58. return "/app/forderungen/liste?" + urlencode({"dashboard_segment": pack_segment(selection)})
  59. def build_dashboard(db, username, now=None, only_invoices=False, stack="abteilung"):
  60. if stack not in STACKS:
  61. raise ValueError("Ungültige Stapelung")
  62. now = now or datetime.now(BERLIN)
  63. today = now.astimezone(BERLIN).date()
  64. if only_invoices:
  65. columns, source = read_invoices(
  66. db, username, ["F.[Rechnung_Gutschrift] = :dashboard_invoice_type"], {"dashboard_invoice_type": "Rechnung"}
  67. )
  68. else:
  69. columns, source = read_invoices(db, username)
  70. groups = {view: {} for view in DASHBOARD_VIEWS}
  71. total, departments, observed = Decimal(0), set(), set()
  72. bands = follow_up_bands(today)
  73. for values in source:
  74. row = dict(zip(columns, values))
  75. department = _key(row.get("Abteilung"))
  76. departments.add(department)
  77. if stack == "abteilung":
  78. segment_id = department
  79. else:
  80. when = _date(row.get("Wiedervorlage") or row.get("Beleg_Faellig_Datum"))
  81. bucket = (
  82. 4
  83. if when is None
  84. else (
  85. 0
  86. if when < today
  87. else 1 if when <= today + timedelta(days=3) else 2 if when <= today + timedelta(days=14) else 3
  88. )
  89. )
  90. segment_id = bands[bucket]["id"]
  91. observed.add(segment_id)
  92. amount = Decimal(str(row["Beleg_Offen"] or 0))
  93. total += amount
  94. age = _key(row.get("Staffel_Text"))
  95. site = {"client": str(row["Client_DB"]), "site": _key(row.get("Standort_ID"))}
  96. person_id = _key(row.get("Verursacher_Benutzer_ID"))
  97. person = {"id": person_id} if person_id else {"text": _key(row.get("Verursacher_Text"))}
  98. definitions = [
  99. (
  100. "Altersstaffel",
  101. age,
  102. age or "Ohne Altersstaffel",
  103. int(row["Beleg_Tage_offen"]) if age and row.get("Beleg_Tage_offen") is not None else 10**12,
  104. ),
  105. (
  106. "Standort",
  107. site,
  108. f"{row.get('Standort_Name') or site['site'] or 'Ohne Standort'} ({row.get('Hauptbetrieb_Name') or 'Mandant ' + site['client']})",
  109. 0,
  110. ),
  111. ("Verursacher", person, row.get("Verursacher_Text") or person_id or "Ohne Verursacher", 0),
  112. ]
  113. for view, key, label, order in definitions:
  114. identity = pack_segment(key)
  115. group = groups[view].setdefault(
  116. identity, dict(key=key, label=label, order=order, count=0, amount=Decimal(0), segments={})
  117. )
  118. group["order"] = min(group["order"], order)
  119. group["count"] += 1
  120. group["amount"] += amount
  121. segment = group["segments"].setdefault(segment_id, dict(count=0, amount=Decimal(0)))
  122. segment["count"] += 1
  123. segment["amount"] += amount
  124. items = (
  125. [
  126. dict(id=dep, label=dep or "Ohne Abteilung", color=color(dep), key=dep)
  127. for dep in sorted(departments, key=lambda value: (value is None, value or ""))
  128. ]
  129. if stack == "abteilung"
  130. else [band for band in bands if band["id"] != "undated" or "undated" in observed]
  131. )
  132. def serialized(amount):
  133. return str(amount.quantize(Decimal("0.01")))
  134. output = {}
  135. for view, mapping in groups.items():
  136. result = []
  137. labels = Counter(group["label"].casefold() for group in mapping.values())
  138. for group in mapping.values():
  139. label = group["label"]
  140. if labels[label.casefold()] > 1:
  141. if view == "Verursacher":
  142. label += " (" + (group["key"].get("id") or "ohne Benutzer-ID") + ")"
  143. elif view == "Standort":
  144. label += " · Standort-ID " + (group["key"]["site"] or "ohne ID")
  145. segments = [
  146. dict(
  147. id=item["id"],
  148. department=item["id"] if stack == "abteilung" else None,
  149. label=item["label"],
  150. count=group["segments"][item["id"]]["count"],
  151. amount=serialized(group["segments"][item["id"]]["amount"]),
  152. color=item["color"],
  153. url=_url(view, group["key"], item["key"], only_invoices, stack),
  154. )
  155. for item in items
  156. if item["id"] in group["segments"]
  157. ]
  158. result.append(
  159. dict(
  160. label=label,
  161. order=group["order"],
  162. count=group["count"],
  163. amount=serialized(group["amount"]),
  164. segments=segments,
  165. url=_url(view, group["key"], only_invoices=only_invoices),
  166. )
  167. )
  168. output[view] = result
  169. return dict(
  170. count=len(source),
  171. amount=serialized(total),
  172. amount_display=number_format(total),
  173. views=output,
  174. only_invoices=only_invoices,
  175. stack=stack,
  176. stack_label="Abteilung" if stack == "abteilung" else "Wiedervorlage",
  177. stack_items=[{name: value for name, value in item.items() if name != "key"} for item in items],
  178. generated=now.astimezone(BERLIN).strftime("%d.%m.%Y %H:%M"),
  179. )