diff --git a/core/static/core/style.scss b/core/static/core/style.scss index 4ceb4bb4..7522666b 100644 --- a/core/static/core/style.scss +++ b/core/static/core/style.scss @@ -503,6 +503,10 @@ th { text-align: center; padding: 5px 10px; + >input[type="checkbox"] { + padding: unset; + } + >ul { margin-top: 0; } diff --git a/counter/admin.py b/counter/admin.py index de1d9d0b..ed24dd23 100644 --- a/counter/admin.py +++ b/counter/admin.py @@ -22,6 +22,7 @@ from counter.models import ( Counter, Customer, Eticket, + InvoiceCall, Permanency, Product, ProductType, @@ -160,3 +161,11 @@ class CashRegisterSummaryAdmin(SearchModelAdmin): class EticketAdmin(SearchModelAdmin): list_display = ("product", "event_date", "event_title") search_fields = ("product__name", "event_title") + + +@admin.register(InvoiceCall) +class InvoiceCallAdmin(SearchModelAdmin): + list_display = ("club", "month", "is_validated") + search_fields = ("club__name",) + list_filter = (("club", admin.RelatedOnlyFieldListFilter),) + date_hierarchy = "month" diff --git a/counter/forms.py b/counter/forms.py index 9b1a9ab6..5e144d44 100644 --- a/counter/forms.py +++ b/counter/forms.py @@ -1,15 +1,18 @@ import json import math import uuid +from datetime import date +from dateutil.relativedelta import relativedelta from django import forms -from django.db.models import Q +from django.db.models import Exists, OuterRef, Q from django.forms import BaseModelFormSet from django.utils.timezone import now 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,10 +32,12 @@ from counter.models import ( Counter, Customer, Eticket, + InvoiceCall, Product, Refilling, ReturnableProduct, ScheduledProductAction, + Selling, StudentCard, get_product_actions, ) @@ -478,3 +483,48 @@ 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: date, **kwargs): + super().__init__(*args, **kwargs) + self.month = month + self.clubs = list( + Club.objects.filter( + Exists( + Selling.objects.filter( + club=OuterRef("pk"), + date__gte=month, + date__lte=month + relativedelta(months=1), + ) + ) + ).annotate( + validated_invoice=Exists( + InvoiceCall.objects.filter( + club=OuterRef("pk"), month=month, is_validated=True + ) + ) + ) + ) + self.fields = { + str(club.id): forms.BooleanField( + required=False, initial=club.validated_invoice + ) + for club in self.clubs + } + + def save(self): + invoice_calls = [ + InvoiceCall( + month=self.month, + club_id=club.id, + is_validated=self.cleaned_data.get(str(club.id), False), + ) + for club in self.clubs + ] + InvoiceCall.objects.bulk_create( + invoice_calls, + update_conflicts=True, + update_fields=["is_validated"], + unique_fields=["month", "club"], + ) diff --git a/counter/migrations/0033_invoicecall.py b/counter/migrations/0033_invoicecall.py new file mode 100644 index 00000000..e9fb0d49 --- /dev/null +++ b/counter/migrations/0033_invoicecall.py @@ -0,0 +1,51 @@ +# Generated by Django 5.2.3 on 2025-10-15 21:54 + +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", "0032_scheduledproductaction"), + ] + + 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", + "constraints": [ + models.UniqueConstraint( + fields=("club", "month"), + name="counter_invoicecall_unique_club_month", + ) + ], + }, + ), + ] diff --git a/counter/models.py b/counter/models.py index 685071c3..e08e3905 100644 --- a/counter/models.py +++ b/counter/models.py @@ -15,6 +15,7 @@ from __future__ import annotations import base64 +import contextlib import os import random import string @@ -1395,3 +1396,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, str): + with contextlib.suppress(ValueError): + # If the string is given as YYYY-mm, try to parse it. + # If it fails, it means that the string may be in the form YYYY-mm-dd + # or in an invalid format. + # Whatever the case, we let Django deal with it + # and raise an error if needed + value = datetime.strptime(value, "%Y-%m") + value = super().to_python(value) + if value is None: + return None + return value.replace(day=1) + + +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") + constraints = [ + models.UniqueConstraint( + fields=["club", "month"], name="counter_invoicecall_unique_club_month" + ) + ] + + def __str__(self): + return f"invoice call of {self.month} made by {self.club}" diff --git a/counter/templates/counter/invoices_call.jinja b/counter/templates/counter/invoices_call.jinja index 973021fb..8dd651e9 100644 --- a/counter/templates/counter/invoices_call.jinja +++ b/counter/templates/counter/invoices_call.jinja @@ -15,24 +15,34 @@ +

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


- - - - - - - {% for i in sums %} + {% include "core/base/notifications.jinja" %} + + {% csrf_token %} +
{% trans %}Club{% endtrans %}{% trans %}Sum{% endtrans %}
+ - - + + + - {% endfor %} - -
{{ i['club__name'] }}{{ i['selling_sum'] }} €{% trans %}Club{% endtrans %}{% trans %}Sum{% endtrans %}{% trans %}Validated{% endtrans %}
-{% endblock %} - - - + + + {% for invoice in invoices %} + + {{ invoice.club__name }} + {{ "%.2f"|format(invoice.selling_sum) }} € + + {{ form[invoice.club_id|string] }} + + + {% endfor %} + + + + + +{% 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..099ab1b9 --- /dev/null +++ b/counter/tests/test_invoices.py @@ -0,0 +1,76 @@ +from datetime import date, datetime + +import pytest +from dateutil.relativedelta import relativedelta +from django.contrib.auth.models import Permission +from django.core.exceptions import ValidationError +from django.test import Client +from django.urls import reverse +from django.utils.timezone import localdate +from model_bakery import baker +from pytest_django.asserts import assertRedirects + +from club.models import Club +from core.models import User +from counter.baker_recipes import sale_recipe +from counter.forms import InvoiceCallForm +from counter.models import Customer, InvoiceCall, Selling + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "month", [date(2025, 10, 20), "2025-10", datetime(2025, 10, 15, 12, 30)] +) +def test_invoice_date_with_date(month: date | datetime | str): + club = baker.make(Club) + invoice = InvoiceCall.objects.create(club=club, month=month) + invoice.refresh_from_db() + assert not invoice.is_validated + 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(ValidationError): + InvoiceCall.objects.create(club=club, month="2025-13") + + +@pytest.mark.django_db +@pytest.mark.parametrize("query", [None, {"month": "2025-08"}]) +def test_invoice_call_view(client: Client, query: dict | None): + user = baker.make( + User, + user_permissions=[ + *Permission.objects.filter( + codename__in=["view_invoicecall", "change_invoicecall"] + ) + ], + ) + client.force_login(user) + url = reverse("counter:invoices_call", query=query) + assert client.get(url).status_code == 200 + assertRedirects(client.post(url), url) + + +@pytest.mark.django_db +def test_invoice_call_form(): + Selling.objects.all().delete() + month = localdate() - relativedelta(months=1) + clubs = baker.make(Club, _quantity=2) + recipe = sale_recipe.extend(date=month, customer=baker.make(Customer, amount=10000)) + recipe.make(club=clubs[0], quantity=2, unit_price=200) + recipe.make(club=clubs[0], quantity=3, unit_price=5) + recipe.make(club=clubs[1], quantity=20, unit_price=10) + form = InvoiceCallForm( + month=month, data={str(clubs[0].id): True, str(clubs[1].id): False} + ) + assert form.is_valid() + form.save() + assert InvoiceCall.objects.filter( + club=clubs[0], month=month, is_validated=True + ).exists() + assert InvoiceCall.objects.filter( + club=clubs[1], month=month, is_validated=False + ).exists() diff --git a/counter/views/invoice.py b/counter/views/invoice.py index cabbccdb..63585796 100644 --- a/counter/views/invoice.py +++ b/counter/views/invoice.py @@ -12,77 +12,81 @@ # OR WITHIN THE LOCAL FILE "LICENSE" # # -from datetime import datetime, timedelta -from datetime import timezone as tz +from datetime import datetime +from urllib.parse import urlencode -from django.db.models import F -from django.utils import timezone -from django.views.generic import TemplateView +from dateutil.relativedelta import relativedelta +from django.contrib.auth.mixins import PermissionRequiredMixin +from django.contrib.messages.views import SuccessMessageMixin +from django.db.models import F, Sum +from django.utils.timezone import localdate, make_aware +from django.utils.translation import gettext_lazy as _ +from django.views.generic import FormView -from counter.fields import CurrencyField +from counter.forms import InvoiceCallForm from counter.models import Refilling, Selling -from counter.views.mixins import CounterAdminMixin, CounterAdminTabsMixin +from counter.views.mixins import CounterAdminTabsMixin -class InvoiceCallView(CounterAdminTabsMixin, CounterAdminMixin, TemplateView): +class InvoiceCallView( + CounterAdminTabsMixin, PermissionRequiredMixin, SuccessMessageMixin, FormView +): template_name = "counter/invoices_call.jinja" current_tab = "invoices_call" + permission_required = ["counter.view_invoicecall", "counter.change_invoicecall"] + form_class = InvoiceCallForm + success_message = _("Invoice calls status has been updated.") + + def get_month(self): + kwargs = self.request.GET or self.request.POST + if "month" in kwargs: + return make_aware(datetime.strptime(kwargs["month"], "%Y-%m")) + return localdate().replace(day=1) - relativedelta(months=1) + + def get_form_kwargs(self): + return super().get_form_kwargs() | {"month": self.get_month()} + + def form_valid(self, form): + form.save() + return super().form_valid(form) + + def get_success_url(self): + # redirect to the month from which the request is originated + url = self.request.path + kwargs = self.request.GET or self.request.POST + if "month" in kwargs: + query = urlencode({"month": kwargs["month"]}) + url += f"?{query}" + return url 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: - 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, - ) - 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 + start_date = self.get_month() + end_date = start_date + relativedelta(months=1) - 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(res=Sum("amount", default=0))["res"] + kwargs["sum_cb"] += ( + Selling.objects.filter( + payment_method="CARD", + is_validated=True, + date__gte=start_date, + date__lte=end_date, + ) + .annotate(amount=F("unit_price") * F("quantity")) + .aggregate(res=Sum("amount", default=0))["res"] ) kwargs["start_date"] = start_date - kwargs["sums"] = ( - Selling.objects.values("club__name") - .annotate( - selling_sum=Sum( - Case( - When( - date__gte=start_date, - date__lt=end_date, - then=F("unit_price") * F("quantity"), - ), - output_field=CurrencyField(), - ) - ) - ) + kwargs["invoices"] = ( + Selling.objects.filter(date__gte=start_date, date__lt=end_date) + .values("club_id", "club__name") + .annotate(selling_sum=Sum(F("unit_price") * F("quantity"))) .exclude(selling_sum=None) .order_by("-selling_sum") ) diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 8c151488..3c790c88 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-09-26 17:36+0200\n" +"POT-Creation-Date: 2025-10-17 13:41+0200\n" "PO-Revision-Date: 2016-07-18\n" "Last-Translator: Maréchal \n" @@ -556,6 +556,7 @@ msgstr "" #: core/templates/core/user_godfathers_tree.jinja #: core/templates/core/user_preferences.jinja #: counter/templates/counter/cash_register_summary.jinja +#: counter/templates/counter/invoices_call.jinja #: counter/templates/counter/product_form.jinja #: forum/templates/forum/reply.jinja #: subscription/templates/subscription/fragments/creation_form.jinja @@ -689,15 +690,15 @@ msgstr "Vente" msgid "Mailing list" msgstr "Listes de diffusion" +#: club/views.py +msgid "You are now a member of this club." +msgstr "Vous êtes maintenant membre de ce club." + #: club/views.py #, python-format msgid "%(user)s has been added to club." msgstr "%(user)s a été ajouté au club." -#: club/views.py -msgid "You are now a member of this club." -msgstr "Vous êtes maintenant membre de ce club." - #: com/forms.py msgid "Format: 16:9 | Resolution: 1920x1080" msgstr "Format : 16:9 | Résolution : 1920x1080" @@ -3314,6 +3315,40 @@ msgstr "Changement des comptoirs" msgid "Product scheduled action" msgstr "Actions sur produit planifiées" +#: counter/models.py +msgid "Product actions must declare a clocked schedule." +msgstr "Les actions sur les produits doivent avoir un horaire planifié." + +#: counter/models.py +msgid "Year + month field (day forced to 1)" +msgstr "Champ Année + mois (jour forcé à 1)" + +#: counter/models.py +#, python-format +msgid "" +"“%(value)s” value has an invalid date format. It must be in YYYY-MM format." +msgstr "" +"La valeur « %(value)s » a un format de date invalide. Ce doit être au format " +"YYYY-MM." + +#: counter/models.py +#, python-format +msgid "" +"“%(value)s” value has the correct format (YYYY-MM) but it is an invalid date." +msgstr "La valeur « %(value)s » a le bon format, mais est une date invalide." + +#: counter/models.py +msgid "invoice date" +msgstr "date de la facture" + +#: counter/models.py +msgid "Invoice call" +msgstr "Appel à facture" + +#: counter/models.py +msgid "Invoice calls" +msgstr "Appels à facture" + #: counter/templates/counter/activity.jinja #, python-format msgid "%(counter_name)s activity" @@ -3544,6 +3579,10 @@ msgstr "Payements en Carte Bancaire" msgid "Sum" msgstr "Somme" +#: counter/templates/counter/invoices_call.jinja +msgid "Validated" +msgstr "Validé" + #: counter/templates/counter/last_ops.jinja #, python-format msgid "%(counter_name)s last operations" @@ -3833,6 +3872,10 @@ msgstr "L'utilisateur n'est pas barman." msgid "Bad location, someone is already logged in somewhere else" msgstr "Mauvais comptoir, quelqu'un est déjà connecté ailleurs" +#: counter/views/invoice.py +msgid "Invoice calls status has been updated." +msgstr "Le statut des appels à facture a été mis à jour." + #: counter/views/mixins.py msgid "Cash summary" msgstr "Relevé de caisse"