aboutsummaryrefslogtreecommitdiffstats
path: root/abrechnung.py
diff options
context:
space:
mode:
Diffstat (limited to 'abrechnung.py')
-rw-r--r--abrechnung.py321
1 files changed, 321 insertions, 0 deletions
diff --git a/abrechnung.py b/abrechnung.py
new file mode 100644
index 0000000..985863c
--- /dev/null
+++ b/abrechnung.py
@@ -0,0 +1,321 @@
+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()