feat: bump client-services (accumulated feature work + deploy fixes)

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>
This commit is contained in:
2026-07-19 10:00:14 +03:00
parent 02ec1aa28f
commit 34ae328b0d
182 changed files with 34874 additions and 3556 deletions

View File

@@ -0,0 +1,727 @@
"""
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,
))