Files

269 lines
9.5 KiB
Python
Raw Permalink Normal View History

import json
from datetime import date, datetime, time, timedelta
import redis.asyncio as aioredis
from fastapi import HTTPException, status
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
2026-04-29 09:39:56 -05:00
from app.modules.business.models import BusinessConfig, TableType
from app.modules.business.service import get_business_config, list_table_types
from app.modules.calendar.schemas import DayAvailability, SlotRead
from app.modules.reservations.models import Reservation
SLOTS_CACHE_TTL = 300 # 5 minutos
def _cache_key(business_id: int, target_date: date) -> str:
return f"slots:{business_id}:{target_date.isoformat()}"
2026-04-29 09:39:56 -05:00
def _cache_key_party(business_id: int, target_date: date, party_size: int) -> str:
return f"slots:{business_id}:{target_date.isoformat()}:p{party_size}"
def _generate_slots(open_time: time, close_time: time, slot_duration: int) -> list[tuple[time, time]]:
slots = []
base = date.today()
current = datetime.combine(base, open_time)
end = datetime.combine(base, close_time)
delta = timedelta(minutes=slot_duration)
while current + delta <= end:
slots.append((current.time(), (current + delta).time()))
current += delta
return slots
async def _count_reservations_per_slot(
db: AsyncSession,
business_id: int,
target_date: date,
slots: list[tuple[time, time]],
) -> dict[tuple[time, time], int]:
result = await db.execute(
select(Reservation.time_start, func.count(Reservation.id))
.where(
and_(
Reservation.business_id == business_id,
Reservation.date == target_date,
Reservation.status.in_(["pending", "confirmed"]),
)
)
.group_by(Reservation.time_start)
)
counts = {row[0]: row[1] for row in result.all()}
return {slot: counts.get(slot[0], 0) for slot in slots}
2026-04-29 09:39:56 -05:00
async def _count_reservations_by_table_type(
db: AsyncSession,
business_id: int,
target_date: date,
slots: list[tuple[time, time]],
) -> dict[tuple[time, time], dict[int, int]]:
"""Devuelve {slot: {table_type_id: count}} para slots con table_type_id asignado."""
result = await db.execute(
select(Reservation.time_start, Reservation.table_type_id, func.count(Reservation.id))
.where(
and_(
Reservation.business_id == business_id,
Reservation.date == target_date,
Reservation.status.in_(["pending", "confirmed"]),
Reservation.table_type_id.isnot(None),
)
)
.group_by(Reservation.time_start, Reservation.table_type_id)
)
slot_counts: dict[tuple[time, time], dict[int, int]] = {s: {} for s in slots}
for time_start, table_type_id, count in result.all():
for slot in slots:
if slot[0] == time_start:
slot_counts[slot][table_type_id] = count
return slot_counts
async def get_available_slots(
db: AsyncSession,
redis: aioredis.Redis,
business_id: int,
target_date: date,
) -> DayAvailability:
2026-04-29 09:39:56 -05:00
"""Disponibilidad genérica (sin filtro de party_size). Usada por el panel de control."""
cache_key = _cache_key(business_id, target_date)
cached = await redis.get(cache_key)
if cached:
return DayAvailability.model_validate_json(cached)
config = await get_business_config(db, business_id)
2026-04-29 09:39:56 -05:00
is_open = (
target_date.weekday() in (config.open_days or [])
and target_date not in (config.blocked_dates or [])
)
if not is_open:
result = DayAvailability(date=target_date, is_open=False, slots=[])
await redis.setex(cache_key, SLOTS_CACHE_TTL, result.model_dump_json())
return result
2026-04-29 09:39:56 -05:00
raw_slots = _generate_slots(config.open_time, config.close_time, config.slot_duration)
# Restaurantes con mesas configuradas: mostrar disponibilidad agregada
table_types = await list_table_types(db, business_id)
if table_types:
slot_table_counts = await _count_reservations_by_table_type(db, business_id, target_date, raw_slots)
total_tables = sum(t.quantity for t in table_types)
slots = []
for s in raw_slots:
used = sum(slot_table_counts[s].values())
slots.append(SlotRead(
time_start=s[0],
time_end=s[1],
available=max(0, total_tables - used),
max_per_slot=total_tables,
))
else:
counts = await _count_reservations_per_slot(db, business_id, target_date, raw_slots)
slots = [
SlotRead(
time_start=s[0],
time_end=s[1],
available=max(0, config.max_per_slot - counts[s]),
max_per_slot=config.max_per_slot,
)
for s in raw_slots
]
result = DayAvailability(date=target_date, is_open=True, slots=slots)
await redis.setex(cache_key, SLOTS_CACHE_TTL, result.model_dump_json())
return result
async def get_available_slots_for_party(
db: AsyncSession,
redis: aioredis.Redis,
business_id: int,
target_date: date,
party_size: int,
) -> DayAvailability:
"""Disponibilidad filtrada por tamaño de grupo. Usada por el bot para restaurantes."""
cache_key = _cache_key_party(business_id, target_date, party_size)
cached = await redis.get(cache_key)
if cached:
return DayAvailability.model_validate_json(cached)
config = await get_business_config(db, business_id)
is_open = (
target_date.weekday() in (config.open_days or [])
and target_date not in (config.blocked_dates or [])
)
if not is_open:
result = DayAvailability(date=target_date, is_open=False, slots=[])
await redis.setex(cache_key, SLOTS_CACHE_TTL, result.model_dump_json())
return result
raw_slots = _generate_slots(config.open_time, config.close_time, config.slot_duration)
2026-04-29 09:39:56 -05:00
table_types = await list_table_types(db, business_id)
if not table_types:
# Negocio sin mesas configuradas → usa max_per_slot genérico
counts = await _count_reservations_per_slot(db, business_id, target_date, raw_slots)
slots = [
SlotRead(
time_start=s[0],
time_end=s[1],
available=max(0, config.max_per_slot - counts[s]),
max_per_slot=config.max_per_slot,
)
for s in raw_slots
]
else:
# Mesas configuradas: buscar tipos con capacidad >= party_size
fitting_types = [t for t in table_types if t.capacity >= party_size]
slot_table_counts = await _count_reservations_by_table_type(db, business_id, target_date, raw_slots)
slots = []
for s in raw_slots:
available_tables = 0
total_fitting = 0
for t in fitting_types:
used = slot_table_counts[s].get(t.id, 0)
free = max(0, t.quantity - used)
available_tables += free
total_fitting += t.quantity
slots.append(SlotRead(
time_start=s[0],
time_end=s[1],
available=available_tables,
max_per_slot=total_fitting,
))
result = DayAvailability(date=target_date, is_open=True, slots=slots)
await redis.setex(cache_key, SLOTS_CACHE_TTL, result.model_dump_json())
return result
2026-04-29 09:39:56 -05:00
async def find_best_table_for_party(
table_types: list[TableType],
slot_table_counts: dict[int, int],
party_size: int,
) -> TableType | None:
"""Devuelve la mesa más pequeña que cabe el grupo y que tiene disponibilidad."""
fitting = sorted(
[t for t in table_types if t.capacity >= party_size],
key=lambda t: t.capacity,
)
for t in fitting:
used = slot_table_counts.get(t.id, 0)
if used < t.quantity:
return t
return None
async def get_availability_range(
db: AsyncSession,
redis: aioredis.Redis,
business_id: int,
start: date,
end: date,
) -> list[DayAvailability]:
if (end - start).days > 31:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="El rango máximo es 31 días",
)
days = []
current = start
while current <= end:
days.append(await get_available_slots(db, redis, business_id, current))
current += timedelta(days=1)
return days
async def invalidate_slots_cache(redis: aioredis.Redis, business_id: int, target_date: date) -> None:
2026-04-29 09:39:56 -05:00
# Invalida cache genérico y cualquier cache por party_size (patrón)
await redis.delete(_cache_key(business_id, target_date))
2026-04-29 09:39:56 -05:00
# Borra claves con party_size para esa fecha
pattern = f"slots:{business_id}:{target_date.isoformat()}:p*"
keys = await redis.keys(pattern)
if keys:
await redis.delete(*keys)
async def add_blocked_date(db: AsyncSession, business_id: int, target_date: date) -> None:
config = await get_business_config(db, business_id)
blocked = list(config.blocked_dates or [])
if target_date not in blocked:
blocked.append(target_date)
config.blocked_dates = blocked
await db.commit()
async def remove_blocked_date(db: AsyncSession, business_id: int, target_date: date) -> None:
config = await get_business_config(db, business_id)
blocked = list(config.blocked_dates or [])
if target_date not in blocked:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Fecha no bloqueada")
blocked.remove(target_date)
config.blocked_dates = blocked
await db.commit()