bober-bbq-bot/bober_bbq/admin/routes.py
byrsapty 71210b6bdb Add independent on/off toggles for the "≈" estimate badge
Previously the badge always showed next to the price for any per_100g
product and never next to the weight at all. Adds two per-product
checkboxes (show_estimate_badge_price, default on; show_estimate_badge_weight,
default off) so the admin can turn either one on or off independently —
covers a shashlik-style product where the weight itself is also just an
estimate, not only the price.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 03:28:05 +03:00

1059 lines
44 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import uuid
from pathlib import Path
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
from flask import flash, jsonify, redirect, render_template, request, url_for
from flask_login import current_user, login_required, login_user, logout_user
from werkzeug.utils import secure_filename
from bober_bbq.admin import admin_bp
from bober_bbq.admin.authz import admin_required
from bober_bbq.config import config
from bober_bbq.extensions import db
from bober_bbq.models import (
ALLERGENS,
DEFAULT_SETTINGS,
ORDER_STATUS_LABELS,
ORDER_STATUS_NEXT,
ORDER_STATUSES,
ORDER_TYPE_LABELS,
ORDER_TYPES,
PAYMENT_METHODS,
AdminUser,
Category,
Ingredient,
Order,
OrderItem,
Product,
PromoCode,
Setting,
StockMovement,
)
from bober_bbq.utils import checkbox_prro, login_throttle
from bober_bbq.utils.audit import log_action
from bober_bbq.utils.inventory import deduct_for_order, inventory_enabled, restore_for_order
from bober_bbq.utils.notify_customer import notify_customer_status_change
from bober_bbq.utils.pricing import calc_delivery_fee, calc_pickup_discount
from bober_bbq.utils.theme import derive_palette
from bober_bbq.utils.workhours import is_open as _is_open
from bot.services import order_service
ALLOWED_IMAGE_EXT = {"jpg", "jpeg", "png", "webp", "svg"}
@admin_bp.context_processor
def inject_admin_globals():
# logo/cafe_name are public info (already exposed via /api/settings), so
# show them on the login page too, before the user is authenticated.
globals_ = {
"logo_url": Setting.get("logo_url", ""),
"cafe_name": Setting.get("cafe_name", "Bober BBQ"),
"admin_theme": derive_palette(Setting.get("admin_theme_accent", "#ff7a3d")),
}
if not current_user.is_authenticated:
return globals_
inv_enabled = Setting.get_bool("inventory_enabled", False)
suppliers_manager_access = Setting.get_bool("suppliers_manager_access", False)
globals_.update(
{
"new_orders_count": Order.query.filter_by(status="new").count(),
"is_open": _is_open(),
"work_hours_from": Setting.get("work_hours_from", "11:00"),
"work_hours_to": Setting.get("work_hours_to", "21:00"),
"order_status_labels": ORDER_STATUS_LABELS,
"inventory_enabled": inv_enabled,
"low_stock_count": (
Ingredient.query.filter(Ingredient.current_stock <= Ingredient.reorder_threshold).count()
if inv_enabled
else 0
),
"suppliers_manager_access": suppliers_manager_access,
"suppliers_nav_visible": current_user.role == "admin" or suppliers_manager_access,
"sidebar_collapsible": Setting.get_bool("sidebar_collapsible_menu", True),
}
)
return globals_
# ---------------------------------------------------------------------------
# Auth
# ---------------------------------------------------------------------------
@admin_bp.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
ip = request.remote_addr or "unknown"
locked_for = login_throttle.seconds_locked(ip)
if locked_for is not None:
flash(f"Забагато невдалих спроб входу. Спробуйте ще раз через {locked_for // 60 + 1} хв.", "error")
return render_template("admin/login.html")
username = request.form.get("username", "")
password = request.form.get("password", "")
user = AdminUser.query.filter_by(username=username).first()
if user and user.check_password(password):
login_throttle.record_success(ip)
user.last_login_at = datetime.now(timezone.utc)
db.session.commit()
login_user(user)
return redirect(url_for("admin.dashboard"))
login_throttle.record_failure(ip)
flash("Невірний логін або пароль", "error")
return render_template("admin/login.html")
@admin_bp.route("/api/new-orders-count")
@login_required
def api_new_orders_count():
return jsonify({"count": Order.query.filter_by(status="new").count()})
@admin_bp.route("/logout")
@login_required
def logout():
logout_user()
return redirect(url_for("admin.login"))
# ---------------------------------------------------------------------------
# Dashboard
# ---------------------------------------------------------------------------
def _pct_change(today: float, yesterday: float) -> float | None:
if yesterday <= 0:
return None
return round((today - yesterday) / yesterday * 100)
STATUS_COLORS = {
"new": "#ff7a3d",
"confirmed": "#eab308",
"cooking": "#fb923c",
"ready": "#34d399",
"courier": "#a78bfa",
"completed": "#22c55e",
"cancelled": "#f87171",
}
def _uk_plural(n: int, one: str, few: str, many: str) -> str:
n_mod_100 = n % 100
if 11 <= n_mod_100 <= 14:
return many
n_mod_10 = n % 10
if n_mod_10 == 1:
return one
if 2 <= n_mod_10 <= 4:
return few
return many
def _rating_summary() -> dict:
counts = {n: 0 for n in range(1, 6)}
for (r,) in db.session.query(Order.rating).filter(Order.rating.isnot(None)):
if r in counts:
counts[r] += 1
total = sum(counts.values())
average = round(sum(n * c for n, c in counts.items()) / total, 1) if total else 0
max_bucket = max(counts.values()) if total else 0
return {
"count": total,
"count_label": _uk_plural(total, "оцінка", "оцінки", "оцінок"),
"average": average,
"average_rounded": round(average),
"distribution": counts,
"distribution_pct": {n: (round(c / max_bucket * 100) if max_bucket else 0) for n, c in counts.items()},
}
def _status_donut(status_counts: dict) -> dict:
total = sum(status_counts.values())
legend = []
stops = []
angle = 0.0
for status in ORDER_STATUSES:
count = status_counts.get(status, 0)
pct = (count / total * 100) if total else 0
color = STATUS_COLORS[status]
legend.append({"status": status, "label": ORDER_STATUS_LABELS[status], "count": count, "pct": round(pct)})
if count:
start = angle
end = angle + pct
stops.append(f"{color} {start:.2f}% {end:.2f}%")
angle = end
gradient = f"conic-gradient({', '.join(stops)})" if stops else "conic-gradient(var(--panel-2) 0 100%)"
return {"total": total, "legend": legend, "gradient": gradient}
@admin_bp.route("/")
@login_required
def dashboard():
tz = ZoneInfo(config.TIMEZONE)
now_local = datetime.now(tz)
today_start_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
yesterday_start_local = today_start_local - timedelta(days=1)
today_start = today_start_local.astimezone(timezone.utc).replace(tzinfo=None)
yesterday_start = yesterday_start_local.astimezone(timezone.utc).replace(tzinfo=None)
today_orders = Order.query.filter(Order.created_at >= today_start).all()
yesterday_orders = Order.query.filter(
Order.created_at >= yesterday_start, Order.created_at < today_start
).all()
# Cancelled orders never earned anything — kept out of every
# revenue/count KPI below, but still counted in the status donut
# further down (that chart's whole point is showing cancellations too).
today_active = [o for o in today_orders if o.status != "cancelled"]
yesterday_active = [o for o in yesterday_orders if o.status != "cancelled"]
def summarize(orders):
delivery = [o for o in orders if o.order_type == "delivery"]
pickup = [o for o in orders if o.order_type == "pickup"]
dine_in = [o for o in orders if o.order_type == "dine_in"]
revenue = sum(o.total for o in orders)
return {
"count": len(orders),
"delivery": len(delivery),
"pickup": len(pickup),
"dine_in": len(dine_in),
"revenue": revenue,
"avg_check": round(revenue / len(orders)) if orders else 0,
}
today_stats = summarize(today_active)
yesterday_stats = summarize(yesterday_active)
status_counts = {s: 0 for s in ORDER_STATUSES}
for o in today_orders:
status_counts[o.status] = status_counts.get(o.status, 0) + 1
recent_orders = Order.query.order_by(Order.created_at.desc()).limit(8).all()
donut = _status_donut(status_counts)
top_products: dict[str, int] = {}
for order in today_active:
for item in order.items:
top_products[item.product_name] = top_products.get(item.product_name, 0) + item.quantity
top_products_today = sorted(top_products.items(), key=lambda kv: kv[1], reverse=True)[:5]
top_products_max = max((qty for _, qty in top_products_today), default=1)
finance_today = {
"revenue": today_stats["revenue"],
"card": sum(o.total for o in today_active if o.payment_method == "card"),
"cash": sum(o.total for o in today_active if o.payment_method == "cash"),
"avg_check": today_stats["avg_check"],
"count": today_stats["count"],
}
stats = {
"today": today_stats,
"trend": {
"count": _pct_change(today_stats["count"], yesterday_stats["count"]),
"delivery": _pct_change(today_stats["delivery"], yesterday_stats["delivery"]),
"pickup": _pct_change(today_stats["pickup"], yesterday_stats["pickup"]),
"dine_in": _pct_change(today_stats["dine_in"], yesterday_stats["dine_in"]),
"revenue": _pct_change(today_stats["revenue"], yesterday_stats["revenue"]),
"avg_check": _pct_change(today_stats["avg_check"], yesterday_stats["avg_check"]),
},
"yesterday": yesterday_stats,
"new_orders": Order.query.filter_by(status="new").count(),
"products": Product.query.count(),
"categories": Category.query.count(),
"total_orders": Order.query.count(),
}
return render_template(
"admin/dashboard.html",
orders=recent_orders,
stats=stats,
donut=donut,
top_products_today=top_products_today,
top_products_max=top_products_max,
finance_today=finance_today,
delivery_chat_configured=bool(Setting.get("delivery_chat_id")),
pickup_chat_configured=bool(Setting.get("pickup_chat_id")),
dine_in_chat_configured=bool(Setting.get("dine_in_chat_id")),
ratings=_rating_summary(),
recent_reviews=Order.query.filter(Order.rating.isnot(None)).order_by(Order.rated_at.desc()).limit(5).all(),
)
# ---------------------------------------------------------------------------
# Reviews (customer ratings)
# ---------------------------------------------------------------------------
@admin_bp.route("/reviews")
@login_required
def reviews_list():
score = request.args.get("score", type=int)
query = Order.query.filter(Order.rating.isnot(None))
if score in range(1, 6):
query = query.filter(Order.rating == score)
page = request.args.get("page", 1, type=int)
pagination = db.paginate(
query.order_by(Order.rated_at.desc()),
page=page,
per_page=50,
error_out=False,
)
return render_template(
"admin/reviews.html",
reviews=pagination.items,
pagination=pagination,
score=score,
ratings=_rating_summary(),
)
# ---------------------------------------------------------------------------
# Categories
# ---------------------------------------------------------------------------
@admin_bp.route("/categories")
@login_required
def categories_list():
categories = Category.query.order_by(Category.sort_order).all()
return render_template("admin/categories.html", categories=categories)
@admin_bp.route("/categories/new", methods=["POST"])
@admin_required
def categories_new():
name = request.form.get("name", "").strip()
slug = request.form.get("slug", "").strip() or _slugify(name)
if not name:
flash("Назва категорії обовʼязкова", "error")
return redirect(url_for("admin.categories_list"))
name_en = request.form.get("name_en", "").strip() or None
max_order = db.session.query(db.func.max(Category.sort_order)).scalar() or 0
db.session.add(Category(name=name, name_en=name_en, slug=slug, sort_order=max_order + 1))
db.session.commit()
log_action("category_create", f"Додано категорію «{name}»")
flash("Категорію додано", "success")
return redirect(url_for("admin.categories_list"))
@admin_bp.route("/categories/<int:category_id>/edit", methods=["POST"])
@admin_required
def categories_edit(category_id):
category = Category.query.get_or_404(category_id)
category.name = request.form.get("name", category.name).strip()
category.name_en = request.form.get("name_en", "").strip() or None
category.is_active = bool(request.form.get("is_active"))
db.session.commit()
log_action("category_edit", f"Оновлено категорію «{category.name}»")
flash("Категорію оновлено", "success")
return redirect(url_for("admin.categories_list"))
@admin_bp.route("/categories/<int:category_id>/delete", methods=["POST"])
@admin_required
def categories_delete(category_id):
category = Category.query.get_or_404(category_id)
name = category.name
db.session.delete(category)
db.session.commit()
log_action("category_delete", f"Видалено категорію «{name}»")
flash("Категорію видалено", "success")
return redirect(url_for("admin.categories_list"))
@admin_bp.route("/categories/<int:category_id>/move/<direction>", methods=["POST"])
@admin_required
def categories_move(category_id, direction):
categories = Category.query.order_by(Category.sort_order).all()
idx = next((i for i, c in enumerate(categories) if c.id == category_id), None)
if idx is not None:
swap_idx = idx - 1 if direction == "up" else idx + 1
if 0 <= swap_idx < len(categories):
categories[idx].sort_order, categories[swap_idx].sort_order = (
categories[swap_idx].sort_order,
categories[idx].sort_order,
)
db.session.commit()
return redirect(url_for("admin.categories_list"))
# ---------------------------------------------------------------------------
# Promo codes
# ---------------------------------------------------------------------------
@admin_bp.route("/promo-codes")
@admin_required
def promo_codes_list():
promos = PromoCode.query.order_by(PromoCode.created_at.desc()).all()
return render_template("admin/promo_codes.html", promos=promos)
@admin_bp.route("/promo-codes/new", methods=["POST"])
@admin_required
def promo_codes_new():
code = request.form.get("code", "").strip().upper()
if not code:
flash("Введіть код промокоду", "error")
return redirect(url_for("admin.promo_codes_list"))
if PromoCode.query.filter(db.func.upper(PromoCode.code) == code).first():
flash(f"Промокод «{code}» вже існує", "error")
return redirect(url_for("admin.promo_codes_list"))
discount_percent = request.form.get("discount_percent", type=int) or 0
if not (1 <= discount_percent <= 100):
flash("Знижка має бути від 1 до 100%", "error")
return redirect(url_for("admin.promo_codes_list"))
expires_at = None
expires_raw = request.form.get("expires_at", "").strip()
if expires_raw:
try:
expires_at = datetime.strptime(expires_raw, "%Y-%m-%d").replace(hour=23, minute=59, second=59)
except ValueError:
flash("Невірний формат дати", "error")
return redirect(url_for("admin.promo_codes_list"))
db.session.add(
PromoCode(
code=code,
discount_percent=discount_percent,
first_order_only=bool(request.form.get("first_order_only")),
min_order_amount=request.form.get("min_order_amount", type=int) or 0,
max_uses=request.form.get("max_uses", type=int),
expires_at=expires_at,
)
)
db.session.commit()
log_action("promo_code_create", f"Створено промокод «{code}» ({discount_percent}%)")
flash("Промокод створено", "success")
return redirect(url_for("admin.promo_codes_list"))
@admin_bp.route("/promo-codes/<int:promo_id>/toggle", methods=["POST"])
@admin_required
def promo_codes_toggle(promo_id):
promo = PromoCode.query.get_or_404(promo_id)
promo.is_active = not promo.is_active
db.session.commit()
log_action("promo_code_toggle", f"{'Увімкнено' if promo.is_active else 'Вимкнено'} промокод «{promo.code}»")
return redirect(url_for("admin.promo_codes_list"))
@admin_bp.route("/promo-codes/<int:promo_id>/delete", methods=["POST"])
@admin_required
def promo_codes_delete(promo_id):
promo = PromoCode.query.get_or_404(promo_id)
code = promo.code
db.session.delete(promo)
db.session.commit()
log_action("promo_code_delete", f"Видалено промокод «{code}»")
flash("Промокод видалено", "success")
return redirect(url_for("admin.promo_codes_list"))
# ---------------------------------------------------------------------------
# Products
# ---------------------------------------------------------------------------
@admin_bp.route("/products")
@login_required
def products_list():
category_id = request.args.get("category_id", type=int)
query = Product.query
if category_id:
query = query.filter_by(category_id=category_id)
products = query.order_by(Product.category_id, Product.sort_order).all()
categories = Category.query.order_by(Category.sort_order).all()
return render_template(
"admin/products.html", products=products, categories=categories, selected_category=category_id
)
@admin_bp.route("/products/new", methods=["GET", "POST"])
@admin_required
def products_new():
categories = Category.query.order_by(Category.sort_order).all()
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.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)
@admin_bp.route("/products/<int:product_id>/edit", methods=["GET", "POST"])
@admin_required
def products_edit(product_id):
product = Product.query.get_or_404(product_id)
categories = Category.query.order_by(Category.sort_order).all()
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")
return redirect(url_for("admin.products_list"))
return render_template("admin/product_form.html", product=product, categories=categories, allergens=ALLERGENS)
@admin_bp.route("/products/<int:product_id>/delete", methods=["POST"])
@admin_required
def products_delete(product_id):
product = Product.query.get_or_404(product_id)
name = product.name
db.session.delete(product)
db.session.commit()
log_action("product_delete", f"Видалено товар «{name}»")
flash("Товар видалено", "success")
return redirect(url_for("admin.products_list"))
@admin_bp.route("/products/<int:product_id>/toggle", methods=["POST"])
@admin_required
def products_toggle(product_id):
product = Product.query.get_or_404(product_id)
product.is_active = not product.is_active
log_action("product_toggle", f"Товар «{product.name}»: {'показано' if product.is_active else 'приховано'}")
db.session.commit()
return redirect(url_for("admin.products_list"))
def _apply_product_form(product: Product):
product.name = request.form.get("name", "").strip()
product.description = request.form.get("description", "").strip()
product.name_en = request.form.get("name_en", "").strip() or None
product.description_en = request.form.get("description_en", "").strip() or None
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
price_override = request.form.get("estimated_price_override", type=int)
product.estimated_price_override = (
price_override if product.pricing_mode == "per_100g" and price_override and price_override > 0 else None
)
product.show_estimate_badge_price = bool(request.form.get("show_estimate_badge_price"))
product.show_estimate_badge_weight = bool(request.form.get("show_estimate_badge_weight"))
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)
file = request.files.get("image")
if file and file.filename:
ext = file.filename.rsplit(".", 1)[-1].lower()
if ext in ALLOWED_IMAGE_EXT:
filename = f"{uuid.uuid4().hex}.{ext}"
upload_dir = Path(config.UPLOAD_FOLDER)
upload_dir.mkdir(parents=True, exist_ok=True)
file.save(upload_dir / secure_filename(filename))
product.image_url = f"/static/uploads/{filename}"
# ---------------------------------------------------------------------------
# Orders
# ---------------------------------------------------------------------------
@admin_bp.route("/orders/new", methods=["GET", "POST"])
@login_required
def orders_new():
if request.method == "POST":
order_type = request.form.get("order_type")
if order_type not in ORDER_TYPES:
flash("Оберіть тип замовлення", "error")
return redirect(url_for("admin.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, raw_price in zip(product_ids, quantities, unit_prices):
if not raw_id:
continue
try:
pid = int(raw_id)
qty = int(raw_qty)
except (TypeError, ValueError):
continue
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, unit_price))
if not items:
flash("Додайте хоча б один товар", "error")
return redirect(url_for("admin.orders_new"))
payment_method = request.form.get("payment_method", "cash")
if payment_method not in PAYMENT_METHODS:
payment_method = "cash"
status = request.form.get("status", "new")
if status not in ORDER_STATUSES:
status = "new"
data = {
"name": request.form.get("customer_name", "").strip(),
"phone": request.form.get("customer_phone", "").strip(),
"payment_method": payment_method,
"payment_status": "paid" if request.form.get("payment_status") == "paid" else "unpaid",
"status": status,
"address_city": request.form.get("address_city", "").strip(),
"address_street": request.form.get("address_street", "").strip(),
"address_house": request.form.get("address_house", "").strip(),
"address_apartment": request.form.get("address_apartment", "").strip(),
"pickup_time": request.form.get("pickup_time", "").strip(),
"table_number": request.form.get("table_number", "").strip(),
}
order = order_service.create_manual_order(order_type, data, items)
log_action(
"order_manual_create",
f"Створено замовлення {order.display_number()} вручну ({ORDER_TYPE_LABELS[order_type]}, {current_user.username})",
)
checkbox_prro.fiscalize_in_background(order.id)
flash(f"Замовлення {order.display_number()} створено", "success")
return redirect(url_for("admin.orders_detail", order_id=order.id))
products = Product.query.filter_by(is_active=True).order_by(Product.name).all()
return render_template(
"admin/order_new.html",
products=products,
order_types=ORDER_TYPES,
order_type_labels=ORDER_TYPE_LABELS,
statuses=ORDER_STATUSES,
)
@admin_bp.route("/orders/<int:order_id>/edit", methods=["GET", "POST"])
@admin_required
def orders_edit(order_id):
order = Order.query.get_or_404(order_id)
if request.method == "POST":
order_type = request.form.get("order_type")
if order_type not in ORDER_TYPES:
flash("Оберіть тип замовлення", "error")
return redirect(url_for("admin.orders_edit", order_id=order_id))
# Include products already on this order even if since deactivated,
# so editing an unrelated field doesn't silently drop a line whose
# product just got hidden from the menu in the meantime.
existing_product_ids = [i.product_id for i in order.items if i.product_id]
products_by_id = {
p.id: p
for p in Product.query.filter(db.or_(Product.is_active.is_(True), Product.id.in_(existing_product_ids))).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, raw_price in zip(product_ids, quantities, unit_prices):
if not raw_id:
continue
try:
pid = int(raw_id)
qty = int(raw_qty)
except (TypeError, ValueError):
continue
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, unit_price))
if not items:
flash("Додайте хоча б один товар", "error")
return redirect(url_for("admin.orders_edit", order_id=order_id))
payment_method = request.form.get("payment_method", "cash")
if payment_method not in PAYMENT_METHODS:
payment_method = "cash"
was_paid = order.payment_status == "paid"
now_paid = request.form.get("payment_status") == "paid"
# Reconcile stock around the item swap: undo whatever the old item
# list deducted, then deduct for the new one — skipped for a
# cancelled order, which has no stock reserved against it in the
# first place (cancelling already restored it).
reconcile_stock = inventory_enabled() and order.status != "cancelled"
if reconcile_stock:
restore_for_order(order)
OrderItem.query.filter_by(order_id=order.id).delete()
db.session.flush()
items_total = 0
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=unit_price, quantity=qty)
)
items_total += unit_price * qty
if order_type == "delivery":
delivery_fee = calc_delivery_fee(items_total)
discount_amount = 0
elif order_type == "pickup":
delivery_fee = 0
discount_amount = calc_pickup_discount(items_total)
else:
delivery_fee = 0
discount_amount = 0
manual_discount = max(request.form.get("manual_discount_amount", type=int) or 0, 0)
order.order_type = order_type
order.customer_name = request.form.get("customer_name", "").strip() or "Гість"
order.customer_phone = request.form.get("customer_phone", "").strip() or None
order.address_city = request.form.get("address_city", "").strip() or None
order.address_street = request.form.get("address_street", "").strip() or None
order.address_house = request.form.get("address_house", "").strip() or None
order.address_apartment = request.form.get("address_apartment", "").strip() or None
order.pickup_time = request.form.get("pickup_time", "").strip() or None
order.table_number = request.form.get("table_number", "").strip() or None
order.payment_method = payment_method
order.payment_status = "paid" if now_paid else "unpaid"
order.items_total = items_total
order.delivery_fee = delivery_fee
order.discount_amount = discount_amount
order.manual_discount_amount = manual_discount
order.manual_discount_note = request.form.get("manual_discount_note", "").strip() or None
order.total = max(
0, items_total + delivery_fee - discount_amount - order.promo_discount_amount - manual_discount
)
if now_paid and not was_paid and order.paid_at is None:
order.paid_at = datetime.now(timezone.utc)
db.session.commit()
if reconcile_stock:
deduct_for_order(order)
log_action("order_edit", f"Замовлення {order.display_number()}: відредаговано вручну ({current_user.username})")
if now_paid and not was_paid:
checkbox_prro.fiscalize_in_background(order.id)
flash(f"Замовлення {order.display_number()} оновлено", "success")
return redirect(url_for("admin.orders_detail", order_id=order.id))
existing_product_ids = [i.product_id for i in order.items if i.product_id]
products = (
Product.query.filter(db.or_(Product.is_active.is_(True), Product.id.in_(existing_product_ids)))
.order_by(Product.name)
.all()
)
return render_template(
"admin/order_edit.html",
order=order,
products=products,
order_types=ORDER_TYPES,
order_type_labels=ORDER_TYPE_LABELS,
)
@admin_bp.route("/orders")
@login_required
def orders_list():
order_type = request.args.get("type")
status = request.args.get("status")
payment_status = request.args.get("payment_status")
q = request.args.get("q", "").strip()
query = Order.query
if order_type:
query = query.filter_by(order_type=order_type)
if status:
query = query.filter_by(status=status)
if payment_status:
query = query.filter_by(payment_status=payment_status)
if q:
like = f"%{q}%"
conditions = [Order.customer_name.ilike(like), Order.customer_phone.ilike(like)]
if q.lstrip("№#").isdigit():
conditions.append(Order.number == int(q.lstrip("№#")))
query = query.filter(db.or_(*conditions))
page = request.args.get("page", 1, type=int)
pagination = db.paginate(
query.order_by(Order.created_at.desc()),
page=page,
per_page=50,
error_out=False,
)
return render_template(
"admin/orders.html",
orders=pagination.items,
pagination=pagination,
statuses=ORDER_STATUSES,
order_type=order_type,
status=status,
payment_status=payment_status,
q=q,
)
def _order_timeline(order):
"""Steps for the visual progress stepper on the order detail page —
"courier" only appears for delivery orders."""
steps = ["new", "confirmed", "cooking", "ready"]
if order.order_type == "delivery":
steps.append("courier")
steps.append("completed")
if order.status == "cancelled":
current_index = -1 # nothing shown as done/current — see cancelled banner instead
else:
current_index = steps.index(order.status) if order.status in steps else -1
timeline = []
for i, key in enumerate(steps):
if current_index == -1:
state = "upcoming"
elif i < current_index:
state = "done"
elif i == current_index:
state = "current"
else:
state = "upcoming"
timeline.append({"key": key, "label": ORDER_STATUS_LABELS.get(key, key), "state": state})
return timeline
@admin_bp.route("/orders/<int:order_id>")
@login_required
def orders_detail(order_id):
order = Order.query.get_or_404(order_id)
next_statuses = ORDER_STATUS_NEXT.get(order.status, [])
# "courier" only makes sense for delivery orders
if order.order_type != "delivery" and "courier" in next_statuses:
next_statuses = [s for s in next_statuses if s != "courier"]
return render_template(
"admin/order_detail.html",
order=order,
statuses=ORDER_STATUSES,
next_statuses=next_statuses,
timeline=_order_timeline(order),
checkbox_enabled=checkbox_prro.enabled(),
)
@admin_bp.route("/orders/<int:order_id>/status", methods=["POST"])
@login_required
def orders_status(order_id):
order = Order.query.get_or_404(order_id)
new_status = request.form.get("status")
if new_status in ORDER_STATUSES:
old_status = order.status
if new_status == "cancelled":
order.cancel_reason = request.form.get("cancel_reason", "").strip() or None
order.status = new_status
db.session.commit()
if new_status == "cancelled" and old_status != "cancelled":
restore_for_order(order)
log_action(
"order_status_change",
f"Замовлення {order.display_number()}: «{ORDER_STATUS_LABELS.get(old_status, old_status)}» → "
f"«{ORDER_STATUS_LABELS.get(new_status, new_status)}»"
+ (f" (причина: {order.cancel_reason})" if order.cancel_reason and new_status == "cancelled" else ""),
)
notify_customer_status_change(order)
flash("Статус замовлення оновлено", "success")
return redirect(url_for("admin.orders_detail", order_id=order_id))
@admin_bp.route("/orders/<int:order_id>/mark-paid", methods=["POST"])
@login_required
def orders_mark_paid(order_id):
order = Order.query.get_or_404(order_id)
if order.payment_method == "cash" and order.payment_status != "paid":
order.payment_status = "paid"
order.paid_at = datetime.now(timezone.utc)
db.session.commit()
log_action("order_mark_paid", f"Замовлення {order.display_number()}: позначено оплаченим (готівка)")
checkbox_prro.fiscalize_in_background(order.id)
flash_msg = "Замовлення позначено оплаченим"
if checkbox_prro.enabled():
flash_msg += " — чек формується у фоні, з'явиться на сторінці замовлення за кілька секунд"
flash(flash_msg, "success")
return redirect(request.referrer or url_for("admin.orders_detail", order_id=order_id))
# ---------------------------------------------------------------------------
# Settings
# ---------------------------------------------------------------------------
CHECKBOX_SETTINGS = {
"allow_orders_outside_hours",
"force_closed",
"announcement_enabled",
"customer_status_notifications",
"order_rating_enabled",
"inventory_enabled",
"monobank_test_mode",
"monobank_show_basket",
"liqpay_sandbox_mode",
"checkbox_enabled",
"checkbox_deliver_receipt_to_customer",
"checkbox_auto_close_enabled",
"suppliers_manager_access",
"sidebar_collapsible_menu",
}
FILE_SETTINGS = {"logo_url"}
@admin_bp.route("/settings", methods=["GET", "POST"])
@admin_required
def settings_view():
if request.method == "POST":
for key in DEFAULT_SETTINGS.keys():
if key in FILE_SETTINGS:
continue
if key in CHECKBOX_SETTINGS:
Setting.set(key, "1" if request.form.get(key) else "0")
else:
Setting.set(key, request.form.get(key, DEFAULT_SETTINGS[key]))
logo_file = request.files.get("logo")
if logo_file and logo_file.filename:
ext = logo_file.filename.rsplit(".", 1)[-1].lower()
if ext in ALLOWED_IMAGE_EXT:
filename = f"logo-{uuid.uuid4().hex}.{ext}"
upload_dir = Path(config.UPLOAD_FOLDER)
upload_dir.mkdir(parents=True, exist_ok=True)
logo_file.save(upload_dir / secure_filename(filename))
Setting.set("logo_url", f"/static/uploads/{filename}")
db.session.commit()
log_action("settings_update", "Оновлено налаштування системи")
flash("Налаштування збережено", "success")
return redirect(url_for("admin.settings_view"))
values = {key: Setting.get(key, default) for key, default in DEFAULT_SETTINGS.items()}
return render_template("admin/settings.html", values=values)
@admin_bp.route("/settings/test-monobank", methods=["POST"])
@admin_required
def settings_test_monobank():
from bober_bbq.payments.monobank import verify_token
field = request.args.get("field", "monobank_token")
if field not in ("monobank_token", "monobank_test_token"):
field = "monobank_token"
label = "Тестовий" if field == "monobank_test_token" else "Бойовий"
# Test whatever's in that field right now (even if not saved yet), so
# the admin can check a token before committing to it; if left blank,
# fall back to whatever's already saved for that specific field.
submitted = request.form.get(field, "").strip()
token = submitted or Setting.get(field, "")
ok, message = verify_token(token or None)
flash(f"{label} токен: {message}", "success" if ok else "error")
return redirect(url_for("admin.settings_view"))
@admin_bp.route("/settings/test-liqpay", methods=["POST"])
@admin_required
def settings_test_liqpay():
from bober_bbq.payments.liqpay import verify_token as liqpay_verify_token
# Same "test whatever's in the form right now, even if unsaved" UX as
# settings_test_monobank -- fall back to the saved value for whichever
# field was left blank.
public_key = request.form.get("liqpay_public_key", "").strip() or Setting.get("liqpay_public_key", "")
private_key = request.form.get("liqpay_private_key", "").strip() or Setting.get("liqpay_private_key", "")
ok, message = liqpay_verify_token(public_key or None, private_key or None)
flash(f"LiqPay: {message}", "success" if ok else "error")
return redirect(url_for("admin.settings_view"))
@admin_bp.route("/settings/test-checkbox", methods=["POST"])
@admin_required
def settings_test_checkbox():
login = request.form.get("checkbox_login", "").strip() or None
password = request.form.get("checkbox_password", "").strip() or None
ok, message = checkbox_prro.test_connection(login, password)
flash(f"Checkbox: {message}", "success" if ok else "error")
return redirect(url_for("admin.settings_view"))
@admin_bp.route("/settings/close-checkbox-shift", methods=["POST"])
@admin_required
def settings_close_checkbox_shift():
ok, message = checkbox_prro.close_shift()
flash(f"Зміна каси: {message}", "success" if ok else "error")
return redirect(url_for("admin.settings_view"))
@admin_bp.route("/orders/<int:order_id>/delete", methods=["POST"])
@admin_required
def orders_delete(order_id):
order = Order.query.get_or_404(order_id)
display = order.display_number()
summary = f"{display} ({ORDER_TYPE_LABELS.get(order.order_type, order.order_type)}, {order.total} грн)"
# Cancelling already restores deducted stock — only do it again here if
# that never happened, or a cancelled order being deleted would get its
# ingredients credited back twice.
if order.status != "cancelled":
restore_for_order(order)
# StockMovement rows have no cascade from Order (they're a standalone
# ledger) — detach them instead of leaving order_id pointing at a row
# that no longer exists, which the "Рухи по складу" page already
# handles fine for movements that never had an order (manual adjustments).
StockMovement.query.filter_by(order_id=order.id).update({"order_id": None})
log_action("order_delete", f"Видалено замовлення {summary}")
db.session.delete(order)
db.session.commit()
flash(f"Замовлення {display} видалено остаточно", "success")
return redirect(url_for("admin.orders_list"))
@admin_bp.route("/orders/<int:order_id>/fiscalize", methods=["POST"])
@login_required
def orders_fiscalize(order_id):
order = Order.query.get_or_404(order_id)
checkbox_prro.fiscalize_in_background(order.id)
log_action("order_fiscalize", f"Замовлення {order.display_number()}: запущено формування чека")
flash("Чек формується у фоні — оновіть сторінку за кілька секунд", "success")
return redirect(url_for("admin.orders_detail", order_id=order.id))
def _slugify(name: str) -> str:
base = "".join(ch.lower() if ch.isalnum() else "-" for ch in name).strip("-")
slug = base or uuid.uuid4().hex[:8]
suffix = 1
candidate = slug
while Category.query.filter_by(slug=candidate).first():
suffix += 1
candidate = f"{slug}-{suffix}"
return candidate