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>
1165 lines
52 KiB
Python
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
|