Files
livekit--agents/examples/hotel_receptionist/hotel_db.py
T
2026-07-13 13:39:38 +08:00

2086 lines
73 KiB
Python

from __future__ import annotations
import json
import logging
import os
from collections.abc import Awaitable, Callable, Sequence
from dataclasses import dataclass, fields
from datetime import date, time, timedelta
from itertools import groupby
from typing import Any, Literal, get_args
import apsw
from livekit.agents.utils import shortuuid
logger = logging.getLogger("hotel-receptionist.db")
@dataclass(frozen=True)
class Pricing:
breakfast_per_night: int = 2500
valet_per_night: int = 3500
late_checkout: int = 4000
pet_fee: int = 5000
smoking_cleaning_fee: int = 25000
tax_rate_pct: int = 12
cancellation_window_hours: int = 48
cancellation_forfeit_nights: int = 1
minibar_auto_refund_threshold: int = 2000
PRICING = Pricing()
RoomExtra = Literal["breakfast", "valet", "late_checkout", "pets"]
ALLOWED_EXTRAS: frozenset[str] = frozenset(get_args(RoomExtra))
def extras_total(extras: Sequence[str], nights: int) -> int:
total = 0
if "breakfast" in extras:
total += PRICING.breakfast_per_night * nights
if "valet" in extras:
total += PRICING.valet_per_night * nights
if "late_checkout" in extras:
total += PRICING.late_checkout
if "pets" in extras:
total += PRICING.pet_fee
return total
def apply_tax(amount_cents: int) -> int:
return (amount_cents * PRICING.tax_rate_pct) // 100
def format_usd(cents: int) -> str:
sign = "-" if cents < 0 else ""
cents = abs(cents)
return f"{sign}${cents // 100}.{cents % 100:02d}"
def speak_usd(cents: int) -> str:
dollars, change = divmod(abs(cents), 100)
if change == 0:
return f"{dollars} dollars"
return f"{dollars} dollars and {change} cents"
def speak_time(t: time) -> str:
"""A clock time as natural speech, e.g. '7 PM', '6:30 PM'."""
hour = t.hour % 12 or 12
suffix = "PM" if t.hour >= 12 else "AM"
return f"{hour} {suffix}" if t.minute == 0 else f"{hour}:{t.minute:02d} {suffix}"
DisputeCategory = Literal[
"minibar",
"room_service_restaurant",
"damage_cleaning",
"late_checkout_fee",
"cancellation_fee",
"no_show",
"double_charge_billing_error",
"other",
]
DisputeAction = Literal[
"auto_refund_if_under_threshold",
"verify_explain_then_offer_credit",
"explain_no_refund",
"explain_policy_offer_goodwill",
"correct_immediately_or_open_ticket",
]
@dataclass(frozen=True)
class DisputePolicy:
action: DisputeAction
escalation: Literal["manager", "accounting", "none"]
explanation: str
DISPUTE_POLICIES: dict[DisputeCategory, DisputePolicy] = {
"minibar": DisputePolicy(
action="auto_refund_if_under_threshold",
escalation="manager",
explanation=(
"For small minibar charges I can waive them right away. "
"If it's a larger amount I'll verify against the housekeeping note first."
),
),
"room_service_restaurant": DisputePolicy(
action="verify_explain_then_offer_credit",
escalation="manager",
explanation=(
"I'll pull up the order. If something looks off I can apply a credit, "
"or escalate to the food and beverage manager."
),
),
"damage_cleaning": DisputePolicy(
action="explain_no_refund",
escalation="manager",
explanation=(
"Damage and cleaning fees are assessed by housekeeping. I can't waive them, "
"but I can have the manager review and follow up by email."
),
),
"late_checkout_fee": DisputePolicy(
action="explain_policy_offer_goodwill",
escalation="manager",
explanation=(
f"Late checkout past noon is {format_usd(PRICING.late_checkout)}. "
"If this is your first time I can waive it as a one-time courtesy."
),
),
"cancellation_fee": DisputePolicy(
action="explain_policy_offer_goodwill",
escalation="manager",
explanation=(
f"Our policy is free cancellation up to {PRICING.cancellation_window_hours} "
f"hours before check-in. Inside that window it's one night. "
"If you're a returning guest I can waive it once."
),
),
"no_show": DisputePolicy(
action="explain_no_refund",
escalation="manager",
explanation=(
"This room was guaranteed to your card and there's no cancellation on record, "
"so it was held for you and charged as a no-show under the guarantee policy. "
"I can't reverse a guaranteed charge myself, but I can have the manager review "
"it and follow up by email."
),
),
"double_charge_billing_error": DisputePolicy(
action="correct_immediately_or_open_ticket",
escalation="accounting",
explanation=(
"If I can see the duplicate I'll refund it right now. "
"Otherwise accounting will open a ticket and email you within two business days."
),
),
"other": DisputePolicy(
action="verify_explain_then_offer_credit",
escalation="manager",
explanation="Let me look into that and offer a fair resolution.",
),
}
# Set HOTEL_TODAY=YYYY-MM-DD before import for deterministic sim runs.
TODAY: date = (
date.fromisoformat(os.environ["HOTEL_TODAY"]) if os.environ.get("HOTEL_TODAY") else date.today()
)
MAX_PARTY_SIZE = 6
RoomType = Literal["king", "queen_2beds", "double_queen", "suite", "penthouse"]
@dataclass
class RoomTypeAvailability:
type: RoomType
nightly_rate: int
# every distinct view available for this type on the dates,
# e.g. ["city", "garden"]
views: list[str]
@dataclass
class RoomBooking:
id: int
code: str
room_id: str
room_type: RoomType
smoking: bool
nightly_rate: int
first_name: str
last_name: str
email: str
phone: str
check_in: date
check_out: date
guests: int
extras: list[RoomExtra]
total: int
card_last4: str
status: Literal["confirmed", "cancelled"]
late_arrival_note: str | None
@property
def nights(self) -> int:
return (self.check_out - self.check_in).days
@dataclass
class RestaurantTable:
id: int
label: str
capacity: int
location: str
description: str = ""
@dataclass
class RestaurantReservation:
id: int
code: str
table_id: int
first_name: str
last_name: str
phone: str
party_size: int
date: date
time: time
notes: str | None
status: Literal["confirmed", "cancelled"]
@dataclass
class TimeSlot:
time: time
available_table_ids: list[int]
@dataclass
class LineItem:
label: str
amount_cents: int
@dataclass
class Invoice:
id: int
booking_code: str
line_items: list[LineItem]
subtotal: int
taxes: int
total: int
paid: bool
FollowupKind = Literal[
"housekeeping", # in-house guest needs something brought or fixed (towels, amenities, maintenance)
"sales_lead", # group bookings, events, weddings, corporate rates
"identity_change", # caller wants name/email/phone/card on file updated
"callback", # caller asked to be called back later
"verification_help", # verification failed; route to a human
"early_checkout", # in-house guest wants to leave early; front desk handles
"abandoned_booking", # caller dropped mid-booking; a human can call back
"lost_and_found", # guest reports an item left behind; route to housekeeping/lost-and-found
"other",
]
EmailKind = Literal[
"booking_confirmation", # the room booking details + code, re-sent to the address on file
"folio", # itemized bill / invoice, re-sent to the address on file
]
# Emergency classification (manual P1§13/P3§8): health -> ambulance, fire -> fire
# brigade, safety/theft/assault/threat -> police; every kind also alerts the duty
# manager and sends hotel staff to the room.
EmergencyKind = Literal["medical", "fire", "security"]
# Hotel departments a caller can be transferred to. NOT a guest room - the
# operator never connects a caller to a guest (see guest-privacy policy).
TransferDestination = Literal[
"restaurant",
"duty_manager",
"housekeeping",
]
@dataclass
class Followup:
id: int
code: str
kind: FollowupKind
caller_name: str
caller_phone: str
summary: str
status: Literal["open", "resolved"]
# the predominant room-share arrangement for a group block
GroupShareType = Literal["twin", "double", "single", "mixed"]
@dataclass(frozen=True)
class Tour:
name: str
pickup_time: time
pickup_location: str
price_per_person: int | None # cents; None for flat-priced tours
flat_price: int | None
max_party: int
description: str
# Bookable through the concierge desk. policies/tours.md describes the same
# catalog to the agent - keep the two in sync.
TOURS: dict[str, Tour] = {
"half_day_city": Tour(
name="Half-day city highlights",
pickup_time=time(9, 0),
pickup_location="hotel lobby",
price_per_person=6500,
flat_price=None,
max_party=12,
description="small group, English-speaking guide, about 4.5 hours, entry fees included",
),
"full_day_city": Tour(
name="Full-day city and bay",
pickup_time=time(8, 30),
pickup_location="hotel lobby",
price_per_person=11000,
flat_price=None,
max_party=12,
description="small group, English-speaking guide, lunch and entry fees included, back about 5 PM",
),
"private_city": Tour(
name="Private half-day tour",
pickup_time=time(10, 0),
pickup_location="hotel lobby",
price_per_person=None,
flat_price=29000,
max_party=4,
description="private car and English-speaking guide, flexible start, up to 4 guests",
),
}
@dataclass(frozen=True)
class SpaService:
name: str
price: int # cents, per guest
duration_min: int
max_party: int
description: str
# Bookable through the spa / health club desk. policies/spa.md describes the
# same catalog to the agent - keep the two in sync.
SPA_SERVICES: dict[str, SpaService] = {
"deep_tissue_massage": SpaService(
name="Deep-tissue massage",
price=14000,
duration_min=60,
max_party=2,
description="60-minute deep-tissue massage with a licensed therapist",
),
"signature_facial": SpaService(
name="Signature facial",
price=12000,
duration_min=50,
max_party=2,
description="50-minute signature facial, all skin types",
),
"personal_training": SpaService(
name="Personal training session",
price=8000,
duration_min=45,
max_party=1,
description="45-minute one-on-one session in the health club with a trainer",
),
"group_yoga": SpaService(
name="Group yoga class",
price=4000,
duration_min=60,
max_party=8,
description="60-minute group yoga class in the studio",
),
}
@dataclass(frozen=True)
class BusinessCenterService:
name: str
price_per_hour: int | None # cents; None for flat-priced services
flat_price: int | None
max_hours: int
description: str
# Bookable through the business centre. policies/business_center.md describes the
# same catalog to the agent - keep the two in sync.
BUSINESS_CENTER_SERVICES: dict[str, BusinessCenterService] = {
"meeting_room": BusinessCenterService(
name="Meeting room",
price_per_hour=4000,
flat_price=None,
max_hours=8,
description="seats up to 8, screen and whiteboard, booked by the hour",
),
"secretarial": BusinessCenterService(
name="Secretarial service",
price_per_hour=3500,
flat_price=None,
max_hours=4,
description="typing, dictation, and document prep, booked by the hour",
),
"printing": BusinessCenterService(
name="Printing and binding",
price_per_hour=None,
flat_price=2500,
max_hours=1,
description="flat-rate print, copy, and bind job, ready same day",
),
}
@dataclass(frozen=True)
class FloralArrangement:
name: str
price: int # cents, flat per arrangement
# Ordered through the concierge desk via the hotel florist. policies/florist.md
# describes the same catalog to the agent - keep the two in sync.
FLORIST_ARRANGEMENTS: dict[str, FloralArrangement] = {
"bouquet": FloralArrangement(name="Seasonal hand-tied bouquet", price=6500),
"roses": FloralArrangement(name="Dozen long-stem roses", price=9500),
"centerpiece": FloralArrangement(name="Table centerpiece arrangement", price=14000),
}
# The partner property used when a confirmed guest has to be walked.
WALK_PARTNER_HOTEL = "the Harbor House"
@dataclass
class ConflictResolution:
"""Result of resolve_room_conflict: exactly one of moved_to / walk is set."""
moved_to: str | None = None # new room id, stay unchanged
moved_to_type: str = ""
moved_to_view: str = ""
upgraded: bool = False
walk_partner: str | None = None
walk_return_date: date | None = None
class Unavailable(Exception):
pass
class NotFound(Exception):
pass
def invoice_line_items(
*, nights: int, room_subtotal: int, extras: Sequence[str], tax: int
) -> list[LineItem]:
"""The itemized invoice for a stay. Shared by book_room and the seed script
so the breakdown can't drift between them."""
items = [LineItem(f"Room ({nights} nights)", room_subtotal)]
if "breakfast" in extras:
items.append(LineItem(f"Breakfast ({nights} nights)", PRICING.breakfast_per_night * nights))
if "valet" in extras:
items.append(LineItem(f"Valet ({nights} nights)", PRICING.valet_per_night * nights))
if "late_checkout" in extras:
items.append(LineItem("Late checkout", PRICING.late_checkout))
if "pets" in extras:
items.append(LineItem("Pet fee", PRICING.pet_fee))
items.append(LineItem(f"Tax ({PRICING.tax_rate_pct}%)", tax))
return items
def compute_invoice(
*, nightly_rate: int, nights: int, extras: Sequence[str]
) -> tuple[int, int, int, list[LineItem]]:
"""Single source of truth for booking math: returns (subtotal, taxes, total, line_items).
Used by book_room, update_booking, and the seed script so no caller can drift."""
room_subtotal = nightly_rate * nights
subtotal = room_subtotal + extras_total(extras, nights)
taxes = apply_tax(subtotal)
items = invoice_line_items(nights=nights, room_subtotal=room_subtotal, extras=extras, tax=taxes)
return subtotal, taxes, subtotal + taxes, items
OnChange = Callable[[], Awaitable[None]]
class HotelDB:
def __init__(self, conn: apsw.Connection, *, on_change: OnChange | None = None) -> None:
self._conn: apsw.Connection = conn
self.on_change = on_change
@classmethod
def empty(cls, *, on_change: OnChange | None = None) -> HotelDB:
conn = apsw.Connection(":memory:")
_install_schema(conn)
return cls(conn, on_change=on_change)
@classmethod
def from_bytes(cls, seed_bytes: bytes, *, on_change: OnChange | None = None) -> HotelDB:
conn = apsw.Connection(":memory:")
conn.deserialize("main", seed_bytes)
_install_schema(conn)
return cls(conn, on_change=on_change)
@classmethod
def open_path(cls, db_path: str, *, on_change: OnChange | None = None) -> HotelDB:
conn = apsw.Connection(db_path)
_install_schema(conn)
return cls(conn, on_change=on_change)
@property
def connection(self) -> apsw.Connection:
return self._conn
def serialize(self) -> bytes:
return bytes(self._conn.serialize("main"))
def close(self) -> None:
self._conn.close()
async def aclose(self) -> None:
self.close()
async def list_room_types_available(
self,
*,
check_in: date,
check_out: date,
guests: int,
smoking: bool | None = None,
exclude_booking_code: str | None = None,
) -> list[RoomTypeAvailability]:
rows = self.connection.execute(
_SQL_AVAILABILITY,
{
"guests": guests,
"smoking": int(smoking) if smoking is not None else None,
"check_in": check_in.isoformat(),
"check_out": check_out.isoformat(),
"exclude": exclude_booking_code,
},
)
return [
RoomTypeAvailability(t, rate, views=sorted((concat or "").split(",")))
for t, rate, concat in rows
]
async def list_restaurant_availability(
self, *, on_date: date, party_size: int
) -> list[TimeSlot]:
rows = self.connection.execute(
_SQL_DINING_AVAILABILITY, {"party_size": party_size, "date": on_date.isoformat()}
)
return [
TimeSlot(time.fromisoformat(slot), [tid for _, tid in group if tid is not None])
for slot, group in groupby(rows, key=lambda row: row[0])
]
async def find_booking(
self,
*,
last_name: str,
confirmation_code: str | None = None,
email: str | None = None,
card_last4: str | None = None,
) -> RoomBooking | None:
return _row_to_booking(
self.connection.execute(
_SQL_FIND_BOOKING,
{
"last_name": last_name,
"code": confirmation_code.upper() if confirmation_code else None,
"email": email,
"card_last4": card_last4,
},
).fetchone()
)
async def find_restaurant_reservation(
self, *, last_name: str, confirmation_code: str | None = None, on_date: date | None = None
) -> RestaurantReservation | None:
return _row_to_reservation(
self.connection.execute(
_SQL_FIND_RESERVATION,
{
"last_name": last_name,
"code": confirmation_code.upper() if confirmation_code else None,
"date": on_date.isoformat() if on_date else None,
},
).fetchone()
)
async def get_invoice(self, booking_code: str) -> Invoice:
row = self.connection.execute(_SQL_GET_INVOICE, {"code": booking_code}).fetchone()
if not row:
raise NotFound(f"no invoice for {booking_code}")
invoice_id, line_items_json, subtotal, taxes, total, paid = row
return Invoice(
id=invoice_id,
booking_code=booking_code,
line_items=[LineItem(**li) for li in json.loads(line_items_json)],
subtotal=subtotal,
taxes=taxes,
total=total,
paid=bool(paid),
)
async def book_room(
self,
*,
room_type: RoomType,
smoking: bool,
guests: int,
check_in: date,
check_out: date,
first_name: str,
last_name: str,
email: str,
phone: str,
card_last4: str,
extras: list[RoomExtra],
view: str | None = None,
) -> RoomBooking:
clean_extras = sorted(e for e in extras if e in ALLOWED_EXTRAS)
code = shortuuid("HTL-")
conn = self.connection
with conn:
row = conn.execute(
_SQL_FREE_ROOM,
{
"room_type": room_type,
"smoking": int(smoking),
"guests": guests,
"check_in": check_in.isoformat(),
"check_out": check_out.isoformat(),
"exclude": None,
"view": view,
"prefer": None,
},
).fetchone()
if not row:
what = f"{view} {room_type}" if view else room_type
raise Unavailable(f"sold out: {what}")
room_id, nightly_rate = row
nights = (check_out - check_in).days
subtotal, taxes, total, items = compute_invoice(
nightly_rate=nightly_rate, nights=nights, extras=clean_extras
)
booking_id = _insert(
conn,
"hotel_bookings",
{
"code": code,
"room_id": room_id,
"first_name": first_name,
"last_name": last_name,
# spoken emails are case-free; transcription capitalization is noise
"email": email.strip().lower(),
# digits only: a spoken number transcribes with unpredictable punctuation
"phone": "".join(c for c in phone if c.isdigit()),
"check_in": check_in.isoformat(),
"check_out": check_out.isoformat(),
"guests": guests,
"extras": ",".join(clean_extras),
"total": total,
"card_last4": card_last4,
},
)
_insert(
conn,
"hotel_invoices",
{
"booking_code": code,
"line_items": json.dumps([li.__dict__ for li in items]),
"subtotal": subtotal,
"taxes": taxes,
"total": total,
},
)
if self.on_change:
await self.on_change()
return RoomBooking(
id=booking_id,
code=code,
room_id=room_id,
room_type=room_type,
smoking=smoking,
nightly_rate=nightly_rate,
first_name=first_name,
last_name=last_name,
email=email,
phone=phone,
check_in=check_in,
check_out=check_out,
guests=guests,
extras=clean_extras,
total=total,
card_last4=card_last4,
status="confirmed",
late_arrival_note=None,
)
async def update_booking(
self,
*,
booking_code: str,
room_type: RoomType,
smoking: bool,
guests: int,
check_in: date,
check_out: date,
extras: list[RoomExtra],
view: str | None = None,
) -> RoomBooking:
# Re-pick a free room of the new (type, smoking) for the new dates,
# ignoring the booking being modified itself (so same-room "extend
# by one night" doesn't conflict with itself). The room the guest
# already has wins when it still fits - a date change must never
# quietly move someone out of their garden view. A requested `view`
# filters to rooms with that view, which is how the guest gets moved
# to a different room (e.g. a city-view room to a garden-view one).
clean_extras = sorted(e for e in extras if e in ALLOWED_EXTRAS)
conn = self.connection
with conn:
current = conn.execute(
"SELECT room_id FROM hotel_bookings WHERE code = ? AND status = 'confirmed'",
(booking_code,),
).fetchone()
row = conn.execute(
_SQL_FREE_ROOM,
{
"room_type": room_type,
"smoking": int(smoking),
"guests": guests,
"check_in": check_in.isoformat(),
"check_out": check_out.isoformat(),
"exclude": booking_code,
"view": view,
"prefer": current[0] if current else None,
},
).fetchone()
if not row:
what = f"{view} {room_type}" if view else room_type
raise Unavailable(f"sold out: {what}")
room_id, nightly_rate = row
nights = (check_out - check_in).days
subtotal, taxes, total, items = compute_invoice(
nightly_rate=nightly_rate, nights=nights, extras=clean_extras
)
changed = _update(
conn,
"hotel_bookings",
{
"room_id": room_id,
"check_in": check_in.isoformat(),
"check_out": check_out.isoformat(),
"guests": guests,
"extras": ",".join(clean_extras),
"total": total,
},
{"code": booking_code, "status": "confirmed"},
)
if changed == 0:
raise NotFound(f"booking not found: {booking_code}")
_update(
conn,
"hotel_invoices",
{
"line_items": json.dumps([li.__dict__ for li in items]),
"subtotal": subtotal,
"taxes": taxes,
"total": total,
},
{"booking_code": booking_code},
)
if self.on_change:
await self.on_change()
updated = _row_to_booking(
conn.execute(_SQL_BOOKING_BY_CODE, {"code": booking_code}).fetchone()
)
if updated is None:
raise NotFound(f"booking vanished mid-update: {booking_code}")
return updated
async def cancel_room_booking(self, booking_code: str) -> None:
conn = self.connection
changed = _update(
conn,
"hotel_bookings",
{"status": "cancelled"},
{"code": booking_code, "status": "confirmed"},
)
if changed == 0:
raise NotFound(f"booking not found: {booking_code}")
if self.on_change:
await self.on_change()
async def lookup_guest_history(self, *, last_name: str) -> str | None:
"""Return a returning guest's remembered preferences from past stays, or None
if there's no history on file for that name."""
row = self.connection.execute(
"SELECT preferences FROM guest_history WHERE LOWER(last_name) = LOWER(?)",
(last_name,),
).fetchone()
return row[0] if row else None
async def set_do_not_disturb(self, *, room: str) -> str:
"""Record a Do-Not-Disturb hold on a room and return a reference. The switchboard
holds the room's calls and messages until it's lifted; emergencies override it."""
# Normalize to the canonical room id (and reject a mis-heard room) so storage
# matches every other room-referencing table regardless of how it was spoken.
room_id = self._require_room(room)
code = shortuuid("DND-")
with self.connection as conn:
_insert(conn, "do_not_disturb", {"code": code, "room_id": room_id})
if self.on_change:
await self.on_change()
return code
async def add_to_waitlist(
self,
*,
first_name: str,
last_name: str,
phone: str,
check_in: date,
check_out: date,
guests: int,
) -> str:
"""Record a waitlist entry for dates the hotel is sold out on, and return a
reference. No room is held - the desk calls back only if something frees up."""
code = shortuuid("WL-")
with self.connection as conn:
_insert(
conn,
"waitlist",
{
"code": code,
"first_name": first_name,
"last_name": last_name,
"phone": "".join(c for c in phone if c.isdigit()) or phone,
"check_in": check_in.isoformat(),
"check_out": check_out.isoformat(),
"guests": guests,
},
)
if self.on_change:
await self.on_change()
return code
async def reinstate_booking(self, booking_code: str) -> None:
"""Reactivate a previously cancelled booking, but only if its original room is
still free for its dates. Raises NotFound if the code is unknown, Unavailable if
the room has since been taken. A no-op if the booking is already confirmed."""
conn = self.connection
row = conn.execute(
"SELECT room_id, check_in, check_out, status FROM hotel_bookings WHERE code = ?",
(booking_code,),
).fetchone()
if row is None:
raise NotFound(f"booking not found: {booking_code}")
room_id, check_in, check_out, status = row
if status == "confirmed":
return
clash = conn.execute(
"SELECT 1 FROM hotel_bookings WHERE room_id = ? AND status = 'confirmed' "
"AND code != ? AND NOT (check_out <= ? OR check_in >= ?) LIMIT 1",
(room_id, booking_code, check_in, check_out),
).fetchone()
if clash:
raise Unavailable("that room is no longer free for those dates")
with conn:
_update(conn, "hotel_bookings", {"status": "confirmed"}, {"code": booking_code})
if self.on_change:
await self.on_change()
async def book_restaurant(
self,
*,
first_name: str,
last_name: str,
phone: str,
party_size: int,
on_date: date,
at_time: time,
notes: str | None = None,
) -> RestaurantReservation:
conn = self.connection
row = conn.execute(
_SQL_FREE_TABLE,
{"party_size": party_size, "date": on_date.isoformat(), "time": at_time.isoformat()},
).fetchone()
if not row:
raise Unavailable(f"restaurant full: {on_date} {at_time}")
table_id = row[0]
code = shortuuid("RES-")
reservation_id = _insert(
conn,
"restaurant_reservations",
{
"code": code,
"table_id": table_id,
"first_name": first_name,
"last_name": last_name,
"phone": "".join(c for c in phone if c.isdigit()),
"party_size": party_size,
"date": on_date.isoformat(),
"time": at_time.isoformat(),
"notes": notes,
},
)
if self.on_change:
await self.on_change()
return RestaurantReservation(
id=reservation_id,
code=code,
table_id=table_id,
first_name=first_name,
last_name=last_name,
phone=phone,
party_size=party_size,
date=on_date,
time=at_time,
notes=notes,
status="confirmed",
)
async def cancel_restaurant_reservation(self, code: str) -> None:
conn = self.connection
changed = _update(
conn,
"restaurant_reservations",
{"status": "cancelled"},
{"code": code, "status": "confirmed"},
)
if changed == 0:
raise NotFound(f"reservation not found: {code}")
if self.on_change:
await self.on_change()
async def modify_restaurant_reservation(
self,
*,
code: str,
on_date: date,
at_time: time,
party_size: int | None = None,
) -> RestaurantReservation:
"""Change a confirmed reservation's date/time (and optionally party size).
Prefers the reservation's current table when it's still free at the new
slot (mirrors the prefer-current-room logic in update_booking), so a
same-table time shift leaves table_location unchanged. Falls back to the
next free table only if the current one is taken or too small.
"""
if on_date < TODAY:
raise Unavailable(f"{on_date.isoformat()} is in the past")
conn = self.connection
with conn:
current = conn.execute(
"SELECT table_id, party_size FROM restaurant_reservations "
"WHERE code = :code AND status = 'confirmed'",
{"code": code},
).fetchone()
if not current:
raise NotFound(f"reservation not found: {code}")
current_table_id, current_party = current
new_party = party_size if party_size is not None else current_party
row = conn.execute(
_SQL_FREE_TABLE_FOR_MODIFY,
{
"party_size": new_party,
"date": on_date.isoformat(),
"time": at_time.isoformat(),
"code": code,
"current_table_id": current_table_id,
},
).fetchone()
if not row:
raise Unavailable(f"restaurant full: {on_date} {at_time}")
table_id = row[0]
_update(
conn,
"restaurant_reservations",
{
"table_id": table_id,
"party_size": new_party,
"date": on_date.isoformat(),
"time": at_time.isoformat(),
},
{"code": code, "status": "confirmed"},
)
if self.on_change:
await self.on_change()
updated = _row_to_reservation(
conn.execute(_SQL_RESERVATION_BY_CODE, {"code": code}).fetchone()
)
if updated is None:
raise NotFound(f"reservation vanished mid-update: {code}")
return updated
async def flag_late_arrival(self, *, booking_code: str, note: str) -> None:
conn = self.connection
changed = _update(
conn,
"hotel_bookings",
{"late_arrival_note": note},
{"code": booking_code, "status": "confirmed"},
)
if changed == 0:
raise NotFound(f"booking not found: {booking_code}")
if self.on_change:
await self.on_change()
async def update_booking_card(self, *, booking_code: str, card_last4: str) -> None:
conn = self.connection
changed = _update(
conn,
"hotel_bookings",
{"card_last4": card_last4},
{"code": booking_code, "status": "confirmed"},
)
if changed == 0:
raise NotFound(f"booking not found: {booking_code}")
if self.on_change:
await self.on_change()
async def record_followup(
self,
*,
kind: FollowupKind,
caller_name: str,
caller_phone: str,
summary: str,
) -> str:
code = shortuuid("FUP-")
digits = "".join(c for c in caller_phone if c.isdigit())
conn = self.connection
with conn:
_insert(
conn,
"hotel_followups",
{
"code": code,
"kind": kind,
"caller_name": caller_name,
# "room 402" and "415-555-0173" both normalize to digits
"caller_phone": digits or caller_phone,
"summary": summary,
},
)
if self.on_change:
await self.on_change()
return code
async def schedule_wakeup_call(
self,
*,
room: str,
guest_name: str,
call_date: date,
call_time: time,
) -> str:
room_id = self._require_room(room)
conn = self.connection
if call_date < TODAY:
raise Unavailable(f"{call_date.isoformat()} is in the past")
code = shortuuid("WUC-")
with conn:
_insert(
conn,
"wakeup_calls",
{
"code": code,
"room_id": room_id,
"guest_name": guest_name,
"date": call_date.isoformat(),
"time": call_time.isoformat(),
},
)
if self.on_change:
await self.on_change()
return code
async def book_tour(
self,
*,
tour_id: str,
guest_name: str,
guest_phone: str,
on_date: date,
party_size: int,
) -> tuple[str, Tour, int]:
tour = TOURS.get(tour_id)
if tour is None:
raise NotFound(f"no such tour: {tour_id} - options: {', '.join(TOURS)}")
if on_date < TODAY:
raise Unavailable(f"{on_date.isoformat()} is in the past")
if party_size > tour.max_party:
raise Unavailable(f"{tour.name} takes at most {tour.max_party} guests")
total = tour.flat_price or (tour.price_per_person or 0) * party_size
code = shortuuid("TUR-")
with self.connection as conn:
_insert(
conn,
"tour_bookings",
{
"code": code,
"tour_id": tour_id,
"guest_name": guest_name,
"guest_phone": "".join(c for c in guest_phone if c.isdigit()),
"date": on_date.isoformat(),
"party_size": party_size,
"total": total,
},
)
if self.on_change:
await self.on_change()
return code, tour, total
async def book_spa_appointment(
self,
*,
service_id: str,
guest_name: str,
guest_phone: str,
on_date: date,
at_time: time,
party_size: int,
) -> tuple[str, SpaService, int]:
service = SPA_SERVICES.get(service_id)
if service is None:
raise NotFound(
f"no such spa service: {service_id} - options: {', '.join(SPA_SERVICES)}"
)
if on_date < TODAY:
raise Unavailable(f"{on_date.isoformat()} is in the past")
if party_size > service.max_party:
raise Unavailable(f"{service.name} takes at most {service.max_party} guests")
total = service.price * party_size
code = shortuuid("SPA-")
with self.connection as conn:
_insert(
conn,
"spa_bookings",
{
"code": code,
"service_id": service_id,
"guest_name": guest_name,
"guest_phone": "".join(c for c in guest_phone if c.isdigit()),
"date": on_date.isoformat(),
"time": at_time.isoformat(),
"party_size": party_size,
"total": total,
},
)
if self.on_change:
await self.on_change()
return code, service, total
async def book_business_center(
self,
*,
service_id: str,
guest_name: str,
guest_phone: str,
on_date: date,
at_time: time,
duration_hours: int,
) -> tuple[str, BusinessCenterService, int]:
service = BUSINESS_CENTER_SERVICES.get(service_id)
if service is None:
raise NotFound(
f"no such service: {service_id} - options: {', '.join(BUSINESS_CENTER_SERVICES)}"
)
if on_date < TODAY:
raise Unavailable(f"{on_date.isoformat()} is in the past")
if duration_hours > service.max_hours:
raise Unavailable(f"{service.name} is booked for at most {service.max_hours} hours")
total = service.flat_price or (service.price_per_hour or 0) * duration_hours
code = shortuuid("BIZ-")
with self.connection as conn:
_insert(
conn,
"business_center_bookings",
{
"code": code,
"service_id": service_id,
"guest_name": guest_name,
"guest_phone": "".join(c for c in guest_phone if c.isdigit()),
"date": on_date.isoformat(),
"time": at_time.isoformat(),
"duration_hours": duration_hours,
"total": total,
},
)
if self.on_change:
await self.on_change()
return code, service, total
async def order_flowers(
self,
*,
arrangement_id: str,
guest_name: str,
guest_phone: str,
deliver_to: str,
on_date: date,
card_message: str,
) -> tuple[str, FloralArrangement, int]:
arrangement = FLORIST_ARRANGEMENTS.get(arrangement_id)
if arrangement is None:
raise NotFound(
f"no such arrangement: {arrangement_id} - options: "
f"{', '.join(FLORIST_ARRANGEMENTS)}"
)
if on_date < TODAY:
raise Unavailable(f"{on_date.isoformat()} is in the past")
total = arrangement.price
code = shortuuid("FLR-")
with self.connection as conn:
_insert(
conn,
"florist_orders",
{
"code": code,
"arrangement_id": arrangement_id,
"guest_name": guest_name,
"guest_phone": "".join(c for c in guest_phone if c.isdigit()),
"deliver_to": deliver_to,
"date": on_date.isoformat(),
"message": card_message,
"total": total,
},
)
if self.on_change:
await self.on_change()
return code, arrangement, total
async def send_email(self, *, recipient: str, kind: str) -> str:
"""Stub email send: records that a document of `kind` was sent to `recipient`
and returns a reference. No real mail goes out; the row is the gradable signal
that the agent actually sent rather than just claiming to."""
if kind not in get_args(EmailKind):
raise NotFound(
f"unknown email kind: {kind} - options: {', '.join(get_args(EmailKind))}"
)
code = shortuuid("EML-")
with self.connection as conn:
_insert(
conn,
"emails_sent",
{
"code": code,
"recipient": recipient.strip().lower(),
"kind": kind,
},
)
if self.on_change:
await self.on_change()
return code
async def transfer_call(self, *, destination: str, summary: str) -> str:
"""Stub call transfer: records that the caller was transferred to a hotel
department with a one-line summary, and returns a reference."""
if destination not in get_args(TransferDestination):
raise NotFound(
f"unknown destination: {destination} - options: {', '.join(get_args(TransferDestination))}"
)
code = shortuuid("XFR-")
with self.connection as conn:
_insert(
conn,
"transfer_calls",
{
"code": code,
"destination": destination,
"summary": summary,
},
)
if self.on_change:
await self.on_change()
return code
async def request_flight_reconfirmation(
self,
*,
room: str,
airline: str,
flight_number: str,
flight_date: date,
booking_reference: str,
seat_check: bool,
) -> str:
room_id = self._require_room(room)
code = shortuuid("FLT-")
with self.connection as conn:
_insert(
conn,
"flight_reconfirmations",
{
"code": code,
"room_id": room_id,
"airline": airline.strip().title(),
# spoken codes arrive with unpredictable spaces/dashes
"flight_number": "".join(c for c in flight_number if c.isalnum()).upper(),
"flight_date": flight_date.isoformat(),
"booking_reference": "".join(
c for c in booking_reference if c.isalnum()
).upper(),
"seat_check": int(seat_check),
},
)
if self.on_change:
await self.on_change()
return code
async def book_airport_car(
self,
*,
room: str,
pickup_date: date,
pickup_time: time,
passengers: int,
) -> str:
room_id = self._require_room(room)
if pickup_date < TODAY:
raise Unavailable(f"{pickup_date.isoformat()} is in the past")
code = shortuuid("CAR-")
with self.connection as conn:
_insert(
conn,
"airport_cars",
{
"code": code,
"room_id": room_id,
"pickup_date": pickup_date.isoformat(),
"pickup_time": pickup_time.isoformat(),
"passengers": passengers,
},
)
if self.on_change:
await self.on_change()
return code
async def dispatch_emergency(self, *, room: str, kind: str, situation: str) -> str:
# A bad kind is an invalid argument, not a missing entity - keep it distinct
# from the room's NotFound so the tool can't misreport it as a bad room number.
if kind not in get_args(EmergencyKind):
raise ValueError(
f"unknown emergency kind: {kind} - options: {', '.join(get_args(EmergencyKind))}"
)
room_id = self._require_room(room)
code = shortuuid("EMG-")
with self.connection as conn:
_insert(
conn,
"emergency_dispatches",
{"code": code, "room_id": room_id, "kind": kind, "situation": situation},
)
if self.on_change:
await self.on_change()
return code
async def room_conflict(self, *, booking_code: str) -> tuple[date, date] | None:
"""The overlap window if another confirmed booking holds this booking's room."""
row = self.connection.execute(_SQL_ROOM_CONFLICT, {"code": booking_code}).fetchone()
if not row:
return None
return date.fromisoformat(row[0]), date.fromisoformat(row[1])
async def resolve_room_conflict(self, *, booking_code: str) -> ConflictResolution:
"""The house re-accommodation procedure, in fixed order: try to move the
booking to a free room of the same or higher category for the whole
(remaining) stay - an upgrade is free - and only when nothing in the
house fits, arrange a walk: tonight at the partner hotel on us, back in
the original room from the next day."""
conn = self.connection
booking = conn.execute(
"SELECT room_id, check_in, check_out, guests FROM hotel_bookings"
" WHERE code = :code AND status = 'confirmed'",
{"code": booking_code},
).fetchone()
if not booking:
raise NotFound(f"booking not found: {booking_code}")
if await self.room_conflict(booking_code=booking_code) is None:
raise Unavailable("no room conflict on this booking - nothing to resolve")
room_id, check_in, check_out, guests = booking
original = conn.execute(
"SELECT smoking, nightly_rate FROM hotel_rooms WHERE id = :id", {"id": room_id}
).fetchone()
start = max(date.fromisoformat(check_in), TODAY)
candidate = conn.execute(
_SQL_FREE_BETTER_ROOM,
{
"exclude_room": room_id,
"exclude_code": booking_code,
"guests": guests,
"smoking": original[0],
"min_rate": original[1],
"check_in": start.isoformat(),
"check_out": check_out,
},
).fetchone()
if candidate:
new_room, new_type, new_view, new_rate = candidate
with conn:
# the rate on the booking doesn't change - a forced move is never
# the guest's cost, so an upgrade rides at the original total
_update(conn, "hotel_bookings", {"room_id": new_room}, {"code": booking_code})
if self.on_change:
await self.on_change()
return ConflictResolution(
moved_to=new_room,
moved_to_type=new_type,
moved_to_view=new_view,
upgraded=new_rate > original[1],
)
return_date = start + timedelta(days=1)
with conn:
_insert(
conn,
"walk_arrangements",
{
"code": shortuuid("WLK-"),
"booking_code": booking_code,
"partner_hotel": WALK_PARTNER_HOTEL,
"return_date": return_date.isoformat(),
},
)
if self.on_change:
await self.on_change()
return ConflictResolution(walk_partner=WALK_PARTNER_HOTEL, walk_return_date=return_date)
def _require_room(self, room: str) -> str:
"""Normalize a spoken room number ("304") to its id and require it exists."""
room_id = room.strip().upper()
if not room_id.startswith("RM_"):
room_id = f"RM_{room_id}"
if not self.connection.execute(
"SELECT 1 FROM hotel_rooms WHERE id = :id", {"id": room_id}
).fetchone():
raise NotFound(f"no such room: {room}")
return room_id
async def take_guest_message(
self,
*,
recipient: str,
caller_name: str,
caller_phone: str,
message: str,
) -> str:
"""Record a message addressed to a (possibly) in-house guest. Whether the
recipient actually has a stay here is resolved internally and never
returned, so the agent taking the message cannot leak guest presence."""
code = shortuuid("MSG-")
conn = self.connection
with conn:
in_house = conn.execute(
"SELECT first_name || ' ' || last_name FROM hotel_bookings"
" WHERE status = 'confirmed'"
" AND LOWER(first_name || ' ' || last_name) = LOWER(TRIM(:name))"
" AND check_in <= :today AND check_out > :today",
{"name": recipient, "today": TODAY.isoformat()},
).fetchone()
_insert(
conn,
"guest_messages",
{
"code": code,
# matched messages take the registered guest's casing so the
# stored name doesn't depend on how the caller's was heard
"recipient": in_house[0] if in_house else recipient,
"caller_name": caller_name,
"caller_phone": "".join(c for c in caller_phone if c.isdigit()),
"message": message,
"status": "delivered" if in_house else "undeliverable",
},
)
if self.on_change:
await self.on_change()
return code
async def peek_stay_total(
self,
*,
room_type: str,
smoking: bool,
guests: int,
check_in: date,
check_out: date,
view: str | None,
extras: Sequence[str],
) -> int | None:
"""The exact total (with tax) for the room book_room would pick right now -
so the agent can quote the real number in the read-back instead of doing
per-night arithmetic itself (and forgetting tax)."""
row = self.connection.execute(
_SQL_FREE_ROOM,
{
"room_type": room_type,
"smoking": int(smoking),
"guests": guests,
"check_in": check_in.isoformat(),
"check_out": check_out.isoformat(),
"exclude": None,
"view": view,
"prefer": None,
},
).fetchone()
if not row:
return None
nights = (check_out - check_in).days
_, _, total, _ = compute_invoice(nightly_rate=row[1], nights=nights, extras=list(extras))
return total
async def record_group_inquiry(
self,
*,
company: str,
contact_name: str,
contact_phone: str,
party_size: int,
share_type: GroupShareType,
check_in: date,
nights: int,
) -> str:
code = shortuuid("GRP-")
conn = self.connection
with conn:
_insert(
conn,
"group_inquiries",
{
"code": code,
"company": company,
"contact_name": contact_name,
# digits only: a spoken callback number transcribes with
# unpredictable punctuation, and nothing dials it back out
"contact_phone": "".join(c for c in contact_phone if c.isdigit()),
"party_size": party_size,
"share_type": share_type,
"check_in": check_in.isoformat(),
"nights": nights,
},
)
if self.on_change:
await self.on_change()
return code
async def file_dispute(
self,
*,
booking_code: str,
line_item: str,
amount_cents: int,
category: DisputeCategory,
caller_note: str,
outcome: str,
refund_amount: int,
) -> str:
case_number = shortuuid("DSP-")
conn = self.connection
with conn:
_insert(
conn,
"hotel_disputes",
{
"case_number": case_number,
"booking_code": booking_code,
"line_item": line_item,
"amount": amount_cents,
"category": category,
"caller_note": caller_note,
"outcome": outcome,
"refund_amount": refund_amount,
},
)
if refund_amount > 0:
conn.execute(
"UPDATE hotel_invoices SET total = total - :refund WHERE booking_code = :code",
{"refund": refund_amount, "code": booking_code},
)
if self.on_change:
await self.on_change()
return case_number
def _install_schema(conn: apsw.Connection) -> None:
# Views DROP+CREATE so they pick up the current TODAY each time.
for _ in conn.execute(SCHEMA):
pass
for _ in conn.execute(VIEWS):
pass
_SQL_ROOM_CONFLICT = """
SELECT MAX(b.check_in, a.check_in), MIN(b.check_out, a.check_out)
FROM hotel_bookings a
JOIN hotel_bookings b
ON b.room_id = a.room_id AND b.code != a.code AND b.status = 'confirmed'
AND b.check_in < a.check_out AND b.check_out > a.check_in
WHERE a.code = :code AND a.status = 'confirmed'
LIMIT 1
"""
# A room that can absorb a conflicted booking for its whole (remaining) stay:
# fits the party, matches smoking, same or higher category (rate), cheapest first.
_SQL_FREE_BETTER_ROOM = """
SELECT r.id, r.type, r.room_view, r.nightly_rate
FROM hotel_rooms r
WHERE r.id != :exclude_room
AND r.max_occupancy >= :guests
AND r.smoking = :smoking
AND r.nightly_rate >= :min_rate
AND NOT EXISTS (
SELECT 1 FROM hotel_bookings b
WHERE b.room_id = r.id AND b.status = 'confirmed' AND b.code != :exclude_code
AND b.check_in < :check_out AND b.check_out > :check_in)
ORDER BY r.nightly_rate, r.id
LIMIT 1
"""
SCHEMA = """
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS hotel_rooms (
id TEXT PRIMARY KEY, -- human room number, e.g. 'RM_201' (floor 2, room 01)
type TEXT NOT NULL CHECK (type IN ('king','queen_2beds','double_queen','suite','penthouse')),
nightly_rate INTEGER NOT NULL,
max_occupancy INTEGER NOT NULL,
smoking BOOLEAN NOT NULL DEFAULT 0,
pets_allowed BOOLEAN NOT NULL DEFAULT 0,
room_view TEXT NOT NULL CHECK (room_view IN ('city','ocean','garden','interior'))
);
CREATE TABLE IF NOT EXISTS hotel_bookings (
id INTEGER PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
room_id TEXT NOT NULL REFERENCES hotel_rooms(id),
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
email TEXT NOT NULL,
phone TEXT NOT NULL,
check_in DATE NOT NULL,
check_out DATE NOT NULL,
guests INTEGER NOT NULL CHECK (guests >= 1),
extras TEXT NOT NULL DEFAULT '',
total INTEGER NOT NULL,
card_last4 TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'confirmed' CHECK (status IN ('confirmed','cancelled')),
late_arrival_note TEXT,
CHECK (check_out > check_in)
);
CREATE TABLE IF NOT EXISTS restaurant_tables (
id INTEGER PRIMARY KEY,
label TEXT NOT NULL UNIQUE,
capacity INTEGER NOT NULL CHECK (capacity >= 1),
location TEXT NOT NULL CHECK (location IN ('indoor','terrace','bar')),
description TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS restaurant_reservations (
id INTEGER PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
table_id INTEGER NOT NULL REFERENCES restaurant_tables(id),
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
phone TEXT NOT NULL,
party_size INTEGER NOT NULL CHECK (party_size >= 1),
date DATE NOT NULL,
time TIME NOT NULL,
notes TEXT,
status TEXT NOT NULL DEFAULT 'confirmed' CHECK (status IN ('confirmed','cancelled'))
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_restaurant_slot
ON restaurant_reservations(date, time, table_id) WHERE status = 'confirmed';
CREATE TABLE IF NOT EXISTS hotel_invoices (
id INTEGER PRIMARY KEY,
booking_code TEXT NOT NULL UNIQUE REFERENCES hotel_bookings(code),
line_items JSON NOT NULL,
subtotal INTEGER NOT NULL,
taxes INTEGER NOT NULL,
total INTEGER NOT NULL,
paid BOOLEAN NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS hotel_disputes (
id INTEGER PRIMARY KEY,
case_number TEXT NOT NULL UNIQUE,
booking_code TEXT NOT NULL REFERENCES hotel_bookings(code),
line_item TEXT NOT NULL,
amount INTEGER NOT NULL,
category TEXT NOT NULL CHECK (category IN ('minibar','room_service_restaurant','damage_cleaning','late_checkout_fee','cancellation_fee','no_show','double_charge_billing_error','other')),
caller_note TEXT NOT NULL,
outcome TEXT NOT NULL CHECK (outcome IN ('auto_refunded','credit_offered','explained_no_action','goodwill_waived','escalated_to_manager','accounting_ticket_opened','open')),
refund_amount INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','resolved','rejected'))
);
CREATE TABLE IF NOT EXISTS hotel_followups (
id INTEGER PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
kind TEXT NOT NULL CHECK (kind IN ('housekeeping','sales_lead','identity_change','callback','verification_help','early_checkout','abandoned_booking','lost_and_found','other')),
caller_name TEXT NOT NULL,
caller_phone TEXT NOT NULL,
summary TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','resolved'))
);
CREATE TABLE IF NOT EXISTS tour_bookings (
id INTEGER PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
tour_id TEXT NOT NULL CHECK (tour_id IN ('half_day_city','full_day_city','private_city')),
guest_name TEXT NOT NULL,
guest_phone TEXT NOT NULL,
date DATE NOT NULL,
party_size INTEGER NOT NULL CHECK (party_size >= 1),
total INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'confirmed' CHECK (status IN ('confirmed','cancelled'))
);
CREATE TABLE IF NOT EXISTS spa_bookings (
id INTEGER PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
service_id TEXT NOT NULL CHECK (service_id IN ('deep_tissue_massage','signature_facial','personal_training','group_yoga')),
guest_name TEXT NOT NULL,
guest_phone TEXT NOT NULL,
date DATE NOT NULL,
time TIME NOT NULL,
party_size INTEGER NOT NULL CHECK (party_size >= 1),
total INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'confirmed' CHECK (status IN ('confirmed','cancelled'))
);
CREATE TABLE IF NOT EXISTS business_center_bookings (
id INTEGER PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
service_id TEXT NOT NULL CHECK (service_id IN ('meeting_room','secretarial','printing')),
guest_name TEXT NOT NULL,
guest_phone TEXT NOT NULL,
date DATE NOT NULL,
time TIME NOT NULL,
duration_hours INTEGER NOT NULL CHECK (duration_hours >= 1),
total INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'confirmed' CHECK (status IN ('confirmed','cancelled'))
);
CREATE TABLE IF NOT EXISTS florist_orders (
id INTEGER PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
arrangement_id TEXT NOT NULL CHECK (arrangement_id IN ('bouquet','roses','centerpiece')),
guest_name TEXT NOT NULL,
guest_phone TEXT NOT NULL,
deliver_to TEXT NOT NULL,
date DATE NOT NULL,
message TEXT NOT NULL DEFAULT '',
total INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'confirmed' CHECK (status IN ('confirmed','cancelled'))
);
CREATE TABLE IF NOT EXISTS emails_sent (
id INTEGER PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
recipient TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('booking_confirmation','folio')),
status TEXT NOT NULL DEFAULT 'sent'
);
CREATE TABLE IF NOT EXISTS transfer_calls (
id INTEGER PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
destination TEXT NOT NULL CHECK (destination IN ('restaurant','duty_manager','housekeeping')),
summary TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'transferred'
);
CREATE TABLE IF NOT EXISTS waitlist (
id INTEGER PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
phone TEXT NOT NULL,
check_in TEXT NOT NULL,
check_out TEXT NOT NULL,
guests INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'waiting'
);
CREATE TABLE IF NOT EXISTS do_not_disturb (
id INTEGER PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
room_id TEXT NOT NULL REFERENCES hotel_rooms(id),
status TEXT NOT NULL DEFAULT 'active'
);
-- Reference data (read-only): preferences remembered from a returning guest's past
-- stays. The agent reads this to personalize; it never writes here.
CREATE TABLE IF NOT EXISTS guest_history (
id INTEGER PRIMARY KEY,
last_name TEXT NOT NULL,
preferences TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS flight_reconfirmations (
id INTEGER PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
room_id TEXT NOT NULL REFERENCES hotel_rooms(id),
airline TEXT NOT NULL,
flight_number TEXT NOT NULL,
flight_date DATE NOT NULL,
booking_reference TEXT NOT NULL,
seat_check BOOLEAN NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','confirmed','problem'))
);
CREATE TABLE IF NOT EXISTS airport_cars (
id INTEGER PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
room_id TEXT NOT NULL REFERENCES hotel_rooms(id),
pickup_date DATE NOT NULL,
pickup_time TIME NOT NULL,
passengers INTEGER NOT NULL CHECK (passengers >= 1),
status TEXT NOT NULL DEFAULT 'booked' CHECK (status IN ('booked','cancelled'))
);
CREATE TABLE IF NOT EXISTS emergency_dispatches (
id INTEGER PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
room_id TEXT NOT NULL REFERENCES hotel_rooms(id),
kind TEXT NOT NULL DEFAULT 'medical' CHECK (kind IN ('medical','fire','security')),
situation TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'dispatched' CHECK (status IN ('dispatched','resolved'))
);
CREATE TABLE IF NOT EXISTS walk_arrangements (
id INTEGER PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
booking_code TEXT NOT NULL REFERENCES hotel_bookings(code),
partner_hotel TEXT NOT NULL,
return_date DATE NOT NULL,
status TEXT NOT NULL DEFAULT 'arranged' CHECK (status IN ('arranged','completed'))
);
CREATE TABLE IF NOT EXISTS wakeup_calls (
id INTEGER PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
room_id TEXT NOT NULL REFERENCES hotel_rooms(id),
guest_name TEXT NOT NULL,
date DATE NOT NULL,
time TIME NOT NULL,
status TEXT NOT NULL DEFAULT 'scheduled'
CHECK (status IN ('scheduled','completed','cancelled'))
);
CREATE TABLE IF NOT EXISTS guest_messages (
id INTEGER PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
recipient TEXT NOT NULL,
caller_name TEXT NOT NULL,
caller_phone TEXT NOT NULL,
message TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('delivered','undeliverable'))
);
CREATE TABLE IF NOT EXISTS group_inquiries (
id INTEGER PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
company TEXT NOT NULL,
contact_name TEXT NOT NULL,
contact_phone TEXT NOT NULL,
party_size INTEGER NOT NULL CHECK (party_size >= 15),
share_type TEXT NOT NULL CHECK (share_type IN ('twin','double','single','mixed')),
check_in DATE NOT NULL,
nights INTEGER NOT NULL CHECK (nights >= 1),
status TEXT NOT NULL DEFAULT 'pending_credit_approval'
CHECK (status IN ('pending_credit_approval','approved','declined'))
);
CREATE TABLE IF NOT EXISTS lk_descriptions (
name TEXT PRIMARY KEY,
description TEXT NOT NULL
);
"""
VIEWS = f"""
DROP VIEW IF EXISTS hotel_room_status;
CREATE VIEW hotel_room_status AS
SELECT r.id AS room_number, r.type, r.room_view, r.max_occupancy, r.nightly_rate,
CASE WHEN b.code IS NULL THEN 'available' ELSE 'occupied' END AS status,
b.code AS current_booking, b.first_name, b.last_name, b.check_out AS free_after
FROM hotel_rooms r
LEFT JOIN hotel_bookings b
ON b.room_id = r.id AND b.status = 'confirmed'
AND b.check_in <= '{TODAY.isoformat()}' AND b.check_out > '{TODAY.isoformat()}'
ORDER BY r.id;
DROP VIEW IF EXISTS restaurant_table_status;
CREATE VIEW restaurant_table_status AS
WITH slots(t) AS (
VALUES ('17:30:00'),('18:00:00'),('18:30:00'),('19:00:00'),
('19:30:00'),('20:00:00'),('20:30:00'),('21:00:00')
),
grid AS (
SELECT rt.id, rt.label, rt.capacity, rt.location, s.t,
CASE WHEN r.code IS NULL THEN '✓' ELSE '✗' END AS cell
FROM restaurant_tables rt
CROSS JOIN slots s
LEFT JOIN restaurant_reservations r
ON r.table_id = rt.id AND r.status = 'confirmed'
AND r.date = '{TODAY.isoformat()}' AND r.time = s.t
)
SELECT label, capacity, location,
MAX(CASE WHEN t='17:30:00' THEN cell END) AS "5:30 PM",
MAX(CASE WHEN t='18:00:00' THEN cell END) AS "6:00 PM",
MAX(CASE WHEN t='18:30:00' THEN cell END) AS "6:30 PM",
MAX(CASE WHEN t='19:00:00' THEN cell END) AS "7:00 PM",
MAX(CASE WHEN t='19:30:00' THEN cell END) AS "7:30 PM",
MAX(CASE WHEN t='20:00:00' THEN cell END) AS "8:00 PM",
MAX(CASE WHEN t='20:30:00' THEN cell END) AS "8:30 PM",
MAX(CASE WHEN t='21:00:00' THEN cell END) AS "9:00 PM"
FROM grid GROUP BY id ORDER BY label;
"""
_BOOKING_COLS = "b.id, b.code, b.room_id, r.type AS room_type, r.smoking, r.nightly_rate, b.first_name, b.last_name, b.email, b.phone, b.check_in, b.check_out, b.guests, b.extras, b.total, b.card_last4, b.status, b.late_arrival_note"
# Names that _row_to_booking maps the SELECT columns onto - derived from the
# dataclass so adding a field in one place keeps them aligned.
_BOOKING_COL_NAMES: tuple[str, ...] = tuple(f.name for f in fields(RoomBooking))
_RESERVATION_COLS: tuple[str, ...] = tuple(f.name for f in fields(RestaurantReservation))
_SQL_FREE_ROOM = """
SELECT id, nightly_rate FROM hotel_rooms
WHERE type = :room_type AND smoking = :smoking AND max_occupancy >= :guests
AND (:view IS NULL OR room_view = :view)
AND NOT EXISTS (
SELECT 1 FROM hotel_bookings b
WHERE b.room_id = hotel_rooms.id AND b.status = 'confirmed'
AND (:exclude IS NULL OR b.code != :exclude)
AND NOT (b.check_out <= :check_in OR b.check_in >= :check_out))
ORDER BY CASE WHEN id = :prefer THEN 0 ELSE 1 END, id LIMIT 1
"""
_SQL_AVAILABILITY = """
SELECT r.type, r.nightly_rate, GROUP_CONCAT(DISTINCT r.room_view)
FROM hotel_rooms r
WHERE r.max_occupancy >= :guests
AND (:smoking IS NULL OR r.smoking = :smoking)
AND NOT EXISTS (
SELECT 1 FROM hotel_bookings b
WHERE b.room_id = r.id AND b.status = 'confirmed'
AND (:exclude IS NULL OR b.code != :exclude)
AND NOT (b.check_out <= :check_in OR b.check_in >= :check_out))
GROUP BY r.type ORDER BY r.nightly_rate
"""
_SQL_FREE_TABLE = """
SELECT t.id FROM restaurant_tables t
WHERE t.capacity >= :party_size
AND NOT EXISTS (
SELECT 1 FROM restaurant_reservations r
WHERE r.table_id = t.id AND r.status = 'confirmed'
AND r.date = :date AND r.time = :time)
ORDER BY t.capacity, t.id LIMIT 1
"""
# Pick a table for a modified reservation: prefer the reservation's CURRENT
# table when it's still big enough and free at the new slot (so a same-evening
# time shift keeps the same table_id and table_location), otherwise fall back to
# the next free table. Excludes the reservation being modified from the conflict
# check so shifting in place doesn't collide with itself.
_SQL_FREE_TABLE_FOR_MODIFY = """
SELECT t.id FROM restaurant_tables t
WHERE t.capacity >= :party_size
AND NOT EXISTS (
SELECT 1 FROM restaurant_reservations r
WHERE r.table_id = t.id AND r.status = 'confirmed' AND r.code != :code
AND r.date = :date AND r.time = :time)
ORDER BY (t.id = :current_table_id) DESC, t.capacity, t.id LIMIT 1
"""
_SQL_DINING_AVAILABILITY = """
WITH slots(slot) AS (
VALUES ('17:30:00'),('18:00:00'),('18:30:00'),('19:00:00'),
('19:30:00'),('20:00:00'),('20:30:00'),('21:00:00')
)
SELECT slots.slot, rt.id
FROM slots
LEFT JOIN restaurant_tables rt
ON rt.capacity >= :party_size
AND NOT EXISTS (
SELECT 1 FROM restaurant_reservations r
WHERE r.table_id = rt.id AND r.status = 'confirmed'
AND r.date = :date AND r.time = slots.slot)
ORDER BY slots.slot, rt.capacity, rt.id
"""
_SQL_FIND_BOOKING = f"""
SELECT {_BOOKING_COLS} FROM hotel_bookings b
JOIN hotel_rooms r ON r.id = b.room_id
WHERE LOWER(b.last_name) = LOWER(:last_name)
AND (:code IS NULL OR REPLACE(b.code, '-', '') = REPLACE(:code, '-', ''))
AND (:email IS NULL OR LOWER(b.email) = LOWER(:email))
AND (:card_last4 IS NULL OR b.card_last4 = :card_last4)
LIMIT 1
"""
_SQL_BOOKING_BY_CODE = f"""
SELECT {_BOOKING_COLS} FROM hotel_bookings b
JOIN hotel_rooms r ON r.id = b.room_id
WHERE b.code = :code LIMIT 1
"""
_SQL_FIND_RESERVATION = f"""
SELECT {", ".join(_RESERVATION_COLS)} FROM restaurant_reservations
WHERE LOWER(last_name) = LOWER(:last_name)
AND (:code IS NULL OR REPLACE(code, '-', '') = REPLACE(:code, '-', ''))
AND (:date IS NULL OR date = :date)
LIMIT 1
"""
_SQL_RESERVATION_BY_CODE = f"""
SELECT {", ".join(_RESERVATION_COLS)} FROM restaurant_reservations
WHERE code = :code LIMIT 1
"""
_SQL_GET_INVOICE = "SELECT id, line_items, subtotal, taxes, total, paid FROM hotel_invoices WHERE booking_code = :code"
def _insert(conn: apsw.Connection, table: str, row: dict[str, Any]) -> int:
keys = ", ".join(row)
placeholders = ", ".join(f":{k}" for k in row)
conn.execute(f"INSERT INTO {table} ({keys}) VALUES ({placeholders})", row)
return conn.last_insert_rowid()
def _update(
conn: apsw.Connection, table: str, set_fields: dict[str, Any], where: dict[str, Any]
) -> int:
set_clause = ", ".join(f"{k} = :{k}" for k in set_fields)
where_clause = " AND ".join(f"{k} = :w_{k}" for k in where)
params = {**set_fields, **{f"w_{k}": v for k, v in where.items()}}
conn.execute(f"UPDATE {table} SET {set_clause} WHERE {where_clause}", params)
return conn.changes()
def _row_to_booking(row: tuple[Any, ...] | None) -> RoomBooking | None:
if row is None:
return None
d = dict(zip(_BOOKING_COL_NAMES, row, strict=True))
d["check_in"], d["check_out"] = (
date.fromisoformat(d["check_in"]),
date.fromisoformat(d["check_out"]),
)
d["extras"] = [e for e in d["extras"].split(",") if e]
d["smoking"] = bool(d["smoking"])
return RoomBooking(**d)
def _row_to_reservation(row: tuple[Any, ...] | None) -> RestaurantReservation | None:
if row is None:
return None
d = dict(zip(_RESERVATION_COLS, row, strict=True))
d["date"], d["time"] = date.fromisoformat(d["date"]), time.fromisoformat(d["time"])
return RestaurantReservation(**d)