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 }} €
-
-
- {% trans %}Club{% endtrans %} |
- {% trans %}Sum{% endtrans %} |
-
-
- {% for i in sums %}
+ {% include "core/base/notifications.jinja" %}
+
+
+
+
+{% 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"