Snapshot of in-progress work across local_backend, manager_dashboard, and waiter_pwa (pricing, chat, fiscal, prep zones, recovery codes, CRM, inventory, permissions), plus the nginx/docker-compose deploy fixes for the Unraid + NPM reverse-proxy setup. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
728 lines
28 KiB
Python
728 lines
28 KiB
Python
"""
|
|
Pricing resolution engine.
|
|
|
|
resolve_price() is called once per OrderItem at add-to-order time.
|
|
The result is the final snapshotted unit_price — it never changes after that.
|
|
|
|
Stacking algorithm (per design spec):
|
|
1. Collect all passing modifiers for this product (item-scoped first, then global).
|
|
2. Separate into stackable (allow_stack=1) and non-stackable piles.
|
|
3. From the non-stackable pile, apply only the first passing one (lowest sort_order).
|
|
4. From the stackable pile, apply ALL passing ones in sort_order order,
|
|
each on the running price result.
|
|
5. Apply system-wide rounding to nearest 0.10 as a final pass.
|
|
|
|
Deal evaluation:
|
|
evaluate_deals() is called after items are added. It returns a list of DealOffer
|
|
objects that the API caller surfaces to the waiter as prompts.
|
|
"""
|
|
import json
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timezone, time as dtime
|
|
from typing import Optional
|
|
|
|
from sqlalchemy.orm import Session
|
|
from tz import to_local
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Data structures (no ORM dependency — safe to import anywhere)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@dataclass
|
|
class OrderContext:
|
|
"""Snapshot of order-level facts needed to evaluate conditions."""
|
|
channel: str # 'pos' | 'online' | 'qr' | 'takeaway'
|
|
now: datetime # current UTC time
|
|
cart_items: list # list of {product_id, category_id, quantity, unit_price}
|
|
active_price_group_ids: set # set of PriceGroup.id that are currently active
|
|
user_tier: Optional[str] = None # 'bronze'|'silver'|'gold' — placeholder
|
|
|
|
|
|
@dataclass
|
|
class PriceEvent:
|
|
"""In-memory representation of a price event, before it's written to DB."""
|
|
order_id: int
|
|
order_item_id: Optional[int] # set after OrderItem is committed
|
|
event_type: str
|
|
modifier_id: Optional[int] = None
|
|
deal_id: Optional[int] = None
|
|
price_before: Optional[float] = None
|
|
price_after: Optional[float] = None
|
|
delta_amount: Optional[float] = None
|
|
applied_by_user_id: Optional[int] = None
|
|
waiter_note: Optional[str] = None
|
|
conditions_snapshot: Optional[dict] = None
|
|
selected_item_ids: Optional[list] = None
|
|
|
|
|
|
@dataclass
|
|
class DealOffer:
|
|
"""Returned to the API when a deal is triggered and needs waiter confirmation."""
|
|
deal_id: int
|
|
deal_name: str
|
|
action_type: str # mirrors Deal.action_type
|
|
# For free_item: the single product to add
|
|
free_item_id: Optional[int] = None
|
|
free_item_name: Optional[str] = None
|
|
# For free_choice: pool of selectable products
|
|
free_choices: list = field(default_factory=list) # [{id, name}]
|
|
free_quantity: int = 1
|
|
# For price-modifying deals applied to a specific item
|
|
target_order_item_id: Optional[int] = None
|
|
price_before: Optional[float] = None
|
|
price_after: Optional[float] = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Condition evaluators
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _eval_condition(ctype: str, params: dict, ctx: OrderContext,
|
|
product_id: int, quantity: float) -> tuple[bool, dict]:
|
|
"""
|
|
Evaluate one condition. Returns (passed, snapshot_dict).
|
|
snapshot_dict records the actual runtime value — written to price_event_log for audit.
|
|
Unknown or placeholder conditions pass silently (forward-compatible).
|
|
"""
|
|
now_local = to_local(ctx.now)
|
|
current_time = now_local.time()
|
|
current_date = now_local.date()
|
|
current_weekday = now_local.weekday() # Mon=0 Sun=6
|
|
|
|
if ctype == "time_range":
|
|
t_from = dtime.fromisoformat(params["from"])
|
|
t_to = dtime.fromisoformat(params["to"])
|
|
if t_from <= t_to:
|
|
passed = t_from <= current_time <= t_to
|
|
else:
|
|
# Crosses midnight
|
|
passed = current_time >= t_from or current_time <= t_to
|
|
return passed, {"time_range": current_time.strftime("%H:%M")}
|
|
|
|
if ctype == "date_range":
|
|
from datetime import date
|
|
d_from = date.fromisoformat(params["from"])
|
|
d_to = date.fromisoformat(params["to"])
|
|
passed = d_from <= current_date <= d_to
|
|
return passed, {"date_range": current_date.isoformat()}
|
|
|
|
if ctype == "specific_date":
|
|
from datetime import date
|
|
passed = current_date.isoformat() in params.get("dates", [])
|
|
return passed, {"specific_date": current_date.isoformat()}
|
|
|
|
if ctype == "day_of_week":
|
|
passed = current_weekday in params.get("days", [])
|
|
return passed, {"day_of_week": current_weekday}
|
|
|
|
if ctype == "min_item_quantity":
|
|
cart_qty = sum(
|
|
ci["quantity"] for ci in ctx.cart_items
|
|
if ci["product_id"] == product_id
|
|
)
|
|
passed = cart_qty >= params.get("min", 1)
|
|
return passed, {"min_item_quantity": cart_qty}
|
|
|
|
if ctype == "min_category_qty":
|
|
cat_id = params.get("category_id")
|
|
cart_qty = sum(
|
|
ci["quantity"] for ci in ctx.cart_items
|
|
if ci.get("category_id") == cat_id
|
|
)
|
|
passed = cart_qty >= params.get("min", 1)
|
|
return passed, {"min_category_qty": cart_qty}
|
|
|
|
if ctype == "min_order_value":
|
|
total = sum(ci["quantity"] * ci["unit_price"] for ci in ctx.cart_items)
|
|
passed = total >= params.get("min", 0.0)
|
|
return passed, {"min_order_value": round(total, 2)}
|
|
|
|
if ctype == "order_channel":
|
|
passed = ctx.channel in params.get("channels", [])
|
|
return passed, {"order_channel": ctx.channel}
|
|
|
|
if ctype == "price_group_active":
|
|
pgid = params.get("price_group_id")
|
|
passed = pgid in ctx.active_price_group_ids
|
|
return passed, {"price_group_active": passed}
|
|
|
|
if ctype == "user_tier":
|
|
passed = ctx.user_tier in params.get("tiers", [])
|
|
return passed, {"user_tier": ctx.user_tier}
|
|
|
|
if ctype == "low_stock":
|
|
# Placeholder until inventory is implemented — always passes silently
|
|
return True, {"low_stock": "placeholder"}
|
|
|
|
# Unknown condition type — pass silently for forward compatibility
|
|
return True, {ctype: "unknown_type_skipped"}
|
|
|
|
|
|
def _all_conditions_pass(conditions, ctx: OrderContext,
|
|
product_id: int, quantity: float) -> tuple[bool, dict]:
|
|
"""AND-evaluate all conditions. Returns (all_passed, merged_snapshot)."""
|
|
snapshot = {}
|
|
for cond in conditions:
|
|
params = json.loads(cond.params) if isinstance(cond.params, str) else cond.params
|
|
passed, snap = _eval_condition(cond.condition_type, params, ctx, product_id, quantity)
|
|
snapshot.update(snap)
|
|
if not passed:
|
|
return False, snapshot
|
|
return True, snapshot
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Modifier action math
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _apply_action(price: float, action_type: str, action_value: float,
|
|
round_to: Optional[str]) -> float:
|
|
if action_type == "set":
|
|
result = action_value
|
|
elif action_type == "add_amount":
|
|
result = price + action_value
|
|
elif action_type == "add_percent":
|
|
result = price * (1 + action_value / 100.0)
|
|
else:
|
|
result = price # unknown action — no-op
|
|
|
|
result = max(result, 0.0) # price can never go below 0
|
|
|
|
if round_to:
|
|
result = _round_to(result, round_to)
|
|
|
|
return result
|
|
|
|
|
|
def _round_to(value: float, mode: str) -> float:
|
|
if mode == "0.05":
|
|
return round(value / 0.05) * 0.05
|
|
if mode == "0.10":
|
|
return round(value / 0.10) * 0.10
|
|
if mode == "0.20":
|
|
return round(value / 0.20) * 0.20
|
|
if mode == "0.50":
|
|
return round(value / 0.50) * 0.50
|
|
if mode == "x.99":
|
|
return float(int(value)) + 0.99 if value >= 1 else value
|
|
if mode == "x.00":
|
|
return float(round(value))
|
|
return value
|
|
|
|
|
|
def _system_round(value: float) -> float:
|
|
"""System-wide final rounding: nearest 0.10."""
|
|
return round(round(value / 0.10) * 0.10, 2)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PriceGroup auto-schedule evaluation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _price_group_is_active(pg, now: datetime) -> bool:
|
|
"""
|
|
Returns True if this price group should currently be considered active.
|
|
Checks is_active flag, then auto-schedule if configured.
|
|
"""
|
|
if pg.auto_enable_time and pg.auto_disable_time and pg.auto_days:
|
|
now_local = now.astimezone()
|
|
weekday = now_local.weekday()
|
|
try:
|
|
auto_days = json.loads(pg.auto_days)
|
|
except (ValueError, TypeError):
|
|
auto_days = []
|
|
if weekday in auto_days:
|
|
t_enable = dtime.fromisoformat(pg.auto_enable_time)
|
|
t_disable = dtime.fromisoformat(pg.auto_disable_time)
|
|
current = now_local.time()
|
|
if t_enable <= t_disable:
|
|
return t_enable <= current <= t_disable
|
|
else:
|
|
return current >= t_enable or current <= t_disable
|
|
return bool(pg.is_active)
|
|
|
|
|
|
def build_active_price_group_ids(db: Session, now: datetime) -> set:
|
|
"""Fetch all price groups once per request and return active IDs."""
|
|
from models.pricing import PriceGroup
|
|
groups = db.query(PriceGroup).all()
|
|
return {pg.id for pg in groups if _price_group_is_active(pg, now)}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Modifier target matching
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _parse_ids(raw) -> list:
|
|
if not raw:
|
|
return []
|
|
if isinstance(raw, list):
|
|
return raw
|
|
try:
|
|
return json.loads(raw)
|
|
except Exception:
|
|
return []
|
|
|
|
|
|
def _modifier_targets_product(modifier, product, db: Session) -> bool:
|
|
"""Return True if any target row on a global modifier covers this product."""
|
|
from models.product import Product
|
|
for t in modifier.targets:
|
|
if t.target_type == "all":
|
|
return True
|
|
if t.target_type == "item":
|
|
ids = _parse_ids(t.target_ids) or ([t.target_id] if t.target_id else [])
|
|
if product.id in ids:
|
|
return True
|
|
if t.target_type == "category":
|
|
ids = _parse_ids(t.target_ids) or ([t.target_id] if t.target_id else [])
|
|
if product.category_id in ids:
|
|
return True
|
|
if t.target_type == "prep_zone":
|
|
ids = _parse_ids(t.target_ids) or ([t.target_id] if t.target_id else [])
|
|
zone_ids = {pz.id for pz in product.prep_zones}
|
|
if any(i in zone_ids for i in ids):
|
|
return True
|
|
if t.target_type == "tag":
|
|
prod_tags = set(json.loads(product.tags) if product.tags else [])
|
|
tags = _parse_ids(t.target_tags) or ([t.target_tag] if t.target_tag else [])
|
|
if any(tag in prod_tags for tag in tags):
|
|
return True
|
|
return False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main resolver
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def resolve_price(
|
|
product,
|
|
quantity: float,
|
|
options_extra: float,
|
|
ctx: OrderContext,
|
|
db: Session,
|
|
) -> tuple[float, list[PriceEvent]]:
|
|
"""
|
|
Compute the final unit_price for one order item.
|
|
|
|
Returns (final_unit_price, list_of_PriceEvent_to_log).
|
|
PriceEvents have order_item_id=None — caller sets it after the OrderItem is committed.
|
|
|
|
order_id must be set on the PriceEvents before writing them; the caller does this.
|
|
"""
|
|
from models.pricing import PriceModifier
|
|
|
|
base = product.base_price + options_extra
|
|
events: list[PriceEvent] = []
|
|
|
|
# --- Load modifiers ---
|
|
# Item-scoped modifiers for this product
|
|
item_modifiers = (
|
|
db.query(PriceModifier)
|
|
.filter(
|
|
PriceModifier.scope == "item",
|
|
PriceModifier.item_id == product.id,
|
|
PriceModifier.is_active == 1,
|
|
)
|
|
.order_by(PriceModifier.sort_order)
|
|
.all()
|
|
)
|
|
|
|
# Global modifiers (all active ones, filtered by target in Python)
|
|
all_global = (
|
|
db.query(PriceModifier)
|
|
.filter(PriceModifier.scope == "global", PriceModifier.is_active == 1)
|
|
.order_by(PriceModifier.sort_order)
|
|
.all()
|
|
)
|
|
global_modifiers = [
|
|
m for m in all_global
|
|
if _modifier_targets_product(m, product, db)
|
|
]
|
|
|
|
# Item-scoped take priority: evaluated first, then global appended
|
|
all_modifiers = item_modifiers + global_modifiers
|
|
|
|
if not all_modifiers:
|
|
return _system_round(base), events
|
|
|
|
# --- Separate stackable vs non-stackable ---
|
|
stackable = []
|
|
non_stackable = []
|
|
for m in all_modifiers:
|
|
passed, snapshot = _all_conditions_pass(m.conditions, ctx, product.id, quantity)
|
|
if passed:
|
|
if m.allow_stack:
|
|
stackable.append((m, snapshot))
|
|
else:
|
|
non_stackable.append((m, snapshot))
|
|
|
|
price = base
|
|
|
|
# --- Apply first non-stackable ---
|
|
if non_stackable:
|
|
m, snapshot = non_stackable[0]
|
|
price_before = price
|
|
price = _apply_action(price, m.action_type, m.action_value, m.round_to)
|
|
events.append(PriceEvent(
|
|
order_id=0, # caller will fill in
|
|
order_item_id=None,
|
|
event_type="modifier_applied",
|
|
modifier_id=m.id,
|
|
price_before=price_before,
|
|
price_after=price,
|
|
delta_amount=round(price - price_before, 4),
|
|
conditions_snapshot=snapshot,
|
|
))
|
|
|
|
# --- Apply all stackable modifiers ---
|
|
for m, snapshot in stackable:
|
|
price_before = price
|
|
price = _apply_action(price, m.action_type, m.action_value, m.round_to)
|
|
events.append(PriceEvent(
|
|
order_id=0,
|
|
order_item_id=None,
|
|
event_type="modifier_applied",
|
|
modifier_id=m.id,
|
|
price_before=price_before,
|
|
price_after=price,
|
|
delta_amount=round(price - price_before, 4),
|
|
conditions_snapshot=snapshot,
|
|
))
|
|
|
|
final = _system_round(price)
|
|
|
|
# Update the last event's price_after to reflect the final rounded value
|
|
if events:
|
|
events[-1].price_after = final
|
|
events[-1].delta_amount = round(final - events[-1].price_before, 4)
|
|
|
|
return final, events
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Deal evaluation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _deal_targets_met(deal, ctx: OrderContext, db: Session) -> bool:
|
|
"""Return True if the cart contains items that satisfy the deal's target rules."""
|
|
from models.product import Product
|
|
|
|
if not deal.targets:
|
|
return True # no target = always matches
|
|
|
|
for target in deal.targets:
|
|
if target.target_type == "any":
|
|
if ctx.cart_items:
|
|
return True
|
|
if target.target_type == "item":
|
|
ids = _parse_ids(target.target_ids) or ([target.target_id] if target.target_id else [])
|
|
if any(ci["product_id"] in ids for ci in ctx.cart_items):
|
|
return True
|
|
if target.target_type == "category":
|
|
ids = _parse_ids(target.target_ids) or ([target.target_id] if target.target_id else [])
|
|
if any(ci.get("category_id") in ids for ci in ctx.cart_items):
|
|
return True
|
|
if target.target_type == "tag":
|
|
tags = _parse_ids(target.target_tags) or ([target.target_tag] if target.target_tag else [])
|
|
if tags:
|
|
for ci in ctx.cart_items:
|
|
prod = db.query(Product).filter(Product.id == ci["product_id"]).first()
|
|
if prod:
|
|
prod_tags = set(json.loads(prod.tags) if prod.tags else [])
|
|
if any(tag in prod_tags for tag in tags):
|
|
return True
|
|
return False
|
|
|
|
|
|
def evaluate_deals(
|
|
order_id: int,
|
|
ctx: OrderContext,
|
|
already_fired_deal_ids: set,
|
|
db: Session,
|
|
) -> list[DealOffer]:
|
|
"""
|
|
Check all active deals against the current cart. Returns DealOffer objects
|
|
for deals that are newly triggered (not already fired this order).
|
|
|
|
already_fired_deal_ids: set of deal IDs that have already been offered/applied
|
|
on this order (derived from price_event_log by the caller).
|
|
"""
|
|
from models.pricing import Deal
|
|
from models.product import Product
|
|
|
|
deals = db.query(Deal).filter(Deal.is_active == 1).order_by(Deal.sort_order).all()
|
|
offers: list[DealOffer] = []
|
|
|
|
for deal in deals:
|
|
if deal.id in already_fired_deal_ids:
|
|
continue
|
|
|
|
# Check conditions
|
|
conditions_pass, _ = _all_conditions_pass(deal.conditions, ctx, 0, 0)
|
|
if not conditions_pass:
|
|
continue
|
|
|
|
# Check cart targets
|
|
if not _deal_targets_met(deal, ctx, db):
|
|
continue
|
|
|
|
# Build the offer
|
|
offer = DealOffer(
|
|
deal_id=deal.id,
|
|
deal_name=deal.name,
|
|
action_type=deal.action_type,
|
|
)
|
|
|
|
if deal.action_type == "free_item" and deal.action_free_item_id:
|
|
prod = db.query(Product).filter(Product.id == deal.action_free_item_id).first()
|
|
offer.free_item_id = deal.action_free_item_id
|
|
offer.free_item_name = prod.name if prod else None
|
|
offer.free_quantity = deal.action_free_quantity
|
|
|
|
elif deal.action_type == "free_choice" and deal.action_free_target_ids:
|
|
try:
|
|
target_ids = json.loads(deal.action_free_target_ids)
|
|
except (ValueError, TypeError):
|
|
target_ids = []
|
|
|
|
if deal.action_free_target_type == "item":
|
|
prods = db.query(Product).filter(Product.id.in_(target_ids)).all()
|
|
offer.free_choices = [{"id": p.id, "name": p.name} for p in prods]
|
|
elif deal.action_free_target_type == "category":
|
|
prods = db.query(Product).filter(
|
|
Product.category_id.in_(target_ids),
|
|
Product.lifecycle_status == "active",
|
|
Product.is_available == True, # noqa: E712
|
|
).all()
|
|
offer.free_choices = [{"id": p.id, "name": p.name} for p in prods]
|
|
elif deal.action_free_target_type == "tag":
|
|
all_prods = db.query(Product).filter(
|
|
Product.lifecycle_status == "active",
|
|
Product.is_available == True, # noqa: E712
|
|
).all()
|
|
offer.free_choices = [
|
|
{"id": p.id, "name": p.name}
|
|
for p in all_prods
|
|
if any(tag in (json.loads(p.tags) if p.tags else []) for tag in target_ids)
|
|
]
|
|
offer.free_quantity = deal.action_free_quantity
|
|
|
|
offers.append(offer)
|
|
|
|
return offers
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Waiter discount limit enforcement
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def check_waiter_discount_limits(
|
|
waiter_id: int,
|
|
order_id: int,
|
|
item_ids: list[int],
|
|
discount_percent: float,
|
|
item_unit_prices: dict[int, float], # {order_item_id: current_unit_price}
|
|
db: Session,
|
|
) -> tuple[bool, str]:
|
|
"""
|
|
Validate that applying the given discount does not exceed any configured limit.
|
|
Returns (ok, error_message). error_message is empty string when ok=True.
|
|
"""
|
|
from models.pricing import WaiterDiscountSettings
|
|
from models.order import OrderDiscount
|
|
from models.settings import PosSettings
|
|
from models.business_day import BusinessDay
|
|
from models.shift import WaiterShift
|
|
|
|
# --- Global kill-switch ---
|
|
global_row = db.query(PosSettings).filter(PosSettings.key == "discounts.enabled").first()
|
|
if global_row and global_row.value == "false":
|
|
return False, "Discounts are globally disabled"
|
|
|
|
# --- Per-waiter settings ---
|
|
settings = db.query(WaiterDiscountSettings).filter(
|
|
WaiterDiscountSettings.user_id == waiter_id
|
|
).first()
|
|
|
|
if not settings or not settings.can_apply_discounts:
|
|
return False, "You do not have permission to apply discounts"
|
|
|
|
# --- Validate percent limits ---
|
|
if settings.max_discount_percent is not None:
|
|
if discount_percent > settings.max_discount_percent:
|
|
return False, f"Discount exceeds your maximum of {settings.max_discount_percent:.0f}%"
|
|
|
|
# --- Compute proposed euro amounts ---
|
|
proposed_amounts = {
|
|
item_id: round(unit_price * discount_percent / 100.0, 2)
|
|
for item_id, unit_price in item_unit_prices.items()
|
|
if item_id in item_ids
|
|
}
|
|
proposed_total = sum(proposed_amounts.values())
|
|
proposed_count = len(item_ids)
|
|
|
|
# --- Per-item amount limit ---
|
|
if settings.max_discount_amount is not None:
|
|
for item_id, amount in proposed_amounts.items():
|
|
if amount > settings.max_discount_amount:
|
|
return False, f"Single-item discount of €{amount:.2f} exceeds your limit of €{settings.max_discount_amount:.2f}"
|
|
|
|
# --- Items per order ---
|
|
if settings.max_items_per_order is not None:
|
|
existing_count_order = db.query(OrderDiscount).filter(
|
|
OrderDiscount.order_id == order_id
|
|
).count()
|
|
if existing_count_order + proposed_count > settings.max_items_per_order:
|
|
remaining = settings.max_items_per_order - existing_count_order
|
|
return False, f"You can only discount {remaining} more item(s) on this order"
|
|
|
|
# --- Current workday ---
|
|
bd = db.query(BusinessDay).filter(BusinessDay.closed_at == None).first() # noqa: E711
|
|
if bd:
|
|
# Items per workday
|
|
if settings.max_items_per_workday is not None:
|
|
from models.order import Order
|
|
wd_order_ids = [
|
|
o.id for o in db.query(Order).filter(Order.business_day_id == bd.id).all()
|
|
]
|
|
wd_count = db.query(OrderDiscount).filter(
|
|
OrderDiscount.applied_by == waiter_id,
|
|
OrderDiscount.order_id.in_(wd_order_ids),
|
|
).count()
|
|
if wd_count + proposed_count > settings.max_items_per_workday:
|
|
remaining = settings.max_items_per_workday - wd_count
|
|
return False, f"You can only discount {remaining} more item(s) today"
|
|
|
|
# Total value per workday
|
|
if settings.max_total_value_workday is not None:
|
|
from models.order import Order
|
|
wd_order_ids = [
|
|
o.id for o in db.query(Order).filter(Order.business_day_id == bd.id).all()
|
|
]
|
|
existing_rows = db.query(OrderDiscount).filter(
|
|
OrderDiscount.applied_by == waiter_id,
|
|
OrderDiscount.order_id.in_(wd_order_ids),
|
|
).all()
|
|
existing_wd_total = sum(_discount_euro(r) for r in existing_rows)
|
|
if existing_wd_total + proposed_total > settings.max_total_value_workday:
|
|
remaining = settings.max_total_value_workday - existing_wd_total
|
|
return False, f"You have only €{remaining:.2f} of discount budget left today"
|
|
|
|
# --- Current shift ---
|
|
shift = db.query(WaiterShift).filter(
|
|
WaiterShift.waiter_id == waiter_id,
|
|
WaiterShift.ended_at == None, # noqa: E711
|
|
).order_by(WaiterShift.started_at.desc()).first()
|
|
|
|
if shift:
|
|
# Items per shift
|
|
if settings.max_items_per_shift is not None:
|
|
from models.order import Order
|
|
shift_order_ids = [
|
|
o.id for o in db.query(Order).filter(
|
|
Order.opened_at >= shift.started_at
|
|
).all()
|
|
]
|
|
shift_count = db.query(OrderDiscount).filter(
|
|
OrderDiscount.applied_by == waiter_id,
|
|
OrderDiscount.order_id.in_(shift_order_ids),
|
|
).count()
|
|
if shift_count + proposed_count > settings.max_items_per_shift:
|
|
remaining = settings.max_items_per_shift - shift_count
|
|
return False, f"You can only discount {remaining} more item(s) this shift"
|
|
|
|
# Total value per shift
|
|
if settings.max_total_value_shift is not None:
|
|
from models.order import Order
|
|
shift_order_ids = [
|
|
o.id for o in db.query(Order).filter(
|
|
Order.opened_at >= shift.started_at
|
|
).all()
|
|
]
|
|
existing_rows = db.query(OrderDiscount).filter(
|
|
OrderDiscount.applied_by == waiter_id,
|
|
OrderDiscount.order_id.in_(shift_order_ids),
|
|
).all()
|
|
existing_shift_total = sum(_discount_euro(r) for r in existing_rows)
|
|
if existing_shift_total + proposed_total > settings.max_total_value_shift:
|
|
remaining = settings.max_total_value_shift - existing_shift_total
|
|
return False, f"You have only €{remaining:.2f} of discount budget left this shift"
|
|
|
|
# --- Global limits from pos_settings ---
|
|
def _gs(key):
|
|
row = db.query(PosSettings).filter(PosSettings.key == key).first()
|
|
return float(row.value) if row and row.value else None
|
|
|
|
g_max_wd_value = _gs("discounts.max_total_value_workday")
|
|
g_max_shift_val = _gs("discounts.max_total_value_shift")
|
|
g_max_shift_cnt = _gs("discounts.max_items_per_shift")
|
|
|
|
if bd and g_max_wd_value is not None:
|
|
from models.order import Order
|
|
wd_order_ids = [o.id for o in db.query(Order).filter(Order.business_day_id == bd.id).all()]
|
|
all_wd_rows = db.query(OrderDiscount).filter(
|
|
OrderDiscount.order_id.in_(wd_order_ids)
|
|
).all()
|
|
all_wd_total = sum(_discount_euro(r) for r in all_wd_rows)
|
|
if all_wd_total + proposed_total > g_max_wd_value:
|
|
remaining = g_max_wd_value - all_wd_total
|
|
return False, f"Store-wide discount budget has only €{remaining:.2f} left today"
|
|
|
|
if shift and (g_max_shift_val is not None or g_max_shift_cnt is not None):
|
|
from models.order import Order
|
|
shift_order_ids = [
|
|
o.id for o in db.query(Order).filter(Order.opened_at >= shift.started_at).all()
|
|
]
|
|
all_shift_rows = db.query(OrderDiscount).filter(
|
|
OrderDiscount.order_id.in_(shift_order_ids)
|
|
).all()
|
|
if g_max_shift_val is not None:
|
|
total = sum(_discount_euro(r) for r in all_shift_rows)
|
|
if total + proposed_total > g_max_shift_val:
|
|
remaining = g_max_shift_val - total
|
|
return False, f"Store-wide shift discount budget has only €{remaining:.2f} left"
|
|
if g_max_shift_cnt is not None:
|
|
count = len(all_shift_rows)
|
|
if count + proposed_count > int(g_max_shift_cnt):
|
|
remaining = int(g_max_shift_cnt) - count
|
|
return False, f"Store-wide shift item limit: only {remaining} discount(s) left"
|
|
|
|
return True, ""
|
|
|
|
|
|
def _discount_euro(row) -> float:
|
|
"""Convert an OrderDiscount row to a euro amount."""
|
|
if row.price_before is not None and row.price_after is not None:
|
|
return max(0.0, row.price_before - row.price_after)
|
|
# Fallback for legacy rows without price_before/after
|
|
if row.discount_type == "fixed":
|
|
return row.discount_value
|
|
return 0.0 # percent rows without snapshots can't be computed retroactively
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Log helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def write_price_events(events: list[PriceEvent], order_id: int,
|
|
order_item_id: int, db: Session):
|
|
"""Persist PriceEvent objects to price_event_log. Call after OrderItem is committed."""
|
|
from models.pricing import PriceEventLog
|
|
for ev in events:
|
|
db.add(PriceEventLog(
|
|
order_id=order_id,
|
|
order_item_id=order_item_id,
|
|
event_type=ev.event_type,
|
|
modifier_id=ev.modifier_id,
|
|
deal_id=ev.deal_id,
|
|
price_before=ev.price_before,
|
|
price_after=ev.price_after,
|
|
delta_amount=ev.delta_amount,
|
|
applied_by_user_id=ev.applied_by_user_id,
|
|
waiter_note=ev.waiter_note,
|
|
conditions_snapshot=json.dumps(ev.conditions_snapshot) if ev.conditions_snapshot else None,
|
|
selected_item_ids=json.dumps(ev.selected_item_ids) if ev.selected_item_ids else None,
|
|
))
|