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 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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 = [];
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
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 {
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 -%}
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user