From 699a73c8a0d04f3d5a7f6fdbf6d60650e6db1350 Mon Sep 17 00:00:00 2001 From: byrsapty Date: Thu, 3 Sep 2026 20:33:04 +0300 Subject: [PATCH] Add per-100g product pricing and editable order line prices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some items (grilled shashlik) can't have a flat price since the cooked weight varies. Product gains pricing_mode ("fixed"/"per_100g") and reference_weight_g; Product.effective_unit_price computes an estimated per-unit charge (price-per-100g * reference_weight_g / 100) that every cart/order call site now reads instead of raw Product.price, so the customer sees a ready "≈" estimate everywhere (menu, cart, checkout) with zero changes to the actual cart/checkout math. Also adds a general per-line price override to the admin order create/edit forms (unit_price), so staff can correct the charged amount after weighing an item — for any product, not just weight-priced ones. This incidentally fixes an existing bug where editing an order for an unrelated reason (e.g. a phone number) silently re-snapshotted every line item to today's catalog price instead of preserving what was actually charged. Co-Authored-By: Claude Sonnet 5 --- bober_bbq/admin/routes.py | 50 +++- bober_bbq/migrate.py | 2 + bober_bbq/models.py | 34 ++- bober_bbq/templates/admin/order_edit.html | 26 +- bober_bbq/templates/admin/order_new.html | 26 +- bober_bbq/templates/admin/product_form.html | 49 +++- bober_bbq/templates/admin/products.html | 8 +- bot/services/cart_service.py | 3 +- bot/services/order_service.py | 20 +- tests/test_pricing.py | 285 +++++++++++++++++++ webapp/src/components/CartItemRow.tsx | 6 + webapp/src/components/ProductCard.tsx | 6 + webapp/src/components/ProductDetailModal.tsx | 6 + webapp/src/i18n/translations.ts | 2 + webapp/src/styles.css | 1 + webapp/src/types.ts | 2 + 16 files changed, 488 insertions(+), 38 deletions(-) create mode 100644 tests/test_pricing.py 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 %}
- {% for p in products %} - + {% endfor %} +
{% endfor %} @@ -122,13 +123,14 @@ @@ -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 @@
- {% for p in products %} - + {% endfor %} +
@@ -106,13 +107,14 @@ @@ -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 && ( + + {" "} + ≈ + + )}