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 django import forms
from django.core.validators import MaxValueValidator
from django.db.models import Exists, OuterRef, Q
from django.forms import BaseModelFormSet
from django.utils.timezone import now
@@ -363,25 +362,6 @@ class ProductForm(forms.ModelForm):
*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):
return (
super().is_valid()
@@ -424,18 +404,6 @@ class ProductFormulaForm(forms.ModelForm):
"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

View File

@@ -448,10 +448,6 @@ class Product(models.Model):
return True
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):
def for_user(self, user: User) -> Self:
@@ -543,18 +539,6 @@ class ProductFormula(models.Model):
def __str__(self):
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):
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 {
quantity: number;
product: Product;
product: CounterItem;
errors: string[];
constructor(product: Product, quantity: number) {
constructor(product: CounterItem, quantity: number) {
this.quantity = quantity;
this.product = product;
this.errors = [];

View File

@@ -2,6 +2,7 @@ import { AlertMessage } from "#core:utils/alert-message";
import { BasketItem } from "#counter:counter/basket";
import type {
CounterConfig,
CounterItem,
ErrorMessage,
ProductFormula,
} from "#counter:counter/types";
@@ -63,8 +64,10 @@ document.addEventListener("alpine:init", () => {
},
checkFormulas() {
// Try to find a formula.
// A formula is found if all its elements are already in the basket
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) => {
return f.products.every((p: number) => products.has(p));
@@ -72,22 +75,29 @@ document.addEventListener("alpine:init", () => {
if (formula === undefined) {
return;
}
// Now that the formula is found, remove the items composing it from the basket
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;
if (this.basket[key].quantity <= 0) {
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(
interpolate(
gettext("Formula %(formula)s applied"),
{ formula: config.products[formula.result.toString()].name },
{ formula: result.name },
true,
),
{ success: true },
);
this.addToBasket(formula.result.toString(), 1);
},
getBasketSize() {

View File

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

View File

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

View File

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

View File

@@ -54,7 +54,12 @@
</div>
<div class="form-group">
<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>
{%- if form.DELETE -%}

View File

@@ -7,12 +7,7 @@ from counter.forms import ProductFormulaForm
class TestFormulaForm(TestCase):
@classmethod
def setUpTestData(cls):
cls.products = product_recipe.make(
selling_price=iter([1.5, 1, 1]),
special_selling_price=iter([1.4, 0.9, 0.9]),
_quantity=3,
_bulk_create=True,
)
cls.products = product_recipe.make(_quantity=3, _bulk_create=True)
def test_ok(self):
form = ProductFormulaForm(
@@ -26,23 +21,6 @@ class TestFormulaForm(TestCase):
assert formula.result == self.products[0]
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):
form = ProductFormulaForm(
data={

View File

@@ -73,7 +73,7 @@ class CounterClick(
kwargs["form_kwargs"] = {
"customer": self.customer,
"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
@@ -103,7 +103,7 @@ class CounterClick(
):
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)
@@ -206,12 +206,12 @@ class CounterClick(
def get_context_data(self, **kwargs):
"""Add customer to the context."""
kwargs = super().get_context_data(**kwargs)
kwargs["products"] = self.products
kwargs["prices"] = self.prices
kwargs["formulas"] = ProductFormula.objects.filter(
result__in=self.products
result__in=[p.product_id for p in self.prices]
).prefetch_related("products")
kwargs["categories"] = defaultdict(list)
for price in kwargs["products"]:
for price in self.prices:
kwargs["categories"][price.product.product_type].append(price)
kwargs["customer"] = self.customer
kwargs["cancel_url"] = self.get_success_url()

View File

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