From 18fd92eef29d84d9115431a6090ebecea39a0857 Mon Sep 17 00:00:00 2001 From: Bafokeng Masitha Date: Mon, 23 Mar 2026 22:44:56 +0200 Subject: [PATCH 1/5] Code for the new improved design but 50% done --- backend/main.py | 34 - backend/pyrightconfig.json | 4 + backend/pytest.ini | 3 + .../services/fleet_management/allocation.py | 4 +- .../services/fleet_management/api/__init__.py | 0 .../{ => api}/fleet_management_api.py | 2 +- .../fleet_management/engines/__init__.py | 0 .../engines/driver_vehicle_state_manager.py | 408 +++++ .../engines/dynamic_reallocation_engine.py | 0 .../engines/heuristic_allocation_engine.py | 558 +++++++ .../engines/maintenance_wellbeing_engine.py | 0 .../engines/optimization_engine.py | 0 .../engines/standby_market_engine.py | 0 .../engines/token_generator_engine.py | 347 +++++ .../engines/trip_lifecycle_engine.py | 341 +++++ .../engines/trip_request_processor.py | 158 ++ .../engines/vehicle_suitability_module.py | 0 backend/services/fleet_management/models.py | 277 +++- .../services/fleet_management/monitoring.py | 2 +- .../services/fleet_management/trip_request.py | 45 - backend/tests/test_allocation.py | 47 - backend/tests/test_fleet_management.py | 1315 +++++++++++++++++ backend/tests/test_monitoring.py | 48 - backend/tests/test_placeholder.py | 2 - backend/tests/test_trip_request.py | 34 - 25 files changed, 3386 insertions(+), 243 deletions(-) create mode 100644 backend/pyrightconfig.json create mode 100644 backend/pytest.ini create mode 100644 backend/services/fleet_management/api/__init__.py rename backend/services/fleet_management/{ => api}/fleet_management_api.py (96%) create mode 100644 backend/services/fleet_management/engines/__init__.py create mode 100644 backend/services/fleet_management/engines/driver_vehicle_state_manager.py create mode 100644 backend/services/fleet_management/engines/dynamic_reallocation_engine.py create mode 100644 backend/services/fleet_management/engines/heuristic_allocation_engine.py create mode 100644 backend/services/fleet_management/engines/maintenance_wellbeing_engine.py create mode 100644 backend/services/fleet_management/engines/optimization_engine.py create mode 100644 backend/services/fleet_management/engines/standby_market_engine.py create mode 100644 backend/services/fleet_management/engines/token_generator_engine.py create mode 100644 backend/services/fleet_management/engines/trip_lifecycle_engine.py create mode 100644 backend/services/fleet_management/engines/trip_request_processor.py create mode 100644 backend/services/fleet_management/engines/vehicle_suitability_module.py delete mode 100644 backend/services/fleet_management/trip_request.py delete mode 100644 backend/tests/test_allocation.py create mode 100644 backend/tests/test_fleet_management.py delete mode 100644 backend/tests/test_monitoring.py delete mode 100644 backend/tests/test_placeholder.py delete mode 100644 backend/tests/test_trip_request.py diff --git a/backend/main.py b/backend/main.py index 8590e9a..e69de29 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,34 +0,0 @@ -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() 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/services/fleet_management/allocation.py b/backend/services/fleet_management/allocation.py index 660f41a..6744fe8 100644 --- a/backend/services/fleet_management/allocation.py +++ b/backend/services/fleet_management/allocation.py @@ -2,7 +2,7 @@ from typing import Optional import random from .models import TripRequest, User, Vehicle -from .trip_request import TRIP_REQUESTS, VEHICLE_POOL, EMPLOYEES +from .engines.trip_request_processor import TRIP_REQUESTS, VEHICLE_POOL, EMPLOYEES from .monitoring import generate_trip_token # <-- Use token for trip auth # ---------------------------- @@ -105,7 +105,7 @@ def rerecommend_trip(request_id: str, new_vehicle_id: Optional[str] = None, new_ def auto_rerecommend_trip(request_id: str): """System automatically re-runs recommendation for vehicle/driver.""" - from .trip_request import recommend_vehicle + from .engines.trip_request_processor import recommend_vehicle trip = next((t for t in TRIP_REQUESTS if t.request_id == request_id), None) if not trip: 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/fleet_management_api.py b/backend/services/fleet_management/api/fleet_management_api.py similarity index 96% rename from backend/services/fleet_management/fleet_management_api.py rename to backend/services/fleet_management/api/fleet_management_api.py index 2b05f23..9bf8125 100644 --- a/backend/services/fleet_management/fleet_management_api.py +++ b/backend/services/fleet_management/api/fleet_management_api.py @@ -1,7 +1,7 @@ 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 services.fleet_management.engines.trip_request_processor 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 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/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..e69de29 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..a79ca5e --- /dev/null +++ b/backend/services/fleet_management/engines/heuristic_allocation_engine.py @@ -0,0 +1,558 @@ +""" +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, +) +from fleet_management.engines.trip_request_processor import ( + EMPLOYEES, + get_trip, +) +from fleet_management.engines.token_generator_engine import issue_token + + +# =========================================================================== +# 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() + + # 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 — translates GIS terrain hint into acceptable vehicle types +# +# 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[VehicleType]] = { + "sedan_ok": [VehicleType.SEDAN, VehicleType.SUV, VehicleType.FOUR_BY_FOUR, VehicleType.MINIBUS], + "suv_preferred": [VehicleType.SUV, VehicleType.FOUR_BY_FOUR], + "4x4_required": [VehicleType.FOUR_BY_FOUR], + "specialist_required": [], +} + + +# =========================================================================== +# 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, list(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 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 + # standby_market_engine.py will handle this when we build it + # For now we flag it clearly so the API layer can handle it + return { + "success": False, + "trip_id": trip.request_id, + "status": trip.status, # Stays APPROVED + "escalate_to_standby": True, + "errors": [ + "No suitable government vehicle available. " + "Escalating to Standby Market Engine." + ], + } + + # --- 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..e69de29 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..e69de29 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..e69de29 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..f01e658 --- /dev/null +++ b/backend/services/fleet_management/engines/trip_request_processor.py @@ -0,0 +1,158 @@ +""" +fleet_management/engines/trip_request_processor.py +---------------------------------------------------- +Responsible for: + 1. Validating incoming trip requests from employees + 2. Creating and storing TripRequest objects + +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, +) + + +# =========================================================================== +# 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 _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 + + +# =========================================================================== +# 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. Create and store the TripRequest + 4. Return a structured result with trip details flag + + Returns a dict with keys: + - success (bool) + - trip (TripRequest | None) + - errors (list[str]) + """ + + # Step 1 — Verify employee exists + employee = _get_employee(data.user_id) + if not employee: + return { + "success": False, + "trip": None, + "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, + "errors": errors, + } + + + # Step 3 — 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 4 — Return result + return { + "success": True, + "trip": trip, + "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..e69de29 diff --git a/backend/services/fleet_management/models.py b/backend/services/fleet_management/models.py index b2d4903..c42489e 100644 --- a/backend/services/fleet_management/models.py +++ b/backend/services/fleet_management/models.py @@ -1,55 +1,274 @@ -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" + + +# =========================================================================== +# 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 + +# --------------------------------------------------------------------------- +# Vehicles +# --------------------------------------------------------------------------- -@dataclass -class Vehicle: +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 +# --------------------------------------------------------------------------- + +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 -@dataclass -class TripRequest: +# --------------------------------------------------------------------------- +# 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 + + # 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 index 2d524fd..59e5c9b 100644 --- a/backend/services/fleet_management/monitoring.py +++ b/backend/services/fleet_management/monitoring.py @@ -5,7 +5,7 @@ from io import BytesIO from datetime import datetime, timedelta from .models import TripRequest -from .trip_request import TRIP_REQUESTS +from .engines.trip_request_processor import TRIP_REQUESTS def generate_trip_token(trip_id: str, mode="qr") -> dict | str: """ 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/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..2d6ea84 --- /dev/null +++ b/backend/tests/test_fleet_management.py @@ -0,0 +1,1315 @@ +""" +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) + +Run from backend/: + pytest tests/test_fleet_management.py -v +""" + +import pytest +from datetime import datetime, timedelta + +from fleet_management.models import ( + TripStatus, + VehicleStatus, + 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, +) + + +# =========================================================================== +# 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() + + 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 vehicles are unavailable, the system should flag + escalation to the Standby Market Engine. + """ + 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 False + assert result["escalate_to_standby"] is True + + 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 + # Score should be low but not necessarily 0.0 + assert score < 0.4 + + def test_vehicle_with_no_service_record_scores_low(self): + """A vehicle with no service history should score low due to worst-case assumptions.""" + vehicle = VEHICLE_POOL[0] + vehicle.last_service_date = None + vehicle.manufacture_date = None + + score = compute_vehicle_readiness(vehicle) + + # service_factor = 0.0 (no record), age_factor = 0.5 (unknown), mileage = 0.0 (no service) + # score = (0.0 * 0.5) + (0.5 * 0.2) + (0.0 * 0.3) = 0.10 + assert score == pytest.approx(0.10, abs=0.01) + + 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"]) \ 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_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"] - - From 1f46869f12a90d1b2232f73516cd33577c8ffa50 Mon Sep 17 00:00:00 2001 From: Bafokeng Masitha Date: Mon, 23 Mar 2026 22:48:34 +0200 Subject: [PATCH 2/5] Add code for new design that is 50% done --- .../services/fleet_management/allocation.py | 122 ------------------ .../services/fleet_management/monitoring.py | 99 -------------- 2 files changed, 221 deletions(-) delete mode 100644 backend/services/fleet_management/allocation.py delete mode 100644 backend/services/fleet_management/monitoring.py diff --git a/backend/services/fleet_management/allocation.py b/backend/services/fleet_management/allocation.py deleted file mode 100644 index 6744fe8..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 .engines.trip_request_processor 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 .engines.trip_request_processor 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/monitoring.py b/backend/services/fleet_management/monitoring.py deleted file mode 100644 index 59e5c9b..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 .engines.trip_request_processor 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}." From 4ddabb9391310fabfb2625290b7f130ce5233a19 Mon Sep 17 00:00:00 2001 From: Bafokeng Masitha Date: Wed, 25 Mar 2026 13:55:35 +0200 Subject: [PATCH 3/5] Implementation of the updated design fleet management service where everything is implemented except the vehicle suitability module and MILP optimizer. Also the API endpoints have been created. --- backend/main.py | 43 + .../api/fleet_management_api.py | 593 ++++++- .../engines/allocation_utils.py | 133 ++ .../engines/dynamic_reallocation_engine.py | 771 +++++++++ .../engines/heuristic_allocation_engine.py | 56 +- .../engines/maintenance_wellbeing_engine.py | 363 ++++ .../engines/priority_policy_engine.py | 389 +++++ .../engines/standby_market_engine.py | 526 ++++++ backend/services/fleet_management/models.py | 27 + backend/tests/test_fleet_management.py | 1457 ++++++++++++++++- 10 files changed, 4243 insertions(+), 115 deletions(-) create mode 100644 backend/services/fleet_management/engines/allocation_utils.py create mode 100644 backend/services/fleet_management/engines/priority_policy_engine.py diff --git a/backend/main.py b/backend/main.py index e69de29..2a40b0a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -0,0 +1,43 @@ +""" +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/services/fleet_management/api/fleet_management_api.py b/backend/services/fleet_management/api/fleet_management_api.py index 9bf8125..b94e05f 100644 --- a/backend/services/fleet_management/api/fleet_management_api.py +++ b/backend/services/fleet_management/api/fleet_management_api.py @@ -1,88 +1,533 @@ -from fastapi import FastAPI, HTTPException +""" +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 services.fleet_management.engines.trip_request_processor 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, +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, +) + + +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 ) - recommend_vehicle(trip) - return {"message": f"Trip {trip.request_id} created successfully", "trip_id": trip.request_id} + if not result["success"]: + _raise(result) + return result -@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} +@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 -@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) +@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 -@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} +@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 -@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} +@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 -@app.patch("/trip-request/{request_id}/complete") -def complete_trip(request_id: str): - result = end_trip(request_id) - return {"message": 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/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/dynamic_reallocation_engine.py b/backend/services/fleet_management/engines/dynamic_reallocation_engine.py index e69de29..c2a4fd2 100644 --- a/backend/services/fleet_management/engines/dynamic_reallocation_engine.py +++ b/backend/services/fleet_management/engines/dynamic_reallocation_engine.py @@ -0,0 +1,771 @@ +""" +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 + return escalate_to_standby(trip_id, pickup_lat, pickup_lon) + + 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 + return escalate_to_standby(trip.request_id, pickup_lat, pickup_lon) + + if not driver: + return { + "success": False, + "trip_id": trip_id, + "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 + return escalate_to_standby(trip_id, pickup_lat, pickup_lon) + + # 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 index a79ca5e..e02fd32 100644 --- a/backend/services/fleet_management/engines/heuristic_allocation_engine.py +++ b/backend/services/fleet_management/engines/heuristic_allocation_engine.py @@ -31,12 +31,21 @@ 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 # =========================================================================== @@ -136,6 +145,13 @@ def process_admin_decision(request_id: str, admin_id: str, data: AdminApprovalIn 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) @@ -228,23 +244,8 @@ def process_admin_decision(request_id: str, admin_id: str, data: AdminApprovalIn ), ] -# --------------------------------------------------------------------------- -# Vehicle hint map — translates GIS terrain hint into acceptable vehicle types -# -# 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[VehicleType]] = { - "sedan_ok": [VehicleType.SEDAN, VehicleType.SUV, VehicleType.FOUR_BY_FOUR, VehicleType.MINIBUS], - "suv_preferred": [VehicleType.SUV, VehicleType.FOUR_BY_FOUR], - "4x4_required": [VehicleType.FOUR_BY_FOUR], - "specialist_required": [], -} +# VEHICLE_HINT_MAP imported from models.py +# Moved there to avoid circular import with standby_market_engine # =========================================================================== @@ -269,7 +270,7 @@ def _select_best_vehicle(trip: TripRequest) -> Optional[Vehicle]: 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, list(VehicleType)) + 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: @@ -278,7 +279,7 @@ def _select_best_vehicle(trip: TripRequest) -> Optional[Vehicle]: candidates = [ v for v in VEHICLE_POOL if v.current_status == VehicleStatus.AVAILABLE - and v.vehicle_type in acceptable_types + and v.vehicle_type.value in acceptable_types and v.capacity >= trip.passengers ] @@ -356,17 +357,14 @@ def auto_allocate(trip: TripRequest) -> dict: if not vehicle: # Design decision: no standard vehicle available -> escalate to standby market - # standby_market_engine.py will handle this when we build it - # For now we flag it clearly so the API layer can handle it + # 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 { - "success": False, - "trip_id": trip.request_id, - "status": trip.status, # Stays APPROVED - "escalate_to_standby": True, - "errors": [ - "No suitable government vehicle available. " - "Escalating to Standby Market Engine." - ], + **standby_result, + "escalate_to_standby": not standby_result["success"], } # --- DRIVER SELECTION --- diff --git a/backend/services/fleet_management/engines/maintenance_wellbeing_engine.py b/backend/services/fleet_management/engines/maintenance_wellbeing_engine.py index e69de29..19e2794 100644 --- a/backend/services/fleet_management/engines/maintenance_wellbeing_engine.py +++ 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/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 index e69de29..60ef069 100644 --- a/backend/services/fleet_management/engines/standby_market_engine.py +++ 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/models.py b/backend/services/fleet_management/models.py index c42489e..89a23ad 100644 --- a/backend/services/fleet_management/models.py +++ b/backend/services/fleet_management/models.py @@ -71,6 +71,29 @@ class TokenMode(str, Enum): 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 # =========================================================================== @@ -210,6 +233,10 @@ class TripRequest(BaseModel): 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" diff --git a/backend/tests/test_fleet_management.py b/backend/tests/test_fleet_management.py index 2d6ea84..54a5f91 100644 --- a/backend/tests/test_fleet_management.py +++ b/backend/tests/test_fleet_management.py @@ -11,6 +11,10 @@ 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) Run from backend/: pytest tests/test_fleet_management.py -v @@ -22,6 +26,7 @@ from fleet_management.models import ( TripStatus, VehicleStatus, + VehicleType, DriverStatus, TripRequestIn, AdminApprovalIn, @@ -67,6 +72,46 @@ 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, +) # =========================================================================== @@ -82,6 +127,12 @@ def reset_state(): """ 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 @@ -296,8 +347,9 @@ def test_vehicle_and_driver_are_locked_after_allocation(self): def test_no_vehicle_available_escalates_to_standby(self): """ - When all vehicles are unavailable, the system should flag - escalation to the Standby Market Engine. + 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 @@ -309,8 +361,10 @@ def test_no_vehicle_available_escalates_to_standby(self): request_id, admin_id="A001", data=AdminApprovalIn(approve=True) ) - assert result["success"] is False - assert result["escalate_to_standby"] is 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): """ @@ -1058,21 +1112,38 @@ def test_vehicle_at_service_limit_has_low_mileage_factor(self): score = compute_vehicle_readiness(vehicle) - # Mileage factor is 0.0, but age and service factors still contribute - # Score should be low but not necessarily 0.0 - assert score < 0.4 + # 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 history should score low due to worst-case assumptions.""" + """ + 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) - # service_factor = 0.0 (no record), age_factor = 0.5 (unknown), mileage = 0.0 (no service) - # score = (0.0 * 0.5) + (0.5 * 0.2) + (0.0 * 0.3) = 0.10 - assert score == pytest.approx(0.10, abs=0.01) + 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.""" @@ -1312,4 +1383,1366 @@ def test_snapshot_counts_match_lists(self): snapshot = get_fleet_availability_snapshot() assert snapshot["vehicles_available"] == len(snapshot["vehicles"]) - assert snapshot["drivers_available"] == len(snapshot["drivers"]) \ No newline at end of file + 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_ongoing_trip_id(self) -> str: + """Helper — creates an ONGOING trip, returns request_id.""" + 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 + 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"]) \ No newline at end of file From df7560e5fbed19a72205db73bd9d1cfe589c9fea Mon Sep 17 00:00:00 2001 From: Bafokeng Masitha Date: Fri, 3 Apr 2026 02:19:07 +0200 Subject: [PATCH 4/5] Added the MILP Optimization engine, the whole GIS service and also minor fixes to problems we encountered when connecting the frontend and backend --- backend.zip | Bin 0 -> 131892 bytes backend/requirements.txt | 2 + .../api/fleet_management_api.py | 65 + .../engines/dynamic_reallocation_engine.py | 19 +- .../engines/optimization_engine.py | 624 +++++++++ .../engines/trip_request_processor.py | 95 +- .../engines/vehicle_suitability_module.py | 216 +++ backend/services/gis_service/__init__.py | 0 backend/services/gis_service/api/gis_api.py | 294 +++++ .../engines/route_request_processor.py | 273 ++++ .../engines/terrain_analysis_engine.py | 380 ++++++ .../vehicle_suitability_mapping_module.py | 158 +++ backend/services/gis_service/main.py | 16 + backend/services/gis_service/models.py | 212 +++ backend/tests/test_fleet_management.py | 697 +++++++++- backend/tests/test_gis_service.py | 1165 +++++++++++++++++ 16 files changed, 4207 insertions(+), 9 deletions(-) create mode 100644 backend.zip create mode 100644 backend/services/gis_service/__init__.py create mode 100644 backend/services/gis_service/api/gis_api.py create mode 100644 backend/services/gis_service/engines/route_request_processor.py create mode 100644 backend/services/gis_service/engines/terrain_analysis_engine.py create mode 100644 backend/services/gis_service/engines/vehicle_suitability_mapping_module.py create mode 100644 backend/services/gis_service/main.py create mode 100644 backend/services/gis_service/models.py create mode 100644 backend/tests/test_gis_service.py diff --git a/backend.zip b/backend.zip new file mode 100644 index 0000000000000000000000000000000000000000..1a2d6e3ba4a5f3a3e1d1e919153a39c78bc65f1d GIT binary patch literal 131892 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 Bil6`h literal 0 HcmV?d00001 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/api/fleet_management_api.py b/backend/services/fleet_management/api/fleet_management_api.py index b94e05f..87f942f 100644 --- a/backend/services/fleet_management/api/fleet_management_api.py +++ b/backend/services/fleet_management/api/fleet_management_api.py @@ -78,6 +78,11 @@ 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"]) @@ -510,6 +515,66 @@ def reset_hours(data: DriverHoursResetIn): 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 # =========================================================================== diff --git a/backend/services/fleet_management/engines/dynamic_reallocation_engine.py b/backend/services/fleet_management/engines/dynamic_reallocation_engine.py index c2a4fd2..94821ba 100644 --- a/backend/services/fleet_management/engines/dynamic_reallocation_engine.py +++ b/backend/services/fleet_management/engines/dynamic_reallocation_engine.py @@ -335,7 +335,11 @@ def _handle_vehicle_breakdown(trip_id: str) -> dict: trip.status = TripStatus.APPROVED # Reset for standby escalation pickup_lat = trip.current_latitude or -29.3167 pickup_lon = trip.current_longitude or 27.4833 - return escalate_to_standby(trip_id, pickup_lat, pickup_lon) + 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 @@ -520,12 +524,17 @@ def _handle_route_blocked(trip_id: str) -> dict: if not vehicle: pickup_lat = trip.current_latitude or -29.3167 pickup_lon = trip.current_longitude or 27.4833 - return escalate_to_standby(trip.request_id, pickup_lat, pickup_lon) + 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."] } @@ -603,7 +612,11 @@ def _handle_vehicle_recalled(trip_id: str) -> dict: pickup_lat = trip.current_latitude or -29.3167 pickup_lon = trip.current_longitude or 27.4833 trip.status = TripStatus.APPROVED - return escalate_to_standby(trip_id, pickup_lat, pickup_lon) + 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: diff --git a/backend/services/fleet_management/engines/optimization_engine.py b/backend/services/fleet_management/engines/optimization_engine.py index e69de29..ab5d377 100644 --- a/backend/services/fleet_management/engines/optimization_engine.py +++ 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/trip_request_processor.py b/backend/services/fleet_management/engines/trip_request_processor.py index f01e658..a99b34d 100644 --- a/backend/services/fleet_management/engines/trip_request_processor.py +++ b/backend/services/fleet_management/engines/trip_request_processor.py @@ -4,6 +4,7 @@ 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. @@ -23,6 +24,7 @@ User, UserRole, ) +from fleet_management.engines.vehicle_suitability_module import apply_suitability_to_trip # =========================================================================== @@ -53,6 +55,23 @@ def _generate_request_id() -> str: 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. @@ -81,6 +100,55 @@ def _validate_trip_input(data: TripRequestIn) -> list[str]: 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 # =========================================================================== @@ -92,12 +160,14 @@ def create_trip_request(data: TripRequestIn) -> dict: Steps: 1. Check the employee exists 2. Validate all input fields - 3. Create and store the TripRequest - 4. Return a structured result with trip details flag + 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]) """ @@ -107,6 +177,7 @@ def create_trip_request(data: TripRequestIn) -> dict: return { "success": False, "trip": None, + "is_routine": False, "errors": [f"Employee with ID '{data.user_id}' not found."], } @@ -116,11 +187,14 @@ def create_trip_request(data: TripRequestIn) -> dict: 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 3 — Build and store the TripRequest + # Step 4 — Build and store the TripRequest trip = TripRequest( request_id=_generate_request_id(), user_id=data.user_id, @@ -135,10 +209,23 @@ def create_trip_request(data: TripRequestIn) -> dict: TRIP_REQUESTS.append(trip) - # Step 4 — Return result + # 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": [], } diff --git a/backend/services/fleet_management/engines/vehicle_suitability_module.py b/backend/services/fleet_management/engines/vehicle_suitability_module.py index e69de29..579a8be 100644 --- a/backend/services/fleet_management/engines/vehicle_suitability_module.py +++ 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/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/tests/test_fleet_management.py b/backend/tests/test_fleet_management.py index 54a5f91..5b0f964 100644 --- a/backend/tests/test_fleet_management.py +++ b/backend/tests/test_fleet_management.py @@ -15,6 +15,8 @@ 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 @@ -112,6 +114,30 @@ 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, +) # =========================================================================== @@ -2088,9 +2114,18 @@ def _get_allocated_trip_id(self) -> str: ) 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() + 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) @@ -2745,4 +2780,662 @@ def test_score_on_nonexistent_trip_fails(self): 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"]) \ No newline at end of file + 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 From 3fe56056adc96cafd53a97fadee88c6c4d67690e Mon Sep 17 00:00:00 2001 From: Bafokeng Masitha Date: Tue, 7 Apr 2026 18:45:15 +0200 Subject: [PATCH 5/5] Added Notification Service - intake handler, delivery engine, log manager, 47 tests --- backend.zip | Bin 131892 -> 0 bytes .../api/notification_api.py | 257 ++++++++ .../engines/delivery_engine.py | 248 ++++++++ .../engines/intake_handler.py | 146 +++++ .../engines/notification_log_manager.py | 149 +++++ backend/services/notification_service/main.py | 21 + .../services/notification_service/models.py | 171 +++++ backend/tests/test_notification_service.py | 591 ++++++++++++++++++ 8 files changed, 1583 insertions(+) delete mode 100644 backend.zip create mode 100644 backend/services/notification_service/api/notification_api.py create mode 100644 backend/services/notification_service/engines/delivery_engine.py create mode 100644 backend/services/notification_service/engines/intake_handler.py create mode 100644 backend/services/notification_service/engines/notification_log_manager.py create mode 100644 backend/services/notification_service/main.py create mode 100644 backend/services/notification_service/models.py create mode 100644 backend/tests/test_notification_service.py diff --git a/backend.zip b/backend.zip deleted file mode 100644 index 1a2d6e3ba4a5f3a3e1d1e919153a39c78bc65f1d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 131892 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 Bil6`h 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_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