mirror of
https://github.com/ae-utbm/sith.git
synced 2026-03-13 15:15:03 +00:00
adapt formulas to new price system
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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() {
|
||||
|
||||
9
counter/static/bundled/counter/types.d.ts
vendored
9
counter/static/bundled/counter/types.d.ts
vendored
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 }},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 -%}
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user