""" SambaPOS MSSQL Database Client Connects to SambaPOS EPOS database to fetch menu categories and top sellers data. """ import logging from datetime import date, datetime, timedelta from decimal import Decimal from typing import Optional import aioodbc logger = logging.getLogger(__name__) class SambaPOSClient: """Client for querying SambaPOS MSSQL database.""" def __init__(self, host: str, port: int, database: str, username: str, password: str): """ Initialize SambaPOS client with connection parameters. Args: host: SQL Server host (e.g., 'localhost\\SQLEXPRESS17') port: SQL Server port (default 1433) database: Database name (e.g., 'clean') username: SQL Server username password: SQL Server password """ self.host = host self.port = port self.database = database self.username = username self.password = password # Build connection string # Note: For named instances like 'localhost\SQLEXPRESS17', the port is ignored # and the instance name is used for connection if '\\' in host: # Named instance - don't use port self.connection_string = ( f"DRIVER={{ODBC Driver 17 for SQL Server}};" f"SERVER={host};" f"DATABASE={database};" f"UID={username};" f"PWD={password};" f"TrustServerCertificate=yes;" f"Connection Timeout=30" ) else: # Default instance - use port self.connection_string = ( f"DRIVER={{ODBC Driver 17 for SQL Server}};" f"SERVER={host},{port};" f"DATABASE={database};" f"UID={username};" f"PWD={password};" f"TrustServerCertificate=yes;" f"Connection Timeout=30" ) # Log connection details (without password) logger.info(f"SambaPOS connection configured: host={host}, database={database}, user={username}") async def test_connection(self) -> dict: """ Test database connectivity. Returns: dict with 'success' bool and 'message' str """ try: async with aioodbc.connect(dsn=self.connection_string) as conn: async with conn.cursor() as cursor: await cursor.execute("SELECT 1") await cursor.fetchone() return {"success": True, "message": "Connection successful"} except Exception as e: logger.error(f"SambaPOS connection test failed: {e}") return {"success": False, "message": str(e)} async def get_categories(self) -> list[dict]: """ Fetch all unique values from the 'Kitchen Course' product tag. Returns: List of dicts with 'id' and 'name' for each Kitchen Course value """ # Query to get unique Kitchen Course tag values from MenuItems.CustomTags JSON # CustomTags format: [{"TN":"Kitchen Course","TV":"Starters"},{"TN":"Kitchen Print","TV":"Y"}] # Using CROSS APPLY to safely calculate positions before SUBSTRING query = """ SELECT DISTINCT ROW_NUMBER() OVER (ORDER BY KitchenCourse) as Id, KitchenCourse as Name FROM ( SELECT DISTINCT CASE WHEN tv.tv_start > 0 AND pos.tv_end > tv.tv_start THEN SUBSTRING(CustomTags, tv.tv_start, pos.tv_end - tv.tv_start) ELSE NULL END as KitchenCourse FROM MenuItems CROSS APPLY ( SELECT CHARINDEX('"TN":"Kitchen Course"', CustomTags) as tn_pos ) tn CROSS APPLY ( SELECT CASE WHEN tn.tn_pos > 0 THEN CHARINDEX('"TV":"', CustomTags, tn.tn_pos) + 6 ELSE 0 END as tv_start ) tv CROSS APPLY ( SELECT CASE WHEN tv.tv_start > 6 THEN CHARINDEX('"', CustomTags, tv.tv_start) ELSE 0 END as tv_end ) pos WHERE CustomTags LIKE '%"TN":"Kitchen Course"%' ) sub WHERE KitchenCourse IS NOT NULL AND KitchenCourse != '' ORDER BY KitchenCourse """ try: async with aioodbc.connect(dsn=self.connection_string) as conn: async with conn.cursor() as cursor: await cursor.execute(query) rows = await cursor.fetchall() result = [{"id": int(row[0]), "name": row[1].strip()} for row in rows] logger.info(f"Found {len(result)} Kitchen Course categories: {[r['name'] for r in result]}") return result except Exception as e: logger.error(f"Failed to fetch SambaPOS Kitchen Course categories: {e}") raise async def debug_menu_items(self) -> dict: """ Debug method to explore MenuItems table structure and sample data. Returns column names and sample CustomTags values. """ try: async with aioodbc.connect(dsn=self.connection_string) as conn: async with conn.cursor() as cursor: # Get column names from MenuItems table await cursor.execute(""" SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'MenuItems' ORDER BY ORDINAL_POSITION """) columns = [row[0] for row in await cursor.fetchall()] # Get sample of CustomTags values (if column exists) sample_tags = [] if 'CustomTags' in columns: await cursor.execute(""" SELECT TOP 20 Name, CustomTags FROM MenuItems WHERE CustomTags IS NOT NULL AND CustomTags != '' """) sample_tags = [{"name": row[0], "custom_tags": row[1]} for row in await cursor.fetchall()] # Also check for any tag-related tables await cursor.execute(""" SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME LIKE '%Tag%' OR TABLE_NAME LIKE '%Property%' ORDER BY TABLE_NAME """) tag_tables = [row[0] for row in await cursor.fetchall()] return { "menuitem_columns": columns, "sample_custom_tags": sample_tags, "tag_related_tables": tag_tables } except Exception as e: logger.error(f"Debug MenuItems failed: {e}") raise async def debug_table_schema(self) -> dict: """ Debug method to inspect column names in key SambaPOS tables. Used to identify correct column names for SQL queries. """ try: async with aioodbc.connect(dsn=self.connection_string) as conn: async with conn.cursor() as cursor: result = {} # Check key tables used in restaurant spend query tables = ['Tickets', 'Orders', 'TicketEntities', 'MenuItems'] for table in tables: await cursor.execute(f""" SELECT COLUMN_NAME, DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = '{table}' ORDER BY ORDINAL_POSITION """) columns = [{"name": row[0], "type": row[1]} for row in await cursor.fetchall()] result[table] = columns return result except Exception as e: logger.error(f"Debug table schema failed: {e}") raise async def debug_zero_price_order_states(self, limit: int = 20) -> list[dict]: """ Returns sample OrderStates blobs for Orders where Price = 0. Used to discover the JSON structure so we can extract original/package price. """ query = """ SELECT TOP (?) o.Id, o.MenuItemName, o.Price, o.Quantity, o.OrderStates FROM Orders o JOIN Tickets t ON o.TicketId = t.Id WHERE o.Price = 0 AND o.OrderStates IS NOT NULL AND o.OrderStates != '' AND t.IsClosed = 1 ORDER BY o.Id DESC """ try: async with aioodbc.connect(dsn=self.connection_string) as conn: async with conn.cursor() as cursor: await cursor.execute(query, [limit]) rows = await cursor.fetchall() return [ { "order_id": row[0], "menu_item_name": row[1], "price": float(row[2]) if row[2] is not None else None, "quantity": float(row[3]) if row[3] is not None else None, "order_states": row[4], } for row in rows ] except Exception as e: logger.error(f"debug_zero_price_order_states failed: {e}") raise async def get_product_tag_groups(self) -> list[dict]: """ Discover available product tag groups from the CustomTags field. Returns: List of unique tag group names found in CustomTags """ query = """ SELECT DISTINCT CASE WHEN CHARINDEX(':', value) > 0 THEN LTRIM(RTRIM(LEFT(value, CHARINDEX(':', value) - 1))) ELSE NULL END as TagGroup FROM MenuItems CROSS APPLY STRING_SPLIT(CustomTags, ',') WHERE CustomTags IS NOT NULL AND CustomTags != '' ORDER BY TagGroup """ try: async with aioodbc.connect(dsn=self.connection_string) as conn: async with conn.cursor() as cursor: await cursor.execute(query) rows = await cursor.fetchall() return [{"name": row[0]} for row in rows if row[0]] except Exception as e: logger.error(f"Failed to fetch SambaPOS tag groups: {e}") raise async def get_top_sellers( self, from_date: date, to_date: date, categories: list[str], limit: int = 10, excluded_categories: list[str] | None = None ) -> dict[str, list[dict]]: """ Get top sellers grouped by Kitchen Course tag. Args: from_date: Start date for the query (inclusive) to_date: End date for the query (inclusive) categories: List of Kitchen Course values to include (e.g., ['Starters', 'Mains', 'Desserts']) limit: Max items per category (default 10) excluded_categories: List of ScreenMenuCategory names to exclude from results Returns: Dict mapping Kitchen Course to list of items with: - item_name: str - qty: int - revenue: Decimal """ if not categories: return {} # Build placeholders for categories cat_placeholders = ','.join(['?' for _ in categories]) # Build exclusion filter for MenuItems.GroupCode if provided exclusion_filter = "" exclusion_params = [] if excluded_categories: # Filter out items where GroupCode matches excluded groups excl_placeholders = ','.join(['?' for _ in excluded_categories]) exclusion_filter = f"AND (mi.GroupCode IS NULL OR mi.GroupCode NOT IN ({excl_placeholders}))" exclusion_params = excluded_categories # Query using Kitchen Course tag from MenuItems.CustomTags JSON # CustomTags format: [{"TN":"Kitchen Course","TV":"Starters"},...] # Using CROSS APPLY to safely calculate positions before SUBSTRING query = f""" SELECT KitchenCourse as Category, MenuItemName, SUM(CAST(Quantity AS DECIMAL(18,2))) as TotalQty, SUM(Price * Quantity) as TotalRevenue FROM ( SELECT o.MenuItemName, o.Quantity, o.Price, CASE WHEN tv.tv_start > 0 AND pos.tv_end > tv.tv_start THEN SUBSTRING(mi.CustomTags, tv.tv_start, pos.tv_end - tv.tv_start) ELSE NULL END as KitchenCourse FROM Orders o JOIN Tickets t ON o.TicketId = t.Id JOIN MenuItems mi ON o.MenuItemId = mi.Id CROSS APPLY ( SELECT CHARINDEX('"TN":"Kitchen Course"', mi.CustomTags) as tn_pos ) tn CROSS APPLY ( SELECT CASE WHEN tn.tn_pos > 0 THEN CHARINDEX('"TV":"', mi.CustomTags, tn.tn_pos) + 6 ELSE 0 END as tv_start ) tv CROSS APPLY ( SELECT CASE WHEN tv.tv_start > 6 THEN CHARINDEX('"', mi.CustomTags, tv.tv_start) ELSE 0 END as tv_end ) pos WHERE t.Date >= CAST(? AS DATE) AND t.Date < DATEADD(day, 1, CAST(? AS DATE)) AND t.IsClosed = 1 AND mi.CustomTags LIKE '%"TN":"Kitchen Course"%' AND (o.OrderStates IS NULL OR o.OrderStates NOT LIKE '%"S":"Void"%') AND (o.OrderStates IS NULL OR o.OrderStates NOT LIKE '%"S":"Canceled"%') {exclusion_filter} ) OrdersWithCourse WHERE KitchenCourse IN ({cat_placeholders}) GROUP BY KitchenCourse, MenuItemName ORDER BY KitchenCourse, TotalQty DESC """ # Parameters: from_date, to_date, excluded categories, then Kitchen Course categories params = [from_date.isoformat(), to_date.isoformat()] + exclusion_params + categories try: async with aioodbc.connect(dsn=self.connection_string) as conn: async with conn.cursor() as cursor: await cursor.execute(query, params) rows = await cursor.fetchall() # Group results by category results: dict[str, list[dict]] = {cat: [] for cat in categories} for row in rows: category = row[0] if category in results and len(results[category]) < limit: results[category].append({ "item_name": row[1], "qty": int(row[2]) if row[2] else 0, "revenue": Decimal(str(row[3])) if row[3] else Decimal("0") }) logger.info(f"SambaPOS top sellers: fetched {len(rows)} rows for {len(categories)} Kitchen Course categories") return results except Exception as e: logger.error(f"Failed to fetch SambaPOS top sellers: {e}") raise async def get_top_sellers_by_revenue( self, from_date: date, to_date: date, categories: list[str], limit: int = 10, excluded_categories: list[str] | None = None ) -> dict[str, list[dict]]: """ Get top sellers grouped by Kitchen Course tag, sorted by revenue. Args: from_date: Start date for the query (inclusive) to_date: End date for the query (inclusive) categories: List of Kitchen Course values to include limit: Max items per category (default 10) excluded_categories: List of MenuItems.GroupCode values to exclude from results Returns: Dict mapping Kitchen Course to list of items with: - item_name: str - qty: int - revenue: Decimal """ if not categories: return {} # Build placeholders for categories cat_placeholders = ','.join(['?' for _ in categories]) # Build exclusion filter for MenuItems.GroupCode if provided exclusion_filter = "" exclusion_params = [] if excluded_categories: excl_placeholders = ','.join(['?' for _ in excluded_categories]) exclusion_filter = f"AND (mi.GroupCode IS NULL OR mi.GroupCode NOT IN ({excl_placeholders}))" exclusion_params = excluded_categories # Query using Kitchen Course tag from MenuItems.CustomTags JSON # CustomTags format: [{"TN":"Kitchen Course","TV":"Starters"},...] # Using CROSS APPLY to safely calculate positions before SUBSTRING query = f""" SELECT KitchenCourse as Category, MenuItemName, SUM(CAST(Quantity AS DECIMAL(18,2))) as TotalQty, SUM(Price * Quantity) as TotalRevenue FROM ( SELECT o.MenuItemName, o.Quantity, o.Price, CASE WHEN tv.tv_start > 0 AND pos.tv_end > tv.tv_start THEN SUBSTRING(mi.CustomTags, tv.tv_start, pos.tv_end - tv.tv_start) ELSE NULL END as KitchenCourse FROM Orders o JOIN Tickets t ON o.TicketId = t.Id JOIN MenuItems mi ON o.MenuItemId = mi.Id CROSS APPLY ( SELECT CHARINDEX('"TN":"Kitchen Course"', mi.CustomTags) as tn_pos ) tn CROSS APPLY ( SELECT CASE WHEN tn.tn_pos > 0 THEN CHARINDEX('"TV":"', mi.CustomTags, tn.tn_pos) + 6 ELSE 0 END as tv_start ) tv CROSS APPLY ( SELECT CASE WHEN tv.tv_start > 6 THEN CHARINDEX('"', mi.CustomTags, tv.tv_start) ELSE 0 END as tv_end ) pos WHERE t.Date >= CAST(? AS DATE) AND t.Date < DATEADD(day, 1, CAST(? AS DATE)) AND t.IsClosed = 1 AND mi.CustomTags LIKE '%"TN":"Kitchen Course"%' AND (o.OrderStates IS NULL OR o.OrderStates NOT LIKE '%"S":"Void"%') AND (o.OrderStates IS NULL OR o.OrderStates NOT LIKE '%"S":"Canceled"%') {exclusion_filter} ) OrdersWithCourse WHERE KitchenCourse IN ({cat_placeholders}) GROUP BY KitchenCourse, MenuItemName ORDER BY KitchenCourse, TotalRevenue DESC """ # Parameters: from_date, to_date, excluded categories, then Kitchen Course categories params = [from_date.isoformat(), to_date.isoformat()] + exclusion_params + categories try: async with aioodbc.connect(dsn=self.connection_string) as conn: async with conn.cursor() as cursor: await cursor.execute(query, params) rows = await cursor.fetchall() # Group results by category results: dict[str, list[dict]] = {cat: [] for cat in categories} for row in rows: category = row[0] if category in results and len(results[category]) < limit: results[category].append({ "item_name": row[1], "qty": int(row[2]) if row[2] else 0, "revenue": Decimal(str(row[3])) if row[3] else Decimal("0") }) return results except Exception as e: logger.error(f"Failed to fetch SambaPOS top sellers by revenue: {e}") raise async def get_menu_items_with_portions( self, categories: list[str] | None = None ) -> list[dict]: """ Fetch all menu items with their portion names, filtered by Kitchen Course categories. Joins MenuItems → MenuItemPortions to get portion-level detail. Used for the SambaPOS item picker in the DishEditor. Args: categories: Optional list of Kitchen Course values to filter by Returns: List of dicts with menu_item_name, portion_name, category """ # Build optional category filter cat_filter = "" params = [] if categories: cat_placeholders = ','.join(['?' for _ in categories]) cat_filter = f"WHERE KitchenCourse IN ({cat_placeholders})" params = categories query = f""" SELECT MenuItemName, PortionName, KitchenCourse, MenuItemId FROM ( SELECT mi.Name AS MenuItemName, mip.Name AS PortionName, mi.Id AS MenuItemId, CASE WHEN tv.tv_start > 0 AND pos.tv_end > tv.tv_start THEN SUBSTRING(mi.CustomTags, tv.tv_start, pos.tv_end - tv.tv_start) ELSE NULL END as KitchenCourse FROM MenuItems mi JOIN MenuItemPortions mip ON mip.MenuItemId = mi.Id CROSS APPLY ( SELECT CHARINDEX('"TN":"Kitchen Course"', mi.CustomTags) as tn_pos ) tn CROSS APPLY ( SELECT CASE WHEN tn.tn_pos > 0 THEN CHARINDEX('"TV":"', mi.CustomTags, tn.tn_pos) + 6 ELSE 0 END as tv_start ) tv CROSS APPLY ( SELECT CASE WHEN tv.tv_start > 6 THEN CHARINDEX('"', mi.CustomTags, tv.tv_start) ELSE 0 END as tv_end ) pos WHERE mi.CustomTags LIKE '%"TN":"Kitchen Course"%' ) sub {cat_filter} ORDER BY KitchenCourse, MenuItemName, PortionName """ try: async with aioodbc.connect(dsn=self.connection_string) as conn: async with conn.cursor() as cursor: # Get menu item IDs that appear on a POS screen menu pos_menu_item_ids: set[int] = set() try: await cursor.execute(""" SELECT DISTINCT smi.MenuItemId FROM ScreenMenuItems smi JOIN ScreenMenuCategories smc ON smc.Id = smi.ScreenMenuCategoryId JOIN ScreenMenus sm ON sm.Id = smc.ScreenMenuId """) pos_menu_item_ids = {row[0] for row in await cursor.fetchall()} logger.info(f"SambaPOS screen menu items: {len(pos_menu_item_ids)} mapped to POS") except Exception as e: logger.warning(f"Could not query ScreenMenuItems (table may not exist): {e}") await cursor.execute(query, params) rows = await cursor.fetchall() results = [] for row in rows: if row[0] and row[2]: # must have name and category results.append({ "menu_item_name": row[0], "portion_name": row[1] or "Normal", "category": row[2], "on_pos_menu": row[3] in pos_menu_item_ids }) logger.info(f"SambaPOS menu items with portions: {len(results)} items") return results except Exception as e: logger.error(f"Failed to fetch SambaPOS menu items with portions: {e}") raise async def get_sales_breakdown( self, from_date: date, to_date: date, categories: list[str], excluded_categories: list[str] | None = None ) -> list[dict]: """ Get all sold items for a date range, grouped by MenuItemName + PortionName + Kitchen Course. Used for the Sales GP report — returns all items (no limit) so every sold dish can be matched against recipe costs. Args: from_date: Start date (inclusive) to_date: End date (inclusive) categories: Kitchen Course values to include excluded_categories: GroupCode values to exclude Returns: List of dicts with menu_item_name, portion_name, category, total_qty, total_revenue_gross """ if not categories: return [] cat_placeholders = ','.join(['?' for _ in categories]) exclusion_filter = "" exclusion_params = [] if excluded_categories: excl_placeholders = ','.join(['?' for _ in excluded_categories]) exclusion_filter = f"AND (mi.GroupCode IS NULL OR mi.GroupCode NOT IN ({excl_placeholders}))" exclusion_params = excluded_categories # For DBB/package orders, o.Price is zeroed but the original price is stored in # OrderStates as SN="Origional Price" (intentional typo in SambaPOS), SV=. # A Package state (SN="Package", SV="DBB") confirms it was a DBB allocation. # For orders not yet allocated (price=0, no state yet) we fall back to the # catalogue price from MenuItemPrices (PriceTag=''). query = f""" SELECT KitchenCourse as Category, MenuItemName, PortionName, SUM(CAST(Quantity AS DECIMAL(18,2))) as TotalQty, SUM(EffectivePrice * Quantity) as TotalRevenue, SUM(CASE WHEN IsPackage = 1 THEN CAST(Quantity AS DECIMAL(18,2)) ELSE 0 END) as PackageQty FROM ( SELECT o.MenuItemName, o.PortionName, o.Quantity, o.Price, -- Original price from DBB allocation state (SN='Origional Price', value in SV) orig.OriginalPrice, -- Catalogue price fallback from MenuItemPrices (for not-yet-allocated orders) mip.Price as CataloguePrice, -- 1 = confirmed DBB package order CASE WHEN orig.OriginalPrice IS NOT NULL THEN 1 ELSE 0 END as IsPackage, -- Effective price: original > catalogue > actual (0) CASE WHEN o.Price = 0 AND orig.OriginalPrice > 0 THEN orig.OriginalPrice WHEN o.Price = 0 AND ISNULL(mip.Price, 0) > 0 THEN mip.Price ELSE o.Price END as EffectivePrice, CASE WHEN tv.tv_start > 0 AND pos.tv_end > tv.tv_start THEN SUBSTRING(mi.CustomTags, tv.tv_start, pos.tv_end - tv.tv_start) ELSE NULL END as KitchenCourse FROM Orders o JOIN Tickets t ON o.TicketId = t.Id JOIN MenuItems mi ON o.MenuItemId = mi.Id -- Extract original price from OrderStates using CHARINDEX/SUBSTRING. -- SambaPOS stores it as: {{"SN":"Origional Price","SV":"9.75",...}} -- Compatible with all SQL Server versions (no OPENJSON needed). OUTER APPLY ( SELECT CASE WHEN sn_pos.p > 0 AND sv_pos.p > 0 AND CHARINDEX('"', o.OrderStates, sv_pos.p + 6) > sv_pos.p + 6 AND ISNUMERIC(SUBSTRING( o.OrderStates, sv_pos.p + 6, CHARINDEX('"', o.OrderStates, sv_pos.p + 6) - sv_pos.p - 6 )) = 1 THEN CAST(SUBSTRING( o.OrderStates, sv_pos.p + 6, CHARINDEX('"', o.OrderStates, sv_pos.p + 6) - sv_pos.p - 6 ) AS DECIMAL(18,2)) ELSE NULL END AS OriginalPrice FROM ( SELECT CHARINDEX('"SN":"Origional Price"', o.OrderStates) AS p ) sn_pos CROSS APPLY ( SELECT CHARINDEX('"SV":"', o.OrderStates, sn_pos.p) AS p ) sv_pos ) orig -- Catalogue price fallback for not-yet-allocated orders LEFT JOIN MenuItemPortions mipn ON mipn.MenuItemId = mi.Id AND mipn.Name = o.PortionName LEFT JOIN MenuItemPrices mip ON mip.MenuItemPortionId = mipn.Id AND (mip.PriceTag IS NULL OR mip.PriceTag = '') CROSS APPLY ( SELECT CHARINDEX('"TN":"Kitchen Course"', mi.CustomTags) as tn_pos ) tn CROSS APPLY ( SELECT CASE WHEN tn.tn_pos > 0 THEN CHARINDEX('"TV":"', mi.CustomTags, tn.tn_pos) + 6 ELSE 0 END as tv_start ) tv CROSS APPLY ( SELECT CASE WHEN tv.tv_start > 6 THEN CHARINDEX('"', mi.CustomTags, tv.tv_start) ELSE 0 END as tv_end ) pos WHERE t.Date >= CAST(? AS DATE) AND t.Date < DATEADD(day, 1, CAST(? AS DATE)) AND t.IsClosed = 1 AND mi.CustomTags LIKE '%"TN":"Kitchen Course"%' AND (o.OrderStates IS NULL OR o.OrderStates NOT LIKE '%"SN":"Void"%') AND (o.OrderStates IS NULL OR o.OrderStates NOT LIKE '%"SN":"Canceled"%') {exclusion_filter} ) OrdersWithCourse WHERE KitchenCourse IN ({cat_placeholders}) GROUP BY KitchenCourse, MenuItemName, PortionName ORDER BY KitchenCourse, TotalRevenue DESC """ params = [from_date.isoformat(), to_date.isoformat()] + exclusion_params + categories try: async with aioodbc.connect(dsn=self.connection_string) as conn: async with conn.cursor() as cursor: await cursor.execute(query, params) rows = await cursor.fetchall() results = [] for row in rows: results.append({ "category": row[0], "menu_item_name": row[1], "portion_name": row[2] or "Normal", "total_qty": int(row[3]) if row[3] else 0, "total_revenue_gross": Decimal(str(row[4])) if row[4] else Decimal("0"), "package_qty": int(row[5]) if row[5] else 0, }) logger.info(f"SambaPOS sales breakdown: {len(results)} item/portion combinations for {from_date} to {to_date}") return results except Exception as e: logger.error(f"Failed to fetch SambaPOS sales breakdown: {e}") raise async def get_menu_group_codes(self) -> list[dict]: """ Fetch all distinct GroupCode values from MenuItems table. GroupCode is used for categorizing menu items (e.g., 'Drinks', 'Extras'). Used for excluding entire groups from top sellers reports. Returns: List of dicts with 'name' for each group code """ query = """ SELECT DISTINCT GroupCode FROM MenuItems WHERE GroupCode IS NOT NULL AND GroupCode != '' ORDER BY GroupCode """ try: async with aioodbc.connect(dsn=self.connection_string) as conn: async with conn.cursor() as cursor: await cursor.execute(query) rows = await cursor.fetchall() result = [{"name": row[0].strip()} for row in rows] logger.info(f"Found {len(result)} MenuItems GroupCodes: {[r['name'] for r in result]}") return result except Exception as e: logger.error(f"Failed to fetch MenuItems GroupCodes: {e}") raise async def get_gl_codes(self) -> list[dict]: """ Fetch all unique GL codes from NewBook GLA custom tags. NewBook GLA format in CustomTags: [{"TN":"NewBook GLA","TV":"3101"},...] Returns: List of dicts with 'code' for each unique GL code (sorted) """ query = """ SELECT DISTINCT GLACode FROM ( SELECT CASE WHEN tv.tv_start > 0 AND pos.tv_end > tv.tv_start THEN SUBSTRING(CustomTags, tv.tv_start, pos.tv_end - tv.tv_start) ELSE NULL END as GLACode FROM MenuItems CROSS APPLY ( SELECT CHARINDEX('"TN":"NewBook GLA"', CustomTags) as gla_pos ) gla CROSS APPLY ( SELECT CASE WHEN gla.gla_pos > 0 THEN CHARINDEX('"TV":"', CustomTags, gla.gla_pos) + 6 ELSE 0 END as tv_start ) tv CROSS APPLY ( SELECT CASE WHEN tv.tv_start > 6 THEN CHARINDEX('"', CustomTags, tv.tv_start) ELSE 0 END as tv_end ) pos WHERE CustomTags LIKE '%"TN":"NewBook GLA"%' ) sub WHERE GLACode IS NOT NULL AND GLACode != '' ORDER BY GLACode """ try: async with aioodbc.connect(dsn=self.connection_string) as conn: async with conn.cursor() as cursor: await cursor.execute(query) rows = await cursor.fetchall() result = [{"code": row[0].strip()} for row in rows if row[0] and row[0].strip()] logger.info(f"Found {len(result)} unique GL codes from ProductTag: {[r['code'] for r in result][:10]}...") return result except Exception as e: logger.error(f"Failed to fetch GL codes from ProductTag: {e}") raise async def get_restaurant_spend( self, from_date: date, to_date: date, tracked_categories: list[str], food_gl_codes: list[str], beverage_gl_codes: list[str] ) -> list[dict]: """ Get restaurant spend data for Resos booking matching (Phase 8). Filters tickets to restaurant tables with tracked course items, extracts GL codes from NewBook GLA custom tags, and splits spend by food/beverage categories. Args: from_date: Start date (inclusive) to_date: End date (inclusive) tracked_categories: Kitchen Course values to include (e.g., ['Starters', 'Mains']) food_gl_codes: GL codes for food items (e.g., ['3101']) beverage_gl_codes: GL codes for beverage items (e.g., ['2101', '2102']) Returns: List of ticket data dicts with: - ticket_id: int - ticket_date: date - ticket_time: time - table_name: str (for fallback matching) - ticket_tag: str | None (for primary matching - "BOOKING_ID - Name") - booking_id: str | None (extracted from ticket_tag) - food_total: Decimal - beverage_total: Decimal - total_spend: Decimal - service_period: str | None (based on timestamp) """ if not tracked_categories: logger.warning("get_restaurant_spend called with empty tracked_categories") return [] # VALIDATE GL CODES CONFIGURED if not food_gl_codes or not beverage_gl_codes: raise ValueError("Food and Beverage GL codes must be configured in Settings > SambaPOS Integration") # Build placeholders for categories cat_placeholders = ','.join(['?' for _ in tracked_categories]) # Build GL code IN clauses food_gl_placeholders = ','.join(['?' for _ in food_gl_codes]) if food_gl_codes else "''" beverage_gl_placeholders = ','.join(['?' for _ in beverage_gl_codes]) if beverage_gl_codes else "''" # Query tickets with table entities and tracked course items # Calculate food and beverage spend separately based on GL codes query = f""" WITH FoodSpend AS ( SELECT t.Id, SUM(o.Price * o.Quantity) as FoodTotal FROM Orders o INNER JOIN Tickets t ON o.TicketId = t.Id INNER JOIN MenuItems mi ON o.MenuItemId = mi.Id CROSS APPLY ( SELECT CHARINDEX('"TN":"NewBook GLA"', mi.CustomTags) as gla_pos ) gla CROSS APPLY ( SELECT CASE WHEN gla.gla_pos > 0 THEN CHARINDEX('"TV":"', mi.CustomTags, gla.gla_pos) + 6 ELSE 0 END as tv_start ) tv CROSS APPLY ( SELECT CASE WHEN tv.tv_start > 6 THEN CHARINDEX('"', mi.CustomTags, tv.tv_start) ELSE 0 END as tv_end ) pos CROSS APPLY ( SELECT CASE WHEN tv.tv_start > 0 AND pos.tv_end > tv.tv_start THEN SUBSTRING(mi.CustomTags, tv.tv_start, pos.tv_end - tv.tv_start) ELSE NULL END as GLACode ) tag WHERE t.Date >= CAST(? AS DATE) AND t.Date < DATEADD(day, 1, CAST(? AS DATE)) AND t.IsClosed = 1 AND (o.OrderStates IS NULL OR o.OrderStates NOT LIKE '%"S":"Void"%') AND (o.OrderStates IS NULL OR o.OrderStates NOT LIKE '%"S":"Canceled"%') AND tag.GLACode IN ({food_gl_placeholders}) GROUP BY t.Id ), BeverageSpend AS ( SELECT t.Id, SUM(o.Price * o.Quantity) as BeverageTotal FROM Orders o INNER JOIN Tickets t ON o.TicketId = t.Id INNER JOIN MenuItems mi ON o.MenuItemId = mi.Id CROSS APPLY ( SELECT CHARINDEX('"TN":"NewBook GLA"', mi.CustomTags) as gla_pos ) gla CROSS APPLY ( SELECT CASE WHEN gla.gla_pos > 0 THEN CHARINDEX('"TV":"', mi.CustomTags, gla.gla_pos) + 6 ELSE 0 END as tv_start ) tv CROSS APPLY ( SELECT CASE WHEN tv.tv_start > 6 THEN CHARINDEX('"', mi.CustomTags, tv.tv_start) ELSE 0 END as tv_end ) pos CROSS APPLY ( SELECT CASE WHEN tv.tv_start > 0 AND pos.tv_end > tv.tv_start THEN SUBSTRING(mi.CustomTags, tv.tv_start, pos.tv_end - tv.tv_start) ELSE NULL END as GLACode ) tag WHERE t.Date >= CAST(? AS DATE) AND t.Date < DATEADD(day, 1, CAST(? AS DATE)) AND t.IsClosed = 1 AND (o.OrderStates IS NULL OR o.OrderStates NOT LIKE '%"S":"Void"%') AND (o.OrderStates IS NULL OR o.OrderStates NOT LIKE '%"S":"Canceled"%') AND tag.GLACode IN ({beverage_gl_placeholders}) GROUP BY t.Id ), MainCourseCount AS ( SELECT t.Id, COUNT(o.Id) as MainCourseCount FROM Orders o INNER JOIN Tickets t ON o.TicketId = t.Id INNER JOIN MenuItems mi ON o.MenuItemId = mi.Id CROSS APPLY ( SELECT CHARINDEX('"TN":"Kitchen Course"', mi.CustomTags) as kc_pos ) kc CROSS APPLY ( SELECT CASE WHEN kc.kc_pos > 0 THEN CHARINDEX('"TV":"', mi.CustomTags, kc.kc_pos) + 6 ELSE 0 END as tv_start ) tv CROSS APPLY ( SELECT CASE WHEN tv.tv_start > 6 THEN CHARINDEX('"', mi.CustomTags, tv.tv_start) ELSE 0 END as tv_end ) pos CROSS APPLY ( SELECT CASE WHEN tv.tv_start > 0 AND pos.tv_end > tv.tv_start THEN SUBSTRING(mi.CustomTags, tv.tv_start, pos.tv_end - tv.tv_start) ELSE NULL END as KitchenCourse ) course WHERE t.Date >= CAST(? AS DATE) AND t.Date < DATEADD(day, 1, CAST(? AS DATE)) AND t.IsClosed = 1 AND (o.OrderStates IS NULL OR o.OrderStates NOT LIKE '%"S":"Void"%') AND (o.OrderStates IS NULL OR o.OrderStates NOT LIKE '%"S":"Canceled"%') AND course.KitchenCourse IN ({cat_placeholders}) GROUP BY t.Id ) SELECT DISTINCT t.Id as TicketId, t.Date as TicketDate, t.LastUpdateTime as TicketClosedTime, -- Calculate "practical ticket time" as average of food order timestamps -- Handles pre-dinner drinks scenario (bar tab before booking time) ( SELECT AVG(CAST(o.CreatedDateTime AS FLOAT)) FROM Orders o INNER JOIN MenuItems mi ON o.MenuItemId = mi.Id CROSS APPLY ( SELECT CHARINDEX('"TN":"NewBook GLA"', mi.CustomTags) as gla_pos ) gla CROSS APPLY ( SELECT CASE WHEN gla.gla_pos > 0 THEN CHARINDEX('"TV":"', mi.CustomTags, gla.gla_pos) + 6 ELSE 0 END as tv_start ) tv CROSS APPLY ( SELECT CASE WHEN tv.tv_start > 6 THEN CHARINDEX('"', mi.CustomTags, tv.tv_start) ELSE 0 END as tv_end ) pos CROSS APPLY ( SELECT CASE WHEN tv.tv_start > 0 AND pos.tv_end > tv.tv_start THEN SUBSTRING(mi.CustomTags, tv.tv_start, pos.tv_end - tv.tv_start) ELSE NULL END as GLACode ) tag WHERE o.TicketId = t.Id AND tag.GLACode IN ({food_gl_placeholders}) AND (o.OrderStates IS NULL OR o.OrderStates NOT LIKE '%"S":"Void"%') AND (o.OrderStates IS NULL OR o.OrderStates NOT LIKE '%"S":"Canceled"%') ) as AvgFoodOrderTime, t.TicketTags as TicketTags, -- Extract table name from TicketEntities ( SELECT TOP 1 EntityName FROM TicketEntities te WHERE te.Ticket_Id = t.Id AND te.EntityTypeId = 2 ) as TableName, -- Check if ticket has a Room entity (EntityTypeId = 3) -- If this returns a value, the customer selected their room = they're a resident ( SELECT TOP 1 EntityName FROM TicketEntities te WHERE te.Ticket_Id = t.Id AND te.EntityTypeId = 3 ) as RoomEntity, -- Food and beverage spend from GL codes ISNULL(fs.FoodTotal, 0) as FoodTotal, ISNULL(bs.BeverageTotal, 0) as BeverageTotal, -- Main course count for fallback cover estimation ISNULL(mcc.MainCourseCount, 0) as MainCourseCount FROM Tickets t LEFT JOIN FoodSpend fs ON fs.Id = t.Id LEFT JOIN BeverageSpend bs ON bs.Id = t.Id LEFT JOIN MainCourseCount mcc ON mcc.Id = t.Id WHERE t.Date >= CAST(? AS DATE) AND t.Date < DATEADD(day, 1, CAST(? AS DATE)) AND t.IsClosed = 1 -- Must have table entity AND EXISTS ( SELECT 1 FROM TicketEntities te WHERE te.Ticket_Id = t.Id AND te.EntityTypeId = 2 ) -- Must have at least one tracked course item AND EXISTS ( SELECT 1 FROM Orders ord JOIN Tickets tck ON ord.TicketId = tck.Id JOIN MenuItems mi2 ON ord.MenuItemId = mi2.Id CROSS APPLY ( SELECT CHARINDEX('"TN":"Kitchen Course"', mi2.CustomTags) as kc_pos ) kc CROSS APPLY ( SELECT CASE WHEN kc.kc_pos > 0 THEN CHARINDEX('"TV":"', mi2.CustomTags, kc.kc_pos) + 6 ELSE 0 END as tv_start ) tv CROSS APPLY ( SELECT CASE WHEN tv.tv_start > 6 THEN CHARINDEX('"', mi2.CustomTags, tv.tv_start) ELSE 0 END as tv_end ) pos CROSS APPLY ( SELECT CASE WHEN tv.tv_start > 0 AND pos.tv_end > tv.tv_start THEN SUBSTRING(mi2.CustomTags, tv.tv_start, pos.tv_end - tv.tv_start) ELSE NULL END as KitchenCourse ) course WHERE tck.Id = t.Id AND course.KitchenCourse IN ({cat_placeholders}) AND (ord.OrderStates IS NULL OR ord.OrderStates NOT LIKE '%"S":"Void"%') AND (ord.OrderStates IS NULL OR ord.OrderStates NOT LIKE '%"S":"Canceled"%') ) ORDER BY t.Date """ # Build params: # - FoodSpend CTE: from_date, to_date, food_gl_codes (if any) # - BeverageSpend CTE: from_date, to_date, beverage_gl_codes (if any) # - MainCourseCount CTE: from_date, to_date, tracked_categories # - AvgFoodOrderTime subquery: food_gl_codes (if any) # - Main query: from_date, to_date, tracked_categories params = [ from_date.isoformat(), # FoodSpend CTE from_date to_date.isoformat(), # FoodSpend CTE to_date ] if food_gl_codes: params.extend(food_gl_codes) # FoodSpend CTE GL codes params.extend([ from_date.isoformat(), # BeverageSpend CTE from_date to_date.isoformat(), # BeverageSpend CTE to_date ]) if beverage_gl_codes: params.extend(beverage_gl_codes) # BeverageSpend CTE GL codes params.extend([ from_date.isoformat(), # MainCourseCount CTE from_date to_date.isoformat(), # MainCourseCount CTE to_date ]) params.extend(tracked_categories) # MainCourseCount CTE Kitchen Course categories # AvgFoodOrderTime subquery in SELECT if food_gl_codes: params.extend(food_gl_codes) # AvgFoodOrderTime subquery GL codes params.extend([ from_date.isoformat(), # Main query from_date to_date.isoformat() # Main query to_date ]) params.extend(tracked_categories) # Main query Kitchen Course categories try: async with aioodbc.connect(dsn=self.connection_string) as conn: async with conn.cursor() as cursor: # Log the query for debugging logger.info(f"Executing restaurant spend query with {len(params)} parameters") logger.info(f"Params: {params}") logger.info(f"Query:\n{query}") await cursor.execute(query, params) rows = await cursor.fetchall() results = [] for row in rows: ticket_id = row[0] ticket_date = row[1] closed_time = row[2] # t.LastUpdateTime (DATETIME) avg_food_time_float = row[3] # AvgFoodOrderTime (FLOAT or None) ticket_tags = row[4] if row[4] else "" table_name = row[5] room_entity = row[6] # Room entity name if customer selected their room food_total = Decimal(str(row[7])) if row[7] else Decimal("0") beverage_total = Decimal(str(row[8])) if row[8] else Decimal("0") main_course_count = int(row[9]) if row[9] else 0 total_spend = food_total + beverage_total # Calculate practical ticket time from average food order timestamps # Handles pre-dinner drinks scenario (bar tab before booking time) ticket_time = None if avg_food_time_float: # SQL AVG(CAST(datetime AS FLOAT)) returns days since 1900-01-01 # Convert back to datetime base_date = datetime(1900, 1, 1) practical_datetime = base_date + timedelta(days=float(avg_food_time_float)) ticket_time = practical_datetime.time() elif closed_time: # Fallback to ticket closed time if no food orders ticket_time = closed_time.time() if closed_time else None # Extract booking ID from ticket tags # Tag format: "BOOKING_ID - Guest Name" or similar booking_id = None # Placeholder for future implementation: # if " - " in ticket_tags: # booking_id = ticket_tags.split(" - ")[0].strip() # Parse "Covers" from ticket tags (JSON array format) # Tags format: [{"TN":"Tag Name","TV":"Tag Value"}, ...] covers = 0 if ticket_tags: import json try: tags_list = json.loads(ticket_tags) for tag in tags_list: if tag.get('TN') == 'Covers': covers = int(tag.get('TV', 0)) break except (json.JSONDecodeError, ValueError, KeyError): pass # Fallback to main course count if covers tag is 0 or not found if covers == 0 and main_course_count > 0: covers = main_course_count results.append({ "ticket_id": ticket_id, "ticket_date": ticket_date, "ticket_time": ticket_time, # Practical time from avg food orders "ticket_closed_time": closed_time, # Actual closed time "table_name": table_name, "room_entity": room_entity, "has_room_entity": bool(room_entity), "ticket_tag": ticket_tags if ticket_tags else None, "booking_id": booking_id, "food_total": food_total, "beverage_total": beverage_total, "total_spend": total_spend, "estimated_covers": covers, # Estimated covers from tags or main course count "main_course_count": main_course_count # For debugging/validation }) logger.info(f"SambaPOS restaurant spend: fetched {len(results)} tickets with table entities") return results except Exception as e: logger.error(f"Failed to fetch SambaPOS restaurant spend: {e}") raise