FastAPI backend (Python 3.11, MSSQL ODBC for SambaPOS, Azure DI OCR),
kitchen_db on central PG. React/TS/Vite frontend with navy sidebar layout.
Backend: auth.py (APP_SLUG=kitchen, SimpleNamespace — archive routes use
.kitchen_id/.is_admin without modification), main.py (51 migrations, scheduler,
internal router for KDS bookings feed), api/internal.py, full archive API
(31 routers: invoices, recipes, menus, sambapos, resos, newbook, disputes,
purchase_orders, etc.), models, migrations, OCR pipeline.
kitchen_id pinned to 1 (B1 — single hotel).
Frontend: AuthGate (app=kitchen, token shim for archive compat — B5b pending),
Layout (navy sidebar, 6 sections, Lucide icons, teal --app-primary),
App.tsx (Outlet pattern, UploadApp outside Layout), index.css (full :root block).
strict: false — archive components have type issues; build clean.
Note: 45 archive components call fetch('/api/...') without /kitchen/ prefix
(B5b). Runtime 404s; deferred until after initial testing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1220 lines
54 KiB
Python
1220 lines
54 KiB
Python
"""
|
|
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=<price>.
|
|
# 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
|