301 lines
11 KiB
Python
301 lines
11 KiB
Python
# 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, Literal
|
||
|
||
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 |