From aa3f641bf24c04e85655488b72106cf805430c74 Mon Sep 17 00:00:00 2001 From: localhorst Date: Tue, 21 Apr 2026 22:26:30 +0200 Subject: [PATCH] add report generator script --- .gitignore | 2 + README.md | 9 + tools/generate_report.py | 677 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 688 insertions(+) create mode 100644 tools/generate_report.py diff --git a/.gitignore b/.gitignore index 8b8c81d..28c3ede 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ target/ dependency-reduced-pom.xml +tools/*.db +tools/*.html \ No newline at end of file diff --git a/README.md b/README.md index 00cbcdf..ca721e8 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,15 @@ python tools/merge_db.py -o OUTPUT_DB INPUT_DB [INPUT_DB ...] python tools/merge_db.py -o merged.db 02.db 03.db 04.db ``` +### generate_report + +Generates a German HTML + PDF report from a [jFxKasse](https://git.mosad.xyz/localhorst/jFxKasse) SQLite database, including category/item tables, order KPIs, revenue breakdowns, and hourly trend charts per category. + +```bash +python tools/generate_report.py kassendaten.db # → kassendaten_bericht.html +python tools/generate_report.py kassendaten.db -o out.html +``` + ## License GPL-3.0 \ No newline at end of file diff --git a/tools/generate_report.py b/tools/generate_report.py new file mode 100644 index 0000000..29377a7 --- /dev/null +++ b/tools/generate_report.py @@ -0,0 +1,677 @@ +#!/usr/bin/env python3 +""" +generate_report.py – Erstellt einen deutschen HTML-Bericht aus einer SQLite-Datenbank + (Schema: category, positionen, jobs) – erstellt mit jFxKasse. + +Usage: + python generate_report.py [-o output.html] +""" + +import argparse +import json +import shutil +import sqlite3 +import subprocess +import sys +from collections import defaultdict +from datetime import datetime, timedelta +from pathlib import Path + + +SOFTWARE_NAME = "jFxKasse" +SOFTWARE_URL = "https://git.mosad.xyz/localhorst/jFxKasse" + +# --------------------------------------------------------------------------- +# Data helpers +# --------------------------------------------------------------------------- + + +def load_db(path: str) -> sqlite3.Connection: + con = sqlite3.connect(path) + con.row_factory = sqlite3.Row + return con + + +def parse_time(t: str) -> datetime: + return datetime.strptime(t.strip(), "%H:%M %d.%m.%Y") + + +def fmt_dt(dt: datetime) -> str: + return dt.strftime("%d.%m.%Y %H:%M Uhr") + + +def fmt_dur(td: timedelta) -> str: + total = int(td.total_seconds()) + h, rem = divmod(total, 3600) + m = rem // 60 + return f"{h} Std. {m} Min." + + +def fmt_eur(val) -> str: + try: + return f"{float(val):.2f} €" + except Exception: + return str(val) + + +# --------------------------------------------------------------------------- +# Chart helpers +# --------------------------------------------------------------------------- + +CHARTJS = "https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js" + +_chart_id = 0 + + +def next_id(prefix="chart") -> str: + global _chart_id + _chart_id += 1 + return f"{prefix}_{_chart_id}" + + +PALETTE = [ + "#c0392b", + "#2980b9", + "#27ae60", + "#8e44ad", + "#d35400", + "#16a085", + "#f39c12", + "#2c3e50", + "#7f8c8d", + "#e74c3c", + "#1abc9c", + "#9b59b6", + "#e67e22", + "#34495e", + "#95a5a6", +] + + +def bar_chart_html( + labels: list, + values: list, + xlabel: str = "", + ylabel: str = "", + color: str = "#2c3e50", + height: int = 280, +) -> str: + cid = next_id("bar") + data = { + "labels": labels, + "datasets": [ + { + "label": ylabel, + "data": values, + "backgroundColor": color, + "borderRadius": 4, + "borderSkipped": False, + } + ], + } + opts = { + "responsive": True, + "maintainAspectRatio": False, + "plugins": {"legend": {"display": False}}, + "scales": { + "x": { + "title": { + "display": bool(xlabel), + "text": xlabel, + "font": {"size": 11}, + "color": "#555", + }, + "grid": {"color": "#ebebeb"}, + "ticks": {"color": "#555", "font": {"size": 10}}, + }, + "y": { + "title": { + "display": bool(ylabel), + "text": ylabel, + "font": {"size": 11}, + "color": "#555", + }, + "grid": {"color": "#ebebeb"}, + "ticks": {"color": "#555", "font": {"size": 10}}, + "beginAtZero": True, + }, + }, + } + return ( + f'
' + f'
' + f"" + ) + + +def pie_chart_html(labels: list, values: list, height: int = 280) -> str: + cid = next_id("pie") + colors = [PALETTE[i % len(PALETTE)] for i in range(len(labels))] + data = { + "labels": labels, + "datasets": [ + { + "data": values, + "backgroundColor": colors, + "borderWidth": 2, + "borderColor": "#fff", + } + ], + } + opts = { + "responsive": True, + "maintainAspectRatio": False, + "plugins": { + "legend": { + "position": "right", + "labels": { + "font": { + "size": 11, + "family": "'Source Code Pro','Courier New',monospace", + }, + "color": "#2c2c2c", + "padding": 14, + "boxWidth": 14, + }, + } + }, + } + return ( + f'
' + f'
' + f"" + ) + + +# --------------------------------------------------------------------------- +# Analysis +# --------------------------------------------------------------------------- + + +def analyse(con: sqlite3.Connection) -> dict: + result = {} + + result["categories"] = [ + dict(r) + for r in con.execute("SELECT catid, catname FROM category ORDER BY catid") + ] + + result["positionen"] = [ + dict(r) + for r in con.execute( + "SELECT p.posid, p.name, p.value, c.catname, p.color " + "FROM positionen p LEFT JOIN category c ON p.cat=c.catid " + "ORDER BY p.posid" + ) + ] + + all_jobs = [dict(r) for r in con.execute("SELECT * FROM jobs ORDER BY jobid")] + + times = [parse_time(j["time"]) for j in all_jobs] + result["first_job"] = min(times) + result["last_job"] = max(times) + result["duration"] = result["last_job"] - result["first_job"] + result["total_jobs"] = len(all_jobs) + result["storniert_count"] = sum(1 for j in all_jobs if j["state"] == "storniert") + result["verbucht_count"] = sum(1 for j in all_jobs if j["state"] == "verbucht") + + vj = [j for j in all_jobs if j["state"] == "verbucht"] + values = [float(j["jobvalue"]) for j in vj] + result["sum_total"] = sum(values) + result["avg_value"] = sum(values) / len(values) if values else 0 + + min_val = min(values) + max_val = max(values) + result["min_job"] = next(j for j in vj if float(j["jobvalue"]) == min_val) + result["max_job"] = [j for j in vj if float(j["jobvalue"]) == max_val][-1] + + # Overall hourly trend (verbucht, skip empty hours) + hour_counts: dict[str, int] = defaultdict(int) + for j in vj: + dt = parse_time(j["time"]) + hour_counts[dt.strftime("%d.%m. %H:00")] += 1 + sorted_hours = sorted( + hour_counts, key=lambda k: datetime.strptime(k, "%d.%m. %H:00") + ) + result["trend_labels"] = sorted_hours + result["trend_values"] = [hour_counts[k] for k in sorted_hours] + + # Per-category aggregations (expand semicolon-separated fields) + cat_units: dict[str, int] = defaultdict(int) + cat_revenue: dict[str, float] = defaultdict(float) + cat_pos_units: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int)) + cat_pos_revenue: dict[str, dict[str, float]] = defaultdict( + lambda: defaultdict(float) + ) + cat_day_hour: dict[str, dict[str, dict[int, int]]] = defaultdict( + lambda: defaultdict(lambda: defaultdict(int)) + ) + + for j in vj: + dt = parse_time(j["time"]) + day = dt.strftime("%d.%m.%Y") + qtys = j["positionen_quantity"].split(";") + names = j["positionen_name"].split(";") + vals = j["positionen_value"].split(";") + cats = j["positionen_cat"].split(";") + for qty, name, val, cat in zip(qtys, names, vals, cats): + cat = cat.strip() + name = name.strip() + try: + q = int(qty.strip()) + except ValueError: + q = 1 + try: + v = float(val.strip()) + except ValueError: + v = 0.0 + cat_units[cat] += q + cat_revenue[cat] += v * q + cat_pos_units[cat][name] += q + cat_pos_revenue[cat][name] += v * q + cat_day_hour[cat][day][dt.hour] += q + + result["cat_units"] = dict(cat_units) + result["cat_revenue"] = dict(cat_revenue) + result["cat_pos_units"] = {c: dict(p) for c, p in cat_pos_units.items()} + result["cat_pos_revenue"] = {c: dict(p) for c, p in cat_pos_revenue.items()} + result["cat_day_hour"] = { + cat: {day: dict(hours) for day, hours in days.items()} + for cat, days in cat_day_hour.items() + } + + return result + + +# --------------------------------------------------------------------------- +# CSS +# --------------------------------------------------------------------------- + +CSS = """ +@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700&family=Source+Code+Pro:wght@400;600&display=swap'); + +*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;} +:root{ + --ink:#1a1a1a;--paper:#f7f4ef;--rule:#d4c9b8; + --red:#c0392b;--muted:#6b6560;--accent:#2c3e50;--white:#fff; +} +body{font-family:'Source Code Pro','Courier New',monospace;background:var(--paper); + color:var(--ink);font-size:13px;line-height:1.7;} +.page-wrap{max-width:980px;margin:0 auto;padding:48px 40px;} + +.report-header{border-top:4px solid var(--ink);border-bottom:2px solid var(--rule); + padding:28px 0 24px;margin-bottom:40px;} +.report-header h1{font-family:'Playfair Display',Georgia,serif;font-size:2.1rem; + letter-spacing:-0.02em;color:var(--ink);margin-bottom:4px;} +.report-header .sub{color:var(--muted);font-size:11px;letter-spacing:0.08em;text-transform:uppercase;} +.sw-link{display:inline-block;margin-top:10px;font-size:11px;color:var(--accent); + text-decoration:none;border-bottom:1px solid var(--accent);opacity:.8;} +.sw-link:hover{opacity:1;} +.meta-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr)); + gap:8px 24px;margin-top:18px;padding-top:16px;border-top:1px solid var(--rule);} +.meta-item{display:flex;flex-direction:column;gap:2px;} +.meta-label{font-size:10px;text-transform:uppercase;letter-spacing:0.1em;color:var(--muted);} +.meta-value{font-weight:600;color:var(--ink);font-size:12px;} + +.section{margin-bottom:48px;} +.section-title{font-family:'Playfair Display',Georgia,serif;font-size:1.25rem; + border-bottom:2px solid var(--ink);padding-bottom:6px;margin-bottom:20px;color:var(--accent);} +.section-sub{font-size:10px;text-transform:uppercase;letter-spacing:0.1em; + color:var(--muted);margin-bottom:14px;margin-top:-10px;} + +table{width:100%;border-collapse:collapse;font-size:12px;} +th{background:var(--ink);color:var(--paper);text-align:left;padding:8px 12px; + font-weight:600;letter-spacing:0.05em;font-size:10px;text-transform:uppercase;} +td{padding:7px 12px;border-bottom:1px solid var(--rule);vertical-align:top;} +tr:nth-child(even) td{background:rgba(0,0,0,.025);} + +.kpi-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(155px,1fr)); + gap:14px;margin-bottom:22px;} +.kpi-card{border:1px solid var(--rule);padding:15px 16px;background:var(--white);position:relative;} +.kpi-card::before{content:'';position:absolute;top:0;left:0;width:3px;height:100%;background:var(--red);} +.kpi-label{font-size:10px;text-transform:uppercase;letter-spacing:0.1em;color:var(--muted);margin-bottom:3px;} +.kpi-value{font-family:'Playfair Display',Georgia,serif;font-size:1.45rem;color:var(--ink);line-height:1.2;} +.kpi-sub{font-size:10px;color:var(--muted);margin-top:4px;} + +.dot{display:inline-block;width:10px;height:10px;border-radius:50%; + margin-right:6px;vertical-align:middle;border:1px solid rgba(0,0,0,.15);} + +.chart-grid-2{display:grid;grid-template-columns:repeat(auto-fit,minmax(420px,1fr));gap:24px;margin-top:8px;} +.chart-box{border:1px solid var(--rule);padding:18px 18px 14px;background:var(--white);} +.chart-box-title{font-size:11px;font-weight:600;text-transform:uppercase; + letter-spacing:0.08em;color:var(--accent);margin-bottom:3px;} +.chart-box-note{font-size:11px;color:var(--muted);margin-bottom:8px;} + +.rule{border:none;border-top:1px solid var(--rule);margin:32px 0;} + +.report-footer{border-top:2px solid var(--ink);padding-top:14px;margin-top:48px; + font-size:10px;color:var(--muted);display:flex;justify-content:space-between;flex-wrap:wrap;gap:4px;} +.report-footer a{color:inherit;} + +@media print{ + body{background:#fff;} + .page-wrap{padding:20px;} + .chart-grid-2{grid-template-columns:1fr 1fr;} + .section,.kpi-grid,.chart-box{page-break-inside:avoid;} +} +""" + + +# --------------------------------------------------------------------------- +# HTML builder +# --------------------------------------------------------------------------- + + +def build_html(db_path: str, data: dict, export_time: datetime) -> str: + p = Path(db_path) + display_name = p.name + file_size_kb = p.stat().st_size / 1024 + file_mtime = datetime.fromtimestamp(p.stat().st_mtime) + + # ---- Header ---- + header = f""" +
+
Auswertungsbericht · Kassensystem
+

{display_name}

+ Erstellt mit {SOFTWARE_NAME} +
+
Dateiname + {display_name}
+
Dateigröße + {file_size_kb:.1f} KB
+
Erstellt am + {file_mtime.strftime('%d.%m.%Y %H:%M Uhr')}
+
Export um + {export_time.strftime('%d.%m.%Y %H:%M Uhr')}
+
+
""" + + # ---- Kategorien ---- + cat_rows = "".join( + f'{c["catid"]}{c["catname"]}' + for c in data["categories"] + ) + section_cat = f""" +
+
Kategorien
+ + + {cat_rows} +
#Kategoriename
+
""" + + # ---- Positionen ---- + pos_rows = "".join( + f'{pos["posid"]}' + f'{pos["name"]}' + f'{fmt_eur(pos["value"])}' + f'{pos["catname"] or "–"}' + for pos in data["positionen"] + ) + section_pos = f""" +
+
Positionen / Artikel
+ + + {pos_rows} +
#BezeichnungPreisKategorie
+
""" + + # ---- Jobs ---- + min_j = data["min_job"] + max_j = data["max_job"] + kpi_overview = f""" +
+
Erster Auftrag
+
{fmt_dt(data["first_job"])}
+
Letzter Auftrag
+
{fmt_dt(data["last_job"])}
+
Betriebsdauer
+
{fmt_dur(data["duration"])}
+
Aufträge gesamt
+
{data["total_jobs"]}
+
Verbucht
+
{data["verbucht_count"]}
+
Storniert
+
{data["storniert_count"]}
+
""" + + kpi_deep = f""" +
+
Gesamtumsatz
+
{fmt_eur(data["sum_total"])}
+
nur verbuchte Aufträge
+
Durchschnitt / Auftrag
+
{fmt_eur(data["avg_value"])}
+
nur verbuchte Aufträge
+
Kleinster Auftrag
+
{fmt_eur(min_j["jobvalue"])}
+
Job #{min_j["jobid"]} · {min_j["time"]}
+
Größter Auftrag
+
{fmt_eur(max_j["jobvalue"])}
+
Job #{max_j["jobid"]} · {max_j["time"]}
+
""" + + trend_chart = bar_chart_html( + data["trend_labels"], + data["trend_values"], + xlabel="Stunde", + ylabel="Anzahl Aufträge", + color="#2c3e50", + height=300, + ) + + section_jobs = f""" +
+
Auftragsübersicht
+
Alle Aufträge
+ {kpi_overview} +
+
Tiefenanalyse – nur verbuchte Aufträge
+ {kpi_deep} +
+
Trendverlauf – Aufträge pro Stunde (gesamte Datenbank)
+ {trend_chart} +
""" + + # ---- Pie 1: Einheiten je Kategorie ---- + cu = data["cat_units"] + pie_units = pie_chart_html( + [f"{k} ({v} Stk.)" for k, v in cu.items()], list(cu.values()), height=300 + ) + note_units = "  ·  ".join( + f"{k}: {v} Stk." for k, v in cu.items() + ) + + # ---- Pie 2: Umsatz je Kategorie ---- + cr = data["cat_revenue"] + pie_revenue = pie_chart_html( + [f"{k} ({fmt_eur(v)})" for k, v in cr.items()], list(cr.values()), height=300 + ) + note_revenue = "  ·  ".join( + f"{k}: {fmt_eur(v)}" for k, v in cr.items() + ) + + section_cat_pies = f""" +
+
Kategorienauswertung
+ +
Verkaufte Einheiten nach Kategorie (nur verbuchte Aufträge)
+
+
Einheiten je Kategorie
+
{note_units}
+ {pie_units} +
+ +
Umsatz nach Kategorie (nur verbuchte Aufträge)
+
+
Umsatz je Kategorie
+
{note_revenue}
+ {pie_revenue} +
+
""" + + # ---- Pie 3: Positionen je Kategorie ---- + pos_pie_boxes = "" + for cat, pos_dict in sorted(data["cat_pos_units"].items()): + labels = list(pos_dict.keys()) + values = list(pos_dict.values()) + total = sum(values) + note = "  ·  ".join( + f"{l}: {v}" for l, v in zip(labels, values) + ) + pie = pie_chart_html( + [f"{l} ({v})" for l, v in zip(labels, values)], values, height=260 + ) + pos_pie_boxes += f""" +
+
{cat}
+
Gesamt: {total} Einheiten
+ {pie} +
{note}
+
""" + + section_pos_pies = f""" +
+
Positionen je Kategorie
+
Verkaufte Stückzahl pro Artikel, aufgeteilt nach Kategorie
+
+ {pos_pie_boxes} +
+
""" + + # ---- Per-category trend per day ---- + cat_names = sorted(data["cat_day_hour"].keys()) + cat_trend_boxes = "" + for i, cat in enumerate(cat_names): + color = PALETTE[i % len(PALETTE)] + days = data["cat_day_hour"][cat] + for day in sorted(days, key=lambda d: datetime.strptime(d, "%d.%m.%Y")): + hours = days[day] + labels = [f"{h}:00" for h in sorted(hours)] + values = [hours[h] for h in sorted(hours)] + chart = bar_chart_html( + labels, + values, + xlabel="Stunde", + ylabel="Einheiten", + color=color, + height=220, + ) + cat_trend_boxes += f""" +
+
{cat} · {day}
+ {chart} +
""" + + section_trends = f""" +
+
Trendverlauf pro Kategorie und Tag
+
Verkaufte Artikel-Einheiten pro Stunde
+
+ {cat_trend_boxes} +
+
""" + + # ---- Footer ---- + footer = f""" +""" + + return f""" + + + + +Bericht – {display_name} + + + + +
+{header} +{section_cat} +
+{section_pos} +
+{section_jobs} +
+{section_cat_pies} +
+{section_pos_pies} +
+{section_trends} +{footer} +
+ +""" + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="generate_report", + description="Erstellt einen deutschen HTML-Bericht aus einer jFxKasse SQLite-Datenbank.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + "Beispiele:\n" + " python generate_report.py kassendaten.db\n" + " python generate_report.py kassendaten.db -o bericht.html\n" + ), + ) + p.add_argument("database", help="Pfad zur SQLite-Datenbankdatei") + p.add_argument( + "-o", + "--output", + metavar="OUTPUT_HTML", + help="Pfad zur Ausgabe-HTML-Datei (Standard: _bericht.html)", + ) + return p + + +def main(): + parser = build_parser() + args = parser.parse_args() + + db_path = Path(args.database) + if not db_path.exists(): + parser.error(f"Datei nicht gefunden: {db_path}") + + out_path = ( + Path(args.output) + if args.output + else db_path.with_name(db_path.stem + "_bericht.html") + ) + + print(f"[lade] {db_path.name}") + con = load_db(str(db_path)) + + print("[analyse] Daten werden ausgewertet …") + data = analyse(con) + con.close() + + print("[render] HTML wird erstellt …") + html = build_html(str(db_path), data, datetime.now()) + out_path.write_text(html, encoding="utf-8") + print(f"[fertig] HTML gespeichert: {out_path}") + + +if __name__ == "__main__": + main()