""" 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