feat(backend): refactor mono repository

This commit is contained in:
2025-08-27 11:04:56 -04:00
parent d0dbba21fb
commit be1c729220
37 changed files with 2534 additions and 452 deletions

View File

View File

@@ -0,0 +1,227 @@
# 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
from ..repositories import (
ClassificationRepository,
MemberRepository,
ServiceRepository,
ServiceAvailabilityRepository,
ScheduleRepository
)
from ..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 = 5 * 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)