agdar/appointments/services.py
Marwan Alwali a4665842c9 update
2025-11-23 10:58:07 +03:00

734 lines
24 KiB
Python

"""
Appointments business logic services.
This module contains service classes that encapsulate business logic
for appointment booking, scheduling, and management.
"""
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple
from django.db import transaction
from django.db.models import Q
from django.utils import timezone
from appointments.models import Appointment, Provider, Room, Schedule
from appointments.state_machine import AppointmentStateMachine, StateTransitionValidator
from core.models import Patient, Tenant
from core.services import ConsentService
from finance.services import FinancialClearanceService
logger = logging.getLogger(__name__)
class AppointmentService:
"""Service class for Appointment-related business logic."""
@staticmethod
@transaction.atomic
def book_appointment(
patient: Patient,
provider: Provider,
clinic,
start_time: datetime,
duration_minutes: int,
appointment_type: str,
**kwargs
) -> Appointment:
"""
Book a new appointment with availability validation.
Args:
patient: Patient instance
provider: Provider instance
clinic: Clinic instance
start_time: Appointment start time
duration_minutes: Duration in minutes
appointment_type: Type of appointment
**kwargs: Additional appointment fields
Returns:
Appointment: Created appointment instance
Raises:
ValueError: If time slot is not available
"""
# Calculate end time
end_time = start_time + timedelta(minutes=duration_minutes)
# Check provider availability
if not AppointmentService.check_availability(provider, start_time, end_time):
raise ValueError(
f"Provider {provider.get_full_name()} is not available at "
f"{start_time.strftime('%Y-%m-%d %H:%M')}"
)
# Check for conflicts
conflicts = AppointmentService.check_conflicts(
provider=provider,
start_time=start_time,
end_time=end_time,
exclude_appointment_id=None
)
if conflicts:
raise ValueError(
f"Time slot conflicts with existing appointment(s): "
f"{', '.join([str(a.id) for a in conflicts])}"
)
# Create appointment
appointment = Appointment.objects.create(
tenant=patient.tenant,
patient=patient,
provider=provider,
clinic=clinic,
start_time=start_time,
end_time=end_time,
duration_minutes=duration_minutes,
appointment_type=appointment_type,
status='BOOKED',
**kwargs
)
logger.info(
f"Appointment booked: {appointment.id} for patient {patient.mrn} "
f"with {provider.get_full_name()} on {start_time}"
)
return appointment
@staticmethod
def check_availability(
provider: Provider,
start_time: datetime,
end_time: datetime
) -> bool:
"""
Check if provider is available for the given time slot.
Args:
provider: Provider instance
start_time: Start time to check
end_time: End time to check
Returns:
bool: True if provider is available
"""
# Get provider's schedule for the day
# weekday() returns 0=Monday, 1=Tuesday, etc.
# But Schedule.DayOfWeek uses 0=Sunday, 1=Monday, etc.
# So we need to convert: Python's weekday() + 1, with Sunday wrapping to 0
day_of_week_int = (start_time.weekday() + 1) % 7
schedules = Schedule.objects.filter(
provider=provider,
day_of_week=day_of_week_int,
is_active=True
)
if not schedules.exists():
day_names = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
logger.warning(f"No schedule found for {provider.user.get_full_name()} on {day_names[day_of_week_int]}")
return False
# Check if time falls within any schedule
for schedule in schedules:
# Combine date with schedule times
schedule_start = datetime.combine(
start_time.date(),
schedule.start_time
)
schedule_end = datetime.combine(
start_time.date(),
schedule.end_time
)
# Make timezone aware if needed
if timezone.is_aware(start_time):
schedule_start = timezone.make_aware(schedule_start)
schedule_end = timezone.make_aware(schedule_end)
# Check if appointment falls within schedule
if schedule_start <= start_time and end_time <= schedule_end:
return True
return False
@staticmethod
def check_conflicts(
provider: Provider,
start_time: datetime,
end_time: datetime,
exclude_appointment_id: Optional[str] = None
) -> List[Appointment]:
"""
Check for conflicting appointments.
Args:
provider: Provider instance
start_time: Start time to check
end_time: End time to check
exclude_appointment_id: Appointment ID to exclude from check
Returns:
list: List of conflicting appointments
"""
conflicts = Appointment.objects.filter(
provider=provider,
status__in=['BOOKED', 'CONFIRMED', 'ARRIVED', 'IN_PROGRESS']
).filter(
Q(start_time__lt=end_time, end_time__gt=start_time)
)
if exclude_appointment_id:
conflicts = conflicts.exclude(id=exclude_appointment_id)
return list(conflicts)
@staticmethod
@transaction.atomic
def confirm_appointment(appointment: Appointment, confirmed_by=None) -> Appointment:
"""
Confirm an appointment with state machine validation.
Args:
appointment: Appointment instance
confirmed_by: User confirming the appointment
Returns:
Appointment: Confirmed appointment instance
Raises:
ValueError: If appointment cannot be confirmed
"""
# Validate state transition
is_valid, message = StateTransitionValidator.validate_and_transition(
appointment, 'CONFIRMED'
)
if not is_valid:
raise ValueError(message)
appointment.status = 'CONFIRMED'
appointment.confirmation_sent_at = timezone.now()
if confirmed_by:
# Store confirmed_by if field exists
if hasattr(appointment, 'confirmed_by'):
appointment.confirmed_by = confirmed_by
appointment.save()
logger.info(f"Appointment confirmed: {appointment.id}")
return appointment
@staticmethod
@transaction.atomic
def mark_arrival(appointment: Appointment, arrived_by=None) -> Appointment:
"""
Mark patient as arrived with state machine validation and prerequisite checks.
Performs financial clearance and consent verification before allowing check-in.
Args:
appointment: Appointment instance
arrived_by: User marking the arrival
Returns:
Appointment: Updated appointment instance
Raises:
ValueError: If prerequisites not met or appointment cannot be marked as arrived
"""
# Validate state transition
is_valid, message = StateTransitionValidator.validate_and_transition(
appointment, 'ARRIVED'
)
if not is_valid:
raise ValueError(message)
# Check financial clearance
finance_cleared, finance_message = FinancialClearanceService.check_clearance(
appointment.patient,
appointment.service_type
)
if not finance_cleared:
raise ValueError(f"Financial clearance required: {finance_message}")
# Check consent verification
consent_verified, consent_message = ConsentService.verify_consent_for_service(
appointment.patient,
appointment.service_type
)
if not consent_verified:
raise ValueError(f"Consent verification required: {consent_message}")
# All checks passed - mark as arrived
appointment.status = 'ARRIVED'
appointment.arrival_at = timezone.now()
appointment.finance_cleared = True
appointment.consent_verified = True
appointment.save()
# Alert provider
from core.tasks import create_notification_task
if appointment.provider:
create_notification_task.delay(
user_id=str(appointment.provider.user.id),
title="Patient Arrived",
message=f"Patient {appointment.patient.full_name_en} has arrived for their appointment",
notification_type='INFO',
related_object_type='appointment',
related_object_id=str(appointment.id),
)
logger.info(
f"Patient arrived for appointment {appointment.id}. "
f"Finance cleared: {finance_cleared}, Consent verified: {consent_verified}"
)
return appointment
@staticmethod
def check_arrival_prerequisites(appointment: Appointment) -> dict:
"""
Check if all prerequisites are met for patient arrival.
Args:
appointment: Appointment instance
Returns:
dict: Status of all prerequisites
"""
# Check financial clearance
finance_cleared, finance_message = FinancialClearanceService.check_clearance(
appointment.patient,
appointment.service_type
)
# Check consent verification
consent_verified, consent_message = ConsentService.verify_consent_for_service(
appointment.patient,
appointment.service_type
)
# Get missing consents if any
missing_consents = []
if not consent_verified:
missing_consents = ConsentService.get_missing_consents(
appointment.patient,
appointment.service_type
)
# Get outstanding invoices if any
outstanding_invoices = []
if not finance_cleared:
outstanding_invoices = FinancialClearanceService.get_outstanding_invoices(
appointment.patient
)
return {
'can_check_in': finance_cleared and consent_verified,
'financial': {
'cleared': finance_cleared,
'message': finance_message,
'outstanding_invoices': outstanding_invoices,
},
'consent': {
'verified': consent_verified,
'message': consent_message,
'missing_consents': missing_consents,
},
}
@staticmethod
@transaction.atomic
def reschedule_appointment(
appointment: Appointment,
new_start_time: datetime,
new_duration_minutes: Optional[int] = None,
reason: str = None,
rescheduled_by=None
) -> Appointment:
"""
Reschedule an appointment to a new time with state machine validation.
Args:
appointment: Appointment instance
new_start_time: New start time
new_duration_minutes: New duration (optional)
reason: Reason for rescheduling
rescheduled_by: User performing the reschedule
Returns:
Appointment: Rescheduled appointment instance
Raises:
ValueError: If new time slot is not available or state transition invalid
"""
# Validate state transition
if not AppointmentStateMachine.can_reschedule(appointment.status):
raise ValueError(
f"Cannot reschedule appointment with status {appointment.status}. "
f"Rescheduling only allowed from: {', '.join(AppointmentStateMachine.RESCHEDULABLE_STATES)}"
)
# Use existing duration if not provided
duration = new_duration_minutes or appointment.duration_minutes
new_end_time = new_start_time + timedelta(minutes=duration)
# Check availability
if not AppointmentService.check_availability(
appointment.provider,
new_start_time,
new_end_time
):
raise ValueError(
f"Provider not available at {new_start_time.strftime('%Y-%m-%d %H:%M')}"
)
# Check conflicts (excluding current appointment)
conflicts = AppointmentService.check_conflicts(
provider=appointment.provider,
start_time=new_start_time,
end_time=new_end_time,
exclude_appointment_id=str(appointment.id)
)
if conflicts:
raise ValueError("Time slot conflicts with existing appointment(s)")
# Update appointment
old_start_time = appointment.scheduled_date
old_time = appointment.scheduled_time
appointment.scheduled_date = new_start_time.date()
appointment.scheduled_time = new_start_time.time()
appointment.duration = duration
appointment.status = 'RESCHEDULED'
appointment.reschedule_reason = reason or "Rescheduled by request"
appointment.reschedule_count += 1
if rescheduled_by and hasattr(appointment, 'rescheduled_by'):
appointment.rescheduled_by = rescheduled_by
appointment.save()
logger.info(
f"Appointment rescheduled: {appointment.id} from {old_start_time} {old_time} to {new_start_time}"
)
return appointment
@staticmethod
@transaction.atomic
def cancel_appointment(
appointment: Appointment,
reason: str,
cancelled_by=None
) -> Appointment:
"""
Cancel an appointment with state machine validation.
Args:
appointment: Appointment instance
reason: Cancellation reason
cancelled_by: User cancelling the appointment
Returns:
Appointment: Cancelled appointment instance
Raises:
ValueError: If appointment cannot be cancelled
"""
# Validate cancellation is allowed
if not AppointmentStateMachine.can_cancel(appointment.status):
raise ValueError(
f"Cannot cancel appointment with status {appointment.status}. "
f"Cancellation only allowed from: {', '.join(AppointmentStateMachine.CANCELLABLE_STATES)}"
)
appointment.status = 'CANCELLED'
appointment.cancel_reason = reason
if cancelled_by:
appointment.cancelled_by = cancelled_by
appointment.save()
logger.info(f"Appointment cancelled: {appointment.id} - Reason: {reason}")
return appointment
@staticmethod
@transaction.atomic
def start_appointment(appointment: Appointment, started_by=None) -> Appointment:
"""
Start an appointment (mark as IN_PROGRESS).
Args:
appointment: Appointment instance
started_by: User starting the appointment
Returns:
Appointment: Updated appointment instance
Raises:
ValueError: If appointment cannot be started
"""
# Validate state transition
is_valid, message = StateTransitionValidator.validate_and_transition(
appointment, 'IN_PROGRESS'
)
if not is_valid:
raise ValueError(message)
appointment.status = 'IN_PROGRESS'
appointment.start_at = timezone.now()
appointment.save()
logger.info(f"Appointment started: {appointment.id}")
return appointment
@staticmethod
@transaction.atomic
def complete_appointment(appointment: Appointment, completed_by=None) -> Appointment:
"""
Complete an appointment.
Args:
appointment: Appointment instance
completed_by: User completing the appointment
Returns:
Appointment: Updated appointment instance
Raises:
ValueError: If appointment cannot be completed
"""
# Validate state transition
is_valid, message = StateTransitionValidator.validate_and_transition(
appointment, 'COMPLETED'
)
if not is_valid:
raise ValueError(message)
appointment.status = 'COMPLETED'
appointment.end_at = timezone.now()
appointment.save()
logger.info(f"Appointment completed: {appointment.id}")
return appointment
@staticmethod
def get_calendar_slots(
provider: Provider,
date: datetime.date,
slot_duration_minutes: int = 30
) -> List[Dict]:
"""
Get available time slots for a provider on a specific date.
Args:
provider: Provider instance
date: Date to get slots for
slot_duration_minutes: Duration of each slot in minutes
Returns:
list: List of time slot dictionaries with availability status
"""
# Convert date to day_of_week integer (0=Sunday, 1=Monday, etc.)
day_of_week_int = (date.weekday() + 1) % 7
# Get provider's schedule for the day
schedules = Schedule.objects.filter(
provider=provider,
day_of_week=day_of_week_int,
is_active=True
)
if not schedules.exists():
return []
# Get existing appointments for the day
appointments = Appointment.objects.filter(
provider=provider,
start_time__date=date,
status__in=['BOOKED', 'CONFIRMED', 'ARRIVED', 'IN_PROGRESS']
)
slots = []
for schedule in schedules:
# Generate slots for this schedule
current_time = datetime.combine(date, schedule.start_time)
end_time = datetime.combine(date, schedule.end_time)
# Make timezone aware if needed
if timezone.is_aware(timezone.now()):
current_time = timezone.make_aware(current_time)
end_time = timezone.make_aware(end_time)
while current_time + timedelta(minutes=slot_duration_minutes) <= end_time:
slot_end = current_time + timedelta(minutes=slot_duration_minutes)
# Check if slot is available
is_available = True
for appointment in appointments:
if (current_time < appointment.end_time and
slot_end > appointment.start_time):
is_available = False
break
# Check if slot is in the past
if current_time < timezone.now():
is_available = False
slots.append({
'start_time': current_time,
'end_time': slot_end,
'is_available': is_available,
'duration_minutes': slot_duration_minutes
})
current_time = slot_end
return slots
class ScheduleService:
"""Service class for Schedule-related business logic."""
@staticmethod
@transaction.atomic
def create_schedule(
provider: Provider,
day_of_week: str,
start_time: datetime.time,
end_time: datetime.time,
**kwargs
) -> Schedule:
"""
Create a provider schedule.
Args:
provider: Provider instance
day_of_week: Day of week (MONDAY, TUESDAY, etc.)
start_time: Schedule start time
end_time: Schedule end time
**kwargs: Additional schedule fields
Returns:
Schedule: Created schedule instance
Raises:
ValueError: If schedule conflicts with existing schedule
"""
# Check for conflicts
conflicts = ScheduleService.check_conflicts(
provider=provider,
day_of_week=day_of_week,
start_time=start_time,
end_time=end_time
)
if conflicts:
raise ValueError(
f"Schedule conflicts with existing schedule(s) on {day_of_week}"
)
# Create schedule
schedule = Schedule.objects.create(
tenant=provider.tenant,
provider=provider,
day_of_week=day_of_week,
start_time=start_time,
end_time=end_time,
**kwargs
)
logger.info(
f"Schedule created for {provider.get_full_name()} on {day_of_week} "
f"from {start_time} to {end_time}"
)
return schedule
@staticmethod
def check_conflicts(
provider: Provider,
day_of_week: str,
start_time: datetime.time,
end_time: datetime.time,
exclude_schedule_id: Optional[str] = None
) -> List[Schedule]:
"""
Check for conflicting schedules.
Args:
provider: Provider instance
day_of_week: Day of week
start_time: Start time to check
end_time: End time to check
exclude_schedule_id: Schedule ID to exclude from check
Returns:
list: List of conflicting schedules
"""
conflicts = Schedule.objects.filter(
provider=provider,
day_of_week=day_of_week,
is_active=True
).filter(
Q(start_time__lt=end_time, end_time__gt=start_time)
)
if exclude_schedule_id:
conflicts = conflicts.exclude(id=exclude_schedule_id)
return list(conflicts)
@staticmethod
def get_available_slots(
provider: Provider,
start_date: datetime.date,
end_date: datetime.date,
slot_duration_minutes: int = 30
) -> Dict[str, List[Dict]]:
"""
Get available time slots for a provider across a date range.
Args:
provider: Provider instance
start_date: Start date
end_date: End date
slot_duration_minutes: Duration of each slot
Returns:
dict: Dictionary mapping dates to lists of available slots
"""
slots_by_date = {}
current_date = start_date
while current_date <= end_date:
slots = AppointmentService.get_calendar_slots(
provider=provider,
date=current_date,
slot_duration_minutes=slot_duration_minutes
)
# Filter to only available slots
available_slots = [slot for slot in slots if slot['is_available']]
if available_slots:
slots_by_date[str(current_date)] = available_slots
current_date += timedelta(days=1)
return slots_by_date