from typing import Optional, List, Dict, Any from datetime import datetime from app.models.reservation import Reservation, ReservationStatus from app.models.classroom import Classroom from app.models.subject import Commission, CommissionTeacher from app.repositories.reservation_repository import ReservationRepository from app.repositories.classroom_repository import ClassroomRepository from app.schemas.reservation_dto import ReservationCreateDTO, ReservationUpdateDTO from app import db class ReservationService: """Capa de servicios para la orquestación y validación de reservas y cronograma.""" def __init__(self, reservation_repo: Optional[ReservationRepository] = None, classroom_repo: Optional[ClassroomRepository] = None): self.reservation_repo = reservation_repo or ReservationRepository() self.classroom_repo = classroom_repo or ClassroomRepository() def get_reservation(self, reservation_id: int) -> Optional[Reservation]: """Obtiene una reserva por su ID.""" return self.reservation_repo.get_by_id(reservation_id) def list_reservations(self, start_dt: datetime, end_dt: datetime, classroom_id: Optional[int] = None) -> List[Reservation]: """Obtiene reservas dentro de un rango temporal.""" return self.reservation_repo.get_by_date_range(start_dt, end_dt, classroom_id=classroom_id) def check_conflicts(self, classroom_id: int, start_time: datetime, end_time: datetime, exclude_id: Optional[int] = None) -> List[Reservation]: """Verifica si existen solapamientos en un aula específica.""" classroom = self.classroom_repo.get_by_id(classroom_id) # Las aulas virtuales tienen concurrencia ilimitada (sin colisiones) if classroom and classroom.is_virtual: return [] return self.reservation_repo.find_conflicts( classroom_id=classroom_id, start_time=start_time, end_time=end_time, exclude_reservation_id=exclude_id ) def check_teacher_conflicts(self, teacher_id: int, start_time: datetime, end_time: datetime, exclude_id: Optional[int] = None) -> List[Reservation]: """Verifica si el docente ya tiene otra reserva en la misma franja horaria.""" if not teacher_id: return [] return self.reservation_repo.find_teacher_conflicts( teacher_id=teacher_id, start_time=start_time, end_time=end_time, exclude_reservation_id=exclude_id ) def check_full_conflicts_matrix(self, classroom_id: int, start_time: datetime, end_time: datetime, teacher_id: Optional[int] = None, commission_id: Optional[int] = None, expected_attendees: Optional[int] = None, allow_co_teaching_exception: bool = False, exclude_id: Optional[int] = None) -> Dict[str, Any]: """ Matriz completa de validación de conflictos (Fase 2 MVP): 1. Conflicto físico de aula (bloqueo estricto para aulas físicas). 2. Conflicto docente (detección con soporte de co-docencia). 3. Conflicto de aforo (capacidad física). """ classroom = self.classroom_repo.get_by_id(classroom_id) if not classroom or not classroom.is_active: raise ValueError(f"El aula con ID {classroom_id} no existe o no se encuentra activa.") commission = None if commission_id: commission = Commission.query.get(commission_id) if not teacher_id and commission: teacher_id = commission.teacher_id # 1. Conflicto Físico physical_conflicts = [] if not classroom.is_virtual: physical_conflicts = self.check_conflicts(classroom_id, start_time, end_time, exclude_id=exclude_id) # 2. Conflicto Docente y Co-docencia teacher_conflicts = [] has_co_teaching_coverage = False co_teachers_list = [] if teacher_id: teacher_conflicts = self.check_teacher_conflicts(teacher_id, start_time, end_time, exclude_id=exclude_id) if teacher_conflicts and commission: # Comprobar si la comisión cuenta con equipo docente de respaldo co_teachers = CommissionTeacher.query.filter_by(commission_id=commission.id).all() other_teachers = [ct for ct in co_teachers if ct.user_id != teacher_id] if other_teachers: has_co_teaching_coverage = True co_teachers_list = [ {'id': ct.user_id, 'name': ct.teacher.name if ct.teacher else 'Docente', 'role': ct.role} for ct in other_teachers ] # 3. Conflicto de Aforo has_capacity_conflict = False capacity_message = None if not classroom.is_virtual and expected_attendees is not None: if expected_attendees > classroom.capacity: has_capacity_conflict = True capacity_message = f"La cantidad esperada ({expected_attendees}) supera la capacidad del aula ({classroom.capacity})." # Evaluación de Bloqueos block_reasons = [] if physical_conflicts: c_strs = [f"#{c.id} ({c.start_time.strftime('%H:%M')} a {c.end_time.strftime('%H:%M')})" for c in physical_conflicts] block_reasons.append(f"Conflicto de horario en aula {classroom.code}: se solapa con las reservas {', '.join(c_strs)}.") if has_capacity_conflict: block_reasons.append(f"Conflicto de aforo: {capacity_message}") if teacher_conflicts: if has_co_teaching_coverage or allow_co_teaching_exception: # Cobertura justificada por equipo de co-docencia pass else: t_strs = [f"#{c.id} ({c.start_time.strftime('%H:%M')} a {c.end_time.strftime('%H:%M')})" for c in teacher_conflicts] block_reasons.append(f"Conflicto de horario docente: el profesor ya tiene clases asignadas en {', '.join(t_strs)} y la comisión no cuenta con equipo de co-docencia.") is_blocked = len(block_reasons) > 0 return { 'is_valid': not is_blocked, 'is_blocked': is_blocked, 'block_reasons': block_reasons, 'physical_conflicts': [c.to_dict() for c in physical_conflicts], 'teacher_conflicts': [c.to_dict() for c in teacher_conflicts], 'has_co_teaching_coverage': has_co_teaching_coverage, 'co_teachers': co_teachers_list, 'has_capacity_conflict': has_capacity_conflict, 'classroom': classroom.to_dict() } def create_reservation(self, dto: ReservationCreateDTO) -> Reservation: """ Crea una nueva reserva aplicando la matriz integral de conflictos. """ matrix = self.check_full_conflicts_matrix( classroom_id=dto.classroom_id, start_time=dto.start_time, end_time=dto.end_time, teacher_id=dto.user_id, commission_id=dto.commission_id, expected_attendees=dto.expected_attendees, allow_co_teaching_exception=getattr(dto, 'allow_co_teaching_exception', False) ) if matrix['is_blocked']: raise ValueError(" | ".join(matrix['block_reasons'])) reservation = Reservation( classroom_id=dto.classroom_id, commission_id=dto.commission_id, user_id=dto.user_id, start_time=dto.start_time, end_time=dto.end_time, purpose=dto.purpose, expected_attendees=dto.expected_attendees, shift=dto.shift, virtual_link=dto.virtual_link, notes=dto.notes, status=dto.status or 'CONFIRMED' ) return self.reservation_repo.save(reservation) def update_reservation(self, reservation_id: int, dto: ReservationUpdateDTO) -> Reservation: """Actualiza una reserva existente verificando disponibilidad horaria y matriz de conflictos.""" reservation = self.reservation_repo.get_by_id(reservation_id) if not reservation: raise ValueError(f"Reserva con ID {reservation_id} no encontrada.") classroom_id = dto.classroom_id or reservation.classroom_id start_time = dto.start_time or reservation.start_time end_time = dto.end_time or reservation.end_time expected_attendees = dto.expected_attendees if dto.expected_attendees is not None else reservation.expected_attendees matrix = self.check_full_conflicts_matrix( classroom_id=classroom_id, start_time=start_time, end_time=end_time, teacher_id=reservation.user_id, commission_id=reservation.commission_id, expected_attendees=expected_attendees, allow_co_teaching_exception=getattr(dto, 'allow_co_teaching_exception', False), exclude_id=reservation.id ) if matrix['is_blocked']: raise ValueError(" | ".join(matrix['block_reasons'])) if dto.classroom_id is not None: reservation.classroom_id = dto.classroom_id if dto.start_time is not None: reservation.start_time = dto.start_time if dto.end_time is not None: reservation.end_time = dto.end_time if dto.purpose is not None: reservation.purpose = dto.purpose if dto.expected_attendees is not None: reservation.expected_attendees = dto.expected_attendees if dto.shift is not None: reservation.shift = dto.shift if dto.virtual_link is not None: reservation.virtual_link = dto.virtual_link if dto.notes is not None: reservation.notes = dto.notes if dto.status is not None: reservation.status = dto.status return self.reservation_repo.save(reservation) def cancel_reservation(self, reservation_id: int) -> bool: """Cancela una reserva estableciendo su estado en CANCELLED.""" reservation = self.reservation_repo.get_by_id(reservation_id) if not reservation: return False reservation.status = ReservationStatus.CANCELLED.value self.reservation_repo.save(reservation) return True def confirm_reservation(self, reservation_id: int) -> bool: """Confirma una reserva estableciendo su estado en CONFIRMED.""" reservation = self.reservation_repo.get_by_id(reservation_id) if not reservation: return False reservation.status = ReservationStatus.CONFIRMED.value self.reservation_repo.save(reservation) return True