From 3bcd417ad0c8778e33bffb1b66d004735d3f92d9 Mon Sep 17 00:00:00 2001 From: Kenneth SOARES Date: Mon, 2 Jun 2025 16:50:46 +0200 Subject: [PATCH] Basic implementation of invoice call validation --- counter/forms.py | 38 +++++ counter/migrations/0032_invoicecall.py | 45 +++++ counter/models.py | 46 ++++++ counter/templates/counter/invoices_call.jinja | 46 +++--- counter/tests/test_invoices.py | 33 ++++ counter/views/invoice.py | 155 ++++++++++++++---- 6 files changed, 310 insertions(+), 53 deletions(-) create mode 100644 counter/migrations/0032_invoicecall.py create mode 100644 counter/tests/test_invoices.py diff --git a/counter/forms.py b/counter/forms.py index 9b1a9ab6..b2e9a678 100644 --- a/counter/forms.py +++ b/counter/forms.py @@ -10,6 +10,7 @@ from django.utils.translation import gettext_lazy as _ from django_celery_beat.models import ClockedSchedule from phonenumber_field.widgets import RegionalPhoneNumberWidget +from club.models import Club from club.widgets.ajax_select import AutoCompleteSelectClub from core.models import User from core.views.forms import ( @@ -29,6 +30,7 @@ from counter.models import ( Counter, Customer, Eticket, + InvoiceCall, Product, Refilling, ReturnableProduct, @@ -478,3 +480,39 @@ class BaseBasketForm(forms.BaseFormSet): BasketForm = forms.formset_factory( BasketProductForm, formset=BaseBasketForm, absolute_max=None, min_num=1 ) + + +class InvoiceCallForm(forms.Form): + def __init__(self, *args, month, clubs: list[Club] | None = None, **kwargs): + super().__init__(*args, **kwargs) + self.month = month + self.clubs = clubs + + if self.clubs is None: + self.clubs = [] + + invoices = { + i["club_id"]: i["is_validated"] + for i in InvoiceCall.objects.filter( + club__in=self.clubs, month=self.month + ).values("club_id", "is_validated") + } + + for club in self.clubs: + is_validated = invoices.get(club.id, False) + + self.fields[f"club_{club.id}"] = forms.BooleanField( + required=False, initial=is_validated + ) + + def save(self): + for club in self.clubs: + field_name = f"club_{club.id}" + is_validated = self.cleaned_data.get(field_name, False) + + InvoiceCall.objects.update_or_create( + month=self.month, club=club, defaults={"is_validated": is_validated} + ) + + def get_club_name(self, club_id): + return f"club_{club_id}" diff --git a/counter/migrations/0032_invoicecall.py b/counter/migrations/0032_invoicecall.py new file mode 100644 index 00000000..b02edf20 --- /dev/null +++ b/counter/migrations/0032_invoicecall.py @@ -0,0 +1,45 @@ +# Generated by Django 5.2.3 on 2025-09-09 10:24 + +import django.db.models.deletion +from django.db import migrations, models + +import counter.models + + +class Migration(migrations.Migration): + dependencies = [ + ("club", "0014_alter_club_options_rename_unix_name_club_slug_name_and_more"), + ("counter", "0031_alter_counter_options"), + ] + + operations = [ + migrations.CreateModel( + name="InvoiceCall", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "is_validated", + models.BooleanField(default=False, verbose_name="is validated"), + ), + ("month", counter.models.MonthField(verbose_name="invoice date")), + ( + "club", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="club.club" + ), + ), + ], + options={ + "verbose_name": "Invoice call", + "verbose_name_plural": "Invoice calls", + }, + ), + ] diff --git a/counter/models.py b/counter/models.py index 685071c3..6e5bdba4 100644 --- a/counter/models.py +++ b/counter/models.py @@ -1395,3 +1395,49 @@ class ScheduledProductAction(PeriodicTask): # adapted in the case of scheduled product action, # so we skip it and execute directly Model.validate_unique return super(PeriodicTask, self).validate_unique(*args, **kwargs) + + +class MonthField(models.DateField): + description = _("Year + month field (day forced to 1)") + default_error_messages = { + "invalid": _( + "“%(value)s” value has an invalid date format. It must be " + "in YYYY-MM format." + ), + "invalid_date": _( + "“%(value)s” value has the correct format (YYYY-MM) " + "but it is an invalid date." + ), + } + + def to_python(self, value): + if isinstance(value, date): + return value.replace(day=1) + + if isinstance(value, str): + try: + year, month = map(int, value.split("-")) + return date(year, month, 1) + except (ValueError, TypeError) as err: + raise ValueError( + self.error_messages["invalid"] % {"value": value} + ) from err + + return super().to_python(value) + + +class InvoiceCall(models.Model): + is_validated = models.BooleanField(verbose_name=_("is validated"), default=False) + club = models.ForeignKey(Club, on_delete=models.CASCADE) + month = MonthField(verbose_name=_("invoice date")) + + class Meta: + verbose_name = _("Invoice call") + verbose_name_plural = _("Invoice calls") + + def __str__(self): + return f"invoice call of {self.month} made by {self.club}" + + def save(self, *args, **kwargs): + self.month = self._meta.get_field("month").to_python(self.month) + super().save(*args, **kwargs) diff --git a/counter/templates/counter/invoices_call.jinja b/counter/templates/counter/invoices_call.jinja index 973021fb..9085d05d 100644 --- a/counter/templates/counter/invoices_call.jinja +++ b/counter/templates/counter/invoices_call.jinja @@ -15,24 +15,32 @@ -
-

{% trans %}CB Payments{% endtrans %} : {{ sum_cb }} €

-
- - - - - - - {% for i in sums %} - - - - - {% endfor %} - -
{% trans %}Club{% endtrans %}{% trans %}Sum{% endtrans %}
{{ i['club__name'] }}{{ i['selling_sum'] }} €
-{% endblock %} - +
+ {% csrf_token %} +
+

{% trans %}CB Payments{% endtrans %} : {{ sum_cb }} €

+
+ + + + + + + + {% for data in club_data %} + + + + + + {% endfor %} + +
{% trans %}Club{% endtrans %}{% trans %}Sum{% endtrans %}{% trans %}Validated{% endtrans %}
{{ data.club.name }}{{"%.2f"|format(data.sum)}} € + {{ form[form.get_club_name(data.club.id)] }} +
+ + +
+{% endblock %} \ No newline at end of file diff --git a/counter/tests/test_invoices.py b/counter/tests/test_invoices.py new file mode 100644 index 00000000..1aebca5f --- /dev/null +++ b/counter/tests/test_invoices.py @@ -0,0 +1,33 @@ +from datetime import date + +import pytest +from model_bakery import baker + +from club.models import Club +from counter.models import InvoiceCall + + +@pytest.mark.django_db +def test_invoice_date_with_date(): + club = baker.make(Club) + invoice = InvoiceCall.objects.create(club=club, month=date(2025, 10, 20)) + + assert not invoice.is_validated + assert str(invoice) == f"invoice call of {invoice.month} made by {club}" + assert invoice.month == date(2025, 10, 1) + + +@pytest.mark.django_db +def test_invoice_date_with_string(): + club = baker.make(Club) + invoice = InvoiceCall.objects.create(club=club, month="2025-10") + + assert invoice.month == date(2025, 10, 1) + + +@pytest.mark.django_db +def test_invoice_call_invalid_month_string(): + club = baker.make(Club) + + with pytest.raises(ValueError): + InvoiceCall.objects.create(club=club, month="2025-13") diff --git a/counter/views/invoice.py b/counter/views/invoice.py index cabbccdb..a215c16a 100644 --- a/counter/views/invoice.py +++ b/counter/views/invoice.py @@ -12,15 +12,17 @@ # OR WITHIN THE LOCAL FILE "LICENSE" # # -from datetime import datetime, timedelta +from datetime import date, datetime, timedelta from datetime import timezone as tz -from django.db.models import F +from django.db.models import Exists, F, OuterRef +from django.shortcuts import redirect from django.utils import timezone from django.views.generic import TemplateView from counter.fields import CurrencyField -from counter.models import Refilling, Selling +from counter.forms import InvoiceCallForm +from counter.models import Club, InvoiceCall, Refilling, Selling from counter.views.mixins import CounterAdminMixin, CounterAdminTabsMixin @@ -28,48 +30,82 @@ class InvoiceCallView(CounterAdminTabsMixin, CounterAdminMixin, TemplateView): template_name = "counter/invoices_call.jinja" current_tab = "invoices_call" + def dispatch(self, request, *args, **kwargs): + previous_month = (date.today().replace(day=1) - timedelta(days=1)).strftime( + "%Y-%m" + ) + + if request.method == "GET" and "month" not in request.GET: + request.GET = request.GET.copy() + request.GET["month"] = previous_month + + if request.method == "POST" and "month" not in request.POST: + request.POST = request.POST.copy() + request.POST["month"] = previous_month + + return super().dispatch(request, *args, **kwargs) + + def get(self, request, *args, **kwargs): + month_str = request.GET.get("month") + + try: + start_date = datetime.strptime(month_str, "%Y-%m").date() + today = timezone.now().date().replace(day=1) + if start_date > today: + return redirect("counter:invoices_call") + except ValueError: + return redirect("counter:invoices_call") + return super().get(request, *args, **kwargs) + def get_context_data(self, **kwargs): """Add sums to the context.""" kwargs = super().get_context_data(**kwargs) - kwargs["months"] = Selling.objects.datetimes("date", "month", order="DESC") - if "month" in self.request.GET: + + months_with_sellings = list( + Selling.objects.datetimes("date", "month", order="DESC") + ) + + first_month = months_with_sellings[-1].replace(day=1).date() + last_month = (date.today().replace(day=1) - timedelta(days=1)).replace(day=1) + current = last_month + months = [] + + while current >= first_month: + months.append(current) + current = (current.replace(day=1) - timedelta(days=1)).replace(day=1) + + kwargs["months"] = months + + month_str = self.request.GET.get("month") + + try: start_date = datetime.strptime(self.request.GET["month"], "%Y-%m") - else: - start_date = datetime( - year=timezone.now().year, - month=(timezone.now().month + 10) % 12 + 1, - day=1, - ) + except ValueError: + return redirect("counter:invoices_call") + start_date = start_date.replace(tzinfo=tz.utc) end_date = (start_date + timedelta(days=32)).replace( day=1, hour=0, minute=0, microsecond=0 ) from django.db.models import Case, Sum, When - kwargs["sum_cb"] = sum( - [ - r.amount - for r in Refilling.objects.filter( - payment_method="CARD", - is_validated=True, - date__gte=start_date, - date__lte=end_date, - ) - ] - ) - kwargs["sum_cb"] += sum( - [ - s.quantity * s.unit_price - for s in Selling.objects.filter( - payment_method="CARD", - is_validated=True, - date__gte=start_date, - date__lte=end_date, - ) - ] - ) + kwargs["sum_cb"] = Refilling.objects.filter( + payment_method="CARD", + is_validated=True, + date__gte=start_date, + date__lte=end_date, + ).aggregate(amount=Sum(F("amount"), default=0))["amount"] + + kwargs["sum_cb"] += Selling.objects.filter( + payment_method="CARD", + is_validated=True, + date__gte=start_date, + date__lte=end_date, + ).aggregate(amount=Sum(F("quantity") * F("unit_price"), default=0))["amount"] + kwargs["start_date"] = start_date - kwargs["sums"] = ( + + kwargs["sums"] = list( Selling.objects.values("club__name") .annotate( selling_sum=Sum( @@ -86,4 +122,55 @@ class InvoiceCallView(CounterAdminTabsMixin, CounterAdminMixin, TemplateView): .exclude(selling_sum=None) .order_by("-selling_sum") ) + + club_names = [i["club__name"] for i in kwargs["sums"]] + clubs = Club.objects.filter(name__in=club_names) + + invoice_calls = InvoiceCall.objects.filter(month=month_str, club__in=clubs) + invoice_statuses = {ic.club.name: ic.is_validated for ic in invoice_calls} + + kwargs["form"] = InvoiceCallForm(clubs=clubs, month=month_str) + + kwargs["club_data"] = [] + for club in clubs: + selling_sum = next( + ( + item["selling_sum"] + for item in kwargs["sums"] + if item["club__name"] == club.name + ), + 0, + ) + kwargs["club_data"].append( + { + "club": club, + "sum": selling_sum, + "validated": invoice_statuses.get(club.name, False), + } + ) + return kwargs + + def post(self, request, *args, **kwargs): + month_str = request.POST.get("month") + + try: + start_date = datetime.strptime(month_str, "%Y-%m") + start_date = date(start_date.year, start_date.month, 1) + except ValueError: + return redirect(request.path) + + selling_subquery = Selling.objects.filter( + club=OuterRef("pk"), + date__year=start_date.year, + date__month=start_date.month, + ) + + clubs = Club.objects.filter(Exists(selling_subquery)) + + form = InvoiceCallForm(request.POST, clubs=clubs, month=month_str) + + if form.is_valid(): + form.save() + + return redirect(f"{request.path}?month={request.POST.get('month', '')}")