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, ))