Files
routesapi/app/services/rider/substitution_service.py
2026-06-22 17:40:08 +05:30

164 lines
5.9 KiB
Python

"""
Rider Substitution Service
==========================
Stores ops-team substitution records: when rider X is absent, rider Y
(the sub) covers X's kitchens and zone for that day.
On each dispatch call, AssignmentService loads today's map and merges the
absent rider's kitchen + zone affinity into the sub rider's profile for
that request only. No server restart needed. Automatically reverts the
next day because the lookup is date-keyed.
Storage: SQLite at data/substitutions.db
"""
import logging
import os
import sqlite3
import threading
from datetime import datetime, timezone
from typing import Dict, List, Optional
from zoneinfo import ZoneInfo
logger = logging.getLogger(__name__)
# All date comparisons use IST so the substitution fires on the correct
# calendar day regardless of what timezone the server (Docker/UTC) runs in.
_IST = ZoneInfo("Asia/Kolkata")
def _today_ist() -> str:
"""Current date in IST as YYYY-MM-DD string."""
return datetime.now(_IST).date().isoformat()
_PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__)
))))
_DB_PATH = os.path.join(_PROJECT_ROOT, "data", "substitutions.db")
_DDL = """
CREATE TABLE IF NOT EXISTS rider_substitutions (
sub_date TEXT NOT NULL,
absent_rider_id INTEGER NOT NULL,
sub_rider_id INTEGER NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (sub_date, absent_rider_id)
);
"""
class SubstitutionService:
def __init__(self, db_path: str = _DB_PATH):
self._db_path = db_path
self._lock = threading.Lock()
# In-memory cache for today's substitution map.
# Keyed by IST date string so it auto-invalidates at midnight IST.
self._cache: Dict[int, int] = {}
self._cache_date: str = ""
self._init_db()
def _init_db(self):
os.makedirs(os.path.dirname(self._db_path), exist_ok=True)
with sqlite3.connect(self._db_path) as conn:
conn.execute(_DDL)
conn.commit()
def _conn(self) -> sqlite3.Connection:
conn = sqlite3.connect(self._db_path)
conn.row_factory = sqlite3.Row
return conn
def _invalidate_cache(self):
"""Clear the in-memory cache so the next get_today_map() re-reads from DB."""
self._cache_date = ""
def register(self, sub_date: str, absent_rider_id: int, sub_rider_id: int) -> Dict:
"""Register or update a substitution for a given date."""
created_at = datetime.now(timezone.utc).isoformat()
with self._lock:
with self._conn() as conn:
conn.execute(
"""
INSERT INTO rider_substitutions
(sub_date, absent_rider_id, sub_rider_id, created_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(sub_date, absent_rider_id)
DO UPDATE SET sub_rider_id = excluded.sub_rider_id,
created_at = excluded.created_at
""",
(sub_date, int(absent_rider_id), int(sub_rider_id), created_at),
)
conn.commit()
self._invalidate_cache()
logger.info(
f"[Substitution] Registered: {sub_date} — absent={absent_rider_id} sub={sub_rider_id}"
)
return {
"sub_date": sub_date,
"absent_rider_id": absent_rider_id,
"sub_rider_id": sub_rider_id,
"created_at": created_at,
}
def cancel(self, sub_date: str, absent_rider_id: int) -> bool:
"""Remove a substitution record. Returns True if a row was deleted."""
with self._lock:
with self._conn() as conn:
cur = conn.execute(
"DELETE FROM rider_substitutions WHERE sub_date=? AND absent_rider_id=?",
(sub_date, int(absent_rider_id)),
)
conn.commit()
self._invalidate_cache()
return cur.rowcount > 0
def get_today_map(self) -> Dict[int, int]:
"""
Return {absent_rider_id: sub_rider_id} for today (IST).
Result is cached in memory — one SQLite read per IST day (or after any
register/cancel call). Every dispatch call after the first is a plain
dict lookup with no DB I/O.
"""
today = _today_ist()
with self._lock:
if self._cache_date == today:
return dict(self._cache)
# Cache miss: query DB once, store result
with self._conn() as conn:
rows = conn.execute(
"SELECT absent_rider_id, sub_rider_id FROM rider_substitutions WHERE sub_date=?",
(today,),
).fetchall()
self._cache = {int(r["absent_rider_id"]): int(r["sub_rider_id"]) for r in rows}
self._cache_date = today
if self._cache:
logger.info(f"[Substitution] Cache loaded for {today}: {self._cache}")
return dict(self._cache)
def list_all(self, from_date: Optional[str] = None) -> List[Dict]:
"""Return all substitutions on or after from_date (default: today), ordered by date."""
cutoff = from_date or _today_ist()
with self._conn() as conn:
rows = conn.execute(
"""SELECT sub_date, absent_rider_id, sub_rider_id, created_at
FROM rider_substitutions
WHERE sub_date >= ?
ORDER BY sub_date, absent_rider_id""",
(cutoff,),
).fetchall()
return [dict(r) for r in rows]
_service: Optional[SubstitutionService] = None
_service_lock = threading.Lock()
def get_substitution_service() -> SubstitutionService:
global _service
if _service is None:
with _service_lock:
if _service is None:
_service = SubstitutionService()
return _service