diff options
| author | Leonard Kugis <leonard@kug.is> | 2025-12-22 22:53:48 +0100 |
|---|---|---|
| committer | Leonard Kugis <leonard@kug.is> | 2025-12-22 22:53:48 +0100 |
| commit | 3a09c707ba2ba50ca3d3941ebe9ae9ea7c3f04bf (patch) | |
| tree | 51c45fe7deab17482adc2d8d3c253e392ff6a825 /abrechnung.py | |
| download | xembu-3a09c707ba2ba50ca3d3941ebe9ae9ea7c3f04bf.tar.gz | |
Initial commit
Diffstat (limited to 'abrechnung.py')
| -rw-r--r-- | abrechnung.py | 321 |
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() |
