kitchen/backend/models/newbook.py
jtricerolph 8d688b459d Initial kitchen scaffold — Phase 1 kitchen port (build-verified 2026-07-11)
FastAPI backend (Python 3.11, MSSQL ODBC for SambaPOS, Azure DI OCR),
kitchen_db on central PG. React/TS/Vite frontend with navy sidebar layout.

Backend: auth.py (APP_SLUG=kitchen, SimpleNamespace — archive routes use
.kitchen_id/.is_admin without modification), main.py (51 migrations, scheduler,
internal router for KDS bookings feed), api/internal.py, full archive API
(31 routers: invoices, recipes, menus, sambapos, resos, newbook, disputes,
purchase_orders, etc.), models, migrations, OCR pipeline.
kitchen_id pinned to 1 (B1 — single hotel).

Frontend: AuthGate (app=kitchen, token shim for archive compat — B5b pending),
Layout (navy sidebar, 6 sections, Lucide icons, teal --app-primary),
App.tsx (Outlet pattern, UploadApp outside Layout), index.css (full :root block).
strict: false — archive components have type issues; build clean.

Note: 45 archive components call fetch('/api/...') without /kitchen/ prefix
(B5b). Runtime 404s; deferred until after initial testing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 12:15:39 +00:00

171 lines
8.4 KiB
Python

