Files
jFxKasse/tools/generate_report.py

678 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 <database.db> [-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'<div style="position:relative;height:{height}px;width:100%;margin:8px 0;">'
f'<canvas id="{cid}"></canvas></div>'
f"<script>(function(){{"
f'var ctx=document.getElementById("{cid}").getContext("2d");'
f'new Chart(ctx,{{type:"bar",'
f"data:{json.dumps(data, ensure_ascii=False)},"
f"options:{json.dumps(opts, ensure_ascii=False)}}});"
f"}})();</script>"
)
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'<div style="position:relative;height:{height}px;width:100%;margin:8px 0;">'
f'<canvas id="{cid}"></canvas></div>'
f"<script>(function(){{"
f'var ctx=document.getElementById("{cid}").getContext("2d");'
f'new Chart(ctx,{{type:"pie",'
f"data:{json.dumps(data, ensure_ascii=False)},"
f"options:{json.dumps(opts, ensure_ascii=False)}}});"
f"}})();</script>"
)
# ---------------------------------------------------------------------------
# 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"""
<div class="report-header">
<div class="sub">Auswertungsbericht · Kassensystem</div>
<h1>{display_name}</h1>
<a class="sw-link" href="{SOFTWARE_URL}" target="_blank">Erstellt mit {SOFTWARE_NAME}</a>
<div class="meta-grid">
<div class="meta-item"><span class="meta-label">Dateiname</span>
<span class="meta-value">{display_name}</span></div>
<div class="meta-item"><span class="meta-label">Dateigröße</span>
<span class="meta-value">{file_size_kb:.1f} KB</span></div>
<div class="meta-item"><span class="meta-label">Erstellt am</span>
<span class="meta-value">{file_mtime.strftime('%d.%m.%Y %H:%M Uhr')}</span></div>
<div class="meta-item"><span class="meta-label">Export um</span>
<span class="meta-value">{export_time.strftime('%d.%m.%Y %H:%M Uhr')}</span></div>
</div>
</div>"""
# ---- Kategorien ----
cat_rows = "".join(
f'<tr><td>{c["catid"]}</td><td>{c["catname"]}</td></tr>'
for c in data["categories"]
)
section_cat = f"""
<div class="section">
<div class="section-title">Kategorien</div>
<table>
<thead><tr><th>#</th><th>Kategoriename</th></tr></thead>
<tbody>{cat_rows}</tbody>
</table>
</div>"""
# ---- Positionen ----
pos_rows = "".join(
f'<tr><td>{pos["posid"]}</td>'
f'<td><span class="dot" style="background:{pos["color"]};"></span>{pos["name"]}</td>'
f'<td>{fmt_eur(pos["value"])}</td>'
f'<td>{pos["catname"] or ""}</td></tr>'
for pos in data["positionen"]
)
section_pos = f"""
<div class="section">
<div class="section-title">Positionen / Artikel</div>
<table>
<thead><tr><th>#</th><th>Bezeichnung</th><th>Preis</th><th>Kategorie</th></tr></thead>
<tbody>{pos_rows}</tbody>
</table>
</div>"""
# ---- Jobs ----
min_j = data["min_job"]
max_j = data["max_job"]
kpi_overview = f"""
<div class="kpi-grid">
<div class="kpi-card"><div class="kpi-label">Erster Auftrag</div>
<div class="kpi-value" style="font-size:.95rem;">{fmt_dt(data["first_job"])}</div></div>
<div class="kpi-card"><div class="kpi-label">Letzter Auftrag</div>
<div class="kpi-value" style="font-size:.95rem;">{fmt_dt(data["last_job"])}</div></div>
<div class="kpi-card"><div class="kpi-label">Betriebsdauer</div>
<div class="kpi-value" style="font-size:.95rem;">{fmt_dur(data["duration"])}</div></div>
<div class="kpi-card"><div class="kpi-label">Aufträge gesamt</div>
<div class="kpi-value">{data["total_jobs"]}</div></div>
<div class="kpi-card"><div class="kpi-label">Verbucht</div>
<div class="kpi-value" style="color:#27ae60;">{data["verbucht_count"]}</div></div>
<div class="kpi-card"><div class="kpi-label">Storniert</div>
<div class="kpi-value" style="color:#c0392b;">{data["storniert_count"]}</div></div>
</div>"""
kpi_deep = f"""
<div class="kpi-grid">
<div class="kpi-card"><div class="kpi-label">Gesamtumsatz</div>
<div class="kpi-value">{fmt_eur(data["sum_total"])}</div>
<div class="kpi-sub">nur verbuchte Aufträge</div></div>
<div class="kpi-card"><div class="kpi-label">Durchschnitt / Auftrag</div>
<div class="kpi-value">{fmt_eur(data["avg_value"])}</div>
<div class="kpi-sub">nur verbuchte Aufträge</div></div>
<div class="kpi-card"><div class="kpi-label">Kleinster Auftrag</div>
<div class="kpi-value">{fmt_eur(min_j["jobvalue"])}</div>
<div class="kpi-sub">Job #{min_j["jobid"]} · {min_j["time"]}</div></div>
<div class="kpi-card"><div class="kpi-label">Größter Auftrag</div>
<div class="kpi-value">{fmt_eur(max_j["jobvalue"])}</div>
<div class="kpi-sub">Job #{max_j["jobid"]} · {max_j["time"]}</div></div>
</div>"""
trend_chart = bar_chart_html(
data["trend_labels"],
data["trend_values"],
xlabel="Stunde",
ylabel="Anzahl Aufträge",
color="#2c3e50",
height=300,
)
section_jobs = f"""
<div class="section">
<div class="section-title">Auftragsübersicht</div>
<div class="section-sub">Alle Aufträge</div>
{kpi_overview}
<hr class="rule">
<div class="section-sub">Tiefenanalyse nur verbuchte Aufträge</div>
{kpi_deep}
<hr class="rule">
<div class="section-sub">Trendverlauf Aufträge pro Stunde (gesamte Datenbank)</div>
{trend_chart}
</div>"""
# ---- 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 = " &nbsp;·&nbsp; ".join(
f"<strong>{k}</strong>: {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 = " &nbsp;·&nbsp; ".join(
f"<strong>{k}</strong>: {fmt_eur(v)}" for k, v in cr.items()
)
section_cat_pies = f"""
<div class="section">
<div class="section-title">Kategorienauswertung</div>
<div class="section-sub">Verkaufte Einheiten nach Kategorie (nur verbuchte Aufträge)</div>
<div class="chart-box" style="margin-bottom:28px;">
<div class="chart-box-title">Einheiten je Kategorie</div>
<div class="chart-box-note">{note_units}</div>
{pie_units}
</div>
<div class="section-sub">Umsatz nach Kategorie (nur verbuchte Aufträge)</div>
<div class="chart-box">
<div class="chart-box-title">Umsatz je Kategorie</div>
<div class="chart-box-note">{note_revenue}</div>
{pie_revenue}
</div>
</div>"""
# ---- 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 = " &nbsp;·&nbsp; ".join(
f"<strong>{l}</strong>: {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"""
<div class="chart-box">
<div class="chart-box-title">{cat}</div>
<div class="chart-box-note">Gesamt: {total} Einheiten</div>
{pie}
<div class="chart-box-note" style="margin-top:10px;font-size:10px;line-height:1.8;">{note}</div>
</div>"""
section_pos_pies = f"""
<div class="section">
<div class="section-title">Positionen je Kategorie</div>
<div class="section-sub">Verkaufte Stückzahl pro Artikel, aufgeteilt nach Kategorie</div>
<div class="chart-grid-2">
{pos_pie_boxes}
</div>
</div>"""
# ---- 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"""
<div class="chart-box">
<div class="chart-box-title">{cat} · {day}</div>
{chart}
</div>"""
section_trends = f"""
<div class="section">
<div class="section-title">Trendverlauf pro Kategorie und Tag</div>
<div class="section-sub">Verkaufte Artikel-Einheiten pro Stunde</div>
<div class="chart-grid-2">
{cat_trend_boxes}
</div>
</div>"""
# ---- Footer ----
footer = f"""
<div class="report-footer">
<span>Exportiert am {export_time.strftime('%d.%m.%Y um %H:%M Uhr')}</span>
<span>Quelle: {display_name} &nbsp;·&nbsp;
<a href="{SOFTWARE_URL}">{SOFTWARE_NAME}</a></span>
</div>"""
return f"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Bericht {display_name}</title>
<script src="{CHARTJS}"></script>
<style>{CSS}</style>
</head>
<body>
<div class="page-wrap">
{header}
{section_cat}
<hr class="rule">
{section_pos}
<hr class="rule">
{section_jobs}
<hr class="rule">
{section_cat_pies}
<hr class="rule">
{section_pos_pies}
<hr class="rule">
{section_trends}
{footer}
</div>
</body>
</html>"""
# ---------------------------------------------------------------------------
# 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: <dateiname>_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()