734 lines
24 KiB
Python
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
|