agdar/appointments/room_conflict_service.py
Marwan Alwali 2f1681b18c update
2025-11-11 13:44:48 +03:00

380 lines
13 KiB
Python

"""
Room Conflict Detection Service.
This service prevents multiple therapists from booking the same room
at overlapping times, ensuring physical space availability.
"""
from datetime import datetime, timedelta
from django.db.models import Q
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from .models import Appointment, Room
class RoomConflictError(Exception):
"""Raised when a room conflict is detected."""
pass
class RoomAvailabilityService:
"""
Service for checking and managing room availability.
Prevents double-booking of physical spaces.
"""
@staticmethod
def check_room_availability(room, scheduled_date, scheduled_time, duration, exclude_appointment=None):
"""
Check if a room is available at the specified time.
Args:
room: Room instance
scheduled_date: Date of the appointment
scheduled_time: Time of the appointment
duration: Duration in minutes
exclude_appointment: Appointment to exclude from check (for rescheduling)
Returns:
tuple: (is_available: bool, conflicting_appointments: QuerySet)
Raises:
RoomConflictError: If room is not available
"""
if not room:
return True, Appointment.objects.none()
# Calculate end time
start_datetime = datetime.combine(scheduled_date, scheduled_time)
end_datetime = start_datetime + timedelta(minutes=duration)
# Find overlapping appointments in the same room
conflicting_appointments = Appointment.objects.filter(
room=room,
scheduled_date=scheduled_date,
status__in=[
Appointment.Status.BOOKED,
Appointment.Status.CONFIRMED,
Appointment.Status.ARRIVED,
Appointment.Status.IN_PROGRESS,
]
)
# Exclude current appointment if rescheduling
if exclude_appointment:
conflicting_appointments = conflicting_appointments.exclude(id=exclude_appointment.id)
# Check for time overlaps
conflicts = []
for apt in conflicting_appointments:
apt_start = datetime.combine(apt.scheduled_date, apt.scheduled_time)
apt_end = apt_start + timedelta(minutes=apt.duration)
# Check if times overlap
if (start_datetime < apt_end and end_datetime > apt_start):
conflicts.append(apt)
is_available = len(conflicts) == 0
return is_available, conflicts
@staticmethod
def validate_room_availability(room, scheduled_date, scheduled_time, duration, exclude_appointment=None):
"""
Validate room availability and raise exception if not available.
Args:
room: Room instance
scheduled_date: Date of the appointment
scheduled_time: Time of the appointment
duration: Duration in minutes
exclude_appointment: Appointment to exclude from check
Raises:
RoomConflictError: If room is not available
"""
is_available, conflicts = RoomAvailabilityService.check_room_availability(
room, scheduled_date, scheduled_time, duration, exclude_appointment
)
if not is_available:
conflict_details = []
for apt in conflicts:
conflict_details.append(
f"{apt.scheduled_time.strftime('%H:%M')} - "
f"{apt.provider.user.get_full_name()} - "
f"{apt.patient}"
)
raise RoomConflictError(
f"Room {room.room_number} is not available at {scheduled_time.strftime('%H:%M')} "
f"on {scheduled_date.strftime('%Y-%m-%d')}. "
f"Conflicting appointments: {', '.join(conflict_details)}"
)
@staticmethod
def get_available_rooms(clinic, scheduled_date, scheduled_time, duration, tenant):
"""
Get list of available rooms for a clinic at the specified time.
Args:
clinic: Clinic instance
scheduled_date: Date of the appointment
scheduled_time: Time of the appointment
duration: Duration in minutes
tenant: Tenant instance
Returns:
QuerySet: Available rooms
"""
all_rooms = Room.objects.filter(
clinic=clinic,
tenant=tenant,
is_available=True
)
available_rooms = []
for room in all_rooms:
is_available, _ = RoomAvailabilityService.check_room_availability(
room, scheduled_date, scheduled_time, duration
)
if is_available:
available_rooms.append(room.id)
return Room.objects.filter(id__in=available_rooms)
@staticmethod
def get_room_schedule(room, date):
"""
Get all appointments for a room on a specific date.
Args:
room: Room instance
date: Date to check
Returns:
QuerySet: Appointments ordered by time
"""
return Appointment.objects.filter(
room=room,
scheduled_date=date,
status__in=[
Appointment.Status.BOOKED,
Appointment.Status.CONFIRMED,
Appointment.Status.ARRIVED,
Appointment.Status.IN_PROGRESS,
Appointment.Status.COMPLETED,
]
).order_by('scheduled_time')
@staticmethod
def get_room_utilization(room, start_date, end_date):
"""
Calculate room utilization percentage for a date range.
Args:
room: Room instance
start_date: Start date
end_date: End date
Returns:
dict: Utilization statistics
"""
appointments = Appointment.objects.filter(
room=room,
scheduled_date__range=[start_date, end_date],
status__in=[
Appointment.Status.COMPLETED,
Appointment.Status.IN_PROGRESS,
]
)
total_minutes = sum(apt.duration for apt in appointments)
# Calculate available hours (assuming 8-hour workday)
days = (end_date - start_date).days + 1
available_minutes = days * 8 * 60 # 8 hours per day
utilization_percentage = (total_minutes / available_minutes * 100) if available_minutes > 0 else 0
return {
'total_appointments': appointments.count(),
'total_minutes': total_minutes,
'total_hours': total_minutes / 60,
'available_minutes': available_minutes,
'utilization_percentage': round(utilization_percentage, 2),
}
@staticmethod
def find_next_available_slot(room, start_date, duration, max_days=7):
"""
Find the next available time slot for a room.
Args:
room: Room instance
start_date: Date to start searching from
duration: Required duration in minutes
max_days: Maximum days to search ahead
Returns:
tuple: (date, time) of next available slot, or (None, None) if not found
"""
from datetime import time
# Define working hours (8 AM to 6 PM)
work_start = time(8, 0)
work_end = time(18, 0)
slot_duration = 30 # Check in 30-minute increments
current_date = start_date
end_search_date = start_date + timedelta(days=max_days)
while current_date <= end_search_date:
current_time = work_start
while current_time < work_end:
# Check if this slot is available
is_available, _ = RoomAvailabilityService.check_room_availability(
room, current_date, current_time, duration
)
if is_available:
return current_date, current_time
# Move to next slot
current_datetime = datetime.combine(current_date, current_time)
next_datetime = current_datetime + timedelta(minutes=slot_duration)
current_time = next_datetime.time()
# Move to next day
current_date += timedelta(days=1)
return None, None
@staticmethod
def get_conflict_summary(room, scheduled_date):
"""
Get a summary of all appointments in a room for a specific date.
Args:
room: Room instance
scheduled_date: Date to check
Returns:
list: List of appointment summaries
"""
appointments = RoomAvailabilityService.get_room_schedule(room, scheduled_date)
summary = []
for apt in appointments:
start_time = apt.scheduled_time
end_time = (
datetime.combine(scheduled_date, start_time) +
timedelta(minutes=apt.duration)
).time()
summary.append({
'appointment_number': apt.appointment_number,
'patient': str(apt.patient),
'provider': apt.provider.user.get_full_name(),
'start_time': start_time.strftime('%H:%M'),
'end_time': end_time.strftime('%H:%M'),
'duration': apt.duration,
'status': apt.get_status_display(),
'service_type': apt.service_type,
})
return summary
class MultiProviderRoomChecker:
"""
Specialized checker for rooms shared by multiple providers.
Ensures no overlapping bookings across different therapists.
"""
@staticmethod
def get_shared_rooms(clinic, tenant):
"""
Get rooms that are shared by multiple providers.
Args:
clinic: Clinic instance
tenant: Tenant instance
Returns:
QuerySet: Rooms with multiple providers
"""
from django.db.models import Count
# Get rooms that have appointments from multiple providers
rooms_with_multiple_providers = Appointment.objects.filter(
clinic=clinic,
tenant=tenant,
room__isnull=False
).values('room').annotate(
provider_count=Count('provider', distinct=True)
).filter(provider_count__gt=1).values_list('room', flat=True)
return Room.objects.filter(id__in=rooms_with_multiple_providers)
@staticmethod
def validate_multi_provider_booking(room, provider, scheduled_date, scheduled_time, duration, exclude_appointment=None):
"""
Validate booking for a room shared by multiple providers.
Args:
room: Room instance
provider: Provider instance
scheduled_date: Date of the appointment
scheduled_time: Time of the appointment
duration: Duration in minutes
exclude_appointment: Appointment to exclude from check
Raises:
RoomConflictError: If room is not available
"""
# Use the standard room availability check
# This works for both single and multi-provider rooms
RoomAvailabilityService.validate_room_availability(
room, scheduled_date, scheduled_time, duration, exclude_appointment
)
@staticmethod
def get_room_provider_schedule(room, scheduled_date):
"""
Get schedule showing which providers are using the room on a specific date.
Args:
room: Room instance
scheduled_date: Date to check
Returns:
dict: Provider schedules
"""
appointments = RoomAvailabilityService.get_room_schedule(room, scheduled_date)
provider_schedules = {}
for apt in appointments:
provider_name = apt.provider.user.get_full_name()
if provider_name not in provider_schedules:
provider_schedules[provider_name] = []
start_time = apt.scheduled_time
end_time = (
datetime.combine(scheduled_date, start_time) +
timedelta(minutes=apt.duration)
).time()
provider_schedules[provider_name].append({
'start_time': start_time.strftime('%H:%M'),
'end_time': end_time.strftime('%H:%M'),
'patient': str(apt.patient),
'service_type': apt.service_type,
'status': apt.get_status_display(),
})
return provider_schedules