From a818ef0a7c3c7ff0a058a5b06cfd5de5cf4b14e1 Mon Sep 17 00:00:00 2001 From: Holger Frey Date: Wed, 23 Apr 2025 11:33:54 +0200 Subject: [PATCH] moved business travel functions to own module --- pyproject.toml | 3 +- work_helpers/fill_forms.py | 204 +----------------------- work_helpers/travels.py | 317 +++++++++++++++++++++++++++++++++++++ 3 files changed, 320 insertions(+), 204 deletions(-) create mode 100644 work_helpers/travels.py diff --git a/pyproject.toml b/pyproject.toml index 82507a1..7399c38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,8 +24,7 @@ requires = [ [tool.flit.scripts] form_inspect = "work_helpers.fill_forms:inspect" -form_prepare_payments = "work_helpers.fill_forms:prepare_payments" -form_fill_payments = "work_helpers.fill_forms:payments" +travel_final_payment = "work_helpers.travels:final_payment" nice_path = "work_helpers.nice_path:make_nice_path" random_password = "work_helpers.password:get_random_password" random_ints = "work_helpers.random_int:generate_random_number_list" diff --git a/work_helpers/fill_forms.py b/work_helpers/fill_forms.py index 8c75d3a..002cb2a 100644 --- a/work_helpers/fill_forms.py +++ b/work_helpers/fill_forms.py @@ -1,81 +1,14 @@ -import click -from datetime import datetime import pathlib -import sys -import shutil -import pandas as pd import warnings -import re -from PyPDFForm import FormWrapper, PdfWrapper +import click +from PyPDFForm import PdfWrapper warnings.filterwarnings("ignore") -Pathlike = pathlib.Path | str - WINHOME = pathlib.Path("/mnt/c/Users/Holgi/") DESKTOP = WINHOME / "Desktop" -TODAY = datetime.now().strftime("%Y-%m-%d") - - -def _iso_date_to_german(date: str) -> str: - return ".".join(reversed(date.split("-"))) - - -def _search_files( - folder: Pathlike, partial_name: str, suffix: str -) -> list[pathlib.Path]: - parent = pathlib.Path(folder) - if not suffix.startswith("."): - suffix = f".{suffix}" - files = (item for item in parent.iterdir() if item.is_file()) - non_hidden = (item for item in files if not item.stem.startswith(".")) - non_tempfile = (item for item in files if not item.stem.startswith("~")) - types = ( - item for item in non_tempfile if item.suffix.lower() == suffix.lower() - ) - return [item for item in types if partial_name.lower() in item.stem.lower()] - - -def _get_latest_file( - folder: Pathlike, partial_name: str, suffix: str -) -> pathlib.Path | None: - results = _search_files(folder, partial_name, suffix) - if not results: - return None - creation_times = [item.stat().st_ctime for item in results] - by_creation_time = sorted(zip(creation_times, results)) - newest_with_time = by_creation_time[-1] # latest entry - return newest_with_time[1] # the path entry of the tuple - - -def _get_form_path(partial_name: str) -> pathlib.Path: - own_parent = pathlib.Path(__file__).parent - matches = _search_files(own_parent, partial_name, ".pdf") - if len(matches) == 1: - return matches[0] - counts = len(matches) - msg = f"Found {counts} matching pdf forms for '{partial_name}'" - raise IOError(msg) - - -def _get_unique(data: pd.DataFrame, column: str) -> str | int | float: - uniques = data[column].unique() - if len(uniques) != 1: - msg = f"Found multiple unique values for '{column}'" - raise ValueError(msg) - return uniques[0] - - -def _extract_travel_number(data: pd.DataFrame, column: str) -> str: - belege = data[column] - travel_nr = belege.apply( - lambda x: re.search("(5\d{7,8})", x.replace(" ", "")) - ) - match_result = travel_nr[travel_nr.first_valid_index()] - return match_result[0] - @click.command() @click.argument( @@ -95,136 +28,3 @@ def inspect(form, output): preview_stream = PdfWrapper(str(form)).preview with output.open("wb+") as output_stream: output_stream.write(preview_stream) - - -def payments(): - downloads = WINHOME / "Downloads" - forms = DESKTOP / "Formulare" - - latest_export = _get_latest_file(downloads, "Buchungen_SAP", ".xlsx") - if not latest_export: - sys.exit("Could not find an SuperX export file, aborting.") - - fields = ["BelegNr", "VorgängerBelegNr", "Kostenstelle", "Fonds", "Projekt"] - converters = {field: str for field in fields} - data = pd.read_excel(latest_export, skiprows=3, converters=converters) - - travel_nr = _extract_travel_number(data, "BelegNr") - - mask = data["Werttyp"] == "Zahlung" - payments = data[mask].copy() - - summary = ( - payments.groupby("BelegNr") - .agg({"Betrag": "sum", "BuDat": "first"}) - .reset_index() - .sort_values("BelegNr") - ) - - try: - cost_center = _get_unique(payments, "Kostenstelle") - fonds = _get_unique(payments, "Fonds") - project = _get_unique(payments, "Projekt") - if not project or len(project) <= 4: - project = "" - except ValueError as e: - sys.exit(str(e)) - - form_data = { - "Projekt": project, - "Kostenstelle": cost_center, - "Mittelbindung": travel_nr, - "Fonds": fonds, - } - print(f"Projekt: {project}") - print(f"Kostenstelle: {cost_center}") - print(f"Mittelbindung: {travel_nr}") - print(f"Fonds: {fonds}") - - print("") - print(f" Datum Betrag SuperX") - print("") - - for i, row in summary.iterrows(): - index = i + 1 - form_data.update( - { - f"Datum{index}": row["BuDat"], - f"BelegNr aus SuberX{index}": row["BelegNr"], - # no field "Euro{index}" - # the automatic form calculation would not work. - } - ) - betrag = f"{row['Betrag']:0.2f}".replace(".", ",") - print(f" {row['BuDat']} {betrag:>7} {row['BelegNr']}") - - source_path = forms / "Vorlage UK-Abschlag, v2023-01.pdf" - destination_path = DESKTOP / f"{TODAY} UK-Abschlag.pdf" - form = FormWrapper(str(source_path)) - filled = form.fill(form_data, flatten=False) - destination_path.write_bytes(filled.read()) - - -@click.command() -@click.option("-l", "--search_last_name", prompt=True, required=True) -@click.option("-d", "--iso_date", prompt=True, required=True) -@click.option("-p", "--place", prompt=True, required=True) -def prepare_payments(search_last_name: str, iso_date: str, place: str): - forms_path = DESKTOP / "Formulare" - templates_path = forms_path / "vorbereitet UK-As" - - templates = _search_files(templates_path, f" {search_last_name}", ".pdf") - if len(templates) == 0: - sys.exit( - f"Could not find a UK-A template for search '{search_last_name}'" - ) - if len(templates) > 1: - sys.exit( - f"Found multiple UK-A templates for search '{search_last_name}'" - ) - template = templates[0] - - rest, first_name = template.stem.rsplit(",", maxsplit=1) - *_, last_name = rest.split() - first_name = first_name.strip() - last_name = last_name.strip() - - first_last = f"{first_name} {last_name}" - last_first = f"{last_name}, {first_name}" - travel_short = f"{last_first}, {place}" - travel_name = f"{iso_date} {travel_short}" - date = _iso_date_to_german(iso_date) - - folder = DESKTOP / f"{travel_name} (abgerechnet)" - folder.mkdir() - - rk_path = folder / f" {travel_short}, Reisekostenabrechnung.txt" - rk_path.write_text(rk_path.stem) - - uka_hint_path = ( - folder / f" UK-A {last_first}, Dienstreise {place}, Schlusszahlung.txt" - ) - content = "\t".join( - [ - _iso_date_to_german(TODAY), - first_last, - f"Schlusszahlung Dienstreise {place}, {date}", - ] - ) - uka_hint_path.write_text(content) - - source_path = forms_path / "Vorlage UK-Abschlag, v2023-01.pdf" - destination_path = folder / f"{TODAY} {travel_short}, UK-Abschlag.pdf" - shutil.copy(source_path, destination_path) - - form = FormWrapper(str(template)) - form_data = { - "Verwendungszweck": f"Schlusszahlung Dienstreise nach {place}", - "Begründung": f"Schlusszahlung Dienstreise {first_last} nach {place} am {date}", - "Datum_Feststellung": _iso_date_to_german(TODAY), - "Datum_Anordnung": _iso_date_to_german(TODAY), - } - filled = form.fill(form_data, flatten=False) - - uka_path = folder / f"{TODAY} {travel_short}, UK-A Schlusszahlung.pdf" - uka_path.write_bytes(filled.read()) diff --git a/work_helpers/travels.py b/work_helpers/travels.py new file mode 100644 index 0000000..fdecb93 --- /dev/null +++ b/work_helpers/travels.py @@ -0,0 +1,317 @@ +import pathlib +import re +import sys +import warnings +from dataclasses import dataclass +from datetime import datetime +from typing import Iterable + +import click +import pandas as pd +from PyPDFForm import FormWrapper + +warnings.filterwarnings("ignore") + +Pathlike = pathlib.Path | str + +WINHOME = pathlib.Path("/mnt/c/Users/Holgi/") +DESKTOP = WINHOME / "Desktop" + +TODAY = datetime.now().strftime("%Y-%m-%d") + + +@dataclass +class PrePaymentEntry: + date: str + amount: float + superx_id: str + + @classmethod + def from_series(cls, series: pd.Series) -> "PrePamentEntry": + return cls( + date=series["BuDat"], + amount=series["Betrag"], + superx_id=series["BelegNr"], + ) + + @property + def amount_localized(self, *, decimal=",", thousands=".") -> str: + non_local = f"{self.amount:_.2f}" + return non_local.replace(".", decimal).replace("_", thousands) + + +@dataclass +class PrePayments: + travel_nr: str + cost_center: str + fonds: str + project: str + payments: list[PrePaymentEntry] + + def __iter__(self) -> Iterable[PrePaymentEntry]: + return iter(self.payments) + + def to_form_data(self) -> dict[str, str]: + form_data = { + "Projekt": self.project, + "Kostenstelle": self.cost_center, + "Mittelbindung": self.travel_nr, + "Fonds": self.fonds, + } + for index, entry in enumerate(self, start=1): + form_data.update( + { + f"Datum{index}": entry.date, + f"BelegNr aus SuberX{index}": entry.superx_id, + # no field "Euro{index}" + # the automatic form calculation would not work. + } + ) + return form_data + + +@dataclass +class TravelInfo: + first_name: str + last_name: str + iso_date: str + place: str + uka_form: pathlib.Path + + @property + def first_last(self) -> str: + return f"{self.first_name} {self.last_name}" + + @property + def last_first(self) -> str: + return f"{self.last_name}, {self.first_name}" + + @property + def travel_short(self) -> str: + return f"{self.last_first}, {self.place}" + + @property + def travel_name(self) -> str: + return f"{self.iso_date} {self.travel_short}" + + @property + def ger_date(self) -> str: + return _iso_date_to_german(self.iso_date) + + +def _iso_date_to_german(date: str) -> str: + return ".".join(reversed(date.split("-"))) + + +def _search_files( + folder: Pathlike, partial_name: str, suffix: str +) -> list[pathlib.Path]: + parent = pathlib.Path(folder) + if not suffix.startswith("."): + suffix = f".{suffix}" + files = (item for item in parent.iterdir() if item.is_file()) + non_hidden = (item for item in files if not item.stem.startswith(".")) + non_tempfile = (item for item in files if not item.stem.startswith("~")) + types = ( + item for item in non_tempfile if item.suffix.lower() == suffix.lower() + ) + return [item for item in types if partial_name.lower() in item.stem.lower()] + + +def _get_latest_file( + folder: Pathlike, partial_name: str, suffix: str +) -> pathlib.Path | None: + results = _search_files(folder, partial_name, suffix) + if not results: + return None + creation_times = [item.stat().st_ctime for item in results] + by_creation_time = sorted(zip(creation_times, results)) + newest_with_time = by_creation_time[-1] # latest entry + return newest_with_time[1] # the path entry of the tuple + + +def _get_form_path(partial_name: str) -> pathlib.Path: + own_parent = pathlib.Path(__file__).parent + matches = _search_files(own_parent, partial_name, ".pdf") + if len(matches) == 1: + return matches[0] + counts = len(matches) + msg = f"Found {counts} matching pdf forms for '{partial_name}'" + raise OSError(msg) + + +def _get_unique(data: pd.DataFrame, column: str) -> str | int | float: + uniques = data[column].unique() + if len(uniques) != 1: + msg = f"Found multiple unique values for '{column}'" + raise ValueError(msg) + return uniques[0] + + +def _extract_travel_number(data: pd.DataFrame, column: str) -> str: + belege = data[column] + travel_nr = belege.apply( + lambda x: re.search(r"(5\d{9,10})", x.replace(" ", "")) + ) + match_result = travel_nr[travel_nr.first_valid_index()] + return match_result[0] + + +def _read_pre_payments(file_path: Pathlike = None) -> PrePayments: + if not file_path: + downloads = WINHOME / "Downloads" + + file_path = _get_latest_file(downloads, "Buchungen_SAP", ".xlsx") + if not file_path: + sys.exit("Could not find an SuperX export file, aborting.") + + fields = ["BelegNr", "VorgängerBelegNr", "Kostenstelle", "Fonds", "Projekt"] + converters = {field: str for field in fields} + raw_data = pd.read_excel(file_path, skiprows=3, converters=converters) + + travel_nr = _extract_travel_number(raw_data, "BelegNr") + try: + cost_center = _get_unique(raw_data, "Kostenstelle") + fonds = _get_unique(raw_data, "Fonds") + project = _get_unique(raw_data, "Projekt") + if not project or len(project) <= 5: + project = "" + except ValueError as e: + sys.exit(str(e)) + + mask = raw_data["Werttyp"] == "Zahlung" + raw_payments = raw_data[mask].copy() + + summary = ( + raw_payments.groupby("BelegNr") + .agg({"Betrag": "sum", "BuDat": "first"}) + .reset_index() + .sort_values("BelegNr") + ) + + paymments = [ + PrePaymentEntry.from_series(row) for i, row in summary.iterrows() + ] + + return PrePayments( + travel_nr=travel_nr, + cost_center=cost_center, + fonds=fonds, + project=project, + payments=paymments, + ) + + +def _complete_travel_info( + search_last_name: str, iso_date: str, place: str +) -> TravelInfo: + forms_path = DESKTOP / "Formulare" + templates_path = forms_path / "vorbereitete UK-As" + + templates = _search_files(templates_path, f" {search_last_name}", ".pdf") + if len(templates) == 0: + sys.exit( + f"Could not find a UK-A template for search '{search_last_name}'" + ) + if len(templates) > 1: + sys.exit( + f"Found multiple UK-A templates for search '{search_last_name}'" + ) + uka_form = templates[0] + + rest, first_name = uka_form.stem.rsplit(",", maxsplit=1) + *_, last_name = rest.split() + first_name = first_name.strip() + last_name = last_name.strip() + + return TravelInfo( + first_name=first_name, + last_name=last_name, + iso_date=iso_date, + place=place, + uka_form=uka_form, + ) + + +def _create_text_stub(path: Pathlike, *, content: str = None) -> None: + path = pathlib.Path(path) + content = content or path.stem + path.write_text(content) + + +def fill_pre_payments_form(destination_path: Pathlike) -> PrePayments: + pre_payments = _read_pre_payments() + + print(f"Projekt: {pre_payments.project}") + print(f"Kostenstelle: {pre_payments.cost_center}") + print(f"Mittelbindung: {pre_payments.travel_nr}") + print(f"Fonds: {pre_payments.fonds}") + + print() + print(" Datum Betrag SuperX") + print() + + for row in pre_payments.payments: + print(f" {row.date} {row.amount_localized:>7} {row.superx_id}") + + forms = DESKTOP / "Formulare" + source_path = forms / "Vorlage UK-Abschlag, v2023-01.pdf" + form = FormWrapper(str(source_path)) + filled = form.fill(pre_payments.to_form_data(), flatten=False) + pathlib.Path(destination_path).write_bytes(filled.read()) + + return pre_payments + + +@click.command() +@click.option("-l", "--search_last_name", prompt=True, required=True) +@click.option("-d", "--iso_date", prompt=True, required=True) +@click.option("-p", "--place", prompt=True, required=True) +def final_payment(search_last_name: str, iso_date: str, place: str): + info = _complete_travel_info(search_last_name, iso_date, place) + + folder = DESKTOP / f"{info.travel_name} (abgerechnet)" + folder.mkdir() + + destination_path = folder / f"{TODAY} {info.travel_short}, UK-Abschlag.pdf" + payments = fill_pre_payments_form(destination_path) + + rk_path = folder / f"DATE {info.travel_short}, Reisekostenabrechnung.txt" + _create_text_stub(rk_path) + + uka_hint_path = ( + folder + / f"HUEL UK-A {info.last_first}, Dienstreise {info.place}, Schlusszahlung.txt" + ) + content = "\t".join( + [ + info.ger_date.replace(".", "/"), + info.first_last, + f"Schlusszahlung Dienstreise {info.place}, {info.ger_date}", + ] + ) + _create_text_stub(uka_hint_path, content=content) + + sz_path = ( + folder + / f"DATE {info.travel_short}, Schlusszahlung an Unikasse, KONTO, HUELNR.txt" + ) + _create_text_stub(sz_path, content="Schlusszahlung an Unikasse geschickt.") + + form = FormWrapper(str(info.uka_form)) + form_data = { + "Verwendungszweck": f"Schlusszahlung Dienstreise nach {info.place}", + "Begründung": f"Schlusszahlung Dienstreise {info.first_last} nach {info.place} am {info.ger_date}", + "Datum_Feststellung": _iso_date_to_german(TODAY), + "Datum_Anordnung": _iso_date_to_german(TODAY), + "KostenstelleKontierung": payments.cost_center, + "FondsKontierung": payments.fonds, + "ProjektKontierung": payments.project, + "Bezug zur Mittelbindung": payments.travel_nr, + "Schlusszahlung": True, + "UK-Abschlag": True, + } + filled = form.fill(form_data, flatten=False) + + uka_path = folder / f"{TODAY} {info.travel_short}, UK-A Schlusszahlung.pdf" + uka_path.write_bytes(filled.read())