import pandas as pd import hashlib import matplotlib.pyplot as plt from matplotlib.backends.backend_pdf import PdfPages from matplotlib import font_manager from datetime import datetime import os import re import argparse import tarfile import subprocess def parse_csv(filename): df = pd.read_csv(filename, sep=';', encoding='utf-8') df['Betrag'] = df['Betrag'].fillna('0') df['Betrag'] = df['Betrag'].apply(lambda x: str(x).replace('€', '').replace(',', '.').strip()) df['Kilometer'] = df['Position'].str.contains('Autofahrt', case=False) def extract_km(row): if row['Kilometer']: match = re.search(r"((\d+,\d+\skm)|(\d+\.\d+\skm))", row['Betrag']) if match: return float(match.group(0).replace(',', '.').replace(" ", "").replace("km", "")) else: raise ValueError(f"Kilometerangabe unlesbar in Zeile: {row}") return 0.0 df['km'] = df.apply(extract_km, axis=1) df['Betrag'] = df.apply(lambda row: 0.0 if row['Kilometer'] else float(row['Betrag']), axis=1) df['Datum'] = pd.to_datetime(df['Datum'], format="%Y-%m-%d", dayfirst=True) return df def compute_hash(filepath, base_dir="."): try: full_path = os.path.join(base_dir, filepath) with open(full_path, 'rb') as f: return hashlib.sha1(f.read()).hexdigest() except: return None def compute_settlements(df): personen = df['Name'].unique() # Tankdaten tankkosten_df = df[df['Position'].str.contains('Tanken', case=False)] km_df = df[df['Position'].str.contains('Autofahrt', case=False)] total_km = km_df['km'].sum() total_tank = tankkosten_df['Betrag'].sum() km_preis = total_tank / total_km if total_km else 0 # Gruppiert nach Person km_pro_person = km_df.groupby('Name')['km'].sum().to_dict() tank_auslagen = tankkosten_df.groupby('Name')['Betrag'].sum().to_dict() # Sonstige Kosten sonstige_df = df[~df['Position'].str.contains('Tanken|Autofahrt', case=False)] total_sonstige = sonstige_df['Betrag'].sum() sonstige_auslagen = sonstige_df.groupby('Name')['Betrag'].sum().to_dict() sonstige_pro_person = total_sonstige / len(personen) # Gesamtanteil und Auslagen pro Person gesamt_anteil = { p: km_pro_person.get(p, 0) * km_preis + sonstige_pro_person for p in personen } gesamt_ausgelegt = { p: tank_auslagen.get(p, 0) + sonstige_auslagen.get(p, 0) for p in personen } # Saldo berechnen saldo = {p: gesamt_ausgelegt[p] - gesamt_anteil[p] for p in personen} return saldo, gesamt_ausgelegt, gesamt_anteil, km_df, km_preis def minimize_payments(saldo): empfaenger = [] zahler = [] for person, betrag in saldo.items(): if round(betrag, 2) > 0: empfaenger.append([person, round(betrag, 2)]) elif round(betrag, 2) < 0: zahler.append([person, -round(betrag, 2)]) zahlungen = [] i = j = 0 while i < len(zahler) and j < len(empfaenger): payer, amount = zahler[i] receiver, need = empfaenger[j] zahlung = min(amount, need) zahlungen.append((payer, receiver, zahlung)) zahler[i][1] -= zahlung empfaenger[j][1] -= zahlung if zahler[i][1] == 0: i += 1 if empfaenger[j][1] == 0: j += 1 return zahlungen def create_pdf(df, saldo, ausgelegt, anteil, zahlungen, km_info, km_preis, pdf_path, base_dir=".", anteil_pro_person=None): mono_font = font_manager.FontProperties(family='Inconsolata', size=8) with PdfPages(pdf_path) as pdf: columns = ['Datum', 'Position', 'Name', 'Betrag/km', 'Rechnung', 'SHA1'] table_data = [] for _, row in df.iterrows(): hash_str = compute_hash(row['Rechnung'], base_dir) if pd.notna(row['Rechnung']) else '' table_data.append([ row['Datum'].strftime('%Y-%m-%d'), row['Position'], row['Name'], f"{row['Betrag']:.2f} €" if not row['Kilometer'] else f"{row['km']} km", row['Rechnung'] if pd.notna(row['Rechnung']) else '', "{}\n{}".format(hash_str[:int(len(hash_str)/2)], hash_str[int(len(hash_str)/2):]) if hash_str else "" ]) chunk_size = 25 fontprops = font_manager.FontProperties(size=8) for start in range(0, len(table_data), chunk_size): fig, ax = plt.subplots(figsize=(8.27, 11.69)) ax.axis('off') table_data_chunk = table_data[start:start+chunk_size] renderer = fig.canvas.get_renderer() def get_text_width(text, prop): text_obj = plt.text(0, 0, text, fontproperties=prop) bb = text_obj.get_window_extent(renderer=renderer) text_obj.remove() return bb.width col_widths = [] for col_idx in range(len(columns)): max_width = get_text_width(columns[col_idx], fontprops) for row in table_data_chunk: max_width = max(max_width, get_text_width(str(row[col_idx]), fontprops)) col_widths.append(max_width) col_widths_inches = [w / fig.dpi for w in col_widths] total_width = sum(col_widths_inches) scaled_widths = [w / total_width for w in col_widths_inches] table = ax.table(cellText=table_data_chunk, colLabels=columns, loc='center', cellLoc='left') table.auto_set_font_size(False) for cell in table.get_celld().values(): cell.get_text().set_fontproperties(mono_font) for (row, col), cell in table.get_celld().items(): if col < len(scaled_widths): cell.set_width(scaled_widths[col]) cell.PAD = 0.05 table.scale(1, 2) pdf.savefig() plt.close() fig, axs = plt.subplots(3, 2, figsize=(8.27, 11.69)) fig.suptitle("Auswertung", fontsize=14, fontproperties=mono_font) df_plot = pd.DataFrame({'Ausgelegt': ausgelegt, 'Anteil': anteil}) df_plot.plot.bar(ax=axs[0, 0]) axs[0, 0].set_title('Ausgelegt vs. Anteil', fontproperties=mono_font) axs[0, 0].tick_params(axis='x', rotation=0) axs[0, 0].legend(prop=mono_font) for name, group in km_info.groupby('Name'): group_sorted = group.sort_values('Datum').copy() group_sorted['km_cumsum'] = group_sorted['km'].cumsum() monatserster = pd.Timestamp(group_sorted['Datum'].min().replace(day=1)) monatsletzter = pd.Timestamp(group_sorted['Datum'].max().replace(day=1)) + pd.offsets.MonthEnd(0) null_row = pd.DataFrame({ 'Datum': [monatserster], 'km_cumsum': [0] }) end_row = pd.DataFrame({ 'Datum': [monatsletzter], 'km_cumsum': [group_sorted['km_cumsum'].iloc[-1]] }) plot_df = pd.concat([null_row, group_sorted[['Datum', 'km_cumsum']], end_row]) plot_df = plot_df.sort_values('Datum') dates = plot_df['Datum'] values = plot_df['km_cumsum'] axs[0, 1].plot(dates, values, label=name, linewidth=1) axs[0, 1].fill_between(dates, values, alpha=0.15) axs[0, 1].set_title('Kumulativer km-Verlauf im Monat', fontproperties=mono_font) axs[0, 1].set_ylabel('km', fontproperties=mono_font) axs[0, 1].set_xlabel('Tag des Monats', fontproperties=mono_font) alle_daten = km_info['Datum'].sort_values().unique() axs[0, 1].set_xticks(alle_daten) axs[0, 1].set_xticklabels([d.day for d in alle_daten], rotation=0) axs[0, 1].legend(prop=mono_font) axs[1, 0].set_title("Kumulative sonstige Ausgaben", fontproperties=mono_font) sonstige = df[~df['Position'].str.contains('Tanken|Autofahrt', case=False)] for name, group in sonstige.groupby('Name'): group_sorted = group.sort_values('Datum').copy() group_sorted['betrag_cumsum'] = group_sorted['Betrag'].cumsum() monatserster = pd.Timestamp(group_sorted['Datum'].min().replace(day=1)) monatsletzter = pd.Timestamp(group_sorted['Datum'].max().replace(day=1)) + pd.offsets.MonthEnd(0) null_row = pd.DataFrame({ 'Datum': [monatserster], 'betrag_cumsum': [0] }) end_row = pd.DataFrame({ 'Datum': [monatsletzter], 'betrag_cumsum': [group_sorted['betrag_cumsum'].iloc[-1]] }) plot_df = pd.concat([null_row, group_sorted[['Datum', 'betrag_cumsum']], end_row]) plot_df = plot_df.sort_values('Datum') dates = plot_df['Datum'] values = plot_df['betrag_cumsum'] axs[1, 0].plot(dates, values, label=name, linewidth=1) axs[1, 0].fill_between(dates, values, alpha=0.15) axs[1, 0].set_ylabel("€", fontproperties=mono_font) axs[1, 0].set_xlabel("Tag des Monats", fontproperties=mono_font) alle_daten = pd.date_range(start=monatserster, end=monatsletzter) axs[1, 0].set_xticks(alle_daten) axs[1, 0].set_xticklabels([d.day for d in alle_daten], rotation=0, fontproperties=mono_font) axs[1, 0].legend(prop=mono_font) axs[2, 0].axis('off') # Grundlagen berechnen personen = list(ausgelegt.keys()) gesamt_km = km_info['km'].sum() gesamt_tank = df[df['Position'].str.contains("Tanken", case=False)]['Betrag'].sum() gesamt_sonstige = df[~df['Position'].str.contains("Tanken|Autofahrt", case=False)]['Betrag'].sum() sonstige_pro_person = gesamt_sonstige / len(personen) km_preis = gesamt_tank / gesamt_km if gesamt_km else 0 # Gruppierungen km_pro_person = km_info.groupby('Name')['km'].sum().to_dict() tank_auslagen = df[df['Position'].str.contains("Tanken", case=False)].groupby('Name')['Betrag'].sum().to_dict() sonstige_auslagen = df[~df['Position'].str.contains("Tanken|Autofahrt", case=False)].groupby('Name')['Betrag'].sum().to_dict() # KFZ-Ausgabe kfztxt = ["KFZ:"] for p in personen: km = km_pro_person.get(p, 0) anteil_km = (km / gesamt_km * 100) if gesamt_km > 0 else 0 ausgelegt_tank = tank_auslagen.get(p, 0) anteil_tank = km * km_preis kfztxt.append( f"{p}: {km:.2f} km; {anteil_km:.1f} %; {ausgelegt_tank:.2f} €; {anteil_tank:.2f} €" ) kfztxt.append(f"Gesamt: {gesamt_km:.2f} km; {gesamt_tank:.2f} €; {km_preis:.4f} € / km") # Sonstige-Ausgabe sonstigetxt = ["\nSonstige Auslagen:"] for p in personen: ausgelegt_sonstige = sonstige_auslagen.get(p, 0) sonstigetxt.append( f"{p}: {ausgelegt_sonstige:.2f} €; {sonstige_pro_person:.2f} €" ) sonstigetxt.append( f"Gesamt: {gesamt_sonstige:.2f} €; {sonstige_pro_person:.2f} € p.P." ) # Zielbeträge gesamttxt = ["\nBeträge ohne Ausgleich:"] for p in personen: km = km_pro_person.get(p, 0) anteil_km = (km / gesamt_km * 100) if gesamt_km > 0 else 0 ausgelegt_tank = tank_auslagen.get(p, 0) anteil_tank = km * km_preis zielbetrag = ausgelegt_tank - anteil_tank + sonstige_auslagen.get(p, 0) gesamttxt.append(f"{p}: {zielbetrag:.2f} €") # Zusammensetzen und anzeigen rechenweg = "\n".join(kfztxt + sonstigetxt + gesamttxt) axs[2, 0].text(0, 1, rechenweg, verticalalignment='top', fontproperties=mono_font) axs[2, 1].axis('off') zahlungstext = "\n".join([f"{payer} → {receiver}: {betrag:.2f} €" for payer, receiver, betrag in zahlungen]) axs[2, 1].text(0, 1, "Ausgleich:\n" + zahlungstext, verticalalignment='top', fontproperties=mono_font) for ax in axs.flat: for label in ax.get_xticklabels() + ax.get_yticklabels(): label.set_fontproperties(mono_font) pdf.savefig() plt.close() def main(): parser = argparse.ArgumentParser() parser.add_argument("monatspfad", help="Pfad zum Ordner im Format YYYY-MM") parser.add_argument("zielpfad", help="Zielverzeichnis für .tar.zst Archiv") args = parser.parse_args() monat = os.path.basename(args.monatspfad) csv_path = os.path.join(args.monatspfad, f"{monat}.csv") pdf_path = os.path.join(args.monatspfad, f"{monat}.pdf") df = parse_csv(csv_path) if df['Datum'].isna().any(): fehlerhafte_zeilen = df[df['Datum'].isna()] raise ValueError(f"Ungültige Datumsangaben in folgenden Zeilen:\n{fehlerhafte_zeilen}") saldo, ausgelegt, anteil, km_info, km_preis = compute_settlements(df) zahlungen = minimize_payments(saldo) create_pdf(df, saldo, ausgelegt, anteil, zahlungen, km_info, km_preis, pdf_path, base_dir=args.monatspfad) # Archivieren archivpfad = os.path.join(args.zielpfad, f"{monat}.tar") with tarfile.open(archivpfad, "w") as tar: tar.add(args.monatspfad, arcname=monat) subprocess.run(["zstd", "--rm", archivpfad], check=True) if __name__ == '__main__': main()