#!/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()