tematicas por tipo de bot
This commit is contained in:
@ -1,9 +1,88 @@
|
||||
from app.modules.bot_engine.schemas import CollectedData, ConversationContext
|
||||
from app.modules.business.models import Business, BusinessConfig
|
||||
from app.modules.bot_engine.schemas import ConversationContext
|
||||
from app.modules.business.models import Business, BusinessConfig, Service, TableType
|
||||
from app.modules.calendar.schemas import DayAvailability
|
||||
|
||||
DAYS_ES = ["lunes", "martes", "miércoles", "jueves", "viernes", "sábado", "domingo"]
|
||||
|
||||
# Contexto general por tipo de negocio
|
||||
BUSINESS_TYPE_CONTEXT: dict[str, dict] = {
|
||||
"restaurant": {
|
||||
"description": (
|
||||
"Eres el asistente de reservas de un restaurante. Tu misión es ayudar a los "
|
||||
"clientes a reservar una mesa. Siempre pregunta cuántas personas son antes de "
|
||||
"mostrar disponibilidad, ya que los horarios dependen del tamaño del grupo. "
|
||||
"Puedes informar sobre el menú o servicios disponibles si el cliente pregunta."
|
||||
),
|
||||
"party_term": "comensales",
|
||||
"reservation_term": "reserva de mesa",
|
||||
"ask_party_first": True,
|
||||
},
|
||||
"clinic": {
|
||||
"description": (
|
||||
"Eres el asistente de citas de una clínica o consultorio. Tu misión es ayudar "
|
||||
"a los pacientes a agendar su cita médica. Trata a los pacientes con respeto y "
|
||||
"empatía. Puedes informar sobre los servicios o especialidades disponibles si "
|
||||
"el paciente pregunta. Pregunta el motivo de la consulta solo si es necesario "
|
||||
"para determinar el servicio correcto."
|
||||
),
|
||||
"party_term": "pacientes",
|
||||
"reservation_term": "cita médica",
|
||||
"ask_party_first": False,
|
||||
},
|
||||
"salon": {
|
||||
"description": (
|
||||
"Eres el asistente de reservas de un salón de belleza. Tu misión es ayudar a "
|
||||
"los clientes a reservar su cita. Puedes informar sobre los servicios y precios "
|
||||
"disponibles si el cliente pregunta. Pregunta qué servicio desean para asignar "
|
||||
"el tiempo correcto."
|
||||
),
|
||||
"party_term": "personas",
|
||||
"reservation_term": "cita",
|
||||
"ask_party_first": False,
|
||||
},
|
||||
"spa": {
|
||||
"description": (
|
||||
"Eres el asistente de reservas de un spa o centro de bienestar. Tu misión es "
|
||||
"ayudar a los clientes a reservar sus tratamientos. Usa un tono relajante y "
|
||||
"acogedor. Puedes informar sobre los tratamientos y precios disponibles si el "
|
||||
"cliente pregunta."
|
||||
),
|
||||
"party_term": "personas",
|
||||
"reservation_term": "reserva de tratamiento",
|
||||
"ask_party_first": False,
|
||||
},
|
||||
"barbershop": {
|
||||
"description": (
|
||||
"Eres el asistente de reservas de una barbería. Tu misión es ayudar a los "
|
||||
"clientes a reservar su turno. Puedes informar sobre los servicios y precios "
|
||||
"disponibles si el cliente pregunta."
|
||||
),
|
||||
"party_term": "personas",
|
||||
"reservation_term": "turno",
|
||||
"ask_party_first": False,
|
||||
},
|
||||
"gym": {
|
||||
"description": (
|
||||
"Eres el asistente de reservas de un gimnasio o entrenador personal. Tu misión "
|
||||
"es ayudar a los clientes a reservar sus sesiones de entrenamiento. Puedes "
|
||||
"informar sobre los servicios y planes disponibles si el cliente pregunta."
|
||||
),
|
||||
"party_term": "personas",
|
||||
"reservation_term": "sesión",
|
||||
"ask_party_first": False,
|
||||
},
|
||||
"other": {
|
||||
"description": (
|
||||
"Eres el asistente de reservas de un negocio de servicios. Tu misión es ayudar "
|
||||
"a los clientes a agendar su reserva. Puedes informar sobre los servicios "
|
||||
"disponibles si el cliente pregunta."
|
||||
),
|
||||
"party_term": "personas",
|
||||
"reservation_term": "reserva",
|
||||
"ask_party_first": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _format_open_days(open_days: list[int]) -> str:
|
||||
return ", ".join(DAYS_ES[d] for d in sorted(open_days))
|
||||
@ -14,16 +93,50 @@ def _format_slots(availability: DayAvailability | None) -> str:
|
||||
return "No hay disponibilidad para esa fecha."
|
||||
available = [s for s in availability.slots if s.available > 0]
|
||||
if not available:
|
||||
return "No quedan slots disponibles para esa fecha."
|
||||
return "No quedan lugares disponibles para esa fecha."
|
||||
return ", ".join(s.time_start.strftime("%H:%M") for s in available)
|
||||
|
||||
|
||||
def _format_table_types(table_types: list[TableType]) -> str:
|
||||
lines = []
|
||||
for t in sorted(table_types, key=lambda x: x.capacity):
|
||||
label = f" ({t.label})" if t.label else ""
|
||||
lines.append(f" - {t.quantity} mesa(s) para {t.capacity} persona(s){label}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_services(services: list[Service]) -> str:
|
||||
if not services:
|
||||
return ""
|
||||
lines = []
|
||||
for s in services:
|
||||
if not s.is_active:
|
||||
continue
|
||||
parts = [f"• {s.name}"]
|
||||
if s.description:
|
||||
parts.append(f": {s.description}")
|
||||
extras = []
|
||||
if s.price is not None:
|
||||
extras.append(f"${float(s.price):,.0f}")
|
||||
if s.duration_minutes:
|
||||
extras.append(f"{s.duration_minutes} min")
|
||||
if extras:
|
||||
parts.append(f" ({', '.join(extras)})")
|
||||
lines.append("".join(parts))
|
||||
return "\n".join(lines) if lines else ""
|
||||
|
||||
|
||||
def build_system_prompt(
|
||||
business: Business,
|
||||
config: BusinessConfig,
|
||||
availability: DayAvailability | None,
|
||||
context: ConversationContext,
|
||||
table_types: list[TableType] | None = None,
|
||||
services: list[Service] | None = None,
|
||||
) -> str:
|
||||
btype = business.type or "other"
|
||||
type_ctx = BUSINESS_TYPE_CONTEXT.get(btype, BUSINESS_TYPE_CONTEXT["other"])
|
||||
|
||||
tone_instruction = (
|
||||
"Usa un tono formal y profesional."
|
||||
if config.tone == "formal"
|
||||
@ -38,29 +151,53 @@ def build_system_prompt(
|
||||
f"- Personas: {collected.party_size or 'pendiente'}",
|
||||
])
|
||||
|
||||
slots_info = _format_slots(availability)
|
||||
# Bloque de mesas (solo restaurantes con mesas configuradas)
|
||||
tables_block = ""
|
||||
if table_types:
|
||||
tables_block = f"""
|
||||
CONFIGURACIÓN DE MESAS:
|
||||
{_format_table_types(table_types)}
|
||||
Los horarios disponibles ya están filtrados según el tamaño del grupo indicado.
|
||||
Si no hay mesas disponibles para ese grupo, informa amablemente y sugiere otros horarios o fechas.
|
||||
"""
|
||||
|
||||
# Bloque de servicios
|
||||
services_block = ""
|
||||
if services:
|
||||
formatted = _format_services(services)
|
||||
if formatted:
|
||||
services_block = f"""
|
||||
SERVICIOS DISPONIBLES:
|
||||
{formatted}
|
||||
Puedes informar sobre estos servicios si el cliente pregunta. No es obligatorio elegir uno para reservar a menos que sea necesario para determinar la duración.
|
||||
"""
|
||||
|
||||
# Indicación de preguntar comensales primero (restaurantes)
|
||||
party_first_hint = ""
|
||||
if type_ctx["ask_party_first"] and not collected.party_size:
|
||||
party_first_hint = f"\nIMPORTANTE: Pregunta primero cuántos {type_ctx['party_term']} serán antes de mostrar disponibilidad.\n"
|
||||
|
||||
return f"""Eres {config.assistant_name}, asistente virtual de {business.name}.
|
||||
{type_ctx['description']}
|
||||
{tone_instruction}
|
||||
Responde SIEMPRE en el idioma del cliente.
|
||||
|
||||
{party_first_hint}{tables_block}{services_block}
|
||||
HORARIO DEL NEGOCIO:
|
||||
- Días de atención: {_format_open_days(config.open_days or [])}
|
||||
- Horario: {config.open_time.strftime("%H:%M")} a {config.close_time.strftime("%H:%M")}
|
||||
- Duración de cada turno: {config.slot_duration} minutos
|
||||
- Capacidad por turno: {config.max_per_slot} persona(s)
|
||||
|
||||
SLOTS DISPONIBLES PARA LA FECHA SOLICITADA: {slots_info}
|
||||
SLOTS DISPONIBLES: {_format_slots(availability)}
|
||||
|
||||
DATOS YA RECOPILADOS DEL CLIENTE:
|
||||
{collected_summary}
|
||||
|
||||
OBJETIVO: Recopilar nombre, fecha, hora y número de personas para crear la reserva.
|
||||
Si ya tienes todos los datos, acción = "create_reservation".
|
||||
Si el cliente quiere cancelar, acción = "cancel".
|
||||
OBJETIVO: Recopilar nombre, fecha, hora y número de {type_ctx['party_term']} para crear la {type_ctx['reservation_term']}.
|
||||
Si ya tienes todos los datos confirmados por el cliente, acción = "create_reservation".
|
||||
Si el cliente quiere cancelar o no desea continuar, acción = "cancel".
|
||||
En cualquier otro caso, acción = "collect_more".
|
||||
|
||||
Responde ÚNICAMENTE con JSON válido siguiendo este esquema exacto (sin markdown, sin explicaciones):
|
||||
Responde ÚNICAMENTE con JSON válido (sin markdown, sin explicaciones):
|
||||
{{
|
||||
"message": "<texto para enviar al cliente>",
|
||||
"action": "collect_more" | "create_reservation" | "cancel",
|
||||
|
||||
@ -10,8 +10,8 @@ from app.core.config import settings
|
||||
from app.modules.bot_engine.prompt import build_system_prompt
|
||||
from app.modules.bot_engine.schemas import BotResponse, CollectedData, ConversationContext
|
||||
from app.modules.business.models import Business
|
||||
from app.modules.business.service import get_business_config
|
||||
from app.modules.calendar.service import get_available_slots
|
||||
from app.modules.business.service import get_business_config, list_services, list_table_types
|
||||
from app.modules.calendar.service import get_available_slots, get_available_slots_for_party
|
||||
from app.modules.reservations.schemas import ReservationCreate
|
||||
from app.modules.reservations.service import create_reservation
|
||||
from app.modules.whatsapp.client import send_text_message
|
||||
@ -106,17 +106,27 @@ async def process_message(
|
||||
try:
|
||||
context = await _load_context(redis, business.id, phone)
|
||||
config = await get_business_config(db, business.id)
|
||||
table_types = await list_table_types(db, business.id)
|
||||
services = await list_services(db, business.id)
|
||||
|
||||
availability = None
|
||||
if context.collected_data.date:
|
||||
try:
|
||||
availability = await get_available_slots(
|
||||
db, redis, business.id, date.fromisoformat(context.collected_data.date)
|
||||
)
|
||||
party_size = context.collected_data.party_size or 1
|
||||
if table_types:
|
||||
availability = await get_available_slots_for_party(
|
||||
db, redis, business.id,
|
||||
date.fromisoformat(context.collected_data.date),
|
||||
party_size,
|
||||
)
|
||||
else:
|
||||
availability = await get_available_slots(
|
||||
db, redis, business.id, date.fromisoformat(context.collected_data.date)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
system_prompt = build_system_prompt(business, config, availability, context)
|
||||
system_prompt = build_system_prompt(business, config, availability, context, table_types, services)
|
||||
|
||||
context.messages.append({"role": "user", "content": text})
|
||||
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
from datetime import date, time
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Column,
|
||||
Date,
|
||||
Enum,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
Numeric,
|
||||
String,
|
||||
Time,
|
||||
func,
|
||||
@ -41,6 +43,8 @@ class Business(Base):
|
||||
users = relationship("User", back_populates="business", cascade="all, delete-orphan")
|
||||
config = relationship("BusinessConfig", back_populates="business", uselist=False)
|
||||
reservations = relationship("Reservation", back_populates="business")
|
||||
table_types = relationship("TableType", back_populates="business", cascade="all, delete-orphan")
|
||||
services = relationship("Service", back_populates="business", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class BusinessConfig(Base):
|
||||
@ -63,3 +67,33 @@ class BusinessConfig(Base):
|
||||
welcome_message = Column(String, nullable=True)
|
||||
|
||||
business = relationship("Business", back_populates="config")
|
||||
|
||||
|
||||
class TableType(Base):
|
||||
__tablename__ = "table_types"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
business_id = Column(
|
||||
Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
capacity = Column(Integer, nullable=False)
|
||||
quantity = Column(Integer, nullable=False)
|
||||
label = Column(String, nullable=True)
|
||||
|
||||
business = relationship("Business", back_populates="table_types")
|
||||
|
||||
|
||||
class Service(Base):
|
||||
__tablename__ = "services"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
business_id = Column(
|
||||
Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
name = Column(String, nullable=False)
|
||||
description = Column(String, nullable=True)
|
||||
price = Column(Numeric(10, 2), nullable=True)
|
||||
duration_minutes = Column(Integer, nullable=True)
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
|
||||
business = relationship("Business", back_populates="services")
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, Response, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
@ -40,3 +40,77 @@ async def update_my_config(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await service.update_business_config(db, business_id, body)
|
||||
|
||||
|
||||
@router.get("/me/tables", response_model=list[schemas.TableTypeRead])
|
||||
async def list_tables(
|
||||
business_id: int = Depends(get_current_business),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await service.list_table_types(db, business_id)
|
||||
|
||||
|
||||
@router.post("/me/tables", response_model=schemas.TableTypeRead, status_code=201)
|
||||
async def create_table(
|
||||
body: schemas.TableTypeCreate,
|
||||
business_id: int = Depends(get_current_business),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await service.create_table_type(db, business_id, body)
|
||||
|
||||
|
||||
@router.put("/me/tables/{table_id}", response_model=schemas.TableTypeRead)
|
||||
async def update_table(
|
||||
table_id: int,
|
||||
body: schemas.TableTypeUpdate,
|
||||
business_id: int = Depends(get_current_business),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await service.update_table_type(db, business_id, table_id, body)
|
||||
|
||||
|
||||
@router.delete("/me/tables/{table_id}", status_code=204)
|
||||
async def delete_table(
|
||||
table_id: int,
|
||||
business_id: int = Depends(get_current_business),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await service.delete_table_type(db, business_id, table_id)
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.get("/me/services", response_model=list[schemas.ServiceRead])
|
||||
async def list_services(
|
||||
business_id: int = Depends(get_current_business),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await service.list_services(db, business_id)
|
||||
|
||||
|
||||
@router.post("/me/services", response_model=schemas.ServiceRead, status_code=201)
|
||||
async def create_service(
|
||||
body: schemas.ServiceCreate,
|
||||
business_id: int = Depends(get_current_business),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await service.create_service(db, business_id, body)
|
||||
|
||||
|
||||
@router.put("/me/services/{service_id}", response_model=schemas.ServiceRead)
|
||||
async def update_service(
|
||||
service_id: int,
|
||||
body: schemas.ServiceUpdate,
|
||||
business_id: int = Depends(get_current_business),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await service.update_service(db, business_id, service_id, body)
|
||||
|
||||
|
||||
@router.delete("/me/services/{service_id}", status_code=204)
|
||||
async def delete_service(
|
||||
service_id: int,
|
||||
business_id: int = Depends(get_current_business),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await service.delete_service(db, business_id, service_id)
|
||||
return Response(status_code=204)
|
||||
|
||||
@ -46,3 +46,50 @@ class BusinessConfigUpdate(BaseModel):
|
||||
assistant_name: str | None = None
|
||||
tone: str | None = None
|
||||
welcome_message: str | None = None
|
||||
|
||||
|
||||
class TableTypeCreate(BaseModel):
|
||||
capacity: int
|
||||
quantity: int
|
||||
label: str | None = None
|
||||
|
||||
|
||||
class TableTypeUpdate(BaseModel):
|
||||
capacity: int | None = None
|
||||
quantity: int | None = None
|
||||
label: str | None = None
|
||||
|
||||
|
||||
class TableTypeRead(BaseModel):
|
||||
id: int
|
||||
capacity: int
|
||||
quantity: int
|
||||
label: str | None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ServiceCreate(BaseModel):
|
||||
name: str
|
||||
description: str | None = None
|
||||
price: float | None = None
|
||||
duration_minutes: int | None = None
|
||||
|
||||
|
||||
class ServiceUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
price: float | None = None
|
||||
duration_minutes: int | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class ServiceRead(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
description: str | None
|
||||
price: float | None
|
||||
duration_minutes: int | None
|
||||
is_active: bool
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@ -2,8 +2,15 @@ from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.modules.business.models import Business, BusinessConfig
|
||||
from app.modules.business.schemas import BusinessConfigUpdate, BusinessUpdate
|
||||
from app.modules.business.models import Business, BusinessConfig, Service, TableType
|
||||
from app.modules.business.schemas import (
|
||||
BusinessConfigUpdate,
|
||||
BusinessUpdate,
|
||||
ServiceCreate,
|
||||
ServiceUpdate,
|
||||
TableTypeCreate,
|
||||
TableTypeUpdate,
|
||||
)
|
||||
|
||||
|
||||
async def get_business(db: AsyncSession, business_id: int) -> Business:
|
||||
@ -47,3 +54,91 @@ async def update_business_config(
|
||||
await db.commit()
|
||||
await db.refresh(config)
|
||||
return config
|
||||
|
||||
|
||||
async def list_table_types(db: AsyncSession, business_id: int) -> list[TableType]:
|
||||
result = await db.execute(
|
||||
select(TableType)
|
||||
.where(TableType.business_id == business_id)
|
||||
.order_by(TableType.capacity)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def create_table_type(db: AsyncSession, business_id: int, data: TableTypeCreate) -> TableType:
|
||||
table = TableType(business_id=business_id, **data.model_dump())
|
||||
db.add(table)
|
||||
await db.commit()
|
||||
await db.refresh(table)
|
||||
return table
|
||||
|
||||
|
||||
async def update_table_type(
|
||||
db: AsyncSession, business_id: int, table_id: int, data: TableTypeUpdate
|
||||
) -> TableType:
|
||||
result = await db.execute(
|
||||
select(TableType).where(TableType.id == table_id, TableType.business_id == business_id)
|
||||
)
|
||||
table = result.scalar_one_or_none()
|
||||
if not table:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tipo de mesa no encontrado")
|
||||
for field, value in data.model_dump(exclude_none=True).items():
|
||||
setattr(table, field, value)
|
||||
await db.commit()
|
||||
await db.refresh(table)
|
||||
return table
|
||||
|
||||
|
||||
async def delete_table_type(db: AsyncSession, business_id: int, table_id: int) -> None:
|
||||
result = await db.execute(
|
||||
select(TableType).where(TableType.id == table_id, TableType.business_id == business_id)
|
||||
)
|
||||
table = result.scalar_one_or_none()
|
||||
if not table:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tipo de mesa no encontrado")
|
||||
await db.delete(table)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def list_services(db: AsyncSession, business_id: int) -> list[Service]:
|
||||
result = await db.execute(
|
||||
select(Service)
|
||||
.where(Service.business_id == business_id)
|
||||
.order_by(Service.name)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def create_service(db: AsyncSession, business_id: int, data: ServiceCreate) -> Service:
|
||||
service = Service(business_id=business_id, **data.model_dump())
|
||||
db.add(service)
|
||||
await db.commit()
|
||||
await db.refresh(service)
|
||||
return service
|
||||
|
||||
|
||||
async def update_service(
|
||||
db: AsyncSession, business_id: int, service_id: int, data: ServiceUpdate
|
||||
) -> Service:
|
||||
result = await db.execute(
|
||||
select(Service).where(Service.id == service_id, Service.business_id == business_id)
|
||||
)
|
||||
service = result.scalar_one_or_none()
|
||||
if not service:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Servicio no encontrado")
|
||||
for field, value in data.model_dump(exclude_none=True).items():
|
||||
setattr(service, field, value)
|
||||
await db.commit()
|
||||
await db.refresh(service)
|
||||
return service
|
||||
|
||||
|
||||
async def delete_service(db: AsyncSession, business_id: int, service_id: int) -> None:
|
||||
result = await db.execute(
|
||||
select(Service).where(Service.id == service_id, Service.business_id == business_id)
|
||||
)
|
||||
service = result.scalar_one_or_none()
|
||||
if not service:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Servicio no encontrado")
|
||||
await db.delete(service)
|
||||
await db.commit()
|
||||
|
||||
@ -6,8 +6,8 @@ from fastapi import HTTPException, status
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.modules.business.models import BusinessConfig
|
||||
from app.modules.business.service import get_business_config
|
||||
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
|
||||
|
||||
@ -18,6 +18,10 @@ def _cache_key(business_id: int, target_date: date) -> str:
|
||||
return f"slots:{business_id}:{target_date.isoformat()}"
|
||||
|
||||
|
||||
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()
|
||||
@ -51,19 +55,46 @@ async def _count_reservations_per_slot(
|
||||
return {slot: counts.get(slot[0], 0) for slot in slots}
|
||||
|
||||
|
||||
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:
|
||||
"""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)
|
||||
|
||||
is_open = (
|
||||
target_date.weekday() in (config.open_days or [])
|
||||
and target_date not in (config.blocked_dates or [])
|
||||
@ -75,23 +106,119 @@ async def get_available_slots(
|
||||
return result
|
||||
|
||||
raw_slots = _generate_slots(config.open_time, config.close_time, config.slot_duration)
|
||||
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
|
||||
]
|
||||
# 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)
|
||||
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
|
||||
|
||||
|
||||
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,
|
||||
@ -113,7 +240,13 @@ async def get_availability_range(
|
||||
|
||||
|
||||
async def invalidate_slots_cache(redis: aioredis.Redis, business_id: int, target_date: date) -> None:
|
||||
# Invalida cache genérico y cualquier cache por party_size (patrón)
|
||||
await redis.delete(_cache_key(business_id, target_date))
|
||||
# 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:
|
||||
|
||||
@ -26,6 +26,8 @@ class Reservation(Base):
|
||||
default="manual",
|
||||
)
|
||||
notes = Column(String, nullable=True)
|
||||
table_type_id = Column(Integer, ForeignKey("table_types.id", ondelete="SET NULL"), nullable=True)
|
||||
created_at = Column(Date, server_default=func.current_date())
|
||||
|
||||
business = relationship("Business", back_populates="reservations")
|
||||
table_type = relationship("TableType")
|
||||
|
||||
@ -5,8 +5,10 @@ from fastapi import HTTPException, status
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.modules.business.service import get_business_config
|
||||
from app.modules.calendar.service import invalidate_slots_cache
|
||||
from sqlalchemy import func
|
||||
|
||||
from app.modules.business.service import get_business_config, list_table_types
|
||||
from app.modules.calendar.service import find_best_table_for_party, invalidate_slots_cache
|
||||
from app.modules.reservations.models import Reservation
|
||||
from app.modules.reservations.schemas import ReservationCreate, ReservationUpdate
|
||||
|
||||
@ -55,6 +57,34 @@ async def create_reservation(
|
||||
config = await get_business_config(db, business_id)
|
||||
time_end = _compute_time_end(data.time_start, config.slot_duration)
|
||||
|
||||
# Asignar mesa automáticamente si el negocio tiene tipos de mesa configurados
|
||||
table_type_id = None
|
||||
table_types = await list_table_types(db, business_id)
|
||||
if table_types:
|
||||
# Contar reservas por table_type en ese slot
|
||||
result = await db.execute(
|
||||
select(Reservation.table_type_id, func.count(Reservation.id))
|
||||
.where(
|
||||
and_(
|
||||
Reservation.business_id == business_id,
|
||||
Reservation.date == data.date,
|
||||
Reservation.time_start == data.time_start,
|
||||
Reservation.status.in_(["pending", "confirmed"]),
|
||||
Reservation.table_type_id.isnot(None),
|
||||
)
|
||||
)
|
||||
.group_by(Reservation.table_type_id)
|
||||
)
|
||||
slot_table_counts = {row[0]: row[1] for row in result.all()}
|
||||
|
||||
best_table = await find_best_table_for_party(table_types, slot_table_counts, data.party_size)
|
||||
if best_table is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="No hay mesas disponibles para ese número de personas en ese horario.",
|
||||
)
|
||||
table_type_id = best_table.id
|
||||
|
||||
reservation = Reservation(
|
||||
business_id=business_id,
|
||||
client_name=data.client_name,
|
||||
@ -66,6 +96,7 @@ async def create_reservation(
|
||||
source=data.source,
|
||||
notes=data.notes,
|
||||
status="pending",
|
||||
table_type_id=table_type_id,
|
||||
)
|
||||
db.add(reservation)
|
||||
await db.commit()
|
||||
|
||||
Reference in New Issue
Block a user