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:
parent
87870d6e4b
commit
699a73c8a0
16 changed files with 488 additions and 38 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>")
|
||||
|
|
|
|||
|
|
@ -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
285
tests/test_pricing.py
Normal 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)"
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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))}>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue