""" Smart Scheduling Engine for Appointments App. This module implements intelligent appointment scheduling with multi-factor optimization. """ from datetime import datetime, timedelta from typing import List, Dict, Optional from django.db.models import Q from django.utils import timezone class SmartScheduler: """ Intelligent appointment scheduling engine with multi-factor optimization. Uses weighted scoring across multiple factors: - Provider availability (30%) - Patient priority (25%) - No-show risk (20%) - Geographic proximity (15%) - Patient preference (10%) """ def __init__(self, tenant): self.tenant = tenant self.weights = { 'provider_availability': 0.30, 'patient_priority': 0.25, 'no_show_risk': 0.20, 'geographic_proximity': 0.15, 'patient_preference': 0.10 } def find_optimal_slots( self, patient, provider, appointment_type, preferred_dates: List[datetime], duration_minutes: int = 30, max_results: int = 10 ) -> List[Dict]: """ Find optimal appointment slots using multi-factor analysis. Args: patient: PatientProfile instance provider: User instance (healthcare provider) appointment_type: AppointmentTemplate instance preferred_dates: List of preferred datetime objects duration_minutes: Appointment duration in minutes max_results: Maximum number of results to return Returns: List of slot dictionaries with scores and metadata """ # Step 1: Get base available slots base_slots = self._get_available_slots( provider, preferred_dates, duration_minutes ) if not base_slots: return [] # Step 2: Score each slot scored_slots = [] for slot in base_slots: score = self._calculate_slot_score( slot, patient, provider, appointment_type ) scored_slots.append({ 'datetime': slot['start'], 'end_datetime': slot['end'], 'provider': provider, 'score': score, 'factors': score['breakdown'] }) # Step 3: Sort by score and return top results scored_slots.sort(key=lambda x: x['score']['total'], reverse=True) return scored_slots[:max_results] def _get_available_slots( self, provider, date_range: List[datetime], duration_minutes: int ) -> List[Dict]: """Get all available time slots for provider.""" from appointments.models import SlotAvailability, AppointmentRequest slots = [] duration = timedelta(minutes=duration_minutes) for date in date_range: # Get provider's availability for this date availabilities = SlotAvailability.objects.filter( tenant=self.tenant, provider=provider, date=date.date(), is_active=True, is_blocked=False ) for availability in availabilities: # Calculate time slots within availability window current_time = datetime.combine( availability.date, availability.start_time ) end_time = datetime.combine( availability.date, availability.end_time ) while current_time + duration <= end_time: # Check if slot is already booked if not self._is_slot_booked(provider, current_time, duration): slots.append({ 'start': current_time, 'end': current_time + duration, 'availability_id': availability.id }) current_time += timedelta(minutes=availability.duration_minutes or 30) return slots def _is_slot_booked( self, provider, start_time: datetime, duration: timedelta ) -> bool: """Check if a time slot is already booked.""" from appointments.models import AppointmentRequest end_time = start_time + duration overlapping = AppointmentRequest.objects.filter( tenant=self.tenant, provider=provider, scheduled_datetime__lt=end_time, scheduled_end_datetime__gt=start_time, status__in=['PENDING', 'CONFIRMED', 'CHECKED_IN', 'IN_PROGRESS'] ).exists() return overlapping def _calculate_slot_score( self, slot: Dict, patient, provider, appointment_type ) -> Dict: """Calculate multi-factor score for a slot.""" from appointments.models import SchedulingPreference scores = {} # Factor 1: Provider availability (buffer time consideration) scores['provider_availability'] = self._score_provider_availability( provider, slot['start'] ) # Factor 2: Patient priority scores['patient_priority'] = self._score_patient_priority( patient, appointment_type ) # Factor 3: No-show risk mitigation scores['no_show_risk'] = self._score_no_show_risk( patient, slot['start'] ) # Factor 4: Geographic proximity scores['geographic_proximity'] = self._score_geographic_proximity( patient, slot['start'] ) # Factor 5: Patient preference alignment scores['patient_preference'] = self._score_patient_preference( patient, provider, slot['start'] ) # Calculate weighted total total_score = sum( scores[factor] * self.weights[factor] for factor in scores ) return { 'total': round(total_score, 2), 'breakdown': scores } def _score_provider_availability( self, provider, slot_time: datetime ) -> float: """Score based on provider's schedule density.""" from appointments.models import AppointmentRequest # Check appointments before and after this slot buffer_time = timedelta(minutes=15) appointments_nearby = AppointmentRequest.objects.filter( tenant=self.tenant, provider=provider, scheduled_datetime__gte=slot_time - buffer_time, scheduled_datetime__lte=slot_time + buffer_time, status__in=['CONFIRMED', 'CHECKED_IN', 'IN_PROGRESS'] ).count() # Prefer slots with some buffer time if appointments_nearby == 0: return 100.0 # Ideal - plenty of buffer elif appointments_nearby == 1: return 75.0 # Good - some buffer else: return 50.0 # Acceptable - tight schedule def _score_patient_priority( self, patient, appointment_type ) -> float: """Score based on clinical priority.""" from appointments.models import AppointmentPriorityRule # Check if there are priority rules for this appointment type rules = AppointmentPriorityRule.objects.filter( tenant=self.tenant, is_active=True, appointment_types=appointment_type ).first() if rules: return min(rules.base_priority_score, 100.0) return 50.0 # Default priority def _score_no_show_risk( self, patient, slot_time: datetime ) -> float: """Score based on predicted no-show probability.""" from appointments.models import SchedulingPreference try: prefs = SchedulingPreference.objects.get( tenant=self.tenant, patient=patient ) # Lower score for higher no-show risk no_show_rate = float(prefs.average_no_show_rate) return max(0, 100 - (no_show_rate * 100)) except SchedulingPreference.DoesNotExist: return 75.0 # Neutral score for new patients def _score_geographic_proximity( self, patient, slot_time: datetime ) -> float: """Score based on travel time and time of day.""" from appointments.models import SchedulingPreference try: prefs = SchedulingPreference.objects.get( tenant=self.tenant, patient=patient ) if not prefs.travel_time_to_clinic: return 50.0 # Prefer slots that allow comfortable travel time hour = slot_time.hour travel_minutes = prefs.travel_time_to_clinic.total_seconds() / 60 # Morning rush hour (7-9 AM) if 7 <= hour <= 9: penalty = travel_minutes * 0.5 # Evening rush hour (4-6 PM) elif 16 <= hour <= 18: penalty = travel_minutes * 0.5 else: penalty = 0 return max(0, 100 - penalty) except SchedulingPreference.DoesNotExist: return 50.0 def _score_patient_preference( self, patient, provider, slot_time: datetime ) -> float: """Score based on patient's historical preferences.""" from appointments.models import SchedulingPreference try: prefs = SchedulingPreference.objects.get( tenant=self.tenant, patient=patient ) score = 50.0 # Base score # Check day preference day_name = slot_time.strftime('%A') if day_name in prefs.preferred_days: score += 25.0 # Check time preference hour = slot_time.hour if hour < 12 and 'morning' in prefs.preferred_times: score += 15.0 elif 12 <= hour < 17 and 'afternoon' in prefs.preferred_times: score += 15.0 elif hour >= 17 and 'evening' in prefs.preferred_times: score += 15.0 # Check provider preference if provider in prefs.preferred_providers.all(): score += 10.0 return min(score, 100.0) except SchedulingPreference.DoesNotExist: return 50.0 def predict_no_show_probability(self, patient, slot_time: datetime) -> float: """ Predict probability of no-show using historical data. Args: patient: PatientProfile instance slot_time: Proposed appointment datetime Returns: Float between 0 and 1 representing no-show probability """ from appointments.models import SchedulingPreference try: prefs = SchedulingPreference.objects.get( tenant=self.tenant, patient=patient ) # Base probability from historical rate base_prob = float(prefs.average_no_show_rate) / 100 # Adjust based on time factors hour = slot_time.hour day_of_week = slot_time.weekday() # Early morning appointments have higher no-show rates if hour < 8: base_prob *= 1.3 # Monday appointments have higher no-show rates if day_of_week == 0: base_prob *= 1.2 # Friday afternoon appointments have higher no-show rates if day_of_week == 4 and hour >= 14: base_prob *= 1.15 return min(base_prob, 1.0) except SchedulingPreference.DoesNotExist: return 0.15 # Default 15% no-show rate for new patients