add report generator script

This commit is contained in:
2026-04-21 22:26:30 +02:00
parent d4f89f39bd
commit aa3f641bf2
3 changed files with 688 additions and 0 deletions

2
.gitignore vendored
View File

@ -1,2 +1,4 @@
target/
dependency-reduced-pom.xml
tools/*.db
tools/*.html

View File

@ -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

677
tools/generate_report.py Normal file
View 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 = " &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()