Marwan Alwali 263292f6be update
2025-11-04 00:50:06 +03:00

384 lines
12 KiB
Python

"""
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