diff --git a/backend/main.py b/backend/main.py index 8590e9a..2a40b0a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,34 +1,43 @@ -from fleet_management.monitoring import authenticate_trip, end_trip -from fleet_management.trip_request import create_trip_request, recommend_vehicle -from fleet_management.allocation import admin_approve_trip, fleet_manager_confirm_trip -from datetime import datetime - -def cli_demo(): - - # Step 1: Create trip - trip = create_trip_request( - user_id="U001", - pickup="Maseru Central", - destination="Thaba-Tseka", - trip_date=datetime(2026, 1, 15, 9, 0), - purpose="Official Meeting" - ) - recommend_vehicle(trip) - - # Step 2: Admin approval - admin_approve_trip(trip.request_id, approve=True) - - # Step 3: Fleet manager allocation - allocation_info = fleet_manager_confirm_trip(trip.request_id, fleet_manager_id="F001") - - # Step 4: Trip authentication & start - print("\nAuthenticating and starting trip...") - employee_pin = allocation_info['security_pin'] - driver_input_pin = allocation_info['security_pin'] - print(authenticate_trip(trip.request_id, employee_pin, driver_input_pin)) - - # Step 5: End trip - print(end_trip(trip.request_id)) - -if __name__ == "__main__": - cli_demo() +""" +main.py +------- +FastAPI application entrypoint for the GovRide Fleet Management Service. + +Run with: + cd backend/services + uvicorn fleet_management.main:app --reload --port 8000 + +API docs available at: + http://localhost:8000/docs (Swagger UI) + http://localhost:8000/redoc (ReDoc) +""" + +from fastapi import FastAPI +from fleet_management.api.fleet_management_api import router + +app = FastAPI( + title="GovRide Fleet Management API", + description=( + "Autonomous government fleet management system for Lesotho. " + "Handles trip requests, allocation, lifecycle, disruptions, " + "maintenance, standby providers and priority scoring." + ), + version="1.0.0", +) + +app.include_router(router) + + +@app.get("/", tags=["Health"]) +def root(): + return { + "service": "GovRide Fleet Management API", + "version": "1.0.0", + "status": "running", + "docs": "/docs", + } + + +@app.get("/health", tags=["Health"]) +def health_check(): + return {"status": "healthy"} \ No newline at end of file diff --git a/backend/pyrightconfig.json b/backend/pyrightconfig.json new file mode 100644 index 0000000..686e014 --- /dev/null +++ b/backend/pyrightconfig.json @@ -0,0 +1,4 @@ +{ + "pythonVersion": "3.11", + "extraPaths": ["./services"] +} \ No newline at end of file diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..b28fbd7 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +pythonpath = services \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index d084366..71225d3 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,3 +5,5 @@ uvicorn pydantic pytest qrcode[pil] +pulp +httpx \ No newline at end of file diff --git a/backend/services/fleet_management/allocation.py b/backend/services/fleet_management/allocation.py deleted file mode 100644 index 660f41a..0000000 --- a/backend/services/fleet_management/allocation.py +++ /dev/null @@ -1,122 +0,0 @@ -from datetime import datetime -from typing import Optional -import random -from .models import TripRequest, User, Vehicle -from .trip_request import TRIP_REQUESTS, VEHICLE_POOL, EMPLOYEES -from .monitoring import generate_trip_token # <-- Use token for trip auth - -# ---------------------------- -# Mock Data -# ---------------------------- -ADMIN_MANAGERS = [ - User("A001", "Mampho Nthunya", "admin_manager", "M001"), - User("A002", "Lerato Moeti", "admin_manager", "M002"), -] - -FLEET_MANAGERS = [ - User("F001", "Pheko Matela", "fleet_manager", "MFM001"), -] - -DRIVERS = [ - User("D001", "Teboho Mohlomi", "driver", "MFM001"), - User("D002", "Lineo Seeiso", "driver", "MFM001"), -] - -# ---------------------------- -# Admin logic -# ---------------------------- -def get_admin_manager_for_employee(user_id: str) -> Optional[User]: - """Finds the admin manager for the employee's ministry.""" - employee = next((u for u in EMPLOYEES if u.user_id == user_id), None) - if not employee: - return None - return next((a for a in ADMIN_MANAGERS if a.ministry_id == employee.ministry_id), None) - - -def admin_approve_trip(request_id: str, approve: bool) -> str: - """Admin Manager approves or rejects the trip.""" - trip = next((t for t in TRIP_REQUESTS if t.request_id == request_id), None) - if not trip: - return f"Trip {request_id} not found." - - admin_manager = get_admin_manager_for_employee(trip.user_id) - if not admin_manager: - return "No Admin Manager found for employee’s ministry." - - if approve: - trip.status = "approved" - trip.approved_by_admin = admin_manager.name - return f"Trip {trip.request_id} approved by {admin_manager.name}." - else: - trip.status = "rejected" - return f"Trip {trip.request_id} rejected by {admin_manager.name}." - - -# ---------------------------- -# Fleet Manager Allocation -# ---------------------------- -def fleet_manager_confirm_trip(request_id: str, fleet_manager_id: str, - vehicle_id: Optional[str] = None, driver_id: Optional[str] = None): - """Fleet Manager confirms or modifies the system recommendation.""" - trip = next((t for t in TRIP_REQUESTS if t.request_id == request_id), None) - if not trip or trip.status != "approved": - return f"Trip {request_id} not available for allocation." - - fleet_manager = next((f for f in FLEET_MANAGERS if f.user_id == fleet_manager_id), None) - if not fleet_manager: - return f"Fleet Manager with ID {fleet_manager_id} not found." - - vehicle = next((v for v in VEHICLE_POOL if v.vehicle_id == vehicle_id), None) if vehicle_id else VEHICLE_POOL[0] - driver = next((d for d in DRIVERS if d.user_id == driver_id), None) if driver_id else random.choice(DRIVERS) - - trip.status = "allocated" - trip.assigned_vehicle = vehicle.registration_number if vehicle else None - trip.assigned_driver = driver.name if driver else None - trip.recommended_by_fleet_manager = fleet_manager.name - # Generate token for employee to share with driver - trip.token = generate_trip_token(trip.request_id) - - return { - "trip_id": trip.request_id, - "vehicle": trip.assigned_vehicle, - "driver": trip.assigned_driver, - "fleet_manager": fleet_manager.name, - "trip_token": trip.token, # <- use this instead of old PIN - } - - -def rerecommend_trip(request_id: str, new_vehicle_id: Optional[str] = None, new_driver_id: Optional[str] = None): - """Fleet Manager manually re-recommends vehicle/driver.""" - trip = next((t for t in TRIP_REQUESTS if t.request_id == request_id), None) - if not trip: - return f"Trip {request_id} not found." - - if new_vehicle_id: - vehicle = next((v for v in VEHICLE_POOL if v.vehicle_id == new_vehicle_id), None) - trip.assigned_vehicle = vehicle.registration_number if vehicle else trip.assigned_vehicle - - if new_driver_id: - driver = next((d for d in DRIVERS if d.user_id == new_driver_id), None) - trip.assigned_driver = driver.name if driver else trip.assigned_driver - - trip.recommended_by_fleet_manager = FLEET_MANAGERS[0].name - return f"Trip {trip.request_id} rerecommended with updates: vehicle={trip.assigned_vehicle}, driver={trip.assigned_driver}." - - -def auto_rerecommend_trip(request_id: str): - """System automatically re-runs recommendation for vehicle/driver.""" - from .trip_request import recommend_vehicle - - trip = next((t for t in TRIP_REQUESTS if t.request_id == request_id), None) - if not trip: - return f"Trip {request_id} not found." - - if trip.status not in ["approved", "allocated"]: - return f"Trip {trip.request_id} cannot be auto re-recommended in its current state ({trip.status})." - - # Automatically pick a vehicle - new_vehicle = recommend_vehicle(trip) - trip.assigned_vehicle = new_vehicle.registration_number - trip.recommended_by_fleet_manager = FLEET_MANAGERS[0].name - - return f"System automatically re-recommended vehicle {new_vehicle.registration_number} for trip {trip.request_id}." diff --git a/backend/services/fleet_management/api/__init__.py b/backend/services/fleet_management/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/fleet_management/api/fleet_management_api.py b/backend/services/fleet_management/api/fleet_management_api.py new file mode 100644 index 0000000..87f942f --- /dev/null +++ b/backend/services/fleet_management/api/fleet_management_api.py @@ -0,0 +1,598 @@ +""" +fleet_management/api/fleet_management_api.py +--------------------------------------------- +FastAPI router for the Fleet Management Service. + +All endpoints are organised by actor: + - Employee : submit trips, request tokens, confirm completion + - Admin Manager: approve/reject trips, request elevation + - Driver : authenticate, update GPS, mark arrived + - Fleet Manager: override allocation, view fleet, handle disruptions + - Director : approve elevations when admin quota exhausted + +Design principles: + - Every engine function returns a dict with 'success' and 'errors' + - API layer translates those into proper HTTP status codes + - 200 for success, 400 for bad input, 404 for not found, 409 for + state conflicts (e.g. trip not in correct status) + - Request/response models are defined inline using Pydantic + - No business logic in this file — only routing and HTTP translation +""" + +from fastapi import APIRouter, HTTPException, status +from pydantic import BaseModel +from datetime import datetime +from typing import Optional + +from fleet_management.models import ( + TripRequestIn, + AdminApprovalIn, + AllocationIn, + TokenRequestIn, + TokenMode, + TripAuthIn, +) +from fleet_management.engines.trip_request_processor import ( + create_trip_request, + get_trip, + get_trips_by_user, + get_trips_by_status, + TRIP_REQUESTS, +) +from fleet_management.engines.heuristic_allocation_engine import ( + process_admin_decision, + fleet_manager_override, +) +from fleet_management.engines.token_generator_engine import ( + get_token_for_employee, + consume_token, +) +from fleet_management.engines.trip_lifecycle_engine import ( + start_trip, + update_gps, + driver_mark_arrived, + employee_confirm_completion, + get_trip_status, +) +from fleet_management.engines.driver_vehicle_state_manager import ( + get_fleet_availability_snapshot, + update_vehicle_odometer, + record_vehicle_service, + update_driver_hours, + reset_driver_hours_manually, +) +from fleet_management.engines.maintenance_wellbeing_engine import ( + run_full_fleet_health_check, + get_maintenance_schedule, + run_vehicle_maintenance_check, +) +from fleet_management.engines.dynamic_reallocation_engine import ( + handle_disruption, + DisruptionType, +) +from fleet_management.engines.priority_policy_engine import ( + compute_priority_score, + approve_director_elevation, + get_admin_elevation_status, +) +from fleet_management.engines.standby_market_engine import ( + get_provider_rankings, +) +from fleet_management.engines.vehicle_suitability_module import ( + apply_suitability_to_trip, + get_suitability_for_score, +) +from fleet_management.engines.optimization_engine import batch_solve + + +router = APIRouter(prefix="/fleet", tags=["Fleet Management"]) + + +# =========================================================================== +# HELPER +# =========================================================================== + +def _raise(result: dict) -> None: + """ + Translates engine error results into HTTP exceptions. + Keeps endpoint handlers clean — just call _raise(result) if not success. + """ + errors = result.get("errors", ["An unknown error occurred."]) + first_error = errors[0] if errors else "An unknown error occurred." + + if "not found" in first_error.lower(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=first_error) + if "not pending" in first_error.lower() or \ + "cannot be" in first_error.lower() or \ + "not allocated" in first_error.lower() or \ + "not ongoing" in first_error.lower() or \ + "not ready" in first_error.lower() or \ + "not active" in first_error.lower(): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=first_error) + + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=first_error) + + +# =========================================================================== +# REQUEST MODELS (API-layer only) +# =========================================================================== + +class GPSUpdateIn(BaseModel): + latitude: float + longitude: float + + +class DriverArrivalIn(BaseModel): + driver_id: str + + +class DisruptionIn(BaseModel): + disruption_type: DisruptionType + + +class ElevationRequestIn(BaseModel): + admin_id: str + elevation_requested: bool = False + + +class DirectorApprovalIn(BaseModel): + director_id: str + admin_id: str + + +class OdometerUpdateIn(BaseModel): + vehicle_id: str + km_travelled: float + + +class DriverHoursUpdateIn(BaseModel): + driver_id: str + hours_driven: float + + +class ServiceRecordIn(BaseModel): + vehicle_id: str + + +class DriverHoursResetIn(BaseModel): + driver_id: str + + +class ProviderRankingIn(BaseModel): + pickup_latitude: float + pickup_longitude: float + + +# =========================================================================== +# EMPLOYEE ENDPOINTS +# =========================================================================== + +@router.post( + "/trips", + status_code=status.HTTP_201_CREATED, + summary="Submit a new trip request", + description=( + "Employee submits a trip request. Trip is created in PENDING status " + "awaiting Admin Manager approval." + ), +) +def submit_trip_request(data: TripRequestIn): + result = create_trip_request(data) + if not result["success"]: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=result["errors"], + ) + trip = result["trip"] + return { + "trip_id": trip.request_id, + "status": trip.status, + "message": "Trip request submitted successfully. Awaiting Admin Manager approval.", + } + + +@router.get( + "/trips/{trip_id}", + summary="Get trip status and details", + description="Returns the current status, assignment and tracking details for a trip.", +) +def get_trip_details(trip_id: str): + result = get_trip_status(trip_id) + if not result["success"]: + _raise(result) + return result + + +@router.get( + "/trips/user/{user_id}", + summary="Get all trips for an employee", + description="Returns all trips submitted by a specific employee.", +) +def get_employee_trips(user_id: str): + trips = get_trips_by_user(user_id) + return { + "user_id": user_id, + "total": len(trips), + "trips": [ + { + "trip_id": t.request_id, + "destination": t.destination, + "trip_date": t.trip_date, + "status": t.status, + "priority_score": t.priority_score, + } + for t in trips + ], + } + + +@router.post( + "/trips/{trip_id}/token", + summary="Request trip authentication token", + description=( + "Employee requests their QR or text token after allocation. " + "If the token has expired, a fresh one is automatically issued." + ), +) +def request_trip_token(trip_id: str, data: TokenRequestIn): + result = get_token_for_employee(data) + if not result["success"]: + _raise(result) + return result + + +@router.post( + "/trips/{trip_id}/confirm", + summary="Employee confirms trip completion", + description=( + "Employee confirms they have received the service at the destination. " + "Moves trip from ARRIVING to COMPLETED and releases vehicle and driver." + ), +) +def confirm_trip_completion(trip_id: str, user_id: str): + result = employee_confirm_completion(trip_id, user_id) + if not result["success"]: + _raise(result) + return result + + +# =========================================================================== +# ADMIN MANAGER ENDPOINTS +# =========================================================================== + +@router.patch( + "/trips/{trip_id}/approve", + summary="Admin Manager approves or rejects a trip", + description=( + "The only mandatory human step. On approval, the system immediately " + "computes the priority score and auto-allocates a vehicle and driver." + ), +) +def approve_trip(trip_id: str, admin_id: str, data: AdminApprovalIn): + result = process_admin_decision(trip_id, admin_id, data) + if not result["success"]: + _raise(result) + return result + + +@router.post( + "/trips/{trip_id}/elevate", + summary="Request priority elevation for a trip", + description=( + "Admin Manager requests elevation of a trip above its natural tier. " + "Consumes one elevation token from the admin's biweekly quota (max 3). " + "If quota exhausted, trip is flagged for Director digital signature." + ), +) +def elevate_trip_priority(trip_id: str, data: ElevationRequestIn): + result = compute_priority_score( + trip_id, + admin_id=data.admin_id, + elevation_requested=data.elevation_requested, + ) + if not result["success"]: + _raise(result) + return result + + +@router.get( + "/admin/{admin_id}/elevation-status", + summary="Get admin elevation token status", + description=( + "Returns how many elevation tokens an admin has used and how many " + "remain in the current biweekly period." + ), +) +def get_elevation_status(admin_id: str): + return get_admin_elevation_status(admin_id) + + +# =========================================================================== +# DRIVER ENDPOINTS +# =========================================================================== + +@router.post( + "/trips/{trip_id}/start", + summary="Driver authenticates to start trip", + description=( + "Driver scans or enters the token to authenticate and start the trip. " + "Token is consumed — cannot be reused. Moves trip to ONGOING." + ), +) +def start_trip_endpoint(trip_id: str, data: TripAuthIn): + result = start_trip(trip_id, data.token_value) + if not result["success"]: + _raise(result) + return result + + +@router.post( + "/trips/{trip_id}/gps", + summary="Update live GPS coordinates", + description=( + "Driver's device sends GPS coordinates during an ONGOING trip. " + "Each update is appended to the route history." + ), +) +def update_trip_gps(trip_id: str, data: GPSUpdateIn): + result = update_gps(trip_id, data.latitude, data.longitude) + if not result["success"]: + _raise(result) + return result + + +@router.post( + "/trips/{trip_id}/arrived", + summary="Driver marks arrival at destination", + description=( + "Driver marks that they have arrived. Moves trip from ONGOING to ARRIVING. " + "Employee must then confirm completion to close the trip." + ), +) +def mark_arrived(trip_id: str, data: DriverArrivalIn): + result = driver_mark_arrived(trip_id, data.driver_id) + if not result["success"]: + _raise(result) + return result + + +# =========================================================================== +# FLEET MANAGER ENDPOINTS +# =========================================================================== + +@router.patch( + "/trips/{trip_id}/override", + summary="Fleet Manager overrides vehicle/driver allocation", + description=( + "Optional step. System has already auto-allocated. Fleet Manager can " + "swap the vehicle, driver, or both. A fresh token is issued after override." + ), +) +def override_allocation(trip_id: str, data: AllocationIn): + result = fleet_manager_override(trip_id, data) + if not result["success"]: + _raise(result) + return result + + +@router.post( + "/trips/{trip_id}/disrupt", + summary="Report a disruption event", + description=( + "Reports a disruption on an active trip. " + "System automatically handles reallocation based on disruption type. " + "Types: vehicle_breakdown, driver_emergency, trip_cancellation, " + "employee_no_show, route_blocked, vehicle_recalled." + ), +) +def report_disruption(trip_id: str, data: DisruptionIn): + result = handle_disruption(trip_id, data.disruption_type) + if not result["success"]: + _raise(result) + return result + + +@router.get( + "/fleet/snapshot", + summary="Get fleet availability snapshot", + description=( + "Returns all currently available vehicles and drivers with " + "readiness scores, odometer readings and hours remaining." + ), +) +def fleet_snapshot(): + return get_fleet_availability_snapshot() + + +@router.get( + "/fleet/maintenance", + summary="Get vehicle maintenance schedule", + description=( + "Returns vehicles approaching or past their service threshold, " + "sorted by urgency (most overdue first)." + ), +) +def maintenance_schedule(): + return get_maintenance_schedule() + + +@router.post( + "/fleet/health-check", + summary="Run full fleet health check", + description=( + "Runs vehicle maintenance and driver wellbeing checks across the " + "entire fleet. Locks overdue vehicles and suspends drivers at hour limit. " + "Returns fleet_ready: true/false." + ), +) +def fleet_health_check(): + return run_full_fleet_health_check() + + +@router.post( + "/fleet/maintenance-check", + summary="Run vehicle maintenance check", + description="Checks all vehicles against the 10,000km service threshold.", +) +def vehicle_maintenance_check(): + return run_vehicle_maintenance_check() + + +@router.get( + "/trips/{trip_id}/providers", + summary="Get ranked standby provider list", + description=( + "Returns all available standby providers ranked by score for a trip. " + "Used by Fleet Manager to inspect provider selection before escalation." + ), +) +def get_ranked_providers(trip_id: str, data: ProviderRankingIn): + result = get_provider_rankings( + trip_id, data.pickup_latitude, data.pickup_longitude + ) + if not result["success"]: + _raise(result) + return result + + +@router.post( + "/fleet/vehicles/odometer", + summary="Update vehicle odometer after trip", + description=( + "Records km travelled and updates the vehicle odometer. " + "Readiness score is recomputed immediately after update." + ), +) +def update_odometer(data: OdometerUpdateIn): + result = update_vehicle_odometer(data.vehicle_id, data.km_travelled) + if not result["success"]: + _raise(result) + return result + + +@router.post( + "/fleet/vehicles/service", + summary="Record a vehicle service", + description=( + "Records that a vehicle has been serviced. Updates last_service_date " + "and last_service_odometer_km. Readiness score is recomputed immediately." + ), +) +def record_service(data: ServiceRecordIn): + result = record_vehicle_service(data.vehicle_id) + if not result["success"]: + _raise(result) + return result + + +@router.post( + "/fleet/drivers/hours", + summary="Update driver hours after trip", + description=( + "Adds hours to a driver's biweekly accumulator. " + "Driver is automatically suspended if 60-hour limit is reached." + ), +) +def update_hours(data: DriverHoursUpdateIn): + result = update_driver_hours(data.driver_id, data.hours_driven) + if not result["success"]: + _raise(result) + return result + + +@router.post( + "/fleet/drivers/reset-hours", + summary="Manually reset driver hours", + description=( + "Fleet Manager manually resets a driver's hour accumulator. " + "Reinstates suspended drivers to AVAILABLE." + ), +) +def reset_hours(data: DriverHoursResetIn): + result = reset_driver_hours_manually(data.driver_id) + if not result["success"]: + _raise(result) + return result + + +# =========================================================================== +# VEHICLE SUITABILITY ENDPOINTS +# =========================================================================== + +class SuitabilityApplyIn(BaseModel): + terrain_difficulty_score: float + + +@router.post( + "/trips/{trip_id}/suitability", + summary="Apply terrain suitability to a trip", + description=( + "Called by the GIS service after computing the Terrain Difficulty Score. " + "Converts the score into a vehicle hint and suitability scores, " + "then writes them onto the trip for use by the Allocation Engine." + ), +) +def apply_terrain_suitability(trip_id: str, data: SuitabilityApplyIn): + result = apply_suitability_to_trip(trip_id, data.terrain_difficulty_score) + if not result["success"]: + _raise(result) + return result + + +@router.get( + "/suitability/{score}", + summary="Get vehicle suitability for a terrain score", + description=( + "Returns the vehicle hint and per-vehicle suitability scores " + "for a given terrain difficulty score (0.0-1.0). " + "Used by Fleet Manager dashboard for inspection." + ), +) +def get_terrain_suitability(score: float): + return get_suitability_for_score(score) + + +# =========================================================================== +# OPTIMIZATION ENDPOINTS +# =========================================================================== + +class BatchSolveIn(BaseModel): + trip_date: datetime + vehicle_type_filter: Optional[str] = None + + +@router.post( + "/optimize/batch", + summary="Run batch optimization for a specific date", + description=( + "Triggered when many trips for the same day need optimisation. " + "Runs the MILP solver (or heuristic fallback) across all pending trips " + "for the given date. Returns updated assignments and auditable justification JSON." + ), +) +def run_batch_optimization(data: BatchSolveIn): + result = batch_solve(data.trip_date, data.vehicle_type_filter) + return result + + +# =========================================================================== +# DIRECTOR ENDPOINTS +# =========================================================================== + +@router.post( + "/trips/{trip_id}/director-approve", + summary="Director approves priority elevation", + description=( + "Director digitally approves an elevation when the Admin Manager's " + "biweekly quota is exhausted. One-time exception — does not restore quota." + ), +) +def director_approve_elevation(trip_id: str, data: DirectorApprovalIn): + result = approve_director_elevation( + trip_id, + director_id=data.director_id, + admin_id=data.admin_id, + ) + if not result["success"]: + _raise(result) + return result \ No newline at end of file diff --git a/backend/services/fleet_management/engines/__init__.py b/backend/services/fleet_management/engines/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/fleet_management/engines/allocation_utils.py b/backend/services/fleet_management/engines/allocation_utils.py new file mode 100644 index 0000000..ccfc7e8 --- /dev/null +++ b/backend/services/fleet_management/engines/allocation_utils.py @@ -0,0 +1,133 @@ +""" +fleet_management/engines/allocation_utils.py +--------------------------------------------- +Shared allocation utility functions used by multiple engines. + +Design note: + These functions were originally private helpers inside + heuristic_allocation_engine.py but are needed by both the + heuristic allocation engine and the dynamic reallocation engine. + Extracting them here keeps each engine clean and avoids importing + private functions (underscore-prefixed) across module boundaries. + +Functions: + select_best_vehicle -- heuristic vehicle selection from pool + select_best_driver -- heuristic driver selection from pool + commit_allocation -- writes allocation onto trip, locks vehicle/driver + release_current_allocation -- frees vehicle and driver from a trip +""" + +from datetime import datetime +from typing import Optional + +from fleet_management.models import ( + TripRequest, + TripStatus, + Vehicle, + VehicleStatus, + Driver, + DriverStatus, + VEHICLE_HINT_MAP, + VehicleType, +) + + +def select_best_vehicle( + trip: TripRequest, + vehicle_pool: list[Vehicle], +) -> Optional[Vehicle]: + """ + Picks the most suitable available vehicle using heuristic rules. + + Rules applied in order: + 1. Vehicle must be AVAILABLE + 2. Vehicle type must match the terrain vehicle_hint from GIS + (if no hint is set, sedan_ok is assumed — any vehicle works) + 3. Vehicle capacity must cover the number of passengers + 4. Highest readiness_score wins among remaining candidates + """ + hint = trip.vehicle_hint or "sedan_ok" + acceptable_types = VEHICLE_HINT_MAP.get(hint, [t.value for t in VehicleType]) + + if not acceptable_types: + return None + + candidates = [ + v for v in vehicle_pool + if v.current_status == VehicleStatus.AVAILABLE + and v.vehicle_type.value in acceptable_types + and v.capacity >= trip.passengers + ] + + if not candidates: + return None + + return max(candidates, key=lambda v: v.readiness_score) + + +def select_best_driver(driver_pool: list[Driver]) -> Optional[Driver]: + """ + Picks the best available driver using heuristic rules. + + Rules applied in order: + 1. Driver must be AVAILABLE + 2. Fewest hours_driven_this_period wins (wellbeing rule) + """ + candidates = [d for d in driver_pool if d.status == DriverStatus.AVAILABLE] + if not candidates: + return None + return min(candidates, key=lambda d: d.hours_driven_this_period) + + +def commit_allocation( + trip: TripRequest, + vehicle: Vehicle, + driver: Driver, + allocated_by: str, +) -> None: + """ + Writes allocation onto the trip and locks the vehicle and driver. + + This is the single point where allocation state is committed — + both auto-allocation and fleet manager override go through here. + This ensures vehicle/driver status is always updated consistently. + """ + trip.assigned_vehicle_id = vehicle.vehicle_id + trip.assigned_driver_id = driver.driver_id + trip.allocated_by = allocated_by + trip.allocated_at = datetime.now() + trip.status = TripStatus.ALLOCATED + + vehicle.current_status = VehicleStatus.ON_TRIP + driver.status = DriverStatus.ON_TRIP + driver.current_vehicle_id = vehicle.vehicle_id + + +def release_current_allocation( + trip: TripRequest, + vehicle_pool: list[Vehicle], + driver_pool: list[Driver], +) -> None: + """ + Frees the currently allocated vehicle and driver back to AVAILABLE. + + Must be called before committing a new allocation to prevent + previously assigned resources from remaining locked as ON_TRIP + even though they are no longer assigned to the trip. + """ + if trip.assigned_vehicle_id: + prev_vehicle = next( + (v for v in vehicle_pool if v.vehicle_id == trip.assigned_vehicle_id), + None + ) + if prev_vehicle: + prev_vehicle.current_status = VehicleStatus.AVAILABLE + + if trip.assigned_driver_id: + prev_driver = next( + (d for d in driver_pool if d.driver_id == trip.assigned_driver_id), + None + ) + if prev_driver: + prev_driver.status = DriverStatus.AVAILABLE + prev_driver.current_vehicle_id = None \ No newline at end of file diff --git a/backend/services/fleet_management/engines/driver_vehicle_state_manager.py b/backend/services/fleet_management/engines/driver_vehicle_state_manager.py new file mode 100644 index 0000000..4bad78a --- /dev/null +++ b/backend/services/fleet_management/engines/driver_vehicle_state_manager.py @@ -0,0 +1,408 @@ +""" +fleet_management/engines/driver_vehicle_state_manager.py +--------------------------------------------------------- +Responsible for: + 1. Computing vehicle readiness scores based on mileage, age and service recency + 2. Updating odometer readings as trips complete + 3. Tracking and accumulating driver hours across trips + 4. Resetting driver hours on a biweekly cycle + 5. Providing current availability snapshots for the allocation engine + +Design reference: + - "Tracks availability & readiness of drivers and vehicles" + - Called by the trip lifecycle engine when trips complete (odometer + hours update) + - Called by the allocation engine before selecting vehicles (readiness refresh) + +Readiness Score Formula (research-based): + Based on Jardine et al. (Maintenance, Replacement and Reliability, 2006) + and Pintelon & Parodi-Herz (2008) fleet reliability models. + + readiness = (mileage_factor x 0.5) + (age_factor x 0.2) + (service_factor x 0.3) + + mileage_factor -- how far the vehicle is from its next service (0.0-1.0) + uses last_service_odometer_km for accurate km-since-service + age_factor -- degradation based on years since manufacture_date (0.0-1.0) + service_factor -- time elapsed since last_service_date in days (0.0-1.0) + + Weights reflect that mileage is the strongest predictor of mechanical + failure in operational mixed-terrain fleets (urban + rural, as in Lesotho). + +Driver Hours Policy: + Based on EU Drivers Hours Regulation (EC) No 561/2006. + Maximum 60 hours per 14-day (biweekly) period. + Hours accumulate in hours_driven_this_period on the Driver model. + Reset is driven by hours_period_start on the Driver model. + Reset automatically every 14 days — no manual intervention needed. + +Service Interval: + 10,000 km -- standard for mixed urban/rural government fleets in Southern Africa. + Aligns with South African National Roads Agency and regional fleet policies. +""" + +from datetime import datetime +from fleet_management.models import ( + Vehicle, + VehicleStatus, + Driver, + DriverStatus, +) +from fleet_management.engines.heuristic_allocation_engine import ( + VEHICLE_POOL, + DRIVER_POOL, +) + + +# =========================================================================== +# CONSTANTS +# =========================================================================== + +SERVICE_INTERVAL_KM = 10_000 # km between scheduled services +MAX_VEHICLE_AGE_YEARS = 15 # vehicles older than this score 0.0 on age factor +MAX_SERVICE_GAP_DAYS = 365 # vehicles unserviced longer than this score 0.0 +MAX_DRIVER_HOURS_BIWEEKLY = 60.0 # EU standard -- max hours per 14-day period +DRIVER_HOURS_RESET_DAYS = 14 # biweekly reset cycle + + +# =========================================================================== +# VEHICLE READINESS SCORING +# =========================================================================== + +def _compute_mileage_factor(vehicle: Vehicle) -> float: + """ + How close the vehicle is to its next service threshold. + + Formula: + km_since_service = odometer_km - last_service_odometer_km + mileage_factor = 1.0 - (km_since_service / SERVICE_INTERVAL_KM) + + A vehicle at 0km since last service scores 1.0. + A vehicle at exactly the service limit scores 0.0. + A vehicle past the service limit is clamped to 0.0. + + Design note: + Uses last_service_odometer_km from the Vehicle model for an accurate + km-since-service reading rather than modulo approximation. + """ + km_since_service = vehicle.odometer_km - vehicle.last_service_odometer_km + + # Guard against bad data (e.g. last_service_odometer > current odometer) + if km_since_service < 0: + km_since_service = 0.0 + + factor = 1.0 - (km_since_service / SERVICE_INTERVAL_KM) + return max(0.0, min(1.0, factor)) + + +def _compute_age_factor(vehicle: Vehicle) -> float: + """ + Degradation based on years since the vehicle's manufacture date. + + Formula: + age_years = (now - manufacture_date).days / 365.25 + age_factor = 1.0 - (age_years / MAX_VEHICLE_AGE_YEARS) + + A brand new vehicle scores 1.0. + A vehicle at MAX_VEHICLE_AGE_YEARS scores 0.0. + + Design note: + Uses manufacture_date from the Vehicle model for an accurate age + calculation. If manufacture_date is not recorded, a neutral score + of 0.5 is returned rather than penalising the vehicle unfairly. + """ + if vehicle.manufacture_date is None: + return 0.5 # Unknown manufacture date -- neutral score + + years_in_service = (datetime.now() - vehicle.manufacture_date).days / 365.25 + factor = 1.0 - (years_in_service / MAX_VEHICLE_AGE_YEARS) + return max(0.0, min(1.0, factor)) + + +def _compute_service_factor(vehicle: Vehicle) -> float: + """ + Time elapsed since last service in days. + + Formula: + days_since_service = (now - last_service_date).days + service_factor = 1.0 - (days_since_service / MAX_SERVICE_GAP_DAYS) + + A vehicle serviced today scores 1.0. + A vehicle unserviced for MAX_SERVICE_GAP_DAYS scores 0.0. + + Design note: + Even vehicles with low mileage degrade over time due to fluid + degradation, rubber perishing, and corrosion. This factor captures + calendar-based degradation independent of usage. + Reference: Pintelon & Parodi-Herz (2008). + """ + if vehicle.last_service_date is None: + return 0.0 # No service record -- worst case + + days_since_service = (datetime.now() - vehicle.last_service_date).days + factor = 1.0 - (days_since_service / MAX_SERVICE_GAP_DAYS) + return max(0.0, min(1.0, factor)) + + +def compute_vehicle_readiness(vehicle: Vehicle) -> float: + """ + Computes the composite readiness score for a vehicle. + + Formula (Jardine et al., 2006): + readiness = (mileage_factor x 0.5) + (age_factor x 0.2) + (service_factor x 0.3) + + Weights: + 0.5 -- mileage is the strongest predictor of mechanical failure + 0.2 -- age contributes to latent degradation + 0.3 -- service recency ensures time-based maintenance is captured + + Score range: 0.0 (not ready) to 1.0 (fully ready) + """ + mileage_factor = _compute_mileage_factor(vehicle) + age_factor = _compute_age_factor(vehicle) + service_factor = _compute_service_factor(vehicle) + + score = (mileage_factor * 0.5) + (age_factor * 0.2) + (service_factor * 0.3) + return round(max(0.0, min(1.0, score)), 4) + + +def refresh_all_vehicle_readiness() -> list[dict]: + """ + Recomputes and updates readiness_score for all vehicles in the pool. + Called periodically (e.g. at the start of each day) or before allocation + to ensure the allocation engine always has current readiness data. + + Returns a summary of all vehicles and their updated scores. + """ + results = [] + for vehicle in VEHICLE_POOL: + old_score = vehicle.readiness_score + new_score = compute_vehicle_readiness(vehicle) + vehicle.readiness_score = new_score + results.append({ + "vehicle_id": vehicle.vehicle_id, + "registration": vehicle.registration_number, + "old_score": old_score, + "new_score": new_score, + "status": vehicle.current_status, + }) + return results + + +# =========================================================================== +# ODOMETER UPDATES +# =========================================================================== + +def update_vehicle_odometer(vehicle_id: str, km_travelled: float) -> dict: + """ + Updates the odometer reading of a vehicle after a trip completes. + Called by the trip lifecycle engine on trip completion. + + After updating the odometer, readiness_score is recomputed immediately + so the allocation engine always has fresh data for the next allocation. + + Design note: + km_travelled is calculated from the GPS track recorded during the trip. + The GIS service will eventually compute this more accurately from the + route geometry. For now it is passed in directly. + """ + vehicle = next((v for v in VEHICLE_POOL if v.vehicle_id == vehicle_id), None) + if not vehicle: + return { + "success": False, + "errors": [f"Vehicle '{vehicle_id}' not found."] + } + + if km_travelled < 0: + return { + "success": False, + "errors": ["km_travelled cannot be negative."] + } + + vehicle.odometer_km += km_travelled + + # Recompute readiness immediately after odometer update + vehicle.readiness_score = compute_vehicle_readiness(vehicle) + + return { + "success": True, + "vehicle_id": vehicle_id, + "new_odometer_km": vehicle.odometer_km, + "km_since_last_service": vehicle.odometer_km - vehicle.last_service_odometer_km, + "new_readiness_score": vehicle.readiness_score, + } + + +def record_vehicle_service(vehicle_id: str) -> dict: + """ + Records that a vehicle has been serviced. + Updates last_service_date and last_service_odometer_km. + Recomputes readiness immediately — score should jump significantly. + + Called by the Maintenance & Wellbeing Engine or Fleet Manager + after a vehicle returns from the workshop. + """ + vehicle = next((v for v in VEHICLE_POOL if v.vehicle_id == vehicle_id), None) + if not vehicle: + return { + "success": False, + "errors": [f"Vehicle '{vehicle_id}' not found."] + } + + vehicle.last_service_date = datetime.now() + vehicle.last_service_odometer_km = vehicle.odometer_km + + # Recompute readiness -- mileage and service factors should now be high + vehicle.readiness_score = compute_vehicle_readiness(vehicle) + + return { + "success": True, + "vehicle_id": vehicle_id, + "serviced_at_odometer_km": vehicle.odometer_km, + "new_readiness_score": vehicle.readiness_score, + "message": "Vehicle service recorded. Readiness score updated.", + } + + +# =========================================================================== +# DRIVER HOURS TRACKING +# =========================================================================== + +def _check_and_reset_driver_hours(driver: Driver) -> bool: + """ + Resets hours_driven_this_period if the 14-day biweekly period has elapsed. + Uses hours_period_start on the Driver model -- no module-level state needed. + + Returns True if a reset was performed, False otherwise. + """ + days_since_reset = (datetime.now() - driver.hours_period_start).days + if days_since_reset >= DRIVER_HOURS_RESET_DAYS: + driver.hours_driven_this_period = 0.0 + driver.hours_period_start = datetime.now() + return True + return False + + +def update_driver_hours(driver_id: str, hours_driven: float) -> dict: + """ + Adds hours to a driver's biweekly accumulator after a trip completes. + Called by the trip lifecycle engine on trip completion. + + Design reference: + EU Drivers Hours Regulation (EC) No 561/2006 -- + maximum 60 hours per 14-day period. + + If the biweekly reset is due, hours are reset before adding the new ones. + This ensures the window always reflects the current 14-day period. + + If the driver hits the 60-hour limit, status is set to SUSPENDED. + The Maintenance & Wellbeing Engine enforces this further. + """ + driver = next((d for d in DRIVER_POOL if d.driver_id == driver_id), None) + if not driver: + return { + "success": False, + "errors": [f"Driver '{driver_id}' not found."] + } + + if hours_driven < 0: + return { + "success": False, + "errors": ["hours_driven cannot be negative."] + } + + # Check and apply biweekly reset before adding new hours + was_reset = _check_and_reset_driver_hours(driver) + + driver.hours_driven_this_period += hours_driven + + hours_remaining = MAX_DRIVER_HOURS_BIWEEKLY - driver.hours_driven_this_period + at_limit = driver.hours_driven_this_period >= MAX_DRIVER_HOURS_BIWEEKLY + + if at_limit: + driver.status = DriverStatus.SUSPENDED + + return { + "success": True, + "driver_id": driver_id, + "hours_driven_this_period": driver.hours_driven_this_period, + "hours_remaining": max(0.0, hours_remaining), + "period_start": driver.hours_period_start, + "period_reset_applied": was_reset, + "at_limit": at_limit, + "status": driver.status, + } + + +def reset_driver_hours_manually(driver_id: str) -> dict: + """ + Manually resets a driver's hour accumulator. + Used by the Fleet Manager for exceptional circumstances + (e.g. correcting a data entry error). + + Also reinstates a SUSPENDED driver to AVAILABLE. + """ + driver = next((d for d in DRIVER_POOL if d.driver_id == driver_id), None) + if not driver: + return { + "success": False, + "errors": [f"Driver '{driver_id}' not found."] + } + + driver.hours_driven_this_period = 0.0 + driver.hours_period_start = datetime.now() + + if driver.status == DriverStatus.SUSPENDED: + driver.status = DriverStatus.AVAILABLE + + return { + "success": True, + "driver_id": driver_id, + "message": "Driver hours reset. Driver reinstated to AVAILABLE.", + } + + +# =========================================================================== +# AVAILABILITY SNAPSHOT +# =========================================================================== + +def get_fleet_availability_snapshot() -> dict: + """ + Returns a current snapshot of all vehicle and driver availability. + Used by the Fleet Manager dashboard and the allocation engine. + + Design reference: + "Tracks availability & readiness of drivers and vehicles" + """ + vehicles_available = [ + { + "vehicle_id": v.vehicle_id, + "registration": v.registration_number, + "type": v.vehicle_type, + "readiness_score": v.readiness_score, + "odometer_km": v.odometer_km, + "km_since_last_service": v.odometer_km - v.last_service_odometer_km, + "status": v.current_status, + } + for v in VEHICLE_POOL + if v.current_status == VehicleStatus.AVAILABLE + ] + + drivers_available = [ + { + "driver_id": d.driver_id, + "name": d.name, + "hours_driven_this_period": d.hours_driven_this_period, + "hours_remaining": max(0.0, MAX_DRIVER_HOURS_BIWEEKLY - d.hours_driven_this_period), + "period_start": d.hours_period_start, + "status": d.status, + } + for d in DRIVER_POOL + if d.status == DriverStatus.AVAILABLE + ] + + return { + "success": True, + "vehicles_available": len(vehicles_available), + "drivers_available": len(drivers_available), + "vehicles": vehicles_available, + "drivers": drivers_available, + } \ No newline at end of file diff --git a/backend/services/fleet_management/engines/dynamic_reallocation_engine.py b/backend/services/fleet_management/engines/dynamic_reallocation_engine.py new file mode 100644 index 0000000..94821ba --- /dev/null +++ b/backend/services/fleet_management/engines/dynamic_reallocation_engine.py @@ -0,0 +1,784 @@ +""" +fleet_management/engines/dynamic_reallocation_engine.py +-------------------------------------------------------- +Responsible for: + 1. Detecting and handling fleet disruption events + 2. Locking in-progress trip states during recalculation + 3. Rebuilding updated vehicle/driver pools and constraints + 4. Preparing warm-start MILP inputs for the Optimization Engine + 5. Invoking the Optimization Engine (stubbed — to be wired in later) + 6. Deciding whether the new solution is significantly better + 7. Pushing schedule updates or maintaining current assignment + +Design reference: + - "Event Listener + Recalculator" + - "Detects disruptions: Breakdown, Driver unavailable, Emergency trip, + Weather/road issues" + - "Recalculates: Updated vehicle pool, Updated driver pool, + Updated priority, Updated feasibility constraints" + - "Prepares warm-start MILP inputs" + - "Invokes Optimization Engine only when needed" + +Flowchart (from design): + Event: High-Priority Trip OR Vehicle Breakdown + -> Lock In-Progress Trip States + -> Trigger MIP Warm-Start for Pending Pool + -> Is New Solution Significantly Better? + Yes -> Push Schedule Updates to Driver/Passenger Apps + No -> Maintain Current Assignment to Avoid Confusion + +Disruption scenarios handled: + 1. VEHICLE_BREAKDOWN -- vehicle fails mid-trip + 2. DRIVER_EMERGENCY -- driver incapacitated mid-trip + 3. TRIP_CANCELLATION -- trip cancelled after allocation + 4. EMPLOYEE_NO_SHOW -- employee did not arrive at pickup + 5. ROUTE_BLOCKED -- road impassable (flood, landslide) + 6. VEHICLE_RECALLED -- Fleet Manager recalls vehicle for priority trip + +Improvement threshold: + A new solution is considered "significantly better" if it improves + the overall allocation score by more than IMPROVEMENT_THRESHOLD (15%). + Below this threshold, the disruption to drivers and employees from + reassignment outweighs the benefit. + Reference: Psaraftis et al. (Dynamic Vehicle Routing, 2016) +""" + +from datetime import datetime +from enum import Enum +from typing import Optional + +from fleet_management.models import ( + TripRequest, + TripStatus, + Vehicle, + VehicleStatus, + Driver, + DriverStatus, +) +from fleet_management.engines.trip_request_processor import ( + TRIP_REQUESTS, + get_trip, +) +from fleet_management.engines.heuristic_allocation_engine import ( + VEHICLE_POOL, + DRIVER_POOL, +) +from fleet_management.engines.allocation_utils import ( + select_best_vehicle, + select_best_driver, + commit_allocation, + release_current_allocation, +) +from fleet_management.engines.token_generator_engine import issue_token +from fleet_management.engines.standby_market_engine import escalate_to_standby + + +# =========================================================================== +# CONSTANTS +# =========================================================================== + +IMPROVEMENT_THRESHOLD = 0.15 # 15% improvement required to push new solution + + +# =========================================================================== +# DISRUPTION EVENT TYPES +# =========================================================================== + +class DisruptionType(str, Enum): + VEHICLE_BREAKDOWN = "vehicle_breakdown" + DRIVER_EMERGENCY = "driver_emergency" + TRIP_CANCELLATION = "trip_cancellation" + EMPLOYEE_NO_SHOW = "employee_no_show" + ROUTE_BLOCKED = "route_blocked" + VEHICLE_RECALLED = "vehicle_recalled" + + +# =========================================================================== +# WARM-START MILP INPUT STRUCTURE +# =========================================================================== + +class WarmStartInput: + """ + Structured input prepared for the Optimization Engine. + + Design reference: + "Prepares warm-start MILP inputs" + A warm-start provides the MILP solver with a feasible initial solution + (current allocation) so it doesn't start from scratch. This significantly + reduces solve time for real-time reallocation. + Reference: Psaraftis et al. (Dynamic Vehicle Routing, 2016) + + Fields: + locked_trips -- trips currently ONGOING or ARRIVING (cannot be changed) + pending_trips -- trips APPROVED or ALLOCATED (can be reassigned) + available_vehicles -- vehicles currently free for allocation + available_drivers -- drivers currently free for allocation + current_solution -- existing allocation as warm-start baseline + disruption_type -- what triggered this reallocation + triggered_at -- when the event occurred + """ + def __init__( + self, + locked_trips: list[TripRequest], + pending_trips: list[TripRequest], + available_vehicles: list[Vehicle], + available_drivers: list[Driver], + current_solution: dict, + disruption_type: DisruptionType, + triggered_at: datetime, + ): + self.locked_trips = locked_trips + self.pending_trips = pending_trips + self.available_vehicles = available_vehicles + self.available_drivers = available_drivers + self.current_solution = current_solution + self.disruption_type = disruption_type + self.triggered_at = triggered_at + + +# =========================================================================== +# INTERNAL HELPERS +# =========================================================================== + +def _lock_in_progress_trips() -> list[TripRequest]: + """ + Returns all trips currently ONGOING or ARRIVING. + These trips are locked — their allocations cannot be changed + during recalculation because drivers and vehicles are already + committed and passengers are in transit. + + Design reference: + "Lock In-Progress Trip States" (flowchart step 1) + """ + return [ + t for t in TRIP_REQUESTS + if t.status in [TripStatus.ONGOING, TripStatus.ARRIVING] + ] + + +def _get_pending_trips() -> list[TripRequest]: + """ + Returns all trips that are APPROVED or ALLOCATED. + These are the trips that can be reassigned by the optimization engine. + + Design reference: + "Trigger MIP Warm-Start for Pending Pool" (flowchart step 2) + """ + return [ + t for t in TRIP_REQUESTS + if t.status in [TripStatus.APPROVED, TripStatus.ALLOCATED] + ] + + +def _get_available_vehicles() -> list[Vehicle]: + """Returns vehicles currently available for allocation.""" + return [v for v in VEHICLE_POOL if v.current_status == VehicleStatus.AVAILABLE] + + +def _get_available_drivers() -> list[Driver]: + """Returns drivers currently available for allocation.""" + return [d for d in DRIVER_POOL if d.status == DriverStatus.AVAILABLE] + + +def _build_current_solution(pending_trips: list[TripRequest]) -> dict: + """ + Captures the current allocation state as a warm-start baseline. + Maps trip_id -> {vehicle_id, driver_id} for all allocated trips. + + Design reference: + Warm-start provides the MILP solver with a feasible initial solution + so it can improve from the current state rather than starting cold. + """ + return { + t.request_id: { + "vehicle_id": t.assigned_vehicle_id, + "driver_id": t.assigned_driver_id, + } + for t in pending_trips + if t.assigned_vehicle_id and t.assigned_driver_id + } + + +def _compute_solution_score(trips: list[TripRequest]) -> float: + """ + Computes a simple heuristic score for the current allocation. + Used to determine if the new solution is significantly better. + + Score = average readiness score of allocated vehicles across all trips. + Higher is better. + + Design reference: + "Is New Solution Significantly Better?" (flowchart decision) + In production, the Optimization Engine returns its own objective value. + This heuristic score is used until the MILP solver is wired in. + """ + scores = [] + for trip in trips: + if trip.assigned_vehicle_id: + vehicle = next( + (v for v in VEHICLE_POOL if v.vehicle_id == trip.assigned_vehicle_id), + None + ) + if vehicle: + scores.append(vehicle.readiness_score) + return sum(scores) / len(scores) if scores else 0.0 + + +def _is_significantly_better(old_score: float, new_score: float) -> bool: + """ + Determines if the new solution is worth the disruption of reassignment. + + Design reference: + "Is New Solution Significantly Better?" (flowchart decision) + Psaraftis et al. (2016): unnecessary reassignments cause confusion + and reduce trust in the system. Only push updates if the improvement + exceeds the threshold. + + Threshold: 15% improvement required. + """ + if old_score == 0.0: + return new_score > 0.0 + return (new_score - old_score) / old_score >= IMPROVEMENT_THRESHOLD + + +# =========================================================================== +# OPTIMIZATION ENGINE STUB +# =========================================================================== + +def _invoke_optimization_engine(warm_start: WarmStartInput) -> dict: + """ + STUB — Invokes the Optimization Engine with warm-start inputs. + + Design reference: + "Invokes Optimization Engine only when needed" + "Prepares warm-start MILP inputs" + + This stub uses the heuristic allocation engine as a fallback until + the MILP solver (optimization_engine.py) is built and wired in. + + When optimization_engine.py is ready, replace this function body with: + from fleet_management.engines.optimization_engine import solve + return solve(warm_start) + + Returns a dict with: + - success (bool) + - solution: dict mapping trip_id -> {vehicle_id, driver_id} + - score: float (objective value) + """ + solution = {} + score = 0.0 + + for trip in warm_start.pending_trips: + vehicle = select_best_vehicle(trip, VEHICLE_POOL) + driver = select_best_driver(DRIVER_POOL) + + if vehicle and driver: + solution[trip.request_id] = { + "vehicle_id": vehicle.vehicle_id, + "driver_id": driver.driver_id, + } + score += vehicle.readiness_score + else: + solution[trip.request_id] = { + "vehicle_id": None, + "driver_id": None, + } + + avg_score = score / len(warm_start.pending_trips) if warm_start.pending_trips else 0.0 + + return { + "success": True, + "solution": solution, + "score": avg_score, + } + + +# =========================================================================== +# DISRUPTION HANDLERS +# =========================================================================== + +def _handle_vehicle_breakdown(trip_id: str) -> dict: + """ + Vehicle has broken down mid-trip. + Releases the broken vehicle to MAINTENANCE. + Attempts to find a replacement vehicle. + Driver stays assigned if a new vehicle is found. + + Design reference: + "Breakdown" disruption type. + Vehicle goes to MAINTENANCE — not AVAILABLE. + Trip stays ONGOING with a new vehicle assignment. + """ + trip = get_trip(trip_id) + if not trip: + return {"success": False, "errors": [f"Trip '{trip_id}' not found."]} + + if trip.status not in [TripStatus.ONGOING, TripStatus.ALLOCATED]: + return { + "success": False, + "errors": [f"Trip '{trip_id}' is not active. Status: {trip.status}."] + } + + # Send broken vehicle to MAINTENANCE + broken_vehicle = next( + (v for v in VEHICLE_POOL if v.vehicle_id == trip.assigned_vehicle_id), None + ) + if broken_vehicle: + broken_vehicle.current_status = VehicleStatus.MAINTENANCE + + # Clear vehicle assignment and find a replacement + trip.assigned_vehicle_id = None + + replacement = select_best_vehicle(trip, VEHICLE_POOL) + if not replacement: + # No government vehicle — escalate to standby + trip.status = TripStatus.APPROVED # Reset for standby escalation + pickup_lat = trip.current_latitude or -29.3167 + pickup_lon = trip.current_longitude or 27.4833 + standby_result = escalate_to_standby(trip_id, pickup_lat, pickup_lon) + return { + **standby_result, + "disruption": DisruptionType.VEHICLE_BREAKDOWN, + } + + replacement.current_status = VehicleStatus.ON_TRIP + trip.assigned_vehicle_id = replacement.vehicle_id + + return { + "success": True, + "trip_id": trip_id, + "disruption": DisruptionType.VEHICLE_BREAKDOWN, + "replacement_vehicle": { + "vehicle_id": replacement.vehicle_id, + "registration": replacement.registration_number, + "type": replacement.vehicle_type, + }, + "message": f"Replacement vehicle {replacement.registration_number} assigned.", + } + + +def _handle_driver_emergency(trip_id: str) -> dict: + """ + Driver has become incapacitated mid-trip. + Releases the driver and finds a replacement. + Vehicle stays assigned if a new driver is found. + + Design reference: + "Driver unavailable" disruption type. + """ + trip = get_trip(trip_id) + if not trip: + return {"success": False, "errors": [f"Trip '{trip_id}' not found."]} + + if trip.status not in [TripStatus.ONGOING, TripStatus.ALLOCATED]: + return { + "success": False, + "errors": [f"Trip '{trip_id}' is not active. Status: {trip.status}."] + } + + # Release the incapacitated driver + affected_driver = next( + (d for d in DRIVER_POOL if d.driver_id == trip.assigned_driver_id), None + ) + if affected_driver: + affected_driver.status = DriverStatus.OFF_DUTY + affected_driver.current_vehicle_id = None + + trip.assigned_driver_id = None + + # Find a replacement driver + replacement = select_best_driver(DRIVER_POOL) + if not replacement: + return { + "success": False, + "trip_id": trip_id, + "errors": [ + "No available driver found for replacement. " + "Fleet Manager intervention required." + ] + } + + replacement.status = DriverStatus.ON_TRIP + replacement.current_vehicle_id = trip.assigned_vehicle_id + trip.assigned_driver_id = replacement.driver_id + + # Issue a new token for the replacement driver + trip.status = TripStatus.ALLOCATED # Reset for token reissue + token_result = issue_token(trip_id) + trip.status = TripStatus.ONGOING # Restore to ongoing + + return { + "success": True, + "trip_id": trip_id, + "disruption": DisruptionType.DRIVER_EMERGENCY, + "replacement_driver": { + "driver_id": replacement.driver_id, + "name": replacement.name, + }, + "new_token_id": token_result.get("token_id"), + "message": f"Replacement driver {replacement.name} assigned.", + } + + +def _handle_trip_cancellation(trip_id: str) -> dict: + """ + Trip cancelled after allocation. + Releases vehicle and driver back to AVAILABLE. + No reallocation needed. + + Design reference: + Cancellation after allocation — release resources and close trip. + """ + trip = get_trip(trip_id) + if not trip: + return {"success": False, "errors": [f"Trip '{trip_id}' not found."]} + + if trip.status not in [TripStatus.ALLOCATED, TripStatus.APPROVED]: + return { + "success": False, + "errors": [ + f"Trip '{trip_id}' cannot be cancelled. " + f"Status: {trip.status}. " + "Only ALLOCATED or APPROVED trips can be cancelled." + ] + } + + release_current_allocation(trip, VEHICLE_POOL, DRIVER_POOL) + trip.status = TripStatus.CANCELLED + + return { + "success": True, + "trip_id": trip_id, + "disruption": DisruptionType.TRIP_CANCELLATION, + "message": "Trip cancelled. Vehicle and driver released.", + } + + +def _handle_employee_no_show(trip_id: str) -> dict: + """ + Employee did not arrive at the pickup location. + Releases vehicle and driver. Trip is cancelled. + + Design reference: + Employee no-show — resources wasted, trip closed. + """ + trip = get_trip(trip_id) + if not trip: + return {"success": False, "errors": [f"Trip '{trip_id}' not found."]} + + if trip.status != TripStatus.ALLOCATED: + return { + "success": False, + "errors": [ + f"Trip '{trip_id}' cannot be marked as no-show. " + f"Status: {trip.status}." + ] + } + + release_current_allocation(trip, VEHICLE_POOL, DRIVER_POOL) + trip.status = TripStatus.CANCELLED + + return { + "success": True, + "trip_id": trip_id, + "disruption": DisruptionType.EMPLOYEE_NO_SHOW, + "message": "Employee no-show recorded. Trip cancelled. Resources released.", + } + + +def _handle_route_blocked(trip_id: str) -> dict: + """ + Route is impassable — flood, landslide, or road closure. + Common in Lesotho's mountain districts. + + The terrain hint may need to change (e.g. from sedan_ok to 4x4_required) + so both vehicle and driver are released and fully reallocated. + + Design reference: + "Weather/road issues" disruption type. + """ + trip = get_trip(trip_id) + if not trip: + return {"success": False, "errors": [f"Trip '{trip_id}' not found."]} + + if trip.status not in [TripStatus.ONGOING, TripStatus.ALLOCATED]: + return { + "success": False, + "errors": [f"Trip '{trip_id}' is not active. Status: {trip.status}."] + } + + # Release current vehicle and driver — terrain may require different vehicle + release_current_allocation(trip, VEHICLE_POOL, DRIVER_POOL) + + # Upgrade terrain hint — route blockage often means rougher terrain needed + if trip.vehicle_hint in [None, "sedan_ok"]: + trip.vehicle_hint = "suv_preferred" + elif trip.vehicle_hint == "suv_preferred": + trip.vehicle_hint = "4x4_required" + + # Reallocate with updated terrain hint + trip.status = TripStatus.APPROVED + vehicle = select_best_vehicle(trip, VEHICLE_POOL) + driver = select_best_driver(DRIVER_POOL) + + if not vehicle: + pickup_lat = trip.current_latitude or -29.3167 + pickup_lon = trip.current_longitude or 27.4833 + standby_result = escalate_to_standby(trip.request_id, pickup_lat, pickup_lon) + return { + **standby_result, + "disruption": DisruptionType.ROUTE_BLOCKED, + } + + if not driver: + return { + "success": False, + "trip_id": trip_id, + "disruption": DisruptionType.ROUTE_BLOCKED, + "errors": ["No available driver for reallocation after route blockage."] + } + + commit_allocation(trip, vehicle, driver, allocated_by="system-reallocation") + token_result = issue_token(trip_id) + + return { + "success": True, + "trip_id": trip_id, + "disruption": DisruptionType.ROUTE_BLOCKED, + "updated_vehicle_hint": trip.vehicle_hint, + "replacement_vehicle": { + "vehicle_id": vehicle.vehicle_id, + "registration": vehicle.registration_number, + "type": vehicle.vehicle_type, + }, + "replacement_driver": { + "driver_id": driver.driver_id, + "name": driver.name, + }, + "new_token_id": token_result.get("token_id"), + "message": ( + f"Route blocked. Trip reallocated with updated terrain hint " + f"'{trip.vehicle_hint}'." + ), + } + + +def _handle_vehicle_recalled(trip_id: str) -> dict: + """ + Fleet Manager recalls the vehicle for a higher-priority trip. + Releases the recalled vehicle and finds a replacement. + Driver stays assigned if a new vehicle is found. + + Design reference: + "Vehicle recalled by Fleet Manager" disruption type. + """ + trip = get_trip(trip_id) + if not trip: + return {"success": False, "errors": [f"Trip '{trip_id}' not found."]} + + if trip.status not in [TripStatus.ALLOCATED, TripStatus.ONGOING]: + return { + "success": False, + "errors": [ + f"Trip '{trip_id}' vehicle cannot be recalled. " + f"Status: {trip.status}." + ] + } + + # Important: find replacement BEFORE releasing the recalled vehicle. + # If we release first, the recalled vehicle becomes AVAILABLE and + # gets selected as its own replacement — same bug as fleet_manager_override. + recalled_vehicle = next( + (v for v in VEHICLE_POOL if v.vehicle_id == trip.assigned_vehicle_id), None + ) + + # Temporarily clear vehicle hint reference for selection + # so the recalled vehicle is excluded (it's still ON_TRIP at this point) + replacement = next( + ( + v for v in sorted(VEHICLE_POOL, key=lambda v: v.readiness_score, reverse=True) + if v.current_status == VehicleStatus.AVAILABLE + and v.vehicle_id != trip.assigned_vehicle_id + and v.capacity >= trip.passengers + ), + None + ) + + if not replacement: + # Release the recalled vehicle before escalating + if recalled_vehicle: + recalled_vehicle.current_status = VehicleStatus.AVAILABLE + trip.assigned_vehicle_id = None + pickup_lat = trip.current_latitude or -29.3167 + pickup_lon = trip.current_longitude or 27.4833 + trip.status = TripStatus.APPROVED + standby_result = escalate_to_standby(trip_id, pickup_lat, pickup_lon) + return { + **standby_result, + "disruption": DisruptionType.VEHICLE_RECALLED, + } + + # Now safe to release the recalled vehicle + if recalled_vehicle: + recalled_vehicle.current_status = VehicleStatus.AVAILABLE + + replacement.current_status = VehicleStatus.ON_TRIP + trip.assigned_vehicle_id = replacement.vehicle_id + + # Reissue token if trip was allocated but not yet started + new_token_id = None + if trip.status == TripStatus.ALLOCATED: + token_result = issue_token(trip_id) + new_token_id = token_result.get("token_id") + + return { + "success": True, + "trip_id": trip_id, + "disruption": DisruptionType.VEHICLE_RECALLED, + "replacement_vehicle": { + "vehicle_id": replacement.vehicle_id, + "registration": replacement.registration_number, + "type": replacement.vehicle_type, + }, + "new_token_id": new_token_id, + "message": f"Vehicle recalled. Replacement {replacement.registration_number} assigned.", + } + + +# =========================================================================== +# MAIN EVENT HANDLER +# =========================================================================== + +def handle_disruption(trip_id: str, disruption_type: DisruptionType) -> dict: + """ + Main entry point for all disruption events. + Routes to the correct handler based on disruption type. + + Flowchart: + Event detected + -> Lock in-progress trips + -> Recalculate pending pool + -> Prepare warm-start inputs + -> Invoke Optimization Engine (stubbed) + -> Is new solution significantly better? + Yes -> Push updates + No -> Maintain current assignment + + Design reference: + "Invokes Optimization Engine only when needed" + The optimization step runs AFTER the disruption is handled to + check if the broader pending pool can be improved. The disruption + handler resolves the immediate problem; the optimizer checks + if any knock-on reallocation is worth making. + """ + + # Step 1 — Route to the correct disruption handler + handlers = { + DisruptionType.VEHICLE_BREAKDOWN: _handle_vehicle_breakdown, + DisruptionType.DRIVER_EMERGENCY: _handle_driver_emergency, + DisruptionType.TRIP_CANCELLATION: _handle_trip_cancellation, + DisruptionType.EMPLOYEE_NO_SHOW: _handle_employee_no_show, + DisruptionType.ROUTE_BLOCKED: _handle_route_blocked, + DisruptionType.VEHICLE_RECALLED: _handle_vehicle_recalled, + } + + handler = handlers.get(disruption_type) + if not handler: + return { + "success": False, + "errors": [f"Unknown disruption type: '{disruption_type}'."] + } + + disruption_result = handler(trip_id) + + # Step 2 — Lock in-progress trips + locked_trips = _lock_in_progress_trips() + pending_trips = _get_pending_trips() + + # Step 3 — Build warm-start inputs for optimization engine + warm_start = WarmStartInput( + locked_trips=locked_trips, + pending_trips=pending_trips, + available_vehicles=_get_available_vehicles(), + available_drivers=_get_available_drivers(), + current_solution=_build_current_solution(pending_trips), + disruption_type=disruption_type, + triggered_at=datetime.now(), + ) + + # Capture current solution score before optimization + old_score = _compute_solution_score(pending_trips) + + # Step 4 — Invoke optimization engine (stubbed) + opt_result = _invoke_optimization_engine(warm_start) + + # Step 5 — Is the new solution significantly better? + new_score = opt_result.get("score", 0.0) + + if _is_significantly_better(old_score, new_score): + # Push the optimized solution + optimization_applied = True + _apply_optimized_solution(opt_result["solution"], pending_trips) + else: + # Maintain current assignment — disruption not worth cascade of changes + optimization_applied = False + + return { + **disruption_result, + "optimization": { + "applied": optimization_applied, + "old_score": round(old_score, 4), + "new_score": round(new_score, 4), + "pending_trips_affected": len(pending_trips), + "message": ( + "Optimization applied — schedule updated." + if optimization_applied else + "Optimization not applied — improvement below threshold. " + "Current assignment maintained." + ), + } + } + + +def _apply_optimized_solution(solution: dict, pending_trips: list[TripRequest]) -> None: + """ + Applies the optimized solution to the pending trip pool. + Updates vehicle and driver assignments for all affected trips. + + Design reference: + "Push Schedule Updates to Driver/Passenger Apps" (flowchart) + In production, this also triggers notifications via the + Notification Service. Stubbed here — notification calls will + be added when the Notification Service is built. + """ + for trip in pending_trips: + assignment = solution.get(trip.request_id) + if not assignment: + continue + + new_vehicle_id = assignment.get("vehicle_id") + new_driver_id = assignment.get("driver_id") + + if not new_vehicle_id or not new_driver_id: + continue + + # Only update if assignment has changed + if (trip.assigned_vehicle_id == new_vehicle_id and + trip.assigned_driver_id == new_driver_id): + continue + + # Release current allocation and apply new one + release_current_allocation(trip, VEHICLE_POOL, DRIVER_POOL) + + new_vehicle = next( + (v for v in VEHICLE_POOL if v.vehicle_id == new_vehicle_id), None + ) + new_driver = next( + (d for d in DRIVER_POOL if d.driver_id == new_driver_id), None + ) + + if new_vehicle and new_driver: + commit_allocation( + trip, new_vehicle, new_driver, + allocated_by="system-optimization" + ) \ No newline at end of file diff --git a/backend/services/fleet_management/engines/heuristic_allocation_engine.py b/backend/services/fleet_management/engines/heuristic_allocation_engine.py new file mode 100644 index 0000000..e02fd32 --- /dev/null +++ b/backend/services/fleet_management/engines/heuristic_allocation_engine.py @@ -0,0 +1,556 @@ +""" +fleet_management/engines/heuristic_allocation_engine.py +-------------------------------------------------------- +This engine handles the full allocation flow for Fleet Management. +It is built in stages following the trip lifecycle: + + Stage 1 - Admin approval (ONLY mandatory human step) done + Stage 2 - Autonomous vehicle & driver allocation current + Stage 3 - Fleet Manager override (optional) + +Design reference: + - "Fast, rule-based allocation of vehicles/drivers" + - "The only mandatory human intervention is admin approval/rejection" + - "Fleet Manager role is optional — system works without them" + - "No vehicle found -> escalate to Standby Market Engine" + - "No driver found -> block trip, notify Fleet Manager" +""" + +from datetime import datetime +from typing import Optional + +from fleet_management.models import ( + TripRequest, + TripStatus, + Vehicle, + VehicleType, + VehicleStatus, + FuelType, + Driver, + DriverStatus, + User, + UserRole, + AdminApprovalIn, + VEHICLE_HINT_MAP, +) +from fleet_management.engines.trip_request_processor import ( + EMPLOYEES, + get_trip, +) +from fleet_management.engines.token_generator_engine import issue_token +from fleet_management.engines.standby_market_engine import escalate_to_standby +from fleet_management.engines.allocation_utils import ( + select_best_vehicle, + select_best_driver, + commit_allocation, + release_current_allocation, +) +from fleet_management.engines.priority_policy_engine import compute_priority_score + + +# =========================================================================== +# IN-MEMORY STORES +# =========================================================================== + +ADMIN_MANAGERS: list[User] = [ + User(user_id="A001", name="Mampho Nthunya", role=UserRole.ADMIN_MANAGER, ministry_id="M001"), + User(user_id="A002", name="Lerato Moeti", role=UserRole.ADMIN_MANAGER, ministry_id="M002"), +] + + +# =========================================================================== +# INTERNAL HELPERS +# =========================================================================== + +def _get_admin_for_employee(user_id: str) -> Optional[User]: + """ + Finds the Admin Manager responsible for the employee's ministry. + + Design note: + Each ministry has one Admin Manager. An Admin Manager from Ministry M001 + cannot approve trips for employees in Ministry M002. This enforces + ministry-level governance — each ministry controls its own trips. + """ + employee = next((u for u in EMPLOYEES if u.user_id == user_id), None) + if not employee: + return None + return next((a for a in ADMIN_MANAGERS if a.ministry_id == employee.ministry_id), None) + + +# =========================================================================== +# STAGE 1 — ADMIN APPROVAL (MANDATORY HUMAN STEP) +# =========================================================================== + +def process_admin_decision(request_id: str, admin_id: str, data: AdminApprovalIn) -> dict: + """ + The Admin Manager approves or rejects a PENDING trip. + + Design reference: + "The only mandatory human intervention is admin approval/rejection. + No trip moves forward without this step." + + On approval: + - Trip status moves from PENDING → APPROVED + - The system is now ready to allocate (Stage 2 will handle this) + + On rejection: + - Trip status moves to REJECTED + - rejection_reason is recorded on the trip object + - The flow ends here — no allocation, no token + + Validation checks: + 1. Trip must exist + 2. Trip must be in PENDING status + 3. Admin Manager must belong to the same ministry as the employee + """ + + # Check 1 — trip exists + trip = get_trip(request_id) + if not trip: + return { + "success": False, + "errors": [f"Trip '{request_id}' not found."] + } + + # Check 2 — trip must be pending + if trip.status != TripStatus.PENDING: + return { + "success": False, + "errors": [f"Trip '{request_id}' is not pending. Current status: {trip.status}."] + } + + # Check 3 — admin must belong to the employee's ministry + admin_manager = _get_admin_for_employee(trip.user_id) + if not admin_manager or admin_manager.user_id != admin_id: + return { + "success": False, + "errors": ["This Admin Manager is not authorised to approve trips for this employee's ministry."] + } + + # --- REJECTION --- + if not data.approve: + trip.status = TripStatus.REJECTED + trip.approved_by = admin_id + trip.rejection_reason = data.rejection_reason or "No reason provided." + return { + "success": True, + "trip_id": trip.request_id, + "status": TripStatus.REJECTED, + "rejected_by": admin_manager.name, + "reason": trip.rejection_reason, + } + + # --- APPROVAL --- + trip.status = TripStatus.APPROVED + trip.approved_by = admin_id + trip.approved_at = datetime.now() + + # Compute priority score immediately on approval. + # Design reference: "Priority & Policy Engine outputs a single numeric + # priority value P_i" — stored on TripRequest for the Optimization Engine. + # elevation_requested defaults to False here — admin can separately + # call compute_priority_score with elevation_requested=True if needed. + compute_priority_score(trip.request_id, admin_id=admin_id) + + # Immediately trigger autonomous allocation — no Fleet Manager needed + # Design reference: "System is autonomous — allocation happens on approval" + allocation_result = auto_allocate(trip) + return { + "approved_by": admin_manager.name, + **allocation_result, + } + + +# =========================================================================== +# IN-MEMORY STORES — VEHICLES & DRIVERS +# =========================================================================== + +VEHICLE_POOL: list[Vehicle] = [ + Vehicle( + vehicle_id="V001", + registration_number="A 123-001", + vehicle_type=VehicleType.SEDAN, + fuel_type=FuelType.PETROL, + capacity=4, + current_status=VehicleStatus.AVAILABLE, + readiness_score=0.95, + odometer_km=12000, + last_service_odometer_km=8000, + manufacture_date=datetime(2020, 3, 15), + last_service_date=datetime(2024, 6, 1), + ), + Vehicle( + vehicle_id="V002", + registration_number="A 456-002", + vehicle_type=VehicleType.FOUR_BY_FOUR, + fuel_type=FuelType.DIESEL, + capacity=5, + current_status=VehicleStatus.AVAILABLE, + readiness_score=0.90, + odometer_km=8500, + last_service_odometer_km=5000, + manufacture_date=datetime(2021, 7, 20), + last_service_date=datetime(2024, 8, 15), + ), + Vehicle( + vehicle_id="V003", + registration_number="A 789-003", + vehicle_type=VehicleType.SUV, + fuel_type=FuelType.DIESEL, + capacity=6, + current_status=VehicleStatus.AVAILABLE, + readiness_score=0.85, + odometer_km=20000, + last_service_odometer_km=15000, + manufacture_date=datetime(2019, 1, 10), + last_service_date=datetime(2024, 5, 20), + ), + Vehicle( + vehicle_id="V004", + registration_number="A 321-004", + vehicle_type=VehicleType.MINIBUS, + fuel_type=FuelType.DIESEL, + capacity=14, + current_status=VehicleStatus.MAINTENANCE, + readiness_score=0.0, + odometer_km=45000, + last_service_odometer_km=40000, + manufacture_date=datetime(2015, 11, 5), + last_service_date=datetime(2023, 12, 1), + ), +] + +DRIVER_POOL: list[Driver] = [ + Driver( + driver_id="D001", + name="Teboho Mohlomi", + license_number="LS-DRV-001", + status=DriverStatus.AVAILABLE, + hours_driven_this_period=2.5, + ), + Driver( + driver_id="D002", + name="Lineo Seeiso", + license_number="LS-DRV-002", + status=DriverStatus.AVAILABLE, + hours_driven_this_period=0.0, + ), + Driver( + driver_id="D003", + name="Retselisitsoe Tau", + license_number="LS-DRV-003", + status=DriverStatus.ON_TRIP, + hours_driven_this_period=5.0, + ), +] + +# VEHICLE_HINT_MAP imported from models.py +# Moved there to avoid circular import with standby_market_engine + + +# =========================================================================== +# STAGE 2 HELPERS — HEURISTIC SELECTION +# =========================================================================== + +def _select_best_vehicle(trip: TripRequest) -> Optional[Vehicle]: + """ + Picks the most suitable available vehicle using heuristic rules. + + Rules applied in order: + 1. Vehicle must be AVAILABLE + 2. Vehicle type must match the terrain vehicle_hint from GIS + (if no hint is set, sedan_ok is assumed — any vehicle works) + 3. Vehicle capacity must cover the number of passengers + 4. Highest readiness_score wins among remaining candidates + + Design reference: + "GIS returns vehicle_hint -> Allocation Engine uses it" + The hint comes from the GIS Vehicle Suitability Module and is stored + on the TripRequest after the GIS service is called. If GIS has not + been called yet (e.g. no terrain data), we fall back to sedan_ok. + """ + hint = trip.vehicle_hint or "sedan_ok" + acceptable_types = VEHICLE_HINT_MAP.get(hint, [t.value for t in VehicleType]) + + # specialist_required means no standard vehicle qualifies at all + if not acceptable_types: + return None + + candidates = [ + v for v in VEHICLE_POOL + if v.current_status == VehicleStatus.AVAILABLE + and v.vehicle_type.value in acceptable_types + and v.capacity >= trip.passengers + ] + + if not candidates: + return None + + return max(candidates, key=lambda v: v.readiness_score) + + +def _select_best_driver() -> Optional[Driver]: + """ + Picks the best available driver using heuristic rules. + + Rules applied in order: + 1. Driver must be AVAILABLE + 2. Fewest hours_driven_today wins + + Design reference: + The hours_driven_today rule is a wellbeing rule — it prevents fatigued + drivers from being allocated. The Maintenance & Wellbeing Engine will + enforce hard limits on this later, but the allocation engine already + prefers the most rested driver at selection time. + """ + candidates = [d for d in DRIVER_POOL if d.status == DriverStatus.AVAILABLE] + if not candidates: + return None + return min(candidates, key=lambda d: d.hours_driven_this_period) + + +def _commit_allocation(trip: TripRequest, vehicle: Vehicle, driver: Driver, allocated_by: str) -> None: + """ + Writes allocation onto the trip and locks the vehicle and driver. + + This is the single point where allocation state is committed — both + auto-allocation and fleet manager override go through here. This ensures + vehicle/driver status is always updated consistently. + """ + trip.assigned_vehicle_id = vehicle.vehicle_id + trip.assigned_driver_id = driver.driver_id + trip.allocated_by = allocated_by + trip.allocated_at = datetime.now() + trip.status = TripStatus.ALLOCATED + + # Lock vehicle and driver so they cannot be double-allocated + vehicle.current_status = VehicleStatus.ON_TRIP + driver.status = DriverStatus.ON_TRIP + driver.current_vehicle_id = vehicle.vehicle_id + + +# =========================================================================== +# STAGE 2 — AUTONOMOUS ALLOCATION (SYSTEM-DRIVEN) +# =========================================================================== + +def auto_allocate(trip: TripRequest) -> dict: + """ + System automatically selects the best vehicle and driver the moment + a trip is approved. No human input is needed. + + Design reference: + "The system is autonomous — the moment admin approves, allocation + happens immediately without waiting for a Fleet Manager." + + Outcomes: + - Vehicle found, driver found -> trip moves to ALLOCATED, token issued + - Vehicle not found -> escalate to Standby Market Engine + - Driver not found -> trip stays APPROVED, Fleet Manager notified + + Note on token: + Token generation is handled by token_generator_engine.py which we + will build next. The call is stubbed here as a placeholder. + """ + + # --- VEHICLE SELECTION --- + vehicle = _select_best_vehicle(trip) + + if not vehicle: + # Design decision: no standard vehicle available -> escalate to standby market + # Design reference: "Integrates private vehicle providers if fleet insufficient" + # Use pickup location coordinates — defaulting to Maseru centre if not set + pickup_lat = trip.current_latitude or -29.3167 + pickup_lon = trip.current_longitude or 27.4833 + standby_result = escalate_to_standby(trip.request_id, pickup_lat, pickup_lon) + return { + **standby_result, + "escalate_to_standby": not standby_result["success"], + } + + # --- DRIVER SELECTION --- + driver = _select_best_driver() + + if not driver: + # Design decision: no driver available -> block trip, notify Fleet Manager + return { + "success": False, + "trip_id": trip.request_id, + "status": trip.status, # Stays APPROVED + "escalate_to_standby": False, + "errors": [ + "No available driver found. Trip is blocked until a driver becomes free. " + "Fleet Manager has been notified." + ], + } + + # --- COMMIT ALLOCATION --- + _commit_allocation(trip, vehicle, driver, allocated_by="system") + + # Issue token immediately after allocation + token_result = issue_token(trip.request_id) + + return { + "success": True, + "trip_id": trip.request_id, + "status": TripStatus.ALLOCATED, + "allocated_by": "system", + "vehicle": { + "vehicle_id": vehicle.vehicle_id, + "registration": vehicle.registration_number, + "type": vehicle.vehicle_type, + }, + "driver": { + "driver_id": driver.driver_id, + "name": driver.name, + }, + "token_id": token_result.get("token_id"), + "message": "Vehicle and driver allocated. Token issued.", + } + +# =========================================================================== +# STAGE 3 — FLEET MANAGER OVERRIDE (OPTIONAL) +# =========================================================================== + +FLEET_MANAGERS: list[User] = [ + User(user_id="F001", name="Pheko Matela", role=UserRole.FLEET_MANAGER), +] + + +def fleet_manager_override(request_id: str, data) -> dict: + """ + Allows a Fleet Manager to override the system's auto-allocation. + Can swap the vehicle, the driver, or both. + + Design reference: + "Fleet Manager role is optional — they can confirm or override + but the system works without them." + + This function can only be called on an ALLOCATED trip — meaning the + system has already done its job. The Fleet Manager is overriding a + decision that was already made, not making the first decision. + + When overriding: + - The previously allocated vehicle and driver are released back + to AVAILABLE so they can be used for other trips + - The new vehicle and driver are locked (ON_TRIP) + - A fresh token will be issued (stubbed until token engine is built) + + Validation checks: + 1. Trip must exist + 2. Trip must be in ALLOCATED status — cannot override before system allocates + 3. Fleet Manager must exist in the system + 4. New vehicle (if specified) must be AVAILABLE + 5. New driver (if specified) must be AVAILABLE + """ + + trip = get_trip(request_id) + if not trip: + return { + "success": False, + "errors": [f"Trip '{request_id}' not found."] + } + + # Only ALLOCATED trips can be overridden + # PENDING and APPROVED trips are still being processed by the system + if trip.status != TripStatus.ALLOCATED: + return { + "success": False, + "errors": [ + f"Trip '{request_id}' cannot be overridden. " + f"Current status: {trip.status}. " + "Only ALLOCATED trips can be overridden." + ] + } + + fleet_manager = next((f for f in FLEET_MANAGERS if f.user_id == data.fleet_manager_id), None) + if not fleet_manager: + return { + "success": False, + "errors": [f"Fleet Manager '{data.fleet_manager_id}' not found."] + } + + # --- RESOLVE NEW VEHICLE --- + # Important: validate BEFORE releasing the current allocation. + # If we released first, the previously assigned vehicle would become + # AVAILABLE and pass the availability check — even though the Fleet + # Manager is trying to assign the same vehicle that was just freed. + if data.vehicle_id: + # Fleet Manager specified a vehicle — use it if available + vehicle = next((v for v in VEHICLE_POOL if v.vehicle_id == data.vehicle_id), None) + if not vehicle: + return {"success": False, "errors": [f"Vehicle '{data.vehicle_id}' not found."]} + if vehicle.current_status != VehicleStatus.AVAILABLE: + return {"success": False, "errors": [f"Vehicle '{data.vehicle_id}' is not available."]} + else: + # Fleet Manager did not specify — system picks the best one again + # Temporarily exclude the currently assigned vehicle from selection + vehicle = _select_best_vehicle(trip) + if not vehicle: + return {"success": False, "errors": ["No suitable vehicle available for override."]} + + # --- RESOLVE NEW DRIVER --- + if data.driver_id: + # Fleet Manager specified a driver — use them if available + driver = next((d for d in DRIVER_POOL if d.driver_id == data.driver_id), None) + if not driver: + return {"success": False, "errors": [f"Driver '{data.driver_id}' not found."]} + if driver.status != DriverStatus.AVAILABLE: + return {"success": False, "errors": [f"Driver '{data.driver_id}' is not available."]} + else: + # Fleet Manager did not specify — system picks the best one again + driver = _select_best_driver() + if not driver: + return {"success": False, "errors": ["No available driver found for override."]} + + # Now safe to release — validation has already passed + _release_current_allocation(trip) + + # --- COMMIT THE OVERRIDE --- + _commit_allocation(trip, vehicle, driver, allocated_by=data.fleet_manager_id) + + # Re-issue a fresh token after override + token_result = issue_token(trip.request_id) + + return { + "success": True, + "trip_id": trip.request_id, + "status": TripStatus.ALLOCATED, + "overridden_by": fleet_manager.name, + "vehicle": { + "vehicle_id": vehicle.vehicle_id, + "registration": vehicle.registration_number, + "type": vehicle.vehicle_type, + }, + "driver": { + "driver_id": driver.driver_id, + "name": driver.name, + }, + "token_id": token_result.get("token_id"), + "message": "Allocation overridden by Fleet Manager. Fresh token issued.", + } + + +def _release_current_allocation(trip: TripRequest) -> None: + """ + Frees the currently allocated vehicle and driver back to AVAILABLE. + + Design note: + This must happen before a new allocation is committed. Without this, + the previously assigned vehicle and driver would remain locked as + ON_TRIP even though they are no longer assigned to this trip — making + them invisible to the system for future allocations. + """ + if trip.assigned_vehicle_id: + prev_vehicle = next( + (v for v in VEHICLE_POOL if v.vehicle_id == trip.assigned_vehicle_id), None + ) + if prev_vehicle: + prev_vehicle.current_status = VehicleStatus.AVAILABLE + + if trip.assigned_driver_id: + prev_driver = next( + (d for d in DRIVER_POOL if d.driver_id == trip.assigned_driver_id), None + ) + if prev_driver: + prev_driver.status = DriverStatus.AVAILABLE + prev_driver.current_vehicle_id = None \ No newline at end of file diff --git a/backend/services/fleet_management/engines/maintenance_wellbeing_engine.py b/backend/services/fleet_management/engines/maintenance_wellbeing_engine.py new file mode 100644 index 0000000..19e2794 --- /dev/null +++ b/backend/services/fleet_management/engines/maintenance_wellbeing_engine.py @@ -0,0 +1,363 @@ +""" +fleet_management/engines/maintenance_wellbeing_engine.py +--------------------------------------------------------- +Responsible for: + 1. Monitoring vehicles against the 10,000km service threshold + - Warning at 8,000km (80% of threshold) + - Auto-lock at 10,000km (hard limit — no override) + 2. Monitoring drivers against the 60-hour biweekly limit + - Warning at 50 hours (83% of limit) + - Hard suspension at 60 hours (already enforced by state manager) + 3. Running a full fleet health check across all vehicles and drivers + 4. Providing a maintenance schedule — vehicles approaching or past threshold + +Design reference: + - "Locks vehicles, tracks driver hours, enforces safety" + - Two-stage approach: warn first, auto-lock at hard limit + - Reference: Jardine et al. (Maintenance, Replacement and Reliability, 2006) + - Reference: UK Government Fleet Management Guidelines + - Government accountability principle: automatic lock creates auditable trail — + the system locked it, not a person's judgment + +Thresholds: + Vehicle service warning : 8,000km since last service (80% of 10,000km interval) + Vehicle service hard lock: 10,000km since last service (100% — AUTO MAINTENANCE) + Driver hours warning : 50 hours in biweekly period (83% of 60hr limit) + Driver hours hard limit : 60 hours — AUTO SUSPENSION (enforced by state manager) +""" + +from datetime import datetime +from fleet_management.models import ( + Vehicle, + VehicleStatus, + Driver, + DriverStatus, +) +from fleet_management.engines.heuristic_allocation_engine import ( + VEHICLE_POOL, + DRIVER_POOL, +) +from fleet_management.engines.driver_vehicle_state_manager import ( + SERVICE_INTERVAL_KM, + MAX_DRIVER_HOURS_BIWEEKLY, + compute_vehicle_readiness, +) + + +# =========================================================================== +# THRESHOLDS +# =========================================================================== + +VEHICLE_WARNING_KM = SERVICE_INTERVAL_KM * 0.80 # 8,000km — warn Fleet Manager +VEHICLE_LOCK_KM = SERVICE_INTERVAL_KM * 1.00 # 10,000km — auto-lock +DRIVER_WARNING_HOURS = MAX_DRIVER_HOURS_BIWEEKLY * 0.833 # 50hrs — warn Fleet Manager +DRIVER_HARD_LIMIT_HOURS = MAX_DRIVER_HOURS_BIWEEKLY # 60hrs — auto-suspend + + +# =========================================================================== +# VEHICLE MAINTENANCE ENFORCEMENT +# =========================================================================== + +def check_vehicle_maintenance(vehicle: Vehicle) -> dict: + """ + Checks a single vehicle against the service threshold. + + Design reference: + Two-stage approach (Jardine et al., 2006 / UK Gov Fleet Guidelines): + Stage 1 — WARNING at 8,000km: vehicle flagged, Fleet Manager notified + Stage 2 — HARD LOCK at 10,000km: system sets status to MAINTENANCE, + vehicle cannot be allocated until serviced + + The hard lock is unconditional — no Fleet Manager can override it. + This is a deliberate governance design for a government fleet where + accountability and audit trails are critical. + + Returns a dict describing the vehicle's maintenance status: + - status: "ok" | "warning" | "locked" + - km_since_service: float + - action_taken: bool (True if vehicle was locked by this call) + - message: str + """ + km_since_service = vehicle.odometer_km - vehicle.last_service_odometer_km + + # --- HARD LOCK (10,000km) --- + if km_since_service >= VEHICLE_LOCK_KM: + action_taken = vehicle.current_status != VehicleStatus.MAINTENANCE + + # Lock the vehicle unconditionally + vehicle.current_status = VehicleStatus.MAINTENANCE + vehicle.readiness_score = compute_vehicle_readiness(vehicle) + + return { + "vehicle_id": vehicle.vehicle_id, + "registration": vehicle.registration_number, + "status": "locked", + "km_since_service": round(km_since_service, 2), + "km_until_service": 0, + "action_taken": action_taken, + "message": ( + f"Vehicle {vehicle.registration_number} has been automatically locked " + f"for maintenance. {round(km_since_service)}km since last service — " + f"service interval of {SERVICE_INTERVAL_KM}km exceeded. " + "Vehicle cannot be allocated until serviced." + ), + } + + # --- WARNING (8,000km) --- + if km_since_service >= VEHICLE_WARNING_KM: + km_remaining = VEHICLE_LOCK_KM - km_since_service + return { + "vehicle_id": vehicle.vehicle_id, + "registration": vehicle.registration_number, + "status": "warning", + "km_since_service": round(km_since_service, 2), + "km_until_service": round(km_remaining, 2), + "action_taken": False, + "message": ( + f"Vehicle {vehicle.registration_number} is approaching its service interval. " + f"{round(km_since_service)}km since last service — " + f"{round(km_remaining)}km remaining before auto-lock. " + "Schedule a service soon." + ), + } + + # --- OK --- + km_remaining = VEHICLE_LOCK_KM - km_since_service + return { + "vehicle_id": vehicle.vehicle_id, + "registration": vehicle.registration_number, + "status": "ok", + "km_since_service": round(km_since_service, 2), + "km_until_service": round(km_remaining, 2), + "action_taken": False, + "message": ( + f"Vehicle {vehicle.registration_number} is within service limits. " + f"{round(km_remaining)}km remaining before service is due." + ), + } + + +def run_vehicle_maintenance_check() -> dict: + """ + Runs maintenance check across the entire vehicle pool. + Should be called at the start of each day or after any trip completes. + + Returns a summary with: + - locked: vehicles that were auto-locked (require immediate service) + - warnings: vehicles approaching the threshold + - ok: vehicles within safe limits + """ + locked = [] + warnings = [] + ok = [] + + for vehicle in VEHICLE_POOL: + result = check_vehicle_maintenance(vehicle) + if result["status"] == "locked": + locked.append(result) + elif result["status"] == "warning": + warnings.append(result) + else: + ok.append(result) + + return { + "success": True, + "checked_at": datetime.now(), + "total_vehicles": len(VEHICLE_POOL), + "locked": locked, + "warnings": warnings, + "ok": ok, + "summary": ( + f"{len(locked)} vehicle(s) locked for maintenance, " + f"{len(warnings)} warning(s), " + f"{len(ok)} vehicle(s) ok." + ), + } + + +# =========================================================================== +# DRIVER WELLBEING ENFORCEMENT +# =========================================================================== + +def check_driver_wellbeing(driver: Driver) -> dict: + """ + Checks a single driver against the biweekly hours limit. + + Design reference: + EU Drivers Hours Regulation (EC) No 561/2006 — max 60hrs/14 days. + Two-stage approach mirrors vehicle maintenance: + Stage 1 — WARNING at 50 hours: Fleet Manager notified + Stage 2 — HARD SUSPENSION at 60 hours: driver suspended, + cannot be allocated until hours reset + + The state manager already suspends drivers at 60hrs via update_driver_hours(). + This function provides the warning stage and surfaces the current status + clearly for the Fleet Manager dashboard. + + Returns a dict describing the driver's wellbeing status: + - status: "ok" | "warning" | "suspended" + - hours_driven_this_period: float + - hours_remaining: float + - action_taken: bool (True if driver was suspended by this call) + """ + hours = driver.hours_driven_this_period + hours_remaining = max(0.0, DRIVER_HARD_LIMIT_HOURS - hours) + + # --- HARD SUSPENSION (60 hours) --- + if hours >= DRIVER_HARD_LIMIT_HOURS: + action_taken = driver.status != DriverStatus.SUSPENDED + driver.status = DriverStatus.SUSPENDED + + return { + "driver_id": driver.driver_id, + "name": driver.name, + "status": "suspended", + "hours_driven_this_period": round(hours, 2), + "hours_remaining": 0.0, + "action_taken": action_taken, + "message": ( + f"Driver {driver.name} has been suspended. " + f"{round(hours, 1)} hours driven this period — " + f"EU limit of {DRIVER_HARD_LIMIT_HOURS}hrs exceeded. " + "Driver cannot be allocated until hours reset." + ), + } + + # --- WARNING (50 hours) --- + if hours >= DRIVER_WARNING_HOURS: + return { + "driver_id": driver.driver_id, + "name": driver.name, + "status": "warning", + "hours_driven_this_period": round(hours, 2), + "hours_remaining": round(hours_remaining, 2), + "action_taken": False, + "message": ( + f"Driver {driver.name} is approaching the biweekly hours limit. " + f"{round(hours, 1)} hours driven — " + f"{round(hours_remaining, 1)} hours remaining before suspension. " + "Consider resting this driver." + ), + } + + # --- OK --- + return { + "driver_id": driver.driver_id, + "name": driver.name, + "status": "ok", + "hours_driven_this_period": round(hours, 2), + "hours_remaining": round(hours_remaining, 2), + "action_taken": False, + "message": ( + f"Driver {driver.name} is within hours limits. " + f"{round(hours_remaining, 1)} hours remaining this period." + ), + } + + +def run_driver_wellbeing_check() -> dict: + """ + Runs wellbeing check across the entire driver pool. + Should be called at the start of each day or after any trip completes. + + Returns a summary with: + - suspended: drivers who are at or over the hours limit + - warnings: drivers approaching the limit + - ok: drivers within safe limits + """ + suspended = [] + warnings = [] + ok = [] + + for driver in DRIVER_POOL: + result = check_driver_wellbeing(driver) + if result["status"] == "suspended": + suspended.append(result) + elif result["status"] == "warning": + warnings.append(result) + else: + ok.append(result) + + return { + "success": True, + "checked_at": datetime.now(), + "total_drivers": len(DRIVER_POOL), + "suspended": suspended, + "warnings": warnings, + "ok": ok, + "summary": ( + f"{len(suspended)} driver(s) suspended, " + f"{len(warnings)} warning(s), " + f"{len(ok)} driver(s) ok." + ), + } + + +# =========================================================================== +# FULL FLEET HEALTH CHECK +# =========================================================================== + +def run_full_fleet_health_check() -> dict: + """ + Runs both vehicle maintenance and driver wellbeing checks together. + Returns a unified health report for the Fleet Manager dashboard. + + Design reference: + "Enforces safety" — this is the single function that should be called + at the start of each operational day to ensure the fleet is safe + before any trips are allocated. + """ + vehicle_report = run_vehicle_maintenance_check() + driver_report = run_driver_wellbeing_check() + + fleet_ready = ( + len(vehicle_report["locked"]) == 0 and + len(driver_report["suspended"]) == 0 + ) + + return { + "success": True, + "checked_at": datetime.now(), + "fleet_ready": fleet_ready, + "vehicles": vehicle_report, + "drivers": driver_report, + "summary": ( + f"Fleet health check complete. " + f"Vehicles: {vehicle_report['summary']} " + f"Drivers: {driver_report['summary']}" + ), + } + + +# =========================================================================== +# MAINTENANCE SCHEDULE +# =========================================================================== + +def get_maintenance_schedule() -> list[dict]: + """ + Returns a prioritised list of vehicles that need attention, + ordered by urgency (most overdue first). + + Used by the Fleet Manager to plan upcoming services. + Includes both locked vehicles (immediate) and warning vehicles (upcoming). + """ + schedule = [] + + for vehicle in VEHICLE_POOL: + km_since_service = vehicle.odometer_km - vehicle.last_service_odometer_km + km_remaining = VEHICLE_LOCK_KM - km_since_service + + if km_since_service >= VEHICLE_WARNING_KM: + schedule.append({ + "vehicle_id": vehicle.vehicle_id, + "registration": vehicle.registration_number, + "type": vehicle.vehicle_type, + "km_since_service": round(km_since_service, 2), + "km_until_service": round(max(0.0, km_remaining), 2), + "current_status": vehicle.current_status, + "urgency": "immediate" if km_since_service >= VEHICLE_LOCK_KM else "upcoming", + }) + + # Sort by km_since_service descending — most overdue first + schedule.sort(key=lambda x: x["km_since_service"], reverse=True) + return schedule \ No newline at end of file diff --git a/backend/services/fleet_management/engines/optimization_engine.py b/backend/services/fleet_management/engines/optimization_engine.py new file mode 100644 index 0000000..ab5d377 --- /dev/null +++ b/backend/services/fleet_management/engines/optimization_engine.py @@ -0,0 +1,624 @@ +""" +fleet_management/engines/optimization_engine.py +------------------------------------------------- +Mixed Integer Linear Programming (MILP) Core for fleet assignment optimization. + +Design reference: + "Optimization Engine (Mixed Integer Linear Programming Core — + Event-Driven + Batch Solver)" + "Not used continuously — only triggered when needed." + +Triggers: + A) Batch Solve — many trips requiring the same vehicle type arrive for same day + B) Dynamic Events — Dynamic Reallocation Engine requests a solve + +Inputs: + - Trip data & priority scores (P_i from Priority Policy Engine) + - Vehicle & driver availability (from Driver/Vehicle State Manager) + - Terrain & operational constraints (vehicle hints from Vehicle Suitability Module) + +Outputs: + - Updated assignment matrix (trip → vehicle/driver mapping) + - Minimal-change reassignments (stability constraint — don't disrupt unnecessarily) + - Auditable justification JSON (explainability — why each assignment was made) + +MILP Formulation: + Decision variables: + x[i,j] ∈ {0,1} — 1 if trip i is assigned to vehicle j, 0 otherwise + y[i,k] ∈ {0,1} — 1 if trip i is assigned to driver k, 0 otherwise + + Objective (maximise): + Σ x[i,j] × (priority[i] × 0.40 + + suitability[i,j] × 0.35 + + readiness[j] × 0.25) + - change_penalty × Σ change[i] × 0.10 + + Constraints: + 1. Each trip assigned to at most one vehicle + 2. Each trip assigned to at most one driver + 3. Each vehicle assigned to at most one trip + 4. Each driver assigned to at most one trip + 5. Vehicle must satisfy terrain hint (vehicle type constraint) + 6. Vehicle capacity must cover trip passengers + 7. Driver must be AVAILABLE (not suspended or on_trip) + 8. Warm-start: penalise changes from current solution (stability) + + change_penalty — discourages unnecessary reassignments that confuse + drivers and passengers. Reference: Psaraftis et al. (2016). + +Library: PuLP (MIT license, includes CBC solver by default) + Install: pip install pulp + Falls back to heuristic allocation if PuLP is not installed. +""" + +import json +from datetime import datetime +from typing import Optional + +from fleet_management.models import ( + TripRequest, + TripStatus, + Vehicle, + VehicleStatus, + Driver, + DriverStatus, + VEHICLE_HINT_MAP, + VehicleType, +) +from fleet_management.engines.vehicle_suitability_module import SUITABILITY_TABLE +from fleet_management.engines.allocation_utils import ( + select_best_vehicle, + select_best_driver, + commit_allocation, + release_current_allocation, +) +from fleet_management.engines.token_generator_engine import issue_token + +try: + import pulp + PULP_AVAILABLE = True +except ImportError: + pulp = None # type: ignore[assignment] + PULP_AVAILABLE = False + + +# =========================================================================== +# CONSTANTS +# =========================================================================== + +# Objective function weights +WEIGHT_PRIORITY = 0.40 # Trip priority score P_i +WEIGHT_SUITABILITY = 0.35 # Terrain-vehicle suitability +WEIGHT_READINESS = 0.25 # Vehicle readiness score + +# Stability penalty — penalises changing an existing allocation +# Reference: Psaraftis et al. (Dynamic Vehicle Routing, 2016) +CHANGE_PENALTY = 0.10 + +# Solver time limit in seconds — prevents blocking on hard instances +SOLVER_TIME_LIMIT = 30 + + +# =========================================================================== +# SUITABILITY LOOKUP +# =========================================================================== + +def _get_suitability_score(trip: TripRequest, vehicle: Vehicle) -> float: + """ + Returns the suitability score for a vehicle given the trip's terrain hint. + + Design reference: + "Terrain & Operational Constraints" input to the MILP. + Uses SUITABILITY_TABLE from vehicle_suitability_module. + """ + hint = trip.vehicle_hint or "sedan_ok" + band_scores = SUITABILITY_TABLE.get(hint, {}) + return band_scores.get(vehicle.vehicle_type.value, 0.0) + + +def _vehicle_satisfies_hint(trip: TripRequest, vehicle: Vehicle) -> bool: + """ + Hard constraint — vehicle type must satisfy terrain hint. + Returns False if the vehicle cannot safely handle the terrain. + A suitability score below 0.4 is considered unsafe. + """ + return _get_suitability_score(trip, vehicle) >= 0.4 + + +# =========================================================================== +# MILP SOLVER +# =========================================================================== + +def _solve_milp( + trips: list[TripRequest], + vehicles: list[Vehicle], + drivers: list[Driver], + current_solution: dict, +) -> dict: + """ + Solves the trip-vehicle-driver assignment problem using PuLP MILP. + + Formulation: + Maximise total allocation quality subject to: + - Each trip gets at most one vehicle and one driver + - Each vehicle and driver used at most once + - Vehicle must satisfy terrain hint and capacity + - Stability penalty for changing existing assignments + + Returns: + - solution: dict mapping trip_id -> {vehicle_id, driver_id} + - score: float (objective value) + - status: str (Optimal / Feasible / Infeasible) + """ + if not PULP_AVAILABLE or pulp is None: + raise RuntimeError("PuLP not available") + + prob = pulp.LpProblem("GovRide_Fleet_Assignment", pulp.LpMaximize) + + # Filter to feasible trip-vehicle pairs (hard constraints first) + feasible_pairs = [ + (t, v) for t in trips for v in vehicles + if v.current_status == VehicleStatus.AVAILABLE + and v.capacity >= t.passengers + and _vehicle_satisfies_hint(t, v) + ] + + feasible_driver_pairs = [ + (t, d) for t in trips for d in drivers + if d.status == DriverStatus.AVAILABLE + ] + + if not feasible_pairs or not feasible_driver_pairs: + return {"solution": {}, "score": 0.0, "status": "Infeasible"} + + # Decision variables + # x[trip_id, vehicle_id] = 1 if trip assigned to vehicle + x = pulp.LpVariable.dicts( + "assign_vehicle", + [(t.request_id, v.vehicle_id) for t, v in feasible_pairs], + cat="Binary", + ) + + # y[trip_id, driver_id] = 1 if trip assigned to driver + y = pulp.LpVariable.dicts( + "assign_driver", + [(t.request_id, d.driver_id) for t, d in feasible_driver_pairs], + cat="Binary", + ) + + # Objective function + # Maximise: Σ x[i,j] * (priority*0.4 + suitability*0.35 + readiness*0.25) + # - change_penalty * Σ change indicator + objective_terms = [] + + for t, v in feasible_pairs: + priority = t.priority_score or 0.5 # Default if not scored yet + suitability = _get_suitability_score(t, v) + readiness = v.readiness_score + + base_score = ( + priority * WEIGHT_PRIORITY + + suitability * WEIGHT_SUITABILITY + + readiness * WEIGHT_READINESS + ) + + # Apply stability penalty if this changes the current assignment + current = current_solution.get(t.request_id, {}) + is_change = current.get("vehicle_id") != v.vehicle_id + if is_change: + base_score -= CHANGE_PENALTY + + objective_terms.append(base_score * x[t.request_id, v.vehicle_id]) + + prob += pulp.lpSum(objective_terms), "Total_Quality" + + # Constraint 1 — each trip assigned to at most one vehicle + for t in trips: + trip_vehicle_vars = [ + x[t.request_id, v.vehicle_id] + for t2, v in feasible_pairs + if t2.request_id == t.request_id + ] + if trip_vehicle_vars: + prob += pulp.lpSum(trip_vehicle_vars) <= 1, f"OneVehicle_{t.request_id}" + + # Constraint 2 — each trip assigned to at most one driver + for t in trips: + trip_driver_vars = [ + y[t.request_id, d.driver_id] + for t2, d in feasible_driver_pairs + if t2.request_id == t.request_id + ] + if trip_driver_vars: + prob += pulp.lpSum(trip_driver_vars) <= 1, f"OneDriver_{t.request_id}" + + # Constraint 3 — each vehicle used at most once + for v in vehicles: + vehicle_vars = [ + x[t.request_id, v.vehicle_id] + for t, v2 in feasible_pairs + if v2.vehicle_id == v.vehicle_id + ] + if vehicle_vars: + prob += pulp.lpSum(vehicle_vars) <= 1, f"OneTrip_V_{v.vehicle_id}" + + # Constraint 4 — each driver used at most once + for d in drivers: + driver_vars = [ + y[t.request_id, d.driver_id] + for t, d2 in feasible_driver_pairs + if d2.driver_id == d.driver_id + ] + if driver_vars: + prob += pulp.lpSum(driver_vars) <= 1, f"OneTrip_D_{d.driver_id}" + + # Constraint 5 — vehicle and driver assignments must be consistent + # If a trip is assigned a vehicle, it must also have a driver + for t in trips: + vehicle_vars = [ + x[t.request_id, v.vehicle_id] + for t2, v in feasible_pairs + if t2.request_id == t.request_id + ] + driver_vars = [ + y[t.request_id, d.driver_id] + for t2, d in feasible_driver_pairs + if t2.request_id == t.request_id + ] + if vehicle_vars and driver_vars: + prob += ( + pulp.lpSum(vehicle_vars) == pulp.lpSum(driver_vars), + f"VehicleDriverParity_{t.request_id}" + ) + + # Solve with time limit + solver = pulp.PULP_CBC_CMD( + timeLimit=SOLVER_TIME_LIMIT, + msg=False, # Suppress CBC output + ) + prob.solve(solver) + + status = pulp.LpStatus[prob.status] + + if status not in ["Optimal", "Not Solved"]: + return {"solution": {}, "score": 0.0, "status": status} + + # Extract solution + solution = {} + for t in trips: + assigned_vehicle = next( + (v.vehicle_id for t2, v in feasible_pairs + if t2.request_id == t.request_id + and pulp.value(x[t.request_id, v.vehicle_id]) == 1), + None + ) + assigned_driver = next( + (d.driver_id for t2, d in feasible_driver_pairs + if t2.request_id == t.request_id + and pulp.value(y[t.request_id, d.driver_id]) == 1), + None + ) + if assigned_vehicle and assigned_driver: + solution[t.request_id] = { + "vehicle_id": assigned_vehicle, + "driver_id": assigned_driver, + } + + score = pulp.value(prob.objective) or 0.0 + return {"solution": solution, "score": score, "status": status} + + +# =========================================================================== +# JUSTIFICATION JSON +# =========================================================================== + +def _build_justification( + solution: dict, + trips: list[TripRequest], + vehicles: list[Vehicle], + drivers: list[Driver], + current_solution: dict, + trigger: str, + score: float, +) -> dict: + """ + Produces an auditable justification JSON explaining every assignment. + + Design reference: + "Produces Auditable Justification JSON" (from design diagram) + "Explainability" output — why each trip was assigned to each vehicle/driver. + + This is critical for government accountability — every allocation + decision must be traceable and explainable to auditors. + """ + assignments = [] + changes = [] + + for trip_id, assignment in solution.items(): + trip = next((t for t in trips if t.request_id == trip_id), None) + if not trip: + continue + + vehicle_id = assignment["vehicle_id"] + driver_id = assignment["driver_id"] + + vehicle = next((v for v in vehicles if v.vehicle_id == vehicle_id), None) + driver = next((d for d in drivers if d.driver_id == driver_id), None) + + if not vehicle or not driver: + continue + + suitability = _get_suitability_score(trip, vehicle) + priority = trip.priority_score or 0.5 + readiness = vehicle.readiness_score + + computed_score = ( + priority * WEIGHT_PRIORITY + + suitability * WEIGHT_SUITABILITY + + readiness * WEIGHT_READINESS + ) + + current = current_solution.get(trip_id, {}) + was_changed = ( + current.get("vehicle_id") != vehicle_id or + current.get("driver_id") != driver_id + ) + + assignment_record = { + "trip_id": trip_id, + "destination": trip.destination, + "vehicle_id": vehicle_id, + "vehicle_type": vehicle.vehicle_type.value, + "driver_id": driver_id, + "driver_name": driver.name, + "scores": { + "priority_score": round(priority, 4), + "suitability_score": round(suitability, 4), + "readiness_score": round(readiness, 4), + "composite_score": round(computed_score, 4), + }, + "terrain_hint": trip.vehicle_hint or "sedan_ok", + "was_reassigned": was_changed, + "justification": ( + f"Trip '{trip_id}' to '{trip.destination}' assigned to " + f"{vehicle.vehicle_type.value} ({vehicle.registration_number}) " + f"driven by {driver.name}. " + f"Composite score: {round(computed_score, 4)} " + f"(priority={round(priority, 4)}, " + f"suitability={round(suitability, 4)}, " + f"readiness={round(readiness, 4)}). " + f"{'Reassigned from previous allocation.' if was_changed else 'No change from previous allocation.'}" + ), + } + assignments.append(assignment_record) + if was_changed: + changes.append(trip_id) + + return { + "trigger": trigger, + "solved_at": datetime.now().isoformat(), + "objective_score": round(score, 4), + "total_trips": len(trips), + "trips_assigned": len(solution), + "trips_unassigned": len(trips) - len(solution), + "changes_made": len(changes), + "changed_trips": changes, + "assignments": assignments, + "weights_used": { + "priority": WEIGHT_PRIORITY, + "suitability": WEIGHT_SUITABILITY, + "readiness": WEIGHT_READINESS, + "change_penalty": CHANGE_PENALTY, + }, + } + + +# =========================================================================== +# HEURISTIC FALLBACK +# =========================================================================== + +def _heuristic_fallback( + trips: list[TripRequest], + vehicles: list[Vehicle], + drivers: list[Driver], +) -> dict: + """ + Falls back to the heuristic allocation engine when PuLP is not available. + + Design reference: + Graceful degradation — the system continues to function without PuLP. + The heuristic engine is still sound; MILP just produces a better solution + when multiple trips compete for the same resources. + """ + solution = {} + used_vehicles: set[str] = set() + used_drivers: set[str] = set() + + # Sort trips by priority score descending — serve highest priority first + sorted_trips = sorted( + trips, + key=lambda t: t.priority_score or 0.0, + reverse=True + ) + + for trip in sorted_trips: + vehicle = next( + ( + v for v in sorted(vehicles, key=lambda v: v.readiness_score, reverse=True) + if v.vehicle_id not in used_vehicles + and v.current_status == VehicleStatus.AVAILABLE + and v.capacity >= trip.passengers + and _vehicle_satisfies_hint(trip, v) + ), + None + ) + driver = next( + ( + d for d in sorted(drivers, key=lambda d: d.hours_driven_this_period) + if d.driver_id not in used_drivers + and d.status == DriverStatus.AVAILABLE + ), + None + ) + + if vehicle and driver: + solution[trip.request_id] = { + "vehicle_id": vehicle.vehicle_id, + "driver_id": driver.driver_id, + } + used_vehicles.add(vehicle.vehicle_id) + used_drivers.add(driver.driver_id) + + return solution + + +# =========================================================================== +# MAIN ENTRY POINT — CALLED BY DYNAMIC REALLOCATION ENGINE +# =========================================================================== + +def solve(warm_start, trigger: str = "dynamic_event") -> dict: + """ + Main entry point for the Optimization Engine. + Called by the Dynamic Reallocation Engine via the stub replacement. + + Design reference: + "MILP Optimization Engine (Warm-Start)" — Image 2 + Triggered by: + A) Batch Solve — many trips for same day/vehicle type + B) Dynamic Events — from Dynamic Reallocation Engine + + Args: + warm_start: WarmStartInput from dynamic_reallocation_engine + trigger: "batch" or "dynamic_event" + + Returns a dict with: + - success (bool) + - solution: dict mapping trip_id -> {vehicle_id, driver_id} + - score: float + - status: str (Optimal/Feasible/Heuristic) + - justification: dict (auditable JSON) + - changes_made: int + """ + trips = warm_start.pending_trips + vehicles = warm_start.available_vehicles + drivers = warm_start.available_drivers + current = warm_start.current_solution + + if not trips: + return { + "success": True, + "solution": {}, + "score": 0.0, + "status": "NoTrips", + "justification": {}, + "changes_made": 0, + "message": "No pending trips to optimize.", + } + + # Try MILP solver first, fall back to heuristic if unavailable + if PULP_AVAILABLE: + try: + result = _solve_milp(trips, vehicles, drivers, current) + solution = result["solution"] + score = result["score"] + status = result["status"] + except Exception as e: + # MILP failed — fall back to heuristic + solution = _heuristic_fallback(trips, vehicles, drivers) + score = 0.0 + status = f"HeuristicFallback (MILP error: {str(e)[:50]})" + else: + solution = _heuristic_fallback(trips, vehicles, drivers) + score = sum( + (t.priority_score or 0.5) for t in trips + if t.request_id in solution + ) + status = "HeuristicFallback (PuLP not installed)" + + # Build auditable justification JSON + justification = _build_justification( + solution, trips, vehicles, drivers, current, trigger, score + ) + + changes_made = justification["changes_made"] + + return { + "success": True, + "solution": solution, + "score": score, + "status": status, + "justification": justification, + "changes_made": changes_made, + "message": ( + f"Optimization complete. {len(solution)}/{len(trips)} trips assigned. " + f"{changes_made} reassignment(s) made. Status: {status}." + ), + } + + +# =========================================================================== +# BATCH SOLVE TRIGGER +# =========================================================================== + +def batch_solve(trip_date: datetime, vehicle_type_filter: Optional[str] = None) -> dict: + """ + Batch Solve trigger — called when many trips for the same day need optimisation. + + Design reference: + "Batch Solve — When many requests requiring the same vehicle type + arrive for the same day." + + Args: + trip_date: the date to optimise allocations for + vehicle_type_filter: optional — only optimise trips needing this vehicle type + + This builds a WarmStartInput from current system state and calls solve(). + """ + from fleet_management.engines.trip_request_processor import TRIP_REQUESTS + from fleet_management.engines.heuristic_allocation_engine import ( + VEHICLE_POOL, DRIVER_POOL + ) + from fleet_management.engines.dynamic_reallocation_engine import ( + WarmStartInput, DisruptionType, + _get_pending_trips, _get_available_vehicles, + _get_available_drivers, _build_current_solution, + ) + + # Get all pending trips for the target date + pending = [ + t for t in _get_pending_trips() + if t.trip_date.date() == trip_date.date() + ] + + # Apply vehicle type filter if specified + if vehicle_type_filter: + pending = [ + t for t in pending + if t.vehicle_hint == vehicle_type_filter + ] + + if not pending: + return { + "success": True, + "message": f"No pending trips for {trip_date.date()} " + f"{'with hint ' + vehicle_type_filter if vehicle_type_filter else ''}.", + "trips_optimized": 0, + } + + available_vehicles = _get_available_vehicles() + available_drivers = _get_available_drivers() + current_solution = _build_current_solution(pending) + + warm_start = WarmStartInput( + locked_trips=[], # Batch solve doesn't lock anything + pending_trips=pending, + available_vehicles=available_vehicles, + available_drivers=available_drivers, + current_solution=current_solution, + disruption_type=DisruptionType.TRIP_CANCELLATION, # Not a disruption + triggered_at=datetime.now(), + ) + + result = solve(warm_start, trigger="batch") + result["trips_optimized"] = len(pending) + return result \ No newline at end of file diff --git a/backend/services/fleet_management/engines/priority_policy_engine.py b/backend/services/fleet_management/engines/priority_policy_engine.py new file mode 100644 index 0000000..92676bf --- /dev/null +++ b/backend/services/fleet_management/engines/priority_policy_engine.py @@ -0,0 +1,389 @@ +""" +fleet_management/engines/priority_policy_engine.py +--------------------------------------------------- +Responsible for: + 1. Fetching the employee's priority tier (A, B, or C) + 2. Fetching the ministry-specific priority multiplier + 3. Checking if elevation is requested by the admin + 4. Managing the admin's biweekly elevation quota (3 per period) + 5. Flagging trips for Director digital signature when quota exhausted + 6. Computing the final numeric priority score P_i + +Design reference: + - "Priority & Policy Engine" + - "Computes final trip priority" + - "Uses tier, ministry importance, and elevation tokens" + - "Outputs a single numeric priority value" + +Flowchart (from design): + Trip Request Received + -> Fetch Employee Priority Tier (A, B, or C) + -> Fetch Ministry-Specific Priority Multiplier + -> Is Elevation Requested? + Yes -> Check Admin Biweekly Quota + Quota Available -> Apply Priority Delta + Deduct Token + Quota Exhausted -> Flag for Director Digital Signature + No -> Calculate Standard Priority Weight + -> Final Numeric Priority Score P_i + +Priority Tier Base Scores: + A (Senior officials — Directors, Ministers): 1.0 + B (Mid-level staff — managers, officers): 0.7 + C (Junior staff — clerks, assistants): 0.4 + +Ministry Priority Multipliers (fixed, based on operational criticality): + Reference: Bryson (Strategic Planning for Public and Nonprofit + Organizations, 2011) and OECD Government Fleet Management + Guidelines (2019). Fixed multipliers ensure transparency and + auditability in government resource allocation. + + Critical (1.5): Health, Police/Security, Emergency Services + High (1.3): Education, Agriculture, Water + Standard (1.0): Finance, Public Works, Trade + Admin (0.8): Internal Affairs, Tourism, Forestry + +Elevation Token Quota: + 3 elevations per admin per 14-day biweekly period. + Reference: Hood (The Art of the State, 1998) — scarcity creates + accountability. 3 tokens balances legitimate emergency use against + preventing abuse that would make the priority system meaningless. + +Elevation Formula: + P_i = (base_tier_score * ministry_multiplier) + elevation_delta + elevation_delta = 0.3 (fixed boost applied when elevation is granted) + +Standard Formula (no elevation): + P_i = base_tier_score * ministry_multiplier + +Score range: 0.0 (lowest priority) to 1.8 (highest — Tier A, Critical ministry, elevated) +""" + +from datetime import datetime +from enum import Enum +from typing import Optional +from uuid import uuid4 + +from fleet_management.models import TripRequest, TripStatus +from fleet_management.engines.trip_request_processor import ( + TRIP_REQUESTS, + EMPLOYEES, + get_trip, +) + + +# =========================================================================== +# CONSTANTS +# =========================================================================== + +ELEVATION_DELTA = 0.3 # Priority boost applied when elevation granted +ELEVATION_QUOTA = 3 # Max elevations per admin per biweekly period +ELEVATION_RESET_DAYS = 14 # Biweekly reset cycle + + +# =========================================================================== +# ENUMS +# =========================================================================== + +class PriorityTier(str, Enum): + A = "A" # Senior officials — Directors, Ministers + B = "B" # Mid-level staff — managers, officers + C = "C" # Junior staff — clerks, assistants + + +class MinistryTier(str, Enum): + CRITICAL = "critical" # Health, Police, Emergency + HIGH = "high" # Education, Agriculture, Water + STANDARD = "standard" # Finance, Public Works, Trade + ADMINISTRATIVE = "administrative" # Internal Affairs, Tourism, Forestry + + +# =========================================================================== +# LOOKUP TABLES +# =========================================================================== + +# Base priority score per employee tier +TIER_BASE_SCORES: dict[PriorityTier, float] = { + PriorityTier.A: 1.0, + PriorityTier.B: 0.7, + PriorityTier.C: 0.4, +} + +# Ministry priority multipliers — fixed, based on operational criticality +MINISTRY_MULTIPLIERS: dict[MinistryTier, float] = { + MinistryTier.CRITICAL: 1.5, + MinistryTier.HIGH: 1.3, + MinistryTier.STANDARD: 1.0, + MinistryTier.ADMINISTRATIVE: 0.8, +} + +# Ministry ID -> MinistryTier mapping (mock data — replace with DB in production) +MINISTRY_TIER_MAP: dict[str, MinistryTier] = { + "M001": MinistryTier.CRITICAL, # Ministry of Health + "M002": MinistryTier.HIGH, # Ministry of Education + "M003": MinistryTier.STANDARD, # Ministry of Finance + "M004": MinistryTier.ADMINISTRATIVE, # Ministry of Tourism +} + +# Employee ID -> PriorityTier mapping (mock data — replace with DB in production) +EMPLOYEE_TIER_MAP: dict[str, PriorityTier] = { + "U001": PriorityTier.B, # Thabo Mohapi — mid-level officer + "U002": PriorityTier.C, # Molupi Lekhanya — junior staff + "U003": PriorityTier.A, # Palesa Ramollo — senior official +} + +# Admin ID -> Director ID mapping (who signs when quota exhausted) +ADMIN_DIRECTOR_MAP: dict[str, str] = { + "A001": "DIR001", # Mampho Nthunya's director + "A002": "DIR002", # Lerato Moeti's director +} + + +# =========================================================================== +# ELEVATION TOKEN TRACKING +# In production, store on the Admin Manager's user record in the DB. +# =========================================================================== + +class ElevationRecord: + """Tracks an admin's elevation token usage for the current biweekly period.""" + def __init__(self, admin_id: str): + self.admin_id = admin_id + self.tokens_used = 0 + self.period_start = datetime.now() + +# admin_id -> ElevationRecord +_elevation_records: dict[str, ElevationRecord] = {} + + +def _get_elevation_record(admin_id: str) -> ElevationRecord: + """Gets or creates an elevation record for an admin.""" + if admin_id not in _elevation_records: + _elevation_records[admin_id] = ElevationRecord(admin_id) + record = _elevation_records[admin_id] + + # Auto-reset if biweekly period has elapsed + days_since_reset = (datetime.now() - record.period_start).days + if days_since_reset >= ELEVATION_RESET_DAYS: + record.tokens_used = 0 + record.period_start = datetime.now() + + return record + + +# =========================================================================== +# LOOKUP HELPERS +# =========================================================================== + +def _get_employee_tier(user_id: str) -> PriorityTier: + """ + Fetches the priority tier for an employee. + Defaults to Tier C if not found — lowest priority, safest fallback. + + Design reference: + "Fetch Employee Priority Tier: A, B, or C" (flowchart step 1) + """ + return EMPLOYEE_TIER_MAP.get(user_id, PriorityTier.C) + + +def _get_ministry_multiplier(user_id: str) -> tuple[float, MinistryTier]: + """ + Fetches the ministry-specific priority multiplier for an employee. + Defaults to STANDARD (1.0) if ministry not found. + + Design reference: + "Fetch Ministry-Specific Priority Multiplier" (flowchart step 2) + """ + employee = next((u for u in EMPLOYEES if u.user_id == user_id), None) + if not employee or not employee.ministry_id: + return MINISTRY_MULTIPLIERS[MinistryTier.STANDARD], MinistryTier.STANDARD + + ministry_tier = MINISTRY_TIER_MAP.get( + employee.ministry_id, MinistryTier.STANDARD + ) + return MINISTRY_MULTIPLIERS[ministry_tier], ministry_tier + + +# =========================================================================== +# CORE FUNCTION +# =========================================================================== + +def compute_priority_score( + trip_id: str, + admin_id: str, + elevation_requested: bool = False, +) -> dict: + """ + Computes the final numeric priority score P_i for a trip. + + Design reference: + "Computes final trip priority" + "Uses tier, ministry importance, and elevation tokens" + "Outputs a single numeric priority value" + + Flowchart: + 1. Fetch employee tier + 2. Fetch ministry multiplier + 3. Is elevation requested? + Yes -> Check admin biweekly quota + Available -> Apply delta + deduct token + Exhausted -> Flag for Director signature + No -> Standard priority weight + 4. Return P_i + + The score is stored on the TripRequest and used by the + Optimization Engine to rank trips in the pending pool. + + Returns a dict with: + - success (bool) + - priority_score (float) — the final P_i + - tier (PriorityTier) + - ministry_tier (MinistryTier) + - elevation_applied (bool) + - requires_director_signature (bool) + - tokens_remaining (int) + - errors (list[str]) + """ + trip = get_trip(trip_id) + if not trip: + return { + "success": False, + "priority_score": 0.0, + "errors": [f"Trip '{trip_id}' not found."] + } + + # Step 1 — Fetch employee tier + tier = _get_employee_tier(trip.user_id) + base_score = TIER_BASE_SCORES[tier] + + # Step 2 — Fetch ministry multiplier + multiplier, ministry_tier = _get_ministry_multiplier(trip.user_id) + + # Step 3 — Is elevation requested? + elevation_applied = False + requires_director_signature = False + tokens_remaining = ELEVATION_QUOTA + + if elevation_requested: + record = _get_elevation_record(admin_id) + tokens_remaining = ELEVATION_QUOTA - record.tokens_used + + if record.tokens_used < ELEVATION_QUOTA: + # Quota available — apply elevation delta and deduct token + # Design reference: "Apply Priority Delta + Deduct Token" + elevation_applied = True + record.tokens_used += 1 + tokens_remaining = ELEVATION_QUOTA - record.tokens_used + else: + # Quota exhausted — flag for Director digital signature + # Design reference: "Flag for Superior Digital Signature" + requires_director_signature = True + tokens_remaining = 0 + + # Step 4 — Compute final score P_i + if elevation_applied: + priority_score = (base_score * multiplier) + ELEVATION_DELTA + else: + # Standard priority weight (no elevation or elevation blocked) + # Design reference: "Calculate Standard Priority Weight" + priority_score = base_score * multiplier + + # Clamp to valid range + priority_score = round(max(0.0, min(2.0, priority_score)), 4) + + # Store score on the trip for use by the Optimization Engine + trip.priority_score = priority_score + + return { + "success": True, + "trip_id": trip_id, + "priority_score": priority_score, + "tier": tier, + "base_score": base_score, + "ministry_tier": ministry_tier, + "multiplier": multiplier, + "elevation_requested": elevation_requested, + "elevation_applied": elevation_applied, + "requires_director_signature": requires_director_signature, + "director_id": ADMIN_DIRECTOR_MAP.get(admin_id) if requires_director_signature else None, + "tokens_remaining": tokens_remaining, + "errors": [], + } + + +def approve_director_elevation( + trip_id: str, + director_id: str, + admin_id: str, +) -> dict: + """ + Director digitally approves an elevation that the admin could not + grant due to exhausted quota. + + Design reference: + "Flag for Superior Digital Signature" (flowchart — quota exhausted path) + The Director overrides the quota for a single trip. + This does NOT reset or increase the admin's quota — it is a + one-time exception for this specific trip. + + Validation: + 1. Trip must exist + 2. Director must be the correct superior for this admin + """ + trip = get_trip(trip_id) + if not trip: + return { + "success": False, + "errors": [f"Trip '{trip_id}' not found."] + } + + expected_director = ADMIN_DIRECTOR_MAP.get(admin_id) + if not expected_director or expected_director != director_id: + return { + "success": False, + "errors": [ + f"Director '{director_id}' is not authorised to approve " + f"elevations for Admin Manager '{admin_id}'." + ] + } + + # Compute the elevated score directly — bypasses quota check + tier = _get_employee_tier(trip.user_id) + base_score = TIER_BASE_SCORES[tier] + multiplier, ministry_tier = _get_ministry_multiplier(trip.user_id) + + priority_score = round( + min(2.0, (base_score * multiplier) + ELEVATION_DELTA), 4 + ) + trip.priority_score = priority_score + + return { + "success": True, + "trip_id": trip_id, + "priority_score": priority_score, + "approved_by_director": director_id, + "message": ( + f"Director elevation approved. " + f"Priority score set to {priority_score}." + ), + } + + +def get_admin_elevation_status(admin_id: str) -> dict: + """ + Returns an admin's current elevation token status. + Used by the Fleet Manager dashboard and admin UI. + """ + record = _get_elevation_record(admin_id) + tokens_remaining = ELEVATION_QUOTA - record.tokens_used + days_until_reset = max( + 0, + ELEVATION_RESET_DAYS - (datetime.now() - record.period_start).days + ) + + return { + "admin_id": admin_id, + "tokens_used": record.tokens_used, + "tokens_remaining": tokens_remaining, + "quota": ELEVATION_QUOTA, + "period_start": record.period_start, + "days_until_reset": days_until_reset, + } \ No newline at end of file diff --git a/backend/services/fleet_management/engines/standby_market_engine.py b/backend/services/fleet_management/engines/standby_market_engine.py new file mode 100644 index 0000000..60ef069 --- /dev/null +++ b/backend/services/fleet_management/engines/standby_market_engine.py @@ -0,0 +1,526 @@ +""" +fleet_management/engines/standby_market_engine.py +-------------------------------------------------- +Responsible for: + 1. Maintaining a pool of registered private vehicle providers + 2. Scoring and ranking providers using a weighted heuristic formula + 3. Auto-assigning the best provider when the government fleet has no + suitable vehicle available (escalate_to_standby = True) + 4. Issuing a 6-digit SMS confirmation code to the provider driver + (separate from the internal token system used for government drivers) + +Design reference: + - "Integrates private vehicle providers if fleet insufficient" + - "standby_market_engine.py" (from project structure) + - Triggered when auto_allocate() returns escalate_to_standby = True + - Ranked auto-assignment (Agatz et al., Transportation Science, 2012) + - Same TripStatus lifecycle as government trips + - Same two-step completion: provider marks arrived, employee confirms + +Provider Scoring Formula: + score = (vehicle_type_match x 0.4) + (proximity x 0.3) + (reliability_rating x 0.3) + + vehicle_type_match -- does provider vehicle satisfy terrain hint? (0.0 or 1.0) + proximity -- normalised inverse distance from provider to pickup (0.0-1.0) + reliability_rating -- historical rating based on completed trips (0.0-1.0) + + Weights (Furuhata et al., Transportation Research, 2013): + 0.4 -- vehicle type match is highest priority; wrong vehicle type fails the trip + 0.3 -- proximity reduces wait time and cost + 0.3 -- reliability ensures service quality for government employees + +Authentication: + Private provider drivers use a 6-digit numeric SMS code — separate from the + internal QR/text token system. Simpler for external drivers who may not + have the government app installed. +""" + +import random +import secrets +from datetime import datetime, timedelta +from typing import Optional +from uuid import uuid4 + +from fleet_management.models import ( + TripRequest, + TripStatus, + VehicleType, + VEHICLE_HINT_MAP, +) +from fleet_management.engines.trip_request_processor import get_trip + + +# =========================================================================== +# PROVIDER MODEL +# =========================================================================== + +class StandbyProvider: + """ + Represents a registered private vehicle provider. + + Design note: + This is a standalone class rather than a Pydantic model because + providers are external to the government fleet system. In production + this would be backed by a separate providers database table. + + Attributes: + provider_id -- unique identifier + name -- provider/company name + driver_name -- name of the driver + phone_number -- used for SMS confirmation code delivery + vehicle_type -- type of vehicle offered + capacity -- passenger capacity + latitude -- current location latitude + longitude -- current location longitude + reliability_score -- historical performance score (0.0-1.0) + based on completed trips, cancellations, on-time rate + is_available -- whether provider is currently free to take a trip + current_trip_id -- trip they are currently assigned to (if any) + """ + def __init__( + self, + provider_id: str, + name: str, + driver_name: str, + phone_number: str, + vehicle_type: VehicleType, + capacity: int, + latitude: float, + longitude: float, + reliability_score: float = 1.0, + is_available: bool = True, + ): + self.provider_id = provider_id + self.name = name + self.driver_name = driver_name + self.phone_number = phone_number + self.vehicle_type = vehicle_type + self.capacity = capacity + self.latitude = latitude + self.longitude = longitude + self.reliability_score = reliability_score + self.is_available = is_available + self.current_trip_id: Optional[str] = None + + +class StandbyConfirmationCode: + """ + A 6-digit SMS confirmation code issued to a private provider driver. + Separate from the internal TripToken system used for government drivers. + + Design note: + External providers use a simpler numeric code because they may not + have the government app. The code is sent via SMS to their phone. + """ + def __init__(self, code_id: str, trip_id: str, provider_id: str, + code_value: str, expires_at: datetime): + self.code_id = code_id + self.trip_id = trip_id + self.provider_id = provider_id + self.code_value = code_value + self.expires_at = expires_at + self.is_used = False + + +# =========================================================================== +# IN-MEMORY STORES +# =========================================================================== + +STANDBY_PROVIDERS: list[StandbyProvider] = [ + StandbyProvider( + provider_id="SP001", + name="Maseru Express Transport", + driver_name="Mpho Letsie", + phone_number="+26650001111", + vehicle_type=VehicleType.SEDAN, + capacity=4, + latitude=-29.3167, + longitude=27.4833, + reliability_score=0.92, + ), + StandbyProvider( + provider_id="SP002", + name="Highlands 4x4 Services", + driver_name="Nthabi Mokoena", + phone_number="+26650002222", + vehicle_type=VehicleType.FOUR_BY_FOUR, + capacity=5, + latitude=-29.3500, + longitude=27.5100, + reliability_score=0.88, + ), + StandbyProvider( + provider_id="SP003", + name="Lesotho Group Transport", + driver_name="Tšeliso Phiri", + phone_number="+26650003333", + vehicle_type=VehicleType.MINIBUS, + capacity=14, + latitude=-29.2800, + longitude=27.4600, + reliability_score=0.75, + ), + StandbyProvider( + provider_id="SP004", + name="Mountain SUV Hire", + driver_name="Lebitso Ramoeli", + phone_number="+26650004444", + vehicle_type=VehicleType.SUV, + capacity=6, + latitude=-29.4000, + longitude=27.5500, + reliability_score=0.95, + ), +] + +STANDBY_CODES: list[StandbyConfirmationCode] = [] + + +# =========================================================================== +# INTERNAL HELPERS +# =========================================================================== + +def _generate_confirmation_code() -> str: + """ + Generates a 6-digit numeric SMS confirmation code. + Uses secrets.randbelow for cryptographic randomness. + + Design note: + 6 digits gives 1,000,000 possible codes. Combined with a short + expiry window (4 hours) and single-use enforcement, this is + sufficient for operational security without being complex for + an external driver to type. + """ + return str(secrets.randbelow(900000) + 100000) # Always 6 digits (100000-999999) + + +def _compute_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """ + Computes approximate distance between two GPS points using + the Euclidean distance on degree coordinates. + + Design note: + For ranking purposes, precise geodesic distance is not necessary. + Euclidean approximation is sufficient for comparing relative + proximity of providers in the same region (Lesotho). + For production, replace with Haversine formula or Google Maps + Distance Matrix API. + """ + return ((lat2 - lat1) ** 2 + (lon2 - lon1) ** 2) ** 0.5 + + +def _compute_proximity_score( + provider: StandbyProvider, + pickup_latitude: float, + pickup_longitude: float, + all_providers: list[StandbyProvider], +) -> float: + """ + Normalises provider distance to a 0.0-1.0 proximity score. + + Formula: + proximity = 1.0 - (distance / max_distance_in_pool) + + The closest provider scores 1.0, the furthest scores 0.0. + Normalisation ensures fair comparison regardless of geographic spread. + Reference: Agatz et al. (Transportation Science, 2012). + """ + distances = [ + _compute_distance(p.latitude, p.longitude, pickup_latitude, pickup_longitude) + for p in all_providers + ] + max_distance = max(distances) if distances else 1.0 + if max_distance == 0: + return 1.0 + + own_distance = _compute_distance( + provider.latitude, provider.longitude, + pickup_latitude, pickup_longitude + ) + return round(1.0 - (own_distance / max_distance), 4) + + +def _compute_vehicle_type_match(provider: StandbyProvider, trip: TripRequest) -> float: + """ + Checks whether the provider's vehicle type satisfies the terrain hint. + + Returns 1.0 if the vehicle type is acceptable, 0.0 if not. + Binary score — vehicle type match is a hard requirement, not a preference. + + Design note: + If no terrain hint is set on the trip, we fall back to sedan_ok + which accepts all vehicle types. + """ + hint = trip.vehicle_hint or "sedan_ok" + acceptable_types = VEHICLE_HINT_MAP.get(hint, [t.value for t in VehicleType]) + return 1.0 if provider.vehicle_type.value in acceptable_types else 0.0 + + +def _score_provider( + provider: StandbyProvider, + trip: TripRequest, + pickup_latitude: float, + pickup_longitude: float, + all_providers: list[StandbyProvider], +) -> float: + """ + Computes the composite ranking score for a provider. + + Formula (Furuhata et al., Transportation Research, 2013): + score = (vehicle_type_match x 0.4) + (proximity x 0.3) + (reliability x 0.3) + + Weights: + 0.4 -- vehicle type match: highest priority, wrong type fails the trip + 0.3 -- proximity: reduces wait time and cost + 0.3 -- reliability: ensures service quality for government employees + """ + type_match = _compute_vehicle_type_match(provider, trip) + proximity = _compute_proximity_score( + provider, pickup_latitude, pickup_longitude, all_providers + ) + reliability = provider.reliability_score + + return round( + (type_match * 0.4) + (proximity * 0.3) + (reliability * 0.3), + 4 + ) + + +def _issue_confirmation_code(trip_id: str, provider_id: str) -> StandbyConfirmationCode: + """ + Issues a 6-digit SMS confirmation code to the provider driver. + Invalidates any existing unused code for this trip first. + Code expires 4 hours from issue time. + """ + # Invalidate any existing code for this trip + for existing in STANDBY_CODES: + if existing.trip_id == trip_id and not existing.is_used: + existing.is_used = True + + code = StandbyConfirmationCode( + code_id=f"SC-{uuid4().hex[:6].upper()}", + trip_id=trip_id, + provider_id=provider_id, + code_value=_generate_confirmation_code(), + expires_at=datetime.now() + timedelta(hours=4), + ) + STANDBY_CODES.append(code) + return code + + +# =========================================================================== +# CORE FUNCTIONS +# =========================================================================== + +def escalate_to_standby(trip_id: str, pickup_latitude: float, pickup_longitude: float) -> dict: + """ + Ranks all available providers and auto-assigns the best one. + Called by the API layer when auto_allocate() returns escalate_to_standby=True. + + Design reference: + "Ranked auto-assignment" (Agatz et al., Transportation Science, 2012) + Fully autonomous — no Fleet Manager input needed. + + Steps: + 1. Filter providers: must be available and have sufficient capacity + 2. Score each provider using the weighted formula + 3. Assign the highest-scoring provider + 4. Issue a 6-digit SMS confirmation code + 5. Update trip with provider details and move to ALLOCATED + + Returns a dict with: + - success (bool) + - provider details + - confirmation_code (6-digit — to be sent via SMS in production) + - errors (list[str]) + """ + trip = get_trip(trip_id) + if not trip: + return {"success": False, "errors": [f"Trip '{trip_id}' not found."]} + + if trip.status != TripStatus.APPROVED: + return { + "success": False, + "errors": [ + f"Trip '{trip_id}' cannot be escalated. " + f"Current status: {trip.status}." + ] + } + + # Step 1 — Filter available providers with sufficient capacity + candidates = [ + p for p in STANDBY_PROVIDERS + if p.is_available and p.capacity >= trip.passengers + ] + + if not candidates: + return { + "success": False, + "errors": [ + "No standby providers available for this trip. " + "Fleet Manager intervention required." + ] + } + + # Step 2 — Score each candidate + scored = [ + (p, _score_provider(p, trip, pickup_latitude, pickup_longitude, candidates)) + for p in candidates + ] + + # Step 3 — Sort by score descending, assign the best + scored.sort(key=lambda x: x[1], reverse=True) + best_provider, best_score = scored[0] + + # Step 4 — Issue confirmation code + code = _issue_confirmation_code(trip_id, best_provider.provider_id) + + # Step 5 — Update trip and lock provider + trip.assigned_vehicle_id = f"STANDBY-{best_provider.provider_id}" + trip.assigned_driver_id = f"STANDBY-{best_provider.driver_name}" + trip.allocated_by = "system-standby" + trip.allocated_at = datetime.now() + trip.status = TripStatus.ALLOCATED + trip.token_id = code.code_id # Reuse token_id field for code reference + + best_provider.is_available = False + best_provider.current_trip_id = trip_id + + return { + "success": True, + "trip_id": trip_id, + "status": TripStatus.ALLOCATED, + "allocated_by": "system-standby", + "provider": { + "provider_id": best_provider.provider_id, + "name": best_provider.name, + "driver_name": best_provider.driver_name, + "phone_number": best_provider.phone_number, + "vehicle_type": best_provider.vehicle_type, + "reliability_score": best_provider.reliability_score, + "provider_score": best_score, + }, + "confirmation_code": code.code_value, + "code_expires_at": code.expires_at, + "message": ( + f"Trip assigned to {best_provider.name}. " + f"Confirmation code {code.code_value} sent to {best_provider.phone_number}." + ), + } + + +def authenticate_standby_trip(trip_id: str, code_value: str) -> dict: + """ + Provider driver enters the 6-digit confirmation code to start the trip. + Mirrors consume_token() in the token engine but for external providers. + + On success: + - Code is marked as used + - Trip moves from ALLOCATED -> ONGOING + - started_at is recorded + + Validation: + 1. Trip must exist and be ALLOCATED + 2. Code must exist for this trip + 3. Code must not be used + 4. Code must not be expired + 5. Code value must match + """ + trip = get_trip(trip_id) + if not trip: + return {"success": False, "errors": [f"Trip '{trip_id}' not found."]} + + if trip.status != TripStatus.ALLOCATED: + return { + "success": False, + "errors": [f"Trip '{trip_id}' is not ready to start. Status: {trip.status}."] + } + + code = next( + (c for c in STANDBY_CODES if c.trip_id == trip_id and not c.is_used), + None + ) + + if not code: + return {"success": False, "errors": ["No active confirmation code found for this trip."]} + + if datetime.now() > code.expires_at: + return { + "success": False, + "errors": ["Confirmation code has expired. Please request a new one."] + } + + if code.code_value != code_value: + return {"success": False, "errors": ["Invalid confirmation code."]} + + # Consume the code and start the trip + code.is_used = True + trip.status = TripStatus.ONGOING + trip.started_at = datetime.now() + + return { + "success": True, + "trip_id": trip_id, + "status": TripStatus.ONGOING, + "started_at": trip.started_at, + "message": "Standby trip started successfully.", + } + + +def release_standby_provider(trip_id: str) -> None: + """ + Releases the provider back to available after a trip completes. + Called by the trip lifecycle engine on employee confirmation. + """ + provider = next( + (p for p in STANDBY_PROVIDERS if p.current_trip_id == trip_id), + None + ) + if provider: + provider.is_available = True + provider.current_trip_id = None + + +def get_provider_rankings( + trip_id: str, + pickup_latitude: float, + pickup_longitude: float, +) -> dict: + """ + Returns the ranked list of all providers for a trip without assigning. + Used by the Fleet Manager dashboard to inspect provider scoring. + """ + trip = get_trip(trip_id) + if not trip: + return {"success": False, "errors": [f"Trip '{trip_id}' not found."]} + + candidates = [ + p for p in STANDBY_PROVIDERS + if p.is_available and p.capacity >= trip.passengers + ] + + scored = sorted( + [ + { + "provider_id": p.provider_id, + "name": p.name, + "driver_name": p.driver_name, + "vehicle_type": p.vehicle_type, + "reliability_score": p.reliability_score, + "score": _score_provider(p, trip, pickup_latitude, pickup_longitude, candidates), + "vehicle_type_match": _compute_vehicle_type_match(p, trip), + "proximity_score": _compute_proximity_score( + p, pickup_latitude, pickup_longitude, candidates + ), + } + for p in candidates + ], + key=lambda x: x["score"], + reverse=True, + ) + + return { + "success": True, + "trip_id": trip_id, + "ranked_providers": scored, + } \ No newline at end of file diff --git a/backend/services/fleet_management/engines/token_generator_engine.py b/backend/services/fleet_management/engines/token_generator_engine.py new file mode 100644 index 0000000..e3eb11e --- /dev/null +++ b/backend/services/fleet_management/engines/token_generator_engine.py @@ -0,0 +1,347 @@ +""" +fleet_management/engines/token_generator_engine.py +--------------------------------------------------- +Responsible for: + 1. Generating a secure token after a trip is allocated + 2. Supporting both QR code and text modes (employee chooses) + 3. Expiring the token at midnight on the trip date + 4. Allowing the employee to request a fresh token if expired + 5. Marking the token as used after the driver authenticates + +Design reference: + - "Generate secure trip tokens/QR codes" (token_generator_engine) + - Token is issued after allocation, given to employee + - Driver scans or enters token to start the trip + - Token expires at midnight on the trip date + - If expired, employee can request a new one (no Fleet Manager needed) + - Once used, token cannot be reused + +Flow: + Allocation confirmed + -> issue_token() called automatically + -> TripToken created and stored + -> token_id written onto TripRequest + -> Employee requests QR or text via get_token_for_employee() + -> Driver authenticates via consume_token() +""" + +import secrets +import base64 +from datetime import datetime +from typing import Optional +from uuid import uuid4 + +from io import BytesIO + +try: + import qrcode # type: ignore[import-untyped] + QR_AVAILABLE = True +except ImportError: + qrcode = None # type: ignore[assignment] + QR_AVAILABLE = False + +from fleet_management.models import ( + TripToken, + TokenMode, + TripStatus, + TokenRequestIn, +) +from fleet_management.engines.trip_request_processor import ( + TRIP_REQUESTS, + get_trip, +) + + +# =========================================================================== +# IN-MEMORY STORE +# Replace with DB calls in production. +# =========================================================================== + +TOKENS: list[TripToken] = [] + + +# =========================================================================== +# INTERNAL HELPERS +# =========================================================================== + +def _generate_token_value() -> str: + """ + Generates a cryptographically secure token string. + Uses secrets.token_urlsafe which is safe for authentication purposes. + 8 bytes gives a 11-character URL-safe string — short enough to type, + long enough to be secure. + """ + return secrets.token_urlsafe(8) + + +def _generate_token_id() -> str: + """Generates a unique token ID.""" + return f"TK-{uuid4().hex[:6].upper()}" + + +def _compute_expiry(trip_date: datetime) -> datetime: + """ + Token expires at midnight on the trip date. + + Design decision: + Rather than a fixed window (e.g. 4 hours), the token is valid + for the entire trip day. This is more practical for government + trips where the exact departure time may shift during the day, + but the trip date itself is fixed by the admin approval. + """ + return trip_date.replace(hour=23, minute=59, second=59, microsecond=0) + + +def _generate_qr_image(token_value: str) -> Optional[str]: + """ + Generates a base64-encoded PNG QR code from the token value. + Returns None if the qrcode library is not installed. + + The base64 string can be embedded directly in an HTML img tag: + + """ + if not QR_AVAILABLE or qrcode is None: + return None + + qr = qrcode.QRCode(version=1, box_size=10, border=4) + qr.add_data(token_value) + qr.make(fit=True) + img = qr.make_image(fill_color="black", back_color="white") + + buffer = BytesIO() + img.save(buffer, format="PNG") # type: ignore[arg-type] + return base64.b64encode(buffer.getvalue()).decode("utf-8") + + +def _get_token_by_id(token_id: str) -> Optional[TripToken]: + """Fetches a token by its token_id.""" + return next((t for t in TOKENS if t.token_id == token_id), None) + + +def _get_active_token_for_trip(trip_id: str) -> Optional[TripToken]: + """ + Returns the current active (unused, unexpired) token for a trip. + A trip should only ever have one active token at a time. + """ + return next( + ( + t for t in TOKENS + if t.trip_id == trip_id + and not t.is_used + and datetime.now() <= t.expires_at + ), + None, + ) + + +# =========================================================================== +# CORE FUNCTIONS +# =========================================================================== + +def issue_token(trip_id: str) -> dict: + """ + Creates and stores a new TripToken for an allocated trip. + Called automatically by the allocation engine after allocation is confirmed. + + Design reference: + "Token is issued after allocation — no human input required." + + The token is created without a mode at this stage — the mode (QR or text) + is chosen by the employee when they request it via get_token_for_employee(). + This separation means the system can issue the token immediately on + allocation without waiting to know how the employee wants to receive it. + + Returns a dict with keys: + - success (bool) + - token_id (str) + - errors (list[str]) + """ + trip = get_trip(trip_id) + if not trip: + return { + "success": False, + "token_id": None, + "errors": [f"Trip '{trip_id}' not found."] + } + + if trip.status != TripStatus.ALLOCATED: + return { + "success": False, + "token_id": None, + "errors": [ + f"Cannot issue token — trip '{trip_id}' is not allocated. " + f"Current status: {trip.status}." + ] + } + + # Invalidate any existing unused token for this trip before issuing a new one. + # This handles the case where a Fleet Manager override triggers a re-issue. + for existing in TOKENS: + if existing.trip_id == trip_id and not existing.is_used: + existing.is_used = True + + token = TripToken( + token_id=_generate_token_id(), + trip_id=trip_id, + token_value=_generate_token_value(), + issued_at=datetime.now(), + expires_at=_compute_expiry(trip.trip_date), + ) + + TOKENS.append(token) + + # Write token_id onto the trip so the trip always knows its current token + trip.token_id = token.token_id + + return { + "success": True, + "token_id": token.token_id, + "errors": [], + } + + +def get_token_for_employee(data: TokenRequestIn) -> dict: + """ + Returns the token to the employee in their chosen mode (QR or text). + + Design reference: + "Employee chooses QR or text mode." + "Token expires at midnight on the trip date." + + This is called by the employee after allocation — they choose how they + want to receive the token. The token itself was already created by + issue_token() at allocation time. + + If the token has expired, the employee can call this endpoint again + and a fresh token will be issued automatically — no Fleet Manager needed. + + Returns a dict with: + - success (bool) + - mode (TokenMode) + - token_value (str | None) — populated for text mode + - qr_code_base64 (str | None) — populated for QR mode + - expires_at (datetime) + - errors (list[str]) + """ + trip = get_trip(data.trip_id) + if not trip: + return {"success": False, "errors": [f"Trip '{data.trip_id}' not found."]} + + if trip.status != TripStatus.ALLOCATED: + return { + "success": False, + "errors": [ + f"Trip '{data.trip_id}' is not ready for token retrieval. " + f"Current status: {trip.status}." + ] + } + + # Check for an existing active token + active_token = _get_active_token_for_trip(data.trip_id) + + if not active_token: + # Token has expired — issue a fresh one automatically + # Design decision: employee self-serves, no Fleet Manager needed + reissue_result = issue_token(data.trip_id) + if not reissue_result["success"]: + return {"success": False, "errors": reissue_result["errors"]} + active_token = _get_token_by_id(reissue_result["token_id"]) + + if not active_token: + return {"success": False, "errors": ["Failed to retrieve or generate token."]} + + # Update the mode on the token based on employee preference + active_token.mode = data.mode + + # Build the response based on chosen mode + if data.mode == TokenMode.QR: + qr_image = _generate_qr_image(active_token.token_value) + if not qr_image: + return { + "success": False, + "errors": [ + "QR code generation is unavailable. " + "Please install the 'qrcode' package or use text mode." + ] + } + active_token.qr_code_base64 = qr_image + return { + "success": True, + "mode": TokenMode.QR, + "token_value": None, + "qr_code_base64": qr_image, + "expires_at": active_token.expires_at, + "errors": [], + } + + # Text mode — return the raw token value + return { + "success": True, + "mode": TokenMode.TEXT, + "token_value": active_token.token_value, + "qr_code_base64": None, + "expires_at": active_token.expires_at, + "errors": [], + } + + +def consume_token(trip_id: str, token_value: str) -> dict: + """ + Validates and consumes the token when the driver authenticates. + Called by the trip lifecycle engine when the driver starts the trip. + + Design reference: + "Driver scans or enters token to start the trip." + "Once used, token cannot be reused." + + Validation checks: + 1. Trip must exist and be ALLOCATED + 2. Token must exist for this trip + 3. Token must not already be used + 4. Token must not be expired + 5. Token value must match + + On success: + - Token is marked as used (is_used = True) + - Returns success so the lifecycle engine can move trip to ONGOING + """ + trip = get_trip(trip_id) + if not trip: + return {"success": False, "errors": [f"Trip '{trip_id}' not found."]} + + if trip.status != TripStatus.ALLOCATED: + return { + "success": False, + "errors": [f"Trip '{trip_id}' is not ready to start. Current status: {trip.status}."] + } + + if not trip.token_id: + return {"success": False, "errors": ["No token has been issued for this trip."]} + + token = _get_token_by_id(trip.token_id) + if not token: + return {"success": False, "errors": ["Token not found."]} + + if token.is_used: + return {"success": False, "errors": ["Token has already been used."]} + + if datetime.now() > token.expires_at: + return { + "success": False, + "errors": [ + "Token has expired. " + "The employee must request a new token before the trip can start." + ] + } + + if token.token_value != token_value: + return {"success": False, "errors": ["Invalid token. Authentication failed."]} + + # Mark token as consumed — cannot be reused + token.is_used = True + + return { + "success": True, + "trip_id": trip_id, + "message": "Token authenticated successfully. Trip is ready to start.", + } \ No newline at end of file diff --git a/backend/services/fleet_management/engines/trip_lifecycle_engine.py b/backend/services/fleet_management/engines/trip_lifecycle_engine.py new file mode 100644 index 0000000..82d0162 --- /dev/null +++ b/backend/services/fleet_management/engines/trip_lifecycle_engine.py @@ -0,0 +1,341 @@ +""" +fleet_management/engines/trip_lifecycle_engine.py +-------------------------------------------------- +Responsible for: + 1. Starting a trip — driver authenticates with token + 2. Updating live GPS coordinates during an ongoing trip + 3. Driver marking arrival at destination + 4. Employee confirming receipt — closes the trip as COMPLETED + 5. Releasing vehicle and driver back to AVAILABLE on completion + +Design reference: + - "Trip state machine, status updates, live tracking" + - "During active trips, GIS interprets GPS coordinates for basic live tracking" + - Two-step completion: driver marks arrived -> employee confirms -> COMPLETED + - This two-step ensures both operational practicality (driver knows when + they've arrived) and governance accountability (employee confirms receipt) + +State machine: + ALLOCATED + -> [driver authenticates token] + ONGOING + -> [GPS coordinates updated continuously] + -> [driver marks arrived] + ARRIVING + -> [employee confirms receipt] + COMPLETED +""" + +from datetime import datetime +from typing import Optional + +from fleet_management.models import ( + TripStatus, + VehicleStatus, + DriverStatus, +) +from fleet_management.engines.trip_request_processor import get_trip +from fleet_management.engines.token_generator_engine import consume_token +from fleet_management.engines.heuristic_allocation_engine import ( + VEHICLE_POOL, + DRIVER_POOL, +) + + +# =========================================================================== +# INTERNAL HELPERS +# =========================================================================== + +def _get_vehicle(vehicle_id: str): + return next((v for v in VEHICLE_POOL if v.vehicle_id == vehicle_id), None) + + +def _get_driver(driver_id: str): + return next((d for d in DRIVER_POOL if d.driver_id == driver_id), None) + + +def _release_vehicle_and_driver(vehicle_id: Optional[str], driver_id: Optional[str]) -> None: + """ + Releases the vehicle and driver back to AVAILABLE when a trip completes. + + Design note: + This is called on COMPLETED (not on ARRIVING) because the vehicle + and driver are still committed to the trip until the employee confirms. + Releasing early would allow double-allocation before the trip is truly done. + """ + if vehicle_id: + vehicle = _get_vehicle(vehicle_id) + if vehicle: + vehicle.current_status = VehicleStatus.AVAILABLE + + if driver_id: + driver = _get_driver(driver_id) + if driver: + driver.status = DriverStatus.AVAILABLE + driver.current_vehicle_id = None + + +# =========================================================================== +# STEP 1 — DRIVER AUTHENTICATES & STARTS TRIP +# =========================================================================== + +def start_trip(trip_id: str, token_value: str) -> dict: + """ + Driver authenticates using the token to start the trip. + Delegates token validation to the token_generator_engine. + + Design reference: + "Driver scans or enters token to start the trip." + "Token is consumed — cannot be reused." + + On success: + - Token is consumed (marked as used) + - Trip moves from ALLOCATED -> ONGOING + - started_at timestamp is recorded + + Validation: + 1. Trip must exist + 2. Trip must be ALLOCATED + 3. Token must be valid (delegated to consume_token) + """ + trip = get_trip(trip_id) + if not trip: + return {"success": False, "errors": [f"Trip '{trip_id}' not found."]} + + if trip.status != TripStatus.ALLOCATED: + return { + "success": False, + "errors": [ + f"Trip '{trip_id}' cannot be started. " + f"Current status: {trip.status}." + ] + } + + # Delegate token validation to the token engine + token_result = consume_token(trip_id, token_value) + if not token_result["success"]: + return {"success": False, "errors": token_result["errors"]} + + # Move trip to ONGOING + trip.status = TripStatus.ONGOING + trip.started_at = datetime.now() + + return { + "success": True, + "trip_id": trip_id, + "status": TripStatus.ONGOING, + "started_at": trip.started_at, + "message": "Trip started successfully.", + } + + +# =========================================================================== +# STEP 2 — LIVE GPS TRACKING +# =========================================================================== + +def update_gps(trip_id: str, latitude: float, longitude: float) -> dict: + """ + Updates the live GPS position of an ongoing trip. + + Design reference: + "During active trips, GIS interprets GPS coordinates for basic live tracking." + + Called periodically by the driver's device during the trip. + Each update appends to gps_track (the full route history) and + updates current_latitude/current_longitude (the latest position). + + The GIS service will later interpret these coordinates for terrain + context and route validation — for now we store them cleanly. + + Validation: + 1. Trip must exist + 2. Trip must be ONGOING — GPS updates only make sense for active trips + 3. Coordinates must be within valid ranges + Latitude: -90 to 90 + Longitude: -180 to 180 + """ + trip = get_trip(trip_id) + if not trip: + return {"success": False, "errors": [f"Trip '{trip_id}' not found."]} + + if trip.status != TripStatus.ONGOING: + return { + "success": False, + "errors": [ + f"Cannot update GPS — trip '{trip_id}' is not ongoing. " + f"Current status: {trip.status}." + ] + } + + if not (-90 <= latitude <= 90): + return {"success": False, "errors": ["Latitude must be between -90 and 90."]} + + if not (-180 <= longitude <= 180): + return {"success": False, "errors": ["Longitude must be between -180 and 180."]} + + # Append to full route history and update current position + trip.gps_track.append((latitude, longitude)) + trip.current_latitude = latitude + trip.current_longitude = longitude + + return { + "success": True, + "trip_id": trip_id, + "current_latitude": latitude, + "current_longitude": longitude, + "total_points_recorded": len(trip.gps_track), + } + + +# =========================================================================== +# STEP 3 — DRIVER MARKS ARRIVED +# =========================================================================== + +def driver_mark_arrived(trip_id: str, driver_id: str) -> dict: + """ + Driver marks that they have arrived at the destination. + Moves trip from ONGOING -> ARRIVING. + + Design reference: + "Driver marks arrived -> Employee confirms received -> Trip COMPLETED" + This step triggers the employee confirmation requirement. + + On success: + - Trip moves from ONGOING -> ARRIVING + - arrived_at timestamp is recorded + - Employee is expected to confirm receipt to complete the trip + + Validation: + 1. Trip must exist + 2. Trip must be ONGOING + 3. Driver must be the one assigned to this trip + (prevents another driver from marking someone else's trip) + """ + trip = get_trip(trip_id) + if not trip: + return {"success": False, "errors": [f"Trip '{trip_id}' not found."]} + + if trip.status != TripStatus.ONGOING: + return { + "success": False, + "errors": [ + f"Trip '{trip_id}' cannot be marked as arrived. " + f"Current status: {trip.status}." + ] + } + + # Ensure the driver marking arrival is the one assigned to this trip + if trip.assigned_driver_id != driver_id: + return { + "success": False, + "errors": [ + f"Driver '{driver_id}' is not assigned to trip '{trip_id}'. " + "Only the assigned driver can mark arrival." + ] + } + + trip.status = TripStatus.ARRIVING + trip.arrived_at = datetime.now() + + return { + "success": True, + "trip_id": trip_id, + "status": TripStatus.ARRIVING, + "arrived_at": trip.arrived_at, + "message": "Driver marked as arrived. Awaiting employee confirmation.", + } + + +# =========================================================================== +# STEP 4 — EMPLOYEE CONFIRMS RECEIPT +# =========================================================================== + +def employee_confirm_completion(trip_id: str, user_id: str) -> dict: + """ + Employee confirms they have received the service/reached the destination. + This is the final step — moves trip from ARRIVING -> COMPLETED. + + Design reference: + "Employee confirms received -> Trip COMPLETED" + This step closes the governance loop — the trip requester confirms + the trip was fulfilled, creating a full audit trail. + + On success: + - Trip moves from ARRIVING -> COMPLETED + - completed_at timestamp is recorded + - Vehicle and driver are released back to AVAILABLE + (only at this point — not on arrival — because the trip + is not truly done until the employee confirms) + + Validation: + 1. Trip must exist + 2. Trip must be ARRIVING + 3. User must be the employee who requested the trip + (prevents someone else from closing another person's trip) + """ + trip = get_trip(trip_id) + if not trip: + return {"success": False, "errors": [f"Trip '{trip_id}' not found."]} + + if trip.status != TripStatus.ARRIVING: + return { + "success": False, + "errors": [ + f"Trip '{trip_id}' cannot be confirmed. " + f"Current status: {trip.status}. " + "Trip must be in ARRIVING status for employee confirmation." + ] + } + + # Ensure only the employee who requested the trip can confirm it + if trip.user_id != user_id: + return { + "success": False, + "errors": [ + f"User '{user_id}' did not request this trip. " + "Only the trip requester can confirm completion." + ] + } + + # Close the trip + trip.status = TripStatus.COMPLETED + trip.completed_at = datetime.now() + + # Release vehicle and driver — they are now free for new allocations + _release_vehicle_and_driver(trip.assigned_vehicle_id, trip.assigned_driver_id) + + return { + "success": True, + "trip_id": trip_id, + "status": TripStatus.COMPLETED, + "completed_at": trip.completed_at, + "message": "Trip completed successfully. Vehicle and driver are now available.", + } + + +# =========================================================================== +# UTILITY — GET LIVE TRIP STATUS +# =========================================================================== + +def get_trip_status(trip_id: str) -> dict: + """ + Returns the current status and live position of a trip. + Used by the Fleet Manager dashboard and employee tracking view. + """ + trip = get_trip(trip_id) + if not trip: + return {"success": False, "errors": [f"Trip '{trip_id}' not found."]} + + return { + "success": True, + "trip_id": trip_id, + "status": trip.status, + "assigned_vehicle_id": trip.assigned_vehicle_id, + "assigned_driver_id": trip.assigned_driver_id, + "current_latitude": trip.current_latitude, + "current_longitude": trip.current_longitude, + "gps_points_recorded": len(trip.gps_track), + "started_at": trip.started_at, + "arrived_at": trip.arrived_at, + "completed_at": trip.completed_at, + } \ No newline at end of file diff --git a/backend/services/fleet_management/engines/trip_request_processor.py b/backend/services/fleet_management/engines/trip_request_processor.py new file mode 100644 index 0000000..a99b34d --- /dev/null +++ b/backend/services/fleet_management/engines/trip_request_processor.py @@ -0,0 +1,245 @@ +""" +fleet_management/engines/trip_request_processor.py +---------------------------------------------------- +Responsible for: + 1. Validating incoming trip requests from employees + 2. Creating and storing TripRequest objects + 3. Detecting routine trips (same user + same destination seen before) + +This engine does NOT approve or allocate — it only creates and validates. +Approval is handled by the Admin Manager via the allocation flow. + +In-memory store is used for now (TRIP_REQUESTS list). +Replace with a database layer when moving to production. +""" + +from datetime import datetime +from typing import Optional +from uuid import uuid4 + +from fleet_management.models import ( + TripRequest, + TripStatus, + TripRequestIn, + User, + UserRole, +) +from fleet_management.engines.vehicle_suitability_module import apply_suitability_to_trip + + +# =========================================================================== +# IN-MEMORY STORES +# Replace these with DB calls in production. +# =========================================================================== + +TRIP_REQUESTS: list[TripRequest] = [] + +EMPLOYEES: list[User] = [ + User(user_id="U001", name="Thabo Mohapi", role=UserRole.EMPLOYEE, ministry_id="M001"), + User(user_id="U002", name="Molupi Lekhanya", role=UserRole.EMPLOYEE, ministry_id="M002"), + User(user_id="U003", name="Palesa Ramollo", role=UserRole.EMPLOYEE, ministry_id="M001"), +] + + +# =========================================================================== +# INTERNAL HELPERS +# =========================================================================== + +def _get_employee(user_id: str) -> Optional[User]: + """Returns the employee User object or None if not found.""" + return next((u for u in EMPLOYEES if u.user_id == user_id), None) + + +def _generate_request_id() -> str: + """Generates a unique trip request ID in the format TR-XXXXXX.""" + return f"TR-{uuid4().hex[:6].upper()}" + + +def _is_routine_trip(user_id: str, destination: str) -> bool: + """ + Detects if this user has made a completed trip to the same destination before. + A trip is considered 'routine' if the same user has at least 2 prior + completed trips to the same destination. + + Returns True if routine, False otherwise. + """ + prior_trips = [ + t for t in TRIP_REQUESTS + if t.user_id == user_id + and t.destination.strip().lower() == destination.strip().lower() + and t.status == TripStatus.COMPLETED + ] + return len(prior_trips) >= 2 + + +def _validate_trip_input(data: TripRequestIn) -> list[str]: + """ + Validates the trip request input fields. + Returns a list of error messages. Empty list means input is valid. + """ + errors = [] + + if not data.destination.strip(): + errors.append("Destination cannot be empty.") + + if not data.pickup_location.strip(): + errors.append("Pickup location cannot be empty.") + + if not data.purpose.strip(): + errors.append("Trip purpose cannot be empty.") + + if data.trip_date < datetime.now(): + errors.append("Trip date cannot be in the past.") + + if data.passengers < 1: + errors.append("Passenger count must be at least 1.") + + if data.pickup_location.strip().lower() == data.destination.strip().lower(): + errors.append("Pickup location and destination cannot be the same.") + + return errors + + +# =========================================================================== +# GIS INTEGRATION — TERRAIN DIFFICULTY SCORING +# Calls the GIS service pipeline to get real terrain data. +# Falls back to mock data if the GIS service is unavailable. +# =========================================================================== + +def _get_gis_terrain_score(destination: str) -> float: + """ + Calls the GIS service pipeline to compute the Terrain Difficulty Score. + + Design reference: + "GIS Service computes Terrain Difficulty Score + -> Vehicle Suitability Module converts score -> vehicle_hint + -> Allocation Engine uses vehicle_hint to filter vehicles" + + Pipeline: + 1. route_request_processor.resolve_coordinates() -> Coordinates + 2. terrain_analysis_engine.analyse_terrain() -> TerrainMetadata + 3. Returns terrain_difficulty_score from metadata + + Uses mock=True to avoid API calls during trip creation. + The GIS API endpoint handles real API calls when explicitly requested. + + Falls back gracefully if any step fails. + """ + try: + from gis_service.engines.route_request_processor import resolve_coordinates + from gis_service.engines.terrain_analysis_engine import analyse_terrain + + # Step 1 -- Resolve coordinates (uses cache for known Lesotho locations) + coord_result = resolve_coordinates(destination) + if not coord_result["success"]: + return 0.25 # Default: light urban/paved + + # Step 2 -- Analyse terrain (use_mock=True for fast, no API calls) + terrain_result = analyse_terrain( + coord_result["coordinates"], + use_mock=True, # type: ignore[call-arg] + ) + if not terrain_result["success"]: + return 0.25 + + return terrain_result["metadata"].terrain_difficulty_score + + except Exception: + # GIS service unavailable -- return safe default + return 0.25 + + +# =========================================================================== +# MAIN PROCESSOR FUNCTIONS +# =========================================================================== + +def create_trip_request(data: TripRequestIn) -> dict: + """ + Validates and creates a new TripRequest from employee input. + + Steps: + 1. Check the employee exists + 2. Validate all input fields + 3. Detect if this is a routine trip + 4. Create and store the TripRequest + 5. Return a structured result with trip details and routine flag + + Returns a dict with keys: + - success (bool) + - trip (TripRequest | None) + - is_routine (bool) + - errors (list[str]) + """ + + # Step 1 — Verify employee exists + employee = _get_employee(data.user_id) + if not employee: + return { + "success": False, + "trip": None, + "is_routine": False, + "errors": [f"Employee with ID '{data.user_id}' not found."], + } + + # Step 2 — Validate input + errors = _validate_trip_input(data) + if errors: + return { + "success": False, + "trip": None, + "is_routine": False, + "errors": errors, + } + + # Step 3 — Detect routine trip + is_routine = _is_routine_trip(data.user_id, data.destination) + + # Step 4 — Build and store the TripRequest + trip = TripRequest( + request_id=_generate_request_id(), + user_id=data.user_id, + pickup_location=data.pickup_location.strip(), + destination=data.destination.strip(), + trip_date=data.trip_date, + purpose=data.purpose.strip(), + passengers=data.passengers, + status=TripStatus.PENDING, + created_at=datetime.now(), + ) + + TRIP_REQUESTS.append(trip) + + # Step 5 — Apply terrain suitability via GIS stub + # Design reference: + # "GIS Service computes Terrain Difficulty Score + # -> Vehicle Suitability Module converts score -> vehicle_hint + # -> Allocation Engine uses vehicle_hint to filter vehicles" + # The stub derives a score from the destination name. + # Replace with real GIS call when gis_service/ is integrated. + terrain_score = _get_gis_terrain_score(data.destination) + apply_suitability_to_trip(trip.request_id, terrain_score, trip=trip) + + # Step 6 — Return result + return { + "success": True, + "trip": trip, + "is_routine": is_routine, + "terrain_difficulty_score": terrain_score, + "vehicle_hint": trip.vehicle_hint, + "errors": [], + } + + +def get_trip(request_id: str) -> Optional[TripRequest]: + """Fetches a single trip by its request ID. Returns None if not found.""" + return next((t for t in TRIP_REQUESTS if t.request_id == request_id), None) + + +def get_trips_by_user(user_id: str) -> list[TripRequest]: + """Returns all trips submitted by a specific employee.""" + return [t for t in TRIP_REQUESTS if t.user_id == user_id] + + +def get_trips_by_status(status: TripStatus) -> list[TripRequest]: + """Returns all trips currently in a given status.""" + return [t for t in TRIP_REQUESTS if t.status == status] \ No newline at end of file diff --git a/backend/services/fleet_management/engines/vehicle_suitability_module.py b/backend/services/fleet_management/engines/vehicle_suitability_module.py new file mode 100644 index 0000000..579a8be --- /dev/null +++ b/backend/services/fleet_management/engines/vehicle_suitability_module.py @@ -0,0 +1,216 @@ +""" +fleet_management/engines/vehicle_suitability_module.py +------------------------------------------------------- +Responsible for: + 1. Converting a numeric Terrain Difficulty Score (0.0-1.0) into a + structured vehicle hint for the Allocation Engine + 2. Computing per-vehicle-type suitability scores (0.0-1.0) + 3. Writing the hint and score onto the TripRequest + +Design reference: + - "Vehicle Suitability Module" + - "Converts Difficulty Score into a structured hint for the Allocation Engine" + - "Ensures the right vehicle type is assigned to each terrain" + - "Generates suitability score" + +Flow: + GIS Service computes Terrain Difficulty Score + -> Vehicle Suitability Module converts score -> vehicle_hint + suitability_scores + -> Allocation Engine uses vehicle_hint to filter vehicles + -> Optimization Engine uses suitability_scores to rank vehicles precisely + +Terrain Difficulty Thresholds: + Based on Bekker (Theory of Land Locomotion, 1956) — foundational reference + for off-road vehicle capability — and UN Vehicle Management Manual (2021) + which uses a 4-tier trafficability system for field operations. + + 0.00 - 0.30: Paved/urban — flat roads, tarmac -> sedan_ok + 0.30 - 0.55: Light off-road — gravel, mild inclines -> suv_preferred + 0.55 - 0.80: Serious off-road — steep, rough tracks -> 4x4_required + 0.80 - 1.00: Extreme — no road, deep mud, altitude -> specialist_required + + Matches Lesotho's geography: + Maseru (urban/flat) -> 0.0-0.3 -> sedan_ok + Mafeteng foothills -> 0.3-0.55 -> suv_preferred + Thaba-Tseka / Mokhotlong districts -> 0.55-0.8 -> 4x4_required + Remote Maluti mountain areas -> 0.8-1.0 -> specialist_required + +Per-Vehicle Suitability Scores: + Based on Bekker (1956) and UN Vehicle Management Manual (2021). + Each vehicle type gets a score (0.0-1.0) per terrain band showing + how well-suited it is. Used by the Optimization Engine for precise ranking. +""" + +from fleet_management.models import TripRequest, VehicleType + + +# =========================================================================== +# TERRAIN THRESHOLDS +# =========================================================================== + +TERRAIN_THRESHOLDS = [ + (0.00, 0.30, "sedan_ok"), + (0.30, 0.55, "suv_preferred"), + (0.55, 0.80, "4x4_required"), + (0.80, 1.01, "specialist_required"), # 1.01 to include 1.0 +] + + +# =========================================================================== +# SUITABILITY SCORE TABLES +# Per-vehicle-type suitability scores per terrain band. +# Reference: Bekker (1956), UN Vehicle Management Manual (2021) +# +# Scoring logic: +# 1.0 = ideal vehicle for this terrain +# 0.7 = acceptable, minor performance compromise +# 0.4 = possible but not recommended +# 0.1 = very poor — risk of getting stuck or damage +# 0.0 = not suitable — should not be used +# =========================================================================== + +SUITABILITY_TABLE: dict[str, dict[str, float]] = { + "sedan_ok": { + VehicleType.SEDAN.value: 1.0, # Ideal — paved roads + VehicleType.SUV.value: 0.9, # Works well, slight overkill + VehicleType.FOUR_BY_FOUR.value: 0.8, # Works, unnecessary capability + VehicleType.MINIBUS.value: 0.85, # Fine for flat urban terrain + VehicleType.TRUCK.value: 0.6, # Possible but inefficient + }, + "suv_preferred": { + VehicleType.SEDAN.value: 0.4, # Possible but not recommended + VehicleType.SUV.value: 1.0, # Ideal — designed for this + VehicleType.FOUR_BY_FOUR.value: 0.95, # Excellent, slight overkill + VehicleType.MINIBUS.value: 0.5, # Possible on good gravel + VehicleType.TRUCK.value: 0.65, # Manageable + }, + "4x4_required": { + VehicleType.SEDAN.value: 0.1, # Very poor — risk of damage + VehicleType.SUV.value: 0.5, # Manageable but not ideal + VehicleType.FOUR_BY_FOUR.value: 1.0, # Ideal — built for this + VehicleType.MINIBUS.value: 0.2, # Poor — low clearance + VehicleType.TRUCK.value: 0.7, # Acceptable for freight + }, + "specialist_required": { + VehicleType.SEDAN.value: 0.0, # Not suitable + VehicleType.SUV.value: 0.2, # Very risky + VehicleType.FOUR_BY_FOUR.value: 0.5, # Possible but high risk + VehicleType.MINIBUS.value: 0.0, # Not suitable + VehicleType.TRUCK.value: 0.4, # Depends on truck spec + }, +} + + +# =========================================================================== +# CORE FUNCTIONS +# =========================================================================== + +def compute_vehicle_suitability(terrain_difficulty_score: float) -> dict: + """ + Converts a numeric terrain difficulty score into a vehicle hint + and per-vehicle suitability scores. + + Design reference: + "Converts Difficulty Score into a structured hint for the + Allocation Engine" + "Generates suitability score" + + Args: + terrain_difficulty_score: float between 0.0 and 1.0 + + Returns a dict with: + - vehicle_hint: str (sedan_ok / suv_preferred / 4x4_required / + specialist_required) + - terrain_difficulty_score: float (echoed back) + - terrain_band: str (human-readable description) + - suitability_scores: dict[vehicle_type -> float] + - recommended_types: list[str] (types scoring >= 0.7) + """ + # Clamp to valid range + score = max(0.0, min(1.0, terrain_difficulty_score)) + + # Determine hint from threshold bands + vehicle_hint = "sedan_ok" + for lower, upper, hint in TERRAIN_THRESHOLDS: + if lower <= score < upper: + vehicle_hint = hint + break + + # Get suitability scores for this hint + suitability_scores = SUITABILITY_TABLE.get(vehicle_hint, {}) + + # Terrain band description + terrain_band_map = { + "sedan_ok": "Paved/urban — flat roads, tarmac", + "suv_preferred": "Light off-road — gravel, mild inclines", + "4x4_required": "Serious off-road — steep grades, rough tracks", + "specialist_required": "Extreme — no formal road, deep mud, high altitude", + } + + # Recommended types — those scoring >= 0.7 + recommended_types = [ + vehicle_type + for vehicle_type, s_score in suitability_scores.items() + if s_score >= 0.7 + ] + + return { + "vehicle_hint": vehicle_hint, + "terrain_difficulty_score": round(score, 4), + "terrain_band": terrain_band_map.get(vehicle_hint, "Unknown"), + "suitability_scores": suitability_scores, + "recommended_types": recommended_types, + } + + +def apply_suitability_to_trip(trip_id: str, terrain_difficulty_score: float, trip: TripRequest | None = None) -> dict: + """ + Computes vehicle suitability and writes the results onto the TripRequest. + + Called by the trip_request_processor after creating a trip (passing the + trip object directly to avoid circular imports), or by the GIS service + passing a trip_id for lookup. + + Design reference: + "GIS Service computes Terrain Difficulty Score + -> Vehicle Suitability Module converts score -> vehicle_hint + -> Allocation Engine uses vehicle_hint to filter vehicles" + + Returns a dict with the full suitability result plus trip confirmation. + """ + if trip is None: + # Lazy import to avoid circular dependency + from fleet_management.engines.trip_request_processor import get_trip + trip = get_trip(trip_id) + + if not trip: + return { + "success": False, + "errors": [f"Trip '{trip_id}' not found."] + } + + suitability = compute_vehicle_suitability(terrain_difficulty_score) + + # Write onto the trip + trip.terrain_difficulty_score = suitability["terrain_difficulty_score"] + trip.vehicle_hint = suitability["vehicle_hint"] + + return { + "success": True, + "trip_id": trip_id, + **suitability, + "message": ( + f"Vehicle suitability computed. " + f"Hint '{suitability['vehicle_hint']}' applied to trip." + ), + } + + +def get_suitability_for_score(terrain_difficulty_score: float) -> dict: + """ + Standalone lookup — returns suitability data for a given score + without applying it to any trip. + + Used by the Fleet Manager dashboard and GIS service for inspection. + """ + return compute_vehicle_suitability(terrain_difficulty_score) \ No newline at end of file diff --git a/backend/services/fleet_management/fleet_management_api.py b/backend/services/fleet_management/fleet_management_api.py deleted file mode 100644 index 2b05f23..0000000 --- a/backend/services/fleet_management/fleet_management_api.py +++ /dev/null @@ -1,88 +0,0 @@ -from fastapi import FastAPI, HTTPException -from pydantic import BaseModel -from datetime import datetime -from fleet_management.trip_request import create_trip_request, recommend_vehicle -from fleet_management.allocation import admin_approve_trip, fleet_manager_confirm_trip, rerecommend_trip -from fleet_management.monitoring import authenticate_trip, end_trip - -app = FastAPI(title="GovRide AI Fleet Management API") - -# ---------------------------- -# Request Models -# ---------------------------- - -class TripRequestIn(BaseModel): - user_id: str - pickup_location: str - destination: str - trip_date: datetime - purpose: str - -class AdminApprovalIn(BaseModel): - approve: bool - -class FleetAllocationIn(BaseModel): - fleet_manager_id: str - vehicle_id: str | None = None - driver_id: str | None = None - -class FleetReRecommendationIn(BaseModel): - new_vehicle_id: str | None = None - new_driver_id: str | None = None - -class TripAuthIn(BaseModel): - employee_pin: str - driver_pin: str - - -# ---------------------------- -# Endpoints -# ---------------------------- - -@app.post("/trip-request/") -def submit_trip_request(trip_data: TripRequestIn): - trip = create_trip_request( - user_id=trip_data.user_id, - pickup=trip_data.pickup_location, - destination=trip_data.destination, - trip_date=trip_data.trip_date, - purpose=trip_data.purpose, - ) - recommend_vehicle(trip) - return {"message": f"Trip {trip.request_id} created successfully", "trip_id": trip.request_id} - - -@app.patch("/trip-request/{request_id}/approve") -def approve_trip(request_id: str, data: AdminApprovalIn): - result = admin_approve_trip(request_id, approve=data.approve) - if "not found" in result: - raise HTTPException(status_code=404, detail=result) - return {"message": result} - - -@app.patch("/trip-request/{request_id}/allocate") -def confirm_trip_allocation(request_id: str, data: FleetAllocationIn): - result = fleet_manager_confirm_trip(request_id, data.fleet_manager_id, data.vehicle_id, data.driver_id) - if isinstance(result, str): - raise HTTPException(status_code=404, detail=result) - return result - - -@app.patch("/trip-request/{request_id}/rerecommend") -def rerecommend_allocation(request_id: str, data: FleetReRecommendationIn): - result = rerecommend_trip(request_id, data.new_vehicle_id, data.new_driver_id) - return {"message": result} - - -@app.patch("/trip-request/{request_id}/start") -def start_trip(request_id: str, data: TripAuthIn): - result = authenticate_trip(request_id, data.employee_pin, data.driver_pin) - if "failed" in result: - raise HTTPException(status_code=400, detail=result) - return {"message": result} - - -@app.patch("/trip-request/{request_id}/complete") -def complete_trip(request_id: str): - result = end_trip(request_id) - return {"message": result} diff --git a/backend/services/fleet_management/models.py b/backend/services/fleet_management/models.py index b2d4903..89a23ad 100644 --- a/backend/services/fleet_management/models.py +++ b/backend/services/fleet_management/models.py @@ -1,55 +1,301 @@ -from dataclasses import dataclass, field +""" +fleet_management/models.py +-------------------------- +All Pydantic models for the Fleet Management Service. + +Design principles: + - Every model is a clean Pydantic BaseModel (no dataclasses) + - Enums for all fixed-choice fields (no raw strings like "available"/"pending") + - Optional fields have explicit defaults + - Models are grouped by domain: Users, Vehicles, Trips, Tokens +""" + +from pydantic import BaseModel, Field from datetime import datetime from typing import Optional +from enum import Enum -@dataclass -class Ministry: - """ - Represents a government ministry that uses the system. - """ + +# =========================================================================== +# ENUMS +# =========================================================================== + +class UserRole(str, Enum): + EMPLOYEE = "employee" + ADMIN_MANAGER = "admin_manager" + FLEET_MANAGER = "fleet_manager" + DRIVER = "driver" + + +class VehicleType(str, Enum): + SEDAN = "sedan" + SUV = "suv" + FOUR_BY_FOUR = "4x4" + MINIBUS = "minibus" + TRUCK = "truck" + + +class FuelType(str, Enum): + PETROL = "petrol" + DIESEL = "diesel" + HYBRID = "hybrid" + ELECTRIC = "electric" + + +class VehicleStatus(str, Enum): + AVAILABLE = "available" + ON_TRIP = "on_trip" + MAINTENANCE = "maintenance" # Vehicle is off-road — either in workshop or blocked by system + + +class DriverStatus(str, Enum): + AVAILABLE = "available" + ON_TRIP = "on_trip" + OFF_DUTY = "off_duty" + SUSPENDED = "suspended" # Flagged by wellbeing engine + + +class TripStatus(str, Enum): + PENDING = "pending" # Submitted by employee, awaiting admin approval + APPROVED = "approved" # Admin Manager approved, awaiting fleet allocation + REJECTED = "rejected" # Admin Manager rejected + ALLOCATED = "allocated" # Vehicle & driver assigned, token issued + ONGOING = "ongoing" # Trip authenticated and started + ARRIVING = "arriving" # Driver marked arrived, awaiting employee confirmation + COMPLETED = "completed" # Employee confirmed receipt — trip fully closed + CANCELLED = "cancelled" # Cancelled after allocation + + +class TokenMode(str, Enum): + QR = "qr" + TEXT = "text" + + +# =========================================================================== +# VEHICLE HINT MAP +# Translates GIS terrain hint into acceptable vehicle types. +# Lives here so both the allocation engine and standby market engine +# can import it without circular dependency. +# +# Design reference: +# "GIS returns vehicle_hint -> Allocation Engine uses it to filter vehicles" +# +# sedan_ok -> flat/urban terrain, any vehicle works +# suv_preferred -> hilly terrain, light off-road capability needed +# 4x4_required -> serious terrain, only a 4x4 qualifies +# specialist_required -> no standard vehicle qualifies, escalate to standby +# =========================================================================== + +VEHICLE_HINT_MAP: dict[str, list[str]] = { + "sedan_ok": ["sedan", "suv", "4x4", "minibus"], + "suv_preferred": ["suv", "4x4"], + "4x4_required": ["4x4"], + "specialist_required": [], +} + + +# =========================================================================== +# DOMAIN MODELS +# =========================================================================== + +# --------------------------------------------------------------------------- +# Ministry +# --------------------------------------------------------------------------- + +class Ministry(BaseModel): + """A government ministry that owns employees and has a fleet manager.""" ministry_id: str name: str - budget_code: str -@dataclass -class User: +# --------------------------------------------------------------------------- +# Users +# --------------------------------------------------------------------------- + +class User(BaseModel): """ - Represents a system user (employee, driver, admin, etc.) + Represents any system user. + Role determines what actions they can perform. + ministry_id links employees and admin managers to their ministry. """ user_id: str name: str - role: str # ('employee', 'admin_manager', 'fleet_manager', 'driver', 'admin') - ministry_id: str + role: UserRole + ministry_id: Optional[str] = None # Not required for fleet managers / drivers -@dataclass -class Vehicle: +# --------------------------------------------------------------------------- +# Vehicles +# --------------------------------------------------------------------------- + +class Vehicle(BaseModel): """ - Represents a vehicle in the fleet. + A government fleet vehicle. + readiness_score is computed by the Driver/Vehicle State Manager (0.0 - 1.0) + using the formula: (mileage_factor x 0.5) + (age_factor x 0.2) + (service_factor x 0.3) + Reference: Jardine et al. (Maintenance, Replacement and Reliability, 2006) """ vehicle_id: str registration_number: str - vehicle_type: str - fuel_type: str - current_status: str # ('available', 'in_use', 'maintenance') - acquisition_date: datetime + vehicle_type: VehicleType + fuel_type: FuelType + capacity: int # Passenger capacity (excluding driver) + current_status: VehicleStatus = VehicleStatus.AVAILABLE + readiness_score: float = Field(default=1.0, ge=0.0, le=1.0) + odometer_km: float = 0.0 # Current total odometer reading + last_service_odometer_km: float = 0.0 # Odometer at last service — used for mileage factor + manufacture_date: Optional[datetime] = None # Used for accurate age factor calculation + last_service_date: Optional[datetime] = None # Used for service recency factor + location: Optional[str] = None # Last known location label + + +# --------------------------------------------------------------------------- +# Drivers +# --------------------------------------------------------------------------- + +class Driver(BaseModel): + """ + A fleet driver. + hours_driven_this_period tracks accumulated hours in the current 14-day + biweekly window. Resets automatically every 14 days. + Reference: EU Drivers Hours Regulation (EC) No 561/2006 — max 60hrs/14 days. + """ + driver_id: str + name: str + license_number: str + status: DriverStatus = DriverStatus.AVAILABLE + hours_driven_this_period: float = 0.0 # Biweekly accumulator (resets every 14 days) + hours_period_start: datetime = Field( # Start of the current 14-day period + default_factory=datetime.now + ) + current_vehicle_id: Optional[str] = None + location: Optional[str] = None + +# --------------------------------------------------------------------------- +# Trip Token +# --------------------------------------------------------------------------- -@dataclass -class TripRequest: +class TripToken(BaseModel): + """ + Secure authentication token issued after trip allocation. + Given to the employee; the driver scans/enters it to start the trip. + """ + token_id: str + trip_id: str + token_value: str + mode: TokenMode = TokenMode.QR + qr_code_base64: Optional[str] = None # Populated when mode = QR + issued_at: datetime = Field(default_factory=datetime.now) + expires_at: datetime + is_used: bool = False + + +# --------------------------------------------------------------------------- +# Trip Request +# --------------------------------------------------------------------------- + +class TripRequest(BaseModel): + """ + The central object in Fleet Management. + Moves through a status lifecycle from PENDING → COMPLETED. + + Fields are populated progressively as the trip moves through stages: + - Created with: request_id, user_id, pickup, destination, date, purpose, passengers + - After admin approval: approved_by, approved_at + - After allocation: assigned_vehicle_id, assigned_driver_id, token_id, allocated_at + - After trip start: started_at + - After trip end: completed_at + """ + # Core identity request_id: str + user_id: str # The employee who requested the trip + + # Trip details + pickup_location: str + destination: str + trip_date: datetime + purpose: str + passengers: int = Field(default=1, ge=1) + + # Lifecycle status + status: TripStatus = TripStatus.PENDING + + # Admin approval stage + approved_by: Optional[str] = None # User ID of the admin manager + approved_at: Optional[datetime] = None + rejection_reason: Optional[str] = None + + # Allocation stage + assigned_vehicle_id: Optional[str] = None + assigned_driver_id: Optional[str] = None + allocated_by: Optional[str] = None # Fleet manager user ID + allocated_at: Optional[datetime] = None + token_id: Optional[str] = None # Links to TripToken + + # Priority score (computed by Priority & Policy Engine) + # P_i = base_tier_score * ministry_multiplier [+ elevation_delta] + priority_score: Optional[float] = None + + # Terrain metadata (injected by GIS service) + terrain_difficulty_score: Optional[float] = None + vehicle_hint: Optional[str] = None # e.g. "4x4_required" + + # Lifecycle timestamps + created_at: datetime = Field(default_factory=datetime.now) + started_at: Optional[datetime] = None + arrived_at: Optional[datetime] = None # Driver marked arrived + completed_at: Optional[datetime] = None # Employee confirmed receipt + + # Live tracking — GPS coordinates updated during ONGOING trips + # Stored as a list of (latitude, longitude) tuples + gps_track: list[tuple[float, float]] = Field(default_factory=list) + current_latitude: Optional[float] = None + current_longitude: Optional[float] = None + + +# =========================================================================== +# REQUEST / RESPONSE SCHEMAS (used by the API layer) +# =========================================================================== + +class TripRequestIn(BaseModel): + """Payload sent by an employee to create a new trip request.""" user_id: str pickup_location: str destination: str trip_date: datetime purpose: str passengers: int = 1 - status: str = "pending" - assigned_driver: Optional[str] = None - assigned_vehicle: Optional[str] = None - start_time: Optional[datetime] = None - end_time: Optional[datetime] = None - approved_by_admin: Optional[str] = None - recommended_by_fleet_manager: Optional[str] = None - token: Optional[str] = None + + +class AdminApprovalIn(BaseModel): + """Payload sent by an Admin Manager to approve or reject a trip.""" + approve: bool + rejection_reason: Optional[str] = None # Required if approve=False + + +class AllocationIn(BaseModel): + """ + Payload sent by a Fleet Manager to confirm vehicle/driver allocation. + If vehicle_id or driver_id are omitted, the system uses its recommendation. + """ + fleet_manager_id: str + vehicle_id: Optional[str] = None + driver_id: Optional[str] = None + + +class ReAllocationIn(BaseModel): + """Payload to manually override vehicle or driver after initial allocation.""" + new_vehicle_id: Optional[str] = None + new_driver_id: Optional[str] = None + + +class TokenRequestIn(BaseModel): + """Employee requests a token (QR or text) after allocation.""" + trip_id: str + mode: TokenMode = TokenMode.QR + + +class TripAuthIn(BaseModel): + """Driver submits the token value to authenticate and start the trip.""" + token_value: str \ No newline at end of file diff --git a/backend/services/fleet_management/monitoring.py b/backend/services/fleet_management/monitoring.py deleted file mode 100644 index 2d524fd..0000000 --- a/backend/services/fleet_management/monitoring.py +++ /dev/null @@ -1,99 +0,0 @@ -# Implements trip execution and authentication functionality for starting and ending trips. -import secrets -import base64 -import qrcode -from io import BytesIO -from datetime import datetime, timedelta -from .models import TripRequest -from .trip_request import TRIP_REQUESTS - -def generate_trip_token(trip_id: str, mode="qr") -> dict | str: - """ - Generates a secure 6-character token for the employee. - Default mode is QR, but the employee can choose text. - The token expires after 4 hours and is stored in the trip itself. - """ - trip = next((t for t in TRIP_REQUESTS if t.request_id == trip_id), None) - if not trip or trip.status != "allocated": - return f"Trip {trip_id} is not ready for authentication." - - # Generate token - token = secrets.token_urlsafe(6) - expiry = datetime.now() + timedelta(hours=4) - - # Store token and expiry directly in trip object - trip.token = token - trip.token_expiry = expiry - trip.token_mode = mode - - # Generate QR if requested - if mode == "qr": - qr_image = generate_qr_image(token) - return { - "trip_id": trip_id, - "mode": "qr", - "qr_code_base64": qr_image, - "token_validity_hours": 4 - } - else: - return { - "trip_id": trip_id, - "mode": "text", - "token": token, - "token_validity_hours": 4 - } - -def generate_qr_image(token: str) -> str: - """Generates a base64-encoded QR code for frontend display.""" - qr = qrcode.QRCode(version=1, box_size=10, border=4) - qr.add_data(token) - qr.make(fit=True) - img = qr.make_image(fill_color="black", back_color="white") - - buffered = BytesIO() - img.save(buffered, format="PNG") - return base64.b64encode(buffered.getvalue()).decode("utf-8") - -def driver_authenticate_trip(trip_id: str, token: str) -> str: - """ - The driver enters or scans the token to start the trip. - """ - trip = next((t for t in TRIP_REQUESTS if t.request_id == trip_id), None) - - if not trip or trip.status != "allocated": - return f"Trip {trip_id} cannot be started." - - if not getattr(trip, "token", None) or not getattr(trip, "token_expiry", None): - return "No token has been issued yet." - - if datetime.now() > trip.token_expiry: - trip.token = None - trip.token_expiry = None - trip.token_mode = None - return "Token expired. Please request a new one." - - if token != trip.token: - return "Invalid token." - - # Start the trip - trip.status = "ongoing" - trip.start_time = datetime.now() - - # Clear token after successful authentication - trip.token = None - trip.token_expiry = None - trip.token_mode = None - - return f"Trip {trip_id} has started at {trip.start_time}." - -def end_trip(trip_id: str) -> str: - """ - Mark a trip as completed and record its end time. - """ - trip = next((t for t in TRIP_REQUESTS if t.request_id == trip_id), None) - if not trip or trip.status != "ongoing": - return f"Trip {trip_id} is not ongoing." - - trip.status = "completed" - trip.end_time = datetime.now() - return f"Trip {trip_id} completed at {trip.end_time}." diff --git a/backend/services/fleet_management/trip_request.py b/backend/services/fleet_management/trip_request.py deleted file mode 100644 index 16c9cd5..0000000 --- a/backend/services/fleet_management/trip_request.py +++ /dev/null @@ -1,45 +0,0 @@ -#This is the Trip Request Processor which collects trip requests from employees and gathers required information -from dataclasses import dataclass -from datetime import datetime -from typing import List, Optional -from .models import TripRequest, Vehicle, User - -VEHICLE_POOL = [ - Vehicle("V001", "ABC-123", "sedan", "petrol", "available", datetime(2020, 5, 10)), - Vehicle("V002", "XYZ-456", "4x4", "diesel", "available", datetime(2021, 7, 20)), -] - -EMPLOYEES = [ - User("U001", "Thabo Mohapi", "employee", "M001"), - User("U002", "Molupi Lekhanya", "employee", "M002"), -] - -TRIP_REQUESTS: List[TripRequest] = [] - -def create_trip_request(user_id: str, pickup: str, destination: str, trip_date: datetime, purpose: str, passengers: int = 1) -> TripRequest: - user = next((u for u in EMPLOYEES if u.user_id == user_id), None) - if not user: - raise ValueError(f"User {user_id} not found") - - request_id = f"TR{len(TRIP_REQUESTS)+1:03d}" - trip = TripRequest( - request_id=request_id, - user_id=user_id, - pickup_location=pickup, - destination=destination, - trip_date=trip_date, - purpose=purpose, - passengers=passengers, - status="pending" - ) - TRIP_REQUESTS.append(trip) - print(f"Trip request {trip.request_id} created successfully for {user.name}") - return trip - -def recommend_vehicle(trip: TripRequest) -> Optional[Vehicle]: - for vehicle in VEHICLE_POOL: - if vehicle.current_status == "available": - print(f"Recommended vehicle {vehicle.registration_number} ({vehicle.vehicle_type}) for trip {trip.request_id}") - return vehicle - print(f"No vehicles available for trip {trip.request_id}") - return None diff --git a/backend/services/gis_service/__init__.py b/backend/services/gis_service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/gis_service/api/gis_api.py b/backend/services/gis_service/api/gis_api.py new file mode 100644 index 0000000..16f4376 --- /dev/null +++ b/backend/services/gis_service/api/gis_api.py @@ -0,0 +1,294 @@ +""" +gis_service/api/gis_api.py +-------------------------- +FastAPI router for the GIS/Terrain Service. + +Design reference: + "The GIS/Terrain Service provides lightweight terrain and route information + required during trip allocation." + + Three functions only: + 1. Destination coordinate lookup + 2. Terrain analysis + 3. Difficulty scoring + + Plus live tracking interpretation during active trips. + +Endpoints: + POST /gis/terrain -- full terrain analysis for a trip destination + POST /gis/terrain/batch -- terrain analysis for multiple trips at once + GET /gis/terrain/{trip_id} -- get cached terrain data for a trip + POST /gis/live-tracking -- interpret GPS coordinates during active trip + GET /gis/coordinates -- resolve destination to coordinates only + GET /gis/suitability -- get vehicle hint for a terrain score + +Design principles: + - No business logic here — only routing and HTTP translation + - All heavy lifting in the engines + - use_mock=True by default — set use_mock=False for real API calls + - Every response includes is_mock flag so callers know data source +""" + +from fastapi import APIRouter, HTTPException, Query, status + +from gis_service.models import ( + TerrainRequest, + TerrainResponse, + LiveTrackingRequest, + LiveTrackingResponse, + BatchTerrainRequest, + BatchTerrainResponse, + VehicleHint, +) +from gis_service.engines.route_request_processor import resolve_coordinates +from gis_service.engines.terrain_analysis_engine import analyse_terrain +from gis_service.engines.vehicle_suitability_mapping_module import ( + map_to_vehicle_hint, + build_terrain_response, + get_hint_description, +) + + +router = APIRouter(prefix="/gis", tags=["GIS / Terrain"]) + + +# =========================================================================== +# HELPER +# =========================================================================== + +def _full_pipeline( + trip_id: str, + destination: str, + use_mock: bool = True, +) -> TerrainResponse: + """ + Runs the full GIS pipeline for a destination: + 1. route_request_processor -> coordinates + 2. terrain_analysis_engine -> terrain metadata + difficulty score + 3. vehicle_suitability_mapping_module -> vehicle hint + response + + Design reference: + "Fleet Management Service sends a destination location + -> GIS Service retrieves coordinates + -> queries [APIs] for terrain characteristics + -> computes Terrain Difficulty Score + -> returns structured metadata" + + Raises HTTPException on failure. + """ + # Step 1 -- Resolve coordinates + coord_result = resolve_coordinates(destination) + if not coord_result["success"]: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=coord_result["errors"], + ) + + coordinates = coord_result["coordinates"] + + # Step 2 -- Analyse terrain + terrain_result = analyse_terrain(coordinates, use_mock=use_mock) # type: ignore[call-arg] + if not terrain_result["success"]: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=terrain_result["errors"], + ) + + # Step 3 -- Build response with vehicle hint + response = build_terrain_response( + trip_id=trip_id, + destination=destination, + metadata=terrain_result["metadata"], + is_mock=terrain_result["is_mock"], + ) + + return response + + +# =========================================================================== +# TERRAIN ANALYSIS ENDPOINTS +# =========================================================================== + +@router.post( + "/terrain", + response_model=TerrainResponse, + status_code=status.HTTP_200_OK, + summary="Full terrain analysis for a trip destination", + description=( + "Runs the full GIS pipeline: destination → coordinates → terrain data " + "→ difficulty score → vehicle hint. " + "Called by Fleet Management during trip allocation. " + "Set use_mock=false to use real Open-Elevation and Overpass APIs." + ), +) +def analyse_trip_terrain( + data: TerrainRequest, + use_mock: bool = Query(default=True, description="Use mock data (no API calls)"), +): + return _full_pipeline(data.trip_id, data.destination, use_mock=use_mock) + + +@router.post( + "/terrain/batch", + response_model=BatchTerrainResponse, + status_code=status.HTTP_200_OK, + summary="Terrain analysis for multiple trips at once", + description=( + "Processes multiple terrain requests in a single call. " + "Failed requests are collected in the 'failed' list — " + "they do not cause the entire batch to fail." + ), +) +def analyse_batch_terrain( + data: BatchTerrainRequest, + use_mock: bool = Query(default=True, description="Use mock data (no API calls)"), +): + results = [] + failed = [] + + for request in data.requests: + try: + response = _full_pipeline( + request.trip_id, + request.destination, + use_mock=use_mock, + ) + results.append(response) + except HTTPException as e: + failed.append({ + "trip_id": request.trip_id, + "destination": request.destination, + "error": str(e.detail), + }) + + return BatchTerrainResponse(results=results, failed=failed) + + +# =========================================================================== +# LIVE TRACKING ENDPOINT +# =========================================================================== + +@router.post( + "/live-tracking", + response_model=LiveTrackingResponse, + status_code=status.HTTP_200_OK, + summary="Interpret GPS coordinates during an active trip", + description=( + "Called periodically during ONGOING trips to interpret the driver's " + "current GPS position. Returns elevation and nearest place name. " + "Set use_mock=false for real elevation data." + ), +) +def interpret_live_position( + data: LiveTrackingRequest, + use_mock: bool = Query(default=True, description="Use mock data (no API calls)"), +): + """ + Design reference: + "During active trips, GIS interprets GPS coordinates for basic live tracking" + + Uses the terrain analysis engine to get elevation and road context + for the current GPS position. + """ + from gis_service.models import Coordinates + + coordinates = Coordinates( + latitude=data.latitude, + longitude=data.longitude, + place_name="Current Position", + ) + + terrain_result = analyse_terrain(coordinates, use_mock=use_mock) # type: ignore[call-arg] + + if not terrain_result["success"]: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=terrain_result["errors"], + ) + + metadata = terrain_result["metadata"] + + return LiveTrackingResponse( + trip_id=data.trip_id, + latitude=data.latitude, + longitude=data.longitude, + elevation_meters=metadata.elevation.elevation_meters, + road_type=metadata.road.road_type, + nearest_place=metadata.coordinates.place_name, + terrain_type=metadata.elevation.terrain_type, + ) + + +# =========================================================================== +# UTILITY ENDPOINTS +# =========================================================================== + +@router.get( + "/coordinates", + status_code=status.HTTP_200_OK, + summary="Resolve a destination to GPS coordinates", + description=( + "Converts a destination string (place name, district, address) " + "to GPS coordinates using the Nominatim geocoder (OpenStreetMap). " + "Checks the Lesotho locations cache first — no API call needed " + "for known destinations." + ), +) +def get_coordinates( + destination: str = Query(..., description="Place name, district or address"), +): + result = resolve_coordinates(destination) + if not result["success"]: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=result["errors"], + ) + coords = result["coordinates"] + return { + "destination": destination, + "latitude": coords.latitude, + "longitude": coords.longitude, + "place_name": coords.place_name, + "display_name": coords.display_name, + "source": result["source"], + } + + +@router.get( + "/suitability", + status_code=status.HTTP_200_OK, + summary="Get vehicle hint for a terrain difficulty score", + description=( + "Converts a numeric terrain difficulty score (0.0-1.0) to a " + "vehicle hint. Useful for inspection and debugging. " + "Thresholds: 0.00-0.30=sedan_ok, 0.30-0.55=suv_preferred, " + "0.55-0.80=4x4_required, 0.80-1.00=specialist_required." + ), +) +def get_vehicle_suitability( + score: float = Query(..., ge=0.0, le=1.0, description="Terrain difficulty score"), +): + hint = map_to_vehicle_hint(score) + description = get_hint_description(hint) + return { + "terrain_difficulty_score": score, + "vehicle_hint": hint, + "description": description, + } + + +@router.get( + "/health", + status_code=status.HTTP_200_OK, + summary="GIS service health check", +) +def gis_health(): + return { + "service": "GovRide GIS/Terrain Service", + "status": "healthy", + "data_sources": [ + "Nominatim (OpenStreetMap) — geocoding", + "Open-Elevation (NASA SRTM) — elevation", + "Overpass API (OpenStreetMap) — road type", + ], + } \ No newline at end of file diff --git a/backend/services/gis_service/engines/route_request_processor.py b/backend/services/gis_service/engines/route_request_processor.py new file mode 100644 index 0000000..24a5b05 --- /dev/null +++ b/backend/services/gis_service/engines/route_request_processor.py @@ -0,0 +1,273 @@ +""" +gis_service/engines/route_request_processor.py +----------------------------------------------- +Responsible for: + 1. Validating the destination input (text/place name/district) + 2. Querying the Nominatim geocoder (OpenStreetMap) to convert + destination text → GPS coordinates + 3. Returning a Coordinates object for use by the Terrain Analysis Engine + +Design reference: + "Route Request Processor: + - Receives destination (text/place name/district) + - Validates destination format + - Queries Google Maps to convert destination → coordinates + - Sends coordinates to Terrain Analysis Engine" + + We use Nominatim (OpenStreetMap) instead of Google Maps. + Nominatim is free, requires no API key, and has excellent coverage + of Lesotho including all district towns and rural areas. + + Nominatim Usage Policy: + - Maximum 1 request per second + - Must include a valid User-Agent header + - No bulk geocoding (batch requests) + - Reference: https://operations.osmfoundation.org/policies/nominatim/ + +Known Lesotho Locations (fallback cache): + When the API is unavailable or returns no result, we fall back to a + hardcoded cache of known Lesotho locations. This ensures the system + continues to function during network outages. +""" + +import time +import httpx +from typing import Optional + +from gis_service.models import Coordinates + + +# =========================================================================== +# CONSTANTS +# =========================================================================== + +NOMINATIM_BASE_URL = "https://nominatim.openstreetmap.org/search" +NOMINATIM_USER_AGENT = "GovRide-AI/1.0 (government fleet management system, Lesotho)" +REQUEST_TIMEOUT = 10 # seconds +RATE_LIMIT_DELAY = 1.1 # seconds between requests (Nominatim policy: 1 req/sec) + +# Lesotho bounding box — biases results to Lesotho +# Southwest: -30.6°S, 27.0°E | Northeast: -28.6°S, 29.5°E +LESOTHO_VIEWBOX = "27.0,-30.6,29.5,-28.6" + + +# =========================================================================== +# KNOWN LESOTHO LOCATIONS CACHE +# Fallback when Nominatim is unavailable or returns no result. +# Coordinates sourced from OpenStreetMap. +# =========================================================================== + +LESOTHO_LOCATIONS: dict[str, Coordinates] = { + # Capital + "maseru": Coordinates(latitude=-29.3167, longitude=27.4833, place_name="Maseru"), + + # District headquarters + "thaba-tseka": Coordinates(latitude=-29.5229, longitude=28.6054, place_name="Thaba-Tseka"), + "thaba tseka": Coordinates(latitude=-29.5229, longitude=28.6054, place_name="Thaba-Tseka"), + "mokhotlong": Coordinates(latitude=-29.2833, longitude=29.0667, place_name="Mokhotlong"), + "qacha's nek": Coordinates(latitude=-30.1167, longitude=28.6833, place_name="Qacha's Nek"), + "qachas nek": Coordinates(latitude=-30.1167, longitude=28.6833, place_name="Qacha's Nek"), + "butha-buthe": Coordinates(latitude=-28.7667, longitude=28.2500, place_name="Butha-Buthe"), + "butha buthe": Coordinates(latitude=-28.7667, longitude=28.2500, place_name="Butha-Buthe"), + "leribe": Coordinates(latitude=-28.8667, longitude=28.0500, place_name="Leribe"), + "mafeteng": Coordinates(latitude=-29.8167, longitude=27.2333, place_name="Mafeteng"), + "mohale's hoek": Coordinates(latitude=-30.1500, longitude=27.4667, place_name="Mohale's Hoek"), + "mohales hoek": Coordinates(latitude=-30.1500, longitude=27.4667, place_name="Mohale's Hoek"), + "quthing": Coordinates(latitude=-30.4000, longitude=27.7000, place_name="Quthing"), + "berea": Coordinates(latitude=-29.1333, longitude=27.8167, place_name="Berea"), + + # Notable places + "roma": Coordinates(latitude=-29.4500, longitude=27.7833, place_name="Roma"), + "semonkong": Coordinates(latitude=-29.8500, longitude=28.0500, place_name="Semonkong"), + "katse": Coordinates(latitude=-29.3333, longitude=28.4833, place_name="Katse"), + "katse dam": Coordinates(latitude=-29.3333, longitude=28.4833, place_name="Katse Dam"), + "teyateyaneng": Coordinates(latitude=-29.1500, longitude=27.7500, place_name="Teyateyaneng"), + "maputsoe": Coordinates(latitude=-28.8667, longitude=27.9000, place_name="Maputsoe"), + "hlotse": Coordinates(latitude=-28.8667, longitude=28.0500, place_name="Hlotse"), + "mapoteng": Coordinates(latitude=-29.0333, longitude=27.9167, place_name="Mapoteng"), + "morija": Coordinates(latitude=-29.5333, longitude=27.5167, place_name="Morija"), + "mazenod": Coordinates(latitude=-29.4000, longitude=27.5667, place_name="Mazenod"), + "seshote": Coordinates(latitude=-29.2167, longitude=28.1000, place_name="Seshote"), + + # Hospitals / health facilities + "maseru central hospital": Coordinates(latitude=-29.3100, longitude=27.4800, place_name="Maseru Central Hospital"), + "thaba-tseka district hospital": Coordinates(latitude=-29.5229, longitude=28.6054, place_name="Thaba-Tseka District Hospital"), + "mokhotlong hospital": Coordinates(latitude=-29.2833, longitude=29.0667, place_name="Mokhotlong Hospital"), + "leribe district hospital": Coordinates(latitude=-28.8667, longitude=28.0500, place_name="Leribe District Hospital"), + "filter clinic": Coordinates(latitude=-29.3167, longitude=27.4833, place_name="Filter Clinic, Maseru"), +} + + +# =========================================================================== +# VALIDATION +# =========================================================================== + +def _validate_destination(destination: str) -> list[str]: + """ + Validates the destination string before geocoding. + Returns a list of error messages. Empty list means valid. + """ + errors = [] + + if not destination or not destination.strip(): + errors.append("Destination cannot be empty.") + return errors + + if len(destination.strip()) < 3: + errors.append("Destination is too short. Please provide a place name or address.") + + if len(destination.strip()) > 200: + errors.append("Destination is too long. Maximum 200 characters.") + + return errors + + +# =========================================================================== +# NOMINATIM GEOCODER +# =========================================================================== + +def _query_nominatim(destination: str) -> Optional[Coordinates]: + """ + Queries the Nominatim OpenStreetMap geocoder to convert a + destination string into GPS coordinates. + + Design reference: + "Queries Google Maps to convert destination → coordinates" + We use Nominatim instead — free, no API key, excellent Lesotho coverage. + + Nominatim Usage Policy: + - Maximum 1 request per second + - Must include User-Agent header + - Bias results to Lesotho using viewbox parameter + + Returns None if geocoding fails or returns no results. + """ + params = { + "q": f"{destination}, Lesotho", + "format": "json", + "limit": 1, + "viewbox": LESOTHO_VIEWBOX, + "bounded": 0, # Don't strictly bound — allow results outside box + "addressdetails": 1, + } + + headers = { + "User-Agent": NOMINATIM_USER_AGENT, + "Accept-Language": "en", + } + + try: + # Respect Nominatim rate limit + time.sleep(RATE_LIMIT_DELAY) + + response = httpx.get( + NOMINATIM_BASE_URL, + params=params, + headers=headers, + timeout=REQUEST_TIMEOUT, + ) + response.raise_for_status() + results = response.json() + + if not results: + return None + + result = results[0] + return Coordinates( + latitude=float(result["lat"]), + longitude=float(result["lon"]), + place_name=result.get("name") or result.get("display_name", "").split(",")[0], + display_name=result.get("display_name"), + ) + + except (httpx.HTTPError, httpx.TimeoutException, KeyError, ValueError): + return None + + +def _lookup_known_location(destination: str) -> Optional[Coordinates]: + """ + Looks up the destination in the hardcoded Lesotho locations cache. + Case-insensitive partial match — "Thaba-Tseka District Hospital" + will match "thaba-tseka". + + Used as fallback when Nominatim is unavailable or returns no result. + """ + destination_lower = destination.strip().lower() + + # Try exact match first + if destination_lower in LESOTHO_LOCATIONS: + return LESOTHO_LOCATIONS[destination_lower] + + # Try partial match — check if any known location is in the destination + for key, coords in LESOTHO_LOCATIONS.items(): + if key in destination_lower or destination_lower in key: + return coords + + return None + + +# =========================================================================== +# MAIN FUNCTION +# =========================================================================== + +def resolve_coordinates(destination: str) -> dict: + """ + Validates the destination and resolves it to GPS coordinates. + + Steps: + 1. Validate input + 2. Check known Lesotho locations cache (fast path) + 3. Query Nominatim API + 4. Fall back to cache if API fails + + Design reference: + "Validates destination format" + "Queries [geocoder] to convert destination → coordinates" + "Sends coordinates to Terrain Analysis Engine" + + Returns a dict with: + - success (bool) + - coordinates (Coordinates | None) + - source: "cache" | "nominatim" | None + - errors (list[str]) + """ + # Step 1 — Validate + errors = _validate_destination(destination) + if errors: + return { + "success": False, + "coordinates": None, + "source": None, + "errors": errors, + } + + # Step 2 — Check known locations cache first (fast, no network needed) + cached = _lookup_known_location(destination) + if cached: + return { + "success": True, + "coordinates": cached, + "source": "cache", + "errors": [], + } + + # Step 3 — Query Nominatim + coords = _query_nominatim(destination) + if coords: + return { + "success": True, + "coordinates": coords, + "source": "nominatim", + "errors": [], + } + + # Step 4 — Both failed + return { + "success": False, + "coordinates": None, + "source": None, + "errors": [ + f"Could not resolve coordinates for '{destination}'. " + "Please check the destination name and try again." + ], + } \ No newline at end of file diff --git a/backend/services/gis_service/engines/terrain_analysis_engine.py b/backend/services/gis_service/engines/terrain_analysis_engine.py new file mode 100644 index 0000000..a679db7 --- /dev/null +++ b/backend/services/gis_service/engines/terrain_analysis_engine.py @@ -0,0 +1,380 @@ +""" +gis_service/engines/terrain_analysis_engine.py +----------------------------------------------- +Responsible for: + 1. Querying Open-Elevation API for elevation data at destination coordinates + 2. Querying Overpass API (OpenStreetMap) for road type near destination + 3. Computing elevation gain relative to Maseru (capital, baseline = 1600m) + 4. Classifying terrain type (flat/hilly/mountainous/extreme) + 5. Computing the unified Terrain Difficulty Score (0.0 - 1.0) + +Design reference: + "Terrain Analysis Engine: + - Uses coordinates to query Google Maps terrain/elevation endpoints + - Extracts surface/road type information + - Computes elevation change / slope difficulty + - Produces the unified Terrain Difficulty Score + - Generates minimal terrain metadata" + + We use: + Open-Elevation API -- free, no key, uses SRTM satellite elevation data (NASA) + Overpass API -- free, no key, queries OpenStreetMap road data + +Terrain Difficulty Score Formula: + score = (elevation_factor * 0.5) + (road_factor * 0.5) + + elevation_factor: + Based on elevation gain from Maseru baseline (1600m). + Max considered gain: 1500m + elevation_factor = min(1.0, elevation_gain / 1500) + + road_factor: + paved -> 0.1 + gravel -> 0.4 + dirt -> 0.7 + offroad -> 1.0 + unknown -> 0.5 + + Consistent with fleet management TERRAIN_THRESHOLDS: + 0.00-0.30 -> sedan_ok + 0.30-0.55 -> suv_preferred + 0.55-0.80 -> 4x4_required + 0.80-1.00 -> specialist_required +""" + +import time +import httpx +from typing import Optional + +from gis_service.models import ( + Coordinates, + ElevationData, + RoadData, + RoadType, + TerrainType, + TerrainMetadata, +) + + +# =========================================================================== +# CONSTANTS +# =========================================================================== + +OPEN_ELEVATION_URL = "https://api.open-elevation.com/api/v1/lookup" +OVERPASS_API_URL = "https://overpass-api.de/api/interpreter" +REQUEST_TIMEOUT = 15 +RATE_LIMIT_DELAY = 1.0 + +MASERU_ELEVATION_M = 1600.0 +MAX_ELEVATION_GAIN_M = 1500.0 + +HIGHWAY_TO_ROAD_TYPE: dict[str, RoadType] = { + "motorway": RoadType.PAVED, + "trunk": RoadType.PAVED, + "primary": RoadType.PAVED, + "secondary": RoadType.PAVED, + "tertiary": RoadType.GRAVEL, + "unclassified": RoadType.GRAVEL, + "residential": RoadType.GRAVEL, + "service": RoadType.GRAVEL, + "track": RoadType.DIRT, + "path": RoadType.DIRT, + "footway": RoadType.DIRT, + "bridleway": RoadType.DIRT, + "steps": RoadType.DIRT, +} + +SURFACE_TO_ROAD_TYPE: dict[str, RoadType] = { + "asphalt": RoadType.PAVED, + "paved": RoadType.PAVED, + "concrete": RoadType.PAVED, + "cobblestone": RoadType.PAVED, + "gravel": RoadType.GRAVEL, + "dirt": RoadType.DIRT, + "earth": RoadType.DIRT, + "mud": RoadType.DIRT, + "sand": RoadType.DIRT, + "unpaved": RoadType.DIRT, + "grass": RoadType.DIRT, + "ground": RoadType.DIRT, +} + +ROAD_DIFFICULTY: dict[RoadType, float] = { + RoadType.PAVED: 0.1, + RoadType.GRAVEL: 0.4, + RoadType.DIRT: 0.7, + RoadType.OFFROAD: 1.0, + RoadType.UNKNOWN: 0.5, +} + +ROAD_DESCRIPTIONS: dict[RoadType, str] = { + RoadType.PAVED: "Paved road -- suitable for all vehicles", + RoadType.GRAVEL: "Gravel/unpaved road -- light off-road capability recommended", + RoadType.DIRT: "Dirt track -- serious off-road capability required", + RoadType.OFFROAD: "No formal road -- specialist vehicle required", + RoadType.UNKNOWN: "Road type could not be determined", +} + + +# =========================================================================== +# MOCK DATA +# =========================================================================== + +MOCK_ELEVATIONS: dict[str, float] = { + "maseru": 1600.0, + "thaba-tseka": 2200.0, + "mokhotlong": 2350.0, + "qacha's nek": 1900.0, + "butha-buthe": 1750.0, + "leribe": 1650.0, + "mafeteng": 1600.0, + "mohale's hoek": 1550.0, + "quthing": 1550.0, + "semonkong": 2300.0, + "katse": 2050.0, + "roma": 1800.0, +} + +MOCK_ROAD_TYPES: dict[str, RoadType] = { + "maseru": RoadType.PAVED, + "thaba-tseka": RoadType.GRAVEL, + "mokhotlong": RoadType.DIRT, + "qacha's nek": RoadType.DIRT, + "butha-buthe": RoadType.GRAVEL, + "leribe": RoadType.PAVED, + "mafeteng": RoadType.PAVED, + "mohale's hoek": RoadType.GRAVEL, + "quthing": RoadType.GRAVEL, + "semonkong": RoadType.DIRT, + "katse": RoadType.GRAVEL, + "roma": RoadType.PAVED, +} + + +def _get_mock_elevation(place_name: str) -> float: + place_lower = place_name.lower() + for key, elevation in MOCK_ELEVATIONS.items(): + if key in place_lower or place_lower in key: + return elevation + return MASERU_ELEVATION_M + + +def _get_mock_road_type(place_name: str) -> RoadType: + place_lower = place_name.lower() + for key, road_type in MOCK_ROAD_TYPES.items(): + if key in place_lower or place_lower in key: + return road_type + return RoadType.GRAVEL + + +# =========================================================================== +# ELEVATION QUERY +# =========================================================================== + +def _query_open_elevation(latitude: float, longitude: float) -> Optional[float]: + """ + Queries the Open-Elevation API for elevation at given coordinates. + Uses SRTM (NASA) satellite elevation data with ~90m resolution. + Returns elevation in metres, or None if query fails. + """ + payload = { + "locations": [{"latitude": latitude, "longitude": longitude}] + } + try: + time.sleep(RATE_LIMIT_DELAY) + response = httpx.post( + OPEN_ELEVATION_URL, + json=payload, + timeout=REQUEST_TIMEOUT, + ) + response.raise_for_status() + data = response.json() + return float(data["results"][0]["elevation"]) + except (httpx.HTTPError, httpx.TimeoutException, KeyError, ValueError): + return None + + +# =========================================================================== +# ROAD TYPE QUERY +# =========================================================================== + +def _query_overpass_road_type( + latitude: float, + longitude: float, +) -> tuple[RoadType, Optional[str], Optional[str]]: + """ + Queries the Overpass API (OpenStreetMap) for the nearest road + within 500m of the given coordinates. + Returns (RoadType, highway_tag, surface_tag). + """ + query = f""" + [out:json][timeout:10]; + way(around:500,{latitude},{longitude})[highway]; + out tags 1; + """ + try: + time.sleep(RATE_LIMIT_DELAY) + response = httpx.post( + OVERPASS_API_URL, + data={"data": query}, + timeout=REQUEST_TIMEOUT, + ) + response.raise_for_status() + data = response.json() + + if not data.get("elements"): + return RoadType.OFFROAD, None, None + + tags = data["elements"][0].get("tags", {}) + highway_tag = tags.get("highway") + surface_tag = tags.get("surface") + + if surface_tag and surface_tag in SURFACE_TO_ROAD_TYPE: + road_type = SURFACE_TO_ROAD_TYPE[surface_tag] + elif highway_tag and highway_tag in HIGHWAY_TO_ROAD_TYPE: + road_type = HIGHWAY_TO_ROAD_TYPE[highway_tag] + else: + road_type = RoadType.UNKNOWN + + return road_type, highway_tag, surface_tag + + except (httpx.HTTPError, httpx.TimeoutException, KeyError, ValueError): + return RoadType.UNKNOWN, None, None + + +# =========================================================================== +# TERRAIN CLASSIFICATION +# =========================================================================== + +def _classify_terrain( + elevation_m: float, + elevation_gain_m: float, + road_type: RoadType, +) -> TerrainType: + """ + Classifies terrain type based on elevation gain and road type. + + Design reference: Lesotho's geography context. + Urban : low elevation gain + paved road + Flat : 0-100m gain + Hilly : 100-300m gain + Mountainous : 300-600m gain + Extreme : 600m+ gain + """ + if elevation_gain_m < 50 and road_type == RoadType.PAVED: + return TerrainType.URBAN + if elevation_gain_m < 100: + return TerrainType.FLAT + if elevation_gain_m < 300: + return TerrainType.HILLY + if elevation_gain_m < 600: + return TerrainType.MOUNTAINOUS + return TerrainType.EXTREME + + +# =========================================================================== +# DIFFICULTY SCORE +# =========================================================================== + +def _compute_difficulty_score( + elevation_gain_m: float, + road_type: RoadType, +) -> float: + """ + Computes the unified Terrain Difficulty Score (0.0 - 1.0). + + Formula: + score = (elevation_factor * 0.5) + (road_factor * 0.5) + + elevation_factor = min(1.0, elevation_gain / MAX_ELEVATION_GAIN_M) + road_factor = from ROAD_DIFFICULTY table + """ + elevation_factor = min(1.0, elevation_gain_m / MAX_ELEVATION_GAIN_M) + road_factor = ROAD_DIFFICULTY.get(road_type, 0.5) + score = (elevation_factor * 0.5) + (road_factor * 0.5) + return round(min(1.0, max(0.0, score)), 4) + + +# =========================================================================== +# MAIN FUNCTION +# =========================================================================== + +def analyse_terrain( + coordinates: Coordinates, + use_mock: bool = False, +) -> dict: + """ + Performs full terrain analysis for a set of coordinates. + + Steps: + 1. Get elevation at destination (Open-Elevation API or mock) + 2. Compute elevation gain from Maseru baseline + 3. Get road type near destination (Overpass API or mock) + 4. Classify terrain type + 5. Compute Terrain Difficulty Score + + Args: + coordinates: resolved from route_request_processor + use_mock: skip API calls and use mock data (for development/testing) + + Returns a dict with: + - success (bool) + - metadata (TerrainMetadata | None) + - is_mock (bool) + - errors (list[str]) + """ + place_name = coordinates.place_name or coordinates.display_name or "Unknown" + is_mock = use_mock + + # Step 1 -- Get elevation + if use_mock: + elevation_m = _get_mock_elevation(place_name) + road_type = _get_mock_road_type(place_name) + highway_tag = None + surface_tag = None + else: + elevation_m = _query_open_elevation(coordinates.latitude, coordinates.longitude) + if elevation_m is None: + elevation_m = _get_mock_elevation(place_name) + is_mock = True + + road_type, highway_tag, surface_tag = _query_overpass_road_type( + coordinates.latitude, coordinates.longitude + ) + if road_type in [RoadType.UNKNOWN, RoadType.OFFROAD]: + mock_road = _get_mock_road_type(place_name) + if mock_road != RoadType.GRAVEL: # Only override if we have specific data + road_type = mock_road + is_mock = True + + # Step 2 -- Elevation gain from Maseru + elevation_gain_m = max(0.0, elevation_m - MASERU_ELEVATION_M) + + # Step 3 -- Classify terrain + terrain_type = _classify_terrain(elevation_m, elevation_gain_m, road_type) + + # Step 4 -- Difficulty score + difficulty_score = _compute_difficulty_score(elevation_gain_m, road_type) + + metadata = TerrainMetadata( + coordinates=coordinates, + elevation=ElevationData( + elevation_meters=elevation_m, + elevation_gain_meters=elevation_gain_m, + terrain_type=terrain_type, + ), + road=RoadData( + road_type=road_type, + highway_tag=highway_tag, + surface_tag=surface_tag, + road_description=ROAD_DESCRIPTIONS.get(road_type, ""), + ), + terrain_difficulty_score=difficulty_score, + ) + + return { + "success": True, + "metadata": metadata, + "is_mock": is_mock, + "errors": [], + } \ No newline at end of file diff --git a/backend/services/gis_service/engines/vehicle_suitability_mapping_module.py b/backend/services/gis_service/engines/vehicle_suitability_mapping_module.py new file mode 100644 index 0000000..0281fff --- /dev/null +++ b/backend/services/gis_service/engines/vehicle_suitability_mapping_module.py @@ -0,0 +1,158 @@ +""" +gis_service/engines/vehicle_suitability_mapping_module.py +---------------------------------------------------------- +Responsible for: + 1. Converting the Terrain Difficulty Score into a structured vehicle hint + 2. Producing the final TerrainResponse to return to Fleet Management + +Design reference: + "Vehicle Suitability Mapping Module: + Converts Difficulty Score into a structured hint for the Allocation Engine" + + This module is the bridge between the GIS service and the Fleet Management + Allocation Engine. It takes the raw terrain metadata and produces the + structured output that Fleet Management needs to make allocation decisions. + + The vehicle hint thresholds are identical to those defined in the fleet + management vehicle_suitability_module.py to ensure consistency: + 0.00 - 0.30: sedan_ok (flat/urban) + 0.30 - 0.55: suv_preferred (light off-road) + 0.55 - 0.80: 4x4_required (serious terrain) + 0.80 - 1.00: specialist_required (extreme terrain) + + These thresholds are based on: + Bekker (Theory of Land Locomotion, 1956) + UN Vehicle Management Manual (2021) + Lesotho geographic context + +Integration with Fleet Management: + The TerrainResponse returned by this module is consumed by: + 1. fleet_management trip_request_processor._stub_gis_terrain_score() + (currently stubbed — replaced by real GIS call when integrated) + 2. vehicle_suitability_module.apply_suitability_to_trip() + (writes vehicle_hint onto TripRequest) + 3. heuristic_allocation_engine._select_best_vehicle() + (uses vehicle_hint to filter vehicles) +""" + +from gis_service.models import ( + TerrainMetadata, + TerrainResponse, + VehicleHint, +) + + +# =========================================================================== +# VEHICLE HINT THRESHOLDS +# Must be consistent with fleet_management/vehicle_suitability_module.py +# =========================================================================== + +TERRAIN_TO_HINT: list[tuple[float, float, VehicleHint]] = [ + (0.00, 0.30, VehicleHint.SEDAN_OK), + (0.30, 0.55, VehicleHint.SUV_PREFERRED), + (0.55, 0.80, VehicleHint.FOUR_BY_FOUR_REQUIRED), + (0.80, 1.01, VehicleHint.SPECIALIST_REQUIRED), +] + + +# =========================================================================== +# HINT DESCRIPTIONS +# Human-readable explanation of each vehicle hint. +# Included in the response for transparency. +# =========================================================================== + +HINT_DESCRIPTIONS: dict[VehicleHint, str] = { + VehicleHint.SEDAN_OK: ( + "Flat or urban terrain. Standard sedan suitable. " + "Paved or good gravel roads." + ), + VehicleHint.SUV_PREFERRED: ( + "Light off-road terrain. SUV recommended. " + "Gravel roads or mild inclines." + ), + VehicleHint.FOUR_BY_FOUR_REQUIRED: ( + "Serious off-road terrain. 4x4 required. " + "Steep grades, rough tracks or mountainous roads." + ), + VehicleHint.SPECIALIST_REQUIRED: ( + "Extreme terrain. Specialist vehicle required. " + "No formal road, deep mud, or high altitude." + ), +} + + +# =========================================================================== +# CORE FUNCTION +# =========================================================================== + +def map_to_vehicle_hint(terrain_difficulty_score: float) -> VehicleHint: + """ + Converts a numeric Terrain Difficulty Score to a vehicle hint. + + Design reference: + "Converts Difficulty Score into a structured hint for the Allocation Engine" + + Thresholds match fleet_management/vehicle_suitability_module.py exactly + to ensure the two services speak the same language. + """ + for lower, upper, hint in TERRAIN_TO_HINT: + if lower <= terrain_difficulty_score < upper: + return hint + return VehicleHint.SPECIALIST_REQUIRED + + +def build_terrain_response( + trip_id: str, + destination: str, + metadata: TerrainMetadata, + is_mock: bool = False, +) -> TerrainResponse: + """ + Builds the final TerrainResponse to return to Fleet Management. + + This is the structured output that Fleet Management consumes: + - Destination coordinates + - Road type / roughness indicators + - Elevation gain + - Terrain Difficulty Score + - Vehicle hint for the Allocation Engine + + Design reference: + "GIS returns to Fleet Management: + - Destination coordinates + - Road type / roughness indicators + - Elevation gain + - Terrain Difficulty Score" + """ + vehicle_hint = map_to_vehicle_hint(metadata.terrain_difficulty_score) + + return TerrainResponse( + trip_id=trip_id, + destination=destination, + + # Coordinates + latitude=metadata.coordinates.latitude, + longitude=metadata.coordinates.longitude, + place_name=metadata.coordinates.place_name, + + # Elevation + elevation_meters=metadata.elevation.elevation_meters, + elevation_gain_meters=metadata.elevation.elevation_gain_meters, + terrain_type=metadata.elevation.terrain_type, + + # Road + road_type=metadata.road.road_type, + road_description=metadata.road.road_description, + + # Scoring + terrain_difficulty_score=metadata.terrain_difficulty_score, + vehicle_hint=vehicle_hint, + + # Metadata + is_mock=is_mock, + ) + + +def get_hint_description(vehicle_hint: VehicleHint) -> str: + """Returns the human-readable description of a vehicle hint.""" + return HINT_DESCRIPTIONS.get(vehicle_hint, "Unknown terrain category.") \ No newline at end of file diff --git a/backend/services/gis_service/main.py b/backend/services/gis_service/main.py new file mode 100644 index 0000000..40debba --- /dev/null +++ b/backend/services/gis_service/main.py @@ -0,0 +1,16 @@ +""" +gis_service/main.py +------------------- +Mounts the GIS service router onto the main backend FastAPI app. + +Design reference: + "Mounted as a router inside the main backend app" + +Usage in backend/main.py: + from gis_service.main import gis_router + app.include_router(gis_router) +""" + +from gis_service.api.gis_api import router as gis_router + +__all__ = ["gis_router"] \ No newline at end of file diff --git a/backend/services/gis_service/models.py b/backend/services/gis_service/models.py new file mode 100644 index 0000000..4f04c20 --- /dev/null +++ b/backend/services/gis_service/models.py @@ -0,0 +1,212 @@ +""" +gis_service/models.py +--------------------- +All Pydantic models for the GIS/Terrain Service. + +Design reference: + "The GIS/Terrain Service provides lightweight terrain and route information + required during trip allocation." + + Inputs: destination (text/place name/district) + Outputs: coordinates, road type, elevation gain, Terrain Difficulty Score, + vehicle hint + +Groups: + - Request models (inbound from Fleet Management) + - Internal models (passed between engines) + - Response models (returned to Fleet Management) +""" + +from pydantic import BaseModel, Field +from typing import Optional +from enum import Enum + + +# =========================================================================== +# ENUMS +# =========================================================================== + +class RoadType(str, Enum): + """ + Broad classification of road surface at or near the destination. + Derived from OpenStreetMap highway tags. + """ + PAVED = "paved" # Tarmac/asphalt — motorway, trunk, primary, secondary + GRAVEL = "gravel" # Compacted gravel — tertiary, unclassified + DIRT = "dirt" # Unimproved earth — track, path + OFFROAD = "offroad" # No formal road — cross-country + UNKNOWN = "unknown" # Could not be determined + + +class TerrainType(str, Enum): + """General landscape classification around the destination.""" + FLAT = "flat" # 0-100m elevation change + HILLY = "hilly" # 100-300m elevation change + MOUNTAINOUS = "mountainous" # 300-600m elevation change + EXTREME = "extreme" # 600m+ elevation change + URBAN = "urban" # Built-up area, flat or slight grade + UNKNOWN = "unknown" + + +class VehicleHint(str, Enum): + """ + Vehicle suitability hint produced by the Vehicle Suitability + Mapping Module and consumed by Fleet Management's Allocation Engine. + """ + SEDAN_OK = "sedan_ok" + SUV_PREFERRED = "suv_preferred" + FOUR_BY_FOUR_REQUIRED = "4x4_required" + SPECIALIST_REQUIRED = "specialist_required" + + +# =========================================================================== +# REQUEST MODELS +# =========================================================================== + +class TerrainRequest(BaseModel): + """ + Sent by the Fleet Management Service to request terrain analysis + for a trip destination. + + Design reference: + "Fleet Management Service sends a destination location" + "During active trips, GIS interprets GPS coordinates for live tracking" + """ + trip_id: str = Field(..., description="Unique ID of the trip being evaluated") + destination: str = Field( + ..., + description=( + "Human-readable destination: place name, district, or address. " + "Examples: 'Thaba-Tseka District Hospital', 'Mokhotlong', " + "'Maseru Central Hospital'" + ) + ) + + +class LiveTrackingRequest(BaseModel): + """ + Sent during an active trip to interpret current GPS coordinates. + + Design reference: + "During active trips, GIS interprets GPS coordinates for basic live tracking" + """ + trip_id: str + latitude: float = Field(..., ge=-90, le=90) + longitude: float = Field(..., ge=-180, le=180) + + +# =========================================================================== +# INTERNAL MODELS (passed between engines) +# =========================================================================== + +class Coordinates(BaseModel): + """GPS coordinates resolved from a destination string.""" + latitude: float + longitude: float + place_name: Optional[str] = None # Resolved place name from geocoder + display_name: Optional[str] = None # Full formatted address + + +class ElevationData(BaseModel): + """Elevation information for a set of coordinates.""" + elevation_meters: float # Elevation at destination + elevation_gain_meters: float # Estimated elevation gain from Maseru (capital) + terrain_type: TerrainType + + +class RoadData(BaseModel): + """Road surface and type information near the destination.""" + road_type: RoadType + highway_tag: Optional[str] = None # Raw OSM highway tag (e.g. "track", "primary") + surface_tag: Optional[str] = None # Raw OSM surface tag (e.g. "unpaved", "asphalt") + road_description: str = "" + + +class TerrainMetadata(BaseModel): + """ + Full terrain characterisation computed by the Terrain Analysis Engine. + Passed to the Vehicle Suitability Mapping Module. + + Design reference: + "Computes a Terrain Difficulty Score" + All terrain data is unified here before scoring. + """ + coordinates: Coordinates + elevation: ElevationData + road: RoadData + terrain_difficulty_score: float = Field( + ..., + ge=0.0, + le=1.0, + description="Numeric difficulty score 0.0 (flat urban) to 1.0 (extreme terrain)" + ) + + +# =========================================================================== +# RESPONSE MODELS (returned to Fleet Management) +# =========================================================================== + +class TerrainResponse(BaseModel): + """ + Full terrain analysis response returned to Fleet Management. + + Design reference: + "GIS returns to Fleet Management: + - Destination coordinates + - Road type / roughness indicators + - Elevation gain + - Terrain Difficulty Score" + """ + trip_id: str + destination: str + + # Coordinates + latitude: float + longitude: float + place_name: Optional[str] = None + + # Terrain characteristics + elevation_meters: float + elevation_gain_meters: float + terrain_type: TerrainType + road_type: RoadType + road_description: str + + # Scoring output + terrain_difficulty_score: float = Field(..., ge=0.0, le=1.0) + vehicle_hint: VehicleHint + + # Metadata + data_source: str = "OpenStreetMap + Open-Elevation" + is_mock: bool = False # True when using mock data + + +class LiveTrackingResponse(BaseModel): + """ + Response for live GPS coordinate interpretation during an active trip. + + Design reference: + "During active trips, GIS interprets GPS coordinates for basic live tracking" + """ + trip_id: str + latitude: float + longitude: float + elevation_meters: Optional[float] = None + road_type: Optional[RoadType] = None + nearest_place: Optional[str] = None + terrain_type: Optional[TerrainType] = None + + +# =========================================================================== +# API REQUEST/RESPONSE SCHEMAS +# =========================================================================== + +class BatchTerrainRequest(BaseModel): + """Request terrain analysis for multiple trips at once.""" + requests: list[TerrainRequest] + + +class BatchTerrainResponse(BaseModel): + """Batch terrain analysis results.""" + results: list[TerrainResponse] + failed: list[dict] = [] # {trip_id, destination, error} \ No newline at end of file diff --git a/backend/services/notification_service/api/notification_api.py b/backend/services/notification_service/api/notification_api.py new file mode 100644 index 0000000..fcc5e95 --- /dev/null +++ b/backend/services/notification_service/api/notification_api.py @@ -0,0 +1,257 @@ +""" +notification_service/api/notification_api.py +--------------------------------------------- +FastAPI router for the Notification Service. + +Design reference: + "The Notification Service acts as a simple delivery layer that sends + outgoing notifications on behalf of other microservices." + + Full flow per design diagram: + POST /notify + -> Notification Intake Handler (validate) + -> Is Valid? No -> return 400 Invalid Notification Response + -> Is Valid? Yes -> Delivery Engine (which channel?) + -> SMS / Email / In-App + -> Notification Log Manager (save record) + -> Return success/failure to calling microservice + +Endpoints: + POST /notifications/notify -- send a notification (called by other services) + GET /notifications/logs -- all logs (admin/troubleshoot) + GET /notifications/logs/{recipient_id} -- logs for a specific recipient + GET /notifications/logs/trip/{trip_id} -- logs for a specific trip + GET /notifications/inbox/{user_id} -- in-app inbox for a user + PATCH /notifications/inbox/{user_id}/{notification_id}/read -- mark as read + GET /notifications/summary -- delivery summary stats + GET /notifications/health -- health check +""" + +from fastapi import APIRouter, HTTPException, Query, status + +from notification_service.models import ( + NotificationRequest, + NotificationResponse, + DeliveryStatus, +) +from notification_service.engines.intake_handler import handle_intake +from notification_service.engines.delivery_engine import ( + deliver, + get_in_app_notifications, + mark_in_app_read, +) +from notification_service.engines.notification_log_manager import ( + save_log, + get_logs_by_recipient, + get_logs_by_trip, + get_logs_by_status, + get_all_logs, + get_log_summary, +) + + +router = APIRouter(prefix="/notifications", tags=["Notification Service"]) + + +# =========================================================================== +# MAIN DELIVERY ENDPOINT +# =========================================================================== + +@router.post( + "/notify", + response_model=NotificationResponse, + status_code=status.HTTP_200_OK, + summary="Send a notification", + description=( + "Called by other microservices to send a notification. " + "Validates the payload, routes to the correct channel " + "(SMS, Email, In-App), logs the result, and returns " + "a success or failure response." + ), +) +def send_notification(request: NotificationRequest): + """ + Full notification pipeline per design diagram: + 1. Intake Handler validates the request + 2. Delivery Engine routes to correct channel + 3. Log Manager saves the outcome + 4. Response returned to calling microservice + """ + # Step 1 — Intake Handler: validate + intake_result = handle_intake(request) + + if not intake_result["valid"]: + # Design reference: "Return Invalid Notification Response" (red circle in diagram) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "error": "Invalid notification request.", + "errors": intake_result["errors"], + }, + ) + + # Step 2 — Delivery Engine: route and send + delivery_result = deliver(request) + + # Step 3 — Log Manager: save the outcome + log = save_log( + request = request, + notification_id = delivery_result["notification_id"], + status = delivery_result["status"], + error_detail = delivery_result.get("error_detail"), + ) + + # Step 4 — Return response to calling microservice + # Design reference: "Output to Original Microservice" (diagram) + return NotificationResponse( + success = delivery_result["success"], + notification_id = delivery_result["notification_id"], + recipient_id = request.recipient_id, + channel = request.channel, + status = delivery_result["status"], + message = ( + f"Notification {delivery_result['status'].value} via {request.channel.value}." + if delivery_result["success"] + else f"Notification failed: {delivery_result.get('error_detail', 'Unknown error')}" + ), + errors = [delivery_result["error_detail"]] if delivery_result.get("error_detail") else [], + ) + + +# =========================================================================== +# IN-APP INBOX ENDPOINTS +# =========================================================================== + +@router.get( + "/inbox/{user_id}", + status_code=status.HTTP_200_OK, + summary="Get in-app notification inbox for a user", + description=( + "Returns all in-app notifications for a user, most recent first. " + "The React frontend polls this endpoint every 10 seconds. " + "Unread notifications are flagged with is_read: false." + ), +) +def get_inbox(user_id: str): + notifications = get_in_app_notifications(user_id) + unread = sum(1 for n in notifications if not n.get("is_read", False)) + return { + "user_id": user_id, + "total": len(notifications), + "unread": unread, + "notifications": sorted( + notifications, + key=lambda n: n["timestamp"], + reverse=True, + ), + } + + +@router.patch( + "/inbox/{user_id}/{notification_id}/read", + status_code=status.HTTP_200_OK, + summary="Mark an in-app notification as read", +) +def mark_read(user_id: str, notification_id: str): + found = mark_in_app_read(user_id, notification_id) + if not found: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Notification '{notification_id}' not found for user '{user_id}'.", + ) + return {"success": True, "notification_id": notification_id, "is_read": True} + + +# =========================================================================== +# LOG ENDPOINTS (admin / troubleshooting) +# =========================================================================== + +@router.get( + "/logs", + status_code=status.HTTP_200_OK, + summary="Get all notification logs", + description=( + "Returns paginated notification logs, most recent first. " + "Used by administrators for full audit view. " + "Design reference: 'Used for reference by administrators and troubleshooting'" + ), +) +def list_logs( + limit: int = Query(default=100, ge=1, le=500), + offset: int = Query(default=0, ge=0), + status_filter: str = Query(default=None, description="Filter by 'delivered' or 'failed'"), +): + if status_filter: + try: + filter_status = DeliveryStatus(status_filter) + logs = get_logs_by_status(filter_status) + except ValueError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid status filter '{status_filter}'. Use 'delivered' or 'failed'.", + ) + else: + logs = get_all_logs(limit=limit, offset=offset) + + return { + "total": len(logs), + "logs": [log.model_dump() for log in logs], + } + + +@router.get( + "/logs/recipient/{recipient_id}", + status_code=status.HTTP_200_OK, + summary="Get notification logs for a specific recipient", + description="Returns all notification logs for a user. Used for troubleshooting.", +) +def logs_for_recipient(recipient_id: str): + logs = get_logs_by_recipient(recipient_id) + return { + "recipient_id": recipient_id, + "total": len(logs), + "logs": [log.model_dump() for log in logs], + } + + +@router.get( + "/logs/trip/{trip_id}", + status_code=status.HTTP_200_OK, + summary="Get all notification logs for a trip", + description="Returns every notification sent during a trip's lifecycle.", +) +def logs_for_trip(trip_id: str): + logs = get_logs_by_trip(trip_id) + return { + "trip_id": trip_id, + "total": len(logs), + "logs": [log.model_dump() for log in logs], + } + + +# =========================================================================== +# SUMMARY AND HEALTH +# =========================================================================== + +@router.get( + "/summary", + status_code=status.HTTP_200_OK, + summary="Get notification delivery summary", + description="Returns total counts by status, channel, and message type.", +) +def notification_summary(): + return get_log_summary() + + +@router.get( + "/health", + status_code=status.HTTP_200_OK, + summary="Notification service health check", +) +def health(): + return { + "service": "GovRide Notification Service", + "status": "healthy", + "channels": ["sms", "email", "in_app"], + "note": "SMS and Email channels are stubbed — replace with real providers for production.", + } \ No newline at end of file diff --git a/backend/services/notification_service/engines/delivery_engine.py b/backend/services/notification_service/engines/delivery_engine.py new file mode 100644 index 0000000..de3a28d --- /dev/null +++ b/backend/services/notification_service/engines/delivery_engine.py @@ -0,0 +1,248 @@ +""" +notification_service/engines/delivery_engine.py +------------------------------------------------- +Responsible for: + 1. Routing validated notifications to the correct channel + 2. Sending via SMS gateway, Email provider, or In-App publisher + 3. Returning a simple success or failure result + +Design reference: + "Delivery Engine: + - Sends the message through the requested channel: + SMS gateway + Email provider + In-app notification publisher + - Returns a simple success or failure result" + + Diagram: "Which Channel?" decision routes to: + SMS -> Send via SMS Gateway + Email -> Send via Email Provider + In-App -> Publish to In-App Notification System + +Channel stubs: + In a production deployment these would integrate with real providers: + SMS -> Africa's Talking (covers Lesotho, free sandbox tier) + https://africastalking.com — supports +266 (Lesotho) numbers + Email -> SendGrid or SMTP relay + In-App -> WebSocket push or polling store (our in-memory store for now) + + For GovRide's development phase all three channels are stubbed — + they log what would be sent and return success. Swapping in real + providers requires only replacing the three _send_* functions below. +""" + +import logging +from datetime import datetime +from uuid import uuid4 + +from notification_service.models import ( + NotificationRequest, + NotificationResponse, + DeliveryChannel, + DeliveryStatus, +) + +logger = logging.getLogger(__name__) + + +# =========================================================================== +# IN-APP NOTIFICATION STORE +# In production: replace with a WebSocket push service or a DB table +# that the frontend polls. For now: in-memory per-user inbox. +# =========================================================================== + +# user_id -> list of in-app notifications +IN_APP_INBOX: dict[str, list[dict]] = {} + + +def get_in_app_notifications(recipient_id: str) -> list[dict]: + """Returns all in-app notifications for a user. Used by the API layer.""" + return IN_APP_INBOX.get(recipient_id, []) + + +def mark_in_app_read(recipient_id: str, notification_id: str) -> bool: + """Marks a specific in-app notification as read. Returns True if found.""" + notifications = IN_APP_INBOX.get(recipient_id, []) + for n in notifications: + if n["notification_id"] == notification_id: + n["is_read"] = True + return True + return False + + +# =========================================================================== +# CHANNEL HANDLERS +# =========================================================================== + +def _send_sms(request: NotificationRequest, notification_id: str) -> dict: + """ + Sends a notification via SMS gateway. + + Design reference: "Send via SMS Gateway" + + Production: Africa's Talking API (covers Lesotho +266 numbers) + Development: Stub — logs the message and returns success. + + Africa's Talking covers Lesotho and is the standard SMS gateway + for Southern African government and NGO applications. Free sandbox + available for development with no credit card required. + + Returns dict with success (bool) and error_detail (str | None). + """ + phone = request.recipient_contact + message = request.message + + # [STUB] In production: POST to Africa's Talking /messaging endpoint + # import africastalking + # africastalking.initialize(username, api_key) + # sms = africastalking.SMS + # response = sms.send(message, [phone]) + + logger.info( + f"[SMS] To: {phone} | " + f"Recipient: {request.recipient_id} | " + f"Type: {request.message_type.value} | " + f"ID: {notification_id} | " + f"Message: {message[:80]}{'...' if len(message) > 80 else ''}" + ) + + return {"success": True, "error_detail": None} + + +def _send_email(request: NotificationRequest, notification_id: str) -> dict: + """ + Sends a notification via Email provider. + + Design reference: "Send via Email Provider" + + Production: SendGrid or government SMTP relay + Development: Stub — logs the message and returns success. + + Returns dict with success (bool) and error_detail (str | None). + """ + email = request.recipient_contact + message = request.message + + # [STUB] In production: use SendGrid or smtplib + # import sendgrid + # sg = sendgrid.SendGridAPIClient(api_key) + # mail = Mail(from_email, email, subject, message) + # sg.send(mail) + + logger.info( + f"[Email] To: {email} | " + f"Recipient: {request.recipient_id} | " + f"Type: {request.message_type.value} | " + f"ID: {notification_id} | " + f"Message: {message[:80]}{'...' if len(message) > 80 else ''}" + ) + + return {"success": True, "error_detail": None} + + +def _send_in_app(request: NotificationRequest, notification_id: str) -> dict: + """ + Publishes a notification to the In-App Notification System. + + Design reference: "Publish to In-App Notification System" + + Production: WebSocket push or server-sent events to the React frontend. + Development: Stores in IN_APP_INBOX dict, polled by GET /notifications/{id}. + + The frontend already auto-refreshes every 10 seconds — the in-app inbox + will be visible on the next poll without any WebSocket infrastructure needed + for the current development phase. + + Returns dict with success (bool) and error_detail (str | None). + """ + recipient_id = request.recipient_id + + notification = { + "notification_id": notification_id, + "message_type": request.message_type.value, + "message": request.message, + "trip_id": request.trip_id, + "sender_service": request.sender_service, + "timestamp": datetime.now().isoformat(), + "is_read": False, + } + + if recipient_id not in IN_APP_INBOX: + IN_APP_INBOX[recipient_id] = [] + + IN_APP_INBOX[recipient_id].append(notification) + + logger.info( + f"[In-App] Recipient: {recipient_id} | " + f"Type: {request.message_type.value} | " + f"ID: {notification_id} | " + f"Inbox size: {len(IN_APP_INBOX[recipient_id])}" + ) + + return {"success": True, "error_detail": None} + + +# =========================================================================== +# CHANNEL ROUTER +# =========================================================================== + +# Maps each DeliveryChannel to its handler function +_CHANNEL_HANDLERS = { + DeliveryChannel.SMS: _send_sms, + DeliveryChannel.EMAIL: _send_email, + DeliveryChannel.IN_APP: _send_in_app, +} + + +def deliver(request: NotificationRequest) -> dict: + """ + Routes a validated notification to the correct channel handler. + + Design reference: + "Sends the message through the requested channel" + "Returns a simple success or failure result" + "Which Channel?" decision node in diagram + + Steps: + 1. Generate a unique notification ID + 2. Route to the correct channel handler + 3. Return success or failure result for the Log Manager + + Returns a dict with: + - success (bool) + - notification_id (str) + - status (DeliveryStatus) + - error_detail (str | None) + """ + notification_id = f"NTF-{uuid4().hex[:8].upper()}" + + handler = _CHANNEL_HANDLERS.get(request.channel) + + if not handler: + return { + "success": False, + "notification_id": notification_id, + "status": DeliveryStatus.FAILED, + "error_detail": f"No handler registered for channel '{request.channel}'.", + } + + try: + result = handler(request, notification_id) + return { + "success": result["success"], + "notification_id": notification_id, + "status": DeliveryStatus.DELIVERED if result["success"] else DeliveryStatus.FAILED, + "error_detail": result.get("error_detail"), + } + + except Exception as e: + logger.error( + f"[Delivery] Channel {request.channel.value} failed for " + f"recipient {request.recipient_id}: {e}" + ) + return { + "success": False, + "notification_id": notification_id, + "status": DeliveryStatus.FAILED, + "error_detail": str(e), + } \ No newline at end of file diff --git a/backend/services/notification_service/engines/intake_handler.py b/backend/services/notification_service/engines/intake_handler.py new file mode 100644 index 0000000..2665f23 --- /dev/null +++ b/backend/services/notification_service/engines/intake_handler.py @@ -0,0 +1,146 @@ +""" +notification_service/engines/intake_handler.py +------------------------------------------------ +Responsible for: + 1. Receiving notification requests from internal services + 2. Validating required fields: recipient, message content, channel + 3. Passing validated messages to the Delivery Engine + +Design reference: + "Notification Intake Handler: + - Receives notification requests from internal services + - Validates required fields: + Recipient + Message content + Delivery channel (SMS, email, in-app) + - Passes validated message to the Delivery Engine" + + Diagram flow: + Another Microservice Sends Notification Request + -> Notification Intake Handler + -> Is Notification Valid? + No -> Return Invalid Notification Response + Yes -> Delivery Engine +""" + +from notification_service.models import ( + NotificationRequest, + DeliveryChannel, +) + + +# =========================================================================== +# VALIDATION RULES +# =========================================================================== + +# SMS requires a phone number in recipient_contact +# Email requires an email address in recipient_contact +# In-App only requires recipient_id (looked up internally) +CONTACT_REQUIRED_CHANNELS = { + DeliveryChannel.SMS, + DeliveryChannel.EMAIL, +} + +MAX_MESSAGE_LENGTH = 1000 # Reasonable upper bound for any channel + + +def validate_notification(request: NotificationRequest) -> list[str]: + """ + Validates a notification request against the design-specified required fields. + + Design reference: + "Validates required fields: + - Recipient + - Message content + - Delivery channel (SMS, email, in-app)" + + Returns a list of validation error strings. + Empty list means the request is valid. + + Validation rules: + 1. recipient_id must be present and non-empty + 2. message must be present and non-empty + 3. channel must be a valid DeliveryChannel value + 4. For SMS/Email channels, recipient_contact must be provided + 5. For SMS, recipient_contact must look like a phone number + 6. For Email, recipient_contact must contain an @ symbol + 7. message must not exceed MAX_MESSAGE_LENGTH characters + """ + errors = [] + + # Rule 1 — recipient_id + if not request.recipient_id or not request.recipient_id.strip(): + errors.append("Recipient ID is required and cannot be empty.") + + # Rule 2 — message content + if not request.message or not request.message.strip(): + errors.append("Message content is required and cannot be empty.") + + # Rule 7 — message length + if request.message and len(request.message) > MAX_MESSAGE_LENGTH: + errors.append( + f"Message exceeds maximum length of {MAX_MESSAGE_LENGTH} characters " + f"({len(request.message)} given)." + ) + + # Rules 4, 5, 6 — contact required for SMS/Email + if request.channel in CONTACT_REQUIRED_CHANNELS: + if not request.recipient_contact or not request.recipient_contact.strip(): + errors.append( + f"recipient_contact (phone number or email address) is required " + f"for {request.channel.value} notifications." + ) + else: + contact = request.recipient_contact.strip() + + if request.channel == DeliveryChannel.SMS: + # Basic phone number check — must start with + and contain digits + if not contact.startswith("+") or not contact[1:].replace(" ", "").isdigit(): + errors.append( + f"recipient_contact '{contact}' does not appear to be a valid " + f"phone number. Expected format: +26650001234" + ) + + elif request.channel == DeliveryChannel.EMAIL: + # Basic email check — must contain @ and a dot after @ + if "@" not in contact or "." not in contact.split("@")[-1]: + errors.append( + f"recipient_contact '{contact}' does not appear to be a valid " + f"email address." + ) + + return errors + + +def handle_intake(request: NotificationRequest) -> dict: + """ + Main entry point for the Notification Intake Handler. + + Design reference: + "Receives notification requests from internal services" + "Is Notification Valid?" decision node in diagram + + Steps: + 1. Validate the request + 2. If invalid -> return error result (Invalid Notification Response) + 3. If valid -> return success result for Delivery Engine to process + + Returns a dict with: + - valid (bool) + - request (NotificationRequest | None) + - errors (list[str]) + """ + errors = validate_notification(request) + + if errors: + return { + "valid": False, + "request": None, + "errors": errors, + } + + return { + "valid": True, + "request": request, + "errors": [], + } \ No newline at end of file diff --git a/backend/services/notification_service/engines/notification_log_manager.py b/backend/services/notification_service/engines/notification_log_manager.py new file mode 100644 index 0000000..5e26563 --- /dev/null +++ b/backend/services/notification_service/engines/notification_log_manager.py @@ -0,0 +1,149 @@ +""" +notification_service/engines/notification_log_manager.py +---------------------------------------------------------- +Responsible for: + 1. Saving basic logs of sent notifications + 2. Storing: recipient, message type, timestamp, delivered/failed status + 3. Providing log retrieval for administrators and troubleshooting + +Design reference: + "Notification Log Manager: + - Saves basic logs of sent notifications + - Stores: + Recipient + Message type + Timestamp + Delivered or failed status + - Used for reference by administrators and troubleshooting" +""" + +from datetime import datetime +from typing import Optional +from uuid import uuid4 + +from notification_service.models import ( + NotificationLog, + NotificationRequest, + DeliveryChannel, + DeliveryStatus, + MessageType, +) + + +# =========================================================================== +# IN-MEMORY LOG STORE +# In production: replace with a database table (e.g. notifications_log). +# =========================================================================== + +NOTIFICATION_LOGS: list[NotificationLog] = [] + + +# =========================================================================== +# CORE FUNCTIONS +# =========================================================================== + +def save_log( + request: NotificationRequest, + notification_id: str, + status: DeliveryStatus, + error_detail: Optional[str] = None, +) -> NotificationLog: + """ + Saves a notification delivery record to the log store. + + Design reference: + "Saves basic logs of sent notifications" + "Stores: Recipient, Message type, Timestamp, Delivered or failed status" + + Called after every delivery attempt — both successes and failures are logged + so that administrators can audit the full notification history. + + Returns the saved NotificationLog. + """ + log = NotificationLog( + notification_id = notification_id, + recipient_id = request.recipient_id, + recipient_contact = request.recipient_contact, + message_type = request.message_type, + message = request.message, + channel = request.channel, + status = status, + timestamp = datetime.now(), + trip_id = request.trip_id, + sender_service = request.sender_service, + error_detail = error_detail, + ) + NOTIFICATION_LOGS.append(log) + return log + + +def get_logs_by_recipient(recipient_id: str) -> list[NotificationLog]: + """ + Returns all notification logs for a specific recipient. + Used by administrators to view a user's notification history. + + Design reference: "Used for reference by administrators and troubleshooting" + """ + return [log for log in NOTIFICATION_LOGS if log.recipient_id == recipient_id] + + +def get_logs_by_trip(trip_id: str) -> list[NotificationLog]: + """ + Returns all notification logs associated with a specific trip. + Useful for tracing all communications sent during a trip lifecycle. + """ + return [log for log in NOTIFICATION_LOGS if log.trip_id == trip_id] + + +def get_logs_by_status(status: DeliveryStatus) -> list[NotificationLog]: + """ + Returns all logs filtered by delivery status (delivered or failed). + Used for troubleshooting failed deliveries. + + Design reference: "Used for reference by administrators and troubleshooting" + """ + return [log for log in NOTIFICATION_LOGS if log.status == status] + + +def get_all_logs( + limit: int = 100, + offset: int = 0, +) -> list[NotificationLog]: + """ + Returns paginated notification logs, most recent first. + Used by administrators for full audit view. + """ + sorted_logs = sorted( + NOTIFICATION_LOGS, + key=lambda log: log.timestamp, + reverse=True, + ) + return sorted_logs[offset: offset + limit] + + +def get_log_summary() -> dict: + """ + Returns a summary of all notification logs. + Useful for Fleet Manager / admin dashboards. + """ + total = len(NOTIFICATION_LOGS) + delivered = sum(1 for log in NOTIFICATION_LOGS if log.status == DeliveryStatus.DELIVERED) + failed = sum(1 for log in NOTIFICATION_LOGS if log.status == DeliveryStatus.FAILED) + + by_channel = {} + for log in NOTIFICATION_LOGS: + channel = log.channel.value + by_channel[channel] = by_channel.get(channel, 0) + 1 + + by_type = {} + for log in NOTIFICATION_LOGS: + msg_type = log.message_type.value + by_type[msg_type] = by_type.get(msg_type, 0) + 1 + + return { + "total": total, + "delivered": delivered, + "failed": failed, + "by_channel": by_channel, + "by_type": by_type, + } \ No newline at end of file diff --git a/backend/services/notification_service/main.py b/backend/services/notification_service/main.py new file mode 100644 index 0000000..63040b0 --- /dev/null +++ b/backend/services/notification_service/main.py @@ -0,0 +1,21 @@ +""" +notification_service/main.py +----------------------------- +Entry point for the Notification Service as a standalone FastAPI app. +Can also be mounted as a router inside the root main.py. +""" + +from fastapi import FastAPI +from notification_service.api.notification_api import router + +app = FastAPI( + title="GovRide Notification Service", + description=( + "Lightweight microservice responsible for sending essential system messages " + "to employees, drivers, fleet managers, and private providers. " + "Acts as a simple delivery layer on behalf of other microservices." + ), + version="1.0.0", +) + +app.include_router(router) \ No newline at end of file diff --git a/backend/services/notification_service/models.py b/backend/services/notification_service/models.py new file mode 100644 index 0000000..c978ea8 --- /dev/null +++ b/backend/services/notification_service/models.py @@ -0,0 +1,171 @@ +""" +notification_service/models.py +------------------------------- +All Pydantic models and enums for the Notification Service. + +Design reference: + "The Notification Service is a lightweight microservice responsible for + sending essential system messages to employees, drivers, fleet managers, + and private providers. It acts as a simple delivery layer that sends + outgoing notifications on behalf of other microservices." + + Required fields per design: + - Recipient + - Message content + - Delivery channel (SMS, email, in-app) + + Log stores per design: + - Recipient + - Message type + - Timestamp + - Delivered or failed status +""" + +from pydantic import BaseModel, Field +from typing import Optional +from enum import Enum +from datetime import datetime + + +# =========================================================================== +# ENUMS +# =========================================================================== + +class DeliveryChannel(str, Enum): + """ + The three delivery channels supported by the Notification Service. + Design reference: "SMS gateway, Email provider, In-app notification publisher" + """ + SMS = "sms" + EMAIL = "email" + IN_APP = "in_app" + + +class DeliveryStatus(str, Enum): + """ + Outcome of a delivery attempt. + Design reference: "Delivered or failed status" + """ + DELIVERED = "delivered" + FAILED = "failed" + + +class MessageType(str, Enum): + """ + The type of notification being sent. + Maps to every event in the fleet management lifecycle that triggers + a notification to employees, drivers, fleet managers, or providers. + """ + # Trip lifecycle + TRIP_APPROVED = "trip_approved" + TRIP_REJECTED = "trip_rejected" + TRIP_ALLOCATED = "trip_allocated" + TRIP_STARTED = "trip_started" + TRIP_ARRIVED = "trip_arrived" + TRIP_COMPLETED = "trip_completed" + + # Token + TOKEN_ISSUED = "token_issued" + + # Disruptions + DISRUPTION_REPORTED = "disruption_reported" + VEHICLE_BREAKDOWN = "vehicle_breakdown" + DRIVER_EMERGENCY = "driver_emergency" + REALLOCATION_DONE = "reallocation_done" + + # Standby / provider + STANDBY_ASSIGNED = "standby_assigned" + STANDBY_CODE_ISSUED = "standby_code_issued" + + # Fleet / maintenance + MAINTENANCE_DUE = "maintenance_due" + DRIVER_HOURS_WARNING = "driver_hours_warning" + VEHICLE_LOCKED = "vehicle_locked" + DRIVER_SUSPENDED = "driver_suspended" + + # Admin / priority + ELEVATION_GRANTED = "elevation_granted" + DIRECTOR_SIGNATURE_REQ = "director_signature_required" + + # General + GENERAL = "general" + + +# =========================================================================== +# REQUEST MODEL +# =========================================================================== + +class NotificationRequest(BaseModel): + """ + Inbound notification request from another microservice. + + Design reference: + "Receive a notification request from another microservice." + "Validates required fields: Recipient, Message content, + Delivery channel (SMS, email, in-app)" + + Fields: + recipient_id -- user/driver/provider ID (for in-app routing) + recipient_contact -- phone number (SMS) or email address (Email) + Not required for in-app (looked up by recipient_id) + message_type -- category of notification (for logging) + message -- the actual message body to send + channel -- which delivery channel to use + trip_id -- optional reference to a related trip + sender_service -- which microservice is sending this (for logs) + """ + recipient_id: str = Field(..., description="User/driver/provider ID") + recipient_contact: Optional[str] = Field(None, description="Phone number or email address") + message_type: MessageType = Field(..., description="Category of notification") + message: str = Field(..., description="Notification message body") + channel: DeliveryChannel = Field(..., description="Delivery channel: sms, email, in_app") + trip_id: Optional[str] = Field(None, description="Related trip ID if applicable") + sender_service: str = Field("fleet_management", description="Calling microservice name") + + +# =========================================================================== +# RESPONSE MODEL +# =========================================================================== + +class NotificationResponse(BaseModel): + """ + Response returned to the calling microservice after delivery attempt. + + Design reference: + "Return a basic success/failure response." + "Output to Original Microservice" (diagram) + """ + success: bool + notification_id: str + recipient_id: str + channel: DeliveryChannel + status: DeliveryStatus + message: str + errors: list[str] = [] + + +# =========================================================================== +# LOG MODEL +# =========================================================================== + +class NotificationLog(BaseModel): + """ + Persisted record of a notification delivery attempt. + + Design reference (Log Manager stores): + - Recipient + - Message type + - Timestamp + - Delivered or failed status + """ + notification_id: str + recipient_id: str + recipient_contact: Optional[str] + message_type: MessageType + message: str + channel: DeliveryChannel + status: DeliveryStatus + timestamp: datetime + trip_id: Optional[str] = None + sender_service: str = "unknown" + error_detail: Optional[str] = None \ No newline at end of file diff --git a/backend/tests/test_allocation.py b/backend/tests/test_allocation.py deleted file mode 100644 index 7b63978..0000000 --- a/backend/tests/test_allocation.py +++ /dev/null @@ -1,47 +0,0 @@ - -# ---------------------------- -# test_allocation.py -# ---------------------------- -from datetime import datetime -from fleet_management.allocation import admin_approve_trip, fleet_manager_confirm_trip, rerecommend_trip -from fleet_management.trip_request import TRIP_REQUESTS -from fleet_management.models import TripRequest - -def setup_module(module): - """Create a dummy trip for allocation tests.""" - TRIP_REQUESTS.clear() - trip = TripRequest( - request_id="TR002", - user_id="U001", - pickup_location="Maseru", - destination="Mafeteng", - trip_date=datetime(2026, 1, 20), - purpose="District inspection" - ) - TRIP_REQUESTS.append(trip) - -def test_admin_approve_trip(): - response = admin_approve_trip("TR002", approve=True) - assert "approved" in response - assert TRIP_REQUESTS[0].status == "approved" - -def test_fleet_manager_confirm_trip(): - allocation_info = fleet_manager_confirm_trip("TR002", fleet_manager_id="F001") - assert allocation_info["trip_id"] == "TR002" - assert "trip_token" in allocation_info - token_info = allocation_info["trip_token"] - assert isinstance(token_info, dict) - assert token_info["mode"] in ["qr", "text"] - assert token_info["token_validity_hours"] == 4 - - if token_info["mode"] == "text": - assert len(token_info["token"]) >= 6 - elif token_info["mode"] == "qr": - assert "qr_code_base64" in token_info - assert len(token_info["qr_code_base64"]) > 100 # QR should be big - - -def test_rerecommend_trip(): - response = rerecommend_trip("TR002", new_driver_id="D002") - assert "rerecommended" in response - assert TRIP_REQUESTS[0].assigned_driver == "Lineo Seeiso" diff --git a/backend/tests/test_fleet_management.py b/backend/tests/test_fleet_management.py new file mode 100644 index 0000000..5b0f964 --- /dev/null +++ b/backend/tests/test_fleet_management.py @@ -0,0 +1,3441 @@ +""" +tests/test_fleet_management.py +------------------------------- +Tests for the Fleet Management Service — covering everything built so far. + +Flow being tested: + 1. Employee submits a trip request (trip_request_processor) + 2. Admin Manager approves or rejects (heuristic_allocation_engine - Stage 1) + 3. System auto-allocates vehicle & driver (heuristic_allocation_engine - Stage 2) + 4. Fleet Manager override (optional) (heuristic_allocation_engine - Stage 3) + 5. Token issued & consumed (token_generator_engine) + 6. Trip lifecycle — start, GPS, arrive, complete (trip_lifecycle_engine) + 7. Driver & vehicle state management (driver_vehicle_state_manager) + 8. Maintenance & wellbeing enforcement (maintenance_wellbeing_engine) + 9. Standby market — private provider escalation (standby_market_engine) + 10. Dynamic reallocation — disruption handling (dynamic_reallocation_engine) + 11. Priority & policy scoring (priority_policy_engine) + 12. Vehicle suitability scoring (vehicle_suitability_module) + 13. Optimization engine — MILP/heuristic solver (optimization_engine) + +Run from backend/: + pytest tests/test_fleet_management.py -v +""" + +import pytest +from datetime import datetime, timedelta + +from fleet_management.models import ( + TripStatus, + VehicleStatus, + VehicleType, + DriverStatus, + TripRequestIn, + AdminApprovalIn, + AllocationIn, +) +from fleet_management.engines.trip_request_processor import ( + TRIP_REQUESTS, + EMPLOYEES, + create_trip_request, + get_trip, + get_trips_by_status, +) +from fleet_management.engines.heuristic_allocation_engine import ( + VEHICLE_POOL, + DRIVER_POOL, + ADMIN_MANAGERS, + FLEET_MANAGERS, + process_admin_decision, + fleet_manager_override, +) +from fleet_management.models import TokenMode, TokenRequestIn, TripStatus +from fleet_management.engines.token_generator_engine import ( + TOKENS, + issue_token, + get_token_for_employee, + consume_token, +) +from fleet_management.engines.trip_lifecycle_engine import ( + start_trip, + update_gps, + driver_mark_arrived, + employee_confirm_completion, + get_trip_status, +) +from fleet_management.engines.driver_vehicle_state_manager import ( + compute_vehicle_readiness, + refresh_all_vehicle_readiness, + update_vehicle_odometer, + record_vehicle_service, + update_driver_hours, + reset_driver_hours_manually, + get_fleet_availability_snapshot, + MAX_DRIVER_HOURS_BIWEEKLY, + SERVICE_INTERVAL_KM, +) +from fleet_management.engines.maintenance_wellbeing_engine import ( + check_vehicle_maintenance, + run_vehicle_maintenance_check, + check_driver_wellbeing, + run_driver_wellbeing_check, + run_full_fleet_health_check, + get_maintenance_schedule, + VEHICLE_WARNING_KM, + VEHICLE_LOCK_KM, + DRIVER_WARNING_HOURS, + DRIVER_HARD_LIMIT_HOURS, +) +from fleet_management.engines.standby_market_engine import ( + STANDBY_PROVIDERS, + STANDBY_CODES, + escalate_to_standby, + authenticate_standby_trip, + release_standby_provider, + get_provider_rankings, +) +from fleet_management.engines.dynamic_reallocation_engine import ( + handle_disruption, + DisruptionType, + IMPROVEMENT_THRESHOLD, +) +from fleet_management.engines.priority_policy_engine import ( + compute_priority_score, + approve_director_elevation, + get_admin_elevation_status, + PriorityTier, + MinistryTier, + ELEVATION_QUOTA, + ELEVATION_DELTA, + TIER_BASE_SCORES, + MINISTRY_MULTIPLIERS, + MINISTRY_TIER_MAP, + EMPLOYEE_TIER_MAP, + ADMIN_DIRECTOR_MAP, + _elevation_records, +) +from fleet_management.engines.vehicle_suitability_module import ( + compute_vehicle_suitability, + apply_suitability_to_trip, + get_suitability_for_score, + TERRAIN_THRESHOLDS, + SUITABILITY_TABLE, +) +from fleet_management.engines.optimization_engine import ( + solve, + batch_solve, + WEIGHT_PRIORITY, + WEIGHT_SUITABILITY, + WEIGHT_READINESS, + CHANGE_PENALTY, + PULP_AVAILABLE, +) +from fleet_management.engines.dynamic_reallocation_engine import ( + WarmStartInput, + DisruptionType as DR_DisruptionType, + _get_pending_trips, + _get_available_vehicles, + _get_available_drivers, + _build_current_solution, +) + + +# =========================================================================== +# FIXTURES +# =========================================================================== + +@pytest.fixture(autouse=True) +def reset_state(): + """ + Resets all in-memory stores before each test. + This ensures tests are fully isolated — one test's data + does not bleed into the next. + """ + TRIP_REQUESTS.clear() + TOKENS.clear() + STANDBY_CODES.clear() + _elevation_records.clear() + + for p in STANDBY_PROVIDERS: + p.is_available = True + p.current_trip_id = None + + for v in VEHICLE_POOL: + v.current_status = VehicleStatus.AVAILABLE + v.readiness_score = 0.9 + v.odometer_km = 1000.0 + v.last_service_odometer_km = 0.0 + v.manufacture_date = datetime.now() - timedelta(days=365 * 3) # 3 years old + v.last_service_date = datetime.now() - timedelta(days=30) # Serviced 30 days ago + + for d in DRIVER_POOL: + d.status = DriverStatus.AVAILABLE + d.hours_driven_this_period = 0.0 + d.hours_period_start = datetime.now() + d.current_vehicle_id = None + + yield # test runs here + + +def make_trip_request( + user_id: str = "U001", + pickup_location: str = "Maseru Central", + destination: str = "Thaba-Tseka District Hospital", + trip_date: datetime | None = None, + purpose: str = "Medical supply delivery", + passengers: int = 2, +) -> TripRequestIn: + """Helper to build a valid TripRequestIn with sensible defaults.""" + return TripRequestIn( + user_id=user_id, + pickup_location=pickup_location, + destination=destination, + trip_date=trip_date or datetime.now() + timedelta(days=1), + purpose=purpose, + passengers=passengers, + ) + + +# =========================================================================== +# STAGE 1 — TRIP REQUEST CREATION +# =========================================================================== + +class TestTripRequestCreation: + + def test_valid_trip_request_is_created(self): + """A valid request from a known employee should succeed.""" + result = create_trip_request(make_trip_request()) + + assert result["success"] is True + assert result["trip"] is not None + assert result["trip"].status == TripStatus.PENDING + assert len(TRIP_REQUESTS) == 1 + + def test_trip_gets_unique_request_id(self): + """Every trip must have a unique request ID.""" + result1 = create_trip_request(make_trip_request()) + result2 = create_trip_request(make_trip_request()) + + assert result1["trip"].request_id != result2["trip"].request_id + + def test_unknown_employee_is_rejected(self): + """A user ID that does not exist should fail with a clear error.""" + result = create_trip_request(make_trip_request(user_id="U999")) + + assert result["success"] is False + assert any("not found" in e for e in result["errors"]) + + def test_past_trip_date_is_rejected(self): + """A trip date in the past should fail validation.""" + result = create_trip_request( + make_trip_request(trip_date=datetime.now() - timedelta(days=1)) + ) + + assert result["success"] is False + assert any("past" in e for e in result["errors"]) + + def test_same_pickup_and_destination_rejected(self): + """Pickup and destination cannot be the same place.""" + result = create_trip_request( + make_trip_request(pickup_location="Maseru Central", destination="Maseru Central") + ) + + assert result["success"] is False + assert any("same" in e for e in result["errors"]) + + def test_empty_destination_rejected(self): + """Empty destination should fail validation.""" + result = create_trip_request(make_trip_request(destination=" ")) + + assert result["success"] is False + assert any("Destination" in e for e in result["errors"]) + + def test_zero_passengers_rejected(self): + """Passenger count must be at least 1.""" + result = create_trip_request(make_trip_request(passengers=0)) + + assert result["success"] is False + assert any("Passenger" in e for e in result["errors"]) + + +# =========================================================================== +# STAGE 2 — ADMIN APPROVAL +# =========================================================================== + +class TestAdminApproval: + + def test_admin_approves_trip_and_system_allocates(self): + """ + Approving a trip should immediately trigger auto-allocation. + Trip should end up ALLOCATED with a vehicle and driver assigned. + """ + trip_result = create_trip_request(make_trip_request()) + request_id = trip_result["trip"].request_id + + result = process_admin_decision( + request_id, + admin_id="A001", + data=AdminApprovalIn(approve=True) + ) + + assert result["success"] is True + assert result["status"] == TripStatus.ALLOCATED + assert result["vehicle"] is not None + assert result["driver"] is not None + + def test_admin_rejects_trip(self): + """Rejecting a trip should set status to REJECTED with a reason.""" + trip_result = create_trip_request(make_trip_request()) + request_id = trip_result["trip"].request_id + + result = process_admin_decision( + request_id, + admin_id="A001", + data=AdminApprovalIn(approve=False, rejection_reason="Budget freeze.") + ) + + assert result["success"] is True + assert result["status"] == TripStatus.REJECTED + assert result["reason"] == "Budget freeze." + + def test_wrong_ministry_admin_cannot_approve(self): + """ + Admin Manager from M002 should not be able to approve + a trip from an employee in M001. + """ + trip_result = create_trip_request(make_trip_request(user_id="U001")) # M001 employee + request_id = trip_result["trip"].request_id + + result = process_admin_decision( + request_id, + admin_id="A002", # M002 admin + data=AdminApprovalIn(approve=True) + ) + + assert result["success"] is False + assert any("authorised" in e for e in result["errors"]) + + def test_cannot_approve_non_pending_trip(self): + """Admin cannot approve a trip that is already approved or rejected.""" + trip_result = create_trip_request(make_trip_request()) + request_id = trip_result["trip"].request_id + + # Approve once + process_admin_decision( + request_id, admin_id="A001", data=AdminApprovalIn(approve=True) + ) + + # Try to approve again + result = process_admin_decision( + request_id, admin_id="A001", data=AdminApprovalIn(approve=True) + ) + + assert result["success"] is False + assert any("not pending" in e for e in result["errors"]) + + def test_approving_nonexistent_trip_fails(self): + """Trying to approve a trip ID that does not exist should fail cleanly.""" + result = process_admin_decision( + "TR-DOESNOTEXIST", + admin_id="A001", + data=AdminApprovalIn(approve=True) + ) + + assert result["success"] is False + assert any("not found" in e for e in result["errors"]) + + +# =========================================================================== +# STAGE 3 — AUTONOMOUS ALLOCATION +# =========================================================================== + +class TestAutoAllocation: + + def test_vehicle_and_driver_are_locked_after_allocation(self): + """ + After allocation, the assigned vehicle should be ON_TRIP + and the assigned driver should be ON_TRIP. + """ + trip_result = create_trip_request(make_trip_request()) + request_id = trip_result["trip"].request_id + + process_admin_decision( + request_id, admin_id="A001", data=AdminApprovalIn(approve=True) + ) + + trip = get_trip(request_id) + assert trip is not None + vehicle = next(v for v in VEHICLE_POOL if v.vehicle_id == trip.assigned_vehicle_id) + driver = next(d for d in DRIVER_POOL if d.driver_id == trip.assigned_driver_id) + + assert vehicle.current_status == VehicleStatus.ON_TRIP + assert driver.status == DriverStatus.ON_TRIP + + def test_no_vehicle_available_escalates_to_standby(self): + """ + When all government vehicles are unavailable, the system should + automatically escalate to the standby market and successfully + allocate a private provider — success is True, not False. + """ + for v in VEHICLE_POOL: + v.current_status = VehicleStatus.MAINTENANCE + + trip_result = create_trip_request(make_trip_request()) + request_id = trip_result["trip"].request_id + + result = process_admin_decision( + request_id, admin_id="A001", data=AdminApprovalIn(approve=True) + ) + + # Standby engine is now wired in — escalation succeeds via a provider + assert result["success"] is True + assert result["status"] == TripStatus.ALLOCATED + assert result["provider"] is not None + + def test_no_driver_available_blocks_trip(self): + """ + When all drivers are unavailable, the trip should stay + APPROVED and not be allocated. + """ + for d in DRIVER_POOL: + d.status = DriverStatus.ON_TRIP + + trip_result = create_trip_request(make_trip_request()) + request_id = trip_result["trip"].request_id + + result = process_admin_decision( + request_id, admin_id="A001", data=AdminApprovalIn(approve=True) + ) + + assert result["success"] is False + assert result["escalate_to_standby"] is False + + # Trip should still be APPROVED — not ALLOCATED + trip = get_trip(request_id) + assert trip is not None + assert trip.status == TripStatus.APPROVED + + def test_most_rested_driver_is_selected(self): + """ + The driver with the fewest hours_driven_this_period should + always be selected over a more fatigued one. + """ + DRIVER_POOL[0].hours_driven_this_period = 7.0 # D001 — tired + DRIVER_POOL[1].hours_driven_this_period = 1.0 # D002 — rested + DRIVER_POOL[2].status = DriverStatus.ON_TRIP # D003 — not available + + trip_result = create_trip_request(make_trip_request()) + request_id = trip_result["trip"].request_id + + process_admin_decision( + request_id, admin_id="A001", data=AdminApprovalIn(approve=True) + ) + + trip = get_trip(request_id) + assert trip is not None + assert trip.assigned_driver_id == "D002" # Rested driver should be picked + + def test_highest_readiness_vehicle_is_selected(self): + """ + Among vehicles that qualify, the one with the highest + readiness_score should be selected. + """ + VEHICLE_POOL[0].readiness_score = 0.5 # V001 — lower + VEHICLE_POOL[1].readiness_score = 0.99 # V002 — higher + + trip_result = create_trip_request(make_trip_request()) + request_id = trip_result["trip"].request_id + + process_admin_decision( + request_id, admin_id="A001", data=AdminApprovalIn(approve=True) + ) + + trip = get_trip(request_id) + assert trip is not None + assert trip.assigned_vehicle_id == "V002" + + +# =========================================================================== +# STAGE 4 — FLEET MANAGER OVERRIDE (OPTIONAL) +# =========================================================================== + +class TestFleetManagerOverride: + + def _get_allocated_trip_id(self) -> str: + """Helper — creates and approves a trip, returns its request_id.""" + trip_result = create_trip_request(make_trip_request()) + request_id = trip_result["trip"].request_id + process_admin_decision( + request_id, admin_id="A001", data=AdminApprovalIn(approve=True) + ) + return request_id + + def test_fleet_manager_can_override_vehicle(self): + """Fleet Manager should be able to swap the vehicle on an allocated trip.""" + request_id = self._get_allocated_trip_id() + trip = get_trip(request_id) + assert trip is not None + + # Pick a vehicle that is not currently assigned + new_vehicle = next( + v for v in VEHICLE_POOL + if v.vehicle_id != trip.assigned_vehicle_id + and v.current_status == VehicleStatus.AVAILABLE + ) + + result = fleet_manager_override( + request_id, + data=AllocationIn(fleet_manager_id="F001", vehicle_id=new_vehicle.vehicle_id) + ) + + assert result["success"] is True + assert result["vehicle"]["vehicle_id"] == new_vehicle.vehicle_id + + def test_previous_vehicle_is_released_on_override(self): + """ + When the Fleet Manager overrides, the previously allocated + vehicle must be released back to AVAILABLE. + """ + request_id = self._get_allocated_trip_id() + trip = get_trip(request_id) + assert trip is not None + prev_vehicle_id = trip.assigned_vehicle_id + + new_vehicle = next( + v for v in VEHICLE_POOL + if v.vehicle_id != prev_vehicle_id + and v.current_status == VehicleStatus.AVAILABLE + ) + + fleet_manager_override( + request_id, + data=AllocationIn(fleet_manager_id="F001", vehicle_id=new_vehicle.vehicle_id) + ) + + prev_vehicle = next(v for v in VEHICLE_POOL if v.vehicle_id == prev_vehicle_id) + assert prev_vehicle.current_status == VehicleStatus.AVAILABLE + + def test_cannot_override_pending_trip(self): + """Fleet Manager cannot override a trip that hasn't been allocated yet.""" + trip_result = create_trip_request(make_trip_request()) + request_id = trip_result["trip"].request_id # Still PENDING + + result = fleet_manager_override( + request_id, + data=AllocationIn(fleet_manager_id="F001") + ) + + assert result["success"] is False + assert any("ALLOCATED" in e for e in result["errors"]) + + def test_unknown_fleet_manager_is_rejected(self): + """An unrecognised fleet manager ID should fail.""" + request_id = self._get_allocated_trip_id() + + result = fleet_manager_override( + request_id, + data=AllocationIn(fleet_manager_id="F999") + ) + + assert result["success"] is False + assert any("not found" in e for e in result["errors"]) + + def test_unavailable_vehicle_cannot_be_assigned(self): + """Fleet Manager cannot assign a vehicle that is already on a trip.""" + request_id = self._get_allocated_trip_id() + trip = get_trip(request_id) + + # Try to assign the already-locked vehicle + trip = get_trip(request_id) + assert trip is not None + result = fleet_manager_override( + request_id, + data=AllocationIn( + fleet_manager_id="F001", + vehicle_id=trip.assigned_vehicle_id # Already ON_TRIP + ) + ) + + assert result["success"] is False + assert any("not available" in e for e in result["errors"]) + + +# =========================================================================== +# STAGE 5 — TOKEN GENERATION & CONSUMPTION +# =========================================================================== + +class TestTokenEngine: + + def _get_allocated_trip_id(self) -> str: + """Helper — creates and approves a trip, returns its request_id.""" + trip_result = create_trip_request(make_trip_request()) + request_id = trip_result["trip"].request_id + process_admin_decision( + request_id, admin_id="A001", data=AdminApprovalIn(approve=True) + ) + return request_id + + def test_token_is_issued_automatically_on_allocation(self): + """ + After admin approval triggers auto-allocation, a token should + already exist for the trip — no manual token request needed. + """ + request_id = self._get_allocated_trip_id() + trip = get_trip(request_id) + assert trip is not None + + # token_id should be written onto the trip by the allocation engine + assert trip.token_id is not None + + # The token should exist in the TOKENS store + token = next((t for t in TOKENS if t.token_id == trip.token_id), None) + assert token is not None + assert token.trip_id == request_id + assert not token.is_used + + def test_token_expires_at_midnight_on_trip_date(self): + """ + Token expiry should be set to 23:59:59 on the trip date — + not a fixed number of hours from now. + """ + request_id = self._get_allocated_trip_id() + trip = get_trip(request_id) + assert trip is not None + + token = next((t for t in TOKENS if t.token_id == trip.token_id), None) + assert token is not None + + # Expiry should be on the same date as the trip + assert token.expires_at.date() == trip.trip_date.date() + assert token.expires_at.hour == 23 + assert token.expires_at.minute == 59 + assert token.expires_at.second == 59 + + def test_employee_can_get_token_in_text_mode(self): + """Employee should receive the raw token string when choosing text mode.""" + request_id = self._get_allocated_trip_id() + + result = get_token_for_employee( + TokenRequestIn(trip_id=request_id, mode=TokenMode.TEXT) + ) + + assert result["success"] is True + assert result["mode"] == TokenMode.TEXT + assert result["token_value"] is not None + assert result["qr_code_base64"] is None + + def test_employee_can_get_token_in_qr_mode(self): + """ + Employee should receive a base64 QR image when choosing QR mode. + This test is skipped if the qrcode library is not installed. + """ + try: + import qrcode # noqa: F401 + except ImportError: + pytest.skip("qrcode library not installed") + + request_id = self._get_allocated_trip_id() + + result = get_token_for_employee( + TokenRequestIn(trip_id=request_id, mode=TokenMode.QR) + ) + + assert result["success"] is True + assert result["mode"] == TokenMode.QR + assert result["qr_code_base64"] is not None + assert result["token_value"] is None + + def test_driver_can_consume_valid_token(self): + """Driver should be able to authenticate with a valid token.""" + request_id = self._get_allocated_trip_id() + trip = get_trip(request_id) + assert trip is not None + + # Get the raw token value + token = next((t for t in TOKENS if t.token_id == trip.token_id), None) + assert token is not None + + result = consume_token(request_id, token.token_value) + + assert result["success"] is True + assert token.is_used is True + + def test_token_cannot_be_consumed_twice(self): + """Once a token is used, it cannot be used again.""" + request_id = self._get_allocated_trip_id() + trip = get_trip(request_id) + assert trip is not None + + token = next((t for t in TOKENS if t.token_id == trip.token_id), None) + assert token is not None + + # First consumption — should succeed + consume_token(request_id, token.token_value) + + # Second consumption — should fail + result = consume_token(request_id, token.token_value) + assert result["success"] is False + assert any("already been used" in e for e in result["errors"]) + + def test_wrong_token_value_is_rejected(self): + """Driver entering the wrong token value should be rejected.""" + request_id = self._get_allocated_trip_id() + + result = consume_token(request_id, "WRONG-TOKEN") + + assert result["success"] is False + assert any("Invalid token" in e for e in result["errors"]) + + def test_expired_token_is_rejected_on_consume(self): + """ + If the token has expired by the time the driver tries to use it, + authentication should fail with a clear expiry message. + """ + request_id = self._get_allocated_trip_id() + trip = get_trip(request_id) + assert trip is not None + + # Manually expire the token by backdating its expiry + token = next((t for t in TOKENS if t.token_id == trip.token_id), None) + assert token is not None + token.expires_at = datetime.now() - timedelta(hours=1) + + result = consume_token(request_id, token.token_value) + + assert result["success"] is False + assert any("expired" in e for e in result["errors"]) + + def test_employee_gets_fresh_token_if_expired(self): + """ + If the token has expired, the employee requesting a new one + should automatically get a fresh token — no Fleet Manager needed. + """ + request_id = self._get_allocated_trip_id() + trip = get_trip(request_id) + assert trip is not None + + # Expire the current token + old_token = next((t for t in TOKENS if t.token_id == trip.token_id), None) + assert old_token is not None + old_token.expires_at = datetime.now() - timedelta(hours=1) + old_value = old_token.token_value + + # Employee requests their token — should get a fresh one + result = get_token_for_employee( + TokenRequestIn(trip_id=request_id, mode=TokenMode.TEXT) + ) + + assert result["success"] is True + # New token value should be different from the expired one + assert result["token_value"] != old_value + + def test_cannot_issue_token_for_non_allocated_trip(self): + """Token cannot be issued for a trip that is still PENDING.""" + trip_result = create_trip_request(make_trip_request()) + request_id = trip_result["trip"].request_id # Still PENDING + + result = issue_token(request_id) + + assert result["success"] is False + assert any("not allocated" in e for e in result["errors"]) + + def test_fleet_manager_override_issues_fresh_token(self): + """ + When a Fleet Manager overrides an allocation, a new token + should be issued — the old one should be invalidated. + """ + request_id = self._get_allocated_trip_id() + trip = get_trip(request_id) + assert trip is not None + + old_token_id = trip.token_id + + # Find an available vehicle to override with + new_vehicle = next( + v for v in VEHICLE_POOL + if v.vehicle_id != trip.assigned_vehicle_id + and v.current_status == VehicleStatus.AVAILABLE + ) + + fleet_manager_override( + request_id, + data=AllocationIn( + fleet_manager_id="F001", + vehicle_id=new_vehicle.vehicle_id + ) + ) + + # trip.token_id should now point to a new token + trip = get_trip(request_id) + assert trip is not None + assert trip.token_id != old_token_id + + # Old token should be marked as used/invalidated + old_token = next((t for t in TOKENS if t.token_id == old_token_id), None) + assert old_token is not None + assert old_token.is_used is True + + +# =========================================================================== +# STAGE 6 — TRIP LIFECYCLE +# =========================================================================== + +class TestTripLifecycle: + + def _get_allocated_trip_with_token(self) -> tuple[str, str]: + """ + Helper — creates, approves and allocates a trip. + Returns (request_id, token_value) ready for the driver to authenticate. + """ + trip_result = create_trip_request(make_trip_request()) + request_id = trip_result["trip"].request_id + process_admin_decision( + request_id, admin_id="A001", data=AdminApprovalIn(approve=True) + ) + trip = get_trip(request_id) + assert trip is not None + token = next((t for t in TOKENS if t.token_id == trip.token_id), None) + assert token is not None + return request_id, token.token_value + + def _get_ongoing_trip(self) -> str: + """Helper — returns a request_id for a trip that is already ONGOING.""" + request_id, token_value = self._get_allocated_trip_with_token() + start_trip(request_id, token_value) + return request_id + + def _get_arriving_trip(self) -> str: + """Helper — returns a request_id for a trip that is already ARRIVING.""" + request_id = self._get_ongoing_trip() + trip = get_trip(request_id) + assert trip is not None + assert trip.assigned_driver_id is not None + driver_mark_arrived(request_id, trip.assigned_driver_id) + return request_id + + # ----------------------------------------------------------------------- + # START TRIP + # ----------------------------------------------------------------------- + + def test_driver_can_start_trip_with_valid_token(self): + """Driver authenticates with the correct token — trip moves to ONGOING.""" + request_id, token_value = self._get_allocated_trip_with_token() + + result = start_trip(request_id, token_value) + + assert result["success"] is True + assert result["status"] == TripStatus.ONGOING + + trip = get_trip(request_id) + assert trip is not None + assert trip.status == TripStatus.ONGOING + assert trip.started_at is not None + + def test_start_trip_with_wrong_token_fails(self): + """Driver entering the wrong token cannot start the trip.""" + request_id, _ = self._get_allocated_trip_with_token() + + result = start_trip(request_id, "WRONG-TOKEN") + + assert result["success"] is False + trip = get_trip(request_id) + assert trip is not None + assert trip.status == TripStatus.ALLOCATED # Still allocated, not started + + def test_cannot_start_non_allocated_trip(self): + """A trip that is still PENDING cannot be started.""" + trip_result = create_trip_request(make_trip_request()) + request_id = trip_result["trip"].request_id # PENDING + + result = start_trip(request_id, "any-token") + + assert result["success"] is False + assert any("cannot be started" in e for e in result["errors"]) + + def test_cannot_start_already_ongoing_trip(self): + """A trip that is already ONGOING cannot be started again.""" + request_id = self._get_ongoing_trip() + + result = start_trip(request_id, "any-token") + + assert result["success"] is False + assert any("cannot be started" in e for e in result["errors"]) + + # ----------------------------------------------------------------------- + # GPS TRACKING + # ----------------------------------------------------------------------- + + def test_gps_coordinates_are_stored_on_ongoing_trip(self): + """GPS update on an ongoing trip should store coordinates.""" + request_id = self._get_ongoing_trip() + + result = update_gps(request_id, latitude=-29.3167, longitude=27.4833) + + assert result["success"] is True + assert result["current_latitude"] == -29.3167 + assert result["current_longitude"] == 27.4833 + assert result["total_points_recorded"] == 1 + + def test_multiple_gps_updates_build_full_track(self): + """Each GPS update should append to the route history.""" + request_id = self._get_ongoing_trip() + + update_gps(request_id, -29.3167, 27.4833) + update_gps(request_id, -29.4000, 27.5000) + result = update_gps(request_id, -29.5000, 27.6000) + + assert result["total_points_recorded"] == 3 + + trip = get_trip(request_id) + assert trip is not None + assert len(trip.gps_track) == 3 + assert trip.current_latitude == -29.5000 + assert trip.current_longitude == 27.6000 + + def test_gps_update_rejected_for_non_ongoing_trip(self): + """GPS updates should only be accepted for ONGOING trips.""" + trip_result = create_trip_request(make_trip_request()) + request_id = trip_result["trip"].request_id # PENDING + + result = update_gps(request_id, -29.3167, 27.4833) + + assert result["success"] is False + assert any("not ongoing" in e for e in result["errors"]) + + def test_invalid_latitude_is_rejected(self): + """Latitude outside -90 to 90 should be rejected.""" + request_id = self._get_ongoing_trip() + + result = update_gps(request_id, latitude=95.0, longitude=27.4833) + + assert result["success"] is False + assert any("Latitude" in e for e in result["errors"]) + + def test_invalid_longitude_is_rejected(self): + """Longitude outside -180 to 180 should be rejected.""" + request_id = self._get_ongoing_trip() + + result = update_gps(request_id, latitude=-29.3167, longitude=200.0) + + assert result["success"] is False + assert any("Longitude" in e for e in result["errors"]) + + # ----------------------------------------------------------------------- + # DRIVER MARKS ARRIVED + # ----------------------------------------------------------------------- + + def test_driver_can_mark_arrived(self): + """Assigned driver marking arrival moves trip from ONGOING to ARRIVING.""" + request_id = self._get_ongoing_trip() + trip = get_trip(request_id) + assert trip is not None + + assert trip.assigned_driver_id is not None + result = driver_mark_arrived(request_id, trip.assigned_driver_id) + + assert result["success"] is True + assert result["status"] == TripStatus.ARRIVING + + trip = get_trip(request_id) + assert trip is not None + assert trip.status == TripStatus.ARRIVING + assert trip.arrived_at is not None + + def test_wrong_driver_cannot_mark_arrived(self): + """A driver not assigned to the trip cannot mark it as arrived.""" + request_id = self._get_ongoing_trip() + + result = driver_mark_arrived(request_id, "D999") + + assert result["success"] is False + assert any("not assigned" in e for e in result["errors"]) + + def test_cannot_mark_arrived_if_not_ongoing(self): + """Cannot mark arrival on a trip that hasn't started yet.""" + trip_result = create_trip_request(make_trip_request()) + request_id = trip_result["trip"].request_id # PENDING + + result = driver_mark_arrived(request_id, "D001") + + assert result["success"] is False + assert any("cannot be marked" in e for e in result["errors"]) + + # ----------------------------------------------------------------------- + # EMPLOYEE CONFIRMS COMPLETION + # ----------------------------------------------------------------------- + + def test_employee_can_confirm_completion(self): + """ + Employee confirming receipt moves trip from ARRIVING to COMPLETED + and releases vehicle and driver back to AVAILABLE. + """ + request_id = self._get_arriving_trip() + trip = get_trip(request_id) + assert trip is not None + + vehicle_id = trip.assigned_vehicle_id + driver_id = trip.assigned_driver_id + + result = employee_confirm_completion(request_id, trip.user_id) + + assert result["success"] is True + assert result["status"] == TripStatus.COMPLETED + + trip = get_trip(request_id) + assert trip is not None + assert trip.status == TripStatus.COMPLETED + assert trip.completed_at is not None + + # Vehicle and driver should now be free + vehicle = next(v for v in VEHICLE_POOL if v.vehicle_id == vehicle_id) + driver = next(d for d in DRIVER_POOL if d.driver_id == driver_id) + assert vehicle.current_status == VehicleStatus.AVAILABLE + assert driver.status == DriverStatus.AVAILABLE + + def test_wrong_employee_cannot_confirm_completion(self): + """Only the employee who requested the trip can confirm completion.""" + request_id = self._get_arriving_trip() + + result = employee_confirm_completion(request_id, "U999") + + assert result["success"] is False + assert any("did not request" in e for e in result["errors"]) + + def test_cannot_confirm_if_not_arriving(self): + """Employee cannot confirm a trip that the driver hasn't marked arrived.""" + request_id = self._get_ongoing_trip() + trip = get_trip(request_id) + assert trip is not None + + result = employee_confirm_completion(request_id, trip.user_id) + + assert result["success"] is False + assert any("ARRIVING" in e for e in result["errors"]) + + def test_vehicle_not_released_until_employee_confirms(self): + """ + Vehicle and driver should still be ON_TRIP when driver marks arrived. + They should only be released after employee confirms. + """ + request_id = self._get_ongoing_trip() + trip = get_trip(request_id) + assert trip is not None + + vehicle_id = trip.assigned_vehicle_id + driver_id = trip.assigned_driver_id + + # Driver marks arrived — vehicle/driver should still be locked + assert trip.assigned_driver_id is not None + driver_mark_arrived(request_id, trip.assigned_driver_id) + + vehicle = next(v for v in VEHICLE_POOL if v.vehicle_id == vehicle_id) + driver = next(d for d in DRIVER_POOL if d.driver_id == driver_id) + assert vehicle.current_status == VehicleStatus.ON_TRIP + assert driver.status == DriverStatus.ON_TRIP + + # ----------------------------------------------------------------------- + # FULL FLOW + # ----------------------------------------------------------------------- + + def test_full_trip_lifecycle(self): + """ + End-to-end test of the complete trip lifecycle: + PENDING -> APPROVED -> ALLOCATED -> ONGOING -> ARRIVING -> COMPLETED + """ + # Step 1 — Employee submits + trip_result = create_trip_request(make_trip_request()) + request_id = trip_result["trip"].request_id + trip = get_trip(request_id) + assert trip is not None + assert trip.status == TripStatus.PENDING + + # Step 2 — Admin approves (triggers auto-allocation) + process_admin_decision( + request_id, admin_id="A001", data=AdminApprovalIn(approve=True) + ) + trip = get_trip(request_id) + assert trip is not None + assert trip.status == TripStatus.ALLOCATED + + # Step 3 — Driver authenticates + trip = get_trip(request_id) + assert trip is not None + assert trip.token_id is not None + token = next((t for t in TOKENS if t.token_id == trip.token_id), None) + assert token is not None + start_trip(request_id, token.token_value) + trip = get_trip(request_id) + assert trip is not None + assert trip.status == TripStatus.ONGOING + + # Step 4 — GPS updates + update_gps(request_id, -29.3167, 27.4833) + update_gps(request_id, -29.4500, 27.5500) + trip = get_trip(request_id) + assert trip is not None + assert len(trip.gps_track) == 2 + + # Step 5 — Driver marks arrived + trip = get_trip(request_id) + assert trip is not None + assert trip.assigned_driver_id is not None + driver_mark_arrived(request_id, trip.assigned_driver_id) + trip = get_trip(request_id) + assert trip is not None + assert trip.status == TripStatus.ARRIVING + + # Step 6 — Employee confirms + trip = get_trip(request_id) + assert trip is not None + employee_confirm_completion(request_id, trip.user_id) + trip = get_trip(request_id) + assert trip is not None + assert trip.status == TripStatus.COMPLETED + + +# =========================================================================== +# STAGE 7 — DRIVER & VEHICLE STATE MANAGER +# =========================================================================== + +class TestDriverVehicleStateManager: + + # ----------------------------------------------------------------------- + # VEHICLE READINESS SCORING + # ----------------------------------------------------------------------- + + def test_readiness_score_is_between_0_and_1(self): + """Readiness score must always fall within valid range.""" + vehicle = VEHICLE_POOL[0] + score = compute_vehicle_readiness(vehicle) + assert 0.0 <= score <= 1.0 + + def test_freshly_serviced_vehicle_scores_higher(self): + """ + A vehicle serviced recently with low km since service should + score higher than one that is overdue. + """ + fresh = VEHICLE_POOL[0] + fresh.last_service_date = datetime.now() - timedelta(days=10) + fresh.last_service_odometer_km = fresh.odometer_km - 500 # 500km since service + + overdue = VEHICLE_POOL[1] + overdue.last_service_date = datetime.now() - timedelta(days=300) + overdue.last_service_odometer_km = overdue.odometer_km - 9000 # Near service limit + + fresh_score = compute_vehicle_readiness(fresh) + overdue_score = compute_vehicle_readiness(overdue) + + assert fresh_score > overdue_score + + def test_vehicle_at_service_limit_has_low_mileage_factor(self): + """ + A vehicle that has reached its service interval (10,000km since last service) + should have a mileage factor of 0.0, pulling the overall score down. + """ + vehicle = VEHICLE_POOL[0] + vehicle.odometer_km = 20000.0 + vehicle.last_service_odometer_km = 10000.0 # Exactly at service limit + + score = compute_vehicle_readiness(vehicle) + + # Mileage factor is 0.0, but age and service factors still contribute: + # age_factor (3yr old vehicle) ~ 0.8, service_factor (30 days ago) ~ 0.92 + # score = (0.0 * 0.5) + (0.8 * 0.2) + (0.92 * 0.3) ~ 0.44 + # Score should be below 0.5 since mileage — the highest-weighted factor — is 0 + assert score < 0.5 + + def test_vehicle_with_no_service_record_scores_low(self): + """ + A vehicle with no service date or manufacture date should score lower + than one with full records, due to worst-case assumptions on those factors. + + With reset_state defaults (odometer=1000, last_service_odometer=0): + mileage_factor = 1.0 - (1000 / 10000) = 0.90 + age_factor = 0.5 (unknown manufacture date -> neutral) + service_factor = 0.0 (no last_service_date -> worst case) + score = (0.90 * 0.5) + (0.5 * 0.2) + (0.0 * 0.3) = 0.55 + """ + vehicle = VEHICLE_POOL[0] + vehicle.last_service_date = None + vehicle.manufacture_date = None + + score = compute_vehicle_readiness(vehicle) + + assert score == pytest.approx(0.55, abs=0.01) + + # Also verify it scores lower than a fully-documented vehicle + vehicle_with_records = VEHICLE_POOL[1] + vehicle_with_records.last_service_date = datetime.now() - timedelta(days=10) + vehicle_with_records.manufacture_date = datetime.now() - timedelta(days=365) + full_score = compute_vehicle_readiness(vehicle_with_records) + + assert score < full_score + + def test_refresh_all_vehicle_readiness_updates_scores(self): + """refresh_all_vehicle_readiness should update readiness_score on all vehicles.""" + # Set a known initial score + for v in VEHICLE_POOL: + v.readiness_score = 0.0 + + results = refresh_all_vehicle_readiness() + + assert len(results) == len(VEHICLE_POOL) + # All scores should have been updated from 0.0 + for v in VEHICLE_POOL: + assert v.readiness_score > 0.0 + + def test_older_vehicle_scores_lower_than_newer_on_age_factor(self): + """A vehicle manufactured longer ago should score lower on the age factor.""" + new_vehicle = VEHICLE_POOL[0] + new_vehicle.manufacture_date = datetime.now() - timedelta(days=365) # 1 year old + + old_vehicle = VEHICLE_POOL[1] + old_vehicle.manufacture_date = datetime.now() - timedelta(days=365 * 12) # 12 years old + + # Give both identical mileage and service dates to isolate age factor + for v in [new_vehicle, old_vehicle]: + v.last_service_date = datetime.now() - timedelta(days=30) + v.odometer_km = 1000.0 + v.last_service_odometer_km = 0.0 + + new_score = compute_vehicle_readiness(new_vehicle) + old_score = compute_vehicle_readiness(old_vehicle) + + assert new_score > old_score + + # ----------------------------------------------------------------------- + # ODOMETER UPDATES + # ----------------------------------------------------------------------- + + def test_odometer_updates_correctly(self): + """Odometer should increase by the km_travelled amount.""" + vehicle = VEHICLE_POOL[0] + initial_odometer = vehicle.odometer_km + + result = update_vehicle_odometer(vehicle.vehicle_id, km_travelled=150.0) + + assert result["success"] is True + assert result["new_odometer_km"] == initial_odometer + 150.0 + + def test_readiness_recomputed_after_odometer_update(self): + """ + After an odometer update, readiness_score on the vehicle + should be recomputed immediately. + """ + vehicle = VEHICLE_POOL[0] + vehicle.readiness_score = 0.99 # Set a known value + + update_vehicle_odometer(vehicle.vehicle_id, km_travelled=5000.0) + + # Score should have been recomputed — no longer 0.99 + assert vehicle.readiness_score != 0.99 + + def test_negative_km_is_rejected(self): + """Negative km_travelled should fail with a clear error.""" + result = update_vehicle_odometer(VEHICLE_POOL[0].vehicle_id, km_travelled=-50.0) + + assert result["success"] is False + assert any("negative" in e for e in result["errors"]) + + def test_odometer_update_for_unknown_vehicle_fails(self): + """Updating odometer for a non-existent vehicle should fail cleanly.""" + result = update_vehicle_odometer("V999", km_travelled=100.0) + + assert result["success"] is False + assert any("not found" in e for e in result["errors"]) + + # ----------------------------------------------------------------------- + # VEHICLE SERVICE RECORDING + # ----------------------------------------------------------------------- + + def test_service_record_resets_km_since_service(self): + """ + After recording a service, last_service_odometer_km should be + updated to the current odometer — km since service becomes 0. + """ + vehicle = VEHICLE_POOL[0] + vehicle.odometer_km = 15000.0 + vehicle.last_service_odometer_km = 5000.0 # 10,000km since last service + + result = record_vehicle_service(vehicle.vehicle_id) + + assert result["success"] is True + assert vehicle.last_service_odometer_km == 15000.0 + + def test_readiness_improves_after_service(self): + """ + A vehicle that was overdue for service should have a higher + readiness score after being serviced. + """ + vehicle = VEHICLE_POOL[0] + vehicle.last_service_date = datetime.now() - timedelta(days=350) # Nearly a year + vehicle.last_service_odometer_km = vehicle.odometer_km - 9500 # Near limit + old_score = compute_vehicle_readiness(vehicle) + + record_vehicle_service(vehicle.vehicle_id) + + assert vehicle.readiness_score > old_score + + # ----------------------------------------------------------------------- + # DRIVER HOURS TRACKING + # ----------------------------------------------------------------------- + + def test_driver_hours_accumulate_correctly(self): + """Hours should accumulate across multiple updates in the same period.""" + driver = DRIVER_POOL[0] + driver.hours_driven_this_period = 0.0 + + update_driver_hours(driver.driver_id, 5.0) + update_driver_hours(driver.driver_id, 3.0) + result = update_driver_hours(driver.driver_id, 2.0) + + assert result["success"] is True + assert result["hours_driven_this_period"] == 10.0 + assert result["hours_remaining"] == MAX_DRIVER_HOURS_BIWEEKLY - 10.0 + + def test_driver_suspended_when_hours_limit_reached(self): + """ + A driver who reaches 60 hours in the biweekly period + should be automatically suspended. + """ + driver = DRIVER_POOL[0] + driver.hours_driven_this_period = 58.0 + + result = update_driver_hours(driver.driver_id, 3.0) # Push over 60 + + assert result["at_limit"] is True + assert result["status"] == DriverStatus.SUSPENDED + assert driver.status == DriverStatus.SUSPENDED + + def test_suspended_driver_not_available_for_allocation(self): + """ + A suspended driver should not appear as available + in the fleet availability snapshot. + """ + driver = DRIVER_POOL[0] + driver.hours_driven_this_period = 58.0 + update_driver_hours(driver.driver_id, 3.0) # Suspend the driver + + snapshot = get_fleet_availability_snapshot() + driver_ids = [d["driver_id"] for d in snapshot["drivers"]] + + assert driver.driver_id not in driver_ids + + def test_driver_hours_reset_after_biweekly_period(self): + """ + When 14 days have passed since hours_period_start, + hours should reset to 0 before adding new hours. + """ + driver = DRIVER_POOL[0] + driver.hours_driven_this_period = 45.0 + # Backdate period start to simulate 14 days passing + driver.hours_period_start = datetime.now() - timedelta(days=15) + + result = update_driver_hours(driver.driver_id, 5.0) + + assert result["period_reset_applied"] is True + # Hours should be 5.0 (reset to 0 then added 5.0), not 50.0 + assert result["hours_driven_this_period"] == 5.0 + + def test_manual_reset_reinstates_suspended_driver(self): + """ + Manually resetting a suspended driver's hours should + set their status back to AVAILABLE. + """ + driver = DRIVER_POOL[0] + driver.status = DriverStatus.SUSPENDED + driver.hours_driven_this_period = 62.0 + + result = reset_driver_hours_manually(driver.driver_id) + + assert result["success"] is True + assert driver.hours_driven_this_period == 0.0 + assert driver.status == DriverStatus.AVAILABLE + + def test_negative_hours_rejected(self): + """Negative hours_driven should fail with a clear error.""" + result = update_driver_hours(DRIVER_POOL[0].driver_id, -2.0) + + assert result["success"] is False + assert any("negative" in e for e in result["errors"]) + + def test_hours_update_for_unknown_driver_fails(self): + """Updating hours for a non-existent driver should fail cleanly.""" + result = update_driver_hours("D999", 5.0) + + assert result["success"] is False + assert any("not found" in e for e in result["errors"]) + + # ----------------------------------------------------------------------- + # FLEET AVAILABILITY SNAPSHOT + # ----------------------------------------------------------------------- + + def test_snapshot_only_includes_available_vehicles(self): + """Snapshot should exclude vehicles that are ON_TRIP or MAINTENANCE.""" + VEHICLE_POOL[0].current_status = VehicleStatus.ON_TRIP + VEHICLE_POOL[1].current_status = VehicleStatus.MAINTENANCE + + snapshot = get_fleet_availability_snapshot() + vehicle_ids = [v["vehicle_id"] for v in snapshot["vehicles"]] + + assert "V001" not in vehicle_ids + assert "V002" not in vehicle_ids + + def test_snapshot_only_includes_available_drivers(self): + """Snapshot should exclude drivers who are ON_TRIP or SUSPENDED.""" + DRIVER_POOL[0].status = DriverStatus.ON_TRIP + DRIVER_POOL[1].status = DriverStatus.SUSPENDED + + snapshot = get_fleet_availability_snapshot() + driver_ids = [d["driver_id"] for d in snapshot["drivers"]] + + assert "D001" not in driver_ids + assert "D002" not in driver_ids + + def test_snapshot_includes_km_since_last_service(self): + """Snapshot should show km_since_last_service for each vehicle.""" + VEHICLE_POOL[0].odometer_km = 5000.0 + VEHICLE_POOL[0].last_service_odometer_km = 2000.0 + + snapshot = get_fleet_availability_snapshot() + v001 = next( + (v for v in snapshot["vehicles"] if v["vehicle_id"] == "V001"), None + ) + assert v001 is not None + assert v001["km_since_last_service"] == 3000.0 + + def test_snapshot_counts_match_lists(self): + """vehicles_available count should match the length of the vehicles list.""" + snapshot = get_fleet_availability_snapshot() + + assert snapshot["vehicles_available"] == len(snapshot["vehicles"]) + assert snapshot["drivers_available"] == len(snapshot["drivers"]) + + +# =========================================================================== +# STAGE 8 — MAINTENANCE & WELLBEING ENGINE +# =========================================================================== + +class TestMaintenanceWellbeingEngine: + + # ----------------------------------------------------------------------- + # VEHICLE MAINTENANCE — SINGLE VEHICLE CHECK + # ----------------------------------------------------------------------- + + def test_vehicle_within_limits_returns_ok(self): + """A vehicle well within its service interval should return ok status.""" + vehicle = VEHICLE_POOL[0] + vehicle.odometer_km = 1000.0 + vehicle.last_service_odometer_km = 0.0 # 1,000km since service — well within 10,000km + + result = check_vehicle_maintenance(vehicle) + + assert result["status"] == "ok" + assert result["action_taken"] is False + assert result["km_since_service"] == 1000.0 + + def test_vehicle_at_warning_threshold_returns_warning(self): + """ + A vehicle at 8,000km since last service should return warning + status but NOT be locked. + """ + vehicle = VEHICLE_POOL[0] + vehicle.odometer_km = 8000.0 + vehicle.last_service_odometer_km = 0.0 # Exactly at warning threshold + + result = check_vehicle_maintenance(vehicle) + + assert result["status"] == "warning" + assert result["action_taken"] is False + assert vehicle.current_status == VehicleStatus.AVAILABLE # Not locked yet + + def test_vehicle_at_hard_limit_is_auto_locked(self): + """ + A vehicle at 10,000km since last service should be automatically + locked — status set to MAINTENANCE — unconditionally. + """ + vehicle = VEHICLE_POOL[0] + vehicle.odometer_km = 10000.0 + vehicle.last_service_odometer_km = 0.0 # Exactly at hard limit + + result = check_vehicle_maintenance(vehicle) + + assert result["status"] == "locked" + assert result["action_taken"] is True + assert vehicle.current_status == VehicleStatus.MAINTENANCE + + def test_vehicle_past_hard_limit_is_locked(self): + """A vehicle past 10,000km since service should also be locked.""" + vehicle = VEHICLE_POOL[0] + vehicle.odometer_km = 15000.0 + vehicle.last_service_odometer_km = 0.0 # 15,000km since service + + result = check_vehicle_maintenance(vehicle) + + assert result["status"] == "locked" + assert vehicle.current_status == VehicleStatus.MAINTENANCE + + def test_already_locked_vehicle_action_taken_is_false(self): + """ + If a vehicle is already in MAINTENANCE status, action_taken + should be False — it was already locked, nothing new happened. + """ + vehicle = VEHICLE_POOL[0] + vehicle.odometer_km = 10000.0 + vehicle.last_service_odometer_km = 0.0 + vehicle.current_status = VehicleStatus.MAINTENANCE # Already locked + + result = check_vehicle_maintenance(vehicle) + + assert result["status"] == "locked" + assert result["action_taken"] is False # No new action taken + + def test_locked_vehicle_cannot_be_allocated(self): + """ + After a vehicle is locked by the maintenance engine, + it should not be selectable by the allocation engine. + """ + vehicle = VEHICLE_POOL[0] + vehicle.odometer_km = 10000.0 + vehicle.last_service_odometer_km = 0.0 + + # Run maintenance check — should lock the vehicle + check_vehicle_maintenance(vehicle) + + # Now try to allocate a trip + trip_result = create_trip_request(make_trip_request()) + request_id = trip_result["trip"].request_id + result = process_admin_decision( + request_id, admin_id="A001", data=AdminApprovalIn(approve=True) + ) + + # The locked vehicle should not be assigned + trip = get_trip(request_id) + assert trip is not None + if result["success"]: + assert trip.assigned_vehicle_id != vehicle.vehicle_id + + def test_warning_km_until_service_is_correct(self): + """km_until_service should accurately reflect distance to hard lock.""" + vehicle = VEHICLE_POOL[0] + vehicle.odometer_km = 9000.0 + vehicle.last_service_odometer_km = 0.0 # 9,000km since service + + result = check_vehicle_maintenance(vehicle) + + assert result["status"] == "warning" + assert result["km_until_service"] == 1000.0 # 10,000 - 9,000 + + # ----------------------------------------------------------------------- + # VEHICLE MAINTENANCE — FULL FLEET CHECK + # ----------------------------------------------------------------------- + + def test_full_fleet_check_categorises_all_vehicles(self): + """ + run_vehicle_maintenance_check should categorise every vehicle + in the pool into locked, warning or ok. + """ + result = run_vehicle_maintenance_check() + + total = len(result["locked"]) + len(result["warnings"]) + len(result["ok"]) + assert total == len(VEHICLE_POOL) + + def test_full_fleet_check_locks_overdue_vehicles(self): + """ + Any vehicle at or past 10,000km since service should be + locked after a full fleet check runs. + """ + # Push two vehicles past the threshold + VEHICLE_POOL[0].odometer_km = 10500.0 + VEHICLE_POOL[0].last_service_odometer_km = 0.0 + VEHICLE_POOL[1].odometer_km = 12000.0 + VEHICLE_POOL[1].last_service_odometer_km = 0.0 + + result = run_vehicle_maintenance_check() + + assert len(result["locked"]) == 2 + assert VEHICLE_POOL[0].current_status == VehicleStatus.MAINTENANCE + assert VEHICLE_POOL[1].current_status == VehicleStatus.MAINTENANCE + + # ----------------------------------------------------------------------- + # DRIVER WELLBEING — SINGLE DRIVER CHECK + # ----------------------------------------------------------------------- + + def test_driver_within_limits_returns_ok(self): + """A driver well within hours limits should return ok status.""" + driver = DRIVER_POOL[0] + driver.hours_driven_this_period = 20.0 + + result = check_driver_wellbeing(driver) + + assert result["status"] == "ok" + assert result["action_taken"] is False + assert result["hours_remaining"] == DRIVER_HARD_LIMIT_HOURS - 20.0 + + def test_driver_at_warning_threshold_returns_warning(self): + """ + A driver at 50 hours should return warning status + but NOT be suspended. + """ + driver = DRIVER_POOL[0] + driver.hours_driven_this_period = 50.0 # Exactly at warning threshold + + result = check_driver_wellbeing(driver) + + assert result["status"] == "warning" + assert result["action_taken"] is False + assert driver.status == DriverStatus.AVAILABLE # Not suspended yet + + def test_driver_at_hard_limit_is_suspended(self): + """ + A driver at 60 hours should be automatically suspended + by the wellbeing engine. + """ + driver = DRIVER_POOL[0] + driver.hours_driven_this_period = 60.0 + + result = check_driver_wellbeing(driver) + + assert result["status"] == "suspended" + assert result["action_taken"] is True + assert driver.status == DriverStatus.SUSPENDED + + def test_already_suspended_driver_action_taken_is_false(self): + """ + If a driver is already suspended, action_taken should be False + — no new action was needed. + """ + driver = DRIVER_POOL[0] + driver.hours_driven_this_period = 65.0 + driver.status = DriverStatus.SUSPENDED # Already suspended + + result = check_driver_wellbeing(driver) + + assert result["status"] == "suspended" + assert result["action_taken"] is False + + def test_suspended_driver_cannot_be_allocated(self): + """ + After a driver is suspended by the wellbeing engine, + they should not be selectable by the allocation engine. + """ + # Suspend all but one driver + DRIVER_POOL[0].hours_driven_this_period = 60.0 + DRIVER_POOL[2].hours_driven_this_period = 60.0 + check_driver_wellbeing(DRIVER_POOL[0]) + check_driver_wellbeing(DRIVER_POOL[2]) + + trip_result = create_trip_request(make_trip_request()) + request_id = trip_result["trip"].request_id + process_admin_decision( + request_id, admin_id="A001", data=AdminApprovalIn(approve=True) + ) + + trip = get_trip(request_id) + assert trip is not None + # Only D002 should have been allocated — the others are suspended + assert trip.assigned_driver_id == "D002" + + # ----------------------------------------------------------------------- + # FULL FLEET HEALTH CHECK + # ----------------------------------------------------------------------- + + def test_fleet_ready_when_no_issues(self): + """ + fleet_ready should be True when no vehicles are locked + and no drivers are suspended. + """ + # All vehicles within limits, all drivers with low hours + for v in VEHICLE_POOL: + v.odometer_km = 500.0 + v.last_service_odometer_km = 0.0 + for d in DRIVER_POOL: + d.hours_driven_this_period = 10.0 + d.status = DriverStatus.AVAILABLE + + result = run_full_fleet_health_check() + + assert result["fleet_ready"] is True + + def test_fleet_not_ready_when_vehicle_locked(self): + """fleet_ready should be False when any vehicle is locked.""" + VEHICLE_POOL[0].odometer_km = 10000.0 + VEHICLE_POOL[0].last_service_odometer_km = 0.0 + + result = run_full_fleet_health_check() + + assert result["fleet_ready"] is False + + def test_fleet_not_ready_when_driver_suspended(self): + """fleet_ready should be False when any driver is suspended.""" + DRIVER_POOL[0].hours_driven_this_period = 60.0 + + result = run_full_fleet_health_check() + + assert result["fleet_ready"] is False + + def test_full_health_check_returns_both_vehicle_and_driver_reports(self): + """Full health check should include both vehicle and driver sections.""" + result = run_full_fleet_health_check() + + assert "vehicles" in result + assert "drivers" in result + assert result["vehicles"]["total_vehicles"] == len(VEHICLE_POOL) + assert result["drivers"]["total_drivers"] == len(DRIVER_POOL) + + # ----------------------------------------------------------------------- + # MAINTENANCE SCHEDULE + # ----------------------------------------------------------------------- + + def test_maintenance_schedule_only_includes_warning_and_locked(self): + """ + Maintenance schedule should only include vehicles at or past + the warning threshold — not ok vehicles. + """ + VEHICLE_POOL[0].odometer_km = 8500.0 # Warning zone + VEHICLE_POOL[0].last_service_odometer_km = 0.0 + VEHICLE_POOL[1].odometer_km = 500.0 # OK + VEHICLE_POOL[1].last_service_odometer_km = 0.0 + + schedule = get_maintenance_schedule() + + vehicle_ids = [s["vehicle_id"] for s in schedule] + assert "V001" in vehicle_ids + assert "V002" not in vehicle_ids + + def test_maintenance_schedule_sorted_most_overdue_first(self): + """Most overdue vehicle should appear first in the schedule.""" + VEHICLE_POOL[0].odometer_km = 9500.0 # 9,500km since service + VEHICLE_POOL[0].last_service_odometer_km = 0.0 + VEHICLE_POOL[1].odometer_km = 8500.0 # 8,500km since service + VEHICLE_POOL[1].last_service_odometer_km = 0.0 + + schedule = get_maintenance_schedule() + + assert len(schedule) >= 2 + # V001 (9,500km) should be before V002 (8,500km) + ids = [s["vehicle_id"] for s in schedule] + assert ids.index("V001") < ids.index("V002") + + def test_maintenance_schedule_marks_urgency_correctly(self): + """ + Vehicles past the hard limit should be marked 'immediate', + vehicles in warning zone should be marked 'upcoming'. + """ + VEHICLE_POOL[0].odometer_km = 11000.0 # Past hard limit + VEHICLE_POOL[0].last_service_odometer_km = 0.0 + VEHICLE_POOL[1].odometer_km = 8500.0 # Warning zone + VEHICLE_POOL[1].last_service_odometer_km = 0.0 + + schedule = get_maintenance_schedule() + + v001 = next(s for s in schedule if s["vehicle_id"] == "V001") + v002 = next(s for s in schedule if s["vehicle_id"] == "V002") + + assert v001["urgency"] == "immediate" + assert v002["urgency"] == "upcoming" + + +# =========================================================================== +# STAGE 9 — STANDBY MARKET ENGINE +# =========================================================================== + +class TestStandbyMarketEngine: + + # Maseru centre coordinates used as default pickup + PICKUP_LAT = -29.3167 + PICKUP_LON = 27.4833 + + def _get_approved_trip_id(self) -> str: + """Helper — creates and admin-approves a trip but blocks all government vehicles.""" + # Lock all vehicles so auto_allocate escalates to standby + for v in VEHICLE_POOL: + v.current_status = VehicleStatus.MAINTENANCE + + trip_result = create_trip_request(make_trip_request()) + request_id = trip_result["trip"].request_id + + # Admin approves — auto_allocate will fail on vehicles and escalate + process_admin_decision( + request_id, admin_id="A001", data=AdminApprovalIn(approve=True) + ) + return request_id + + # ----------------------------------------------------------------------- + # ESCALATION & PROVIDER SELECTION + # ----------------------------------------------------------------------- + + def test_escalate_assigns_best_provider(self): + """ + escalate_to_standby should rank all providers and assign + the highest-scoring one automatically. + """ + trip_result = create_trip_request(make_trip_request()) + request_id = trip_result["trip"].request_id + process_admin_decision( + request_id, admin_id="A001", data=AdminApprovalIn(approve=True) + ) + + # Manually escalate (bypassing auto_allocate for isolation) + # First set trip back to APPROVED since auto_allocate may have handled it + trip = get_trip(request_id) + assert trip is not None + if trip.status == TripStatus.ALLOCATED: + # Government vehicles were available — force escalation manually + trip.status = TripStatus.APPROVED + trip.assigned_vehicle_id = None + trip.assigned_driver_id = None + + result = escalate_to_standby(request_id, self.PICKUP_LAT, self.PICKUP_LON) + + assert result["success"] is True + assert result["status"] == TripStatus.ALLOCATED + assert result["provider"] is not None + assert result["confirmation_code"] is not None + + def test_no_available_providers_fails_gracefully(self): + """When all providers are busy, escalation should fail with a clear error.""" + for p in STANDBY_PROVIDERS: + p.is_available = False + + trip_result = create_trip_request(make_trip_request()) + request_id = trip_result["trip"].request_id + process_admin_decision( + request_id, admin_id="A001", data=AdminApprovalIn(approve=True) + ) + + trip = get_trip(request_id) + assert trip is not None + trip.status = TripStatus.APPROVED + + result = escalate_to_standby(request_id, self.PICKUP_LAT, self.PICKUP_LON) + + assert result["success"] is False + assert any("No standby providers" in e for e in result["errors"]) + + def test_provider_locked_after_assignment(self): + """After assignment, the provider should be marked unavailable.""" + trip_result = create_trip_request(make_trip_request()) + request_id = trip_result["trip"].request_id + process_admin_decision( + request_id, admin_id="A001", data=AdminApprovalIn(approve=True) + ) + + trip = get_trip(request_id) + assert trip is not None + trip.status = TripStatus.APPROVED + + result = escalate_to_standby(request_id, self.PICKUP_LAT, self.PICKUP_LON) + assert result["success"] is True + + assigned_id = result["provider"]["provider_id"] + provider = next(p for p in STANDBY_PROVIDERS if p.provider_id == assigned_id) + assert provider.is_available is False + assert provider.current_trip_id == request_id + + def test_vehicle_type_match_filters_wrong_type(self): + """ + A provider whose vehicle type doesn't match the terrain hint + should score 0.0 on type match and not be selected when + better-matched providers are available. + """ + trip_result = create_trip_request(make_trip_request()) + request_id = trip_result["trip"].request_id + + trip = get_trip(request_id) + assert trip is not None + trip.status = TripStatus.APPROVED + trip.vehicle_hint = "4x4_required" # Only 4x4 acceptable + + # Make only SP002 (4x4) available + for p in STANDBY_PROVIDERS: + p.is_available = p.provider_id == "SP002" + + result = escalate_to_standby(request_id, self.PICKUP_LAT, self.PICKUP_LON) + + assert result["success"] is True + assert result["provider"]["provider_id"] == "SP002" + + def test_provider_with_higher_reliability_wins_when_equidistant(self): + """ + When two providers have the same vehicle type and distance, + the one with higher reliability should be selected. + """ + trip_result = create_trip_request(make_trip_request()) + request_id = trip_result["trip"].request_id + + trip = get_trip(request_id) + assert trip is not None + trip.status = TripStatus.APPROVED + + # Give two providers identical location and vehicle type + STANDBY_PROVIDERS[0].latitude = self.PICKUP_LAT + STANDBY_PROVIDERS[0].longitude = self.PICKUP_LON + STANDBY_PROVIDERS[0].vehicle_type = VehicleType.SEDAN + STANDBY_PROVIDERS[0].reliability_score = 0.95 + + STANDBY_PROVIDERS[1].latitude = self.PICKUP_LAT + STANDBY_PROVIDERS[1].longitude = self.PICKUP_LON + STANDBY_PROVIDERS[1].vehicle_type = VehicleType.SEDAN + STANDBY_PROVIDERS[1].reliability_score = 0.60 + + # Only these two available + STANDBY_PROVIDERS[2].is_available = False + STANDBY_PROVIDERS[3].is_available = False + + result = escalate_to_standby(request_id, self.PICKUP_LAT, self.PICKUP_LON) + + assert result["success"] is True + assert result["provider"]["provider_id"] == STANDBY_PROVIDERS[0].provider_id + + def test_confirmation_code_is_six_digits(self): + """Confirmation code must always be exactly 6 digits.""" + trip_result = create_trip_request(make_trip_request()) + request_id = trip_result["trip"].request_id + process_admin_decision( + request_id, admin_id="A001", data=AdminApprovalIn(approve=True) + ) + + trip = get_trip(request_id) + assert trip is not None + trip.status = TripStatus.APPROVED + + result = escalate_to_standby(request_id, self.PICKUP_LAT, self.PICKUP_LON) + assert result["success"] is True + + code = result["confirmation_code"] + assert len(code) == 6 + assert code.isdigit() + + # ----------------------------------------------------------------------- + # AUTO-ESCALATION FROM ALLOCATION ENGINE + # ----------------------------------------------------------------------- + + def test_auto_allocate_escalates_when_no_government_vehicle(self): + """ + When all government vehicles are unavailable, auto_allocate + should automatically escalate to the standby market and + return a successful allocation via a provider. + """ + for v in VEHICLE_POOL: + v.current_status = VehicleStatus.MAINTENANCE + + trip_result = create_trip_request(make_trip_request()) + request_id = trip_result["trip"].request_id + + result = process_admin_decision( + request_id, admin_id="A001", data=AdminApprovalIn(approve=True) + ) + + assert result["success"] is True + assert result["status"] == TripStatus.ALLOCATED + # provider_id is the actual provider ID from STANDBY_PROVIDERS + assert result["provider"]["provider_id"] in [p.provider_id for p in STANDBY_PROVIDERS] + + def test_auto_allocate_fails_when_no_government_vehicle_and_no_provider(self): + """ + When both government vehicles and standby providers are unavailable, + the allocation should fail with a clear error. + """ + for v in VEHICLE_POOL: + v.current_status = VehicleStatus.MAINTENANCE + for p in STANDBY_PROVIDERS: + p.is_available = False + + trip_result = create_trip_request(make_trip_request()) + request_id = trip_result["trip"].request_id + + result = process_admin_decision( + request_id, admin_id="A001", data=AdminApprovalIn(approve=True) + ) + + assert result["success"] is False + assert result["escalate_to_standby"] is True + + # ----------------------------------------------------------------------- + # CONFIRMATION CODE AUTHENTICATION + # ----------------------------------------------------------------------- + + def test_provider_driver_can_authenticate_with_correct_code(self): + """Provider driver entering the correct code starts the trip.""" + trip_result = create_trip_request(make_trip_request()) + request_id = trip_result["trip"].request_id + process_admin_decision( + request_id, admin_id="A001", data=AdminApprovalIn(approve=True) + ) + + trip = get_trip(request_id) + assert trip is not None + trip.status = TripStatus.APPROVED + + escalate_result = escalate_to_standby( + request_id, self.PICKUP_LAT, self.PICKUP_LON + ) + assert escalate_result["success"] is True + + code = escalate_result["confirmation_code"] + auth_result = authenticate_standby_trip(request_id, code) + + assert auth_result["success"] is True + assert auth_result["status"] == TripStatus.ONGOING + + trip = get_trip(request_id) + assert trip is not None + assert trip.status == TripStatus.ONGOING + assert trip.started_at is not None + + def test_wrong_confirmation_code_is_rejected(self): + """Provider driver entering the wrong code cannot start the trip.""" + trip_result = create_trip_request(make_trip_request()) + request_id = trip_result["trip"].request_id + process_admin_decision( + request_id, admin_id="A001", data=AdminApprovalIn(approve=True) + ) + + trip = get_trip(request_id) + assert trip is not None + trip.status = TripStatus.APPROVED + + escalate_to_standby(request_id, self.PICKUP_LAT, self.PICKUP_LON) + result = authenticate_standby_trip(request_id, "000000") + + assert result["success"] is False + assert any("Invalid" in e for e in result["errors"]) + + def test_expired_confirmation_code_is_rejected(self): + """An expired confirmation code cannot be used to start a trip.""" + trip_result = create_trip_request(make_trip_request()) + request_id = trip_result["trip"].request_id + process_admin_decision( + request_id, admin_id="A001", data=AdminApprovalIn(approve=True) + ) + + trip = get_trip(request_id) + assert trip is not None + trip.status = TripStatus.APPROVED + + escalate_result = escalate_to_standby( + request_id, self.PICKUP_LAT, self.PICKUP_LON + ) + code_value = escalate_result["confirmation_code"] + + # Expire the code + active_code = next( + c for c in STANDBY_CODES + if c.trip_id == request_id and not c.is_used + ) + active_code.expires_at = datetime.now() - timedelta(hours=1) + + result = authenticate_standby_trip(request_id, code_value) + + assert result["success"] is False + assert any("expired" in e for e in result["errors"]) + + # ----------------------------------------------------------------------- + # PROVIDER RELEASE & RANKINGS + # ----------------------------------------------------------------------- + + def test_provider_released_after_trip_completes(self): + """Provider should be available again after their trip is released.""" + trip_result = create_trip_request(make_trip_request()) + request_id = trip_result["trip"].request_id + process_admin_decision( + request_id, admin_id="A001", data=AdminApprovalIn(approve=True) + ) + + trip = get_trip(request_id) + assert trip is not None + trip.status = TripStatus.APPROVED + + result = escalate_to_standby(request_id, self.PICKUP_LAT, self.PICKUP_LON) + assert result["success"] is True + + assigned_id = result["provider"]["provider_id"] + provider = next(p for p in STANDBY_PROVIDERS if p.provider_id == assigned_id) + assert provider.is_available is False + + # Release the provider + release_standby_provider(request_id) + + assert provider.is_available is True + assert provider.current_trip_id is None + + def test_get_provider_rankings_returns_scored_list(self): + """get_provider_rankings should return all available providers ranked by score.""" + trip_result = create_trip_request(make_trip_request()) + request_id = trip_result["trip"].request_id + + trip = get_trip(request_id) + assert trip is not None + trip.status = TripStatus.APPROVED + + result = get_provider_rankings(request_id, self.PICKUP_LAT, self.PICKUP_LON) + + assert result["success"] is True + assert len(result["ranked_providers"]) > 0 + + # Scores should be in descending order + scores = [p["score"] for p in result["ranked_providers"]] + assert scores == sorted(scores, reverse=True) + + def test_provider_capacity_filters_out_insufficient_vehicles(self): + """Providers without enough capacity should not appear in rankings.""" + trip_result = create_trip_request(make_trip_request(passengers=10)) + request_id = trip_result["trip"].request_id + + trip = get_trip(request_id) + assert trip is not None + trip.status = TripStatus.APPROVED + + result = get_provider_rankings(request_id, self.PICKUP_LAT, self.PICKUP_LON) + + assert result["success"] is True + # Only providers with capacity >= 10 should appear + for p in result["ranked_providers"]: + provider = next( + sp for sp in STANDBY_PROVIDERS + if sp.provider_id == p["provider_id"] + ) + assert provider.capacity >= 10 + + +# =========================================================================== +# STAGE 10 — DYNAMIC REALLOCATION ENGINE +# =========================================================================== + +class TestDynamicReallocationEngine: + + def _get_allocated_trip_id(self) -> str: + """Helper — creates an ALLOCATED trip, returns request_id.""" + trip_result = create_trip_request(make_trip_request()) + request_id = trip_result["trip"].request_id + process_admin_decision( + request_id, admin_id="A001", data=AdminApprovalIn(approve=True) + ) + return request_id + + def _get_allocated_trip_id_urban(self) -> str: + """Helper — creates an ALLOCATED trip to a flat urban destination.""" + trip_result = create_trip_request(make_trip_request(destination="Maseru Central Office")) + request_id = trip_result["trip"].request_id + process_admin_decision( + request_id, admin_id="A001", data=AdminApprovalIn(approve=True) + ) + return request_id + + def _get_ongoing_trip_id(self) -> str: + """Helper — creates an ONGOING trip, returns request_id.""" + request_id = self._get_allocated_trip_id_urban() + trip = get_trip(request_id) + assert trip is not None + token = next((t for t in TOKENS if t.token_id == trip.token_id), None) + assert token is not None + start_trip(request_id, token.token_value) + return request_id + + # ----------------------------------------------------------------------- + # VEHICLE BREAKDOWN + # ----------------------------------------------------------------------- + + def test_vehicle_breakdown_assigns_replacement(self): + """ + When a vehicle breaks down, the system should find a replacement + vehicle and keep the same driver. + """ + request_id = self._get_ongoing_trip_id() + trip = get_trip(request_id) + assert trip is not None + + original_driver_id = trip.assigned_driver_id + original_vehicle_id = trip.assigned_vehicle_id + + result = handle_disruption(request_id, DisruptionType.VEHICLE_BREAKDOWN) + + assert result["success"] is True + assert result["disruption"] == DisruptionType.VEHICLE_BREAKDOWN + + trip = get_trip(request_id) + assert trip is not None + # New vehicle assigned + assert trip.assigned_vehicle_id != original_vehicle_id + # Same driver kept + assert trip.assigned_driver_id == original_driver_id + + def test_broken_vehicle_sent_to_maintenance(self): + """After a breakdown, the original vehicle should be MAINTENANCE.""" + request_id = self._get_ongoing_trip_id() + trip = get_trip(request_id) + assert trip is not None + broken_vehicle_id = trip.assigned_vehicle_id + + handle_disruption(request_id, DisruptionType.VEHICLE_BREAKDOWN) + + broken_vehicle = next( + v for v in VEHICLE_POOL if v.vehicle_id == broken_vehicle_id + ) + assert broken_vehicle.current_status == VehicleStatus.MAINTENANCE + + def test_breakdown_escalates_to_standby_when_no_government_vehicle(self): + """ + When no replacement government vehicle is available after breakdown, + the system should escalate to the standby market. + """ + request_id = self._get_ongoing_trip_id() + trip = get_trip(request_id) + assert trip is not None + + # Lock all other vehicles + for v in VEHICLE_POOL: + if v.vehicle_id != trip.assigned_vehicle_id: + v.current_status = VehicleStatus.MAINTENANCE + + result = handle_disruption(request_id, DisruptionType.VEHICLE_BREAKDOWN) + + # Should escalate to standby + assert result["success"] is True + assert result["status"] == TripStatus.ALLOCATED + + # ----------------------------------------------------------------------- + # DRIVER EMERGENCY + # ----------------------------------------------------------------------- + + def test_driver_emergency_assigns_replacement_driver(self): + """ + When a driver has an emergency, the system should find a + replacement driver and keep the same vehicle. + """ + request_id = self._get_ongoing_trip_id() + trip = get_trip(request_id) + assert trip is not None + + original_vehicle_id = trip.assigned_vehicle_id + original_driver_id = trip.assigned_driver_id + + result = handle_disruption(request_id, DisruptionType.DRIVER_EMERGENCY) + + assert result["success"] is True + assert result["disruption"] == DisruptionType.DRIVER_EMERGENCY + + trip = get_trip(request_id) + assert trip is not None + # Same vehicle kept + assert trip.assigned_vehicle_id == original_vehicle_id + # New driver assigned + assert trip.assigned_driver_id != original_driver_id + + def test_emergency_driver_set_to_off_duty(self): + """After a driver emergency, the original driver should be OFF_DUTY.""" + request_id = self._get_ongoing_trip_id() + trip = get_trip(request_id) + assert trip is not None + emergency_driver_id = trip.assigned_driver_id + + handle_disruption(request_id, DisruptionType.DRIVER_EMERGENCY) + + emergency_driver = next( + d for d in DRIVER_POOL if d.driver_id == emergency_driver_id + ) + assert emergency_driver.status == DriverStatus.OFF_DUTY + + def test_driver_emergency_fails_when_no_replacement_available(self): + """ + When no replacement driver is available, the disruption + should fail with a clear error. + """ + request_id = self._get_ongoing_trip_id() + trip = get_trip(request_id) + assert trip is not None + + # Make all other drivers unavailable + for d in DRIVER_POOL: + if d.driver_id != trip.assigned_driver_id: + d.status = DriverStatus.ON_TRIP + + result = handle_disruption(request_id, DisruptionType.DRIVER_EMERGENCY) + + assert result["success"] is False + assert any("No available driver" in e for e in result["errors"]) + + def test_driver_emergency_issues_new_token(self): + """A new token should be issued for the replacement driver.""" + request_id = self._get_ongoing_trip_id() + trip = get_trip(request_id) + assert trip is not None + old_token_id = trip.token_id + + result = handle_disruption(request_id, DisruptionType.DRIVER_EMERGENCY) + + assert result["success"] is True + assert result["new_token_id"] is not None + assert result["new_token_id"] != old_token_id + + # ----------------------------------------------------------------------- + # TRIP CANCELLATION + # ----------------------------------------------------------------------- + + def test_cancellation_releases_vehicle_and_driver(self): + """ + Cancelling an allocated trip should release both vehicle + and driver back to AVAILABLE. + """ + request_id = self._get_allocated_trip_id() + trip = get_trip(request_id) + assert trip is not None + + vehicle_id = trip.assigned_vehicle_id + driver_id = trip.assigned_driver_id + + result = handle_disruption(request_id, DisruptionType.TRIP_CANCELLATION) + + assert result["success"] is True + + trip = get_trip(request_id) + assert trip is not None + assert trip.status == TripStatus.CANCELLED + + vehicle = next(v for v in VEHICLE_POOL if v.vehicle_id == vehicle_id) + driver = next(d for d in DRIVER_POOL if d.driver_id == driver_id) + assert vehicle.current_status == VehicleStatus.AVAILABLE + assert driver.status == DriverStatus.AVAILABLE + + def test_cannot_cancel_ongoing_trip(self): + """An ONGOING trip cannot be cancelled — driver is already on the road.""" + request_id = self._get_ongoing_trip_id() + + result = handle_disruption(request_id, DisruptionType.TRIP_CANCELLATION) + + assert result["success"] is False + assert any("cannot be cancelled" in e for e in result["errors"]) + + # ----------------------------------------------------------------------- + # EMPLOYEE NO-SHOW + # ----------------------------------------------------------------------- + + def test_no_show_cancels_trip_and_releases_resources(self): + """Employee no-show should cancel the trip and free vehicle/driver.""" + request_id = self._get_allocated_trip_id() + trip = get_trip(request_id) + assert trip is not None + + vehicle_id = trip.assigned_vehicle_id + driver_id = trip.assigned_driver_id + + result = handle_disruption(request_id, DisruptionType.EMPLOYEE_NO_SHOW) + + assert result["success"] is True + + trip = get_trip(request_id) + assert trip is not None + assert trip.status == TripStatus.CANCELLED + + vehicle = next(v for v in VEHICLE_POOL if v.vehicle_id == vehicle_id) + driver = next(d for d in DRIVER_POOL if d.driver_id == driver_id) + assert vehicle.current_status == VehicleStatus.AVAILABLE + assert driver.status == DriverStatus.AVAILABLE + + def test_no_show_only_works_on_allocated_trip(self): + """No-show can only be recorded for an ALLOCATED trip.""" + trip_result = create_trip_request(make_trip_request()) + request_id = trip_result["trip"].request_id # PENDING + + result = handle_disruption(request_id, DisruptionType.EMPLOYEE_NO_SHOW) + + assert result["success"] is False + assert any("no-show" in e for e in result["errors"]) + + # ----------------------------------------------------------------------- + # ROUTE BLOCKED + # ----------------------------------------------------------------------- + + def test_route_blocked_upgrades_terrain_hint(self): + """ + When a route is blocked, the terrain hint should be upgraded + to require a more capable vehicle type. + """ + request_id = self._get_ongoing_trip_id() + trip = get_trip(request_id) + assert trip is not None + trip.vehicle_hint = "sedan_ok" # Start with basic terrain hint + + result = handle_disruption(request_id, DisruptionType.ROUTE_BLOCKED) + + assert result["success"] is True + # Terrain hint should have been upgraded + trip = get_trip(request_id) + assert trip is not None + assert trip.vehicle_hint == "suv_preferred" + + def test_route_blocked_reallocates_both_vehicle_and_driver(self): + """Route blockage should fully reallocate — new vehicle and driver.""" + request_id = self._get_ongoing_trip_id() + trip = get_trip(request_id) + assert trip is not None + + original_vehicle_id = trip.assigned_vehicle_id + original_driver_id = trip.assigned_driver_id + + result = handle_disruption(request_id, DisruptionType.ROUTE_BLOCKED) + + assert result["success"] is True + assert result["replacement_vehicle"] is not None + assert result["replacement_driver"] is not None + + def test_route_blocked_hint_upgrades_incrementally(self): + """ + Each route blockage should upgrade the terrain hint one step: + sedan_ok -> suv_preferred -> 4x4_required + """ + request_id = self._get_ongoing_trip_id() + trip = get_trip(request_id) + assert trip is not None + trip.vehicle_hint = "suv_preferred" # Already at intermediate hint + + handle_disruption(request_id, DisruptionType.ROUTE_BLOCKED) + + trip = get_trip(request_id) + assert trip is not None + assert trip.vehicle_hint == "4x4_required" + + # ----------------------------------------------------------------------- + # VEHICLE RECALLED + # ----------------------------------------------------------------------- + + def test_vehicle_recalled_assigns_replacement(self): + """Fleet Manager recall should assign a replacement vehicle.""" + request_id = self._get_allocated_trip_id() + trip = get_trip(request_id) + assert trip is not None + original_vehicle_id = trip.assigned_vehicle_id + + result = handle_disruption(request_id, DisruptionType.VEHICLE_RECALLED) + + assert result["success"] is True + trip = get_trip(request_id) + assert trip is not None + assert trip.assigned_vehicle_id != original_vehicle_id + + def test_recalled_vehicle_freed_back_to_available(self): + """The recalled vehicle should be available for other trips.""" + request_id = self._get_allocated_trip_id() + trip = get_trip(request_id) + assert trip is not None + recalled_vehicle_id = trip.assigned_vehicle_id + + handle_disruption(request_id, DisruptionType.VEHICLE_RECALLED) + + recalled_vehicle = next( + v for v in VEHICLE_POOL if v.vehicle_id == recalled_vehicle_id + ) + assert recalled_vehicle.current_status == VehicleStatus.AVAILABLE + + # ----------------------------------------------------------------------- + # OPTIMIZATION INTEGRATION + # ----------------------------------------------------------------------- + + def test_disruption_result_includes_optimization_section(self): + """ + Every disruption result should include an optimization section + showing whether the broader pending pool was improved. + """ + request_id = self._get_allocated_trip_id() + + result = handle_disruption(request_id, DisruptionType.TRIP_CANCELLATION) + + assert result["success"] is True + assert "optimization" in result + assert "applied" in result["optimization"] + assert "old_score" in result["optimization"] + assert "new_score" in result["optimization"] + + def test_unknown_disruption_type_fails_cleanly(self): + """An unrecognised disruption type should return a clear error.""" + request_id = self._get_allocated_trip_id() + + result = handle_disruption(request_id, "alien_invasion") # type: ignore + + assert result["success"] is False + assert any("Unknown disruption" in e for e in result["errors"]) + + def test_disruption_on_nonexistent_trip_fails(self): + """Disruption on a trip that doesn't exist should fail cleanly.""" + result = handle_disruption("TR-DOESNOTEXIST", DisruptionType.VEHICLE_BREAKDOWN) + + assert result["success"] is False + assert any("not found" in e for e in result["errors"]) + + +# =========================================================================== +# STAGE 11 — PRIORITY & POLICY ENGINE +# =========================================================================== + +class TestPriorityPolicyEngine: + + def _create_pending_trip(self, user_id: str = "U001") -> str: + """Helper — creates a PENDING trip and returns its request_id.""" + result = create_trip_request(make_trip_request(user_id=user_id)) + return result["trip"].request_id + + # ----------------------------------------------------------------------- + # STANDARD PRIORITY SCORING (NO ELEVATION) + # ----------------------------------------------------------------------- + + def test_standard_score_uses_tier_and_ministry_multiplier(self): + """ + Standard score = base_tier_score * ministry_multiplier. + U001 is Tier B (0.7) in ministry M001 (Critical, 1.5). + Expected: 0.7 * 1.5 = 1.05 + """ + request_id = self._create_pending_trip(user_id="U001") + + result = compute_priority_score(request_id, admin_id="A001") + + assert result["success"] is True + assert result["tier"] == PriorityTier.B + assert result["ministry_tier"] == MinistryTier.CRITICAL + assert result["priority_score"] == pytest.approx(1.05, abs=0.01) + assert result["elevation_applied"] is False + + def test_tier_a_scores_higher_than_tier_c(self): + """ + Senior officials (Tier A) should always score higher than + junior staff (Tier C) in the same ministry. + """ + # U003 is Tier A, U002 is Tier C + request_a = self._create_pending_trip(user_id="U003") + request_c = self._create_pending_trip(user_id="U002") + + result_a = compute_priority_score(request_a, admin_id="A001") + result_c = compute_priority_score(request_c, admin_id="A002") + + assert result_a["priority_score"] > result_c["priority_score"] + + def test_critical_ministry_scores_higher_than_administrative(self): + """ + Same tier employee in a Critical ministry should score higher + than one in an Administrative ministry. + """ + # Give both employees the same tier for isolation + EMPLOYEE_TIER_MAP["U001"] = PriorityTier.B + EMPLOYEE_TIER_MAP["U002"] = PriorityTier.B + + # M001 = Critical, M002 = High — use M001 vs M004 (Administrative) + # Map U002 to M004 temporarily + from fleet_management.engines.trip_request_processor import EMPLOYEES + emp = next(e for e in EMPLOYEES if e.user_id == "U002") + original_ministry = emp.ministry_id + emp.ministry_id = "M004" # Administrative ministry + + request_critical = self._create_pending_trip(user_id="U001") # M001 Critical + request_admin = self._create_pending_trip(user_id="U002") # M004 Administrative + + result_critical = compute_priority_score(request_critical, admin_id="A001") + result_admin = compute_priority_score(request_admin, admin_id="A002") + + # Restore + emp.ministry_id = original_ministry + + assert result_critical["priority_score"] > result_admin["priority_score"] + + def test_priority_score_stored_on_trip(self): + """Priority score should be written onto the TripRequest object.""" + request_id = self._create_pending_trip() + + result = compute_priority_score(request_id, admin_id="A001") + + trip = get_trip(request_id) + assert trip is not None + assert trip.priority_score == result["priority_score"] + + def test_unknown_employee_defaults_to_tier_c(self): + """An employee not in the tier map should default to Tier C (lowest).""" + # Temporarily add an unknown user + from fleet_management.engines.trip_request_processor import EMPLOYEES + from fleet_management.models import User, UserRole + unknown_user = User( + user_id="U999", name="Unknown User", + role=UserRole.EMPLOYEE, ministry_id="M001" + ) + EMPLOYEES.append(unknown_user) + + result_unknown = create_trip_request( + make_trip_request(user_id="U999") + ) + request_id = result_unknown["trip"].request_id + result = compute_priority_score(request_id, admin_id="A001") + + EMPLOYEES.pop() # Clean up + + assert result["tier"] == PriorityTier.C + + # ----------------------------------------------------------------------- + # ELEVATION — QUOTA AVAILABLE + # ----------------------------------------------------------------------- + + def test_elevation_increases_priority_score(self): + """ + Elevation should add ELEVATION_DELTA (0.3) to the standard score. + U001: standard = 0.7 * 1.5 = 1.05, elevated = 1.05 + 0.3 = 1.35 + """ + request_id = self._create_pending_trip(user_id="U001") + + result = compute_priority_score( + request_id, admin_id="A001", elevation_requested=True + ) + + assert result["success"] is True + assert result["elevation_applied"] is True + assert result["priority_score"] == pytest.approx(1.35, abs=0.01) + assert result["requires_director_signature"] is False + + def test_elevation_deducts_token_from_admin_quota(self): + """Each elevation should consume one token from the admin's quota.""" + request_id = self._create_pending_trip() + + result = compute_priority_score( + request_id, admin_id="A001", elevation_requested=True + ) + + assert result["tokens_remaining"] == ELEVATION_QUOTA - 1 + + def test_admin_can_use_all_three_tokens(self): + """Admin should be able to elevate exactly 3 trips per biweekly period.""" + for _ in range(ELEVATION_QUOTA): + request_id = self._create_pending_trip() + result = compute_priority_score( + request_id, admin_id="A001", elevation_requested=True + ) + assert result["elevation_applied"] is True + + status = get_admin_elevation_status("A001") + assert status["tokens_remaining"] == 0 + + # ----------------------------------------------------------------------- + # ELEVATION — QUOTA EXHAUSTED + # ----------------------------------------------------------------------- + + def test_elevation_flagged_for_director_when_quota_exhausted(self): + """ + When admin has used all 3 tokens, the next elevation request + should be flagged for Director digital signature. + """ + # Exhaust all tokens + for _ in range(ELEVATION_QUOTA): + request_id = self._create_pending_trip() + compute_priority_score( + request_id, admin_id="A001", elevation_requested=True + ) + + # 4th elevation attempt + request_id = self._create_pending_trip() + result = compute_priority_score( + request_id, admin_id="A001", elevation_requested=True + ) + + assert result["elevation_applied"] is False + assert result["requires_director_signature"] is True + assert result["tokens_remaining"] == 0 + assert result["director_id"] == ADMIN_DIRECTOR_MAP["A001"] + + def test_standard_score_applied_when_quota_exhausted(self): + """ + When quota is exhausted and Director hasn't signed, + the standard score (no delta) should be applied. + """ + # Exhaust tokens + for _ in range(ELEVATION_QUOTA): + r = self._create_pending_trip() + compute_priority_score(r, admin_id="A001", elevation_requested=True) + + # 4th attempt — standard score should apply + request_id = self._create_pending_trip(user_id="U001") + result = compute_priority_score( + request_id, admin_id="A001", elevation_requested=True + ) + + # Score should be standard (no delta) — 0.7 * 1.5 = 1.05 + assert result["priority_score"] == pytest.approx(1.05, abs=0.01) + + def test_quota_resets_after_biweekly_period(self): + """ + After 14 days, the admin's quota should reset and + elevations should be available again. + """ + # Exhaust tokens + for _ in range(ELEVATION_QUOTA): + r = self._create_pending_trip() + compute_priority_score(r, admin_id="A001", elevation_requested=True) + + # Simulate 14 days passing by backdating period_start + from fleet_management.engines.priority_policy_engine import _elevation_records + _elevation_records["A001"].period_start = datetime.now() - timedelta(days=15) + + # Should work again after reset + request_id = self._create_pending_trip() + result = compute_priority_score( + request_id, admin_id="A001", elevation_requested=True + ) + + assert result["elevation_applied"] is True + assert result["tokens_remaining"] == ELEVATION_QUOTA - 1 + + # ----------------------------------------------------------------------- + # DIRECTOR APPROVAL + # ----------------------------------------------------------------------- + + def test_director_can_approve_elevation_when_quota_exhausted(self): + """ + Director should be able to grant elevation for a specific trip + even when the admin's quota is exhausted. + """ + # Exhaust admin tokens + for _ in range(ELEVATION_QUOTA): + r = self._create_pending_trip() + compute_priority_score(r, admin_id="A001", elevation_requested=True) + + request_id = self._create_pending_trip(user_id="U001") + # Standard score first + compute_priority_score(request_id, admin_id="A001") + standard_score = get_trip(request_id) + assert standard_score is not None + base_score = standard_score.priority_score + + # Director approves elevation + result = approve_director_elevation( + request_id, + director_id=ADMIN_DIRECTOR_MAP["A001"], + admin_id="A001", + ) + + assert result["success"] is True + trip = get_trip(request_id) + assert trip is not None + assert trip.priority_score is not None + assert base_score is not None + # Score should now include elevation delta + assert trip.priority_score > base_score + + def test_wrong_director_cannot_approve(self): + """ + A Director from a different ministry cannot sign for + another ministry's admin. + """ + request_id = self._create_pending_trip() + + result = approve_director_elevation( + request_id, + director_id="DIR002", # Wrong director for A001 + admin_id="A001", + ) + + assert result["success"] is False + assert any("not authorised" in e for e in result["errors"]) + + def test_director_approval_does_not_restore_admin_quota(self): + """ + Director approval is a one-time exception — it should NOT + increase or reset the admin's token quota. + """ + # Exhaust tokens + for _ in range(ELEVATION_QUOTA): + r = self._create_pending_trip() + compute_priority_score(r, admin_id="A001", elevation_requested=True) + + request_id = self._create_pending_trip() + approve_director_elevation( + request_id, + director_id=ADMIN_DIRECTOR_MAP["A001"], + admin_id="A001", + ) + + # Quota should still be 0 + status = get_admin_elevation_status("A001") + assert status["tokens_remaining"] == 0 + + # ----------------------------------------------------------------------- + # ELEVATION STATUS + # ----------------------------------------------------------------------- + + def test_elevation_status_shows_correct_remaining_tokens(self): + """get_admin_elevation_status should reflect current token usage.""" + request_id = self._create_pending_trip() + compute_priority_score( + request_id, admin_id="A001", elevation_requested=True + ) + + status = get_admin_elevation_status("A001") + + assert status["tokens_used"] == 1 + assert status["tokens_remaining"] == ELEVATION_QUOTA - 1 + assert status["quota"] == ELEVATION_QUOTA + + def test_elevation_status_for_new_admin_shows_full_quota(self): + """A brand new admin should have full quota available.""" + status = get_admin_elevation_status("A001") + + assert status["tokens_remaining"] == ELEVATION_QUOTA + assert status["tokens_used"] == 0 + + def test_score_on_nonexistent_trip_fails(self): + """Computing priority for a non-existent trip should fail cleanly.""" + result = compute_priority_score("TR-DOESNOTEXIST", admin_id="A001") + + assert result["success"] is False + assert any("not found" in e for e in result["errors"]) + + +# =========================================================================== +# STAGE 12 — VEHICLE SUITABILITY MODULE +# =========================================================================== + +class TestVehicleSuitabilityModule: + + def _create_pending_trip(self) -> str: + """Helper — creates a PENDING trip and returns its request_id.""" + result = create_trip_request(make_trip_request()) + return result["trip"].request_id + + # ----------------------------------------------------------------------- + # THRESHOLD BOUNDARY TESTS + # These are the most important tests — bugs live at boundaries + # ----------------------------------------------------------------------- + + def test_score_zero_maps_to_sedan_ok(self): + """Score 0.0 — flat urban terrain — should map to sedan_ok.""" + result = compute_vehicle_suitability(0.0) + assert result["vehicle_hint"] == "sedan_ok" + + def test_score_below_threshold_maps_to_sedan_ok(self): + """Score 0.29 — just below light off-road threshold — still sedan_ok.""" + result = compute_vehicle_suitability(0.29) + assert result["vehicle_hint"] == "sedan_ok" + + def test_score_at_suv_threshold_maps_to_suv_preferred(self): + """Score 0.30 — exactly at light off-road threshold — suv_preferred.""" + result = compute_vehicle_suitability(0.30) + assert result["vehicle_hint"] == "suv_preferred" + + def test_score_mid_suv_band_maps_to_suv_preferred(self): + """Score 0.45 — middle of light off-road band.""" + result = compute_vehicle_suitability(0.45) + assert result["vehicle_hint"] == "suv_preferred" + + def test_score_at_4x4_threshold_maps_to_4x4_required(self): + """Score 0.55 — exactly at serious off-road threshold — 4x4_required.""" + result = compute_vehicle_suitability(0.55) + assert result["vehicle_hint"] == "4x4_required" + + def test_score_mid_4x4_band_maps_to_4x4_required(self): + """Score 0.72 — Thaba-Tseka district level terrain — 4x4_required.""" + result = compute_vehicle_suitability(0.72) + assert result["vehicle_hint"] == "4x4_required" + + def test_score_at_specialist_threshold_maps_to_specialist(self): + """Score 0.80 — exactly at extreme terrain threshold — specialist_required.""" + result = compute_vehicle_suitability(0.80) + assert result["vehicle_hint"] == "specialist_required" + + def test_score_one_maps_to_specialist(self): + """Score 1.0 — maximum difficulty — specialist_required.""" + result = compute_vehicle_suitability(1.0) + assert result["vehicle_hint"] == "specialist_required" + + def test_score_clamped_above_one(self): + """Scores above 1.0 should be clamped to 1.0.""" + result = compute_vehicle_suitability(1.5) + assert result["terrain_difficulty_score"] == 1.0 + assert result["vehicle_hint"] == "specialist_required" + + def test_score_clamped_below_zero(self): + """Scores below 0.0 should be clamped to 0.0.""" + result = compute_vehicle_suitability(-0.5) + assert result["terrain_difficulty_score"] == 0.0 + assert result["vehicle_hint"] == "sedan_ok" + + # ----------------------------------------------------------------------- + # SUITABILITY SCORE ORDERING + # More capable vehicle must never score lower than less capable + # on the same terrain — UN Vehicle Management Manual (2021) + # ----------------------------------------------------------------------- + + def test_4x4_scores_higher_than_sedan_on_serious_terrain(self): + """On 4x4_required terrain, 4x4 must score higher than sedan.""" + result = compute_vehicle_suitability(0.72) + scores = result["suitability_scores"] + assert scores[VehicleType.FOUR_BY_FOUR.value] > scores[VehicleType.SEDAN.value] + + def test_4x4_scores_higher_than_sedan_on_all_offroad_terrain_bands(self): + """ + 4x4 should score higher than sedan on all off-road terrain bands. + Note: on flat urban terrain (sedan_ok), sedan correctly scores higher + than a 4x4 — a more capable vehicle is overkill for urban use. + """ + offroad_bands = ["suv_preferred", "4x4_required", "specialist_required"] + for hint in offroad_bands: + scores = SUITABILITY_TABLE[hint] + assert scores[VehicleType.FOUR_BY_FOUR.value] >= scores[VehicleType.SEDAN.value], \ + f"4x4 scored lower than sedan on {hint} terrain" + + def test_sedan_scores_highest_on_flat_urban_terrain(self): + """ + On flat urban terrain (sedan_ok), sedan should be the ideal vehicle. + Score of 1.0 expected for sedan. + """ + result = compute_vehicle_suitability(0.10) + scores = result["suitability_scores"] + assert scores[VehicleType.SEDAN.value] == 1.0 + + def test_4x4_scores_highest_on_serious_terrain(self): + """On serious off-road terrain (4x4_required), 4x4 should score 1.0.""" + result = compute_vehicle_suitability(0.72) + scores = result["suitability_scores"] + assert scores[VehicleType.FOUR_BY_FOUR.value] == 1.0 + + def test_sedan_scores_zero_on_extreme_terrain(self): + """On extreme terrain (specialist_required), sedan should score 0.0.""" + result = compute_vehicle_suitability(0.90) + scores = result["suitability_scores"] + assert scores[VehicleType.SEDAN.value] == 0.0 + + def test_suv_scores_highest_on_light_offroad_terrain(self): + """On light off-road terrain (suv_preferred), SUV should score 1.0.""" + result = compute_vehicle_suitability(0.40) + scores = result["suitability_scores"] + assert scores[VehicleType.SUV.value] == 1.0 + + # ----------------------------------------------------------------------- + # RECOMMENDED TYPES + # ----------------------------------------------------------------------- + + def test_recommended_types_include_sedan_on_flat_terrain(self): + """Sedan should appear in recommended types for flat urban terrain.""" + result = compute_vehicle_suitability(0.10) + assert VehicleType.SEDAN.value in result["recommended_types"] + + def test_recommended_types_exclude_sedan_on_serious_terrain(self): + """Sedan should NOT appear in recommended types for 4x4_required terrain.""" + result = compute_vehicle_suitability(0.72) + assert VehicleType.SEDAN.value not in result["recommended_types"] + + def test_recommended_types_only_include_high_scoring_vehicles(self): + """All recommended types should have a suitability score >= 0.7.""" + for score in [0.10, 0.40, 0.72, 0.90]: + result = compute_vehicle_suitability(score) + for vehicle_type in result["recommended_types"]: + assert result["suitability_scores"][vehicle_type] >= 0.7, \ + f"{vehicle_type} in recommended_types but score < 0.7 at difficulty {score}" + + # ----------------------------------------------------------------------- + # APPLY TO TRIP + # ----------------------------------------------------------------------- + + def test_suitability_written_onto_trip(self): + """ + apply_suitability_to_trip should write both terrain_difficulty_score + and vehicle_hint onto the TripRequest object. + """ + request_id = self._create_pending_trip() + + result = apply_suitability_to_trip(request_id, 0.72) + + assert result["success"] is True + trip = get_trip(request_id) + assert trip is not None + assert trip.terrain_difficulty_score == pytest.approx(0.72, abs=0.001) + assert trip.vehicle_hint == "4x4_required" + + def test_apply_suitability_to_nonexistent_trip_fails(self): + """Applying suitability to a non-existent trip should fail cleanly.""" + result = apply_suitability_to_trip("TR-DOESNOTEXIST", 0.72) + + assert result["success"] is False + assert any("not found" in e for e in result["errors"]) + + # ----------------------------------------------------------------------- + # INTEGRATION — SUITABILITY AFFECTS ALLOCATION + # This is the key end-to-end test — the hint flows through to vehicle selection + # ----------------------------------------------------------------------- + + def test_4x4_hint_causes_allocation_engine_to_pick_4x4(self): + """ + When terrain requires 4x4, the allocation engine should select + a 4x4 vehicle — not a sedan or SUV. + + This tests the full pipeline: + terrain_difficulty_score (0.72) + -> vehicle_hint (4x4_required) + -> allocation engine filters to FOUR_BY_FOUR only + -> 4x4 vehicle assigned to trip + """ + # Create and approve a trip + trip_result = create_trip_request(make_trip_request()) + request_id = trip_result["trip"].request_id + + # Apply terrain suitability BEFORE approval + apply_suitability_to_trip(request_id, 0.72) # 4x4_required + + # Admin approves — allocation engine uses vehicle_hint + result = process_admin_decision( + request_id, admin_id="A001", data=AdminApprovalIn(approve=True) + ) + + assert result["success"] is True + assert result["status"] == TripStatus.ALLOCATED + + trip = get_trip(request_id) + assert trip is not None + assert trip.assigned_vehicle_id is not None + + assigned_vehicle = next( + v for v in VEHICLE_POOL if v.vehicle_id == trip.assigned_vehicle_id + ) + assert assigned_vehicle.vehicle_type == VehicleType.FOUR_BY_FOUR + + def test_sedan_ok_hint_allows_any_vehicle(self): + """ + When terrain is flat/urban (sedan_ok), any available vehicle + should be eligible for allocation. + """ + trip_result = create_trip_request(make_trip_request()) + request_id = trip_result["trip"].request_id + + apply_suitability_to_trip(request_id, 0.10) # sedan_ok + + result = process_admin_decision( + request_id, admin_id="A001", data=AdminApprovalIn(approve=True) + ) + + assert result["success"] is True + assert result["status"] == TripStatus.ALLOCATED + + +# =========================================================================== +# STAGE 13 — OPTIMIZATION ENGINE +# =========================================================================== + +class TestOptimizationEngine: + """ + Tests for the MILP Optimization Engine. + + Tests are designed to work whether PuLP is installed or not. + When PuLP is available, we verify MILP-specific behaviour. + When not, we verify the heuristic fallback produces correct results. + Both paths must produce valid, sensible assignments. + """ + + def _build_warm_start( + self, + trip_ids: list[str] | None = None, + ) -> "WarmStartInput": + """ + Helper — builds a WarmStartInput from current system state. + Optionally filters to specific trip IDs. + """ + pending = _get_pending_trips() + if trip_ids: + pending = [t for t in pending if t.request_id in trip_ids] + + available_vehicles = _get_available_vehicles() + available_drivers = _get_available_drivers() + current_solution = _build_current_solution(pending) + + return WarmStartInput( + locked_trips=[], + pending_trips=pending, + available_vehicles=available_vehicles, + available_drivers=available_drivers, + current_solution=current_solution, + disruption_type=DR_DisruptionType.VEHICLE_BREAKDOWN, + triggered_at=datetime.now(), + ) + + def _create_approved_trip( + self, + destination: str = "Maseru Central Office", + user_id: str = "U001", + ) -> str: + """ + Helper — creates a trip that is APPROVED but not yet allocated. + Returns request_id. + """ + trip_result = create_trip_request( + make_trip_request(destination=destination, user_id=user_id) + ) + request_id = trip_result["trip"].request_id + # Set to APPROVED without auto-allocating + trip = get_trip(request_id) + assert trip is not None + trip.status = TripStatus.APPROVED + return request_id + + # ----------------------------------------------------------------------- + # BASIC SOLVE — WORKS WITH OR WITHOUT PULP + # ----------------------------------------------------------------------- + + def test_solve_assigns_trips_to_vehicles_and_drivers(self): + """ + Basic smoke test — solve() should produce a valid assignment + for pending trips regardless of whether PuLP is installed. + """ + # Create approved trips + r1 = self._create_approved_trip(destination="Maseru Central Office") + r2 = self._create_approved_trip( + destination="Maseru Central Office", user_id="U002" + ) + + warm_start = self._build_warm_start() + result = solve(warm_start, trigger="dynamic_event") + + assert result["success"] is True + assert "solution" in result + assert "score" in result + assert "justification" in result + # At least one trip should be assigned + assert len(result["solution"]) > 0 + + def test_solve_returns_valid_solution_structure(self): + """ + Each entry in the solution must have vehicle_id and driver_id. + """ + self._create_approved_trip(destination="Maseru Central Office") + + warm_start = self._build_warm_start() + result = solve(warm_start, trigger="dynamic_event") + + for trip_id, assignment in result["solution"].items(): + assert "vehicle_id" in assignment + assert "driver_id" in assignment + assert assignment["vehicle_id"] is not None + assert assignment["driver_id"] is not None + + def test_solve_with_no_pending_trips_returns_empty(self): + """When there are no pending trips, solve should return gracefully.""" + warm_start = WarmStartInput( + locked_trips=[], + pending_trips=[], + available_vehicles=_get_available_vehicles(), + available_drivers=_get_available_drivers(), + current_solution={}, + disruption_type=DR_DisruptionType.VEHICLE_BREAKDOWN, + triggered_at=datetime.now(), + ) + + result = solve(warm_start, trigger="dynamic_event") + + assert result["success"] is True + assert result["solution"] == {} + assert result["changes_made"] == 0 + + def test_solve_with_no_available_vehicles_assigns_nothing(self): + """When no vehicles are available, solution should be empty.""" + self._create_approved_trip(destination="Maseru Central Office") + + for v in VEHICLE_POOL: + v.current_status = VehicleStatus.MAINTENANCE + + warm_start = self._build_warm_start() + result = solve(warm_start, trigger="dynamic_event") + + assert result["success"] is True + assert len(result["solution"]) == 0 + + def test_solve_respects_vehicle_capacity(self): + """ + A vehicle with insufficient capacity should not be assigned + to a trip with more passengers than it can hold. + """ + # Create a trip with many passengers + trip_result = create_trip_request( + make_trip_request( + destination="Maseru Central Office", + passengers=10 + ) + ) + request_id = trip_result["trip"].request_id + trip = get_trip(request_id) + assert trip is not None + trip.status = TripStatus.APPROVED + + warm_start = self._build_warm_start(trip_ids=[request_id]) + result = solve(warm_start, trigger="dynamic_event") + + # If assigned, vehicle must have capacity >= 10 + if request_id in result["solution"]: + vehicle_id = result["solution"][request_id]["vehicle_id"] + vehicle = next(v for v in VEHICLE_POOL if v.vehicle_id == vehicle_id) + assert vehicle.capacity >= 10 + + def test_solve_respects_terrain_hint(self): + """ + A vehicle that doesn't satisfy the terrain hint should not + be assigned to the trip. + """ + trip_result = create_trip_request( + make_trip_request(destination="Maseru Central Office") + ) + request_id = trip_result["trip"].request_id + trip = get_trip(request_id) + assert trip is not None + trip.status = TripStatus.APPROVED + trip.vehicle_hint = "4x4_required" # Only 4x4 qualifies + + # Make only the 4x4 available + for v in VEHICLE_POOL: + if v.vehicle_type != VehicleType.FOUR_BY_FOUR: + v.current_status = VehicleStatus.MAINTENANCE + + warm_start = self._build_warm_start(trip_ids=[request_id]) + result = solve(warm_start, trigger="dynamic_event") + + if request_id in result["solution"]: + vehicle_id = result["solution"][request_id]["vehicle_id"] + vehicle = next(v for v in VEHICLE_POOL if v.vehicle_id == vehicle_id) + assert vehicle.vehicle_type == VehicleType.FOUR_BY_FOUR + + def test_higher_priority_trip_served_when_resources_scarce(self): + """ + When there is only one vehicle and two trips, the higher + priority trip should be assigned the vehicle. + """ + # Create two approved trips + r_high = self._create_approved_trip(destination="Maseru Central Office") + r_low = self._create_approved_trip( + destination="Maseru Central Office", user_id="U002" + ) + + # Set explicit priority scores + trip_high = get_trip(r_high) + trip_low = get_trip(r_low) + assert trip_high is not None + assert trip_low is not None + trip_high.priority_score = 1.5 # High priority + trip_low.priority_score = 0.3 # Low priority + + # Only one vehicle available + for v in VEHICLE_POOL[1:]: + v.current_status = VehicleStatus.MAINTENANCE + + warm_start = self._build_warm_start(trip_ids=[r_high, r_low]) + result = solve(warm_start, trigger="dynamic_event") + + # High priority trip should be assigned + assert r_high in result["solution"] + # Low priority trip may or may not be assigned (resource scarce) + # but if only one vehicle, high priority wins + if r_low not in result["solution"]: + assert r_high in result["solution"] + + # ----------------------------------------------------------------------- + # JUSTIFICATION JSON + # ----------------------------------------------------------------------- + + def test_justification_json_structure_is_complete(self): + """ + Justification JSON must contain all required fields + for government audit purposes. + """ + self._create_approved_trip(destination="Maseru Central Office") + + warm_start = self._build_warm_start() + result = solve(warm_start, trigger="dynamic_event") + + j = result["justification"] + assert "trigger" in j + assert "solved_at" in j + assert "objective_score" in j + assert "total_trips" in j + assert "trips_assigned" in j + assert "trips_unassigned" in j + assert "changes_made" in j + assert "assignments" in j + assert "weights_used" in j + + def test_justification_records_each_assignment(self): + """Each assigned trip should have a justification record.""" + self._create_approved_trip(destination="Maseru Central Office") + + warm_start = self._build_warm_start() + result = solve(warm_start, trigger="dynamic_event") + + assigned_ids = set(result["solution"].keys()) + justified_ids = {a["trip_id"] for a in result["justification"]["assignments"]} + assert assigned_ids == justified_ids + + def test_justification_includes_scores_for_each_assignment(self): + """Each justification record must include all three scoring components.""" + self._create_approved_trip(destination="Maseru Central Office") + + warm_start = self._build_warm_start() + result = solve(warm_start, trigger="dynamic_event") + + for assignment in result["justification"]["assignments"]: + assert "scores" in assignment + assert "priority_score" in assignment["scores"] + assert "suitability_score" in assignment["scores"] + assert "readiness_score" in assignment["scores"] + assert "composite_score" in assignment["scores"] + + def test_justification_trigger_matches(self): + """Trigger field in justification should match the trigger argument.""" + self._create_approved_trip(destination="Maseru Central Office") + + warm_start = self._build_warm_start() + result = solve(warm_start, trigger="batch") + + assert result["justification"]["trigger"] == "batch" + + def test_justification_flags_reassigned_trips(self): + """ + If an existing allocation is changed by the solver, + was_reassigned should be True in the justification. + """ + self._create_approved_trip(destination="Maseru Central Office") + + warm_start = self._build_warm_start() + # Pre-populate current_solution with a different vehicle + # to force a reassignment + pending = warm_start.pending_trips + if pending: + warm_start.current_solution[pending[0].request_id] = { + "vehicle_id": "V999", # Non-existent — forces change + "driver_id": "D999", + } + + result = solve(warm_start, trigger="dynamic_event") + + # Any assignment where current was V999 should be flagged as reassigned + for assignment in result["justification"]["assignments"]: + if assignment["trip_id"] == pending[0].request_id: + assert assignment["was_reassigned"] is True + + # ----------------------------------------------------------------------- + # STABILITY — CHANGE PENALTY + # ----------------------------------------------------------------------- + + def test_existing_valid_assignment_not_changed_unnecessarily(self): + """ + If the current assignment is already valid and good, + the solver should keep it (stability principle). + """ + self._create_approved_trip(destination="Maseru Central Office") + + # First solve + warm_start1 = self._build_warm_start() + result1 = solve(warm_start1, trigger="dynamic_event") + + if not result1["solution"]: + return # Skip if nothing was assigned + + # Second solve with first solution as warm start + warm_start2 = self._build_warm_start() + warm_start2.current_solution = result1["solution"] + result2 = solve(warm_start2, trigger="dynamic_event") + + # With same resources and same warm start, changes should be minimal + # (0 changes expected when nothing has changed) + assert result2["changes_made"] == 0 + + # ----------------------------------------------------------------------- + # BATCH SOLVE + # ----------------------------------------------------------------------- + + def test_batch_solve_returns_success(self): + """batch_solve should succeed even with no trips for the date.""" + result = batch_solve(datetime.now() + timedelta(days=1)) + + assert result["success"] is True + + def test_batch_solve_optimises_trips_for_date(self): + """ + batch_solve should find and optimise pending trips for the given date. + """ + # Create approved trips for tomorrow + r1 = self._create_approved_trip(destination="Maseru Central Office") + r2 = self._create_approved_trip( + destination="Maseru Central Office", user_id="U002" + ) + + trip_date = datetime.now() + timedelta(days=1) + result = batch_solve(trip_date) + + assert result["success"] is True + assert result["trips_optimized"] >= 0 # May be 0 if trip dates don't match + + def test_batch_solve_with_vehicle_type_filter(self): + """ + batch_solve with a vehicle_type_filter should only optimise + trips matching that hint. + """ + result = batch_solve( + datetime.now() + timedelta(days=1), + vehicle_type_filter="4x4_required" + ) + assert result["success"] is True + + # ----------------------------------------------------------------------- + # INTEGRATION WITH DYNAMIC REALLOCATION ENGINE + # ----------------------------------------------------------------------- + + def test_dynamic_reallocation_uses_optimization_engine(self): + """ + When a disruption occurs, the dynamic reallocation engine should + call the optimization engine and return an optimization section + with a justification. + """ + # Create an allocated trip to a flat urban destination + trip_result = create_trip_request( + make_trip_request(destination="Maseru Central Office") + ) + request_id = trip_result["trip"].request_id + process_admin_decision( + request_id, admin_id="A001", data=AdminApprovalIn(approve=True) + ) + + # Start the trip + trip = get_trip(request_id) + assert trip is not None + token = next((t for t in TOKENS if t.token_id == trip.token_id), None) + assert token is not None + start_trip(request_id, token.token_value) + + # Cancel the trip — triggers disruption + optimization + result = handle_disruption(request_id, DisruptionType.TRIP_CANCELLATION) + + # Optimization section should be present + assert "optimization" in result + assert "applied" in result["optimization"] + assert "old_score" in result["optimization"] + assert "new_score" in result["optimization"] + + # ----------------------------------------------------------------------- + # PULP-SPECIFIC TESTS (skipped if PuLP not installed) + # ----------------------------------------------------------------------- + + def test_pulp_solver_returns_optimal_status(self): + """When PuLP is available, solver should return Optimal status.""" + if not PULP_AVAILABLE: + pytest.skip("PuLP not installed — skipping MILP-specific test") + + self._create_approved_trip(destination="Maseru Central Office") + + warm_start = self._build_warm_start() + result = solve(warm_start, trigger="dynamic_event") + + assert result["status"] in ["Optimal", "Not Solved", "HeuristicFallback (PuLP not installed)"] + + def test_heuristic_fallback_when_pulp_unavailable(self): + """ + When PuLP is not installed, the engine should fall back + to heuristic allocation and still produce valid assignments. + """ + if PULP_AVAILABLE: + pytest.skip("PuLP is installed — heuristic fallback not triggered") + + self._create_approved_trip(destination="Maseru Central Office") + + warm_start = self._build_warm_start() + result = solve(warm_start, trigger="dynamic_event") + + assert result["success"] is True + assert "HeuristicFallback" in result["status"] + # Should still produce valid assignments + assert result["solution"] is not None \ No newline at end of file diff --git a/backend/tests/test_gis_service.py b/backend/tests/test_gis_service.py new file mode 100644 index 0000000..8befe98 --- /dev/null +++ b/backend/tests/test_gis_service.py @@ -0,0 +1,1165 @@ +""" +tests/test_gis_service.py +-------------------------- +Tests for the GIS/Terrain Service. + +All tests use mock data (use_mock=True) by default so they run +without network access and don't hammer the free APIs. + +Flow being tested: + 1. Models (gis_service/models.py) + 2. Route request processor — geocoding (route_request_processor.py) + 3. Terrain analysis engine — scoring (terrain_analysis_engine.py) + 4. Vehicle suitability mapping module (vehicle_suitability_mapping_module.py) + 5. Full pipeline — end to end (all three engines together) + 6. API fallback behaviour (mock fallback when real APIs fail) + 7. GIS FastAPI endpoints (gis_api.py) + 8. Batch terrain requests (BatchTerrainRequest/Response) + 9. Live tracking (LiveTrackingRequest/Response) + 10. Fleet management integration (trip_request_processor._get_gis_terrain_score) + 11. Models validation (gis_service/models.py field constraints) + +Run from backend/: + pytest tests/test_gis_service.py -v +""" + +import pytest +from gis_service.models import ( + TerrainRequest, + LiveTrackingRequest, + Coordinates, + RoadType, + TerrainType, + VehicleHint, +) +from gis_service.engines.route_request_processor import ( + resolve_coordinates, + LESOTHO_LOCATIONS, +) +from gis_service.engines.terrain_analysis_engine import ( # type: ignore[attr-defined] + analyse_terrain, + _compute_difficulty_score, # type: ignore[attr-defined] + _classify_terrain, # type: ignore[attr-defined] + _get_mock_elevation, # type: ignore[attr-defined] + _get_mock_road_type, # type: ignore[attr-defined] + MASERU_ELEVATION_M, # type: ignore[attr-defined] + MAX_ELEVATION_GAIN_M, # type: ignore[attr-defined] + ROAD_DIFFICULTY, # type: ignore[attr-defined] +) +from gis_service.engines.vehicle_suitability_mapping_module import ( + map_to_vehicle_hint, + build_terrain_response, + get_hint_description, + TERRAIN_TO_HINT, +) +from gis_service.engines.terrain_analysis_engine import ( # type: ignore[attr-defined] + _query_open_elevation, # type: ignore[attr-defined] + _query_overpass_road_type, # type: ignore[attr-defined] +) +from gis_service.engines.route_request_processor import ( + _lookup_known_location, # type: ignore[attr-defined] + _validate_destination, # type: ignore[attr-defined] +) +from gis_service.models import ( + TerrainMetadata, + ElevationData, + RoadData, + BatchTerrainRequest, + BatchTerrainResponse, +) +from unittest.mock import patch, MagicMock +from fastapi.testclient import TestClient + + +# =========================================================================== +# STAGE 1 — ROUTE REQUEST PROCESSOR +# =========================================================================== + +class TestRouteRequestProcessor: + + def test_known_lesotho_location_resolved_from_cache(self): + """Known Lesotho locations should resolve from the cache without API call.""" + result = resolve_coordinates("Maseru") + + assert result["success"] is True + assert result["source"] == "cache" + assert result["coordinates"] is not None + assert result["coordinates"].latitude == pytest.approx(-29.3167, abs=0.1) + assert result["coordinates"].longitude == pytest.approx(27.4833, abs=0.1) + + def test_thaba_tseka_resolved_from_cache(self): + """Thaba-Tseka should resolve correctly — key mountain destination.""" + result = resolve_coordinates("Thaba-Tseka") + + assert result["success"] is True + assert result["source"] == "cache" + assert result["coordinates"].latitude == pytest.approx(-29.52, abs=0.1) + + def test_partial_match_works(self): + """ + 'Thaba-Tseka District Hospital' should match 'thaba-tseka' + in the cache via partial matching. + """ + result = resolve_coordinates("Thaba-Tseka District Hospital") + + assert result["success"] is True + assert result["coordinates"] is not None + + def test_mokhotlong_resolved_from_cache(self): + """Mokhotlong — extreme terrain destination — should resolve.""" + result = resolve_coordinates("Mokhotlong") + + assert result["success"] is True + assert result["coordinates"].latitude == pytest.approx(-29.28, abs=0.1) + + def test_case_insensitive_lookup(self): + """Cache lookup should be case-insensitive.""" + result_lower = resolve_coordinates("maseru") + result_upper = resolve_coordinates("MASERU") + result_mixed = resolve_coordinates("Maseru") + + assert result_lower["success"] is True + assert result_upper["success"] is True + assert result_mixed["success"] is True + assert result_lower["coordinates"].latitude == result_upper["coordinates"].latitude + + def test_empty_destination_fails_validation(self): + """Empty destination should fail with a clear validation error.""" + result = resolve_coordinates("") + + assert result["success"] is False + assert any("empty" in e.lower() for e in result["errors"]) + + def test_too_short_destination_fails(self): + """A destination under 3 characters should fail validation.""" + result = resolve_coordinates("ab") + + assert result["success"] is False + assert any("too short" in e.lower() for e in result["errors"]) + + def test_all_district_capitals_in_cache(self): + """All 10 Lesotho district capitals should be in the cache.""" + districts = [ + "maseru", "thaba-tseka", "mokhotlong", "qacha's nek", + "butha-buthe", "leribe", "mafeteng", "mohale's hoek", + "quthing", "berea", + ] + for district in districts: + assert district in LESOTHO_LOCATIONS, \ + f"District '{district}' missing from cache" + + def test_coordinates_have_valid_ranges(self): + """All cached coordinates should be within valid lat/lon ranges.""" + for name, coords in LESOTHO_LOCATIONS.items(): + assert -90 <= coords.latitude <= 90, \ + f"Invalid latitude for {name}: {coords.latitude}" + assert -180 <= coords.longitude <= 180, \ + f"Invalid longitude for {name}: {coords.longitude}" + + def test_lesotho_coordinates_in_correct_region(self): + """All cached coordinates should be within Lesotho's bounding box.""" + # Lesotho: lat -30.7 to -28.6, lon 27.0 to 29.5 + for name, coords in LESOTHO_LOCATIONS.items(): + assert -30.7 <= coords.latitude <= -28.5, \ + f"{name} latitude {coords.latitude} outside Lesotho" + assert 27.0 <= coords.longitude <= 29.5, \ + f"{name} longitude {coords.longitude} outside Lesotho" + + +# =========================================================================== +# STAGE 2 — TERRAIN ANALYSIS ENGINE +# =========================================================================== + +class TestTerrainAnalysisEngine: + + def _get_maseru_coords(self) -> Coordinates: + return Coordinates(latitude=-29.3167, longitude=27.4833, place_name="Maseru") + + def _get_mokhotlong_coords(self) -> Coordinates: + return Coordinates(latitude=-29.2833, longitude=29.0667, place_name="Mokhotlong") + + def _get_thaba_tseka_coords(self) -> Coordinates: + return Coordinates(latitude=-29.5229, longitude=28.6054, place_name="Thaba-Tseka") + + # ----------------------------------------------------------------------- + # DIFFICULTY SCORE FORMULA + # ----------------------------------------------------------------------- + + def test_difficulty_score_range_is_valid(self): + """Difficulty score must always be between 0.0 and 1.0.""" + for elevation_gain in [0, 100, 300, 600, 1500, 2000]: + for road_type in RoadType: + score = _compute_difficulty_score(elevation_gain, road_type) + assert 0.0 <= score <= 1.0, \ + f"Score {score} out of range for gain={elevation_gain}, road={road_type}" + + def test_maseru_has_low_difficulty(self): + """ + Maseru (flat, paved) should have a low difficulty score. + Elevation gain = 0, road = paved -> score ~ 0.05 + """ + score = _compute_difficulty_score(0, RoadType.PAVED) + assert score < 0.15 + + def test_mokhotlong_has_high_difficulty(self): + """ + Mokhotlong (750m gain, dirt road) should have high difficulty. + elevation_factor = 750/1500 = 0.5, road_factor = 0.7 + score = (0.5 * 0.5) + (0.7 * 0.5) = 0.60 + """ + elevation_gain = _get_mock_elevation("mokhotlong") - MASERU_ELEVATION_M + score = _compute_difficulty_score(elevation_gain, RoadType.DIRT) + assert score >= 0.55 # Should require 4x4 + + def test_higher_elevation_gain_increases_score(self): + """Higher elevation gain should produce a higher difficulty score.""" + score_low = _compute_difficulty_score(100, RoadType.GRAVEL) + score_high = _compute_difficulty_score(800, RoadType.GRAVEL) + assert score_high > score_low + + def test_worse_road_increases_score(self): + """Worse road type should produce a higher difficulty score.""" + score_paved = _compute_difficulty_score(300, RoadType.PAVED) + score_gravel = _compute_difficulty_score(300, RoadType.GRAVEL) + score_dirt = _compute_difficulty_score(300, RoadType.DIRT) + score_offroad = _compute_difficulty_score(300, RoadType.OFFROAD) + + assert score_paved < score_gravel < score_dirt < score_offroad + + def test_max_elevation_gain_capped_at_1(self): + """Elevation gain beyond MAX_ELEVATION_GAIN_M should be capped at 1.0 factor.""" + score_at_max = _compute_difficulty_score(MAX_ELEVATION_GAIN_M, RoadType.OFFROAD) + score_beyond = _compute_difficulty_score(MAX_ELEVATION_GAIN_M * 2, RoadType.OFFROAD) + assert score_at_max == score_beyond == 1.0 + + # ----------------------------------------------------------------------- + # TERRAIN CLASSIFICATION + # ----------------------------------------------------------------------- + + def test_maseru_classified_as_urban(self): + """Maseru (low gain, paved) should be classified as urban.""" + terrain = _classify_terrain(MASERU_ELEVATION_M, 0, RoadType.PAVED) # type: ignore[call-arg] + assert terrain == TerrainType.URBAN + + def test_high_gain_classified_as_mountainous(self): + """350m gain should be classified as mountainous.""" + terrain = _classify_terrain(1950, 350, RoadType.GRAVEL) # type: ignore[call-arg] + assert terrain == TerrainType.MOUNTAINOUS + + def test_extreme_gain_classified_as_extreme(self): + """700m+ gain should be classified as extreme.""" + terrain = _classify_terrain(2300, 700, RoadType.DIRT) # type: ignore[call-arg] + assert terrain == TerrainType.EXTREME + + def test_small_gain_classified_as_flat(self): + """50m gain should be classified as flat.""" + terrain = _classify_terrain(1650, 50, RoadType.GRAVEL) # type: ignore[call-arg] + assert terrain == TerrainType.FLAT + + # ----------------------------------------------------------------------- + # MOCK DATA + # ----------------------------------------------------------------------- + + def test_mock_elevation_maseru(self): + """Maseru mock elevation should be at baseline (1600m).""" + elevation = _get_mock_elevation("Maseru") + assert elevation == MASERU_ELEVATION_M + + def test_mock_elevation_mokhotlong_higher_than_maseru(self): + """Mokhotlong mock elevation should be higher than Maseru.""" + mokhotlong_elevation = _get_mock_elevation("Mokhotlong") + assert mokhotlong_elevation > MASERU_ELEVATION_M + + def test_mock_road_maseru_is_paved(self): + """Maseru mock road should be paved.""" + road = _get_mock_road_type("Maseru") + assert road == RoadType.PAVED + + def test_mock_road_mokhotlong_is_dirt(self): + """Mokhotlong mock road should be dirt — remote mountain district.""" + road = _get_mock_road_type("Mokhotlong") + assert road == RoadType.DIRT + + # ----------------------------------------------------------------------- + # ANALYSE TERRAIN (full function) + # ----------------------------------------------------------------------- + + def test_analyse_terrain_maseru_returns_success(self): + """analyse_terrain for Maseru should succeed with mock data.""" + result = analyse_terrain(self._get_maseru_coords(), use_mock=True) # type: ignore[call-arg] + + assert result["success"] is True + assert result["metadata"] is not None + assert result["is_mock"] is True + + def test_analyse_terrain_produces_valid_score(self): + """Terrain analysis should produce a score in valid range.""" + result = analyse_terrain(self._get_maseru_coords(), use_mock=True) # type: ignore[call-arg] + + score = result["metadata"].terrain_difficulty_score + assert 0.0 <= score <= 1.0 + + def test_maseru_score_lower_than_mokhotlong(self): + """ + Maseru (urban, paved) should have a lower difficulty score + than Mokhotlong (mountain, dirt). + """ + maseru_result = analyse_terrain(self._get_maseru_coords(), use_mock=True) # type: ignore[call-arg] + mokhotlong_result = analyse_terrain(self._get_mokhotlong_coords(), use_mock=True) # type: ignore[call-arg] + + maseru_score = maseru_result["metadata"].terrain_difficulty_score + mokhotlong_score = mokhotlong_result["metadata"].terrain_difficulty_score + + assert maseru_score < mokhotlong_score + + def test_maseru_terrain_type_is_urban(self): + """Maseru should be classified as urban terrain.""" + result = analyse_terrain(self._get_maseru_coords(), use_mock=True) # type: ignore[call-arg] + assert result["metadata"].elevation.terrain_type == TerrainType.URBAN + + def test_metadata_has_all_required_fields(self): + """TerrainMetadata should contain all required fields.""" + result = analyse_terrain(self._get_maseru_coords(), use_mock=True) # type: ignore[call-arg] + metadata = result["metadata"] + + assert metadata.coordinates is not None + assert metadata.elevation is not None + assert metadata.road is not None + assert metadata.terrain_difficulty_score is not None + assert metadata.elevation.elevation_meters > 0 + assert metadata.elevation.elevation_gain_meters >= 0 + assert metadata.road.road_type is not None + + +# =========================================================================== +# STAGE 3 — VEHICLE SUITABILITY MAPPING MODULE +# =========================================================================== + +class TestVehicleSuitabilityMappingModule: + + # ----------------------------------------------------------------------- + # HINT MAPPING — BOUNDARY TESTS + # Same thresholds as fleet management vehicle_suitability_module + # ----------------------------------------------------------------------- + + def test_score_0_maps_to_sedan_ok(self): + assert map_to_vehicle_hint(0.0) == VehicleHint.SEDAN_OK + + def test_score_0_29_maps_to_sedan_ok(self): + assert map_to_vehicle_hint(0.29) == VehicleHint.SEDAN_OK + + def test_score_0_30_maps_to_suv_preferred(self): + assert map_to_vehicle_hint(0.30) == VehicleHint.SUV_PREFERRED + + def test_score_0_54_maps_to_suv_preferred(self): + assert map_to_vehicle_hint(0.54) == VehicleHint.SUV_PREFERRED + + def test_score_0_55_maps_to_4x4_required(self): + assert map_to_vehicle_hint(0.55) == VehicleHint.FOUR_BY_FOUR_REQUIRED + + def test_score_0_79_maps_to_4x4_required(self): + assert map_to_vehicle_hint(0.79) == VehicleHint.FOUR_BY_FOUR_REQUIRED + + def test_score_0_80_maps_to_specialist(self): + assert map_to_vehicle_hint(0.80) == VehicleHint.SPECIALIST_REQUIRED + + def test_score_1_0_maps_to_specialist(self): + assert map_to_vehicle_hint(1.0) == VehicleHint.SPECIALIST_REQUIRED + + def test_all_hints_have_descriptions(self): + """Every VehicleHint should have a non-empty description.""" + for hint in VehicleHint: + description = get_hint_description(hint) + assert description + assert len(description) > 10 + + # ----------------------------------------------------------------------- + # TERRAIN RESPONSE BUILDING + # ----------------------------------------------------------------------- + + def test_build_terrain_response_structure(self): + """ + build_terrain_response should produce a complete TerrainResponse + with all required fields. + """ + from gis_service.models import ( + TerrainMetadata, ElevationData, RoadData + ) + + metadata = TerrainMetadata( + coordinates=Coordinates( + latitude=-29.3167, + longitude=27.4833, + place_name="Maseru" + ), + elevation=ElevationData( + elevation_meters=1600.0, + elevation_gain_meters=0.0, + terrain_type=TerrainType.URBAN, + ), + road=RoadData( + road_type=RoadType.PAVED, + road_description="Paved road", + ), + terrain_difficulty_score=0.05, + ) + + response = build_terrain_response( + trip_id="TR-TEST01", + destination="Maseru Central", + metadata=metadata, + is_mock=True, + ) + + assert response.trip_id == "TR-TEST01" + assert response.destination == "Maseru Central" + assert response.latitude == -29.3167 + assert response.longitude == 27.4833 + assert response.terrain_difficulty_score == 0.05 + assert response.vehicle_hint == VehicleHint.SEDAN_OK + assert response.is_mock is True + + def test_response_vehicle_hint_consistent_with_score(self): + """ + The vehicle hint in the response must be consistent with the + terrain difficulty score — same thresholds as fleet management. + """ + from gis_service.models import ( + TerrainMetadata, ElevationData, RoadData + ) + + test_cases = [ + (0.10, VehicleHint.SEDAN_OK), + (0.40, VehicleHint.SUV_PREFERRED), + (0.70, VehicleHint.FOUR_BY_FOUR_REQUIRED), + (0.90, VehicleHint.SPECIALIST_REQUIRED), + ] + + for score, expected_hint in test_cases: + metadata = TerrainMetadata( + coordinates=Coordinates(latitude=-29.3167, longitude=27.4833), + elevation=ElevationData( + elevation_meters=1600.0, + elevation_gain_meters=0.0, + terrain_type=TerrainType.URBAN, + ), + road=RoadData( + road_type=RoadType.PAVED, + road_description="", + ), + terrain_difficulty_score=score, + ) + response = build_terrain_response("TR-001", "Test", metadata) + assert response.vehicle_hint == expected_hint, \ + f"Score {score} should map to {expected_hint}, got {response.vehicle_hint}" + + +# =========================================================================== +# STAGE 4 — FULL PIPELINE +# =========================================================================== + +class TestFullGISPipeline: + + def _run_pipeline(self, destination: str) -> dict: + """Helper — runs the full GIS pipeline for a destination.""" + coord_result = resolve_coordinates(destination) + if not coord_result["success"]: + return {"success": False, "errors": coord_result["errors"]} + + terrain_result = analyse_terrain( + coord_result["coordinates"], use_mock=True # type: ignore[call-arg] + ) + if not terrain_result["success"]: + return {"success": False, "errors": terrain_result["errors"]} + + response = build_terrain_response( + trip_id="TR-TEST", + destination=destination, + metadata=terrain_result["metadata"], + is_mock=terrain_result["is_mock"], + ) + return {"success": True, "response": response} + + def test_maseru_full_pipeline(self): + """Full pipeline for Maseru should produce sedan_ok hint.""" + result = self._run_pipeline("Maseru") + + assert result["success"] is True + response = result["response"] + assert response.vehicle_hint == VehicleHint.SEDAN_OK + assert response.terrain_difficulty_score < 0.30 + + def test_mokhotlong_full_pipeline(self): + """Full pipeline for Mokhotlong should produce 4x4_required hint.""" + result = self._run_pipeline("Mokhotlong") + + assert result["success"] is True + response = result["response"] + assert response.vehicle_hint in [ + VehicleHint.FOUR_BY_FOUR_REQUIRED, + VehicleHint.SPECIALIST_REQUIRED, + ] + + def test_thaba_tseka_full_pipeline(self): + """Full pipeline for Thaba-Tseka should produce at least suv_preferred.""" + result = self._run_pipeline("Thaba-Tseka District Hospital") + + assert result["success"] is True + response = result["response"] + assert response.vehicle_hint != VehicleHint.SEDAN_OK + + def test_pipeline_returns_valid_coordinates(self): + """Pipeline should return valid GPS coordinates for all known locations.""" + destinations = ["Maseru", "Mokhotlong", "Leribe", "Mafeteng", "Quthing"] + + for destination in destinations: + result = self._run_pipeline(destination) + assert result["success"] is True, f"Pipeline failed for {destination}" + response = result["response"] + assert -90 <= response.latitude <= 90 + assert -180 <= response.longitude <= 180 + + def test_pipeline_consistency_with_fleet_management(self): + """ + GIS pipeline vehicle hints must be consistent with the + fleet management vehicle_suitability_module thresholds. + + The GIS VehicleHint string values must match what fleet management + expects in TripRequest.vehicle_hint. + """ + # VehicleHint enum values must match fleet management VEHICLE_HINT_MAP keys + from fleet_management.models import VEHICLE_HINT_MAP + + gis_hints = {hint.value for hint in VehicleHint} + fleet_hints = set(VEHICLE_HINT_MAP.keys()) + + assert gis_hints == fleet_hints, ( + f"GIS hints {gis_hints} don't match fleet management hints {fleet_hints}. " + "Both services must use the same hint values." + ) + + def test_difficulty_score_drives_correct_vehicle_type_in_fleet(self): + """ + Integration test: GIS terrain score for Mokhotlong should result in + 4x4_required hint which fleet management maps to FOUR_BY_FOUR vehicles only. + """ + from fleet_management.models import VEHICLE_HINT_MAP + + result = self._run_pipeline("Mokhotlong") + assert result["success"] is True + + vehicle_hint = result["response"].vehicle_hint.value + acceptable_types = VEHICLE_HINT_MAP.get(vehicle_hint, []) + + # For 4x4_required or specialist_required, sedan should NOT be acceptable + assert "sedan" not in acceptable_types, ( + f"Sedan should not be acceptable for Mokhotlong terrain " + f"(hint: {vehicle_hint})" + ) + + def test_invalid_destination_fails_gracefully(self): + """Unknown destination should fail with a clear error, not crash.""" + result = self._run_pipeline("") + + assert result["success"] is False + assert "errors" in result + + +# =========================================================================== +# STAGE 6 — MODELS VALIDATION +# =========================================================================== + +class TestModels: + + def test_terrain_request_requires_trip_id_and_destination(self): + """TerrainRequest must have trip_id and destination.""" + req = TerrainRequest(trip_id="TR-001", destination="Maseru") + assert req.trip_id == "TR-001" + assert req.destination == "Maseru" + + def test_live_tracking_request_validates_lat_range(self): + """Latitude must be between -90 and 90.""" + with pytest.raises(Exception): + LiveTrackingRequest(trip_id="TR-001", latitude=91.0, longitude=27.0) + + def test_live_tracking_request_validates_lon_range(self): + """Longitude must be between -180 and 180.""" + with pytest.raises(Exception): + LiveTrackingRequest(trip_id="TR-001", latitude=-29.3, longitude=181.0) + + def test_coordinates_model(self): + """Coordinates model should store lat/lon and optional place name.""" + coords = Coordinates( + latitude=-29.3167, + longitude=27.4833, + place_name="Maseru", + display_name="Maseru, Lesotho", + ) + assert coords.latitude == -29.3167 + assert coords.longitude == 27.4833 + assert coords.place_name == "Maseru" + assert coords.display_name == "Maseru, Lesotho" + + def test_terrain_metadata_score_clamped_to_range(self): + """TerrainMetadata terrain_difficulty_score must be 0.0-1.0.""" + with pytest.raises(Exception): + TerrainMetadata( + coordinates=Coordinates(latitude=-29.3, longitude=27.4), + elevation=ElevationData( + elevation_meters=1600.0, + elevation_gain_meters=0.0, + terrain_type=TerrainType.URBAN, + ), + road=RoadData(road_type=RoadType.PAVED, road_description=""), + terrain_difficulty_score=1.5, # Invalid — above 1.0 + ) + + def test_batch_terrain_request_holds_multiple_requests(self): + """BatchTerrainRequest should hold a list of TerrainRequests.""" + batch = BatchTerrainRequest(requests=[ + TerrainRequest(trip_id="TR-001", destination="Maseru"), + TerrainRequest(trip_id="TR-002", destination="Mokhotlong"), + ]) + assert len(batch.requests) == 2 + assert batch.requests[0].trip_id == "TR-001" + assert batch.requests[1].trip_id == "TR-002" + + def test_road_type_enum_values(self): + """RoadType enum should have all expected values.""" + assert RoadType.PAVED.value == "paved" + assert RoadType.GRAVEL.value == "gravel" + assert RoadType.DIRT.value == "dirt" + assert RoadType.OFFROAD.value == "offroad" + assert RoadType.UNKNOWN.value == "unknown" + + def test_vehicle_hint_enum_values(self): + """VehicleHint enum values must match fleet management VEHICLE_HINT_MAP keys.""" + assert VehicleHint.SEDAN_OK.value == "sedan_ok" + assert VehicleHint.SUV_PREFERRED.value == "suv_preferred" + assert VehicleHint.FOUR_BY_FOUR_REQUIRED.value == "4x4_required" + assert VehicleHint.SPECIALIST_REQUIRED.value == "specialist_required" + + def test_terrain_type_enum_values(self): + """TerrainType enum should have all expected values.""" + assert TerrainType.FLAT.value == "flat" + assert TerrainType.HILLY.value == "hilly" + assert TerrainType.MOUNTAINOUS.value == "mountainous" + assert TerrainType.EXTREME.value == "extreme" + assert TerrainType.URBAN.value == "urban" + + +# =========================================================================== +# STAGE 7 — ROUTE REQUEST PROCESSOR (EXTENDED) +# =========================================================================== + +class TestRouteRequestProcessorExtended: + + def test_validate_destination_empty_string(self): + """Empty string should return an error.""" + errors = _validate_destination("") # type: ignore[attr-defined] + assert errors + assert any("empty" in e.lower() for e in errors) + + def test_validate_destination_too_short(self): + """Destination under 3 chars should return error.""" + errors = _validate_destination("ab") # type: ignore[attr-defined] + assert errors + assert any("too short" in e.lower() for e in errors) + + def test_validate_destination_too_long(self): + """Destination over 200 chars should return error.""" + errors = _validate_destination("x" * 201) # type: ignore[attr-defined] + assert errors + assert any("too long" in e.lower() for e in errors) + + def test_validate_destination_valid(self): + """Valid destination should return no errors.""" + errors = _validate_destination("Maseru") # type: ignore[attr-defined] + assert errors == [] + + def test_lookup_known_location_exact_match(self): + """Exact cache match should return correct coordinates.""" + coords = _lookup_known_location("maseru") # type: ignore[attr-defined] + assert coords is not None + assert coords.latitude == pytest.approx(-29.3167, abs=0.1) + + def test_lookup_known_location_partial_match(self): + """Partial match should work for hospital names.""" + coords = _lookup_known_location("Maseru Central Hospital") # type: ignore[attr-defined] + assert coords is not None + + def test_lookup_unknown_location_returns_none(self): + """Completely unknown location should return None.""" + coords = _lookup_known_location("Completely Unknown Place XYZ123") # type: ignore[attr-defined] + assert coords is None + + def test_resolve_coordinates_unknown_destination_fails(self): + """ + Destination not in cache and not in Nominatim + should fail gracefully with an error message. + """ + # Patch Nominatim to return None (simulate API failure) + with patch( + "gis_service.engines.route_request_processor._query_nominatim", + return_value=None + ): + result = resolve_coordinates("DefinitelyNotARealPlace99999") + assert result["success"] is False + assert result["errors"] + + def test_resolve_coordinates_nominatim_fallback(self): + """ + When destination is not in cache, Nominatim should be called. + If it returns a result, that result should be used. + """ + fake_coords = Coordinates( + latitude=-29.5, + longitude=27.8, + place_name="Fake Place", + ) + with patch( + "gis_service.engines.route_request_processor._query_nominatim", + return_value=fake_coords + ): + result = resolve_coordinates("Somewhere Unknown In Lesotho") + assert result["success"] is True + assert result["source"] == "nominatim" + assert result["coordinates"].place_name == "Fake Place" + + def test_resolve_coordinates_cache_takes_priority_over_nominatim(self): + """ + Cache should be checked before Nominatim. + Nominatim should NOT be called for known locations. + """ + with patch( + "gis_service.engines.route_request_processor._query_nominatim", + ) as mock_nominatim: + resolve_coordinates("Maseru") + mock_nominatim.assert_not_called() + + +# =========================================================================== +# STAGE 8 — TERRAIN ANALYSIS ENGINE (EXTENDED) +# =========================================================================== + +class TestTerrainAnalysisEngineExtended: + + def _maseru_coords(self) -> Coordinates: + return Coordinates(latitude=-29.3167, longitude=27.4833, place_name="Maseru") + + def test_analyse_terrain_falls_back_to_mock_when_elevation_api_fails(self): + """ + When Open-Elevation API returns None (failure), + analyse_terrain should fall back to mock data and succeed. + """ + with patch( + "gis_service.engines.terrain_analysis_engine._query_open_elevation", + return_value=None + ): + result = analyse_terrain( # type: ignore[call-arg] + self._maseru_coords(), + use_mock=False, + ) + assert result["success"] is True + assert result["is_mock"] is True + + def test_analyse_terrain_falls_back_to_mock_when_road_api_fails(self): + """ + When Overpass API returns UNKNOWN road type, + analyse_terrain should fall back to mock road data. + """ + with patch( + "gis_service.engines.terrain_analysis_engine._query_open_elevation", + return_value=1600.0 + ): + with patch( + "gis_service.engines.terrain_analysis_engine._query_overpass_road_type", + return_value=(RoadType.UNKNOWN, None, None) + ): + result = analyse_terrain( # type: ignore[call-arg] + self._maseru_coords(), + use_mock=False, + ) + assert result["success"] is True + # Should have fallen back to mock road type + assert result["metadata"].road.road_type != RoadType.UNKNOWN + + def test_analyse_terrain_uses_real_api_data_when_available(self): + """ + When APIs return valid data, analyse_terrain should use it + and mark is_mock=False. + """ + with patch( + "gis_service.engines.terrain_analysis_engine._query_open_elevation", + return_value=1750.0 + ): + with patch( + "gis_service.engines.terrain_analysis_engine._query_overpass_road_type", + return_value=(RoadType.PAVED, "primary", "asphalt") + ): + result = analyse_terrain( # type: ignore[call-arg] + self._maseru_coords(), + use_mock=False, + ) + assert result["success"] is True + assert result["is_mock"] is False + assert result["metadata"].elevation.elevation_meters == 1750.0 + assert result["metadata"].road.road_type == RoadType.PAVED + assert result["metadata"].road.highway_tag == "primary" + assert result["metadata"].road.surface_tag == "asphalt" + + def test_elevation_gain_computed_from_maseru_baseline(self): + """Elevation gain should be destination elevation minus Maseru baseline.""" + result = analyse_terrain( # type: ignore[call-arg] + Coordinates(latitude=-29.28, longitude=29.07, place_name="Mokhotlong"), + use_mock=True, + ) + metadata = result["metadata"] + expected_gain = metadata.elevation.elevation_meters - MASERU_ELEVATION_M # type: ignore[attr-defined] + assert metadata.elevation.elevation_gain_meters == pytest.approx(expected_gain, abs=1.0) + + def test_elevation_gain_never_negative(self): + """Elevation gain should be 0 or positive — never negative.""" + # Quthing is below Maseru + result = analyse_terrain( # type: ignore[call-arg] + Coordinates(latitude=-30.4, longitude=27.7, place_name="Quthing"), + use_mock=True, + ) + assert result["metadata"].elevation.elevation_gain_meters >= 0.0 + + def test_road_data_includes_description(self): + """RoadData should include a human-readable description.""" + result = analyse_terrain( # type: ignore[call-arg] + self._maseru_coords(), use_mock=True + ) + assert result["metadata"].road.road_description != "" + + def test_all_lesotho_districts_produce_valid_scores(self): + """ + Running the terrain engine for all district capitals + should produce valid difficulty scores. + """ + from gis_service.engines.route_request_processor import LESOTHO_LOCATIONS + for name, coords in LESOTHO_LOCATIONS.items(): + result = analyse_terrain(coords, use_mock=True) # type: ignore[call-arg] + assert result["success"] is True, f"Failed for {name}" + score = result["metadata"].terrain_difficulty_score + assert 0.0 <= score <= 1.0, f"Score {score} out of range for {name}" + + +# =========================================================================== +# STAGE 9 — GIS API ENDPOINTS +# =========================================================================== + +class TestGISAPI: + """ + Tests for the GIS FastAPI endpoints. + Uses FastAPI TestClient — no real HTTP requests, no network needed. + """ + + @pytest.fixture + def client(self): + from fastapi import FastAPI + from gis_service.api.gis_api import router + app = FastAPI() + app.include_router(router) + return TestClient(app) + + def test_health_endpoint_returns_200(self, client: TestClient): + """GET /gis/health should return 200 and service info.""" + response = client.get("/gis/health") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "healthy" + assert "data_sources" in data + + def test_terrain_endpoint_returns_200_for_maseru(self, client: TestClient): + """POST /gis/terrain should return 200 for a known destination.""" + response = client.post( + "/gis/terrain?use_mock=true", + json={"trip_id": "TR-001", "destination": "Maseru"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["trip_id"] == "TR-001" + assert data["destination"] == "Maseru" + assert "terrain_difficulty_score" in data + assert "vehicle_hint" in data + assert data["is_mock"] is True + + def test_terrain_endpoint_returns_correct_hint_for_mokhotlong(self, client: TestClient): + """Mokhotlong should return 4x4_required or specialist_required hint.""" + response = client.post( + "/gis/terrain?use_mock=true", + json={"trip_id": "TR-002", "destination": "Mokhotlong"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["vehicle_hint"] in ["4x4_required", "specialist_required"] + + def test_terrain_endpoint_returns_422_for_empty_destination(self, client: TestClient): + """Empty destination should return 422 Unprocessable Entity.""" + response = client.post( + "/gis/terrain?use_mock=true", + json={"trip_id": "TR-001", "destination": ""}, + ) + assert response.status_code == 422 + + def test_terrain_endpoint_returns_422_for_unknown_destination(self, client: TestClient): + """Unknown destination (not in cache, Nominatim returns nothing) returns 422.""" + with patch( + "gis_service.engines.route_request_processor._query_nominatim", + return_value=None + ): + response = client.post( + "/gis/terrain?use_mock=true", + json={"trip_id": "TR-001", "destination": "UnknownPlace99999"}, + ) + assert response.status_code == 422 + + def test_batch_terrain_endpoint_returns_all_results(self, client: TestClient): + """POST /gis/terrain/batch should return results for all valid requests.""" + response = client.post( + "/gis/terrain/batch?use_mock=true", + json={ + "requests": [ + {"trip_id": "TR-001", "destination": "Maseru"}, + {"trip_id": "TR-002", "destination": "Mokhotlong"}, + {"trip_id": "TR-003", "destination": "Leribe"}, + ] + }, + ) + assert response.status_code == 200 + data = response.json() + assert len(data["results"]) == 3 + assert data["failed"] == [] + + def test_batch_terrain_partial_failure(self, client: TestClient): + """ + In a batch request, failed destinations should appear in 'failed' + without causing the whole batch to fail. + """ + with patch( + "gis_service.engines.route_request_processor._query_nominatim", + return_value=None + ): + response = client.post( + "/gis/terrain/batch?use_mock=true", + json={ + "requests": [ + {"trip_id": "TR-001", "destination": "Maseru"}, + {"trip_id": "TR-002", "destination": "UnknownPlace99999"}, + ] + }, + ) + assert response.status_code == 200 + data = response.json() + assert len(data["results"]) == 1 + assert len(data["failed"]) == 1 + assert data["failed"][0]["trip_id"] == "TR-002" + + def test_live_tracking_endpoint_returns_200(self, client: TestClient): + """POST /gis/live-tracking should return 200 with terrain context.""" + response = client.post( + "/gis/live-tracking?use_mock=true", + json={ + "trip_id": "TR-001", + "latitude": -29.3167, + "longitude": 27.4833, + }, + ) + assert response.status_code == 200 + data = response.json() + assert data["trip_id"] == "TR-001" + assert data["latitude"] == -29.3167 + assert data["longitude"] == 27.4833 + assert "elevation_meters" in data + assert "road_type" in data + assert "terrain_type" in data + + def test_live_tracking_rejects_invalid_coordinates(self, client: TestClient): + """Live tracking should reject coordinates outside valid ranges.""" + response = client.post( + "/gis/live-tracking?use_mock=true", + json={ + "trip_id": "TR-001", + "latitude": 95.0, # Invalid + "longitude": 27.4833, + }, + ) + assert response.status_code == 422 + + def test_coordinates_endpoint_returns_maseru(self, client: TestClient): + """GET /gis/coordinates should return coordinates for Maseru.""" + response = client.get("/gis/coordinates?destination=Maseru") + assert response.status_code == 200 + data = response.json() + assert data["latitude"] == pytest.approx(-29.3167, abs=0.1) + assert data["source"] == "cache" + + def test_coordinates_endpoint_returns_422_for_empty(self, client: TestClient): + """GET /gis/coordinates with empty destination should return 422.""" + response = client.get("/gis/coordinates?destination=") + assert response.status_code == 422 + + def test_suitability_endpoint_sedan_ok(self, client: TestClient): + """GET /gis/suitability with score 0.1 should return sedan_ok.""" + response = client.get("/gis/suitability?score=0.1") + assert response.status_code == 200 + data = response.json() + assert data["vehicle_hint"] == "sedan_ok" + assert "description" in data + + def test_suitability_endpoint_4x4_required(self, client: TestClient): + """GET /gis/suitability with score 0.7 should return 4x4_required.""" + response = client.get("/gis/suitability?score=0.7") + assert response.status_code == 200 + data = response.json() + assert data["vehicle_hint"] == "4x4_required" + + def test_suitability_endpoint_rejects_score_above_1(self, client: TestClient): + """Suitability endpoint should reject scores above 1.0.""" + response = client.get("/gis/suitability?score=1.5") + assert response.status_code == 422 + + def test_suitability_endpoint_rejects_score_below_0(self, client: TestClient): + """Suitability endpoint should reject scores below 0.0.""" + response = client.get("/gis/suitability?score=-0.1") + assert response.status_code == 422 + + +# =========================================================================== +# STAGE 10 — FLEET MANAGEMENT INTEGRATION +# =========================================================================== + +class TestFleetManagementIntegration: + """ + Tests for the integration between the GIS service and Fleet Management. + Specifically tests _get_gis_terrain_score() in trip_request_processor. + """ + + def test_gis_terrain_score_called_on_trip_creation(self): + """ + When a trip is created, the GIS pipeline should be called + and the terrain_difficulty_score and vehicle_hint should be set. + """ + from fleet_management.engines.trip_request_processor import create_trip_request + from fleet_management.models import TripRequestIn + from datetime import datetime, timedelta + + trip_data = TripRequestIn( + user_id="U001", + pickup_location="Ministry of Health, Maseru", + destination="Maseru Central Hospital", + trip_date=datetime.now() + timedelta(days=1), + purpose="Medical consultation", + passengers=2, + ) + + result = create_trip_request(trip_data) + + assert result["success"] is True + trip = result["trip"] + # GIS pipeline should have set these + assert trip.terrain_difficulty_score is not None + assert trip.vehicle_hint is not None + + def test_maseru_destination_gets_low_terrain_score(self): + """ + A trip to Maseru should get a low terrain difficulty score + (flat/urban terrain → sedan_ok hint). + """ + from fleet_management.engines.trip_request_processor import create_trip_request + from fleet_management.models import TripRequestIn + from datetime import datetime, timedelta + + result = create_trip_request(TripRequestIn( + user_id="U001", + pickup_location="Government HQ", + destination="Maseru Central Office", + trip_date=datetime.now() + timedelta(days=1), + purpose="Official business", + passengers=1, + )) + + assert result["success"] is True + assert result["terrain_difficulty_score"] is not None + assert result["terrain_difficulty_score"] < 0.30 + assert result["vehicle_hint"] == "sedan_ok" + + def test_mokhotlong_destination_gets_high_terrain_score(self): + """ + A trip to Mokhotlong should get a high terrain difficulty score + (mountain terrain → 4x4_required hint). + """ + from fleet_management.engines.trip_request_processor import create_trip_request + from fleet_management.models import TripRequestIn + from datetime import datetime, timedelta + + result = create_trip_request(TripRequestIn( + user_id="U001", + pickup_location="Government HQ, Maseru", + destination="Mokhotlong District Hospital", + trip_date=datetime.now() + timedelta(days=1), + purpose="Official inspection", + passengers=1, + )) + + assert result["success"] is True + assert result["terrain_difficulty_score"] is not None + assert result["terrain_difficulty_score"] >= 0.55 + assert result["vehicle_hint"] in ["4x4_required", "specialist_required"] + + def test_gis_failure_defaults_to_safe_score(self): + """ + If the GIS pipeline fails completely, trip creation should still + succeed with a safe default score of 0.25. + """ + from fleet_management.engines.trip_request_processor import create_trip_request + from fleet_management.models import TripRequestIn + from datetime import datetime, timedelta + + with patch( + "gis_service.engines.route_request_processor.resolve_coordinates", + side_effect=Exception("GIS service unavailable") + ): + result = create_trip_request(TripRequestIn( + user_id="U001", + pickup_location="Government HQ", + destination="Maseru Central Office", + trip_date=datetime.now() + timedelta(days=1), + purpose="Test trip", + passengers=1, + )) + + assert result["success"] is True + # Should have fallen back to default score + assert result["trip"].terrain_difficulty_score == pytest.approx(0.25, abs=0.01) + + def test_vehicle_hint_written_to_trip_request(self): + """ + vehicle_hint on TripRequest should match what the GIS pipeline + computed based on terrain difficulty score. + """ + from fleet_management.engines.trip_request_processor import ( + create_trip_request, get_trip + ) + from fleet_management.models import TripRequestIn + from datetime import datetime, timedelta + + result = create_trip_request(TripRequestIn( + user_id="U001", + pickup_location="Ministry HQ", + destination="Maseru Central Office", + trip_date=datetime.now() + timedelta(days=1), + purpose="Official business", + passengers=1, + )) + + trip = get_trip(result["trip"].request_id) + assert trip is not None + assert trip.vehicle_hint is not None + + # vehicle_hint must be a valid key in VEHICLE_HINT_MAP + from fleet_management.models import VEHICLE_HINT_MAP + assert trip.vehicle_hint in VEHICLE_HINT_MAP \ No newline at end of file diff --git a/backend/tests/test_monitoring.py b/backend/tests/test_monitoring.py deleted file mode 100644 index 5f0a04e..0000000 --- a/backend/tests/test_monitoring.py +++ /dev/null @@ -1,48 +0,0 @@ - -# ---------------------------- -# test_monitoring.py -# ---------------------------- -from datetime import datetime -from fleet_management.monitoring import ( - generate_trip_token, - driver_authenticate_trip, - end_trip -) -from fleet_management.trip_request import TRIP_REQUESTS -from fleet_management.models import TripRequest - -def setup_module(module): - """Prepare a trip for authentication.""" - TRIP_REQUESTS.clear() - trip = TripRequest( - request_id="TR003", - user_id="U001", - pickup_location="Maseru", - destination="Leribe", - trip_date=datetime(2026, 1, 25), - purpose="Official visit", - status="allocated" - ) - TRIP_REQUESTS.append(trip) - -def test_generate_trip_token_qr(): - """Employee generates a QR token.""" - response = generate_trip_token("TR003", mode="qr") - assert response["mode"] == "qr" - assert "qr_code_base64" in response - assert response["token_validity_hours"] == 4 - assert hasattr(TRIP_REQUESTS[0], "token") and TRIP_REQUESTS[0].token is not None - -def test_driver_authenticate_trip_success(): - """Driver uses the employee's token to start the trip.""" - trip = TRIP_REQUESTS[0] - token = trip.token - response = driver_authenticate_trip("TR003", token) - assert "has started" in response - assert trip.status == "ongoing" - -def test_end_trip(): - """Trip should be marked as completed.""" - response = end_trip("TR003") - assert "completed" in response - assert TRIP_REQUESTS[0].status == "completed" diff --git a/backend/tests/test_notification_service.py b/backend/tests/test_notification_service.py new file mode 100644 index 0000000..a899941 --- /dev/null +++ b/backend/tests/test_notification_service.py @@ -0,0 +1,591 @@ +""" +tests/test_notification_service.py +------------------------------------ +Tests for the Notification Service. + +Design reference: + "Notification Service — lightweight delivery layer: + 1. Receive a notification request from another microservice + 2. Validate the notification payload (recipient + message + channel) + 3. Deliver the message via the specified channel (SMS, email, in-app) + 4. Return a basic success/failure response + 5. Log the message delivery outcome for record-keeping" + +Test structure: + - TestIntakeHandler — validation rules + - TestDeliveryEngine — channel routing and delivery + - TestNotificationLogManager — log storage and retrieval + - TestNotificationPipeline — full end-to-end flow +""" + +import pytest +from datetime import datetime + +from notification_service.models import ( + NotificationRequest, + DeliveryChannel, + DeliveryStatus, + MessageType, +) +from notification_service.engines.intake_handler import ( + handle_intake, + validate_notification, +) +from notification_service.engines.delivery_engine import ( + deliver, + get_in_app_notifications, + mark_in_app_read, + IN_APP_INBOX, +) +from notification_service.engines.notification_log_manager import ( + save_log, + get_logs_by_recipient, + get_logs_by_trip, + get_logs_by_status, + get_all_logs, + get_log_summary, + NOTIFICATION_LOGS, +) + + +# =========================================================================== +# FIXTURES +# =========================================================================== + +@pytest.fixture(autouse=True) +def reset_state(): + """Clears all in-memory stores before each test.""" + IN_APP_INBOX.clear() + NOTIFICATION_LOGS.clear() + yield + IN_APP_INBOX.clear() + NOTIFICATION_LOGS.clear() + + +def make_sms_request(**kwargs) -> NotificationRequest: + """Helper — creates a valid SMS notification request.""" + defaults = dict( + recipient_id = "D001", + recipient_contact = "+26650001111", + message_type = MessageType.TRIP_ALLOCATED, + message = "Your trip TR-001 has been allocated. Vehicle: ABC-123.", + channel = DeliveryChannel.SMS, + trip_id = "TR-001", + sender_service = "fleet_management", + ) + defaults.update(kwargs) + return NotificationRequest(**defaults) + + +def make_email_request(**kwargs) -> NotificationRequest: + """Helper — creates a valid Email notification request.""" + defaults = dict( + recipient_id = "U001", + recipient_contact = "thabo.mohapi@gov.ls", + message_type = MessageType.TRIP_APPROVED, + message = "Your trip request TR-001 has been approved.", + channel = DeliveryChannel.EMAIL, + trip_id = "TR-001", + sender_service = "fleet_management", + ) + defaults.update(kwargs) + return NotificationRequest(**defaults) + + +def make_in_app_request(**kwargs) -> NotificationRequest: + """Helper — creates a valid In-App notification request.""" + defaults = dict( + recipient_id = "U001", + message_type = MessageType.TOKEN_ISSUED, + message = "Your trip token is: 7F3K2P. Valid for 24 hours.", + channel = DeliveryChannel.IN_APP, + trip_id = "TR-001", + sender_service = "fleet_management", + ) + defaults.update(kwargs) + return NotificationRequest(**defaults) + + +# =========================================================================== +# STAGE 1 — INTAKE HANDLER +# =========================================================================== + +class TestIntakeHandler: + + # ----------------------------------------------------------------------- + # VALID REQUESTS + # ----------------------------------------------------------------------- + + def test_valid_sms_request_passes(self): + """A well-formed SMS request should pass validation.""" + request = make_sms_request() + result = handle_intake(request) + assert result["valid"] is True + assert result["errors"] == [] + + def test_valid_email_request_passes(self): + """A well-formed email request should pass validation.""" + request = make_email_request() + result = handle_intake(request) + assert result["valid"] is True + assert result["errors"] == [] + + def test_valid_in_app_request_passes(self): + """A valid in-app request (no contact required) should pass.""" + request = make_in_app_request() + result = handle_intake(request) + assert result["valid"] is True + assert result["errors"] == [] + + def test_in_app_requires_no_contact(self): + """In-app notifications should not require recipient_contact.""" + request = make_in_app_request(recipient_contact=None) + result = handle_intake(request) + assert result["valid"] is True + + # ----------------------------------------------------------------------- + # MISSING REQUIRED FIELDS + # ----------------------------------------------------------------------- + + def test_empty_recipient_id_fails(self): + """Empty recipient_id should fail validation.""" + request = make_sms_request(recipient_id="") + result = handle_intake(request) + assert result["valid"] is False + assert any("Recipient ID" in e for e in result["errors"]) + + def test_empty_message_fails(self): + """Empty message content should fail validation.""" + request = make_sms_request(message="") + result = handle_intake(request) + assert result["valid"] is False + assert any("Message content" in e for e in result["errors"]) + + def test_sms_without_contact_fails(self): + """SMS notification without a phone number should fail.""" + request = make_sms_request(recipient_contact=None) + result = handle_intake(request) + assert result["valid"] is False + assert any("recipient_contact" in e for e in result["errors"]) + + def test_email_without_contact_fails(self): + """Email notification without an email address should fail.""" + request = make_email_request(recipient_contact=None) + result = handle_intake(request) + assert result["valid"] is False + assert any("recipient_contact" in e for e in result["errors"]) + + # ----------------------------------------------------------------------- + # FORMAT VALIDATION + # ----------------------------------------------------------------------- + + def test_invalid_phone_number_fails(self): + """A malformed phone number should fail SMS validation.""" + request = make_sms_request(recipient_contact="not-a-phone") + result = handle_intake(request) + assert result["valid"] is False + assert any("phone number" in e for e in result["errors"]) + + def test_valid_lesotho_phone_passes(self): + """A valid Lesotho phone number (+266XXXXXXXX) should pass.""" + request = make_sms_request(recipient_contact="+26650001111") + result = handle_intake(request) + assert result["valid"] is True + + def test_invalid_email_fails(self): + """A malformed email address should fail email validation.""" + request = make_email_request(recipient_contact="not-an-email") + result = handle_intake(request) + assert result["valid"] is False + assert any("email address" in e for e in result["errors"]) + + def test_valid_email_passes(self): + """A properly formatted email should pass validation.""" + request = make_email_request(recipient_contact="user@gov.ls") + result = handle_intake(request) + assert result["valid"] is True + + def test_message_exceeding_max_length_fails(self): + """Messages over 1000 characters should be rejected.""" + long_message = "x" * 1001 + request = make_sms_request(message=long_message) + result = handle_intake(request) + assert result["valid"] is False + assert any("maximum length" in e for e in result["errors"]) + + def test_message_at_max_length_passes(self): + """Messages of exactly 1000 characters should pass.""" + exact_message = "x" * 1000 + request = make_sms_request(message=exact_message) + result = handle_intake(request) + assert result["valid"] is True + + # ----------------------------------------------------------------------- + # MULTIPLE ERRORS + # ----------------------------------------------------------------------- + + def test_multiple_validation_errors_returned(self): + """All validation errors should be returned, not just the first.""" + request = make_sms_request(recipient_id="", message="") + result = handle_intake(request) + assert result["valid"] is False + assert len(result["errors"]) >= 2 + + def test_validated_request_returned_on_success(self): + """Valid intake should return the request object for downstream use.""" + request = make_sms_request() + result = handle_intake(request) + assert result["request"] is not None + assert result["request"].recipient_id == "D001" + + def test_request_is_none_on_failure(self): + """Failed intake should return request=None.""" + request = make_sms_request(recipient_id="") + result = handle_intake(request) + assert result["request"] is None + + +# =========================================================================== +# STAGE 2 — DELIVERY ENGINE +# =========================================================================== + +class TestDeliveryEngine: + + # ----------------------------------------------------------------------- + # SMS DELIVERY + # ----------------------------------------------------------------------- + + def test_sms_delivery_returns_success(self): + """SMS delivery stub should return success.""" + request = make_sms_request() + result = deliver(request) + assert result["success"] is True + assert result["status"] == DeliveryStatus.DELIVERED + + def test_sms_delivery_returns_notification_id(self): + """Every delivery should return a unique notification ID.""" + request = make_sms_request() + result = deliver(request) + assert result["notification_id"].startswith("NTF-") + assert len(result["notification_id"]) == 12 # NTF- + 8 hex chars + + # ----------------------------------------------------------------------- + # EMAIL DELIVERY + # ----------------------------------------------------------------------- + + def test_email_delivery_returns_success(self): + """Email delivery stub should return success.""" + request = make_email_request() + result = deliver(request) + assert result["success"] is True + assert result["status"] == DeliveryStatus.DELIVERED + + def test_email_delivery_returns_notification_id(self): + """Email delivery should return a notification ID.""" + request = make_email_request() + result = deliver(request) + assert result["notification_id"].startswith("NTF-") + + # ----------------------------------------------------------------------- + # IN-APP DELIVERY + # ----------------------------------------------------------------------- + + def test_in_app_delivery_returns_success(self): + """In-app delivery should return success.""" + request = make_in_app_request() + result = deliver(request) + assert result["success"] is True + assert result["status"] == DeliveryStatus.DELIVERED + + def test_in_app_delivery_stores_in_inbox(self): + """In-app delivery should store the notification in the user's inbox.""" + request = make_in_app_request(recipient_id="U001") + deliver(request) + inbox = get_in_app_notifications("U001") + assert len(inbox) == 1 + assert inbox[0]["message"] == request.message + + def test_in_app_delivery_stores_correct_fields(self): + """In-app notification should include all required fields.""" + request = make_in_app_request(recipient_id="U001", trip_id="TR-999") + result = deliver(request) + inbox = get_in_app_notifications("U001") + item = inbox[0] + assert item["notification_id"] == result["notification_id"] + assert item["message_type"] == request.message_type.value + assert item["trip_id"] == "TR-999" + assert item["is_read"] is False + assert "timestamp" in item + + def test_in_app_multiple_notifications_accumulate(self): + """Multiple in-app deliveries to the same user should accumulate.""" + for i in range(3): + deliver(make_in_app_request( + recipient_id = "U001", + message = f"Notification {i}", + )) + inbox = get_in_app_notifications("U001") + assert len(inbox) == 3 + + def test_in_app_mark_as_read(self): + """Marking a notification as read should set is_read=True.""" + request = make_in_app_request(recipient_id="U001") + result = deliver(request) + ntf_id = result["notification_id"] + + found = mark_in_app_read("U001", ntf_id) + assert found is True + + inbox = get_in_app_notifications("U001") + assert inbox[0]["is_read"] is True + + def test_mark_nonexistent_notification_returns_false(self): + """Marking a non-existent notification as read should return False.""" + found = mark_in_app_read("U001", "NTF-DOESNOTEXIST") + assert found is False + + def test_different_users_have_separate_inboxes(self): + """Notifications to U001 should not appear in U002's inbox.""" + deliver(make_in_app_request(recipient_id="U001")) + deliver(make_in_app_request(recipient_id="U002")) + assert len(get_in_app_notifications("U001")) == 1 + assert len(get_in_app_notifications("U002")) == 1 + + # ----------------------------------------------------------------------- + # NOTIFICATION ID UNIQUENESS + # ----------------------------------------------------------------------- + + def test_each_delivery_gets_unique_notification_id(self): + """Every delivery attempt should produce a distinct notification ID.""" + ids = {deliver(make_sms_request())["notification_id"] for _ in range(10)} + assert len(ids) == 10 + + +# =========================================================================== +# STAGE 3 — NOTIFICATION LOG MANAGER +# =========================================================================== + +class TestNotificationLogManager: + + def _deliver_and_log(self, request: NotificationRequest): + """Helper — delivers and logs a notification, returns the log.""" + result = deliver(request) + return save_log( + request = request, + notification_id = result["notification_id"], + status = result["status"], + error_detail = result.get("error_detail"), + ) + + # ----------------------------------------------------------------------- + # LOG STORAGE + # ----------------------------------------------------------------------- + + def test_log_stores_all_required_fields(self): + """Log must contain recipient, message type, timestamp, status.""" + request = make_sms_request(recipient_id="D001", trip_id="TR-001") + log = self._deliver_and_log(request) + + assert log.recipient_id == "D001" + assert log.message_type == MessageType.TRIP_ALLOCATED + assert log.status == DeliveryStatus.DELIVERED + assert log.trip_id == "TR-001" + assert log.channel == DeliveryChannel.SMS + assert isinstance(log.timestamp, datetime) + + def test_log_stored_in_notification_logs(self): + """Saved log should appear in the NOTIFICATION_LOGS store.""" + self._deliver_and_log(make_sms_request()) + assert len(NOTIFICATION_LOGS) == 1 + + def test_multiple_logs_accumulate(self): + """Each delivery should add a new log entry.""" + for _ in range(5): + self._deliver_and_log(make_sms_request()) + assert len(NOTIFICATION_LOGS) == 5 + + # ----------------------------------------------------------------------- + # RETRIEVAL + # ----------------------------------------------------------------------- + + def test_get_logs_by_recipient(self): + """Should return only logs for the specified recipient.""" + self._deliver_and_log(make_sms_request(recipient_id="D001")) + self._deliver_and_log(make_sms_request(recipient_id="D002")) + logs = get_logs_by_recipient("D001") + assert len(logs) == 1 + assert logs[0].recipient_id == "D001" + + def test_get_logs_by_trip(self): + """Should return only logs associated with the specified trip.""" + self._deliver_and_log(make_sms_request(trip_id="TR-001")) + self._deliver_and_log(make_sms_request(trip_id="TR-002")) + logs = get_logs_by_trip("TR-001") + assert len(logs) == 1 + assert logs[0].trip_id == "TR-001" + + def test_get_logs_by_status_delivered(self): + """get_logs_by_status should return only delivered logs.""" + self._deliver_and_log(make_sms_request()) + logs = get_logs_by_status(DeliveryStatus.DELIVERED) + assert len(logs) == 1 + assert logs[0].status == DeliveryStatus.DELIVERED + + def test_get_all_logs_returns_all(self): + """get_all_logs should return all stored logs.""" + for _ in range(3): + self._deliver_and_log(make_sms_request()) + logs = get_all_logs() + assert len(logs) == 3 + + def test_get_all_logs_most_recent_first(self): + """Logs should be returned most recent first.""" + self._deliver_and_log(make_sms_request(message="First")) + self._deliver_and_log(make_sms_request(message="Second")) + logs = get_all_logs() + # Most recent should be "Second" (later timestamp) + assert logs[0].timestamp >= logs[1].timestamp + + def test_get_logs_pagination(self): + """get_all_logs should respect limit and offset.""" + for _ in range(10): + self._deliver_and_log(make_sms_request()) + page1 = get_all_logs(limit=5, offset=0) + page2 = get_all_logs(limit=5, offset=5) + assert len(page1) == 5 + assert len(page2) == 5 + # No overlap + ids1 = {log.notification_id for log in page1} + ids2 = {log.notification_id for log in page2} + assert ids1.isdisjoint(ids2) + + def test_empty_recipient_log_returns_empty_list(self): + """Requesting logs for a recipient with none returns empty list.""" + logs = get_logs_by_recipient("U_NOBODY") + assert logs == [] + + # ----------------------------------------------------------------------- + # SUMMARY + # ----------------------------------------------------------------------- + + def test_summary_counts_correctly(self): + """Log summary should accurately reflect totals by status and channel.""" + self._deliver_and_log(make_sms_request()) + self._deliver_and_log(make_email_request()) + self._deliver_and_log(make_in_app_request()) + + summary = get_log_summary() + assert summary["total"] == 3 + assert summary["delivered"] == 3 + assert summary["failed"] == 0 + assert summary["by_channel"]["sms"] == 1 + assert summary["by_channel"]["email"] == 1 + assert summary["by_channel"]["in_app"] == 1 + + def test_summary_is_empty_when_no_logs(self): + """Summary should show zeros when no logs exist.""" + summary = get_log_summary() + assert summary["total"] == 0 + assert summary["delivered"] == 0 + assert summary["failed"] == 0 + + +# =========================================================================== +# FULL PIPELINE TESTS +# =========================================================================== + +class TestNotificationPipeline: + """ + End-to-end tests that exercise the full design flow: + intake -> valid? -> delivery -> log -> response + """ + + def _full_pipeline(self, request: NotificationRequest) -> dict: + """Simulates what the API endpoint does.""" + intake = handle_intake(request) + if not intake["valid"]: + return {"success": False, "errors": intake["errors"]} + + delivery = deliver(request) + log = save_log( + request = request, + notification_id = delivery["notification_id"], + status = delivery["status"], + ) + return { + "success": delivery["success"], + "notification_id": delivery["notification_id"], + "status": delivery["status"], + "log": log, + } + + def test_full_sms_pipeline(self): + """Full SMS pipeline should succeed end-to-end.""" + result = self._full_pipeline(make_sms_request()) + assert result["success"] is True + assert result["status"] == DeliveryStatus.DELIVERED + assert len(NOTIFICATION_LOGS) == 1 + + def test_full_email_pipeline(self): + """Full email pipeline should succeed end-to-end.""" + result = self._full_pipeline(make_email_request()) + assert result["success"] is True + assert len(NOTIFICATION_LOGS) == 1 + + def test_full_in_app_pipeline(self): + """Full in-app pipeline should succeed and populate inbox.""" + result = self._full_pipeline(make_in_app_request(recipient_id="U001")) + assert result["success"] is True + assert len(get_in_app_notifications("U001")) == 1 + assert len(NOTIFICATION_LOGS) == 1 + + def test_invalid_request_stops_pipeline(self): + """Invalid intake should stop pipeline — no delivery, no log.""" + result = self._full_pipeline(make_sms_request(recipient_id="")) + assert result["success"] is False + assert len(NOTIFICATION_LOGS) == 0 + assert len(IN_APP_INBOX) == 0 + + def test_all_message_types_are_deliverable(self): + """Every MessageType should produce a successful delivery.""" + for msg_type in MessageType: + request = make_in_app_request( + message_type = msg_type, + message = f"Test notification for {msg_type.value}", + ) + intake = handle_intake(request) + assert intake["valid"] is True, f"Intake failed for {msg_type.value}" + result = deliver(request) + assert result["success"] is True, f"Delivery failed for {msg_type.value}" + + def test_trip_lifecycle_notifications_all_logged(self): + """ + Simulates a full trip lifecycle — every event generates a notification + that is correctly logged with the trip_id for traceability. + """ + trip_id = "TR-LIFECYCLE" + + events = [ + (MessageType.TRIP_APPROVED, "U001", DeliveryChannel.IN_APP, None), + (MessageType.TRIP_ALLOCATED, "D001", DeliveryChannel.SMS, "+26650001111"), + (MessageType.TOKEN_ISSUED, "U001", DeliveryChannel.IN_APP, None), + (MessageType.TRIP_STARTED, "U001", DeliveryChannel.IN_APP, None), + (MessageType.TRIP_ARRIVED, "U001", DeliveryChannel.IN_APP, None), + (MessageType.TRIP_COMPLETED, "F001", DeliveryChannel.IN_APP, None), + ] + + for msg_type, recipient, channel, contact in events: + request = NotificationRequest( + recipient_id = recipient, + recipient_contact = contact, + message_type = msg_type, + message = f"Trip {trip_id}: {msg_type.value}", + channel = channel, + trip_id = trip_id, + sender_service = "fleet_management", + ) + self._full_pipeline(request) + + trip_logs = get_logs_by_trip(trip_id) + assert len(trip_logs) == len(events) + assert all(log.trip_id == trip_id for log in trip_logs) \ No newline at end of file diff --git a/backend/tests/test_placeholder.py b/backend/tests/test_placeholder.py deleted file mode 100644 index 3ada1ee..0000000 --- a/backend/tests/test_placeholder.py +++ /dev/null @@ -1,2 +0,0 @@ -def test_placeholder(): - assert True diff --git a/backend/tests/test_trip_request.py b/backend/tests/test_trip_request.py deleted file mode 100644 index a65284e..0000000 --- a/backend/tests/test_trip_request.py +++ /dev/null @@ -1,34 +0,0 @@ -# ---------------------------- -# test_trip_request_.py -# ---------------------------- -from datetime import datetime -import pytest -from fleet_management.trip_request import create_trip_request, recommend_vehicle, TRIP_REQUESTS -from fleet_management.models import TripRequest - -def setup_module(module): - """Clear previous trips before running tests.""" - TRIP_REQUESTS.clear() - -def test_create_trip_request(): - trip = create_trip_request( - user_id="U001", - pickup="Maseru Central", - destination="Thaba-Tseka", - trip_date=datetime(2026, 1, 15), - purpose="Official Meeting" - ) - assert isinstance(trip, TripRequest) - assert trip.user_id == "U001" - assert trip.pickup_location == "Maseru Central" - assert trip.destination == "Thaba-Tseka" - assert trip.status == "pending" - assert trip in TRIP_REQUESTS - -def test_recommend_vehicle(): - trip = TRIP_REQUESTS[0] # Use the existing trip - vehicle = recommend_vehicle(trip) - assert vehicle.registration_number is not None - assert vehicle.vehicle_type in ["sedan", "4x4", "minibus"] - -