From 6d0e7fee05c5c712f654e598683dd146f8b35dc4 Mon Sep 17 00:00:00 2001 From: mariusrklein <47218379+mariusrklein@users.noreply.github.com> Date: Thu, 27 Mar 2025 23:38:09 +0100 Subject: [PATCH] feat(finance): create finance statement and excursion summary --- jdav_web/finance/admin.py | 25 +++ .../finance/locale/de/LC_MESSAGES/django.po | 6 +- jdav_web/finance/models.py | 8 + .../templates/finance/statement_summary.tex | 186 ++++++++++++++++++ jdav_web/locale/de/LC_MESSAGES/django.po | 6 +- jdav_web/members/pdf.py | 56 ++++-- .../change_form_object_tools.html | 5 +- 7 files changed, 272 insertions(+), 20 deletions(-) create mode 100644 jdav_web/finance/templates/finance/statement_summary.tex diff --git a/jdav_web/finance/admin.py b/jdav_web/finance/admin.py index 7ba30cb..95f8c95 100644 --- a/jdav_web/finance/admin.py +++ b/jdav_web/finance/admin.py @@ -14,6 +14,7 @@ from contrib.admin import CommonAdminInlineMixin, CommonAdminMixin from utils import get_member, RestrictedFileField from rules.contrib.admin import ObjectPermissionsModelAdmin +from members.pdf import render_tex_with_attachments from .models import Ledger, Statement, Receipt, Transaction, Bill, StatementSubmitted, StatementConfirmed,\ StatementUnSubmitted, BillOnStatementProxy @@ -212,6 +213,8 @@ class StatementSubmittedAdmin(admin.ModelAdmin): messages.success(request, _("Successfully confirmed %(name)s. I hope you executed the associated transactions, I wont remind you again.") % {'name': str(statement)}) + messages.success(request, + mark_safe(f"Hier kannst du den Abrechnungsbeleg herunterladen.")) #TODO: nice path resolution return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % (self.opts.app_label, self.opts.model_name))) if "confirm" in request.POST: res = statement.validity @@ -316,6 +319,11 @@ class StatementConfirmedAdmin(admin.ModelAdmin): wrap(self.unconfirm_view), name="%s_%s_unconfirm" % (self.opts.app_label, self.opts.model_name), ), + path( + "/summary/", + wrap(self.statement_summary_view), + name="%s_%s_summary" % (self.opts.app_label, self.opts.model_name), + ), ] return custom_urls + urls @@ -342,6 +350,23 @@ class StatementConfirmedAdmin(admin.ModelAdmin): statement=statement) return render(request, 'admin/unconfirm_statement.html', context=context) + + def statement_summary_view(self, request, object_id): + statement = StatementConfirmed.objects.get(pk=object_id) + + if not statement.confirmed: + messages.error(request, + _("%(name)s is not yet confirmed.") % {'name': str(statement)}) + return HttpResponseRedirect(reverse('admin:%s_%s_change' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,))) + excursion = statement.excursion + context = dict(statement=statement.template_context(), excursion=excursion, settings=settings) + + pdf_filename = f"{excursion.code}_{excursion.name}_Zuschussbeleg" if excursion else f"Abrechnungsbeleg" + attachments = [bill.proof.path for bill in statement.bills_covered] + return render_tex_with_attachments(pdf_filename, 'finance/statement_summary.tex', context, attachments) + + statement_summary_view.short_description = _('Download summary') + @admin.register(Transaction) class TransactionAdmin(admin.ModelAdmin): diff --git a/jdav_web/finance/locale/de/LC_MESSAGES/django.po b/jdav_web/finance/locale/de/LC_MESSAGES/django.po index b4ba049..13199ba 100644 --- a/jdav_web/finance/locale/de/LC_MESSAGES/django.po +++ b/jdav_web/finance/locale/de/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-01 21:11+0100\n" +"POT-Creation-Date: 2025-03-27 23:35+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -152,6 +152,10 @@ msgstr "" msgid "Unconfirm statement" msgstr "Bestätigung zurücknehmen" +#: finance/admin.py +msgid "Download summary" +msgstr "Beleg herunterladen" + #: finance/apps.py msgid "Finance" msgstr "Finanzen" diff --git a/jdav_web/finance/models.py b/jdav_web/finance/models.py index 72e795f..b4a770c 100644 --- a/jdav_web/finance/models.py +++ b/jdav_web/finance/models.py @@ -271,6 +271,11 @@ class Statement(CommonModel): def total_bills(self): return sum([bill.amount for bill in self.bill_set.all() if bill.costs_covered]) + @property + def bills_covered(self): + """Returns the bills that are marked for reimbursement by the finance officer""" + return [bill for bill in self.bill_set.all() if bill.costs_covered] + @property def total_bills_theoretic(self): return sum([bill.amount for bill in self.bill_set.all()]) @@ -403,6 +408,7 @@ class Statement(CommonModel): context = { 'total_bills': self.total_bills, 'total_bills_theoretic': self.total_bills_theoretic, + 'bills_covered': self.bills_covered, 'total': self.total, } if self.excursion: @@ -424,6 +430,8 @@ class Statement(CommonModel): 'theoretical_total_staff': self.theoretical_total_staff, 'real_staff_count': self.real_staff_count, 'total_subsidies': self.total_subsidies, + 'subsidy_to': self.subsidy_to, + 'allowance_to': self.allowance_to, } return dict(context, **excursion_context) else: diff --git a/jdav_web/finance/templates/finance/statement_summary.tex b/jdav_web/finance/templates/finance/statement_summary.tex new file mode 100644 index 0000000..3d37b32 --- /dev/null +++ b/jdav_web/finance/templates/finance/statement_summary.tex @@ -0,0 +1,186 @@ +{% load static common tex_extras %} + +\documentclass[a4paper]{article} + +\usepackage[utf8]{inputenc} +% remove all undefined unicode characters instead of throwing an error +\makeatletter +\def\UTFviii@undefined@err#1{} +\makeatother +\usepackage{booktabs} +\usepackage{amssymb} +\usepackage{cmbright} +\usepackage{graphicx} +\usepackage{textpos} +\usepackage[colorlinks, breaklinks]{hyperref} +\usepackage{float} +\usepackage[margin=2cm]{geometry} +\usepackage{array} +\usepackage{tabularx} +\usepackage{ltablex} + +\newcommand{\picpos}[4]{ + \begin{textblock*}{#1}(#2, #3) + \includegraphics[width=\textwidth]{#4} + \end{textblock*} +} + +% custom url command for properly formatting emails +\DeclareUrlCommand\Email{\urlstyle{same}} +% allow linebreak after every character +\expandafter\def\expandafter\UrlBreaks\expandafter{\UrlBreaks +\do\/\do\a\do\b\do\c\do\d\do\e\do\f\do\g\do\h\do\i\do\j\do\k +\do\l\do\m\do\n\do\o\do\p\do\q\do\r\do\s\do\t\do\u\do\v +\do\w\do\x\do\y\do\z +\do\A\do\B\do\C\do\D\do\E\do\F\do\G\do\H\do\I\do\J\do\K +\do\L\do\M\do\N\do\O\do\P\do\Q\do\R\do\S\do\T\do\U\do\V +\do\W\do\X\do\Y\do\Z} + +\renewcommand{\arraystretch}{1.5} + +\newcolumntype{L}{>{\hspace{0pt}\raggedright\arraybackslash}X} +\newcolumntype{S}{>{\raggedright\arraybackslash\hsize=0.7\hsize}X} + +\newcommand{\tickedbox}{ + \makebox[0pt][l]{$\square$}\raisebox{.15ex}{\hspace{0.1em}$\checkmark$} +} +\newcommand{\checkbox}{ + \makebox[0pt][l]{$\square$} +} +\begin{document} +% HEADER RIGHT +{% settings_value 'DEFAULT_STATIC_PATH' as static_root %} +\picpos{4.5cm}{11.5cm}{0cm}{% +{{ static_root }}/general/img/dav_logo_sektion.png% +} +\begin{textblock*}{5cm}(12cm, 2.3cm) + \begin{flushright} + \small + \noindent Deutscher Alpenverein e. V. \\ + Sektion {{ settings.SEKTION }} \\ + {{ settings.SEKTION_STREET }} \\ + {{ settings.SEKTION_TOWN }} \\ + Tel.: {{ settings.SEKTION_TELEPHONE }} \\ + Fax: {{ settings.SEKTION_TELEFAX }} \\ + {{ settings.SEKTION_CONTACT_MAIL }} \\ + \end{flushright} +\end{textblock*} + +% HEADLINE +{\noindent\LARGE{Abrechnungs- und Zuschussbeleg\\[2mm]Sektionsveranstaltung}}\\[1mm] +\textit{Erstellt: {{ creation_date }} }\\ + +{% if excursion %} +\noindent\textbf{\large Ausfahrt} + % DESCRIPTION TABLE + \begin{table}[H] + \begin{tabular}{ll} + Aktivität: & {{ excursion.name|esc_all }} \\ + Ordnungsnummer & {{ excursion.code|esc_all }} \\ + Ort / Stützpunkt: & {{ excursion.place|esc_all }} \\ + Zeitraum: & {{ excursion.duration|esc_all}} Tage ({{ excursion.time_period_str|esc_all }}) \\ + Teilnehmer*innen: & {{ excursion.participant_count }} der Gruppe(n) {{ excursion.groups_str|esc_all }} \\ + Betreuer*innen: & {{excursion.staff_count|esc_all }} ({{ excursion.staff_str|esc_all }}) \\ + Art der Tour: & {% checked_if_true 'Gemeinschaftstour' excursion.get_tour_type %} + {% checked_if_true 'Führungstour' excursion.get_tour_type %} + {% checked_if_true 'Ausbildung' excursion.get_tour_type %} \\ + Anreise: & {% checked_if_true 'ÖPNV' excursion.get_tour_approach %} + {% checked_if_true 'Muskelkraft' excursion.get_tour_approach %} + {% checked_if_true 'Fahrgemeinschaften' excursion.get_tour_approach %} + \end{tabular} + \end{table} + +\noindent\textbf{\large Zuschüsse und Aufwandsentschädigung} +{% if excursion.approved_staff_count > 0 %} + \noindent Gemäß Beschluss des Jugendausschusses gelten folgende Sätze für Zuschüsse pro genehmigter Jugendleiter*in: + + \begin{table}[H] + \centering + \begin{tabular}{lllr} + \toprule + \textbf{Posten} & \textbf{Einzelsatz} & \textbf{Anzahl} & \textbf{Gesamtbetrag pro JL} \\ + \midrule + Zuschuss Übernachtung & {{ statement.price_per_night }} € / Nacht & {{ statement.nights }} Nächte & {{ statement.nights_per_yl }} € \\ + Zuschuss Anreise & {{statement.euro_per_km}} € / km ({{ statement.means_of_transport }}) & {{ statement.kilometers_traveled }} km & {{ statement.transportation_per_yl }} € \\ + Aufwandsentschädigung & {{ statement.allowance_per_day }},00 € / Tag & {{ statement.duration }} Tage & {{ statement.allowance_per_yl }} € \\ + \midrule + \textbf{Summe}& & & \textbf{ {{ statement.total_per_yl }} }€\\ + \bottomrule + \end{tabular} + \end{table} + +\noindent Gemäß JDAV-Betreuungsschlüssel können bei {{ excursion.participant_count }} Teilnehmer*innen +bis zu {{ excursion.approved_staff_count }} Jugendleiter*innen {% if excursion.approved_extra_youth_leader_count %} +(davon {{ excursion.approved_extra_youth_leader_count }} durch das Jugendreferat zusätzlich genehmigt){% endif %} bezuschusst werden. +Zuschüsse und Aufwandsentschädigung werden wie folgt abgerufen: +\begin{itemize} + + {% if statement.allowances_paid > 0 %} + + \item Eine Aufwandsentschädigung von {{ statement.allowance_per_yl }} € pro Jugendleiter*in wird überwiesen an: + {% for m in statement.allowance_to.all %}{% if forloop.counter > 1 %}, {% endif %}{{ m.name }}{% endfor %} + {% else %} + \item Keiner*r der Jugendleiter*innen nimmt eine Aufwandsentschädigung in Anspruch. + {% endif %} + + {% if statement.subsidy_to %} + \item Der Zuschuss zu Übernachtung und Anreise für alle Jugendleiter*innen in Höhe von {{ statement.total_subsidies }} € wird überwiesen an: + {{ statement.subsidy_to.name }} + + {% else %} + \item Zuschüsse zu Übernachtung und Anreise werden nicht in Anspruch genommen. + {% endif %} + +\end{itemize} +{% else %} +\noindent Für die vorliegende Ausfahrt sind keine Jugendleiter*innen anspruchsberechtigt für Zuschüsse oder Aufwandsentschädigung. +{% endif %} + +{% else %} +\vspace{110pt} +{% endif %} + + +\vspace{12pt} + +\noindent\textbf{\large Ausgabenübersicht} +\nopagebreak +\begin{table}[H] + \centering + \begin{tabular}{lllr} + \toprule + \textbf{Titel} & \textbf{Beschreibung} & \textbf{Auszahlung an} & \textbf{Betrag} \\ + \midrule + +{% if statement.bills_covered %} + {% for bill in statement.bills_covered %} + {{ forloop.counter }}. {{ bill.short_description}} & {{ bill.explanation}} & {{ bill.paid_by.name }} & {{ bill.amount }} € \\ + {% endfor %} + \midrule + \multicolumn{3}{l}{\textbf{Summe übernommene Ausgaben}} & \textbf{ {{ statement.total_bills }} }€\\ +{% endif %} +{% if excursion.approved_staff_count > 0 and statement.allowances_paid > 0 or excursion.approved_staff_count > 0 and statement.subsidy_to %} + \midrule + {% if statement.allowances_paid > 0 %} + {% for m in statement.allowance_to.all %} + Aufwandsentschädigung & & {{ m.name }} & {{ statement.allowance_per_yl }} €\\ + {% endfor %} + {% endif %} + {% if statement.subsidy_to %} + \multicolumn{2}{l}{Zuschuss Übernachtung und Anreise für alle Jugendleiter*innen} & {{ statement.subsidy_to.name }} & {{ statement.total_subsidies}} €\\ + {% endif %} + \midrule + \multicolumn{3}{l}{\textbf{Summe Zuschüsse und Aufwandsentschädigung}} & \textbf{ {{ statement.total_staff }} }€\\ +{%endif %} +{% if statement.bills_covered and excursion.approved_staff_count > 0 %} + \midrule + \textbf{Gesamtsumme}& & & \textbf{ {{ statement.total }} }€\\ +{% endif %} + \bottomrule + \end{tabular} +\end{table} + +\noindent Dieser Beleg wird automatisch erstellt und daher nicht unterschrieben. + + +\end{document} diff --git a/jdav_web/locale/de/LC_MESSAGES/django.po b/jdav_web/locale/de/LC_MESSAGES/django.po index cafea14..e32540b 100644 --- a/jdav_web/locale/de/LC_MESSAGES/django.po +++ b/jdav_web/locale/de/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-01 14:54+0100\n" +"POT-Creation-Date: 2025-03-27 23:35+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -236,6 +236,10 @@ msgstr "Löschen?" msgid "Unconfirm" msgstr "Bestätigung zurücknehmen" +#: templates/admin/finance/statementconfirmed/change_form_object_tools.html +msgid "Download summary" +msgstr "Beleg herunterladen" + #: templates/admin/finance/statementsubmitted/change_form_object_tools.html msgid "Reduce transactions" msgstr "Überweisungen minimieren" diff --git a/jdav_web/members/pdf.py b/jdav_web/members/pdf.py index 851d79c..f3a2d15 100644 --- a/jdav_web/members/pdf.py +++ b/jdav_web/members/pdf.py @@ -47,6 +47,23 @@ def render_docx(name, template_path, context, save_only=False): return filename_docx return serve_media(filename_docx, 'application/docx') +def render_tex_with_attachments(name, template_path, context, attachments, save_only=False): + + rendered_pdf = render_tex(name, template_path, context, save_only=True) + + reader = PdfReader(media_path(rendered_pdf)) + writer = PdfWriter() + writer.append(reader) + + pdf_add_attachments(writer, attachments) + + with open(media_path(rendered_pdf), 'wb') as output_stream: + writer.write(output_stream) + + if save_only: + return rendered_pdf + return serve_pdf(rendered_pdf) + def render_tex(name, template_path, context, save_only=False): filename = generate_tex(name, template_path, context) @@ -73,6 +90,27 @@ def render_tex(name, template_path, context, save_only=False): return serve_pdf(filename_pdf) +def pdf_add_attachments(pdf_writer, attachments): + for fp in attachments: + try: + if fp.endswith(".pdf"): + # append pdf directly + img_pdf = PdfReader(fp) + else: + # convert ensures that png files with an alpha channel can be appended + img = Image.open(fp).convert("RGB") + img_io = BytesIO() + img.save(img_io, "pdf") + img_io.seek(0) + img_pdf = PdfReader(img_io) + img_pdf_scaled = scale_pdf_to_a4(img_pdf) + pdf_writer.append(img_pdf_scaled) + + except Exception as e: + print("Could not add image", fp) + print(e) + + def scale_pdf_page_to_a4(page): A4_WIDTH, A4_HEIGHT = 595, 842 @@ -112,23 +150,7 @@ def fill_pdf_form(name, template_path, fields, attachments=[], save_only=False): writer.update_page_form_field_values(None, fields, auto_regenerate=False) - for fp in attachments: - try: - if fp.endswith(".pdf"): - # append pdf directly - img_pdf = PdfReader(fp) - else: - # convert ensures that png files with an alpha channel can be appended - img = Image.open(fp).convert("RGB") - img_io = BytesIO() - img.save(img_io, "pdf") - img_io.seek(0) - img_pdf = PdfReader(img_io) - img_pdf_scaled = scale_pdf_to_a4(img_pdf) - writer.append(img_pdf_scaled) - except Exception as e: - print("Could not add image", fp) - print(e) + pdf_add_attachments(writer, attachments) with open(media_path(filename_pdf), 'wb') as output_stream: writer.write(output_stream) diff --git a/jdav_web/templates/admin/finance/statementconfirmed/change_form_object_tools.html b/jdav_web/templates/admin/finance/statementconfirmed/change_form_object_tools.html index 1906f52..0ab5d39 100644 --- a/jdav_web/templates/admin/finance/statementconfirmed/change_form_object_tools.html +++ b/jdav_web/templates/admin/finance/statementconfirmed/change_form_object_tools.html @@ -7,7 +7,10 @@ {% url opts|admin_urlname:'unconfirm' original.pk|admin_urlquote as invite_url %} {% trans 'Unconfirm' %} - +
  • + {% url opts|admin_urlname:'summary' original.pk|admin_urlquote as invite_url %} + {% trans 'Download summary' %} +
  • {{block.super}} {% endblock %}