kitchen/backend/services/resos_stats.py
jtricerolph 8d688b459d Initial kitchen scaffold — Phase 1 kitchen port (build-verified 2026-07-11)
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>
2026-07-12 12:15:39 +00:00

1165 lines
52 KiB
Python

"""
Resos Statistics Service - Phase 8
Handles matching between SambaPOS tickets and Resos bookings,
calculates spend analysis, and generates statistics for the Bookings Stats Report.
"""
import logging
from datetime import date, datetime, time, timedelta
from decimal import Decimal
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, or_, func
from models.resos import ResosBooking, ResosOpeningHour
from models.settings import KitchenSettings
from services.sambapos_api import SambaPOSClient
logger = logging.getLogger(__name__)
class ResosStatsService:
"""Service for calculating Resos booking statistics with SambaPOS spend integration."""
def __init__(self, kitchen_id: int, db: AsyncSession):
self.kitchen_id = kitchen_id
self.db = db
async def _get_settings(self) -> KitchenSettings:
"""Fetch kitchen settings."""
result = await self.db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == self.kitchen_id)
)
return result.scalar_one()
async def _get_sambapos_client(self, settings: KitchenSettings) -> Optional[SambaPOSClient]:
"""Create SambaPOS client if configured."""
if not all([settings.sambapos_db_host, settings.sambapos_db_name,
settings.sambapos_db_username, settings.sambapos_db_password]):
logger.warning("SambaPOS not configured, skipping spend analysis")
return None
return SambaPOSClient(
host=settings.sambapos_db_host,
port=settings.sambapos_db_port or 1433,
database=settings.sambapos_db_name,
username=settings.sambapos_db_username,
password=settings.sambapos_db_password
)
def _parse_gl_codes(self, gl_code_str: Optional[str]) -> list[str]:
"""Parse comma-separated GL codes from settings."""
if not gl_code_str:
return []
return [code.strip() for code in gl_code_str.split(',') if code.strip()]
def _match_ticket_to_booking_by_id(
self,
ticket: dict,
bookings_by_id: dict[str, dict]
) -> Optional[dict]:
"""
Primary matching: Match ticket to booking by Resos booking ID from ticket tag.
Tag format: "BOOKING_ID - Guest Name" (e.g., "ABC123XYZ - John Smith")
NOT YET IMPLEMENTED - This is a placeholder for future enhancement.
Currently returns None, causing fallback to table matching.
Args:
ticket: Ticket data from SambaPOS
bookings_by_id: Dict mapping resos_booking_id to booking data
Returns:
Matched booking dict or None if no match
"""
# TODO: Implement when ticket tagging with booking ID is added to SambaPOS
# booking_id = ticket.get('booking_id')
# if booking_id and booking_id in bookings_by_id:
# logger.debug(f"Primary match: Ticket {ticket['ticket_id']} matched to booking {booking_id}")
# return bookings_by_id[booking_id]
return None
def _normalize_table_name(self, resos_table_name: str, sambapos_table_name: str) -> bool:
"""
Check if Resos and SambaPOS table names match, using smart normalization.
Handles common naming patterns:
- "Table 1" (Resos) → "T01" (SambaPOS)
- "Table 10" (Resos) → "T10" (SambaPOS)
Args:
resos_table_name: Table name from Resos booking
sambapos_table_name: Table name from SambaPOS ticket
Returns:
True if names match, False otherwise
"""
# Handle None values
if resos_table_name is None or sambapos_table_name is None:
return False
# Exact match
if resos_table_name == sambapos_table_name:
return True
# Case-insensitive match
if resos_table_name.lower() == sambapos_table_name.lower():
return True
# Smart normalization: "Table 1" → "T01"
# Extract number from Resos name (e.g., "Table 1" → "1")
if resos_table_name.startswith("Table "):
try:
table_num = resos_table_name.replace("Table ", "").strip()
# Try zero-padded format: "1" → "T01"
if f"T{table_num.zfill(2)}" == sambapos_table_name:
return True
# Try non-padded format: "10" → "T10"
if f"T{table_num}" == sambapos_table_name:
return True
except:
pass
# SambaPOS format to Resos format: "T01" → "Table 1"
if sambapos_table_name.startswith("T"):
try:
table_num = sambapos_table_name[1:].lstrip("0") or "0"
if f"Table {table_num}" == resos_table_name:
return True
except:
pass
return False
def _match_ticket_to_booking_by_table(
self,
ticket: dict,
bookings_by_table_date: dict[tuple, list[dict]],
opening_hours_mapping: Optional[list[dict]]
) -> Optional[dict]:
"""
Fallback matching: Match ticket to booking by table + date + service period.
Uses smart table name normalization to match different naming conventions:
- "Table 1" (Resos) ↔ "T01" (SambaPOS)
Args:
ticket: Ticket data from SambaPOS with table_name, ticket_date, ticket_time
bookings_by_table_date: Dict mapping (table_name, date) to list of bookings
opening_hours_mapping: Service period time windows from settings
Returns:
Matched booking dict or None if no match
"""
table_name = ticket.get('table_name')
ticket_date = ticket.get('ticket_date')
ticket_time = ticket.get('ticket_time')
if not table_name or not ticket_date or not ticket_time:
return None
# Convert ticket_date to date object if it's a datetime
from datetime import date as date_type, datetime as datetime_type
if isinstance(ticket_date, datetime_type):
ticket_date_only = ticket_date.date()
else:
ticket_date_only = ticket_date
# Try exact match first
key = (table_name, ticket_date_only)
candidate_bookings = bookings_by_table_date.get(key, [])
# If no exact match, try normalized matching
if not candidate_bookings:
logger.info(f"No exact match for table '{table_name}' on {ticket_date_only}, trying normalization...")
matches_found = False
for (booking_table, booking_date), bookings in bookings_by_table_date.items():
if booking_date == ticket_date_only:
matches = self._normalize_table_name(booking_table, table_name)
logger.info(f" Comparing Resos '{booking_table}' with SambaPOS '{table_name}': {matches}")
if matches:
candidate_bookings = bookings
matches_found = True
logger.info(f"✓ Table name MATCHED: '{booking_table}' (Resos) ↔ '{table_name}' (SambaPOS)")
break
if not candidate_bookings:
logger.info(f"❌ No bookings found for table '{table_name}' on {ticket_date_only}")
return None
# Calculate time differences for all bookings (even if only one)
# We still need to enforce the 60-minute window!
candidates_with_time_diff = []
for booking in candidate_bookings:
booking_time = booking['booking_time']
ticket_dt = datetime.combine(ticket_date_only, ticket_time)
booking_dt = datetime.combine(ticket_date_only, booking_time)
time_diff = abs((ticket_dt - booking_dt).total_seconds() / 60)
candidates_with_time_diff.append({
'booking': booking,
'time_diff': time_diff
})
# Filter to bookings within 60 minutes (reduced from 120)
candidates_within_window = [c for c in candidates_with_time_diff if c['time_diff'] <= 60]
if not candidates_within_window:
# No matches within time window
min_diff = min(c['time_diff'] for c in candidates_with_time_diff) if candidates_with_time_diff else None
logger.debug(f"No match: Ticket {ticket['ticket_id']} for {table_name} on {ticket_date_only} - closest booking {min_diff:.0f} min away (outside 60min window)")
return None
# If multiple matches within window, prefer same service period
if len(candidates_within_window) > 1 and opening_hours_mapping:
# Infer ticket's service period
ticket_period = self._infer_service_period_from_time(
ticket_time,
[], # opening_hours_data not available here, but method handles gracefully
ticket_datetime=datetime.combine(ticket_date_only, ticket_time),
settings=None # settings not available here
)
# Find candidates in same service period
same_period_candidates = [
c for c in candidates_within_window
if c['booking'].get('opening_hour_name') == ticket_period
]
if same_period_candidates:
# Return closest match from same service period
best_candidate = min(same_period_candidates, key=lambda c: c['time_diff'])
logger.debug(f"Fallback match: Ticket {ticket['ticket_id']} matched to booking {best_candidate['booking']['resos_booking_id']} (time diff: {best_candidate['time_diff']:.0f} min, same service period: {ticket_period})")
return best_candidate['booking']
# Return closest match by time within window
best_candidate = min(candidates_within_window, key=lambda c: c['time_diff'])
logger.debug(f"Fallback match: Ticket {ticket['ticket_id']} matched to booking {best_candidate['booking']['resos_booking_id']} (time diff: {best_candidate['time_diff']:.0f} min)")
return best_candidate['booking']
async def get_spend_statistics(
self,
from_date: date,
to_date: date
) -> dict:
"""
Calculate spend statistics for date range with resident/non-resident split.
Uses two-fold matching:
1. Primary: Match by booking ID from ticket tag (not yet implemented)
2. Fallback: Match by table + date + service period
Args:
from_date: Start date (inclusive)
to_date: End date (inclusive)
Returns:
Dict with:
- total_spend: Decimal
- food_spend: Decimal
- beverage_spend: Decimal
- resident_spend: Decimal
- non_resident_spend: Decimal
- resident_covers: int
- non_resident_covers: int
- matched_tickets: int
- unmatched_tickets: int
- daily_breakdown: list[dict]
- service_period_breakdown: list[dict]
"""
settings = await self._get_settings()
# Get SambaPOS client
sambapos = await self._get_sambapos_client(settings)
if not sambapos:
return self._empty_stats()
# Parse GL codes
food_gl_codes = self._parse_gl_codes(settings.sambapos_food_gl_codes)
beverage_gl_codes = self._parse_gl_codes(settings.sambapos_beverage_gl_codes)
if not food_gl_codes and not beverage_gl_codes:
logger.warning("No GL codes configured for food/beverage split")
return self._empty_stats()
# Get tracked categories
tracked_categories = []
if settings.sambapos_tracked_categories:
tracked_categories = [cat.strip() for cat in settings.sambapos_tracked_categories.split(',') if cat.strip()]
if not tracked_categories:
logger.warning("No tracked categories configured")
return self._empty_stats()
# Fetch SambaPOS tickets
logger.info(f"Fetching SambaPOS restaurant spend for {from_date} to {to_date}")
tickets = await sambapos.get_restaurant_spend(
from_date=from_date,
to_date=to_date,
tracked_categories=tracked_categories,
food_gl_codes=food_gl_codes,
beverage_gl_codes=beverage_gl_codes
)
logger.info(f"Fetched {len(tickets)} tickets from SambaPOS")
# Fetch Resos bookings (only completed dining events with table assignments)
result = await self.db.execute(
select(ResosBooking).where(
and_(
ResosBooking.kitchen_id == self.kitchen_id,
ResosBooking.booking_date >= from_date,
ResosBooking.booking_date <= to_date,
func.lower(ResosBooking.status).in_(['seated', 'left', 'arrived']), # Only completed bookings
ResosBooking.table_name.isnot(None) # Must have table assignment
)
)
)
bookings = result.scalars().all()
logger.info(f"Fetched {len(bookings)} completed bookings with tables from Resos")
# Fetch opening hours from database to infer service periods for unmatched tickets
from models.resos import ResosOpeningHour
opening_hours_result = await self.db.execute(
select(ResosOpeningHour).where(
ResosOpeningHour.kitchen_id == self.kitchen_id
)
)
opening_hours = opening_hours_result.scalars().all()
logger.info(f"Fetched {len(opening_hours)} opening hours from database for kitchen {self.kitchen_id}")
# Build opening hours data with display names and actual_end times from settings
opening_hours_data = []
opening_hours_map = {}
if settings.resos_opening_hours_mapping:
logger.debug(f"Settings has {len(settings.resos_opening_hours_mapping)} opening hour mappings")
for mapping in settings.resos_opening_hours_mapping:
resos_id = mapping.get('resos_id')
if resos_id:
# Store full mapping (display_name and actual_end)
opening_hours_map[resos_id] = mapping
else:
logger.warning("No resos_opening_hours_mapping in settings")
for oh in opening_hours:
logger.debug(f"Processing opening hour: {oh.name}, start={oh.start_time}, end={oh.end_time}, id={oh.resos_opening_hour_id}")
if oh.start_time and oh.end_time:
mapping = opening_hours_map.get(oh.resos_opening_hour_id, {})
display_name = mapping.get('display_name', oh.name)
# Use actual_end from mapping if available, otherwise use database end_time
actual_end_str = mapping.get('actual_end')
if actual_end_str:
# Parse time string to time object
from datetime import datetime
end_time = datetime.strptime(actual_end_str, '%H:%M').time()
logger.debug(f"Using actual_end {actual_end_str} for {display_name} (database has {oh.end_time})")
else:
end_time = oh.end_time
opening_hours_data.append({
'start_time': oh.start_time,
'end_time': end_time,
'display_name': display_name,
'resos_id': oh.resos_opening_hour_id
})
else:
logger.warning(f"Skipping opening hour '{oh.name}' - missing start_time or end_time")
logger.info(f"Loaded {len(opening_hours_data)} opening hours for service period inference")
# Build lookup dicts for matching
bookings_by_id = {b.resos_booking_id: b for b in bookings}
# Group bookings by (table_name, date) for fallback matching
bookings_by_table_date = {}
resos_table_names = set()
for booking in bookings:
if booking.table_name:
resos_table_names.add(booking.table_name)
key = (booking.table_name, booking.booking_date)
if key not in bookings_by_table_date:
bookings_by_table_date[key] = []
bookings_by_table_date[key].append({
'resos_booking_id': booking.resos_booking_id,
'booking_date': booking.booking_date,
'booking_time': booking.booking_time,
'is_hotel_guest': booking.is_hotel_guest,
'people': booking.people,
'opening_hour_id': booking.opening_hour_id,
'opening_hour_name': booking.opening_hour_name
})
logger.info(f"Resos table names: {sorted(resos_table_names)}")
# NEW APPROACH: Classify ALL tickets as resident/non-resident
# Don't throw away unmatched tickets!
all_tickets = []
resident_tickets = []
non_resident_tickets = []
sambapos_table_names = set()
for ticket in tickets:
if ticket.get('table_name'):
sambapos_table_names.add(ticket['table_name'])
logger.info(f"SambaPOS table names: {sorted(sambapos_table_names)}")
for ticket in tickets:
# Method 1: Check if ticket has a Room entity in SambaPOS
# TODO: Need to add room_entity field to ticket data from get_restaurant_spend
has_room_entity = ticket.get('has_room_entity', False)
is_resident = False
classification_method = 'non-resident-default'
if has_room_entity:
# Definitely a resident - they selected their room
is_resident = True
classification_method = 'room-entity'
else:
# Method 2: Try to match table to Resos booking
matched_booking = self._match_ticket_to_booking_by_id(ticket, bookings_by_id)
if not matched_booking:
matched_booking = self._match_ticket_to_booking_by_table(
ticket,
bookings_by_table_date,
settings.resos_opening_hours_mapping
)
if matched_booking:
# Check if Resos booking indicates hotel guest
is_resident = matched_booking.get('is_hotel_guest', False) or False
classification_method = 'resos-booking-match'
ticket['matched_booking'] = matched_booking
ticket['is_resident'] = is_resident
ticket['classification_method'] = classification_method
all_tickets.append(ticket)
if is_resident:
resident_tickets.append(ticket)
else:
non_resident_tickets.append(ticket)
logger.info(f"Classified {len(all_tickets)} total tickets: {len(resident_tickets)} residents, {len(non_resident_tickets)} non-residents")
# Calculate totals from ALL tickets (not just matched ones)
total_spend = sum(t['total_spend'] for t in all_tickets)
food_spend = sum(t['food_total'] for t in all_tickets)
beverage_spend = sum(t['beverage_total'] for t in all_tickets)
# Resident vs non-resident spend (based on classification)
resident_spend = sum(t['total_spend'] for t in resident_tickets)
non_resident_spend = sum(t['total_spend'] for t in non_resident_tickets)
# Count covers from matched bookings (only for tickets that matched to Resos)
# For tickets classified by room entity, we don't have cover count
resident_covers = sum(
t.get('matched_booking', {}).get('people', 0)
for t in resident_tickets
if 'matched_booking' in t
)
non_resident_covers = sum(
t.get('matched_booking', {}).get('people', 0)
for t in non_resident_tickets
if 'matched_booking' in t
)
# Count how many tickets were matched to Resos bookings
matched_count = sum(1 for t in all_tickets if 'matched_booking' in t)
unmatched_count = len(all_tickets) - matched_count
# Count tickets classified by each method
room_entity_count = sum(1 for t in all_tickets if t.get('classification_method') == 'room-entity')
resos_match_count = sum(1 for t in all_tickets if t.get('classification_method') == 'resos-booking-match')
default_count = sum(1 for t in all_tickets if t.get('classification_method') == 'non-resident-default')
logger.info(f"Classification breakdown: {room_entity_count} by room entity, {resos_match_count} by Resos match, {default_count} defaulted to non-resident")
# Daily breakdown using ALL tickets
daily_breakdown = self._calculate_daily_breakdown(all_tickets, from_date, to_date)
# Service period breakdown using ALL tickets and ALL bookings
service_period_breakdown = self._calculate_service_period_breakdown(all_tickets, bookings, settings, opening_hours_data)
# Daily breakdown by service period
daily_service_breakdown = self._calculate_daily_service_breakdown(all_tickets, bookings, settings, opening_hours_data)
logger.info(f"📊 Daily service breakdown calculated: {len(daily_service_breakdown)} days")
if len(daily_service_breakdown) > 0:
first_day = daily_service_breakdown[0]
logger.info(f"📊 First day: {first_day['date']}, periods: {list(first_day['periods'].keys())}")
return {
'total_spend': float(total_spend),
'food_spend': float(food_spend),
'beverage_spend': float(beverage_spend),
'resident_spend': float(resident_spend),
'non_resident_spend': float(non_resident_spend),
'resident_covers': resident_covers,
'non_resident_covers': non_resident_covers,
'total_tickets': len(all_tickets),
'resident_tickets': len(resident_tickets),
'non_resident_tickets': len(non_resident_tickets),
'matched_to_resos': matched_count,
'unmatched_to_resos': unmatched_count,
'classification': {
'room_entity': room_entity_count,
'resos_booking_match': resos_match_count,
'non_resident_default': default_count
},
'daily_breakdown': daily_breakdown,
'service_period_breakdown': service_period_breakdown,
'daily_service_breakdown': daily_service_breakdown
}
def _empty_stats(self) -> dict:
"""Return empty statistics structure."""
return {
'total_spend': 0.0,
'food_spend': 0.0,
'beverage_spend': 0.0,
'resident_spend': 0.0,
'non_resident_spend': 0.0,
'resident_covers': 0,
'non_resident_covers': 0,
'matched_tickets': 0,
'unmatched_tickets': 0,
'daily_breakdown': [],
'daily_service_breakdown': [],
'service_period_breakdown': []
}
def _calculate_daily_breakdown(
self,
matched_tickets: list[dict],
from_date: date,
to_date: date
) -> list[dict]:
"""Calculate daily spend breakdown."""
# Group by date
daily_data = {}
current = from_date
while current <= to_date:
daily_data[current] = {
'date': current.isoformat(),
'total_spend': 0.0,
'food_spend': 0.0,
'beverage_spend': 0.0,
'resident_spend': 0.0,
'non_resident_spend': 0.0,
'ticket_count': 0
}
current += timedelta(days=1)
for ticket in matched_tickets:
ticket_date = ticket['ticket_date']
if ticket_date not in daily_data:
continue
daily_data[ticket_date]['total_spend'] += float(ticket['total_spend'])
daily_data[ticket_date]['food_spend'] += float(ticket['food_total'])
daily_data[ticket_date]['beverage_spend'] += float(ticket['beverage_total'])
daily_data[ticket_date]['ticket_count'] += 1
if ticket['is_resident']:
daily_data[ticket_date]['resident_spend'] += float(ticket['total_spend'])
else:
daily_data[ticket_date]['non_resident_spend'] += float(ticket['total_spend'])
return sorted(daily_data.values(), key=lambda x: x['date'])
def _infer_service_period_from_time(
self,
ticket_time,
opening_hours_data,
ticket_datetime=None,
settings=None
) -> str:
"""
Infer service period from ticket time using actual opening hours from database.
Two-pass matching:
1. First tries exact match with defined service period times (including manual breakfast if configured)
2. If no match, applies 30-minute buffer to EARLIEST period only (to catch breakfast/early arrivals)
For example, if Lunch is 12:00-16:00 (earliest), tickets from 11:30-11:59 count as Lunch
If multiple periods overlap, the later period (by start_time) takes precedence.
For example, if Lunch is 12:00-15:00 and Dinner is 14:30-22:00, a ticket at 14:45
will be classified as Dinner (the later period).
Args:
ticket_time: Time object from ticket
opening_hours_data: List of dicts with start_time, end_time, display_name
ticket_datetime: Optional datetime object to determine day of week for manual breakfast periods
settings: Optional KitchenSettings object for manual breakfast configuration
Returns:
Display name of matched service period or 'Unknown'
"""
if not ticket_time or not opening_hours_data:
return 'Unknown'
from datetime import time as time_type, datetime, timedelta
# Convert ticket_time to time object if needed
if not isinstance(ticket_time, time_type):
return 'Unknown'
# Merge manual breakfast periods if enabled and ticket_datetime is available
merged_hours_data = list(opening_hours_data) # Copy to avoid modifying original
if (settings and
ticket_datetime and
settings.resos_enable_manual_breakfast and
settings.resos_manual_breakfast_periods):
# Get day of week from ticket datetime (1=Monday, 7=Sunday)
# Python's weekday(): Monday=0, Sunday=6, so we add 1
ticket_day_of_week = ticket_datetime.weekday() + 1
# Filter manual breakfast periods for this day
for breakfast_period in settings.resos_manual_breakfast_periods:
if breakfast_period.get('day') == ticket_day_of_week:
# Parse time strings to time objects
try:
start_str = breakfast_period.get('start')
end_str = breakfast_period.get('end')
if start_str and end_str:
start_time_obj = datetime.strptime(start_str, '%H:%M').time()
end_time_obj = datetime.strptime(end_str, '%H:%M').time()
merged_hours_data.append({
'start_time': start_time_obj,
'end_time': end_time_obj,
'display_name': 'Breakfast',
'resos_id': None, # Manual periods have no Resos ID
'is_manual': True
})
logger.debug(f"Added manual breakfast period for day {ticket_day_of_week}: {start_str} - {end_str}")
except Exception as e:
logger.warning(f"Failed to parse manual breakfast period: {e}")
# Use merged data for matching
periods_to_check = merged_hours_data
# 30-minute buffer before each service period to catch early arrivals
BUFFER_MINUTES = 30
# Sort periods by start_time (descending) so later periods take precedence
# Use datetime for proper sorting, handling midnight-crossing periods
def time_to_minutes(t):
"""Convert time to minutes since midnight for sorting."""
if not t:
return -1
return t.hour * 60 + t.minute
def subtract_minutes_from_time(t, minutes):
"""Subtract minutes from a time object, handling midnight wraparound."""
# Convert to datetime, subtract, convert back to time
temp_dt = datetime.combine(datetime.today(), t)
result_dt = temp_dt - timedelta(minutes=minutes)
return result_dt.time()
def sort_key(period):
start = period.get('start_time')
if not start:
return -1 # Invalid entries sort first (will be skipped)
start_mins = time_to_minutes(start)
# If start time is very early (00:00 - 05:59), treat as late night (next day)
# This ensures late night periods like 22:00-02:00 sort correctly
if start.hour < 6:
start_mins += 24 * 60 # Add 24 hours worth of minutes
return start_mins
sorted_periods = sorted(periods_to_check, key=sort_key, reverse=True)
# FIRST PASS: Try to match using actual service period times (no buffer)
# Later periods are checked first due to reverse sorting
for period in sorted_periods:
start_time = period.get('start_time')
end_time = period.get('end_time')
display_name = period.get('display_name')
if not start_time or not end_time or not display_name:
continue
# Handle periods that cross midnight (e.g., 22:00 - 02:00)
if start_time <= end_time:
# Normal period (e.g., 12:00 - 15:00)
if start_time <= ticket_time <= end_time:
return display_name
else:
# Period crosses midnight (e.g., 22:00 - 02:00)
if ticket_time >= start_time or ticket_time <= end_time:
return display_name
# SECOND PASS: Handle gaps between periods - assign to following period
# If ticket falls in a gap, assign it to the next period
# Sort periods by start time (ascending) for this check
periods_ascending = sorted(periods_to_check, key=sort_key)
ticket_mins = time_to_minutes(ticket_time)
for i, period in enumerate(periods_ascending):
start_time = period.get('start_time')
end_time = period.get('end_time')
display_name = period.get('display_name')
if not start_time or not end_time or not display_name:
continue
start_mins = time_to_minutes(start_time)
end_mins = time_to_minutes(end_time)
# Check if ticket is in a gap before this period starts
if i == 0:
# First period - check if ticket is before it
if ticket_mins < start_mins:
# Ticket is before first period, assign to first period
logger.info(f"Ticket {ticket_time} is before first period - assigning to {display_name}")
return display_name
else:
# Check gap between previous period end and this period start
prev_period = periods_ascending[i - 1]
prev_end = prev_period.get('end_time')
if prev_end:
prev_end_mins = time_to_minutes(prev_end)
# If ticket is in the gap (after previous period end, before this period start)
if prev_end_mins < ticket_mins < start_mins:
# Assign to following period (this period)
logger.info(f"Ticket {ticket_time} is in gap - assigning to following period {display_name}")
return display_name
# Check if this is the last period and ticket is after it
if i == len(periods_ascending) - 1:
if ticket_mins > end_mins:
# Ticket is after last period, assign to last period
logger.info(f"Ticket {ticket_time} is after last period - assigning to {display_name}")
return display_name
# THIRD PASS: Comprehensive fallback (should rarely be needed now)
# This handles "No opening hour" bookings and edge cases
if sorted_periods:
# Get earliest and latest periods
earliest_period = sorted_periods[-1]
latest_period = sorted_periods[0]
earliest_start = earliest_period.get('start_time')
earliest_name = earliest_period.get('display_name')
latest_end = latest_period.get('end_time')
latest_name = latest_period.get('display_name')
# Find if there's a manual breakfast period
breakfast_period = None
for period in periods_to_check:
if period.get('display_name', '').lower() == 'breakfast':
breakfast_period = period
break
ticket_mins = time_to_minutes(ticket_time)
# Handle very early tickets (before earliest period)
if earliest_start:
earliest_mins = time_to_minutes(earliest_start)
# If ticket is before earliest period
if ticket_mins < earliest_mins:
# If there's a manual breakfast and ticket is before it, include in breakfast
if breakfast_period:
breakfast_start = breakfast_period.get('start_time')
if breakfast_start and ticket_mins < time_to_minutes(breakfast_start):
logger.info(f"Ticket {ticket_time} is before breakfast - including in Breakfast period")
return 'Breakfast'
# If ticket is after breakfast but before first mapped period, map to first period
logger.info(f"Ticket {ticket_time} is before earliest period ({earliest_start}) - mapping to {earliest_name}")
return earliest_name
# Handle very late tickets (after latest period)
if latest_end:
latest_end_mins = time_to_minutes(latest_end)
# Handle midnight-crossing periods
if latest_period.get('start_time') and latest_period.get('start_time') > latest_end:
# Period crosses midnight, latest_end is early morning
# Ticket is late if it's after period start and before midnight
if ticket_mins > time_to_minutes(latest_period.get('start_time')):
logger.info(f"Ticket {ticket_time} is after latest period end - mapping to {latest_name}")
return latest_name
else:
# Normal period
if ticket_mins > latest_end_mins:
logger.info(f"Ticket {ticket_time} is after latest period ({latest_end}) - mapping to {latest_name}")
return latest_name
# Log tickets that couldn't be matched to any period
logger.warning(f"⚠️ Could not infer service period for ticket time {ticket_time}. Available periods: {len(periods_to_check)}")
return 'Unknown'
def _calculate_service_period_breakdown(self, all_tickets: list[dict], all_bookings: list, settings, opening_hours_data: list[dict]) -> list[dict]:
"""
Calculate spend breakdown by service period, grouped by display name from settings.
Counts:
- resos_covers: ALL Resos bookings (matched or unmatched)
- samba_covers: ALL SambaPOS tickets (matched or unmatched)
- covers: Max of resos_covers and samba_covers (or resos if matched)
"""
period_data = {}
# Build a lookup map from opening_hour_id to display_name
opening_hours_map = {}
if settings.resos_opening_hours_mapping:
for mapping in settings.resos_opening_hours_mapping:
resos_id = mapping.get('resos_id')
display_name = mapping.get('display_name')
if resos_id and display_name:
opening_hours_map[resos_id] = display_name
# Track which Resos bookings we've already counted to avoid double-counting covers
counted_bookings = set()
for ticket in all_tickets:
# Skip tickets with zero spend (all items void/cancelled)
if float(ticket.get('total_spend', 0)) == 0:
continue
# Try to get service period from matched booking first
if 'matched_booking' in ticket:
# Try to get display name from mapping first
opening_hour_id = ticket['matched_booking'].get('opening_hour_id')
if opening_hour_id and opening_hour_id in opening_hours_map:
period = opening_hours_map[opening_hour_id]
else:
# Fall back to opening_hour_name if no mapping found
period = ticket['matched_booking'].get('opening_hour_name')
# If opening_hour_name is "No opening hour", infer from booking time
if period == 'No opening hour':
logger.info(f"Booking has 'No opening hour' - inferring from booking time")
booking_time = ticket['matched_booking'].get('booking_time')
booking_date = ticket['matched_booking'].get('booking_date')
if booking_time and booking_date:
# Create datetime for day-of-week calculation
from datetime import datetime
booking_datetime = datetime.combine(booking_date, booking_time)
# Infer service period using the same logic as unmatched tickets
inferred_period = self._infer_service_period_from_time(
booking_time,
opening_hours_data,
ticket_datetime=booking_datetime,
settings=settings
)
logger.info(f"Inferred period '{inferred_period}' for booking at {booking_time}")
# Use inferred period if it's not 'Unknown' or 'No opening hour'
if inferred_period and inferred_period not in ('Unknown', 'No opening hour'):
period = inferred_period
logger.info(f"✓ Using inferred period '{period}'")
else:
logger.warning(f"⚠️ Inference returned '{inferred_period}', keeping 'No opening hour'")
else:
logger.warning(f"⚠️ Missing booking time or date for inference")
if not period: # None or empty string
period = 'No opening hour'
logger.warning(f"⚠️ Matched booking has no opening_hour_name: booking_id={ticket['matched_booking'].get('resos_booking_id')}")
# Get covers from Resos booking
people = ticket['matched_booking'].get('people', 0)
samba_covers = ticket.get('estimated_covers', 0) # Covers from SambaPOS ticket
# Only count Resos covers once per booking (avoid double-counting split bills)
booking_id = ticket['matched_booking'].get('resos_booking_id')
if booking_id and booking_id not in counted_bookings:
resos_covers = people
counted_bookings.add(booking_id)
else:
resos_covers = 0 # This booking already counted
else:
# For unmatched tickets, infer service period from ticket time using actual opening hours
ticket_time = ticket.get('ticket_time')
ticket_datetime = ticket.get('ticket_datetime')
period = self._infer_service_period_from_time(
ticket_time,
opening_hours_data,
ticket_datetime=ticket_datetime,
settings=settings
)
# Use estimated covers from SambaPOS for unmatched tickets
people = ticket.get('estimated_covers', 0)
resos_covers = 0 # No Resos booking for unmatched tickets
samba_covers = people
if period not in period_data:
period_data[period] = {
'service_period': period,
'total_spend': 0.0,
'food_spend': 0.0,
'beverage_spend': 0.0,
'covers': 0,
'resos_covers': 0, # Track Resos booking covers separately
'samba_covers': 0, # Track SambaPOS estimated covers separately
'ticket_count': 0
}
period_data[period]['total_spend'] += float(ticket['total_spend'])
period_data[period]['food_spend'] += float(ticket['food_total'])
period_data[period]['beverage_spend'] += float(ticket['beverage_total'])
period_data[period]['covers'] += people # Use Resos covers for matched, SambaPOS for unmatched
period_data[period]['resos_covers'] += resos_covers
period_data[period]['samba_covers'] += samba_covers
period_data[period]['ticket_count'] += 1
# Now add ALL Resos bookings that weren't matched (e.g., no-shows, cancellations, unmatched)
for booking in all_bookings:
# Skip if this booking was already counted via ticket match
if booking.resos_booking_id in counted_bookings:
continue
# This is an unmatched booking - add its covers to resos_covers
# Determine service period from booking's opening hour
opening_hour_id = booking.opening_hour_id
if opening_hour_id and opening_hour_id in opening_hours_map:
period = opening_hours_map[opening_hour_id]
else:
period = booking.opening_hour_name
# Infer period for "No opening hour" bookings
if period == 'No opening hour' and booking.booking_time:
from datetime import datetime
booking_datetime = datetime.combine(booking.booking_date, booking.booking_time)
inferred_period = self._infer_service_period_from_time(
booking.booking_time,
opening_hours_data,
ticket_datetime=booking_datetime,
settings=settings
)
if inferred_period and inferred_period not in ('Unknown', 'No opening hour'):
period = inferred_period
if not period:
period = 'Unknown'
# Initialize period if not exists
if period not in period_data:
period_data[period] = {
'service_period': period,
'total_spend': 0.0,
'food_spend': 0.0,
'beverage_spend': 0.0,
'covers': 0,
'resos_covers': 0,
'samba_covers': 0,
'ticket_count': 0
}
# Add unmatched booking covers to resos_covers
period_data[period]['resos_covers'] += booking.people
period_data[period]['covers'] += booking.people # Also add to total covers
logger.debug(f"Added unmatched booking {booking.resos_booking_id} ({booking.people} people) to {period}")
# Calculate average per cover
result = []
for period, data in period_data.items():
if data['covers'] > 0:
data['avg_spend_per_cover'] = data['total_spend'] / data['covers']
else:
data['avg_spend_per_cover'] = 0.0
result.append(data)
return sorted(result, key=lambda x: x['total_spend'], reverse=True)
def _calculate_daily_service_breakdown(self, all_tickets: list[dict], all_bookings: list, settings, opening_hours_data: list[dict]) -> list[dict]:
"""
Calculate daily breakdown by service period.
Counts ALL Resos bookings (matched or unmatched) and ALL SambaPOS tickets.
Returns list of dicts with format:
{
'date': '2026-01-20',
'periods': {
'Lunch': {'covers': 10, 'resos_covers': 12, 'samba_covers': 10, 'food': 200.0, 'beverage': 50.0, 'total_spend': 250.0},
'Dinner': {'covers': 25, 'resos_covers': 28, 'samba_covers': 24, 'food': 600.0, 'beverage': 150.0, 'total_spend': 750.0},
...
}
}
"""
# Build a lookup map from opening_hour_id to display_name
opening_hours_map = {}
if settings.resos_opening_hours_mapping:
for mapping in settings.resos_opening_hours_mapping:
resos_id = mapping.get('resos_id')
display_name = mapping.get('display_name')
if resos_id and display_name:
opening_hours_map[resos_id] = display_name
# Group by date
daily_data = {}
# Track which Resos bookings we've already counted to avoid double-counting covers
counted_bookings = set()
for ticket in all_tickets:
# Skip tickets with zero spend (all items void/cancelled)
if float(ticket.get('total_spend', 0)) == 0:
continue
ticket_date = ticket.get('ticket_date')
if hasattr(ticket_date, 'date'):
ticket_date = ticket_date.date()
date_str = ticket_date.isoformat()
# Get service period for this ticket
if 'matched_booking' in ticket:
opening_hour_id = ticket['matched_booking'].get('opening_hour_id')
if opening_hour_id and opening_hour_id in opening_hours_map:
period = opening_hours_map[opening_hour_id]
else:
period = ticket['matched_booking'].get('opening_hour_name')
# Infer period for "No opening hour" bookings
if period == 'No opening hour':
booking_time = ticket['matched_booking'].get('booking_time')
booking_date = ticket['matched_booking'].get('booking_date')
if booking_time and booking_date:
from datetime import datetime
booking_datetime = datetime.combine(booking_date, booking_time)
inferred_period = self._infer_service_period_from_time(
booking_time,
opening_hours_data,
ticket_datetime=booking_datetime,
settings=settings
)
if inferred_period and inferred_period not in ('Unknown', 'No opening hour'):
period = inferred_period
if not period:
period = 'No opening hour'
# Get covers from Resos booking
people = ticket['matched_booking'].get('people', 0)
samba_covers = ticket.get('estimated_covers', 0) # Covers from SambaPOS ticket
# Only count Resos covers once per booking (avoid double-counting split bills)
booking_id = ticket['matched_booking'].get('resos_booking_id')
if booking_id and booking_id not in counted_bookings:
resos_covers = people
counted_bookings.add(booking_id)
else:
resos_covers = 0 # This booking already counted
else:
ticket_time = ticket.get('ticket_time')
ticket_datetime = ticket.get('ticket_datetime')
period = self._infer_service_period_from_time(
ticket_time,
opening_hours_data,
ticket_datetime=ticket_datetime,
settings=settings
)
# Use estimated covers from SambaPOS for unmatched tickets
people = ticket.get('estimated_covers', 0)
resos_covers = 0 # No Resos booking for unmatched tickets
samba_covers = people
# Initialize date if not exists
if date_str not in daily_data:
daily_data[date_str] = {}
# Initialize period if not exists
if period not in daily_data[date_str]:
daily_data[date_str][period] = {
'covers': 0,
'resos_covers': 0, # Track Resos booking covers separately
'samba_covers': 0, # Track SambaPOS estimated covers separately
'food': 0.0,
'beverage': 0.0,
'total_spend': 0.0,
'ticket_count': 0
}
# Add ticket data
daily_data[date_str][period]['covers'] += people # Use Resos covers for matched, SambaPOS for unmatched
daily_data[date_str][period]['resos_covers'] += resos_covers
daily_data[date_str][period]['samba_covers'] += samba_covers
daily_data[date_str][period]['food'] += float(ticket['food_total'])
daily_data[date_str][period]['beverage'] += float(ticket['beverage_total'])
daily_data[date_str][period]['total_spend'] += float(ticket['total_spend'])
daily_data[date_str][period]['ticket_count'] += 1
# Now add ALL Resos bookings that weren't matched (e.g., no-shows, cancellations, unmatched)
for booking in all_bookings:
# Skip if this booking was already counted via ticket match
if booking.resos_booking_id in counted_bookings:
continue
# This is an unmatched booking - add its covers to resos_covers
date_str = booking.booking_date.isoformat()
# Determine service period from booking's opening hour
opening_hour_id = booking.opening_hour_id
if opening_hour_id and opening_hour_id in opening_hours_map:
period = opening_hours_map[opening_hour_id]
else:
period = booking.opening_hour_name
# Infer period for "No opening hour" bookings
if period == 'No opening hour' and booking.booking_time:
from datetime import datetime
booking_datetime = datetime.combine(booking.booking_date, booking.booking_time)
inferred_period = self._infer_service_period_from_time(
booking.booking_time,
opening_hours_data,
ticket_datetime=booking_datetime,
settings=settings
)
if inferred_period and inferred_period not in ('Unknown', 'No opening hour'):
period = inferred_period
if not period:
period = 'Unknown'
# Initialize date if not exists
if date_str not in daily_data:
daily_data[date_str] = {}
# Initialize period if not exists
if period not in daily_data[date_str]:
daily_data[date_str][period] = {
'covers': 0,
'resos_covers': 0,
'samba_covers': 0,
'food': 0.0,
'beverage': 0.0,
'total_spend': 0.0,
'ticket_count': 0
}
# Add unmatched booking covers to resos_covers
daily_data[date_str][period]['resos_covers'] += booking.people
daily_data[date_str][period]['covers'] += booking.people # Also add to total covers
logger.debug(f"Added unmatched booking {booking.resos_booking_id} ({booking.people} people) to {date_str} {period}")
# Convert to list format
result = []
for date_str in sorted(daily_data.keys()):
result.append({
'date': date_str,
'periods': daily_data[date_str]
})
return result