164 lines
5.9 KiB
Python
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
|