From b8daed826d7d4224e50952720b4e4e0ac6f3e176 Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Fri, 17 Mar 2023 01:11:03 +0100 Subject: [PATCH] finance: add overview, transaction generation and reduction, further fields --- jdav_web/finance/admin.py | 83 +++++++++-- jdav_web/finance/models.py | 138 ++++++++++++++++-- .../admin/overview_submitted_statement.html | 46 +++++- jdav_web/members/models.py | 40 ++++- .../change_form_object_tools.html | 4 + 5 files changed, 279 insertions(+), 32 deletions(-) diff --git a/jdav_web/finance/admin.py b/jdav_web/finance/admin.py index 184b9d2..7c0a0e4 100644 --- a/jdav_web/finance/admin.py +++ b/jdav_web/finance/admin.py @@ -7,7 +7,7 @@ from functools import update_wrapper from django.utils.translation import gettext_lazy as _ from django.shortcuts import render -from .models import Ledger, Statement, Receipt, Transaction, Bill, StatementSubmitted +from .models import Ledger, Statement, Receipt, Transaction, Bill, StatementSubmitted, StatementConfirmed @admin.register(Ledger) class LedgerAdmin(admin.ModelAdmin): @@ -76,14 +76,36 @@ class StatementAdmin(admin.ModelAdmin): title=_('Submit statement'), opts=self.opts, statement=statement) - + return render(request, 'admin/submit_statement.html', context=context) +class TransactionOnSubmittedStatementInline(admin.TabularInline): + model = Transaction + fields = ['amount', 'member', 'reference'] + formfield_overrides = { + TextField: {'widget': Textarea(attrs={'rows': 1, 'cols': 40})} + } + extra = 0 + + +class BillOnSubmittedStatementInline(BillOnStatementInline): + model = Bill + extra = 0 + sortable_options = [] + fields = ['short_description', 'explanation', 'amount', 'paid_by', 'proof', 'costs_covered'] + formfield_overrides = { + TextField: {'widget': Textarea(attrs={'rows': 1, 'cols': 40})} + } + + def get_readonly_fields(self, request, obj=None): + return ['short_description', 'explanation', 'amount', 'paid_by', 'proof'] + + @admin.register(StatementSubmitted) class StatementSubmittedAdmin(admin.ModelAdmin): fields = ['short_description', 'explanation', 'excursion', 'submitted'] - inlines = [BillOnStatementInline] + inlines = [BillOnSubmittedStatementInline, TransactionOnSubmittedStatementInline] def has_add_permission(self, request, obj=None): return False @@ -111,6 +133,11 @@ class StatementSubmittedAdmin(admin.ModelAdmin): wrap(self.overview_view), name="%s_%s_overview" % (self.opts.app_label, self.opts.model_name), ), + path( + "/reduce_transactions/", + wrap(self.reduce_transactions_view), + name="%s_%s_reduce_transactions" % (self.opts.app_label, self.opts.model_name), + ), ] return custom_urls + urls @@ -120,22 +147,56 @@ class StatementSubmittedAdmin(admin.ModelAdmin): messages.error(request, _("%(name)s is not yet submitted.") % {'name': str(statement)}) return HttpResponseRedirect(reverse('admin:%s_%s_change' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,))) - - if "apply" in request.POST: - #statement.submit() - #messages.success(request, - # _("Successfully submited %(name)s. The finance department will notify the requestors as soon as possible.") % {'name': str(statement)}) + if "confirm" in request.POST: + statement.confirmed = True + statement.save() + for trans in statement.transaction_set.all(): + trans.confirmed = True + trans.save() + + if "generate_transactions" in request.POST: + if statement.transaction_set.count() > 0: + messages.error(request, + _("%(name)s already has transactions. Please delete them first, if you want to generate new ones") % {'name': str(statement)}) + else: + statement.generate_transactions() + messages.success(request, + _("Successfully generated transactions for %(name)s") % {'name': str(statement)}) return HttpResponseRedirect(reverse('admin:%s_%s_change' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,))) context = dict(self.admin_site.each_context(request), title=_('View submitted statement'), opts=self.opts, statement=statement, - total_bills=statement.total_bills(), - total_transportation=statement.total_transportation(), + nights=statement.excursion.night_count, + price_per_night=statement.real_night_cost, + duration=statement.excursion.duration, + staff_count=statement.real_staff_count, + kilometers_traveled=statement.excursion.kilometers_traveled, + means_of_transport=statement.excursion.get_tour_approach(), + euro_per_km=statement.euro_per_km, + allowance_per_day=statement.ALLOWANCE_PER_DAY, + total_bills=statement.total_bills, + nights_per_yl=statement.nights_per_yl, + allowance_per_yl=statement.allowance_per_yl, + transportation_per_yl=statement.transportation_per_yl, + total_per_yl=statement.total_per_yl, + total_staff=statement.total_staff, total=statement.total()) - + return render(request, 'admin/overview_submitted_statement.html', context=context) + def reduce_transactions_view(self, request, object_id): + statement = Statement.objects.get(pk=object_id) + statement.reduce_transactions() + messages.success(request, + _("Successfully reduced transactions for %(name)s.") % {'name': str(statement)}) + return HttpResponseRedirect(reverse('admin:%s_%s_change' % (self.opts.app_label, self.opts.model_name), args=(statement.pk,))) + +@admin.register(StatementConfirmed) +class StatementConfirmedAdmin(admin.ModelAdmin): + fields = ['short_description', 'explanation', 'excursion', 'confirmed'] + readonly_fields = fields + @admin.register(Receipt) class ReceiptAdmin(admin.ModelAdmin): diff --git a/jdav_web/finance/models.py b/jdav_web/finance/models.py index 7f16ac8..9f10ef5 100644 --- a/jdav_web/finance/models.py +++ b/jdav_web/finance/models.py @@ -1,6 +1,9 @@ +import math +from itertools import groupby + from django.db import models from django.utils.translation import gettext_lazy as _ -from members.models import Member, Freizeit +from members.models import Member, Freizeit, OEFFENTLICHE_ANREISE, MUSKELKRAFT_ANREISE # Create your models here. @@ -12,6 +15,8 @@ class Ledger(models.Model): class Statement(models.Model): + ALLOWANCE_PER_DAY = 10 + short_description = models.CharField(verbose_name=_('Short description'), max_length=30, blank=True) @@ -22,7 +27,10 @@ class Statement(models.Model): null=True, on_delete=models.SET_NULL) + night_cost = models.DecimalField(verbose_name=_('Price per night'), default=0, decimal_places=2, max_digits=3) + submitted = models.BooleanField(verbose_name=_('Submitted'), default=False) + confirmed = models.BooleanField(verbose_name=_('Confirmed'), default=False) class Meta: permissions = [('may_edit_submitted_statements', 'Is allowed to edit submitted statements')] @@ -37,23 +45,116 @@ class Statement(models.Model): self.submitted = True self.save() + def generate_transactions(self): + # bills + for bill in self.bill_set.all(): + if not bill.costs_covered: + continue + ref = "{}: {}".format(self.excursion.name, bill.short_description) + Transaction(statement=self, member=bill.paid_by, amount=bill.amount, confirmed=False, reference=ref).save() + + # excursion specific + if self.excursion is None: + return + + for yl in self.excursion.jugendleiter.all(): + real_per_yl = self.total_staff / self.excursion.jugendleiter.count() + ref = _("Compensation for %(excu)s.") % {'excu': self.excursion.name} + Transaction(statement=self, member=yl, amount=real_per_yl, confirmed=False, reference=ref).save() + + def reduce_transactions(self): + transactions = sorted(self.transaction_set.all(), key=lambda trans: trans.member.pk) + for member, transaction_group in groupby(transactions, lambda trans: trans.member): + grp = list(transaction_group) + if len(grp) == 1: + continue + + new_amount = sum((trans.amount for trans in grp)) + new_ref = "\n".join((trans.reference for trans in grp)) + Transaction(statement=self, member=member, amount=new_amount, confirmed=False, reference=new_ref).save() + for trans in grp: + trans.delete() + + @property def total_bills(self): - return sum([bill.amount for bill in self.bill_set.all()]) + return sum([bill.amount for bill in self.bill_set.all() if bill.costs_covered]) + + @property + def euro_per_km(self): + if self.excursion is None: + return 0 + + if self.excursion.tour_approach == MUSKELKRAFT_ANREISE \ + or self.excursion.tour_approach == OEFFENTLICHE_ANREISE: + return 0.15 + else: + return 0.1 + + @property + def transportation_per_yl(self): + if self.excursion is None: + return 0 + + return self.excursion.kilometers_traveled * self.euro_per_km + + @property + def allowance_per_yl(self): + if self.excursion is None: + return 0 + + return self.excursion.duration * self.ALLOWANCE_PER_DAY + + @property + def real_night_cost(self): + return min(self.night_cost, 11) + + @property + def nights_per_yl(self): + if self.excursion is None: + return 0 + + return float(self.excursion.night_count * self.real_night_cost) + + @property + def total_per_yl(self): + return self.transportation_per_yl \ + + self.allowance_per_yl \ + + self.nights_per_yl - def total_transportation(self): + @property + def total_staff(self): + return self.total_per_yl * self.real_staff_count + + @property + def real_staff_count(self): if self.excursion is None: return 0 - exc = self.excursion - return exc.kilometers_traveled * 0.2 + return min(self.excursion.staff_count, self.admissible_staff_count) + + @property + def admissible_staff_count(self): + """An excursion can have as many youth leaders as the max bound on integers allows. Not all youth leaders + are refinanced though.""" + if self.excursion is None: + return 0 + + #raw_staff_count = self.excursion.jugendleiter.count() + participant_count = self.excursion.participant_count + if participant_count < 4: + return 0 + elif 4 <= participant_count <= 7: + return 2 + else: + return 2 + math.ceil((participant_count - 7) / 7) def total(self): - return float(self.total_bills()) + self.total_transportation() + return float(self.total_bills) + self.total_staff class StatementSubmittedManager(models.Manager): def get_queryset(self): - return super().get_queryset().filter(submitted=True) + return super().get_queryset().filter(submitted=True, confirmed=False) class StatementSubmitted(Statement): @@ -65,15 +166,31 @@ class StatementSubmitted(Statement): verbose_name_plural = _('Submitted statements') permissions = (('may_manage_submitted_statements', 'Can view and manage submitted statements.'),) + +class StatementConfirmedManager(models.Manager): + def get_queryset(self): + return super().get_queryset().filter(confirmed=True) + + +class StatementConfirmed(Statement): + objects = StatementConfirmedManager() + + class Meta: + proxy = True + verbose_name = _('Confirmed statement') + verbose_name_plural = _('Confirmed statements') + permissions = (('may_manage_confirmed_statements', 'Can view and manage confirmed statements.'),) + + class Bill(models.Model): - statement = models.ForeignKey(Statement, verbose_name=_('Statement'), - on_delete=models.CASCADE) + statement = models.ForeignKey(Statement, verbose_name=_('Statement'), on_delete=models.CASCADE) short_description = models.CharField(verbose_name=_('Short description'), max_length=30) explanation = models.TextField(verbose_name=_('Explanation'), blank=True) amount = models.DecimalField(max_digits=6, decimal_places=2, default=0) paid_by = models.ForeignKey(Member, verbose_name=_('Paid by'), null=True, on_delete=models.SET_NULL) + costs_covered = models.BooleanField(verbose_name=_('Covered'), default=False) refunded = models.BooleanField(verbose_name=_('Refunded'), default=False) proof = models.ImageField(_('Proof'), upload_to='bill_images', blank=True) @@ -87,6 +204,7 @@ class Bill(models.Model): class Transaction(models.Model): + reference = models.TextField(verbose_name=_('Reference')) amount = models.DecimalField(max_digits=6, decimal_places=2) member = models.ForeignKey(Member, verbose_name=_('Recipient'), on_delete=models.CASCADE) @@ -94,7 +212,7 @@ class Transaction(models.Model): statement = models.ForeignKey(Statement, verbose_name=_('Statement'), on_delete=models.CASCADE) - confirmed = models.BooleanField(verbose_name=_('Confirmed')) + confirmed = models.BooleanField(verbose_name=_('Confirmed'), default=False) class Receipt(models.Model): diff --git a/jdav_web/finance/templates/admin/overview_submitted_statement.html b/jdav_web/finance/templates/admin/overview_submitted_statement.html index 6572822..29635fa 100644 --- a/jdav_web/finance/templates/admin/overview_submitted_statement.html +++ b/jdav_web/finance/templates/admin/overview_submitted_statement.html @@ -1,6 +1,8 @@ {% extends "admin/base_site.html" %} {% load i18n admin_urls static %} +{% load finance_extras %} + {% block extrahead %} {{ block.super }} {{ media }} @@ -18,38 +20,68 @@ › {{ opts.app_config.verbose_name }}{{ opts.verbose_name_plural|capfirst }}{{ statement|truncatewords:"18" }} -› {% translate 'Submit' %} +› {% translate 'Overview' %} {% endblock %} {% block content %} -

