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:
jtricerolph 2026-07-12 12:15:39 +00:00
commit 8d688b459d
10003 changed files with 1928395 additions and 0 deletions

View 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
)