adapt formulas to new price system

This commit is contained in:
imperosol
2026-03-06 15:27:22 +01:00
parent 680dc44486
commit 0f1660ad79
11 changed files with 39 additions and 97 deletions

View File

@@ -6,7 +6,6 @@ from datetime import date, datetime, timezone
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django import forms from django import forms
from django.core.validators import MaxValueValidator
from django.db.models import Exists, OuterRef, Q from django.db.models import Exists, OuterRef, Q
from django.forms import BaseModelFormSet from django.forms import BaseModelFormSet
from django.utils.timezone import now from django.utils.timezone import now
@@ -363,25 +362,6 @@ class ProductForm(forms.ModelForm):
*args, product=self.instance, prefix="action", **kwargs *args, product=self.instance, prefix="action", **kwargs
) )
def formula_init(self, formula: ProductFormula):
"""Part of the form initialisation specific to formula products."""
self.fields["selling_price"].help_text = _(
"This product is a formula. "
"Its price cannot be greater than the price "
"of the products constituting it, which is %(price)s"
) % {"price": formula.max_selling_price}
self.fields["special_selling_price"].help_text = _(
"This product is a formula. "
"Its special price cannot be greater than the price "
"of the products constituting it, which is %(price)s"
) % {"price": formula.max_special_selling_price}
for key, price in (
("selling_price", formula.max_selling_price),
("special_selling_price", formula.max_special_selling_price),
):
self.fields[key].widget.attrs["max"] = price
self.fields[key].validators.append(MaxValueValidator(price))
def is_valid(self): def is_valid(self):
return ( return (
super().is_valid() super().is_valid()
@@ -424,18 +404,6 @@ class ProductFormulaForm(forms.ModelForm):
"the result and a part of the formula." "the result and a part of the formula."
), ),
) )
prices = [p.selling_price for p in cleaned_data["products"]]
special_prices = [p.special_selling_price for p in cleaned_data["products"]]
selling_price = cleaned_data["result"].selling_price
special_selling_price = cleaned_data["result"].special_selling_price
if selling_price > sum(prices) or special_selling_price > sum(special_prices):
self.add_error(
"result",
_(
"The result cannot be more expensive "
"than the total of the other products."
),
)
return cleaned_data return cleaned_data

View File

@@ -448,10 +448,6 @@ class Product(models.Model):
return True return True
return any(user.is_in_group(pk=group.id) for group in buying_groups) return any(user.is_in_group(pk=group.id) for group in buying_groups)
@property
def profit(self):
return self.selling_price - self.purchase_price
class PriceQuerySet(models.QuerySet): class PriceQuerySet(models.QuerySet):
def for_user(self, user: User) -> Self: def for_user(self, user: User) -> Self:
@@ -543,18 +539,6 @@ class ProductFormula(models.Model):
def __str__(self): def __str__(self):
return self.result.name return self.result.name
@cached_property
def max_selling_price(self) -> float:
# iterating over all products is less efficient than doing
# a simple aggregation, but this method is likely to be used in
# coordination with `max_special_selling_price`,
# and Django caches the result of the `all` queryset.
return sum(p.selling_price for p in self.products.all())
@cached_property
def max_special_selling_price(self) -> float:
return sum(p.special_selling_price for p in self.products.all())
class CounterQuerySet(models.QuerySet): class CounterQuerySet(models.QuerySet):
def annotate_has_barman(self, user: User) -> Self: def annotate_has_barman(self, user: User) -> Self:

View File

@@ -1,11 +1,11 @@
import type { Product } from "#counter:counter/types"; import type { CounterItem } from "#counter:counter/types";
export class BasketItem { export class BasketItem {
quantity: number; quantity: number;
product: Product; product: CounterItem;
errors: string[]; errors: string[];
constructor(product: Product, quantity: number) { constructor(product: CounterItem, quantity: number) {
this.quantity = quantity; this.quantity = quantity;
this.product = product; this.product = product;
this.errors = []; this.errors = [];

View File

@@ -2,6 +2,7 @@ import { AlertMessage } from "#core:utils/alert-message";
import { BasketItem } from "#counter:counter/basket"; import { BasketItem } from "#counter:counter/basket";
import type { import type {
CounterConfig, CounterConfig,
CounterItem,
ErrorMessage, ErrorMessage,
ProductFormula, ProductFormula,
} from "#counter:counter/types"; } from "#counter:counter/types";
@@ -63,8 +64,10 @@ document.addEventListener("alpine:init", () => {
}, },
checkFormulas() { checkFormulas() {
// Try to find a formula.
// A formula is found if all its elements are already in the basket
const products = new Set( const products = new Set(
Object.keys(this.basket).map((i: string) => Number.parseInt(i)), Object.values(this.basket).map((item: BasketItem) => item.product.productId),
); );
const formula: ProductFormula = config.formulas.find((f: ProductFormula) => { const formula: ProductFormula = config.formulas.find((f: ProductFormula) => {
return f.products.every((p: number) => products.has(p)); return f.products.every((p: number) => products.has(p));
@@ -72,22 +75,29 @@ document.addEventListener("alpine:init", () => {
if (formula === undefined) { if (formula === undefined) {
return; return;
} }
// Now that the formula is found, remove the items composing it from the basket
for (const product of formula.products) { for (const product of formula.products) {
const key = product.toString(); const key = Object.entries(this.basket).find(
([_, i]: [string, BasketItem]) => i.product.productId === product,
)[0];
this.basket[key].quantity -= 1; this.basket[key].quantity -= 1;
if (this.basket[key].quantity <= 0) { if (this.basket[key].quantity <= 0) {
this.removeFromBasket(key); this.removeFromBasket(key);
} }
} }
// Then add the result product of the formula to the basket
const result = Object.values(config.products)
.filter((item: CounterItem) => item.productId === formula.result)
.reduce((acc, curr) => (acc.price.amount < curr.price.amount ? acc : curr));
this.addToBasket(result.price.id, 1);
this.alertMessage.display( this.alertMessage.display(
interpolate( interpolate(
gettext("Formula %(formula)s applied"), gettext("Formula %(formula)s applied"),
{ formula: config.products[formula.result.toString()].name }, { formula: result.name },
true, true,
), ),
{ success: true }, { success: true },
); );
this.addToBasket(formula.result.toString(), 1);
}, },
getBasketSize() { getBasketSize() {

View File

@@ -2,7 +2,7 @@ export type ErrorMessage = string;
export interface InitialFormData { export interface InitialFormData {
/* Used to refill the form when the backend raises an error */ /* Used to refill the form when the backend raises an error */
id?: keyof Record<string, Product>; id?: keyof Record<string, CounterItem>;
quantity?: number; quantity?: number;
errors?: string[]; errors?: string[];
} }
@@ -15,7 +15,7 @@ export interface ProductFormula {
export interface CounterConfig { export interface CounterConfig {
customerBalance: number; customerBalance: number;
customerId: number; customerId: number;
products: Record<string, Product>; products: Record<string, CounterItem>;
formulas: ProductFormula[]; formulas: ProductFormula[];
formInitial: InitialFormData[]; formInitial: InitialFormData[];
cancelUrl: string; cancelUrl: string;
@@ -26,10 +26,11 @@ interface Price {
amount: number; amount: number;
} }
export interface Product { export interface CounterItem {
productId: number;
price: Price;
code: string; code: string;
name: string; name: string;
price: Price;
hasTrayPrice: boolean; hasTrayPrice: boolean;
quantityForTrayPrice: number; quantityForTrayPrice: number;
} }

View File

@@ -202,7 +202,7 @@
</div> </div>
<div id="products"> <div id="products">
{% if not products %} {% if not prices %}
<div class="alert alert-red"> <div class="alert alert-red">
{% trans %}No products available on this counter for this user{% endtrans %} {% trans %}No products available on this counter for this user{% endtrans %}
</div> </div>
@@ -242,9 +242,11 @@
{{ super() }} {{ super() }}
<script> <script>
const products = { const products = {
{%- for price in products -%} {%- for price in prices -%}
{{ price.id }}: { {{ price.id }}: {
productId: {{ price.product_id }},
price: { id: "{{ price.id }}", amount: {{ price.amount }} }, price: { id: "{{ price.id }}", amount: {{ price.amount }} },
code: "{{ price.product.code }}",
name: "{{ price.full_label }}", name: "{{ price.full_label }}",
hasTrayPrice: {{ price.product.tray | tojson }}, hasTrayPrice: {{ price.product.tray | tojson }},
quantityForTrayPrice: {{ price.product.QUANTITY_FOR_TRAY_PRICE }}, quantityForTrayPrice: {{ price.product.QUANTITY_FOR_TRAY_PRICE }},

View File

@@ -49,14 +49,10 @@
<strong class="card-title">{{ formula.result.name }}</strong> <strong class="card-title">{{ formula.result.name }}</strong>
<p> <p>
{% for p in formula.products.all() %} {% for p in formula.products.all() %}
<i>{{ p.code }} ({{ p.selling_price }})</i> <i>{{ p.name }} ({{ p.code }})</i>
{% if not loop.last %}+{% endif %} {% if not loop.last %}+{% endif %}
{% endfor %} {% endfor %}
</p> </p>
<p>
{{ formula.result.selling_price }}
({% trans %}instead of{% endtrans %} {{ formula.max_selling_price}} €)
</p>
</div> </div>
{% if user.has_perm("counter.delete_productformula") %} {% if user.has_perm("counter.delete_productformula") %}
<button <button

View File

@@ -54,7 +54,12 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<div> <div>
{{ form.is_always_shown.as_field_group() }} {{ form.is_always_shown.errors }}
<div class="row gap">
{{ form.is_always_shown }}
<label for="{{ form.is_always_shown.id_for_label }}">{{ form.is_always_shown.label }}</label>
</div>
<span class="helptext">{{ form.is_always_shown.help_text }}</span>
</div> </div>
</div> </div>
{%- if form.DELETE -%} {%- if form.DELETE -%}

View File

@@ -7,12 +7,7 @@ from counter.forms import ProductFormulaForm
class TestFormulaForm(TestCase): class TestFormulaForm(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
cls.products = product_recipe.make( cls.products = product_recipe.make(_quantity=3, _bulk_create=True)
selling_price=iter([1.5, 1, 1]),
special_selling_price=iter([1.4, 0.9, 0.9]),
_quantity=3,
_bulk_create=True,
)
def test_ok(self): def test_ok(self):
form = ProductFormulaForm( form = ProductFormulaForm(
@@ -26,23 +21,6 @@ class TestFormulaForm(TestCase):
assert formula.result == self.products[0] assert formula.result == self.products[0]
assert set(formula.products.all()) == set(self.products[1:]) assert set(formula.products.all()) == set(self.products[1:])
def test_price_invalid(self):
self.products[0].selling_price = 2.1
self.products[0].save()
form = ProductFormulaForm(
data={
"result": self.products[0].id,
"products": [self.products[1].id, self.products[2].id],
}
)
assert not form.is_valid()
assert form.errors == {
"result": [
"Le résultat ne peut pas être plus cher "
"que le total des autres produits."
]
}
def test_product_both_in_result_and_products(self): def test_product_both_in_result_and_products(self):
form = ProductFormulaForm( form = ProductFormulaForm(
data={ data={

View File

@@ -73,7 +73,7 @@ class CounterClick(
kwargs["form_kwargs"] = { kwargs["form_kwargs"] = {
"customer": self.customer, "customer": self.customer,
"counter": self.object, "counter": self.object,
"allowed_prices": {price.id: price for price in self.products}, "allowed_prices": {price.id: price for price in self.prices},
} }
return kwargs return kwargs
@@ -103,7 +103,7 @@ class CounterClick(
): ):
return redirect(obj) # Redirect to counter return redirect(obj) # Redirect to counter
self.products = obj.get_prices_for(self.customer) self.prices = obj.get_prices_for(self.customer)
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
@@ -206,12 +206,12 @@ class CounterClick(
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
"""Add customer to the context.""" """Add customer to the context."""
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
kwargs["products"] = self.products kwargs["prices"] = self.prices
kwargs["formulas"] = ProductFormula.objects.filter( kwargs["formulas"] = ProductFormula.objects.filter(
result__in=self.products result__in=[p.product_id for p in self.prices]
).prefetch_related("products") ).prefetch_related("products")
kwargs["categories"] = defaultdict(list) kwargs["categories"] = defaultdict(list)
for price in kwargs["products"]: for price in self.prices:
kwargs["categories"][price.product.product_type].append(price) kwargs["categories"][price.product.product_type].append(price)
kwargs["customer"] = self.customer kwargs["customer"] = self.customer
kwargs["cancel_url"] = self.get_success_url() kwargs["cancel_url"] = self.get_success_url()

View File

@@ -33,8 +33,6 @@ class TestMergeUser(TestCase):
cls.club = baker.make(Club) cls.club = baker.make(Club)
cls.eboutic = Counter.objects.get(name="Eboutic") cls.eboutic = Counter.objects.get(name="Eboutic")
cls.barbar = Product.objects.get(code="BARB") cls.barbar = Product.objects.get(code="BARB")
cls.barbar.selling_price = 2
cls.barbar.save()
cls.root = User.objects.get(username="root") cls.root = User.objects.get(username="root")
cls.to_keep = User.objects.create( cls.to_keep = User.objects.create(
username="to_keep", password="plop", email="u.1@utbm.fr" username="to_keep", password="plop", email="u.1@utbm.fr"