add report generator script
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,2 +1,4 @@
|
|||||||
target/
|
target/
|
||||||
dependency-reduced-pom.xml
|
dependency-reduced-pom.xml
|
||||||
|
tools/*.db
|
||||||
|
tools/*.html
|
||||||
@ -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
|
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
|
## License
|
||||||
|
|
||||||
GPL-3.0
|
GPL-3.0
|
||||||
677
tools/generate_report.py
Normal file
677
tools/generate_report.py
Normal file
@ -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 <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 = " · ".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 = " · ".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 = " · ".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} ·
|
||||||
|
<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()
|
||||||
Reference in New Issue
Block a user