feat(finance): create finance statement and excursion summary

MK/finance_workflow
mariusrklein 9 months ago
parent b2a90f54ea
commit 53b77a110a

@ -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 <a href='../statementconfirmed/{object_id}/summary/' target='_blank'>herunterladen</a>.")) #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(
"<path:object_id>/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):

@ -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 <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\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"

@ -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:

@ -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}

@ -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 <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\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"

@ -47,6 +47,23 @@ def render_docx(name, template_path, context, date=None, 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, date=None, save_only=False):
filename = generate_tex(name, template_path, context, date=date)
@ -73,6 +90,27 @@ def render_tex(name, template_path, context, date=None, 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
@ -114,23 +152,7 @@ def fill_pdf_form(name, template_path, fields, attachments=[], date=None, save_o
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)

@ -7,7 +7,10 @@
{% url opts|admin_urlname:'unconfirm' original.pk|admin_urlquote as invite_url %}
<a class="historylink" href="{% add_preserved_filters invite_url %}">{% trans 'Unconfirm' %}</a>
</li>
<li>
{% url opts|admin_urlname:'summary' original.pk|admin_urlquote as invite_url %}
<a class="historylink" href="{% add_preserved_filters invite_url %}">{% trans 'Download summary' %}</a>
</li>
{{block.super}}
{% endblock %}

Loading…
Cancel
Save