From 7be1d1cc637aabaf0799b0eb05045aca46cb22f4 Mon Sep 17 00:00:00 2001 From: imperosol Date: Sun, 23 Nov 2025 01:53:59 +0100 Subject: [PATCH 1/8] feat: ProductFormula model --- counter/migrations/0037_productformula.py | 43 +++++++++++++++++++++++ counter/models.py | 18 ++++++++++ 2 files changed, 61 insertions(+) create mode 100644 counter/migrations/0037_productformula.py diff --git a/counter/migrations/0037_productformula.py b/counter/migrations/0037_productformula.py new file mode 100644 index 00000000..75fbdd7f --- /dev/null +++ b/counter/migrations/0037_productformula.py @@ -0,0 +1,43 @@ +# Generated by Django 5.2.8 on 2025-11-26 11:34 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [("counter", "0036_product_created_at_product_updated_at")] + + operations = [ + migrations.CreateModel( + name="ProductFormula", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "products", + models.ManyToManyField( + help_text="The products that constitute this formula.", + related_name="formulas", + to="counter.product", + verbose_name="products", + ), + ), + ( + "result", + models.OneToOneField( + help_text="The formula product.", + on_delete=django.db.models.deletion.CASCADE, + to="counter.product", + verbose_name="result product", + ), + ), + ], + ), + ] diff --git a/counter/models.py b/counter/models.py index 44101d42..dbd1e7f0 100644 --- a/counter/models.py +++ b/counter/models.py @@ -456,6 +456,24 @@ class Product(models.Model): return self.selling_price - self.purchase_price +class ProductFormula(models.Model): + products = models.ManyToManyField( + Product, + related_name="formulas", + verbose_name=_("products"), + help_text=_("The products that constitute this formula."), + ) + result = models.OneToOneField( + Product, + on_delete=models.CASCADE, + verbose_name=_("result product"), + help_text=_("The product got with the formula."), + ) + + def __str__(self): + return self.result.name + + class CounterQuerySet(models.QuerySet): def annotate_has_barman(self, user: User) -> Self: """Annotate the queryset with the `user_is_barman` field. From b03346c7331d585a95542b3e927ea8d8e0628b0f Mon Sep 17 00:00:00 2001 From: imperosol Date: Wed, 26 Nov 2025 13:22:43 +0100 Subject: [PATCH 2/8] product formulas management views --- counter/forms.py | 26 ++++++++++- counter/templates/counter/formula_list.jinja | 35 +++++++++++++++ counter/urls.py | 22 ++++++++++ counter/views/admin.py | 45 ++++++++++++++++++++ counter/views/mixins.py | 5 +++ 5 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 counter/templates/counter/formula_list.jinja diff --git a/counter/forms.py b/counter/forms.py index ed47232a..43f94720 100644 --- a/counter/forms.py +++ b/counter/forms.py @@ -5,6 +5,7 @@ from datetime import date, datetime, timezone from dateutil.relativedelta import relativedelta from django import forms +from django.core.exceptions import ValidationError from django.db.models import Exists, OuterRef, Q from django.forms import BaseModelFormSet from django.utils.timezone import now @@ -34,6 +35,7 @@ from counter.models import ( Eticket, InvoiceCall, Product, + ProductFormula, Refilling, ReturnableProduct, ScheduledProductAction, @@ -349,13 +351,33 @@ class ProductForm(forms.ModelForm): return product +class ProductFormulaForm(forms.ModelForm): + class Meta: + model = ProductFormula + fields = ["products", "result"] + widgets = { + "products": AutoCompleteSelectMultipleProduct, + "result": AutoCompleteSelectProduct, + } + + def clean(self): + cleaned_data = super().clean() + prices = [p.selling_price for p in cleaned_data["products"]] + selling_price = cleaned_data["result"].selling_price + if selling_price > sum(prices): + raise ValidationError( + _("This formula is more expensive than its constituant products.") + ) + return cleaned_data + + class ReturnableProductForm(forms.ModelForm): class Meta: model = ReturnableProduct fields = ["product", "returned_product", "max_return"] widgets = { - "product": AutoCompleteSelectProduct(), - "returned_product": AutoCompleteSelectProduct(), + "product": AutoCompleteSelectProduct, + "returned_product": AutoCompleteSelectProduct, } def save(self, commit: bool = True) -> ReturnableProduct: # noqa FBT diff --git a/counter/templates/counter/formula_list.jinja b/counter/templates/counter/formula_list.jinja new file mode 100644 index 00000000..dab19302 --- /dev/null +++ b/counter/templates/counter/formula_list.jinja @@ -0,0 +1,35 @@ +{% extends "core/base.jinja" %} + +{% block title %} + {% trans %}Product formulas{% endtrans %} +{% endblock %} + +{% block additional_css %} + + +{% endblock %} + +{% block content %} +
+

{% trans %}Product formulas{% endtrans %}

+

+ + {% trans %}New formula{% endtrans %} + + +

