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