""" SambaPOS API Endpoints Handles SambaPOS MSSQL configuration and category management. """ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from pydantic import BaseModel from database import get_db from models.user import User from models.settings import KitchenSettings from auth import get_current_user, require_cap from services.sambapos_api import SambaPOSClient router = APIRouter() # ============ Pydantic Schemas ============ class SambaPOSSettingsResponse(BaseModel): sambapos_db_host: str | None sambapos_db_port: int | None sambapos_db_name: str | None sambapos_db_username: str | None sambapos_db_password_set: bool sambapos_tracked_categories: list[str] sambapos_excluded_items: list[str] class Config: from_attributes = True class SambaPOSSettingsUpdate(BaseModel): sambapos_db_host: str | None = None sambapos_db_port: int | None = None sambapos_db_name: str | None = None sambapos_db_username: str | None = None sambapos_db_password: str | None = None class CategoryResponse(BaseModel): id: int name: str class MenuItemResponse(BaseModel): name: str category: str class TrackedCategoriesUpdate(BaseModel): categories: list[str] class ExcludedItemsUpdate(BaseModel): items: list[str] # ============ Settings Endpoints ============ @router.get("/settings", response_model=SambaPOSSettingsResponse) async def get_sambapos_settings( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Get SambaPOS connection settings""" result = await db.execute( select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) ) settings = result.scalar_one_or_none() if not settings: raise HTTPException(status_code=404, detail="Settings not found") # Parse tracked categories from comma-separated string tracked_categories = [] if settings.sambapos_tracked_categories: tracked_categories = [c.strip() for c in settings.sambapos_tracked_categories.split(',') if c.strip()] # Parse excluded items from comma-separated string excluded_items = [] if settings.sambapos_excluded_items: excluded_items = [i.strip() for i in settings.sambapos_excluded_items.split('|') if i.strip()] return SambaPOSSettingsResponse( sambapos_db_host=settings.sambapos_db_host, sambapos_db_port=settings.sambapos_db_port, sambapos_db_name=settings.sambapos_db_name, sambapos_db_username=settings.sambapos_db_username, sambapos_db_password_set=bool(settings.sambapos_db_password), sambapos_tracked_categories=tracked_categories, sambapos_excluded_items=excluded_items ) @router.patch("/settings", response_model=SambaPOSSettingsResponse) async def update_sambapos_settings( update: SambaPOSSettingsUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Update SambaPOS connection settings""" result = await db.execute( select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) ) settings = result.scalar_one_or_none() if not settings: raise HTTPException(status_code=404, detail="Settings not found") # Update fields update_data = update.model_dump(exclude_unset=True) for field, value in update_data.items(): if value is not None: setattr(settings, field, value) await db.commit() await db.refresh(settings) # Parse tracked categories from comma-separated string tracked_categories = [] if settings.sambapos_tracked_categories: tracked_categories = [c.strip() for c in settings.sambapos_tracked_categories.split(',') if c.strip()] # Parse excluded items from pipe-separated string excluded_items = [] if settings.sambapos_excluded_items: excluded_items = [i.strip() for i in settings.sambapos_excluded_items.split('|') if i.strip()] return SambaPOSSettingsResponse( sambapos_db_host=settings.sambapos_db_host, sambapos_db_port=settings.sambapos_db_port, sambapos_db_name=settings.sambapos_db_name, sambapos_db_username=settings.sambapos_db_username, sambapos_db_password_set=bool(settings.sambapos_db_password), sambapos_tracked_categories=tracked_categories, sambapos_excluded_items=excluded_items ) @router.post("/test-connection") async def test_sambapos_connection( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Test SambaPOS database connection""" result = await db.execute( select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) ) settings = result.scalar_one_or_none() if not settings: raise HTTPException(status_code=404, detail="Settings not found") if not all([ settings.sambapos_db_host, settings.sambapos_db_name, settings.sambapos_db_username, settings.sambapos_db_password ]): raise HTTPException(status_code=400, detail="SambaPOS database credentials not fully configured") client = SambaPOSClient( host=settings.sambapos_db_host, port=settings.sambapos_db_port or 1433, database=settings.sambapos_db_name, username=settings.sambapos_db_username, password=settings.sambapos_db_password ) result = await client.test_connection() if result["success"]: return {"status": "success", "message": "SambaPOS connection successful"} else: raise HTTPException(status_code=400, detail=f"Connection failed: {result['message']}") # ============ Categories Endpoints ============ @router.get("/categories", response_model=list[CategoryResponse]) async def get_sambapos_categories( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Fetch all menu categories from SambaPOS database""" result = await db.execute( select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) ) settings = result.scalar_one_or_none() if not settings: raise HTTPException(status_code=404, detail="Settings not found") if not all([ settings.sambapos_db_host, settings.sambapos_db_name, settings.sambapos_db_username, settings.sambapos_db_password ]): raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured") client = SambaPOSClient( host=settings.sambapos_db_host, port=settings.sambapos_db_port or 1433, database=settings.sambapos_db_name, username=settings.sambapos_db_username, password=settings.sambapos_db_password ) try: categories = await client.get_categories() return [CategoryResponse(id=cat["id"], name=cat["name"]) for cat in categories] except Exception as e: raise HTTPException(status_code=400, detail=f"Failed to fetch categories: {str(e)}") @router.get("/tracked-categories") async def get_tracked_categories( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Get list of category names enabled for top sellers""" result = await db.execute( select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) ) settings = result.scalar_one_or_none() if not settings: raise HTTPException(status_code=404, detail="Settings not found") # Parse tracked categories from comma-separated string tracked_categories = [] if settings.sambapos_tracked_categories: tracked_categories = [c.strip() for c in settings.sambapos_tracked_categories.split(',') if c.strip()] return {"categories": tracked_categories} @router.patch("/tracked-categories") async def update_tracked_categories( update: TrackedCategoriesUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Update which categories are included in top sellers""" result = await db.execute( select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) ) settings = result.scalar_one_or_none() if not settings: raise HTTPException(status_code=404, detail="Settings not found") # Store as comma-separated string settings.sambapos_tracked_categories = ','.join(update.categories) await db.commit() return {"status": "success", "categories": update.categories} @router.get("/debug/menuitems") async def debug_menuitems( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Debug endpoint to explore MenuItems table structure""" result = await db.execute( select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) ) settings = result.scalar_one_or_none() if not settings or not settings.sambapos_db_password: raise HTTPException(status_code=400, detail="SambaPOS not configured") client = SambaPOSClient( host=settings.sambapos_db_host, port=settings.sambapos_db_port or 1433, database=settings.sambapos_db_name, username=settings.sambapos_db_username, password=settings.sambapos_db_password ) try: return await client.debug_menu_items() except Exception as e: raise HTTPException(status_code=400, detail=str(e)) # ============ Menu Items Endpoints ============ @router.get("/menu-items", response_model=list[MenuItemResponse]) async def get_menu_items( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Fetch all unique menu item names with their categories for exclusion selection""" result = await db.execute( select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) ) settings = result.scalar_one_or_none() if not settings: raise HTTPException(status_code=404, detail="Settings not found") if not all([ settings.sambapos_db_host, settings.sambapos_db_name, settings.sambapos_db_username, settings.sambapos_db_password ]): raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured") # Get tracked categories to filter menu items tracked_categories = [] if settings.sambapos_tracked_categories: tracked_categories = [c.strip() for c in settings.sambapos_tracked_categories.split(',') if c.strip()] client = SambaPOSClient( host=settings.sambapos_db_host, port=settings.sambapos_db_port or 1433, database=settings.sambapos_db_name, username=settings.sambapos_db_username, password=settings.sambapos_db_password ) try: items = await client.get_menu_item_names(categories=tracked_categories if tracked_categories else None) return [MenuItemResponse(name=item["name"], category=item["category"]) for item in items] except Exception as e: raise HTTPException(status_code=400, detail=f"Failed to fetch menu items: {str(e)}") class MenuItemWithPortionResponse(BaseModel): menu_item_name: str portion_name: str category: str on_pos_menu: bool @router.get("/menu-items-with-portions", response_model=list[MenuItemWithPortionResponse]) async def get_menu_items_with_portions( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Fetch all menu items with their portion names, grouped by Kitchen Course category.""" result = await db.execute( select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) ) settings = result.scalar_one_or_none() if not settings: raise HTTPException(status_code=404, detail="Settings not found") if not all([ settings.sambapos_db_host, settings.sambapos_db_name, settings.sambapos_db_username, settings.sambapos_db_password ]): raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured") # Get tracked categories to filter tracked_categories = [] if settings.sambapos_tracked_categories: tracked_categories = [c.strip() for c in settings.sambapos_tracked_categories.split(',') if c.strip()] client = SambaPOSClient( host=settings.sambapos_db_host, port=settings.sambapos_db_port or 1433, database=settings.sambapos_db_name, username=settings.sambapos_db_username, password=settings.sambapos_db_password ) try: items = await client.get_menu_items_with_portions( categories=tracked_categories if tracked_categories else None ) return [ MenuItemWithPortionResponse( menu_item_name=item["menu_item_name"], portion_name=item["portion_name"], category=item["category"], on_pos_menu=item.get("on_pos_menu", False) ) for item in items ] except Exception as e: raise HTTPException(status_code=400, detail=f"Failed to fetch menu items with portions: {str(e)}") @router.get("/excluded-items") async def get_excluded_items( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Get list of menu item names excluded from top sellers report""" result = await db.execute( select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) ) settings = result.scalar_one_or_none() if not settings: raise HTTPException(status_code=404, detail="Settings not found") # Parse excluded items from pipe-separated string excluded_items = [] if settings.sambapos_excluded_items: excluded_items = [i.strip() for i in settings.sambapos_excluded_items.split('|') if i.strip()] return {"items": excluded_items} @router.patch("/excluded-items") async def update_excluded_items( update: ExcludedItemsUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Update which menu item GroupCodes are excluded from top sellers report""" result = await db.execute( select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) ) settings = result.scalar_one_or_none() if not settings: raise HTTPException(status_code=404, detail="Settings not found") # Store as pipe-separated string (to allow commas in names) settings.sambapos_excluded_items = '|'.join(update.items) await db.commit() return {"status": "success", "items": update.items} # ============ Group Codes Endpoints ============ class GroupCodeResponse(BaseModel): name: str @router.get("/group-codes", response_model=list[GroupCodeResponse]) async def get_group_codes( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Fetch all distinct GroupCode values from MenuItems table for exclusion selection""" result = await db.execute( select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) ) settings = result.scalar_one_or_none() if not settings: raise HTTPException(status_code=404, detail="Settings not found") if not all([ settings.sambapos_db_host, settings.sambapos_db_name, settings.sambapos_db_username, settings.sambapos_db_password ]): raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured") client = SambaPOSClient( host=settings.sambapos_db_host, port=settings.sambapos_db_port or 1433, database=settings.sambapos_db_name, username=settings.sambapos_db_username, password=settings.sambapos_db_password ) try: group_codes = await client.get_menu_group_codes() return [GroupCodeResponse(name=gc["name"]) for gc in group_codes] except Exception as e: raise HTTPException(status_code=400, detail=f"Failed to fetch group codes: {str(e)}") # ============ GL Codes Endpoints (Phase 8) ============ class GLCodeResponse(BaseModel): code: str class GLCodesUpdate(BaseModel): food_codes: list[str] beverage_codes: list[str] @router.get("/gl-codes", response_model=list[GLCodeResponse]) async def get_gl_codes( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Fetch all unique GL codes from ProductTag custom tags for food/beverage classification""" result = await db.execute( select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) ) settings = result.scalar_one_or_none() if not settings: raise HTTPException(status_code=404, detail="Settings not found") if not all([ settings.sambapos_db_host, settings.sambapos_db_name, settings.sambapos_db_username, settings.sambapos_db_password ]): raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured") client = SambaPOSClient( host=settings.sambapos_db_host, port=settings.sambapos_db_port or 1433, database=settings.sambapos_db_name, username=settings.sambapos_db_username, password=settings.sambapos_db_password ) try: gl_codes = await client.get_gl_codes() return [GLCodeResponse(code=gc["code"]) for gc in gl_codes] except Exception as e: raise HTTPException(status_code=400, detail=f"Failed to fetch GL codes: {str(e)}") @router.get("/gl-codes/selected") async def get_selected_gl_codes( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Get currently selected food and beverage GL codes""" result = await db.execute( select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) ) settings = result.scalar_one_or_none() if not settings: raise HTTPException(status_code=404, detail="Settings not found") # Parse food GL codes from comma-separated string food_codes = [] if settings.sambapos_food_gl_codes: food_codes = [c.strip() for c in settings.sambapos_food_gl_codes.split(',') if c.strip()] # Parse beverage GL codes from comma-separated string beverage_codes = [] if settings.sambapos_beverage_gl_codes: beverage_codes = [c.strip() for c in settings.sambapos_beverage_gl_codes.split(',') if c.strip()] return { "food_codes": food_codes, "beverage_codes": beverage_codes } @router.patch("/gl-codes") async def update_gl_codes( update: GLCodesUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Update food and beverage GL code selections""" result = await db.execute( select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) ) settings = result.scalar_one_or_none() if not settings: raise HTTPException(status_code=404, detail="Settings not found") # Store as comma-separated strings settings.sambapos_food_gl_codes = ','.join(update.food_codes) settings.sambapos_beverage_gl_codes = ','.join(update.beverage_codes) await db.commit() return { "status": "success", "food_codes": update.food_codes, "beverage_codes": update.beverage_codes } @router.get("/debug/custom-tags") async def debug_custom_tags( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Debug endpoint to see sample CustomTags from MenuItems""" result = await db.execute( select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) ) settings = result.scalar_one_or_none() if not settings: raise HTTPException(status_code=404, detail="Settings not found") if not all([ settings.sambapos_db_host, settings.sambapos_db_name, settings.sambapos_db_username, settings.sambapos_db_password ]): raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured") client = SambaPOSClient( host=settings.sambapos_db_host, port=settings.sambapos_db_port or 1433, database=settings.sambapos_db_name, username=settings.sambapos_db_username, password=settings.sambapos_db_password ) try: return await client.debug_menu_items() except Exception as e: raise HTTPException(status_code=400, detail=f"Failed to debug custom tags: {str(e)}") @router.get("/debug/zero-price-order-states") async def debug_zero_price_order_states( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """ Debug endpoint: returns sample OrderStates blobs for zero-priced orders. Use this to discover the JSON structure and identify where original/package price is stored. """ result = await db.execute( select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) ) settings = result.scalar_one_or_none() if not settings or not settings.sambapos_db_password: raise HTTPException(status_code=400, detail="SambaPOS not configured") client = SambaPOSClient( host=settings.sambapos_db_host, port=settings.sambapos_db_port or 1433, database=settings.sambapos_db_name, username=settings.sambapos_db_username, password=settings.sambapos_db_password ) try: return await client.debug_zero_price_order_states() except Exception as e: raise HTTPException(status_code=400, detail=str(e)) @router.get("/debug/table-schema") async def debug_table_schema( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Debug endpoint to inspect column names in SambaPOS tables""" result = await db.execute( select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) ) settings = result.scalar_one_or_none() if not settings: raise HTTPException(status_code=404, detail="Settings not found") if not all([ settings.sambapos_db_host, settings.sambapos_db_name, settings.sambapos_db_username, settings.sambapos_db_password ]): raise HTTPException(status_code=400, detail="SambaPOS database credentials not configured") client = SambaPOSClient( host=settings.sambapos_db_host, port=settings.sambapos_db_port or 1433, database=settings.sambapos_db_name, username=settings.sambapos_db_username, password=settings.sambapos_db_password ) try: return await client.debug_table_schema() except Exception as e: raise HTTPException(status_code=400, detail=f"Failed to debug schema: {str(e)}")