+ + +
+{% endblock %} diff --git a/counter/urls.py b/counter/urls.py index 67c7d950..9637ecd0 100644 --- a/counter/urls.py +++ b/counter/urls.py @@ -25,6 +25,10 @@ from counter.views.admin import ( CounterStatView, ProductCreateView, ProductEditView, + ProductFormulaCreateView, + ProductFormulaDeleteView, + ProductFormulaEditView, + ProductFormulaListView, ProductListView, ProductTypeCreateView, ProductTypeEditView, @@ -116,6 +120,24 @@ urlpatterns = [ ProductEditView.as_view(), name="product_edit", ), + path( + "admin/formula/", ProductFormulaListView.as_view(), name="product_formula_list" + ), + path( + "admin/formula/new/", + ProductFormulaCreateView.as_view(), + name="product_formula_create", + ), + path( + "admin/formula//edit", + ProductFormulaEditView.as_view(), + name="product_formula_edit", + ), + path( + "admin/formula//delete", + ProductFormulaDeleteView.as_view(), + name="product_formula_delete", + ), path( "admin/product-type/list/", ProductTypeListView.as_view(), diff --git a/counter/views/admin.py b/counter/views/admin.py index 24c112f5..ad48c435 100644 --- a/counter/views/admin.py +++ b/counter/views/admin.py @@ -34,11 +34,13 @@ from counter.forms import ( CloseCustomerAccountForm, CounterEditForm, ProductForm, + ProductFormulaForm, ReturnableProductForm, ) from counter.models import ( Counter, Product, + ProductFormula, ProductType, Refilling, ReturnableProduct, @@ -162,6 +164,49 @@ class ProductEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView): current_tab = "products" +class ProductFormulaListView(CounterAdminTabsMixin, PermissionRequiredMixin, ListView): + model = ProductFormula + queryset = ProductFormula.objects.select_related("result") + template_name = "counter/formula_list.jinja" + current_tab = "formulas" + permission_required = "counter.view_productformula" + + +class ProductFormulaCreateView( + CounterAdminTabsMixin, PermissionRequiredMixin, CreateView +): + model = ProductFormula + form_class = ProductFormulaForm + pk_url_kwarg = "formula_id" + template_name = "core/create.jinja" + current_tab = "formulas" + success_url = reverse_lazy("counter:product_formula_list") + permission_required = "counter.add_productformula" + + +class ProductFormulaEditView( + CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView +): + model = ProductFormula + form_class = ProductFormulaForm + pk_url_kwarg = "formula_id" + template_name = "core/edit.jinja" + current_tab = "formulas" + success_url = reverse_lazy("counter:product_formula_list") + permission_required = "counter.change_productformula" + + +class ProductFormulaDeleteView( + CounterAdminTabsMixin, PermissionRequiredMixin, DeleteView +): + model = ProductFormula + pk_url_kwarg = "formula_id" + template_name = "core/delete_confirm.jinja" + current_tab = "formulas" + success_url = reverse_lazy("counter:product_formula_list") + permission_required = "counter.delete_productformula" + + class ReturnableProductListView( CounterAdminTabsMixin, PermissionRequiredMixin, ListView ): diff --git a/counter/views/mixins.py b/counter/views/mixins.py index c7fabdd6..2cce25b4 100644 --- a/counter/views/mixins.py +++ b/counter/views/mixins.py @@ -100,6 +100,11 @@ class CounterAdminTabsMixin(TabedViewMixin): "slug": "products", "name": _("Products"), }, + { + "url": reverse_lazy("counter:product_formula_list"), + "slug": "formulas", + "name": _("Formulas"), + }, { "url": reverse_lazy("counter:product_type_list"), "slug": "product_types", From 4e73f103d8a7e0eddc54486f7aeebe8b387c224f Mon Sep 17 00:00:00 2001 From: imperosol Date: Wed, 26 Nov 2025 15:53:16 +0100 Subject: [PATCH 3/8] automatically apply formulas on click --- .../bundled/counter/counter-click-index.ts | 49 ++++++++++++++----- counter/static/bundled/counter/types.d.ts | 6 +++ counter/static/counter/css/counter-click.scss | 4 +- counter/templates/counter/counter_click.jinja | 29 +++++++---- counter/views/click.py | 11 +++-- 5 files changed, 72 insertions(+), 27 deletions(-) diff --git a/counter/static/bundled/counter/counter-click-index.ts b/counter/static/bundled/counter/counter-click-index.ts index 6d582b20..3e940bf8 100644 --- a/counter/static/bundled/counter/counter-click-index.ts +++ b/counter/static/bundled/counter/counter-click-index.ts @@ -1,6 +1,10 @@ import { AlertMessage } from "#core:utils/alert-message.ts"; import { BasketItem } from "#counter:counter/basket.ts"; -import type { CounterConfig, ErrorMessage } from "#counter:counter/types.ts"; +import type { + CounterConfig, + ErrorMessage, + ProductFormula, +} from "#counter:counter/types.ts"; import type { CounterProductSelect } from "./components/counter-product-select-index.ts"; document.addEventListener("alpine:init", () => { @@ -47,15 +51,43 @@ document.addEventListener("alpine:init", () => { this.basket[id] = item; + this.checkFormulas(); + if (this.sumBasket() > this.customerBalance) { item.quantity = oldQty; if (item.quantity === 0) { delete this.basket[id]; } - return gettext("Not enough money"); + this.alertMessage.display(gettext("Not enough money"), { success: false }); } + }, - return ""; + checkFormulas() { + const products = new Set( + Object.keys(this.basket).map((i: string) => Number.parseInt(i)), + ); + const formula: ProductFormula = config.formulas.find((f: ProductFormula) => { + return f.products.every((p: number) => products.has(p)); + }); + if (formula === undefined) { + return; + } + for (const product of formula.products) { + const key = product.toString(); + this.basket[key].quantity -= 1; + if (this.basket[key].quantity <= 0) { + this.removeFromBasket(key); + } + } + this.alertMessage.display( + interpolate( + gettext("Formula %(formula)s applied"), + { formula: config.products[formula.result.toString()].name }, + true, + ), + { success: true }, + ); + this.addToBasket(formula.result.toString(), 1); }, getBasketSize() { @@ -70,14 +102,7 @@ document.addEventListener("alpine:init", () => { (acc: number, cur: BasketItem) => acc + cur.sum(), 0, ) as number; - return total; - }, - - addToBasketWithMessage(id: string, quantity: number) { - const message = this.addToBasket(id, quantity); - if (message.length > 0) { - this.alertMessage.display(message, { success: false }); - } + return Math.round(total * 100) / 100; }, onRefillingSuccess(event: CustomEvent) { @@ -116,7 +141,7 @@ document.addEventListener("alpine:init", () => { this.finish(); } } else { - this.addToBasketWithMessage(code, quantity); + this.addToBasket(code, quantity); } this.codeField.widget.clear(); this.codeField.widget.focus(); diff --git a/counter/static/bundled/counter/types.d.ts b/counter/static/bundled/counter/types.d.ts index 18fea258..330b6f0e 100644 --- a/counter/static/bundled/counter/types.d.ts +++ b/counter/static/bundled/counter/types.d.ts @@ -7,10 +7,16 @@ export interface InitialFormData { errors?: string[]; } +export interface ProductFormula { + result: number; + products: number[]; +} + export interface CounterConfig { customerBalance: number; customerId: number; products: Record; + formulas: ProductFormula[]; formInitial: InitialFormData[]; cancelUrl: string; } diff --git a/counter/static/counter/css/counter-click.scss b/counter/static/counter/css/counter-click.scss index 478fa32c..6288f902 100644 --- a/counter/static/counter/css/counter-click.scss +++ b/counter/static/counter/css/counter-click.scss @@ -10,12 +10,12 @@ float: right; } -.basket-error-container { +.basket-message-container { position: relative; display: block } -.basket-error { +.basket-message { z-index: 10; // to get on top of tomselect text-align: center; position: absolute; diff --git a/counter/templates/counter/counter_click.jinja b/counter/templates/counter/counter_click.jinja index dc38d8a3..07bfd461 100644 --- a/counter/templates/counter/counter_click.jinja +++ b/counter/templates/counter/counter_click.jinja @@ -32,13 +32,11 @@
-
{% trans %}Customer{% endtrans %}
@@ -88,11 +86,12 @@
-
+
@@ -111,9 +110,9 @@
- + - + : @@ -213,7 +212,7 @@
{{ category }}
{% for product in categories[category] -%} - + {% endif %} + {%- endfor -%} - -
+
{% endblock %} diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 6f8bd246..c594b166 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -3589,10 +3589,44 @@ msgstr "Il n'y a pas de eticket sur ce site web." msgid "Product formulas" msgstr "Formules de produits" +#: counter/templates/counter/formula_list.jinja +msgid "" +"Formulas allow you to associate a group of products with a result product " +"(the formula itself)." +msgstr "" +"Les formules permettent d'associer un groupe de produits à un produit " +"résultat (la formule en elle-même)." + +#: counter/templates/counter/formula_list.jinja +msgid "" +"If the product of a formula is available on a counter, it will be " +"automatically applied if all the products that make it up are added to the " +"basket." +msgstr "" +"Si le produit d'une formule est disponible sur un comptoir, celle-ci sera " +"automatiquement appliquée si tous les produits qui la constituent sont " +"ajoutés au panier." + +#: counter/templates/counter/formula_list.jinja +msgid "" +"For example, if there is a formula that combines a \"Sandwich Formula\" " +"product with the \"Sandwich\" and \"Soft Drink\" products, then, if a person " +"orders a sandwich and a soft drink, the formula will be applied and the " +"basket will then contain a sandwich formula instead." +msgstr "" +"Par exemple s'il existe une formule associant un produit « Formule " +"sandwich » aux produits « Sandwich » et « Soft », alors, si une personne " +"commande un sandwich et un soft, la formule sera appliquée et le panier " +"contiendra alors une formule sandwich à la place." + #: counter/templates/counter/formula_list.jinja msgid "New formula" msgstr "Nouvelle formule" +#: counter/templates/counter/formula_list.jinja +msgid "instead of" +msgstr "au lieu de" + #: counter/templates/counter/fragments/create_student_card.jinja msgid "No student card registered." msgstr "Aucune carte étudiante enregistrée."