2086 lines
73 KiB
Python
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)
|