Files
xenia-pos-local/local_backend/routers/pricing.py
bonamin 34ae328b0d 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>
2026-07-19 10:00:14 +03:00

685 lines
22 KiB
Python

import json
from datetime import datetime, timezone
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from database import get_db
from models.pricing import (
PriceGroup, PriceModifier, PriceModifierCondition, PriceModifierTarget,
Deal, DealCondition, DealTarget,
PriceEventLog, WaiterDiscountSettings,
)
from schemas.pricing import (
PriceGroupCreate, PriceGroupUpdate, PriceGroupOut,
PriceModifierCreate, PriceModifierUpdate, PriceModifierOut,
PriceModifierReorderRequest,
DealCreate, DealUpdate, DealOut,
WaiterDiscountSettingsIn, WaiterDiscountSettingsOut,
PriceEventOut,
)
from routers.deps import get_current_user, require_manager
from models.user import User
router = APIRouter()
def _utcnow():
return datetime.now(timezone.utc)
# =============================================================================
# PRICE GROUPS
# =============================================================================
@router.get("/groups", response_model=List[PriceGroupOut])
def list_price_groups(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
return db.query(PriceGroup).order_by(PriceGroup.name).all()
@router.post("/groups", response_model=PriceGroupOut, status_code=status.HTTP_201_CREATED)
def create_price_group(
body: PriceGroupCreate,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
pg = PriceGroup(
name=body.name,
description=body.description,
color=body.color,
is_active=int(body.is_active),
auto_enable_time=body.auto_enable_time,
auto_disable_time=body.auto_disable_time,
auto_days=json.dumps(body.auto_days) if body.auto_days is not None else None,
created_by=user.id,
)
db.add(pg)
db.commit()
db.refresh(pg)
return pg
@router.put("/groups/{group_id}", response_model=PriceGroupOut)
def update_price_group(
group_id: int,
body: PriceGroupUpdate,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
pg = db.query(PriceGroup).filter(PriceGroup.id == group_id).first()
if not pg:
raise HTTPException(status_code=404, detail="Price group not found")
pg.name = body.name
pg.description = body.description
pg.color = body.color
pg.is_active = int(body.is_active)
pg.auto_enable_time = body.auto_enable_time
pg.auto_disable_time = body.auto_disable_time
pg.auto_days = json.dumps(body.auto_days) if body.auto_days is not None else None
db.commit()
db.refresh(pg)
return pg
@router.patch("/groups/{group_id}/toggle", response_model=PriceGroupOut)
def toggle_price_group(
group_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
pg = db.query(PriceGroup).filter(PriceGroup.id == group_id).first()
if not pg:
raise HTTPException(status_code=404, detail="Price group not found")
pg.is_active = 0 if pg.is_active else 1
db.commit()
db.refresh(pg)
return pg
@router.delete("/groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_price_group(
group_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
pg = db.query(PriceGroup).filter(PriceGroup.id == group_id).first()
if not pg:
raise HTTPException(status_code=404, detail="Price group not found")
db.delete(pg)
db.commit()
# =============================================================================
# PRICE MODIFIERS
# =============================================================================
@router.get("/modifiers", response_model=List[PriceModifierOut])
def list_modifiers(
scope: Optional[str] = None,
item_id: Optional[int] = None,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
q = db.query(PriceModifier)
if scope:
q = q.filter(PriceModifier.scope == scope)
if item_id is not None:
q = q.filter(PriceModifier.item_id == item_id)
return q.order_by(PriceModifier.sort_order).all()
@router.get("/modifiers/favorites", response_model=List[PriceModifierOut])
def list_favorite_modifiers(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Returns modifiers flagged as favorites for the dashboard quick-toggle panel."""
return (
db.query(PriceModifier)
.filter(PriceModifier.is_favorite == 1)
.order_by(PriceModifier.sort_order)
.all()
)
@router.post("/modifiers", response_model=PriceModifierOut, status_code=status.HTTP_201_CREATED)
def create_modifier(
body: PriceModifierCreate,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
m = PriceModifier(
name=body.name,
description=body.description,
color=body.color,
is_active=int(body.is_active),
is_favorite=int(body.is_favorite),
allow_stack=int(body.allow_stack),
sort_order=body.sort_order,
scope=body.scope,
item_id=body.item_id,
action_type=body.action_type,
action_value=body.action_value,
round_to=body.round_to,
created_by=user.id,
)
db.add(m)
db.flush()
_sync_conditions(m, body.conditions, db, model="modifier")
_sync_modifier_targets(m, body.targets, db)
db.commit()
db.refresh(m)
return m
@router.put("/modifiers/{modifier_id}", response_model=PriceModifierOut)
def update_modifier(
modifier_id: int,
body: PriceModifierUpdate,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
m = db.query(PriceModifier).filter(PriceModifier.id == modifier_id).first()
if not m:
raise HTTPException(status_code=404, detail="Modifier not found")
m.name = body.name
m.description = body.description
m.color = body.color
m.is_active = int(body.is_active)
m.is_favorite = int(body.is_favorite)
m.allow_stack = int(body.allow_stack)
m.sort_order = body.sort_order
m.scope = body.scope
m.item_id = body.item_id
m.action_type = body.action_type
m.action_value = body.action_value
m.round_to = body.round_to
m.updated_at = _utcnow()
m.updated_by = user.id
# Replace conditions and targets
for c in list(m.conditions):
db.delete(c)
for t in list(m.targets):
db.delete(t)
db.flush()
_sync_conditions(m, body.conditions, db, model="modifier")
_sync_modifier_targets(m, body.targets, db)
db.commit()
db.refresh(m)
return m
@router.patch("/modifiers/{modifier_id}/toggle", response_model=PriceModifierOut)
def toggle_modifier(
modifier_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
m = db.query(PriceModifier).filter(PriceModifier.id == modifier_id).first()
if not m:
raise HTTPException(status_code=404, detail="Modifier not found")
m.is_active = 0 if m.is_active else 1
m.updated_at = _utcnow()
m.updated_by = user.id
db.commit()
db.refresh(m)
return m
@router.patch("/modifiers/reorder")
def reorder_modifiers(
body: PriceModifierReorderRequest,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
for item in body.items:
db.query(PriceModifier).filter(PriceModifier.id == item.id).update(
{"sort_order": item.sort_order}
)
db.commit()
return {"ok": True}
@router.delete("/modifiers/{modifier_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_modifier(
modifier_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
m = db.query(PriceModifier).filter(PriceModifier.id == modifier_id).first()
if not m:
raise HTTPException(status_code=404, detail="Modifier not found")
db.delete(m)
db.commit()
# =============================================================================
# DEALS
# =============================================================================
@router.get("/deals", response_model=List[DealOut])
def list_deals(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
return db.query(Deal).order_by(Deal.sort_order).all()
@router.post("/deals", response_model=DealOut, status_code=status.HTTP_201_CREATED)
def create_deal(
body: DealCreate,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
d = Deal(
name=body.name,
description=body.description,
color=body.color,
is_active=int(body.is_active),
sort_order=body.sort_order,
action_type=body.action_type,
action_modifier_id=body.action_modifier_id,
action_value=body.action_value,
action_free_item_id=body.action_free_item_id,
action_free_target_type=body.action_free_target_type,
action_free_target_ids=(
json.dumps(body.action_free_target_ids)
if body.action_free_target_ids is not None else None
),
action_free_quantity=body.action_free_quantity,
created_by=user.id,
)
db.add(d)
db.flush()
_sync_conditions(d, body.conditions, db, model="deal")
_sync_deal_targets(d, body.targets, db)
db.commit()
db.refresh(d)
return d
@router.put("/deals/{deal_id}", response_model=DealOut)
def update_deal(
deal_id: int,
body: DealUpdate,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
d = db.query(Deal).filter(Deal.id == deal_id).first()
if not d:
raise HTTPException(status_code=404, detail="Deal not found")
d.name = body.name
d.description = body.description
d.color = body.color
d.is_active = int(body.is_active)
d.sort_order = body.sort_order
d.action_type = body.action_type
d.action_modifier_id = body.action_modifier_id
d.action_value = body.action_value
d.action_free_item_id = body.action_free_item_id
d.action_free_target_type = body.action_free_target_type
d.action_free_target_ids = (
json.dumps(body.action_free_target_ids)
if body.action_free_target_ids is not None else None
)
d.action_free_quantity = body.action_free_quantity
for c in list(d.conditions):
db.delete(c)
for t in list(d.targets):
db.delete(t)
db.flush()
_sync_conditions(d, body.conditions, db, model="deal")
_sync_deal_targets(d, body.targets, db)
db.commit()
db.refresh(d)
return d
@router.patch("/deals/{deal_id}/toggle", response_model=DealOut)
def toggle_deal(
deal_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
d = db.query(Deal).filter(Deal.id == deal_id).first()
if not d:
raise HTTPException(status_code=404, detail="Deal not found")
d.is_active = 0 if d.is_active else 1
db.commit()
db.refresh(d)
return d
@router.delete("/deals/{deal_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_deal(
deal_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
d = db.query(Deal).filter(Deal.id == deal_id).first()
if not d:
raise HTTPException(status_code=404, detail="Deal not found")
db.delete(d)
db.commit()
# =============================================================================
# WAITER DISCOUNT SETTINGS
# =============================================================================
@router.get("/discount-settings/global")
def get_global_discount_settings(
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
from models.settings import PosSettings
keys = [
"discounts.enabled",
"discounts.max_total_value_workday",
"discounts.max_total_value_shift",
"discounts.max_items_per_shift",
]
rows = db.query(PosSettings).filter(PosSettings.key.in_(keys)).all()
row_map = {r.key.split(".", 1)[1]: r.value for r in rows}
# Coerce types so frontend toggle/number fields work correctly
def _coerce(k, v):
if v is None:
return None
if k == "enabled":
return v.lower() in ("true", "1", "yes")
try:
return float(v) if "." in str(v) else int(v)
except Exception:
return v
return {k: _coerce(k, v) for k, v in row_map.items()}
@router.put("/discount-settings/global")
def set_global_discount_settings(
body: dict,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
from models.settings import PosSettings
allowed = {
"enabled", "max_total_value_workday",
"max_total_value_shift", "max_items_per_shift",
}
now = _utcnow().isoformat()
for k, v in body.items():
if k not in allowed:
continue
full_key = f"discounts.{k}"
row = db.query(PosSettings).filter(PosSettings.key == full_key).first()
if row:
row.value = str(v)
row.updated_at = now
else:
db.add(PosSettings(key=full_key, value=str(v), updated_at=now))
db.commit()
return {"ok": True}
@router.get("/discount-settings/me", response_model=WaiterDiscountSettingsOut)
def get_my_discount_settings(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
s = db.query(WaiterDiscountSettings).filter(
WaiterDiscountSettings.user_id == user.id
).first()
if not s:
return WaiterDiscountSettingsOut(id=0, user_id=user.id, can_apply_discounts=False)
return s
@router.get("/discount-settings/{user_id}", response_model=WaiterDiscountSettingsOut)
def get_waiter_discount_settings(
user_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
s = db.query(WaiterDiscountSettings).filter(
WaiterDiscountSettings.user_id == user_id
).first()
if not s:
# Return defaults (all None = no limits, disabled)
return WaiterDiscountSettingsOut(id=0, user_id=user_id, can_apply_discounts=False)
return s
@router.put("/discount-settings/{user_id}", response_model=WaiterDiscountSettingsOut)
def set_waiter_discount_settings(
user_id: int,
body: WaiterDiscountSettingsIn,
db: Session = Depends(get_db),
user: User = Depends(require_manager),
):
s = db.query(WaiterDiscountSettings).filter(
WaiterDiscountSettings.user_id == user_id
).first()
if not s:
s = WaiterDiscountSettings(user_id=user_id)
db.add(s)
s.can_apply_discounts = int(body.can_apply_discounts)
s.max_discount_percent = body.max_discount_percent
s.max_discount_amount = body.max_discount_amount
s.max_total_value_shift = body.max_total_value_shift
s.max_total_value_workday = body.max_total_value_workday
s.max_items_per_shift = body.max_items_per_shift
s.max_items_per_workday = body.max_items_per_workday
s.max_items_per_order = body.max_items_per_order
db.commit()
db.refresh(s)
return s
# =============================================================================
# PRICE EVENT LOG (read-only, for order detail and reports)
# =============================================================================
@router.get("/events/order/{order_id}", response_model=List[PriceEventOut])
def get_order_price_events(
order_id: int,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
events = (
db.query(PriceEventLog)
.filter(PriceEventLog.order_id == order_id)
.order_by(PriceEventLog.applied_at)
.all()
)
result = []
for ev in events:
out = PriceEventOut.model_validate(ev)
if ev.modifier:
out.modifier_name = ev.modifier.name
if ev.deal:
out.deal_name = ev.deal.name
if ev.applied_by:
out.applied_by_username = ev.applied_by.username
result.append(out)
return result
# =============================================================================
# DEAL OFFER ACCEPT / DISMISS (PWA-facing)
# =============================================================================
@router.post("/deals/accept")
def accept_deal_offer(
body: dict,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""
Waiter confirms a deal offer. Logs the acceptance and directly adds free item(s)
to the order with deal_id + linked_item_id set for binding.
Returns added item IDs so the PWA can refresh.
"""
from models.pricing import PriceEventLog, Deal
from models.order import Order, OrderItem
from models.product import Product
deal_id = body.get("deal_id")
order_id = body.get("order_id")
# For free_choice: list of product_ids the waiter selected + their option snapshots
# Shape: [{ product_id, quantity, selected_options, notes }] or legacy [int, ...]
free_items_raw = body.get("free_items", [])
# Trigger item — the order_item_id that caused the deal to fire (for linking)
trigger_item_id = body.get("trigger_item_id", None)
deal = db.query(Deal).filter(Deal.id == deal_id).first()
if not deal:
raise HTTPException(status_code=404, detail="Deal not found")
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
added_item_ids = []
if deal.action_type in ("free_item", "free_choice"):
# Resolve which products to add
if deal.action_type == "free_item" and deal.action_free_item_id:
items_to_add = [{"product_id": deal.action_free_item_id, "quantity": deal.action_free_quantity,
"selected_options": None, "notes": None}]
else:
# free_choice: caller provides list of chosen products
items_to_add = []
for entry in free_items_raw:
if isinstance(entry, dict):
items_to_add.append({
"product_id": entry.get("product_id"),
"quantity": entry.get("quantity", deal.action_free_quantity),
"selected_options": entry.get("selected_options"),
"notes": entry.get("notes"),
})
else:
items_to_add.append({"product_id": int(entry), "quantity": deal.action_free_quantity,
"selected_options": None, "notes": None})
for entry in items_to_add:
product = db.query(Product).filter(Product.id == entry["product_id"]).first()
if not product:
continue
base_price = product.base_price or 0.0
new_item = OrderItem(
order_id=order_id,
product_id=product.id,
added_by=user.id,
quantity=entry["quantity"],
unit_price=base_price, # full price so the breakdown is readable
price_adjustment=-base_price, # negated to bring effective price to 0
selected_options=json.dumps(entry["selected_options"]) if entry["selected_options"] else None,
notes=entry.get("notes"),
deal_id=deal_id,
linked_item_id=trigger_item_id,
)
db.add(new_item)
db.flush()
added_item_ids.append(new_item.id)
db.add(PriceEventLog(
order_id=order_id,
order_item_id=new_item.id,
event_type="free_item_added",
deal_id=deal_id,
price_before=product.base_price or 0.0,
price_after=0.0,
delta_amount=-(product.base_price or 0.0),
applied_by_user_id=user.id,
))
# Log acceptance
db.add(PriceEventLog(
order_id=order_id,
order_item_id=trigger_item_id,
event_type="deal_offer_accepted",
deal_id=deal_id,
applied_by_user_id=user.id,
selected_item_ids=json.dumps([e.get("product_id") if isinstance(e, dict) else int(e) for e in free_items_raw]) if free_items_raw else None,
))
db.commit()
from services.sse_bus import broadcast_sync
broadcast_sync()
return {
"deal_id": deal_id,
"action_type": deal.action_type,
"added_item_ids": added_item_ids,
}
@router.post("/deals/dismiss")
def dismiss_deal_offer(
body: dict,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Waiter dismisses a deal offer. Logged so it can be re-triggered later."""
from models.pricing import PriceEventLog
deal_id = body.get("deal_id")
order_id = body.get("order_id")
db.add(PriceEventLog(
order_id=order_id,
order_item_id=None,
event_type="deal_offer_dismissed",
deal_id=deal_id,
applied_by_user_id=user.id,
))
db.commit()
return {"ok": True}
# =============================================================================
# Internal helpers
# =============================================================================
def _sync_conditions(parent, conditions_in, db: Session, model: str):
"""Write condition rows for a modifier or deal."""
for c in conditions_in:
if model == "modifier":
db.add(PriceModifierCondition(
modifier_id=parent.id,
condition_type=c.condition_type,
params=json.dumps(c.params),
))
else:
db.add(DealCondition(
deal_id=parent.id,
condition_type=c.condition_type,
params=json.dumps(c.params),
))
def _sync_modifier_targets(modifier, targets_in, db: Session):
for t in targets_in:
db.add(PriceModifierTarget(
modifier_id=modifier.id,
target_type=t.target_type,
target_id=t.target_id,
target_tag=t.target_tag,
target_ids=json.dumps(t.target_ids) if t.target_ids else None,
target_tags=json.dumps(t.target_tags) if t.target_tags else None,
))
def _sync_deal_targets(deal, targets_in, db: Session):
for t in targets_in:
db.add(DealTarget(
deal_id=deal.id,
target_type=t.target_type,
target_id=t.target_id,
target_tag=t.target_tag,
target_ids=json.dumps(t.target_ids) if t.target_ids else None,
target_tags=json.dumps(t.target_tags) if t.target_tags else None,
))