{% translate "Overview" %}

+

{% translate "Bills" %}

+

+

{% blocktrans %}The total amount is {{total_bills}} €.{% endblocktrans %}

{% if statement.excursion %} -

{% trans "Excursion" %}

-

{% blocktrans %}Total distance traveled: {{ statement.excursion.kilometers_traveled }} km by -{{ statement.excursion.tour_approach }}. This results in {{ total_transportation }} €.{% endblocktrans %} +

{% trans "Excursion" %}

+ +{% blocktrans %} +

+This excursion featured {{ staff_count }} youth leader(s), each costing

+

+

+

+

+In total this is {{ total_per_yl }}€ times {{ staff_count }}, giving {{ total_staff }}€. +

+{% endblocktrans %} {% endif %} +

{% trans "Total" %}

+ +

{% blocktrans %} This results in a total amount of {{ total }} € {% endblocktrans %} +

{% csrf_token %} - + + + {% translate "Cancel" %}
{% endblock %} diff --git a/jdav_web/members/models.py b/jdav_web/members/models.py index effdab8..c900b03 100644 --- a/jdav_web/members/models.py +++ b/jdav_web/members/models.py @@ -278,7 +278,7 @@ class Member(Person): lists = Freizeit.objects.filter(activity=kind, membersonlist__member=self) skills[kind.name] = sum([l.difficulty * 3 for l in lists - if l.date < datetime.now().date()]) + if l.date < timezone.now()]) return skills def get_activities(self): @@ -509,8 +509,8 @@ class Freizeit(models.Model): place = models.CharField(verbose_name=_('Place'), default='', max_length=50) destination = models.CharField(verbose_name=_('Destination (optional)'), default='', max_length=50, blank=True) - date = models.DateField(default=datetime.today, verbose_name=_('Date')) - end = models.DateField(verbose_name=_('End (optional)'), blank=True, default=datetime.today) + date = models.DateTimeField(default=datetime.today, verbose_name=_('Begin')) + end = models.DateTimeField(verbose_name=_('End (optional)'), default=datetime.today) # comment = models.TextField(_('Comments'), default='', blank=True) groups = models.ManyToManyField(Group, verbose_name=_('Groups')) jugendleiter = models.ManyToManyField(Member) @@ -554,13 +554,45 @@ class Freizeit(models.Model): if self.tour_approach == MUSKELKRAFT_ANREISE: return "Muskelkraft" elif self.tour_approach == OEFFENTLICHE_ANREISE: - return "Öffentliche VM" + return "ÖPNV" else: return "Fahrgemeinschaften" def get_absolute_url(self): return reverse('admin:members_freizeit_change', args=[str(self.id)]) + @property + def night_count(self): + # convert to date first, since we might start at 11pm and end at 1am, which is one night + return (self.end.date() - self.date.date()).days + + @property + def duration(self): + # number of nights is number of full days + 1 + full_days = self.night_count - 1 + extra_days = 0 + + if self.date.hour <= 12: + extra_days += 1.0 + else: + extra_days += 0.5 + + if self.end.hour <= 12: + extra_days += 1.0 + else: + extra_days += 0.5 + + return full_days + extra_days + + @property + def staff_count(self): + return self.jugendleiter.count() + + @property + def participant_count(self): + ps = set(map(lambda x: x.member, self.membersonlist.distinct())) + jls = set(self.jugendleiter.distinct()) + return len(ps - jls) class MemberNoteList(models.Model): """ diff --git a/jdav_web/templates/admin/finance/statementsubmitted/change_form_object_tools.html b/jdav_web/templates/admin/finance/statementsubmitted/change_form_object_tools.html index 0a51b90..065a1f2 100644 --- a/jdav_web/templates/admin/finance/statementsubmitted/change_form_object_tools.html +++ b/jdav_web/templates/admin/finance/statementsubmitted/change_form_object_tools.html @@ -3,6 +3,10 @@ {% block object-tools-items %} +
  • + {% url opts|admin_urlname:'reduce_transactions' original.pk|admin_urlquote as invite_url %} + {% trans 'Reduce transactions' %} +
  • {% url opts|admin_urlname:'overview' original.pk|admin_urlquote as invite_url %} {% trans 'Overview' %}