forderung_dashboard.py 7.5 KB

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