Files
nimbusflow/backend/services/scheduling_service.py

301 lines
11 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
"""
Highlevel service that implements the roundrobin / boost / cooldown
scheduling algorithm.
It deliberately keeps **business rules** (ordering, eligibility checks)
here, while the repositories remain pure dataaccess 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, schedulestatus constraints, and the *sameday*
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 “5day decline boost” (default: 5days).
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 (YYYYMMDD). 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
# ---- Earlyskip 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
# ---- SAMEDAY 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
# ---- Schedulestatus 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 roundrobin 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
Primarykey of the member who is declining.
service_id : int
Primarykey of the service being declined.
reason : str | None, optional
Optional freeform 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 brandnew schedule row was inserted,
``"updated"`` if an existing row was switched to
``ScheduleStatus.DECLINED``.
*schedule_id* the primarykey 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