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" %}
+
{% for bill in statement.bill_set.all %}
-
- {{bill.short_description}}
+ {{bill.short_description}} {%trans "for" %} {{ bill.amount }}€.
+ {% if not bill.costs_covered %}
+ This bill is not covered by the association.
+ {% endif %}
{% endfor %}
+
{% 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
+
+
+ -
+ {{ nights }} nights for {{ price_per_night }}€ per night making a total of {{ nights_per_yl }}€.
+
+ -
+ {{ duration }} days for {{ allowance_per_day }}€ per day making a total of {{ allowance_per_yl }}€.
+
+ -
+ {{ kilometers_traveled }} km by {{ means_of_transport }} ({{euro_per_km}} € / km) making a total of {{ transportation_per_yl }}€.
+
+
+
+
+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 %}
+
{% 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' %}