# myapp/services/scheduling_service.py # ------------------------------------------------------------ # Scheduling service – orchestrates the various repositories # to pick the next eligible member for a given service. # ------------------------------------------------------------ from __future__ import annotations from datetime import datetime, timezone from typing import Optional, Tuple, List, Iterable from backend.repositories import ( ClassificationRepository, MemberRepository, ServiceRepository, ServiceAvailabilityRepository, ScheduleRepository ) from backend.models import ScheduleStatus class SchedulingService: """ High‑level service that implements the round‑robin / boost / cooldown scheduling algorithm. It deliberately keeps **business rules** (ordering, eligibility checks) here, while the repositories remain pure data‑access helpers. """ def __init__( self, classification_repo: ClassificationRepository, member_repo: MemberRepository, service_repo: ServiceRepository, availability_repo: ServiceAvailabilityRepository, schedule_repo: ScheduleRepository, ) -> None: self.classification_repo = classification_repo self.member_repo = member_repo self.service_repo = service_repo self.availability_repo = availability_repo self.schedule_repo = schedule_repo def schedule_next_member( self, classification_ids: Iterable[int], service_id: int, *, only_active: bool = True, boost_seconds: int = 2 * 24 * 60 * 60, exclude_member_ids: Iterable[int] | None = None, ) -> Optional[Tuple[int, str, str, int]]: """ Choose the next member for ``service_id`` while respecting ServiceAvailability, schedule‑status constraints, and the *same‑day* exclusion rule. Parameters ---------- classification_ids : Iterable[int] One or more classification identifiers. service_id : int The service we are trying to schedule. only_active : bool, optional Filter out inactive members (default: ``True``). boost_seconds : int, optional Seconds for the “5‑day decline boost” (default: 5 days). exclude_member_ids : Iterable[int] | None, optional MemberIds that must be ignored even if they otherwise qualify. Returns ------- Tuple[member_id, first_name, last_name, schedule_id] | None The first eligible member according to the ordering rules, or ``None``. """ # ----------------------------------------------------------------- # 0️⃣ Resolve the Service row → we need ServiceTypeId and ServiceDate. # ----------------------------------------------------------------- svc = self.service_repo.get_by_id(service_id) if svc is None: return None service_type_id = svc.ServiceTypeId # ``svc.ServiceDate`` is stored as a DATE (YYYY‑MM‑DD). We keep it as a string # because SQLite date arithmetic works fine with that format. target_date = svc.ServiceDate # ----------------------------------------------------------------- # 1️⃣ Build the candidate queue (ordering handled by the repo). # ----------------------------------------------------------------- excluded = set(exclude_member_ids or []) candidates: List = self.member_repo.candidate_queue( classification_ids=list(classification_ids), only_active=only_active, boost_seconds=boost_seconds, ) # ----------------------------------------------------------------- # 2️⃣ Walk the ordered queue and apply all constraints. # ----------------------------------------------------------------- for member in candidates: member_id = member.MemberId # ---- Early‑skip for explicit exclusions --------------------------------- if member_id in excluded: continue # ---- Availability check ------------------------------------------------- if not self.availability_repo.get(member_id, service_type_id): continue # not eligible for this service type # ---- SAME‑DAY EXCLUSION ------------------------------------------------ # Ask the repository whether this member already has *any* schedule on # the same calendar day as the target service. if self.schedule_repo.has_schedule_on_date(member_id, target_date): # Member already booked somewhere on this day → skip. continue # ---- Schedule‑status constraints (accepted / pending / declined) --------- if self.schedule_repo.has_any( member_id, service_id, statuses=[ScheduleStatus.ACCEPTED], ): continue if self.schedule_repo.has_any( member_id, service_id, statuses=[ScheduleStatus.PENDING], ): continue if self.schedule_repo.has_any( member_id, service_id, statuses=[ScheduleStatus.DECLINED], ): continue # ----------------------------------------------------------------- # SUCCESS – create a pending schedule. # ----------------------------------------------------------------- schedule = self.schedule_repo.create( service_id=service_id, member_id=member_id, status=ScheduleStatus.PENDING, ) schedule_id = schedule.ScheduleId # ----------------------------------------------------------------- # Update the member's LastScheduledAt so round‑robin stays fair. # ----------------------------------------------------------------- self.member_repo.touch_last_scheduled(member_id) # ----------------------------------------------------------------- # Return the useful bits to the caller. # ----------------------------------------------------------------- return ( member_id, member.FirstName, member.LastName, schedule_id, ) # ----------------------------------------------------------------- # No eligible member found. # ----------------------------------------------------------------- return None def decline_service_for_user( self, member_id: int, service_id: int, *, reason: Optional[str] = None, ) -> Tuple[Literal["created"] | Literal["updated"], int]: """ Mark a service as *declined* for a particular member. Parameters ---------- member_id : int Primary‑key of the member who is declining. service_id : int Primary‑key of the service being declined. reason : str | None, optional Optional free‑form text explaining why the member declined. Stored in the ``Reason`` column if your ``Schedules`` table has one; otherwise it is ignored. Returns ------- Tuple[action, schedule_id] *action* – ``"created"`` if a brand‑new schedule row was inserted, ``"updated"`` if an existing row was switched to ``ScheduleStatus.DECLINED``. *schedule_id* – the primary‑key of the affected ``Schedules`` row. """ # --------------------------------------------------------- # 1️⃣ Look for an existing schedule (any status) for this pair. # --------------------------------------------------------- existing = self.schedule_repo.get_one( member_id=member_id, service_id=service_id, ) if existing: # ----------------------------------------------------- # 2️⃣ There is already a row – just flip its status. # ----------------------------------------------------- self.schedule_repo.update_status( schedule_id=existing.ScheduleId, new_status=ScheduleStatus.DECLINED, reason=reason, ) return ("updated", existing.ScheduleId) # --------------------------------------------------------- # 3️⃣ No row yet – insert a fresh *declined* schedule. # --------------------------------------------------------- new_sched = self.schedule_repo.create( service_id=service_id, member_id=member_id, status=ScheduleStatus.DECLINED, reason=reason, ) return ("created", new_sched.ScheduleId) def preview_next_member( self, classification_ids: Iterable[int], service_id: int, *, only_active: bool = True, boost_seconds: int = 2 * 24 * 60 * 60, exclude_member_ids: Iterable[int] | None = None, ) -> Optional[Tuple[int, str, str]]: """ Preview who would be scheduled next for a service without actually creating the schedule. Same logic as schedule_next_member, but doesn't create any records. Returns ------- Tuple[member_id, first_name, last_name] | None The member who would be scheduled next, or None if no eligible member found. """ # Same logic as schedule_next_member but without creating records svc = self.service_repo.get_by_id(service_id) if svc is None: return None service_type_id = svc.ServiceTypeId target_date = svc.ServiceDate excluded = set(exclude_member_ids or []) candidates: List = self.member_repo.candidate_queue( classification_ids=list(classification_ids), only_active=only_active, boost_seconds=boost_seconds, ) for member in candidates: member_id = member.MemberId if member_id in excluded: continue if not self.availability_repo.get(member_id, service_type_id): continue if self.schedule_repo.has_schedule_on_date(member_id, target_date): continue if self.schedule_repo.has_any( member_id, service_id, statuses=[ScheduleStatus.ACCEPTED], ): continue if self.schedule_repo.has_any( member_id, service_id, statuses=[ScheduleStatus.PENDING], ): continue if self.schedule_repo.has_any( member_id, service_id, statuses=[ScheduleStatus.DECLINED], ): continue # Found eligible member - return without creating schedule return ( member_id, member.FirstName, member.LastName, ) return None