Add per-100g product pricing and editable order line prices

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 <noreply@anthropic.com>
This commit is contained in:
byrsapty 2026-09-03 20:33:04 +03:00
parent 87870d6e4b
commit 699a73c8a0
16 changed files with 488 additions and 38 deletions

View file

@ -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)

View file

@ -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")

View file

@ -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,
}

View file

@ -63,13 +63,14 @@
<div id="order-rows">
{% for item in order.items %}
<div class="recipe-row">
<select name="product_id" onchange="recalcTotal()">
<select name="product_id" onchange="onProductChange(this)">
<option value="">— оберіть товар —</option>
{% for p in products %}
<option value="{{ p.id }}" data-price="{{ p.price }}" {{ 'selected' if p.id == item.product_id }}>{{ p.name }} — {{ p.price }} грн{{ '' if p.is_active else ' (знято з продажу)' }}</option>
<option value="{{ p.id }}" data-price="{{ p.effective_unit_price }}" {{ 'selected' if p.id == item.product_id }}>{{ p.name }} — {{ '≈' if p.pricing_mode == 'per_100g' }}{{ p.effective_unit_price }} грн{{ '' if p.is_active else ' (знято з продажу)' }}</option>
{% endfor %}
</select>
<input type="number" name="quantity" min="1" value="{{ item.quantity }}" oninput="recalcTotal()">
<input type="number" name="unit_price" min="0" value="{{ item.product_price }}" oninput="this.dataset.touched='1'; recalcTotal();" title="Ціна за одиницю (можна змінити вручну)">
<button type="button" class="btn small danger" onclick="this.closest('.recipe-row').remove(); recalcTotal();" aria-label="Прибрати рядок"></button>
</div>
{% endfor %}
@ -122,13 +123,14 @@
<template id="order-row-template">
<div class="recipe-row">
<select name="product_id" onchange="recalcTotal()">
<select name="product_id" onchange="onProductChange(this)">
<option value="">— оберіть товар —</option>
{% for p in products if p.is_active %}
<option value="{{ p.id }}" data-price="{{ p.price }}">{{ p.name }} — {{ p.price }} грн</option>
<option value="{{ p.id }}" data-price="{{ p.effective_unit_price }}">{{ p.name }} — {{ '≈' if p.pricing_mode == 'per_100g' }}{{ p.effective_unit_price }} грн</option>
{% endfor %}
</select>
<input type="number" name="quantity" min="1" value="1" oninput="recalcTotal()">
<input type="number" name="unit_price" min="0" value="" oninput="this.dataset.touched='1'; recalcTotal();" title="Ціна за одиницю (можна змінити вручну)">
<button type="button" class="btn small danger" onclick="this.closest('.recipe-row').remove(); recalcTotal();" aria-label="Прибрати рядок"></button>
</div>
</template>
@ -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;
});

View file

@ -62,13 +62,14 @@
<label>Товари</label>
<div id="order-rows">
<div class="recipe-row">
<select name="product_id" onchange="recalcTotal()">
<select name="product_id" onchange="onProductChange(this)">
<option value="">— оберіть товар —</option>
{% for p in products %}
<option value="{{ p.id }}" data-price="{{ p.price }}">{{ p.name }} — {{ p.price }} грн</option>
<option value="{{ p.id }}" data-price="{{ p.effective_unit_price }}">{{ p.name }} — {{ '≈' if p.pricing_mode == 'per_100g' }}{{ p.effective_unit_price }} грн</option>
{% endfor %}
</select>
<input type="number" name="quantity" min="1" value="1" oninput="recalcTotal()">
<input type="number" name="unit_price" min="0" value="" oninput="this.dataset.touched='1'; recalcTotal();" title="Ціна за одиницю (можна змінити вручну)">
<button type="button" class="btn small danger" onclick="this.closest('.recipe-row').remove(); recalcTotal();" aria-label="Прибрати рядок"></button>
</div>
</div>
@ -106,13 +107,14 @@
<template id="order-row-template">
<div class="recipe-row">
<select name="product_id" onchange="recalcTotal()">
<select name="product_id" onchange="onProductChange(this)">
<option value="">— оберіть товар —</option>
{% for p in products %}
<option value="{{ p.id }}" data-price="{{ p.price }}">{{ p.name }} — {{ p.price }} грн</option>
<option value="{{ p.id }}" data-price="{{ p.effective_unit_price }}">{{ p.name }} — {{ '≈' if p.pricing_mode == 'per_100g' }}{{ p.effective_unit_price }} грн</option>
{% endfor %}
</select>
<input type="number" name="quantity" min="1" value="1" oninput="recalcTotal()">
<input type="number" name="unit_price" min="0" value="" oninput="this.dataset.touched='1'; recalcTotal();" title="Ціна за одиницю (можна змінити вручну)">
<button type="button" class="btn small danger" onclick="this.closest('.recipe-row').remove(); recalcTotal();" aria-label="Прибрати рядок"></button>
</div>
</template>
@ -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;
});

