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>
422 lines
16 KiB
Python
422 lines
16 KiB
Python
"""
|
|
Residents Table Chart API
|
|
|
|
Gantt-style visualization showing hotel bookings with restaurant table indicators.
|
|
"""
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select, and_, or_
|
|
from datetime import date, timedelta
|
|
from typing import Optional
|
|
from pydantic import BaseModel
|
|
|
|
from auth import get_current_user, require_cap
|
|
from database import get_db
|
|
from models.user import User
|
|
from models.newbook import NewbookDailyOccupancy
|
|
from models.resos import ResosBooking
|
|
|
|
router = APIRouter(prefix="/residents-table-chart", tags=["Residents Table Chart"])
|
|
|
|
|
|
class RestaurantBookingDetail(BaseModel):
|
|
has_booking: bool
|
|
time: Optional[str] = None
|
|
people: Optional[int] = None
|
|
table_name: Optional[str] = None
|
|
opening_hour_name: Optional[str] = None
|
|
is_group_match: Optional[bool] = None # True if matched via group/exclude field (not the lead booking)
|
|
|
|
|
|
class BookingSegment(BaseModel):
|
|
booking_id: str | None
|
|
bookings_group_id: Optional[str] = None
|
|
check_in: str
|
|
check_out: str
|
|
nights: list[str]
|
|
is_dbb: Optional[bool] = None
|
|
is_package: Optional[bool] = None
|
|
restaurant_bookings: dict[str, RestaurantBookingDetail]
|
|
|
|
|
|
class RoomRow(BaseModel):
|
|
room_number: str | None
|
|
bookings: list[BookingSegment] # Multiple bookings in the same room
|
|
|
|
|
|
class ResidentsTableChartResponse(BaseModel):
|
|
date_range: dict
|
|
rooms: list[RoomRow] # Changed from 'bookings' to 'rooms'
|
|
summary: dict
|
|
metrics: Optional[dict] = None # Aggregated metrics for different time periods
|
|
|
|
|
|
@router.get("")
|
|
async def get_residents_table_chart(
|
|
start_date: Optional[date] = None,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
) -> ResidentsTableChartResponse:
|
|
"""
|
|
Get Gantt-style chart data showing hotel bookings with restaurant table indicators.
|
|
|
|
Args:
|
|
start_date: First day of 7-day period (defaults to today)
|
|
|
|
Returns:
|
|
Chart data with hotel stays and restaurant booking indicators
|
|
"""
|
|
import logging
|
|
logger = logging.getLogger(__name__)
|
|
|
|
if start_date is None:
|
|
start_date = date.today()
|
|
|
|
logger.info(f"ResidentsTableChart API called with start_date={start_date}")
|
|
|
|
end_date = start_date + timedelta(days=6) # 7-day period
|
|
date_range = {
|
|
"start_date": start_date.isoformat(),
|
|
"end_date": end_date.isoformat(),
|
|
"dates": [(start_date + timedelta(days=i)).isoformat() for i in range(7)]
|
|
}
|
|
|
|
# Fetch Newbook occupancy data for 7-day period
|
|
result = await db.execute(
|
|
select(NewbookDailyOccupancy).where(
|
|
and_(
|
|
NewbookDailyOccupancy.kitchen_id == current_user.kitchen_id,
|
|
NewbookDailyOccupancy.date >= start_date,
|
|
NewbookDailyOccupancy.date <= end_date,
|
|
NewbookDailyOccupancy.rooms_breakdown.isnot(None) # Only records with room breakdown
|
|
)
|
|
).order_by(NewbookDailyOccupancy.date)
|
|
)
|
|
occupancy_records = result.scalars().all()
|
|
logger.info(f"Found {len(occupancy_records)} occupancy records")
|
|
|
|
# Group by room number only (one row per room in Gantt chart)
|
|
# Key: room_number, Value: dict of bookings for that room
|
|
rooms_dict = {}
|
|
for record in occupancy_records:
|
|
# Parse JSONB array - each element is a room object for this date
|
|
rooms = record.rooms_breakdown or []
|
|
|
|
for room in rooms:
|
|
room_number = room.get("room_number")
|
|
booking_id = room.get("booking_id")
|
|
|
|
if room_number not in rooms_dict:
|
|
rooms_dict[room_number] = {}
|
|
|
|
# Track each booking within this room
|
|
if booking_id not in rooms_dict[room_number]:
|
|
rooms_dict[room_number][booking_id] = {
|
|
'booking_id': booking_id,
|
|
'bookings_group_id': room.get("bookings_group_id"),
|
|
'nights': [],
|
|
'is_dbb': room.get("is_dbb", False),
|
|
'is_package': room.get("is_package", False)
|
|
}
|
|
|
|
rooms_dict[room_number][booking_id]['nights'].append(record.date)
|
|
|
|
# Log rooms with multiple bookings to diagnose stacking issue
|
|
for room_number, bookings in rooms_dict.items():
|
|
if len(bookings) > 1:
|
|
logger.warning(f"Room {room_number} has {len(bookings)} different bookings:")
|
|
for booking_id, booking_data in bookings.items():
|
|
nights_str = ', '.join(sorted([n.isoformat() for n in booking_data['nights']]))
|
|
logger.warning(f" - Booking {booking_id}: nights={nights_str}")
|
|
|
|
# Convert to list - one entry per room with all its bookings
|
|
hotel_stays = []
|
|
for room_number, bookings in rooms_dict.items():
|
|
# Collect all bookings for this room
|
|
room_bookings = []
|
|
all_nights = []
|
|
|
|
for booking_data in bookings.values():
|
|
nights = sorted(booking_data['nights'])
|
|
if nights:
|
|
all_nights.extend(nights)
|
|
check_in = nights[0]
|
|
check_out = nights[-1] + timedelta(days=1)
|
|
|
|
room_bookings.append({
|
|
'booking_id': booking_data['booking_id'],
|
|
'bookings_group_id': booking_data.get('bookings_group_id'),
|
|
'check_in': check_in.isoformat(),
|
|
'check_out': check_out.isoformat(),
|
|
'nights': [n.isoformat() for n in nights],
|
|
'is_dbb': booking_data['is_dbb'],
|
|
'is_package': booking_data['is_package']
|
|
})
|
|
|
|
# Create one entry per room with all bookings
|
|
if room_bookings:
|
|
all_nights_sorted = sorted(set(all_nights))
|
|
hotel_stays.append({
|
|
'room_number': room_number,
|
|
'bookings': room_bookings, # Array of all bookings in this room
|
|
'all_nights': [n.isoformat() for n in all_nights_sorted] # All occupied nights for this room
|
|
})
|
|
|
|
logger.info(f"Built {len(hotel_stays)} room entries")
|
|
|
|
# Fetch Resos bookings for hotel guests in this period
|
|
result = await db.execute(
|
|
select(ResosBooking).where(
|
|
and_(
|
|
ResosBooking.kitchen_id == current_user.kitchen_id,
|
|
ResosBooking.booking_date >= start_date,
|
|
ResosBooking.booking_date <= end_date,
|
|
ResosBooking.is_hotel_guest == True,
|
|
ResosBooking.hotel_booking_number.isnot(None)
|
|
)
|
|
)
|
|
)
|
|
resos_bookings = result.scalars().all()
|
|
|
|
# Build lookup: booking_id -> {date -> resos_booking}
|
|
# Also handle group bookings via exclude_flag field (format: "#32990,#32991")
|
|
resos_lookup = {}
|
|
import re
|
|
|
|
for resos_booking in resos_bookings:
|
|
booking_id = resos_booking.hotel_booking_number
|
|
booking_date = resos_booking.booking_date.isoformat()
|
|
|
|
if booking_id not in resos_lookup:
|
|
resos_lookup[booking_id] = {}
|
|
|
|
# Direct match for the lead/primary booking
|
|
resos_lookup[booking_id][booking_date] = {
|
|
'has_booking': True,
|
|
'time': resos_booking.booking_time.strftime('%H:%M') if resos_booking.booking_time else None,
|
|
'people': resos_booking.people,
|
|
'table_name': resos_booking.table_name,
|
|
'opening_hour_name': resos_booking.opening_hour_name,
|
|
'is_group_match': False # Direct match, not a group member
|
|
}
|
|
|
|
# Parse exclude_flag for group bookings (format: "#32990,#32991")
|
|
if resos_booking.exclude_flag:
|
|
# Extract all booking numbers from the exclude_flag field
|
|
group_booking_ids = re.findall(r'#(\d+)', resos_booking.exclude_flag)
|
|
|
|
for group_id in group_booking_ids:
|
|
# Skip the lead booking itself (already added above)
|
|
if group_id == booking_id:
|
|
continue
|
|
|
|
# Add group member with is_group_match=True
|
|
if group_id not in resos_lookup:
|
|
resos_lookup[group_id] = {}
|
|
|
|
# Only add if not already present (don't overwrite direct matches)
|
|
if booking_date not in resos_lookup[group_id]:
|
|
resos_lookup[group_id][booking_date] = {
|
|
'has_booking': True,
|
|
'time': resos_booking.booking_time.strftime('%H:%M') if resos_booking.booking_time else None,
|
|
'people': resos_booking.people,
|
|
'table_name': resos_booking.table_name,
|
|
'opening_hour_name': resos_booking.opening_hour_name,
|
|
'is_group_match': True # Matched via group, not direct
|
|
}
|
|
|
|
logger.info(f"Built resos_lookup with {len(resos_lookup)} booking IDs (including group matches)")
|
|
|
|
# Combine rooms with restaurant bookings
|
|
room_rows = []
|
|
total_room_nights = 0
|
|
nights_with_restaurant = 0
|
|
|
|
try:
|
|
for room_data in hotel_stays:
|
|
booking_segments = []
|
|
|
|
# Process each booking within this room
|
|
for booking_data in room_data['bookings']:
|
|
# Build restaurant bookings dict for each night in the 7-day period
|
|
restaurant_bookings = {}
|
|
|
|
for date_str in date_range['dates']:
|
|
# Check if this date is within this specific booking's nights
|
|
if date_str in booking_data['nights']:
|
|
total_room_nights += 1
|
|
|
|
# Check if there's a restaurant booking for this date
|
|
resos_data = resos_lookup.get(booking_data['booking_id'], {}).get(date_str)
|
|
|
|
if resos_data:
|
|
restaurant_bookings[date_str] = resos_data
|
|
nights_with_restaurant += 1
|
|
else:
|
|
restaurant_bookings[date_str] = {'has_booking': False}
|
|
else:
|
|
# Not staying this night
|
|
restaurant_bookings[date_str] = {'has_booking': False}
|
|
|
|
# Create booking segment with restaurant data
|
|
booking_segments.append(BookingSegment(
|
|
booking_id=booking_data['booking_id'],
|
|
bookings_group_id=booking_data.get('bookings_group_id'),
|
|
check_in=booking_data['check_in'],
|
|
check_out=booking_data['check_out'],
|
|
nights=booking_data['nights'],
|
|
is_dbb=booking_data['is_dbb'],
|
|
is_package=booking_data['is_package'],
|
|
restaurant_bookings=restaurant_bookings
|
|
))
|
|
|
|
# Create room row with all its bookings
|
|
room_rows.append(RoomRow(
|
|
room_number=room_data['room_number'],
|
|
bookings=booking_segments
|
|
))
|
|
except Exception as e:
|
|
logger.error(f"Error building room_rows: {e}", exc_info=True)
|
|
raise
|
|
|
|
logger.info(f"Built {len(room_rows)} room rows")
|
|
|
|
# Sort rooms by room number (natural sort for numeric rooms)
|
|
def natural_sort_key(room: RoomRow):
|
|
"""Natural sort key for room numbers (handles both numeric and alphanumeric)"""
|
|
if not room.room_number:
|
|
return (float('inf'), '') # Put None/empty at end
|
|
|
|
# Extract numeric part for sorting (e.g., "102" -> 102, "A-12" -> 12)
|
|
import re
|
|
numbers = re.findall(r'\d+', room.room_number)
|
|
if numbers:
|
|
return (int(numbers[0]), room.room_number)
|
|
return (float('inf'), room.room_number)
|
|
|
|
room_rows.sort(key=natural_sort_key)
|
|
|
|
# Calculate summary
|
|
coverage_pct = (nights_with_restaurant / total_room_nights * 100) if total_room_nights > 0 else 0.0
|
|
|
|
# Count total bookings across all rooms
|
|
total_bookings = sum(len(room.bookings) for room in room_rows)
|
|
|
|
summary = {
|
|
'total_rooms': len(room_rows),
|
|
'total_bookings': total_bookings,
|
|
'total_room_nights': total_room_nights,
|
|
'nights_with_restaurant': nights_with_restaurant,
|
|
'coverage_percentage': round(coverage_pct, 1)
|
|
}
|
|
|
|
# Calculate aggregated metrics for different time periods
|
|
def get_week_start(d: date) -> date:
|
|
"""Get Monday of the week containing date d"""
|
|
return d - timedelta(days=d.weekday())
|
|
|
|
async def calculate_period_metrics(period_start: date, period_end: date, is_forecast: Optional[bool] = None) -> dict:
|
|
"""Calculate metrics for a specific date range"""
|
|
query = select(NewbookDailyOccupancy).where(
|
|
and_(
|
|
NewbookDailyOccupancy.kitchen_id == current_user.kitchen_id,
|
|
NewbookDailyOccupancy.date >= period_start,
|
|
NewbookDailyOccupancy.date <= period_end,
|
|
NewbookDailyOccupancy.rooms_breakdown.isnot(None)
|
|
)
|
|
)
|
|
|
|
# Filter by forecast status if specified
|
|
if is_forecast is not None:
|
|
query = query.where(NewbookDailyOccupancy.is_forecast == is_forecast)
|
|
|
|
result = await db.execute(query.order_by(NewbookDailyOccupancy.date))
|
|
records = result.scalars().all()
|
|
|
|
# Count metrics
|
|
total_room_nights_period = 0
|
|
unique_bookings = set()
|
|
nights_with_rest = 0
|
|
|
|
for record in records:
|
|
rooms = record.rooms_breakdown or []
|
|
for room in rooms:
|
|
booking_id = room.get("booking_id")
|
|
if booking_id:
|
|
unique_bookings.add(booking_id)
|
|
total_room_nights_period += 1
|
|
|
|
# Check if has restaurant booking for this date
|
|
date_str = record.date.isoformat()
|
|
resos_data = resos_lookup.get(booking_id, {}).get(date_str)
|
|
if resos_data:
|
|
nights_with_rest += 1
|
|
|
|
coverage_pct_period = (nights_with_rest / total_room_nights_period * 100) if total_room_nights_period > 0 else 0.0
|
|
|
|
# Calculate average occupancy
|
|
total_available = 0
|
|
total_occupied = 0
|
|
for record in records:
|
|
if record.total_rooms and record.occupied_rooms:
|
|
total_available += record.total_rooms
|
|
total_occupied += record.occupied_rooms
|
|
|
|
avg_occupancy = (total_occupied / total_available * 100) if total_available > 0 else 0.0
|
|
|
|
return {
|
|
'total_bookings': len(unique_bookings),
|
|
'total_room_nights': total_room_nights_period,
|
|
'nights_with_restaurant': nights_with_rest,
|
|
'coverage_percentage': round(coverage_pct_period, 1),
|
|
'avg_occupancy_percentage': round(avg_occupancy, 1)
|
|
}
|
|
|
|
today = date.today()
|
|
|
|
# This week (Monday to Sunday)
|
|
this_week_start = get_week_start(today)
|
|
this_week_end = this_week_start + timedelta(days=6)
|
|
|
|
# Last week (previous Monday to Sunday)
|
|
last_week_start = this_week_start - timedelta(days=7)
|
|
last_week_end = last_week_start + timedelta(days=6)
|
|
|
|
# Last 30 days rolling (from yesterday)
|
|
yesterday = today - timedelta(days=1)
|
|
rolling_30_start = yesterday - timedelta(days=29)
|
|
rolling_30_end = yesterday
|
|
|
|
# Calculate metrics for each period - always return metrics with default values
|
|
default_metrics = {
|
|
'total_bookings': 0,
|
|
'total_room_nights': 0,
|
|
'nights_with_restaurant': 0,
|
|
'coverage_percentage': 0.0,
|
|
'avg_occupancy_percentage': 0.0
|
|
}
|
|
|
|
try:
|
|
metrics = {
|
|
'this_week_actual': await calculate_period_metrics(this_week_start, this_week_end, is_forecast=False),
|
|
'this_week_forecast': await calculate_period_metrics(this_week_start, this_week_end, is_forecast=True),
|
|
'last_week_actual': await calculate_period_metrics(last_week_start, last_week_end, is_forecast=False),
|
|
'last_30_days_rolling': await calculate_period_metrics(rolling_30_start, rolling_30_end, is_forecast=False),
|
|
}
|
|
logger.info(f"Calculated metrics: {metrics}")
|
|
except Exception as e:
|
|
logger.error(f"Error calculating metrics: {e}", exc_info=True)
|
|
# Return default metrics structure instead of None
|
|
metrics = {
|
|
'this_week_actual': default_metrics.copy(),
|
|
'this_week_forecast': default_metrics.copy(),
|
|
'last_week_actual': default_metrics.copy(),
|
|
'last_30_days_rolling': default_metrics.copy(),
|
|
}
|
|
|
|
return ResidentsTableChartResponse(
|
|
date_range=date_range,
|
|
rooms=room_rows,
|
|
summary=summary,
|
|
metrics=metrics
|
|
)
|