Initial commit
This commit is contained in:
78
app/services/rider/get_active_riders.py
Normal file
78
app/services/rider/get_active_riders.py
Normal file
@@ -0,0 +1,78 @@
|
||||
|
||||
import httpx
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
async def fetch_active_riders() -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch active rider logs from the external API for the current date.
|
||||
Returns a list of rider log dictionaries.
|
||||
"""
|
||||
try:
|
||||
today_str = datetime.now().strftime("%Y-%m-%d")
|
||||
url = "https://jupiter.nearle.app/live/api/v2/partners/getriderlogs/"
|
||||
params = {
|
||||
"applocationid": 1,
|
||||
"partnerid": 44,
|
||||
"fromdate": today_str,
|
||||
"todate": today_str,
|
||||
"keyword": ""
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(url, params=params)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if data and data.get("code") == 200 and data.get("details"):
|
||||
# Filter riders who are in our preferences list and are 'active' or 'idle' (assuming we want online riders)
|
||||
# The user's example showed "onduty": 1. We might want to filter by that.
|
||||
# For now, returning all logs, filtering can happen in assignment logic or here.
|
||||
# Let's return the raw list as requested, filtering logic will be applied during assignment.
|
||||
return data.get("details", [])
|
||||
|
||||
logger.warning(f"Fetch active riders returned no details: {data}")
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching active riders: {e}", exc_info=True)
|
||||
return []
|
||||
|
||||
async def fetch_created_orders() -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch all orders in 'created' state for the current date.
|
||||
"""
|
||||
try:
|
||||
today_str = datetime.now().strftime("%Y-%m-%d")
|
||||
url = "https://jupiter.nearle.app/live/api/v1/orders/tenant/getorders/"
|
||||
# Removed pagesize as per user request to fetch all
|
||||
params = {
|
||||
"applocationid": 0,
|
||||
"tenantid": 0,
|
||||
"locationid": 0,
|
||||
"status": "created",
|
||||
"fromdate": today_str,
|
||||
"todate": today_str,
|
||||
"keyword": "",
|
||||
"pageno": 1
|
||||
# "pagesize" intentionally omitted to fetch all
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.get(url, params=params)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if data and data.get("code") == 200 and data.get("details"):
|
||||
return data.get("details", [])
|
||||
|
||||
logger.warning(f"Fetch created orders returned no details: {data}")
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching created orders: {e}", exc_info=True)
|
||||
return []
|
||||
|
||||
68
app/services/rider/rider_history_service.py
Normal file
68
app/services/rider/rider_history_service.py
Normal file
@@ -0,0 +1,68 @@
|
||||
|
||||
import os
|
||||
import pickle
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Absolute path so this pickle is written to the same location regardless of
|
||||
# which directory uvicorn is launched from.
|
||||
_PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(
|
||||
os.path.abspath(__file__)
|
||||
))))
|
||||
HISTORY_FILE = os.path.join(_PROJECT_ROOT, "data", "rider_history.pkl")
|
||||
|
||||
class RiderHistoryService:
|
||||
def __init__(self, history_file: str = HISTORY_FILE):
|
||||
self.history_file = history_file
|
||||
self.history = self._load_history()
|
||||
|
||||
def _load_history(self) -> Dict[int, Dict[str, float]]:
|
||||
"""Load history from pickle file."""
|
||||
os.makedirs(os.path.dirname(self.history_file), exist_ok=True)
|
||||
if not os.path.exists(self.history_file):
|
||||
return {}
|
||||
|
||||
try:
|
||||
with open(self.history_file, 'rb') as f:
|
||||
return pickle.load(f)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load rider history: {e}")
|
||||
return {}
|
||||
|
||||
def _save_history(self):
|
||||
"""Save history to pickle file."""
|
||||
try:
|
||||
with open(self.history_file, 'wb') as f:
|
||||
pickle.dump(self.history, f)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save rider history: {e}")
|
||||
|
||||
def update_rider_stats(self, rider_id: int, distance_km: float, order_count: int):
|
||||
"""Update cumulative stats for a rider."""
|
||||
rider_id = int(rider_id)
|
||||
if rider_id not in self.history:
|
||||
self.history[rider_id] = {
|
||||
"total_km": 0.0,
|
||||
"total_orders": 0,
|
||||
"last_updated": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
self.history[rider_id]["total_km"] += distance_km
|
||||
self.history[rider_id]["total_orders"] += order_count
|
||||
self.history[rider_id]["last_updated"] = datetime.now().isoformat()
|
||||
|
||||
# Auto-save on update
|
||||
self._save_history()
|
||||
|
||||
def get_rider_score(self, rider_id: int) -> float:
|
||||
"""
|
||||
Get a score representing the rider's historical 'load' (KMs).
|
||||
Higher Score = More KMs driven recently.
|
||||
"""
|
||||
rider_id = int(rider_id)
|
||||
stats = self.history.get(rider_id, {})
|
||||
return stats.get("total_km", 0.0)
|
||||
|
||||
120
app/services/rider/rider_state_manager.py
Normal file
120
app/services/rider/rider_state_manager.py
Normal file
@@ -0,0 +1,120 @@
|
||||
import os
|
||||
import pickle
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Set
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Use an absolute path anchored to the project root (two levels up from this file)
|
||||
# so the pickle is always written to the same place regardless of the working
|
||||
# directory at startup time.
|
||||
_PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(
|
||||
os.path.abspath(__file__)
|
||||
))))
|
||||
STATE_FILE = os.path.join(_PROJECT_ROOT, "data", "rider_active_state.pkl")
|
||||
_FILE_LOCK = threading.Lock()
|
||||
|
||||
|
||||
class RiderStateManager:
|
||||
"""
|
||||
Manages the 'Short-Term' Active State of Riders for session persistence.
|
||||
Tracks:
|
||||
- Minutes Committed (Remaining Workload)
|
||||
- Active Kitchens (Unique Pickups in current queue)
|
||||
- Last Planned Drop Location (for Daisy Chaining)
|
||||
- Timestamp of last update (for Time Decay)
|
||||
"""
|
||||
def __init__(self, state_file: str = STATE_FILE):
|
||||
self.state_file = state_file
|
||||
self.states = self._load_states()
|
||||
|
||||
def _load_states(self) -> Dict[str, Any]:
|
||||
"""Load states from pickle."""
|
||||
os.makedirs(os.path.dirname(self.state_file), exist_ok=True)
|
||||
if not os.path.exists(self.state_file):
|
||||
return {}
|
||||
try:
|
||||
with _FILE_LOCK:
|
||||
with open(self.state_file, 'rb') as f:
|
||||
return pickle.load(f)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load rider active states: {e}")
|
||||
return {}
|
||||
|
||||
def _save_states(self):
|
||||
"""Save states to pickle."""
|
||||
try:
|
||||
with _FILE_LOCK:
|
||||
with open(self.state_file, 'wb') as f:
|
||||
pickle.dump(self.states, f)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save rider active states: {e}")
|
||||
|
||||
def get_rider_state(self, rider_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Get the current active state of a rider with TIME DECAY applied.
|
||||
If the server restarts after 30 mins, the 'minutes_committed' should reduce by 30.
|
||||
"""
|
||||
rider_id = int(rider_id)
|
||||
raw_state = self.states.get(rider_id)
|
||||
|
||||
if not raw_state:
|
||||
return {
|
||||
'minutes_remaining': 0.0,
|
||||
'last_drop_lat': None,
|
||||
'last_drop_lon': None,
|
||||
'active_kitchens': set(),
|
||||
'last_updated_ts': time.time()
|
||||
}
|
||||
|
||||
# Apply Time Decay
|
||||
last_ts = raw_state.get('last_updated_ts', time.time())
|
||||
current_ts = time.time()
|
||||
elapsed_mins = (current_ts - last_ts) / 60.0
|
||||
|
||||
remaining = max(0.0, raw_state.get('minutes_remaining', 0.0) - elapsed_mins)
|
||||
|
||||
# If queue is empty, kitchens are cleared
|
||||
kitchens = raw_state.get('active_kitchens', set())
|
||||
if remaining <= 5.0: # Buffer: if almost done, free up kitchens
|
||||
kitchens = set()
|
||||
|
||||
return {
|
||||
'minutes_remaining': remaining,
|
||||
'last_drop_lat': raw_state.get('last_drop_lat'),
|
||||
'last_drop_lon': raw_state.get('last_drop_lon'),
|
||||
'active_kitchens': kitchens,
|
||||
'last_updated_ts': current_ts
|
||||
}
|
||||
|
||||
def update_rider_state(self, rider_id: int, added_minutes: float, new_kitchens: Set[str], last_lat: float, last_lon: float):
|
||||
"""
|
||||
Update the state after a new assignment.
|
||||
"""
|
||||
rider_id = int(rider_id)
|
||||
|
||||
# Get current state (decayed)
|
||||
current = self.get_rider_state(rider_id)
|
||||
|
||||
# Accumulate
|
||||
updated_minutes = current['minutes_remaining'] + added_minutes
|
||||
updated_kitchens = current['active_kitchens'].union(new_kitchens)
|
||||
|
||||
self.states[rider_id] = {
|
||||
'minutes_remaining': updated_minutes,
|
||||
'last_drop_lat': last_lat,
|
||||
'last_drop_lon': last_lon,
|
||||
'active_kitchens': updated_kitchens,
|
||||
'last_updated_ts': time.time()
|
||||
}
|
||||
|
||||
self._save_states()
|
||||
|
||||
def clear_state(self, rider_id: int):
|
||||
rider_id = int(rider_id)
|
||||
if rider_id in self.states:
|
||||
del self.states[rider_id]
|
||||
self._save_states()
|
||||
163
app/services/rider/substitution_service.py
Normal file
163
app/services/rider/substitution_service.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user