View file

@ -35,8 +35,27 @@
</div>
</div>
<label>Ціна, грн</label>
<input type="number" name="price" value="{{ product.price if product else '' }}" required min="0">
<label>Тип ціни</label>
<select name="pricing_mode" id="pricing-mode" onchange="syncPricingMode()">
<option value="fixed" {{ 'selected' if not product or product.pricing_mode == 'fixed' }}>Фіксована ціна за порцію</option>
<option value="per_100g" {{ 'selected' if product and product.pricing_mode == 'per_100g' }}>Ціна за 100 г (страва "на вагу")</option>
</select>
<label id="price-label">Ціна, грн</label>
<input type="number" name="price" id="price-input" value="{{ product.price if product else '' }}" required min="0" oninput="recalcEstimate()">
<div id="reference-weight-row" class="row">
<div>
<label>Орієнтовна вага порції, г</label>
<input type="number" name="reference_weight_g" id="reference-weight-input"
value="{{ product.reference_weight_g if product and product.reference_weight_g else '' }}"
min="1" oninput="recalcEstimate()">
</div>
<div>
<label>Орієнтовна ціна</label>
<div id="estimate-preview" class="muted" style="padding-top:8px;"></div>
</div>
</div>
<label>Фото</label>
{% if product and product.image_url %}
@ -72,4 +91,30 @@
{% if inventory_enabled and product %}
<a class="btn secondary small" style="margin-top:14px;" href="{{ url_for('admin.inventory_recipe_edit', product_id=product.id) }}">🧾 Рецептура (склад страви)</a>
{% endif %}
<script>
function syncPricingMode() {
var isPer100g = document.getElementById('pricing-mode').value === 'per_100g';
document.getElementById('reference-weight-row').style.display = isPer100g ? 'flex' : 'none';
document.getElementById('price-label').textContent = isPer100g ? 'Ціна за 100 г, грн' : 'Ціна, грн';
recalcEstimate();
}
function recalcEstimate() {
var preview = document.getElementById('estimate-preview');
if (document.getElementById('pricing-mode').value !== 'per_100g') {
preview.textContent = '—';
return;
}
var price100 = parseFloat(document.getElementById('price-input').value || '0');
var refWeight = parseFloat(document.getElementById('reference-weight-input').value || '0');
if (!refWeight) {
preview.textContent = '—';
return;
}
preview.textContent = '≈ ' + Math.round(price100 * refWeight / 100) + ' грн';
}
syncPricingMode();
</script>
{% endblock %}

View file

