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>
This commit is contained in:
commit
8d688b459d
10003 changed files with 1928395 additions and 0 deletions
422
backend/api/residents_table_chart.py
Normal file
422
backend/api/residents_table_chart.py
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
"""
|
||||
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
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue