384 lines
12 KiB
Python
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
|