@ -24,7 +24,13 @@
<td data-label="Назва">{{ p.name }}</td>
<td data-label="Категорія" class="muted">{{ p.category.name }}</td>
<td data-label="Вага">{{ p.weight }}</td>
<td data-label="Ціна">{{ p.price }} грн</td>
<td data-label="Ціна">
{% if p.pricing_mode == 'per_100g' %}
{{ p.price }} грн/100г <span class="muted">(≈{{ p.effective_unit_price }} грн)</span>
{% else %}
{{ p.price }} грн
{% endif %}
</td>
<td data-label="Активний">{{ '✅' if p.is_active else '🚫' }}</td>
<td data-label="" class="actions-cell">
<a class="btn small secondary" href="{{ url_for('admin.products_edit', product_id=p.id) }}">Редагувати</a>

View file

@ -47,9 +47,10 @@ def render_cart_text(items: list[CartItem]) -> str:
lines = ["🛒 <b>Ваш кошик</b>\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}. <b>{name}</b>\n"
f" {item.quantity} шт. × {item.product.price} грн = <b>{item.line_total} грн</b>"
f" {item.quantity} шт. × {approx}{item.product.effective_unit_price} грн = <b>{approx}{item.line_total} грн</b>"
)
total = sum(i.line_total for i in items)
lines.append(f"\n💰 <b>Всього: {total} грн</b>")

View file

@ -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

285
tests/test_pricing.py Normal file
View file

@ -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)"

View file

@ -21,6 +21,12 @@ export function CartItemRow({ item, onChangeQty, onRemove }: Props) {
<div className="sub">
{item.weight ? `${item.weight} · ` : ""}
{item.price} {t("common.currency")}
{item.is_estimated_price && (
<span className="price-estimate-badge" title={t("product.estimated_price_badge")}>
{" "}
</span>
)}
</div>
</div>
<div className="stepper">

View file

@ -86,6 +86,12 @@ export function ProductCard({ product, onAdd, onOpenDetail }: Props) {
<div className="bottom-row">
<span className="price">
{product.price} {t("common.currency")}
{product.is_estimated_price && (
<span className="price-estimate-badge" title={t("product.estimated_price_badge")}>
{" "}
</span>
)}
</span>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<div className="stepper">

View file

@ -89,6 +89,12 @@ export function ProductDetailModal({ product, onClose, onAdd }: Props) {
<div className="product-modal-footer">
<span className="price">
{product.price} {t("common.currency")}
{product.is_estimated_price && (
<span className="price-estimate-badge" title={t("product.estimated_price_badge")}>
{" "}
</span>
)}
</span>
<div className="stepper">
<button aria-label={t("product.decrease_aria")} onClick={() => setQty((q) => Math.max(1, q - 1))}>

View file

@ -45,6 +45,7 @@ export const translations: Record<Locale, Record<string, string>> = {
"product.add": "Додати",
"product.add_aria": "Додати {name} до кошика",
"product.remove_aria": "Видалити з кошика",
"product.estimated_price_badge": "орієнтовно",
"cart.empty.title": "Кошик порожній",
"cart.empty.subtitle": "Перейдіть до меню, щоб додати смачні страви",
@ -181,6 +182,7 @@ export const translations: Record<Locale, Record<string, string>> = {
"product.add": "Add",
"product.add_aria": "Add {name} to cart",
"product.remove_aria": "Remove from cart",
"product.estimated_price_badge": "estimated",
"cart.empty.title": "Your cart is empty",
"cart.empty.subtitle": "Go to the menu to add some delicious food",

View file

@ -388,6 +388,7 @@ input.invalid:focus, textarea.invalid:focus, select.invalid:focus {
}
.price { font-weight: 800; font-size: 15px; color: var(--accent-2); }
.price-estimate-badge { font-weight: 600; font-size: 0.85em; color: var(--muted); cursor: help; }
.stepper {
display: flex;

View file

@ -18,6 +18,7 @@ export interface Product {
description: string;
weight: string;
price: number;
is_estimated_price: boolean;
image_url: string;
allergens: Allergen[];
}
@ -28,6 +29,7 @@ export interface CartLine {
weight: string;
image_url: string;
price: number;
is_estimated_price: boolean;
quantity: number;
line_total: number;
}