264 lines
9.5 KiB
Python
264 lines
9.5 KiB
Python
"""
|
|
Package Auto-Scheduling Service.
|
|
|
|
This service automatically schedules all sessions when a package is purchased,
|
|
respecting session order and therapist availability.
|
|
"""
|
|
|
|
from datetime import datetime, timedelta, time
|
|
from django.db import transaction
|
|
from django.utils import timezone
|
|
from django.utils.translation import gettext_lazy as _
|
|
|
|
from finance.models import Package, PackagePurchase, PackageService
|
|
from appointments.models import Appointment, Provider, Room
|
|
from appointments.room_conflict_service import RoomAvailabilityService
|
|
from core.models import Patient, Clinic
|
|
|
|
|
|
class PackageSchedulingError(Exception):
|
|
"""Raised when package scheduling fails."""
|
|
pass
|
|
|
|
|
|
class PackageSchedulingService:
|
|
"""
|
|
Service for automatically scheduling all sessions in a package.
|
|
Respects session order, therapist availability, and room conflicts.
|
|
"""
|
|
|
|
@staticmethod
|
|
def auto_schedule_package(package_purchase, start_date=None, preferred_time=None, preferred_provider=None):
|
|
"""
|
|
Automatically schedule all sessions for a package purchase.
|
|
|
|
Args:
|
|
package_purchase: PackagePurchase instance
|
|
start_date: Optional start date (defaults to tomorrow)
|
|
preferred_time: Optional preferred time (defaults to 9:00 AM)
|
|
preferred_provider: Optional preferred provider
|
|
|
|
Returns:
|
|
list: Created appointments
|
|
|
|
Raises:
|
|
PackageSchedulingError: If scheduling fails
|
|
"""
|
|
from datetime import date
|
|
|
|
if not start_date:
|
|
start_date = date.today() + timedelta(days=1)
|
|
|
|
if not preferred_time:
|
|
preferred_time = time(9, 0) # 9:00 AM
|
|
|
|
# Get package services ordered by session_order
|
|
package_services = package_purchase.package.packageservice_set.all().order_by('session_order')
|
|
|
|
if not package_services.exists():
|
|
raise PackageSchedulingError("Package has no services defined")
|
|
|
|
created_appointments = []
|
|
current_date = start_date
|
|
current_time = preferred_time
|
|
|
|
with transaction.atomic():
|
|
for package_service in package_services:
|
|
service = package_service.service
|
|
clinic = service.clinic
|
|
|
|
# Create appointments for each session
|
|
for session_num in range(package_service.sessions):
|
|
# Find available provider
|
|
provider = preferred_provider
|
|
if not provider:
|
|
provider = PackageSchedulingService._find_available_provider(
|
|
clinic, current_date, current_time, service.duration_minutes
|
|
)
|
|
|
|
if not provider:
|
|
raise PackageSchedulingError(
|
|
f"No available provider found for {clinic.name_en} on {current_date}"
|
|
)
|
|
|
|
# Find available room
|
|
room = PackageSchedulingService._find_available_room(
|
|
clinic, current_date, current_time, service.duration_minutes, package_purchase.patient.tenant
|
|
)
|
|
|
|
# Create appointment
|
|
appointment = Appointment.objects.create(
|
|
patient=package_purchase.patient,
|
|
clinic=clinic,
|
|
provider=provider,
|
|
room=room,
|
|
service_type=service.name_en,
|
|
scheduled_date=current_date,
|
|
scheduled_time=current_time,
|
|
duration=service.duration_minutes,
|
|
tenant=package_purchase.patient.tenant,
|
|
status=Appointment.Status.BOOKED
|
|
)
|
|
|
|
created_appointments.append(appointment)
|
|
|
|
# Move to next available slot
|
|
current_date, current_time = PackageSchedulingService._get_next_slot(
|
|
current_date, current_time, service.duration_minutes
|
|
)
|
|
|
|
return created_appointments
|
|
|
|
@staticmethod
|
|
def _find_available_provider(clinic, date, time, duration):
|
|
"""Find an available provider for the clinic at the specified time."""
|
|
providers = Provider.objects.filter(
|
|
specialties=clinic,
|
|
is_available=True
|
|
)
|
|
|
|
for provider in providers:
|
|
# Check if provider has conflicting appointments
|
|
conflicts = Appointment.objects.filter(
|
|
provider=provider,
|
|
scheduled_date=date,
|
|
scheduled_time=time,
|
|
status__in=[
|
|
Appointment.Status.BOOKED,
|
|
Appointment.Status.CONFIRMED,
|
|
Appointment.Status.ARRIVED,
|
|
Appointment.Status.IN_PROGRESS,
|
|
]
|
|
)
|
|
|
|
if not conflicts.exists():
|
|
return provider
|
|
|
|
return None
|
|
|
|
@staticmethod
|
|
def _find_available_room(clinic, date, time, duration, tenant):
|
|
"""Find an available room for the clinic at the specified time."""
|
|
available_rooms = RoomAvailabilityService.get_available_rooms(
|
|
clinic, date, time, duration, tenant
|
|
)
|
|
|
|
return available_rooms.first() if available_rooms.exists() else None
|
|
|
|
@staticmethod
|
|
def _get_next_slot(current_date, current_time, duration):
|
|
"""
|
|
Get the next available time slot.
|
|
|
|
Args:
|
|
current_date: Current date
|
|
current_time: Current time
|
|
duration: Duration in minutes
|
|
|
|
Returns:
|
|
tuple: (next_date, next_time)
|
|
"""
|
|
# Add duration + 30 min buffer
|
|
current_datetime = datetime.combine(current_date, current_time)
|
|
next_datetime = current_datetime + timedelta(minutes=duration + 30)
|
|
|
|
# If past 5 PM, move to next day at 9 AM
|
|
if next_datetime.time() >= time(17, 0):
|
|
next_date = current_date + timedelta(days=1)
|
|
# Skip weekends (Friday, Saturday in Saudi Arabia)
|
|
while next_date.weekday() in [4, 5]:
|
|
next_date += timedelta(days=1)
|
|
next_time = time(9, 0)
|
|
else:
|
|
next_date = next_datetime.date()
|
|
next_time = next_datetime.time()
|
|
|
|
return next_date, next_time
|
|
|
|
@staticmethod
|
|
def reschedule_package_session(appointment, new_date, new_time, reason=""):
|
|
"""
|
|
Reschedule a package session and update package tracking.
|
|
|
|
Args:
|
|
appointment: Appointment instance
|
|
new_date: New date
|
|
new_time: New time
|
|
reason: Reason for rescheduling
|
|
|
|
Returns:
|
|
Appointment: Updated appointment
|
|
"""
|
|
# Validate room availability at new time
|
|
if appointment.room:
|
|
RoomAvailabilityService.validate_room_availability(
|
|
appointment.room,
|
|
new_date,
|
|
new_time,
|
|
appointment.duration,
|
|
exclude_appointment=appointment
|
|
)
|
|
|
|
# Update appointment
|
|
appointment.scheduled_date = new_date
|
|
appointment.scheduled_time = new_time
|
|
appointment.reschedule_reason = reason
|
|
appointment.reschedule_count += 1
|
|
appointment.status = Appointment.Status.RESCHEDULED
|
|
appointment.save()
|
|
|
|
return appointment
|
|
|
|
@staticmethod
|
|
def cancel_package_session_with_credit(appointment, reason="", cancelled_by=None):
|
|
"""
|
|
Cancel a package session and restore credit to package.
|
|
|
|
Args:
|
|
appointment: Appointment instance
|
|
reason: Cancellation reason
|
|
cancelled_by: User who cancelled
|
|
|
|
Returns:
|
|
bool: True if credit restored
|
|
"""
|
|
# Find associated package purchase
|
|
# This would need to be linked via invoice or other means
|
|
# For now, we'll mark the appointment as cancelled
|
|
|
|
appointment.status = Appointment.Status.CANCELLED
|
|
appointment.cancel_reason = reason
|
|
appointment.cancelled_by = cancelled_by
|
|
appointment.save()
|
|
|
|
# TODO: Restore session credit to package
|
|
# This requires linking appointments to package purchases
|
|
# For now, return True to indicate cancellation successful
|
|
|
|
return True
|
|
|
|
@staticmethod
|
|
def get_package_schedule_summary(package_purchase):
|
|
"""
|
|
Get a summary of all scheduled sessions for a package.
|
|
|
|
Args:
|
|
package_purchase: PackagePurchase instance
|
|
|
|
Returns:
|
|
dict: Schedule summary
|
|
"""
|
|
# This would require linking appointments to package purchases
|
|
# For now, return basic info
|
|
|
|
return {
|
|
'package': package_purchase.package.name_en,
|
|
'patient': str(package_purchase.patient),
|
|
'total_sessions': package_purchase.total_sessions,
|
|
'sessions_used': package_purchase.sessions_used,
|
|
'sessions_remaining': package_purchase.sessions_remaining,
|
|
'purchase_date': package_purchase.purchase_date,
|
|
'expiry_date': package_purchase.expiry_date,
|
|
'status': package_purchase.get_status_display(),
|
|
}
|