""" Package Auto-Scheduling Service. This service automatically schedules all sessions when a package is purchased, respecting session order and therapist availability. """ from datetime import datetime, timedelta, time from django.db import transaction from django.utils import timezone from django.utils.translation import gettext_lazy as _ from finance.models import Package, PackagePurchase, PackageService from appointments.models import Appointment, Provider, Room from appointments.room_conflict_service import RoomAvailabilityService from core.models import Patient, Clinic class PackageSchedulingError(Exception): """Raised when package scheduling fails.""" pass class PackageSchedulingService: """ Service for automatically scheduling all sessions in a package. Respects session order, therapist availability, and room conflicts. """ @staticmethod def auto_schedule_package(package_purchase, start_date=None, preferred_time=None, preferred_provider=None): """ Automatically schedule all sessions for a package purchase. Args: package_purchase: PackagePurchase instance start_date: Optional start date (defaults to tomorrow) preferred_time: Optional preferred time (defaults to 9:00 AM) preferred_provider: Optional preferred provider Returns: list: Created appointments Raises: PackageSchedulingError: If scheduling fails """ from datetime import date if not start_date: start_date = date.today() + timedelta(days=1) if not preferred_time: preferred_time = time(9, 0) # 9:00 AM # Get package services ordered by session_order package_services = package_purchase.package.packageservice_set.all().order_by('session_order') if not package_services.exists(): raise PackageSchedulingError("Package has no services defined") created_appointments = [] current_date = start_date current_time = preferred_time with transaction.atomic(): for package_service in package_services: service = package_service.service clinic = service.clinic # Create appointments for each session for session_num in range(package_service.sessions): # Find available provider provider = preferred_provider if not provider: provider = PackageSchedulingService._find_available_provider( clinic, current_date, current_time, service.duration_minutes ) if not provider: raise PackageSchedulingError( f"No available provider found for {clinic.name_en} on {current_date}" ) # Find available room room = PackageSchedulingService._find_available_room( clinic, current_date, current_time, service.duration_minutes, package_purchase.patient.tenant ) # Create appointment appointment = Appointment.objects.create( patient=package_purchase.patient, clinic=clinic, provider=provider, room=room, service_type=service.name_en, scheduled_date=current_date, scheduled_time=current_time, duration=service.duration_minutes, tenant=package_purchase.patient.tenant, status=Appointment.Status.BOOKED ) created_appointments.append(appointment) # Move to next available slot current_date, current_time = PackageSchedulingService._get_next_slot( current_date, current_time, service.duration_minutes ) return created_appointments @staticmethod def _find_available_provider(clinic, date, time, duration): """Find an available provider for the clinic at the specified time.""" providers = Provider.objects.filter( specialties=clinic, is_available=True ) for provider in providers: # Check if provider has conflicting appointments conflicts = Appointment.objects.filter( provider=provider, scheduled_date=date, scheduled_time=time, status__in=[ Appointment.Status.BOOKED, Appointment.Status.CONFIRMED, Appointment.Status.ARRIVED, Appointment.Status.IN_PROGRESS, ] ) if not conflicts.exists(): return provider return None @staticmethod def _find_available_room(clinic, date, time, duration, tenant): """Find an available room for the clinic at the specified time.""" available_rooms = RoomAvailabilityService.get_available_rooms( clinic, date, time, duration, tenant ) return available_rooms.first() if available_rooms.exists() else None @staticmethod def _get_next_slot(current_date, current_time, duration): """ Get the next available time slot. Args: current_date: Current date current_time: Current time duration: Duration in minutes Returns: tuple: (next_date, next_time) """ # Add duration + 30 min buffer current_datetime = datetime.combine(current_date, current_time) next_datetime = current_datetime + timedelta(minutes=duration + 30) # If past 5 PM, move to next day at 9 AM if next_datetime.time() >= time(17, 0): next_date = current_date + timedelta(days=1) # Skip weekends (Friday, Saturday in Saudi Arabia) while next_date.weekday() in [4, 5]: next_date += timedelta(days=1) next_time = time(9, 0) else: next_date = next_datetime.date() next_time = next_datetime.time() return next_date, next_time @staticmethod def reschedule_package_session(appointment, new_date, new_time, reason=""): """ Reschedule a package session and update package tracking. Args: appointment: Appointment instance new_date: New date new_time: New time reason: Reason for rescheduling Returns: Appointment: Updated appointment """ # Validate room availability at new time if appointment.room: RoomAvailabilityService.validate_room_availability( appointment.room, new_date, new_time, appointment.duration, exclude_appointment=appointment ) # Update appointment appointment.scheduled_date = new_date appointment.scheduled_time = new_time appointment.reschedule_reason = reason appointment.reschedule_count += 1 appointment.status = Appointment.Status.RESCHEDULED appointment.save() return appointment @staticmethod def cancel_package_session_with_credit(appointment, reason="", cancelled_by=None): """ Cancel a package session and restore credit to package. Args: appointment: Appointment instance reason: Cancellation reason cancelled_by: User who cancelled Returns: bool: True if credit restored """ # Find associated package purchase # This would need to be linked via invoice or other means # For now, we'll mark the appointment as cancelled appointment.status = Appointment.Status.CANCELLED appointment.cancel_reason = reason appointment.cancelled_by = cancelled_by appointment.save() # TODO: Restore session credit to package # This requires linking appointments to package purchases # For now, return True to indicate cancellation successful return True @staticmethod def get_package_schedule_summary(package_purchase): """ Get a summary of all scheduled sessions for a package. Args: package_purchase: PackagePurchase instance Returns: dict: Schedule summary """ # This would require linking appointments to package purchases # For now, return basic info return { 'package': package_purchase.package.name_en, 'patient': str(package_purchase.patient), 'total_sessions': package_purchase.total_sessions, 'sessions_used': package_purchase.sessions_used, 'sessions_remaining': package_purchase.sessions_remaining, 'purchase_date': package_purchase.purchase_date, 'expiry_date': package_purchase.expiry_date, 'status': package_purchase.get_status_display(), }