from datetime import datetime, date
from decimal import Decimal
from sqlalchemy import String, DateTime, Date, ForeignKey, Numeric, Boolean, Text, Integer, UniqueConstraint
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from database import Base
class NewbookGLAccount(Base):
"""GL Accounts fetched from Newbook API"""
__tablename__ = "newbook_gl_accounts"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False)
# Newbook GL Account identifiers
gl_account_id: Mapped[str] = mapped_column(String(50), nullable=False) # Newbook's internal ID
gl_code: Mapped[str | None] = mapped_column(String(50), nullable=True) # Account code
gl_name: Mapped[str] = mapped_column(String(255), nullable=False) # Account name/description
gl_type: Mapped[str | None] = mapped_column(String(100), nullable=True) # Account type from Newbook
# Group info for categorization
gl_group_id: Mapped[str | None] = mapped_column(String(50), nullable=True) # Parent group ID
gl_group_name: Mapped[str | None] = mapped_column(String(255), nullable=True) # Parent group name (e.g., "FNB - Food & Beverage")
# User selection
is_tracked: Mapped[bool] = mapped_column(Boolean, default=False) # User selected for revenue tracking
display_order: Mapped[int] = mapped_column(Integer, default=0) # Order in reports
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
kitchen: Mapped["Kitchen"] = relationship("Kitchen", back_populates="newbook_gl_accounts")
daily_revenue: Mapped[list["NewbookDailyRevenue"]] = relationship(
"NewbookDailyRevenue", back_populates="gl_account", cascade="all, delete-orphan"
)
__table_args__ = (
UniqueConstraint('kitchen_id', 'gl_account_id', name='uq_newbook_gl_account'),
)
class NewbookDailyRevenue(Base):
"""Daily revenue amounts per GL account from Newbook"""
__tablename__ = "newbook_daily_revenue"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False)
gl_account_id: Mapped[int] = mapped_column(ForeignKey("newbook_gl_accounts.id"), nullable=False)
date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
amount_net: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False) # Net/exc tax amount
amount_gross: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True) # Gross/inc tax (if available)
# Sync metadata
fetched_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
kitchen: Mapped["Kitchen"] = relationship("Kitchen", back_populates="newbook_daily_revenue")
gl_account: Mapped["NewbookGLAccount"] = relationship("NewbookGLAccount", back_populates="daily_revenue")
__table_args__ = (
UniqueConstraint('kitchen_id', 'gl_account_id', 'date', name='uq_newbook_revenue_per_day'),
)
class NewbookDailyOccupancy(Base):
"""Daily occupancy and meal allocation data from Newbook"""
__tablename__ = "newbook_daily_occupancy"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False)
date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
# Occupancy metrics
total_rooms: Mapped[int | None] = mapped_column(Integer, nullable=True)
occupied_rooms: Mapped[int | None] = mapped_column(Integer, nullable=True)
occupancy_percentage: Mapped[Decimal | None] = mapped_column(Numeric(5, 2), nullable=True)
total_guests: Mapped[int | None] = mapped_column(Integer, nullable=True) # People count
# Meal allocation data (from booking inventory items)
breakfast_allocation_qty: Mapped[int | None] = mapped_column(Integer, nullable=True)
breakfast_allocation_netvalue: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
dinner_allocation_qty: Mapped[int | None] = mapped_column(Integer, nullable=True)
dinner_allocation_netvalue: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
# Arrival tracking (for cross-referencing with Resos table bookings)
arrival_count: Mapped[int | None] = mapped_column(Integer, nullable=True) # Number of bookings arriving this day
arrival_booking_ids: Mapped[list | None] = mapped_column(JSONB, nullable=True) # List of booking IDs arriving
arrival_booking_details: Mapped[list | None] = mapped_column(JSONB, nullable=True) # Full arrival details with booking refs
# Room-level breakdown (JSONB array) - array of {room_number, booking_id, is_dbb, is_package} for each occupied room
rooms_breakdown: Mapped[list | None] = mapped_column(JSONB, nullable=True)
# Room-level details for residents table chart (DEPRECATED - use rooms_breakdown instead)
room_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
booking_id: Mapped[str | None] = mapped_column(String(100), nullable=True) # Newbook booking reference
guest_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
is_dbb: Mapped[bool] = mapped_column(Boolean, default=False) # Dinner Bed & Breakfast
is_package: Mapped[bool] = mapped_column(Boolean, default=False) # Package deal
# Forecast indicator
is_forecast: Mapped[bool] = mapped_column(Boolean, default=False) # True for future booked dates
fetched_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
kitchen: Mapped["Kitchen"] = relationship("Kitchen", back_populates="newbook_daily_occupancy")
__table_args__ = (
UniqueConstraint('kitchen_id', 'date', name='uq_newbook_occupancy_per_day'),
)
class NewbookSyncLog(Base):
"""Log of Newbook sync operations"""
__tablename__ = "newbook_sync_log"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False)
sync_type: Mapped[str] = mapped_column(String(50), nullable=False) # 'gl_accounts', 'revenue', 'occupancy'
started_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
status: Mapped[str] = mapped_column(String(20), default="running") # running, success, failed
records_fetched: Mapped[int] = mapped_column(Integer, default=0)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
# Date range for the sync (for revenue/occupancy)
date_from: Mapped[date | None] = mapped_column(Date, nullable=True)
date_to: Mapped[date | None] = mapped_column(Date, nullable=True)
# Relationships
kitchen: Mapped["Kitchen"] = relationship("Kitchen", back_populates="newbook_sync_logs")
class NewbookRoomCategory(Base):
"""Room types/categories aggregated from Newbook API (site_list)"""
__tablename__ = "newbook_room_categories"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
kitchen_id: Mapped[int] = mapped_column(ForeignKey("kitchens.id"), nullable=False)
# Room type identifiers (aggregated from site_list)
site_id: Mapped[str] = mapped_column(String(50), nullable=False) # Type name as ID
site_name: Mapped[str] = mapped_column(String(255), nullable=False) # Type name (e.g., "Standard Room")
site_type: Mapped[str | None] = mapped_column(String(100), nullable=True) # Same as site_name
# Room count of this type
room_count: Mapped[int] = mapped_column(Integer, default=0) # Number of rooms of this type
# User selection - included in occupancy/guest calculations
is_included: Mapped[bool] = mapped_column(Boolean, default=True) # Include in occupancy calcs
display_order: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
kitchen: Mapped["Kitchen"] = relationship("Kitchen", back_populates="newbook_room_categories")
__table_args__ = (
UniqueConstraint('kitchen_id', 'site_id', name='uq_newbook_room_category'),
)
# Forward reference
from .user import Kitchen