""" Newbook API Endpoints Handles Newbook configuration, GL account management, and data sync operations. """ import logging from datetime import date, datetime, timedelta logger = logging.getLogger(__name__) from decimal import Decimal from typing import Optional from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func, delete from pydantic import BaseModel from database import get_db from models.user import User from models.settings import KitchenSettings from models.newbook import ( NewbookGLAccount, NewbookDailyRevenue, NewbookDailyOccupancy, NewbookSyncLog, NewbookRoomCategory ) from models.resos import ResosBooking, ResosOpeningHour from auth import get_current_user, require_cap from services.newbook_sync import NewbookSyncService from services.newbook_api import NewbookAPIClient, NewbookAPIError router = APIRouter() # ============ Pydantic Schemas ============ class NewbookSettingsResponse(BaseModel): newbook_api_username: str | None newbook_api_password_set: bool newbook_api_key_set: bool newbook_api_region: str | None newbook_instance_id: str | None newbook_last_sync: datetime | None newbook_auto_sync_enabled: bool newbook_upcoming_sync_enabled: bool newbook_upcoming_sync_interval: int newbook_last_upcoming_sync: datetime | None newbook_breakfast_gl_codes: str | None newbook_dinner_gl_codes: str | None newbook_breakfast_vat_rate: Decimal | None newbook_dinner_vat_rate: Decimal | None use_global_newbook: bool = False class Config: from_attributes = True class NewbookSettingsUpdate(BaseModel): newbook_api_username: str | None = None newbook_api_password: str | None = None newbook_api_key: str | None = None newbook_api_region: str | None = None # au, ap, eu, us newbook_instance_id: str | None = None newbook_auto_sync_enabled: bool | None = None newbook_upcoming_sync_enabled: bool | None = None newbook_upcoming_sync_interval: int | None = None newbook_breakfast_gl_codes: str | None = None newbook_dinner_gl_codes: str | None = None newbook_breakfast_vat_rate: Decimal | None = None newbook_dinner_vat_rate: Decimal | None = None use_global_newbook: bool | None = None class GLAccountResponse(BaseModel): id: int gl_account_id: str gl_code: str | None gl_name: str gl_type: str | None gl_group_id: str | None gl_group_name: str | None is_tracked: bool display_order: int class Config: from_attributes = True class GLAccountUpdate(BaseModel): is_tracked: bool display_order: int | None = None class GLAccountBulkUpdateItem(BaseModel): id: int is_tracked: bool display_order: int | None = None class GLAccountBulkUpdate(BaseModel): updates: list[GLAccountBulkUpdateItem] class RoomCategoryResponse(BaseModel): id: int site_id: str site_name: str site_type: str | None room_count: int = 0 is_included: bool display_order: int class Config: from_attributes = True class RoomCategoryUpdate(BaseModel): is_included: bool display_order: int | None = None class RoomCategoryBulkUpdateItem(BaseModel): id: int is_included: bool display_order: int | None = None class RoomCategoryBulkUpdate(BaseModel): updates: list[RoomCategoryBulkUpdateItem] class DailyRevenueResponse(BaseModel): date: date gl_account_id: int gl_account_name: str amount_net: Decimal class Config: from_attributes = True class RevenueSummaryResponse(BaseModel): start_date: date end_date: date total_revenue: Decimal by_account: list[dict] # {gl_account_name, total} by_date: list[dict] # {date, total} class OccupancyResponse(BaseModel): date: date total_rooms: int | None occupied_rooms: int | None occupancy_percentage: Decimal | None total_guests: int | None breakfast_allocation_qty: int | None breakfast_allocation_netvalue: Decimal | None dinner_allocation_qty: int | None dinner_allocation_netvalue: Decimal | None is_forecast: bool class Config: from_attributes = True class SyncLogResponse(BaseModel): id: int sync_type: str started_at: datetime completed_at: datetime | None status: str records_fetched: int error_message: str | None date_from: date | None date_to: date | None class Config: from_attributes = True class HistoricalSyncRequest(BaseModel): date_from: date date_to: date class CalendarDayData(BaseModel): date: date has_data: bool is_forecast: bool # Occupancy total_rooms: int | None = None occupied_rooms: int | None = None occupancy_percentage: Decimal | None = None total_guests: int | None = None # Meal allocations breakfast_allocation_qty: int | None = None breakfast_allocation_netvalue: Decimal | None = None dinner_allocation_qty: int | None = None dinner_allocation_netvalue: Decimal | None = None # Revenue total_revenue: Decimal | None = None revenue_by_account: list[dict] | None = None # [{gl_name, amount}] class CalendarDataResponse(BaseModel): year: int month: int days: list[CalendarDayData] # ============ Settings Endpoints ============ @router.get("/settings", response_model=NewbookSettingsResponse) async def get_newbook_settings( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Get Newbook API 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") return NewbookSettingsResponse( newbook_api_username=settings.newbook_api_username, newbook_api_password_set=bool(settings.newbook_api_password), newbook_api_key_set=bool(settings.newbook_api_key), newbook_api_region=settings.newbook_api_region, newbook_instance_id=settings.newbook_instance_id, newbook_last_sync=settings.newbook_last_sync, newbook_auto_sync_enabled=settings.newbook_auto_sync_enabled, newbook_upcoming_sync_enabled=settings.newbook_upcoming_sync_enabled, newbook_upcoming_sync_interval=settings.newbook_upcoming_sync_interval, newbook_last_upcoming_sync=settings.newbook_last_upcoming_sync, newbook_breakfast_gl_codes=settings.newbook_breakfast_gl_codes, newbook_dinner_gl_codes=settings.newbook_dinner_gl_codes, newbook_breakfast_vat_rate=settings.newbook_breakfast_vat_rate, newbook_dinner_vat_rate=settings.newbook_dinner_vat_rate, use_global_newbook=settings.use_global_newbook, ) @router.patch("/settings", response_model=NewbookSettingsResponse) async def update_newbook_settings( update: NewbookSettingsUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Update Newbook API 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") # Validate region if provided if update.newbook_api_region and update.newbook_api_region not in ["au", "ap", "eu", "us"]: raise HTTPException(status_code=400, detail="Invalid region. Must be: au, ap, eu, us") # Update fields update_data = update.model_dump(exclude_unset=True) logger.info(f"Newbook settings update for kitchen {current_user.kitchen_id}: {update_data}") for field, value in update_data.items(): if value is not None: old_value = getattr(settings, field, None) setattr(settings, field, value) if field == 'newbook_upcoming_sync_enabled': logger.info(f"Newbook upcoming sync enabled changed: {old_value} -> {value}") await db.commit() await db.refresh(settings) logger.info(f"Newbook settings saved - upcoming_sync_enabled={settings.newbook_upcoming_sync_enabled}") return NewbookSettingsResponse( newbook_api_username=settings.newbook_api_username, newbook_api_password_set=bool(settings.newbook_api_password), newbook_api_key_set=bool(settings.newbook_api_key), newbook_api_region=settings.newbook_api_region, newbook_instance_id=settings.newbook_instance_id, newbook_last_sync=settings.newbook_last_sync, newbook_auto_sync_enabled=settings.newbook_auto_sync_enabled, newbook_upcoming_sync_enabled=settings.newbook_upcoming_sync_enabled, newbook_upcoming_sync_interval=settings.newbook_upcoming_sync_interval, newbook_last_upcoming_sync=settings.newbook_last_upcoming_sync, newbook_breakfast_gl_codes=settings.newbook_breakfast_gl_codes, newbook_dinner_gl_codes=settings.newbook_dinner_gl_codes, newbook_breakfast_vat_rate=settings.newbook_breakfast_vat_rate, newbook_dinner_vat_rate=settings.newbook_dinner_vat_rate, use_global_newbook=settings.use_global_newbook, ) @router.get("/debug-upcoming-sync") async def debug_upcoming_sync( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Debug endpoint to check upcoming sync setting in database""" from sqlalchemy import text # Get value from SQLAlchemy model result = await db.execute( select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) ) settings = result.scalar_one_or_none() # Also get raw value from database raw_result = await db.execute( text("SELECT newbook_upcoming_sync_enabled, newbook_upcoming_sync_interval FROM kitchen_settings WHERE kitchen_id = :kid"), {"kid": current_user.kitchen_id} ) raw_row = raw_result.fetchone() return { "kitchen_id": current_user.kitchen_id, "model_value": settings.newbook_upcoming_sync_enabled if settings else None, "model_interval": settings.newbook_upcoming_sync_interval if settings else None, "raw_db_enabled": raw_row[0] if raw_row else "column_not_found", "raw_db_interval": raw_row[1] if raw_row else "column_not_found", "last_sync": settings.newbook_last_upcoming_sync.isoformat() if settings and settings.newbook_last_upcoming_sync else None } @router.post("/test-connection") async def test_newbook_connection( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Test Newbook API 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") from services.global_settings_service import apply_global_overrides await apply_global_overrides(settings) if not all([ settings.newbook_api_username, settings.newbook_api_password, settings.newbook_api_key, settings.newbook_api_region ]): raise HTTPException(status_code=400, detail="Newbook credentials not fully configured") try: async with NewbookAPIClient( username=settings.newbook_api_username, password=settings.newbook_api_password, api_key=settings.newbook_api_key, region=settings.newbook_api_region, instance_id=settings.newbook_instance_id ) as client: success = await client.test_connection() if success: return {"status": "success", "message": "Newbook connection successful"} else: raise HTTPException(status_code=400, detail="Connection test failed") except NewbookAPIError as e: raise HTTPException(status_code=400, detail=f"Newbook API error: {e.message}") # ============ GL Account Endpoints ============ @router.get("/gl-accounts", response_model=list[GLAccountResponse]) async def list_gl_accounts( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """List all GL accounts for this kitchen, sorted by group then name""" result = await db.execute( select(NewbookGLAccount) .where(NewbookGLAccount.kitchen_id == current_user.kitchen_id) .order_by(NewbookGLAccount.gl_group_name, NewbookGLAccount.display_order, NewbookGLAccount.gl_name) ) return list(result.scalars().all()) @router.post("/gl-accounts/fetch") async def fetch_gl_accounts( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Fetch/refresh GL accounts from Newbook API""" try: sync_service = NewbookSyncService(db, current_user.kitchen_id) accounts = await sync_service.sync_gl_accounts() return { "status": "success", "message": f"Fetched {len(accounts)} GL accounts", "count": len(accounts) } except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except NewbookAPIError as e: raise HTTPException(status_code=400, detail=f"Newbook API error: {e.message}") @router.patch("/gl-accounts/bulk-update") async def bulk_update_gl_accounts( request: GLAccountBulkUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Bulk update GL account selections""" updated = 0 for upd in request.updates: result = await db.execute( select(NewbookGLAccount).where( NewbookGLAccount.id == upd.id, NewbookGLAccount.kitchen_id == current_user.kitchen_id ) ) account = result.scalar_one_or_none() if account: account.is_tracked = upd.is_tracked if upd.display_order is not None: account.display_order = upd.display_order updated += 1 await db.commit() return {"status": "success", "updated": updated} @router.patch("/gl-accounts/{account_id}", response_model=GLAccountResponse) async def update_gl_account( account_id: int, update: GLAccountUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Update GL account tracking settings""" result = await db.execute( select(NewbookGLAccount).where( NewbookGLAccount.id == account_id, NewbookGLAccount.kitchen_id == current_user.kitchen_id ) ) account = result.scalar_one_or_none() if not account: raise HTTPException(status_code=404, detail="GL account not found") account.is_tracked = update.is_tracked if update.display_order is not None: account.display_order = update.display_order await db.commit() await db.refresh(account) return account # ============ Room Category Endpoints ============ @router.get("/room-categories", response_model=list[RoomCategoryResponse]) async def get_room_categories( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Get all room categories for the kitchen""" result = await db.execute( select(NewbookRoomCategory) .where(NewbookRoomCategory.kitchen_id == current_user.kitchen_id) .order_by(NewbookRoomCategory.display_order, NewbookRoomCategory.site_name) ) return list(result.scalars().all()) @router.post("/room-categories/fetch") async def fetch_room_categories( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Fetch room categories from Newbook API and store in database""" # Get settings 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.newbook_api_username: raise HTTPException(status_code=400, detail="Newbook credentials not configured") try: async with NewbookAPIClient( username=settings.newbook_api_username, password=settings.newbook_api_password, api_key=settings.newbook_api_key, region=settings.newbook_api_region or "au", instance_id=settings.newbook_instance_id ) as client: categories = await client.get_site_list() # Delete all existing room categories for this kitchen (fresh replace) await db.execute( delete(NewbookRoomCategory).where( NewbookRoomCategory.kitchen_id == current_user.kitchen_id ) ) # Insert fresh aggregated room types for cat in categories: new_cat = NewbookRoomCategory( kitchen_id=current_user.kitchen_id, site_id=cat["id"], site_name=cat["name"], site_type=cat.get("type"), room_count=cat.get("count", 0), is_included=True, # Default to included ) db.add(new_cat) await db.commit() return { "status": "success", "count": len(categories) } except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except NewbookAPIError as e: raise HTTPException(status_code=400, detail=f"Newbook API error: {e.message}") @router.patch("/room-categories/bulk-update") async def bulk_update_room_categories( request: RoomCategoryBulkUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Bulk update room category selections""" updated = 0 for upd in request.updates: result = await db.execute( select(NewbookRoomCategory).where( NewbookRoomCategory.id == upd.id, NewbookRoomCategory.kitchen_id == current_user.kitchen_id ) ) category = result.scalar_one_or_none() if category: category.is_included = upd.is_included if upd.display_order is not None: category.display_order = upd.display_order updated += 1 await db.commit() return {"status": "success", "updated": updated} @router.patch("/room-categories/{category_id}", response_model=RoomCategoryResponse) async def update_room_category( category_id: int, update: RoomCategoryUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Update room category settings""" result = await db.execute( select(NewbookRoomCategory).where( NewbookRoomCategory.id == category_id, NewbookRoomCategory.kitchen_id == current_user.kitchen_id ) ) category = result.scalar_one_or_none() if not category: raise HTTPException(status_code=404, detail="Room category not found") category.is_included = update.is_included if update.display_order is not None: category.display_order = update.display_order await db.commit() await db.refresh(category) return category # ============ Revenue Endpoints ============ @router.get("/revenue", response_model=list[DailyRevenueResponse]) async def get_revenue( start_date: date, end_date: date, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Get daily revenue data for a date range""" result = await db.execute( select(NewbookDailyRevenue, NewbookGLAccount.gl_name) .join(NewbookGLAccount) .where( NewbookDailyRevenue.kitchen_id == current_user.kitchen_id, NewbookDailyRevenue.date >= start_date, NewbookDailyRevenue.date <= end_date, NewbookGLAccount.is_tracked == True ) .order_by(NewbookDailyRevenue.date, NewbookGLAccount.display_order) ) revenue_data = [] for rev, gl_name in result.all(): revenue_data.append(DailyRevenueResponse( date=rev.date, gl_account_id=rev.gl_account_id, gl_account_name=gl_name, amount_net=rev.amount_net )) return revenue_data @router.get("/revenue/summary", response_model=RevenueSummaryResponse) async def get_revenue_summary( start_date: date, end_date: date, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Get aggregated revenue summary for a date range""" # Total revenue total_result = await db.execute( select(func.sum(NewbookDailyRevenue.amount_net)) .join(NewbookGLAccount) .where( NewbookDailyRevenue.kitchen_id == current_user.kitchen_id, NewbookDailyRevenue.date >= start_date, NewbookDailyRevenue.date <= end_date, NewbookGLAccount.is_tracked == True ) ) total_revenue = total_result.scalar() or Decimal("0.00") # By account by_account_result = await db.execute( select(NewbookGLAccount.gl_name, func.sum(NewbookDailyRevenue.amount_net)) .join(NewbookGLAccount) .where( NewbookDailyRevenue.kitchen_id == current_user.kitchen_id, NewbookDailyRevenue.date >= start_date, NewbookDailyRevenue.date <= end_date, NewbookGLAccount.is_tracked == True ) .group_by(NewbookGLAccount.gl_name) .order_by(func.sum(NewbookDailyRevenue.amount_net).desc()) ) by_account = [{"gl_account_name": name, "total": float(total)} for name, total in by_account_result.all()] # By date by_date_result = await db.execute( select(NewbookDailyRevenue.date, func.sum(NewbookDailyRevenue.amount_net)) .join(NewbookGLAccount) .where( NewbookDailyRevenue.kitchen_id == current_user.kitchen_id, NewbookDailyRevenue.date >= start_date, NewbookDailyRevenue.date <= end_date, NewbookGLAccount.is_tracked == True ) .group_by(NewbookDailyRevenue.date) .order_by(NewbookDailyRevenue.date) ) by_date = [{"date": d.isoformat(), "total": float(total)} for d, total in by_date_result.all()] return RevenueSummaryResponse( start_date=start_date, end_date=end_date, total_revenue=total_revenue, by_account=by_account, by_date=by_date ) # ============ Occupancy Endpoints ============ @router.get("/occupancy", response_model=list[OccupancyResponse]) async def get_occupancy( start_date: date, end_date: date, include_forecast: bool = True, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Get occupancy data for a date range""" query = select(NewbookDailyOccupancy).where( NewbookDailyOccupancy.kitchen_id == current_user.kitchen_id, NewbookDailyOccupancy.date >= start_date, NewbookDailyOccupancy.date <= end_date ) if not include_forecast: query = query.where(NewbookDailyOccupancy.is_forecast == False) query = query.order_by(NewbookDailyOccupancy.date) result = await db.execute(query) return list(result.scalars().all()) # ============ Calendar Data Endpoint ============ @router.get("/calendar/{year}/{month}", response_model=CalendarDataResponse) async def get_calendar_data( year: int, month: int, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Get calendar view data for a specific month""" import calendar from datetime import date as date_type from collections import defaultdict # Calculate month date range _, last_day = calendar.monthrange(year, month) month_start = date_type(year, month, 1) month_end = date_type(year, month, last_day) today = date_type.today() # Fetch occupancy data for the month occupancy_result = await db.execute( select(NewbookDailyOccupancy) .where( NewbookDailyOccupancy.kitchen_id == current_user.kitchen_id, NewbookDailyOccupancy.date >= month_start, NewbookDailyOccupancy.date <= month_end ) ) occupancy_by_date = {occ.date: occ for occ in occupancy_result.scalars().all()} # Fetch revenue data for the month (grouped by date and GL account) revenue_result = await db.execute( select( NewbookDailyRevenue.date, NewbookGLAccount.gl_name, NewbookDailyRevenue.amount_net ) .join(NewbookGLAccount, NewbookDailyRevenue.gl_account_id == NewbookGLAccount.id) .where( NewbookDailyRevenue.kitchen_id == current_user.kitchen_id, NewbookDailyRevenue.date >= month_start, NewbookDailyRevenue.date <= month_end, NewbookGLAccount.is_tracked == True ) .order_by(NewbookDailyRevenue.date, NewbookGLAccount.gl_name) ) # Group revenue by date revenue_by_date = defaultdict(list) for row_date, gl_name, amount in revenue_result.all(): revenue_by_date[row_date].append({ "gl_name": gl_name, "amount": float(amount) }) # Build calendar days days = [] for day in range(1, last_day + 1): current_date = date_type(year, month, day) occupancy = occupancy_by_date.get(current_date) revenue_entries = revenue_by_date.get(current_date, []) has_data = occupancy is not None or len(revenue_entries) > 0 # Determine if forecast: today or future date, or marked as forecast in occupancy # Today is considered "current" (updatable), not historical (locked) is_forecast = current_date >= today if occupancy and occupancy.is_forecast: is_forecast = True # Calculate total revenue for the day total_revenue = sum(entry["amount"] for entry in revenue_entries) if revenue_entries else None day_data = CalendarDayData( date=current_date, has_data=has_data, is_forecast=is_forecast, total_rooms=occupancy.total_rooms if occupancy else None, occupied_rooms=occupancy.occupied_rooms if occupancy else None, occupancy_percentage=occupancy.occupancy_percentage if occupancy else None, total_guests=occupancy.total_guests if occupancy else None, breakfast_allocation_qty=occupancy.breakfast_allocation_qty if occupancy else None, breakfast_allocation_netvalue=occupancy.breakfast_allocation_netvalue if occupancy else None, dinner_allocation_qty=occupancy.dinner_allocation_qty if occupancy else None, dinner_allocation_netvalue=occupancy.dinner_allocation_netvalue if occupancy else None, total_revenue=Decimal(str(total_revenue)) if total_revenue else None, revenue_by_account=revenue_entries if revenue_entries else None ) days.append(day_data) return CalendarDataResponse( year=year, month=month, days=days ) # ============ Sync Endpoints ============ @router.post("/sync/forecast") async def sync_forecast_data( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Sync forecast period data (next ~2 months)""" try: sync_service = NewbookSyncService(db, current_user.kitchen_id) results = await sync_service.sync_forecast_period() return { "status": "success", "message": "Forecast data synced", "results": results } except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except NewbookAPIError as e: raise HTTPException(status_code=400, detail=f"Newbook API error: {e.message}") @router.post("/sync/historical") async def sync_historical_data( request: HistoricalSyncRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Sync historical data for specific date range""" if request.date_from > request.date_to: raise HTTPException(status_code=400, detail="date_from must be before date_to") if (request.date_to - request.date_from).days > 365: raise HTTPException(status_code=400, detail="Date range cannot exceed 1 year") try: sync_service = NewbookSyncService(db, current_user.kitchen_id) results = await sync_service.sync_historical_range(request.date_from, request.date_to) return { "status": "success", "message": f"Historical data synced for {request.date_from} to {request.date_to}", "results": results } except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except NewbookAPIError as e: raise HTTPException(status_code=400, detail=f"Newbook API error: {e.message}") @router.get("/sync/logs", response_model=list[SyncLogResponse]) async def get_sync_logs( limit: int = 20, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Get recent sync logs""" result = await db.execute( select(NewbookSyncLog) .where(NewbookSyncLog.kitchen_id == current_user.kitchen_id) .order_by(NewbookSyncLog.started_at.desc()) .limit(limit) ) return list(result.scalars().all()) # ============ Dashboard Endpoints ============ class ArrivalDayStats(BaseModel): date: date day_name: str # "Today", "Tomorrow", day of week arrival_count: int arrival_guests: int table_bookings: int table_covers: int matched_arrivals: int # Arrivals with table bookings unmatched_arrivals: int # Arrivals without table bookings opportunity_guests: int # Guests from unmatched arrivals class ArrivalDashboardResponse(BaseModel): days: list[ArrivalDayStats] service_filter_name: str | None = None # Name of service type being filtered (e.g., "Dinner") @router.get("/dashboard/arrivals", response_model=ArrivalDashboardResponse) async def get_arrival_dashboard( days: int = 3, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): """Get arrival statistics for dashboard widget (next N days)""" today = date.today() end_date = today + timedelta(days=days - 1) # Fetch kitchen settings to get service filter settings_result = await db.execute( select(KitchenSettings).where(KitchenSettings.kitchen_id == current_user.kitchen_id) ) settings = settings_result.scalar_one_or_none() service_filter_type = settings.resos_arrival_widget_service_filter if settings else None # Get opening hour IDs that match the service type filter service_filter_name = None opening_hour_ids = [] if service_filter_type and settings and settings.resos_opening_hours_mapping: # Find all opening hours mapped to this service type for mapping in settings.resos_opening_hours_mapping: if isinstance(mapping, dict) and mapping.get("service_type") == service_filter_type: opening_hour_ids.append(mapping.get("resos_id")) # Set display name to capitalized service type if opening_hour_ids: service_filter_name = service_filter_type.capitalize() # Fetch occupancy data with arrival info occupancy_result = await db.execute( select(NewbookDailyOccupancy) .where( NewbookDailyOccupancy.kitchen_id == current_user.kitchen_id, NewbookDailyOccupancy.date >= today, NewbookDailyOccupancy.date <= end_date ) .order_by(NewbookDailyOccupancy.date) ) occupancy_by_date = {occ.date: occ for occ in occupancy_result.scalars().all()} # Fetch restaurant bookings for the same period (with optional service filter) # Note: We fetch ALL bookings (not just guest-linked ones) to show total booking count bookings_query = select(ResosBooking).where( ResosBooking.kitchen_id == current_user.kitchen_id, ResosBooking.booking_date >= today, ResosBooking.booking_date <= end_date ) # Apply service filter if set - filter by ANY opening hour that matches the service type if opening_hour_ids: bookings_query = bookings_query.where(ResosBooking.opening_hour_id.in_(opening_hour_ids)) bookings_result = await db.execute(bookings_query) bookings = list(bookings_result.scalars().all()) # Group bookings by date bookings_by_date = {} for booking in bookings: if booking.booking_date not in bookings_by_date: bookings_by_date[booking.booking_date] = [] bookings_by_date[booking.booking_date].append(booking) # Build stats for each day days_stats = [] day_names = ["Today", "Tomorrow"] for i in range(days): current_date = today + timedelta(days=i) occupancy = occupancy_by_date.get(current_date) day_bookings = bookings_by_date.get(current_date, []) # Day name if i < len(day_names): day_name = day_names[i] else: day_name = current_date.strftime("%A") # Day of week # Default values arrival_count = 0 arrival_guests = 0 matched_arrivals = 0 unmatched_arrivals = 0 opportunity_guests = 0 if occupancy and occupancy.arrival_count: arrival_count = occupancy.arrival_count or 0 arrival_details = occupancy.arrival_booking_details or [] # Calculate total guests from arrivals arrival_guests = sum(detail.get("num_guests", 0) for detail in arrival_details) # Get hotel booking numbers from Resos hotel_refs = {b.hotel_booking_number for b in day_bookings if b.hotel_booking_number} # Match arrivals with table bookings matched = [] unmatched = [] for detail in arrival_details: booking_ref = detail.get("booking_reference", "") booking_id = detail.get("booking_id", "") # Check if this arrival has a matching table booking if booking_ref in hotel_refs or booking_id in hotel_refs: matched.append(detail) else: unmatched.append(detail) matched_arrivals = len(matched) unmatched_arrivals = len(unmatched) opportunity_guests = sum(detail.get("num_guests", 0) for detail in unmatched) # Table booking stats table_bookings = len(day_bookings) table_covers = sum(b.people for b in day_bookings) days_stats.append(ArrivalDayStats( date=current_date, day_name=day_name, arrival_count=arrival_count, arrival_guests=arrival_guests, table_bookings=table_bookings, table_covers=table_covers, matched_arrivals=matched_arrivals, unmatched_arrivals=unmatched_arrivals, opportunity_guests=opportunity_guests )) return ArrivalDashboardResponse(days=days_stats, service_filter_name=service_filter_name)