diff --git a/bober_bbq/admin/routes.py b/bober_bbq/admin/routes.py
index 054601c..a53df2c 100644
--- a/bober_bbq/admin/routes.py
+++ b/bober_bbq/admin/routes.py
@@ -471,9 +471,12 @@ def products_new():
if request.method == "POST":
product = Product(category_id=request.form.get("category_id", type=int))
_apply_product_form(product)
+ if product.pricing_mode == "per_100g" and not product.reference_weight_g:
+ flash("Вкажіть орієнтовну вагу порції для товару з ціною за 100 г", "error")
+ return redirect(url_for("admin.products_new"))
db.session.add(product)
db.session.commit()
- log_action("product_create", f"Додано товар «{product.name}» ({product.price} грн)")
+ log_action("product_create", f"Додано товар «{product.name}» ({product.effective_unit_price} грн)")
flash("Товар додано", "success")
return redirect(url_for("admin.products_list"))
return render_template("admin/product_form.html", product=None, categories=categories, allergens=ALLERGENS)
@@ -487,6 +490,10 @@ def products_edit(product_id):
if request.method == "POST":
product.category_id = request.form.get("category_id", type=int)
_apply_product_form(product)
+ if product.pricing_mode == "per_100g" and not product.reference_weight_g:
+ flash("Вкажіть орієнтовну вагу порції для товару з ціною за 100 г", "error")
+ db.session.rollback()
+ return redirect(url_for("admin.products_edit", product_id=product_id))
db.session.commit()
log_action("product_edit", f"Оновлено товар «{product.name}»")
flash("Товар оновлено", "success")
@@ -524,6 +531,10 @@ def _apply_product_form(product: Product):
product.weight = request.form.get("weight", "").strip()
product.weight_en = request.form.get("weight_en", "").strip() or None
product.price = request.form.get("price", type=int) or 0
+ pricing_mode = request.form.get("pricing_mode", "fixed")
+ product.pricing_mode = pricing_mode if pricing_mode in ("fixed", "per_100g") else "fixed"
+ ref_weight = request.form.get("reference_weight_g", type=int)
+ product.reference_weight_g = ref_weight if product.pricing_mode == "per_100g" and ref_weight and ref_weight > 0 else None
product.sort_order = request.form.get("sort_order", type=int) or 0
product.is_active = bool(request.form.get("is_active"))
product.allergens = ",".join(a for a in request.form.getlist("allergens") if a in ALLERGENS)
@@ -554,9 +565,10 @@ def orders_new():
products_by_id = {p.id: p for p in Product.query.filter_by(is_active=True).all()}
product_ids = request.form.getlist("product_id")
quantities = request.form.getlist("quantity")
+ unit_prices = request.form.getlist("unit_price")
items = []
seen_ids = set()
- for raw_id, raw_qty in zip(product_ids, quantities):
+ for raw_id, raw_qty, raw_price in zip(product_ids, quantities, unit_prices):
if not raw_id:
continue
try:
@@ -567,8 +579,17 @@ def orders_new():
product = products_by_id.get(pid)
if not product or qty <= 0 or pid in seen_ids:
continue
+ # unit_price lets staff override what's actually charged (e.g.
+ # after weighing a per_100g item) — falls back to the product's
+ # own price when left blank or entered invalid.
+ try:
+ unit_price = int(raw_price)
+ if unit_price < 0:
+ unit_price = product.effective_unit_price
+ except (TypeError, ValueError):
+ unit_price = product.effective_unit_price
seen_ids.add(pid)
- items.append((product, qty))
+ items.append((product, qty, unit_price))
if not items:
flash("Додайте хоча б один товар", "error")
@@ -635,9 +656,10 @@ def orders_edit(order_id):
}
product_ids = request.form.getlist("product_id")
quantities = request.form.getlist("quantity")
+ unit_prices = request.form.getlist("unit_price")
items = []
seen_ids = set()
- for raw_id, raw_qty in zip(product_ids, quantities):
+ for raw_id, raw_qty, raw_price in zip(product_ids, quantities, unit_prices):
if not raw_id:
continue
try:
@@ -648,8 +670,20 @@ def orders_edit(order_id):
product = products_by_id.get(pid)
if not product or qty <= 0 or pid in seen_ids:
continue
+ # unit_price is whatever staff actually typed for this line —
+ # falls back to the product's own price only when left blank or
+ # invalid, NOT unconditionally re-snapshotted from the live
+ # catalog price like this used to do (that silently reset every
+ # line's charged price on any unrelated edit, e.g. fixing a
+ # phone number).
+ try:
+ unit_price = int(raw_price)
+ if unit_price < 0:
+ unit_price = product.effective_unit_price
+ except (TypeError, ValueError):
+ unit_price = product.effective_unit_price
seen_ids.add(pid)
- items.append((product, qty))
+ items.append((product, qty, unit_price))
if not items:
flash("Додайте хоча б один товар", "error")
@@ -672,11 +706,11 @@ def orders_edit(order_id):
OrderItem.query.filter_by(order_id=order.id).delete()
db.session.flush()
items_total = 0
- for product, qty in items:
+ for product, qty, unit_price in items:
db.session.add(
- OrderItem(order_id=order.id, product_id=product.id, product_name=product.name, product_price=product.price, quantity=qty)
+ OrderItem(order_id=order.id, product_id=product.id, product_name=product.name, product_price=unit_price, quantity=qty)
)
- items_total += product.price * qty
+ items_total += unit_price * qty
if order_type == "delivery":
delivery_fee = calc_delivery_fee(items_total)
diff --git a/bober_bbq/migrate.py b/bober_bbq/migrate.py
index 9d75f06..d0005e2 100644
--- a/bober_bbq/migrate.py
+++ b/bober_bbq/migrate.py
@@ -58,3 +58,5 @@ def migrate() -> None:
_add_column_if_missing("products", "description_en", "description_en TEXT")
_add_column_if_missing("products", "weight_en", "weight_en VARCHAR(32)")
_add_column_if_missing("orders", "tracking_token", "tracking_token VARCHAR(32)")
+ _add_column_if_missing("products", "pricing_mode", "pricing_mode VARCHAR(16) DEFAULT 'fixed'")
+ _add_column_if_missing("products", "reference_weight_g", "reference_weight_g INTEGER")
diff --git a/bober_bbq/models.py b/bober_bbq/models.py
index c992910..95b9c56 100644
--- a/bober_bbq/models.py
+++ b/bober_bbq/models.py
@@ -133,12 +133,36 @@ class Product(db.Model):
description_en = db.Column(db.Text)
weight = db.Column(db.String(32), default="") # e.g. "300 г" or "0.5 л"
weight_en = db.Column(db.String(32)) # e.g. "300 g" or "0.5 l"
- price = db.Column(db.Integer, nullable=False) # UAH, whole hryvnias
+ price = db.Column(db.Integer, nullable=False) # UAH; per-unit if pricing_mode=="fixed", per 100g if "per_100g"
+ # "fixed" (default): price is a flat per-unit charge, as this column has
+ # always meant. "per_100g": price means "per 100 grams" instead — for
+ # items like grilled shashlik whose cooked weight varies, so a flat
+ # per-unit price isn't accurate. reference_weight_g (e.g. 300) is only
+ # meaningful in that mode, used to compute a customer-facing estimate.
+ pricing_mode = db.Column(db.String(16), default="fixed", nullable=False)
+ reference_weight_g = db.Column(db.Integer)
image_url = db.Column(db.String(256), default="")
is_active = db.Column(db.Boolean, default=True, nullable=False)
sort_order = db.Column(db.Integer, default=0, nullable=False)
allergens = db.Column(db.String(256), default="") # comma-separated ALLERGENS keys
+ @property
+ def effective_unit_price(self) -> int:
+ """What to actually charge/display per unit — every cart/order call
+ site should read this instead of `price` directly. For "fixed" it's
+ just `price`, unchanged from before this field existed. For
+ "per_100g" it's an estimate (price-per-100g * reference_weight_g /
+ 100, rounded) — the real amount is only known once the item is
+ physically weighed, at which point staff can override it directly
+ on the order (see admin/routes.py's orders_new/orders_edit)."""
+ if self.pricing_mode == "per_100g" and self.reference_weight_g:
+ return round(self.price * self.reference_weight_g / 100)
+ return self.price
+
+ @property
+ def is_estimated_price(self) -> bool:
+ return self.pricing_mode == "per_100g"
+
@property
def allergen_codes(self) -> list[str]:
return [c for c in (self.allergens or "").split(",") if c]
@@ -159,7 +183,8 @@ class Product(db.Model):
"name": self.name_en if use_en and self.name_en else self.name,
"description": (self.description_en if use_en and self.description_en else self.description) or "",
"weight": (self.weight_en if use_en and self.weight_en else self.weight) or "",
- "price": self.price,
+ "price": self.effective_unit_price,
+ "is_estimated_price": self.is_estimated_price,
"image_url": self.image_url,
"allergens": self.allergen_details(locale),
}
@@ -367,7 +392,7 @@ class CartItem(db.Model):
@property
def line_total(self) -> int:
- return self.product.price * self.quantity
+ return self.product.effective_unit_price * self.quantity
def to_dict(self):
return {
@@ -375,7 +400,8 @@ class CartItem(db.Model):
"name": self.product.name,
"weight": self.product.weight,
"image_url": self.product.image_url,
- "price": self.product.price,
+ "price": self.product.effective_unit_price,
+ "is_estimated_price": self.product.is_estimated_price,
"quantity": self.quantity,
"line_total": self.line_total,
}
diff --git a/bober_bbq/templates/admin/order_edit.html b/bober_bbq/templates/admin/order_edit.html
index 33a42f5..64920b6 100644
--- a/bober_bbq/templates/admin/order_edit.html
+++ b/bober_bbq/templates/admin/order_edit.html
@@ -63,13 +63,14 @@
{% for item in order.items %}
-
{% endfor %}
@@ -122,13 +123,14 @@
-
+
{% for p in products if p.is_active %}
-
+
{% endfor %}
+
@@ -147,13 +149,23 @@
recalcTotal();
}
+ function onProductChange(select) {
+ var row = select.closest('.recipe-row');
+ var priceInput = row.querySelector('input[name="unit_price"]');
+ var option = select.options[select.selectedIndex];
+ var price = option ? (option.dataset.price || '0') : '0';
+ // Only auto-fill if this row's price hasn't been hand-edited yet, so
+ // switching products doesn't clobber a deliberate manual override.
+ if (!priceInput.dataset.touched) priceInput.value = price;
+ recalcTotal();
+ }
+
function recalcTotal() {
var total = 0;
document.querySelectorAll('#order-rows .recipe-row').forEach(function (row) {
- var select = row.querySelector('select[name="product_id"]');
+ var priceInput = row.querySelector('input[name="unit_price"]');
var qtyInput = row.querySelector('input[name="quantity"]');
- var option = select.options[select.selectedIndex];
- var price = option ? parseFloat(option.dataset.price || '0') : 0;
+ var price = parseFloat(priceInput.value || '0');
var qty = parseInt(qtyInput.value, 10) || 0;
total += price * qty;
});
diff --git a/bober_bbq/templates/admin/order_new.html b/bober_bbq/templates/admin/order_new.html
index 467c104..e344869 100644
--- a/bober_bbq/templates/admin/order_new.html
+++ b/bober_bbq/templates/admin/order_new.html
@@ -62,13 +62,14 @@
@@ -106,13 +107,14 @@
-
+
{% for p in products %}
-
+
{% endfor %}
+
@@ -131,13 +133,23 @@
recalcTotal();
}
+ function onProductChange(select) {
+ var row = select.closest('.recipe-row');
+ var priceInput = row.querySelector('input[name="unit_price"]');
+ var option = select.options[select.selectedIndex];
+ var price = option ? (option.dataset.price || '0') : '0';
+ // Only auto-fill if this row's price hasn't been hand-edited yet, so
+ // switching products doesn't clobber a deliberate manual override.
+ if (!priceInput.dataset.touched) priceInput.value = price;
+ recalcTotal();
+ }
+
function recalcTotal() {
var total = 0;
document.querySelectorAll('#order-rows .recipe-row').forEach(function (row) {
- var select = row.querySelector('select[name="product_id"]');
+ var priceInput = row.querySelector('input[name="unit_price"]');
var qtyInput = row.querySelector('input[name="quantity"]');
- var option = select.options[select.selectedIndex];
- var price = option ? parseFloat(option.dataset.price || '0') : 0;
+ var price = parseFloat(priceInput.value || '0');
var qty = parseInt(qtyInput.value, 10) || 0;
total += price * qty;
});
diff --git a/bober_bbq/templates/admin/product_form.html b/bober_bbq/templates/admin/product_form.html
index a45308c..67651be 100644
--- a/bober_bbq/templates/admin/product_form.html
+++ b/bober_bbq/templates/admin/product_form.html
@@ -35,8 +35,27 @@
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{% if product and product.image_url %}
@@ -72,4 +91,30 @@
{% if inventory_enabled and product %}
🧾 Рецептура (склад страви)
{% endif %}
+
+
{% endblock %}
diff --git a/bober_bbq/templates/admin/products.html b/bober_bbq/templates/admin/products.html
index f95370d..089bdfe 100644
--- a/bober_bbq/templates/admin/products.html
+++ b/bober_bbq/templates/admin/products.html
@@ -24,7 +24,13 @@
{{ p.name }} |
{{ p.category.name }} |
{{ p.weight }} |
- {{ p.price }} грн |
+
+ {% if p.pricing_mode == 'per_100g' %}
+ {{ p.price }} грн/100г (≈{{ p.effective_unit_price }} грн)
+ {% else %}
+ {{ p.price }} грн
+ {% endif %}
+ |
{{ '✅' if p.is_active else '🚫' }} |
Редагувати
diff --git a/bot/services/cart_service.py b/bot/services/cart_service.py
index fef90a3..5215594 100644
--- a/bot/services/cart_service.py
+++ b/bot/services/cart_service.py
@@ -47,9 +47,10 @@ def render_cart_text(items: list[CartItem]) -> str:
lines = ["🛒 Ваш кошик\n"]
for i, item in enumerate(items, start=1):
name = escape(item.product.name)
+ approx = "≈" if item.product.is_estimated_price else ""
lines.append(
f"{i}. {name}\n"
- f" {item.quantity} шт. × {item.product.price} грн = {item.line_total} грн"
+ f" {item.quantity} шт. × {approx}{item.product.effective_unit_price} грн = {approx}{item.line_total} грн"
)
total = sum(i.line_total for i in items)
lines.append(f"\n💰 Всього: {total} грн")
diff --git a/bot/services/order_service.py b/bot/services/order_service.py
index 840b973..f515753 100644
--- a/bot/services/order_service.py
+++ b/bot/services/order_service.py
@@ -21,7 +21,7 @@ def _add_items(order: Order, cart_items) -> int:
order=order,
product_id=cart_item.product_id,
product_name=cart_item.product.name,
- product_price=cart_item.product.price,
+ product_price=cart_item.product.effective_unit_price,
quantity=cart_item.quantity,
)
)
@@ -68,26 +68,30 @@ def create_delivery_order(user_id: int, data: dict, promo: PromoCode | None = No
return order
-def _add_items_direct(order: Order, items: list[tuple[Product, int]]) -> int:
+def _add_items_direct(order: Order, items: list[tuple[Product, int, int]]) -> int:
"""Same as _add_items but for items built directly from (Product,
- quantity) pairs instead of a customer's CartItem rows — used by manual
- admin-created orders, which have no cart of their own."""
+ quantity, unit_price) triples instead of a customer's CartItem rows —
+ used by manual admin-created orders, which have no cart of their own.
+ unit_price comes from the admin form (bober_bbq/admin/routes.py's
+ orders_new) rather than always being recomputed from the live product,
+ so staff can override it (e.g. after weighing a per_100g item) at
+ creation time, not just via a later edit."""
items_total = 0
- for product, quantity in items:
+ for product, quantity, unit_price in items:
db.session.add(
OrderItem(
order=order,
product_id=product.id,
product_name=product.name,
- product_price=product.price,
+ product_price=unit_price,
quantity=quantity,
)
)
- items_total += product.price * quantity
+ items_total += unit_price * quantity
return items_total
-def create_manual_order(order_type: str, data: dict, items: list[tuple[Product, int]]) -> Order:
+def create_manual_order(order_type: str, data: dict, items: list[tuple[Product, int, int]]) -> Order:
"""Creates an order directly from the admin panel (walk-in, phone, or
dine-in customers) — attributed to a sentinel "walk-in" Telegram user
rather than a real one, so it counts in all revenue/inventory stats the
diff --git a/tests/test_pricing.py b/tests/test_pricing.py
new file mode 100644
index 0000000..df383a1
--- /dev/null
+++ b/tests/test_pricing.py
@@ -0,0 +1,285 @@
+"""Per-100g product pricing (Product.pricing_mode/reference_weight_g/
+effective_unit_price) and admin-editable order line prices (unit_price on
+orders_new/orders_edit) — added so items like grilled shashlik, whose
+cooked weight varies, can be priced by weight with an estimated total shown
+to the customer, and staff can correct the charged price after weighing.
+"""
+
+from bober_bbq.extensions import db
+from bober_bbq.models import Category, Order, OrderItem, Product, Setting, TelegramUser
+from bober_bbq.config import config
+
+
+def _make_category(app):
+ with app.app_context():
+ cat = Category(name="Тестова категорія", slug="test-pricing-cat", sort_order=999)
+ db.session.add(cat)
+ db.session.commit()
+ return cat.id
+
+
+def _make_per_100g_product(app, price_per_100g=150, reference_weight_g=300):
+ with app.app_context():
+ cat_id = _make_category(app)
+ product = Product(
+ category_id=cat_id,
+ name="Шашлик на вагу",
+ price=price_per_100g,
+ pricing_mode="per_100g",
+ reference_weight_g=reference_weight_g,
+ is_active=True,
+ )
+ db.session.add(product)
+ db.session.commit()
+ return product.id
+
+
+def _admin_login(client, app):
+ with app.app_context():
+ username, password = config.FLASK_ADMIN_USERNAME, config.FLASK_ADMIN_PASSWORD
+ return client.post("/admin/login", data={"username": username, "password": password}, follow_redirects=True)
+
+
+def test_effective_unit_price_fixed_mode_returns_price_unchanged(app):
+ with app.app_context():
+ cat_id = _make_category(app)
+ product = Product(category_id=cat_id, name="Звичайний товар", price=120)
+ assert product.effective_unit_price == 120
+ assert product.is_estimated_price is False
+
+
+def test_effective_unit_price_per_100g_computes_estimate(app):
+ with app.app_context():
+ product_id = _make_per_100g_product(app, price_per_100g=150, reference_weight_g=300)
+ product = db.session.get(Product, product_id)
+ assert product.effective_unit_price == 450
+ assert product.is_estimated_price is True
+
+
+def test_effective_unit_price_per_100g_without_reference_weight_falls_back_to_raw_price(app):
+ with app.app_context():
+ cat_id = _make_category(app)
+ # Defensive case only — the admin form validation should prevent
+ # this combination from ever being saved (see test below).
+ product = Product(category_id=cat_id, name="Некоректний товар", price=150, pricing_mode="per_100g")
+ assert product.effective_unit_price == 150
+
+
+def test_products_api_exposes_estimated_price_and_flag(app, client):
+ product_id = _make_per_100g_product(app, price_per_100g=150, reference_weight_g=300)
+
+ products = {p["id"]: p for p in client.get("/api/products").get_json()}
+ assert products[product_id]["price"] == 450
+ assert products[product_id]["is_estimated_price"] is True
+
+
+def test_cart_uses_estimated_price_for_per_100g_product(app, client):
+ product_id = _make_per_100g_product(app, price_per_100g=150, reference_weight_g=300)
+
+ resp = client.post("/api/cart/items", json={"product_id": product_id, "quantity": 2}, query_string={"dev_user_id": 501})
+ assert resp.status_code == 201
+ body = resp.get_json()
+ assert body["items"][0]["price"] == 450
+ assert body["items"][0]["is_estimated_price"] is True
+ assert body["items"][0]["line_total"] == 900
+ assert body["items_total"] == 900
+
+
+def test_checkout_freezes_estimated_price_on_order_item(app, monkeypatch):
+ from bober_bbq.utils import notify_manager, telegram_notify
+ import bober_bbq.api.checkout as checkout_mod
+
+ monkeypatch.setattr(telegram_notify, "send_message", lambda *a, **kw: None)
+ monkeypatch.setattr(notify_manager, "send_message", lambda *a, **kw: None)
+ monkeypatch.setattr(checkout_mod, "send_message", lambda *a, **kw: None)
+
+ product_id = _make_per_100g_product(app, price_per_100g=150, reference_weight_g=300)
+ telegram_id = 502
+ with app.app_context():
+ db.session.add(TelegramUser(telegram_id=telegram_id, first_name="Тест"))
+ Setting.set("allow_orders_outside_hours", "1")
+ db.session.commit()
+
+ client = app.test_client()
+ client.post(
+ "/api/cart/items",
+ json={"product_id": product_id, "quantity": 1},
+ query_string={"dev_user_id": telegram_id},
+ )
+ resp = client.post(
+ "/api/checkout/pickup",
+ json={"name": "Гість", "phone": "+380501234567", "pickup_time": "19:00", "payment_method": "card"},
+ query_string={"dev_user_id": telegram_id},
+ )
+ assert resp.status_code == 201, resp.get_data(as_text=True)
+ order_id = resp.get_json()["order_id"]
+
+ with app.app_context():
+ item = OrderItem.query.filter_by(order_id=order_id).first()
+ assert item.product_price == 450, "OrderItem.product_price must be the estimated price, not the raw per-100g rate"
+
+
+def test_apply_product_form_rejects_per_100g_without_reference_weight(app):
+ client = app.test_client()
+ _admin_login(client, app)
+ cat_id = _make_category(app)
+
+ resp = client.post(
+ "/admin/products/new",
+ data={
+ "category_id": cat_id,
+ "name": "Без ваги",
+ "description": "",
+ "weight": "",
+ "price": "150",
+ "pricing_mode": "per_100g",
+ "sort_order": "0",
+ },
+ follow_redirects=True,
+ )
+ assert "Вкажіть орієнтовну вагу порції" in resp.get_data(as_text=True)
+ with app.app_context():
+ assert Product.query.filter_by(name="Без ваги").first() is None
+
+
+def test_orders_edit_uses_submitted_unit_price_not_catalog_price(app):
+ client = app.test_client()
+ _admin_login(client, app)
+ cat_id = _make_category(app)
+
+ with app.app_context():
+ product = Product(category_id=cat_id, name="Товар для редагування", price=100, is_active=True)
+ db.session.add(product)
+ db.session.commit()
+ product_id = product.id
+
+ order = Order(user_id=-1, order_type="pickup", customer_name="Гість", payment_method="cash", pickup_time="19:00")
+ db.session.add(order)
+ db.session.flush()
+ db.session.add(OrderItem(order_id=order.id, product_id=product_id, product_name=product.name, product_price=100, quantity=1))
+ order.items_total = 100
+ order.total = 100
+ order.number = 9001
+ db.session.commit()
+ order_id = order.id
+
+ resp = client.post(
+ f"/admin/orders/{order_id}/edit",
+ data={
+ "order_type": "pickup",
+ "customer_name": "Гість",
+ "customer_phone": "",
+ "pickup_time": "19:00",
+ "product_id": [str(product_id)],
+ "quantity": ["1"],
+ "unit_price": ["77"],
+ "payment_method": "cash",
+ "payment_status": "unpaid",
+ "manual_discount_amount": "0",
+ "manual_discount_note": "",
+ },
+ follow_redirects=True,
+ )
+ assert resp.status_code == 200
+
+ with app.app_context():
+ item = OrderItem.query.filter_by(order_id=order_id).first()
+ assert item.product_price == 77, "the manually overridden price must be used, not Product.price (100)"
+
+
+def test_orders_edit_falls_back_to_product_price_when_unit_price_blank(app):
+ client = app.test_client()
+ _admin_login(client, app)
+ cat_id = _make_category(app)
+
+ with app.app_context():
+ product = Product(category_id=cat_id, name="Товар без перезапису", price=88, is_active=True)
+ db.session.add(product)
+ db.session.commit()
+ product_id = product.id
+
+ order = Order(user_id=-1, order_type="pickup", customer_name="Гість", payment_method="cash", pickup_time="19:00")
+ db.session.add(order)
+ db.session.flush()
+ db.session.add(OrderItem(order_id=order.id, product_id=product_id, product_name=product.name, product_price=88, quantity=1))
+ order.items_total = 88
+ order.total = 88
+ order.number = 9002
+ db.session.commit()
+ order_id = order.id
+
+ client.post(
+ f"/admin/orders/{order_id}/edit",
+ data={
+ "order_type": "pickup",
+ "customer_name": "Гість",
+ "customer_phone": "",
+ "pickup_time": "19:00",
+ "product_id": [str(product_id)],
+ "quantity": ["1"],
+ "unit_price": [""],
+ "payment_method": "cash",
+ "payment_status": "unpaid",
+ "manual_discount_amount": "0",
+ "manual_discount_note": "",
+ },
+ follow_redirects=True,
+ )
+
+ with app.app_context():
+ item = OrderItem.query.filter_by(order_id=order_id).first()
+ assert item.product_price == 88
+
+
+def test_orders_edit_unrelated_field_change_does_not_reprice_items(app):
+ """Regression test: orders_edit used to unconditionally re-snapshot
+ OrderItem.product_price from the LIVE catalog price on every save, so
+ fixing a customer's phone number would silently reprice every line to
+ today's price. Bumping the catalog price here and saving with the
+ price field left at its current (pre-filled) value must NOT change
+ what was actually charged."""
+ client = app.test_client()
+ _admin_login(client, app)
+ cat_id = _make_category(app)
+
+ with app.app_context():
+ product = Product(category_id=cat_id, name="Товар з іншою ціною", price=100, is_active=True)
+ db.session.add(product)
+ db.session.commit()
+ product_id = product.id
+
+ order = Order(user_id=-1, order_type="pickup", customer_name="Гість", payment_method="cash", pickup_time="19:00")
+ db.session.add(order)
+ db.session.flush()
+ db.session.add(OrderItem(order_id=order.id, product_id=product_id, product_name=product.name, product_price=100, quantity=1))
+ order.items_total = 100
+ order.total = 100
+ order.number = 9003
+ db.session.commit()
+ order_id = order.id
+
+ # Catalog price changes after the order was placed.
+ product.price = 250
+ db.session.commit()
+
+ client.post(
+ f"/admin/orders/{order_id}/edit",
+ data={
+ "order_type": "pickup",
+ "customer_name": "Новий Телефон",
+ "customer_phone": "+380500000000",
+ "pickup_time": "19:00",
+ "product_id": [str(product_id)],
+ "quantity": ["1"],
+ "unit_price": ["100"], # what the edit form pre-fills from the existing OrderItem
+ "payment_method": "cash",
+ "payment_status": "unpaid",
+ "manual_discount_amount": "0",
+ "manual_discount_note": "",
+ },
+ follow_redirects=True,
+ )
+
+ with app.app_context():
+ item = OrderItem.query.filter_by(order_id=order_id).first()
+ assert item.product_price == 100, "editing an unrelated field must not reprice the line to the new catalog price (250)"
diff --git a/webapp/src/components/CartItemRow.tsx b/webapp/src/components/CartItemRow.tsx
index 24e858e..49344ab 100644
--- a/webapp/src/components/CartItemRow.tsx
+++ b/webapp/src/components/CartItemRow.tsx
@@ -21,6 +21,12 @@ export function CartItemRow({ item, onChangeQty, onRemove }: Props) {
{item.weight ? `${item.weight} · ` : ""}
{item.price} {t("common.currency")}
+ {item.is_estimated_price && (
+
+ {" "}
+ ≈
+
+ )}
diff --git a/webapp/src/components/ProductCard.tsx b/webapp/src/components/ProductCard.tsx
index dc134c2..7808c1f 100644
--- a/webapp/src/components/ProductCard.tsx
+++ b/webapp/src/components/ProductCard.tsx
@@ -86,6 +86,12 @@ export function ProductCard({ product, onAdd, onOpenDetail }: Props) {
{product.price} {t("common.currency")}
+ {product.is_estimated_price && (
+
+ {" "}
+ ≈
+
+ )}
diff --git a/webapp/src/components/ProductDetailModal.tsx b/webapp/src/components/ProductDetailModal.tsx
index 795b298..62e5512 100644
--- a/webapp/src/components/ProductDetailModal.tsx
+++ b/webapp/src/components/ProductDetailModal.tsx
@@ -89,6 +89,12 @@ export function ProductDetailModal({ product, onClose, onAdd }: Props) {
{product.price} {t("common.currency")}
+ {product.is_estimated_price && (
+
+ {" "}
+ ≈
+
+ )}
|