Spaces:
Sleeping
Sleeping
| # Location Service Refactoring Plan | |
| ## π― Objectives | |
| 1. **Centralize location logic** - Single source of truth for all location operations | |
| 2. **Eliminate duplication** - Remove scattered location code across modules | |
| 3. **Maintain privacy** - Only track agent location during active journey (already implemented) | |
| 4. **Zero breaking changes** - Work with existing DB schema, no new fields | |
| 5. **Enhance maintainability** - Clean architecture, reusable components | |
| --- | |
| ## π Current State Analysis | |
| ### Existing Location Data (Already in DB) | |
| | Entity | Location Fields | Usage | | |
| |--------|----------------|-------| | |
| | **customers** | `primary_latitude`, `primary_longitude`, `primary_maps_link` | Customer home address | | |
| | **sales_orders** | `installation_latitude`, `installation_longitude`, `installation_maps_link` | Service installation location | | |
| | **subscriptions** | `service_latitude`, `service_longitude`, `service_maps_link` | Active service location | | |
| | **tasks** | `task_latitude`, `task_longitude`, `task_maps_link` | Infrastructure work location | | |
| | **project_regions** | `latitude`, `longitude`, `maps_link` | Regional hub location | | |
| | **users** | `current_latitude`, `current_longitude`, `current_maps_link` | Agent current position | | |
| | **ticket_assignments** | `journey_start_lat/lng`, `arrival_lat/lng`, `journey_location_history` | Journey tracking | | |
| ### Existing Location Logic (Scattered) | |
| | Location | Code | Issue | | |
| |----------|------|-------| | |
| | **Distance calculation** | `TicketAssignment.journey_distance_km` (Haversine) | Hardcoded in model, not reusable | | |
| | **Region assignment** | `SalesOrderService.auto_assign_region()` (Euclidean distance) | Inaccurate distance formula | | |
| | **Journey tracking** | `TicketAssignment.add_location_breadcrumb()` | Works fine, keep as-is | | |
| | **Location updates** | `TicketAssignmentService.update_location()` | Works fine, keep as-is | | |
| --- | |
| ## ποΈ Refactoring Architecture | |
| ``` | |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| β CENTRALIZED LOCATION LAYER β | |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€ | |
| β β | |
| β ββββββββββββββββββββββββ ββββββββββββββββββββββββ β | |
| β β Geo Utilities β β Location Schemas β β | |
| β β (Pure Functions) β β (Response Models) β β | |
| β β β β β β | |
| β β - haversine() β β - Coordinates β β | |
| β β - is_within_radius() β β - EntityLocation β β | |
| β β - extract_coords() β β - MapPoint β β | |
| β β - validate_coords() β β - JourneyDetails β β | |
| β ββββββββββββββββββββββββ ββββββββββββββββββββββββ β | |
| β β | |
| β ββββββββββββββββββββββββββββββββββββββββββββββββ β | |
| β β LocationService (Read-Only) β β | |
| β β β β | |
| β β - get_entity_location(entity_type, id) β β | |
| β β - get_entities_map_view(project_id, types) β β | |
| β β - calculate_distance(point1, point2) β β | |
| β β - find_nearest_entity(point, entities) β β | |
| β β - get_region_locations(project_id) β β | |
| β ββββββββββββββββββββββββββββββββββββββββββββββββ β | |
| β β | |
| β ββββββββββββββββββββββββββββββββββββββββββββββββ β | |
| β β JourneyService (Analytics Only) β β | |
| β β β β | |
| β β - get_journey_details(assignment_id) β β | |
| β β - calculate_journey_stats(user_id, dates) β β | |
| β β - get_journey_breadcrumbs(assignment_id) β β | |
| β β - validate_journey_route(assignment_id) β β | |
| β ββββββββββββββββββββββββββββββββββββββββββββββββ β | |
| β β | |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| β² | |
| β Uses (Read from existing fields) | |
| β | |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| β EXISTING DB MODELS β | |
| β (No changes - just read existing location fields) β | |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€ | |
| β Customer, SalesOrder, Subscription, Task, ProjectRegion, β | |
| β User, TicketAssignment (journey_location_history JSONB) β | |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| ``` | |
| --- | |
| ## π Implementation Steps | |
| ### **Phase 1: Core Utilities (No Dependencies)** | |
| #### Step 1.1: Create `src/app/utils/geo.py` | |
| **Purpose**: Pure utility functions for geographic calculations | |
| ```python | |
| """ | |
| Geographic Utilities | |
| Pure functions for location calculations. No database dependencies. | |
| Uses Haversine formula for accurate Earth-surface distances. | |
| """ | |
| from typing import Optional, Tuple | |
| import math | |
| import re | |
| def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float: | |
| """ | |
| Calculate distance between two GPS points using Haversine formula. | |
| Args: | |
| lat1, lon1: First point coordinates | |
| lat2, lon2: Second point coordinates | |
| Returns: | |
| Distance in kilometers (accurate for Earth's curvature) | |
| Example: | |
| >>> haversine_distance(-1.2921, 36.8219, -1.2965, 36.7809) | |
| 4.23 # km | |
| """ | |
| # Implementation... | |
| def is_within_radius( | |
| center_lat: float, | |
| center_lon: float, | |
| point_lat: float, | |
| point_lon: float, | |
| radius_km: float | |
| ) -> bool: | |
| """ | |
| Check if point is within radius of center. | |
| Example: | |
| >>> is_within_radius(-1.2921, 36.8219, -1.2925, 36.8225, 1.0) | |
| True # Point is within 1km | |
| """ | |
| # Implementation... | |
| def extract_coordinates_from_maps_link(maps_link: str) -> Optional[Tuple[float, float]]: | |
| """ | |
| Extract latitude and longitude from Google Maps share link. | |
| Supports formats: | |
| - https://maps.google.com/?q=-1.2921,36.8219 | |
| - https://www.google.com/maps/place/@-1.2921,36.8219,15z | |
| - https://goo.gl/maps/... (shortened URLs not supported) | |
| Returns: | |
| Tuple of (latitude, longitude) or None if not found | |
| """ | |
| # Implementation... | |
| def validate_coordinates(lat: float, lon: float) -> bool: | |
| """ | |
| Validate latitude and longitude ranges. | |
| Returns: | |
| True if coordinates are valid (-90 <= lat <= 90, -180 <= lon <= 180) | |
| """ | |
| # Implementation... | |
| ``` | |
| **Migration**: Remove `TicketAssignment.journey_distance_km` Haversine code β Use `geo.haversine_distance()` | |
| --- | |
| ### **Phase 2: Unified Schemas** | |
| #### Step 2.1: Create `src/app/schemas/map.py` | |
| **Purpose**: Consistent response models for location data | |
| ```python | |
| """ | |
| Location & Map Schemas | |
| Unified response models for location data across all entities. | |
| """ | |
| from pydantic import BaseModel, Field | |
| from typing import Optional, Literal, List, Dict, Any | |
| from datetime import datetime | |
| from uuid import UUID | |
| from decimal import Decimal | |
| class Coordinates(BaseModel): | |
| """GPS coordinates""" | |
| latitude: float = Field(..., ge=-90, le=90) | |
| longitude: float = Field(..., ge=-180, le=180) | |
| accuracy: Optional[float] = Field(None, description="Accuracy in meters") | |
| class EntityLocation(BaseModel): | |
| """Generic location wrapper for any entity""" | |
| entity_id: UUID | |
| entity_type: Literal[ | |
| "customer", "sales_order", "subscription", | |
| "ticket", "task", "region", "agent" | |
| ] | |
| name: str | |
| coordinates: Coordinates | |
| # Entity-specific metadata | |
| status: Optional[str] = None | |
| icon: Optional[str] = None # For map markers | |
| color: Optional[str] = None # For map markers | |
| additional_info: Dict[str, Any] = {} | |
| class MapPoint(BaseModel): | |
| """Simplified point for map visualization""" | |
| id: UUID | |
| type: str | |
| name: str | |
| lat: float | |
| lng: float | |
| status: Optional[str] = None | |
| class JourneyBreadcrumb(BaseModel): | |
| """Single GPS point in journey trail""" | |
| lat: float | |
| lng: float | |
| timestamp: datetime | |
| accuracy: Optional[float] = None | |
| speed: Optional[float] = None | |
| battery: Optional[int] = None | |
| network: Optional[str] = None | |
| class JourneyDetails(BaseModel): | |
| """Complete journey information""" | |
| assignment_id: UUID | |
| ticket_id: UUID | |
| agent_id: UUID | |
| agent_name: str | |
| # Timeline | |
| journey_started_at: datetime | |
| arrived_at: Optional[datetime] | |
| journey_duration_minutes: Optional[int] | |
| # Locations | |
| start_location: Coordinates | |
| end_location: Optional[Coordinates] | |
| customer_location: Optional[Coordinates] | |
| # Route | |
| breadcrumbs: List[JourneyBreadcrumb] | |
| total_distance_km: Optional[float] | |
| average_speed_kmh: Optional[float] | |
| # Status | |
| journey_status: Literal["in_progress", "arrived", "completed"] | |
| class MapEntitiesResponse(BaseModel): | |
| """Aggregated map data for frontend""" | |
| project_id: UUID | |
| customers: List[MapPoint] = [] | |
| sales_orders: List[MapPoint] = [] | |
| subscriptions: List[MapPoint] = [] | |
| tickets: List[MapPoint] = [] | |
| tasks: List[MapPoint] = [] | |
| regions: List[MapPoint] = [] | |
| # No agents - privacy (only during journey via journey_location_history) | |
| ``` | |
| --- | |
| ### **Phase 3: LocationService (Read-Only)** | |
| #### Step 3.1: Create `src/app/services/location_service.py` | |
| **Purpose**: Centralized location data aggregation (reads existing fields) | |
| ```python | |
| """ | |
| Location Service | |
| Centralized service for location operations. | |
| READ-ONLY: Queries existing location fields, no writes. | |
| """ | |
| from typing import List, Optional, Dict, Any, Tuple | |
| from uuid import UUID | |
| from sqlalchemy.orm import Session | |
| from sqlalchemy import and_, or_ | |
| from app.models import ( | |
| Customer, SalesOrder, Subscription, Ticket, Task, | |
| ProjectRegion, User, TicketAssignment | |
| ) | |
| from app.schemas.map import ( | |
| EntityLocation, Coordinates, MapPoint, MapEntitiesResponse | |
| ) | |
| from app.utils.geo import haversine_distance, is_within_radius | |
| class LocationService: | |
| """Centralized location operations service""" | |
| def __init__(self, db: Session): | |
| self.db = db | |
| # ============================================ | |
| # Entity Location Extraction | |
| # ============================================ | |
| def get_customer_locations( | |
| self, | |
| project_id: UUID, | |
| region_id: Optional[UUID] = None | |
| ) -> List[MapPoint]: | |
| """Get all customer locations for a project""" | |
| query = self.db.query(Customer).join( | |
| SalesOrder, Customer.id == SalesOrder.customer_id | |
| ).filter( | |
| SalesOrder.project_id == project_id, | |
| Customer.primary_latitude.isnot(None), | |
| Customer.primary_longitude.isnot(None) | |
| ) | |
| if region_id: | |
| query = query.filter(Customer.project_region_id == region_id) | |
| customers = query.all() | |
| return [ | |
| MapPoint( | |
| id=c.id, | |
| type="customer", | |
| name=c.customer_name, | |
| lat=float(c.primary_latitude), | |
| lng=float(c.primary_longitude), | |
| status=None | |
| ) | |
| for c in customers | |
| ] | |
| def get_sales_order_locations( | |
| self, | |
| project_id: UUID, | |
| status: Optional[str] = None | |
| ) -> List[MapPoint]: | |
| """Get installation locations from sales orders""" | |
| # Implementation... | |
| def get_ticket_locations( | |
| self, | |
| project_id: UUID, | |
| status: Optional[str] = None | |
| ) -> List[MapPoint]: | |
| """Get ticket work locations""" | |
| # Tickets inherit location from source (sales_order/subscription/task) | |
| # Implementation... | |
| def get_region_locations(self, project_id: UUID) -> List[MapPoint]: | |
| """Get regional hub locations""" | |
| regions = self.db.query(ProjectRegion).filter( | |
| ProjectRegion.project_id == project_id, | |
| ProjectRegion.latitude.isnot(None), | |
| ProjectRegion.longitude.isnot(None), | |
| ProjectRegion.is_active == True | |
| ).all() | |
| return [ | |
| MapPoint( | |
| id=r.id, | |
| type="region", | |
| name=r.region_name, | |
| lat=float(r.latitude), | |
| lng=float(r.longitude), | |
| status="active" if r.is_active else "inactive" | |
| ) | |
| for r in regions | |
| ] | |
| def get_entities_map_view( | |
| self, | |
| project_id: UUID, | |
| entity_types: List[str] | |
| ) -> MapEntitiesResponse: | |
| """ | |
| Aggregate all entity locations for map visualization. | |
| Args: | |
| project_id: Project UUID | |
| entity_types: List of entity types to include | |
| ['customers', 'sales_orders', 'tickets', 'regions'] | |
| Returns: | |
| Aggregated map data | |
| """ | |
| response = MapEntitiesResponse(project_id=project_id) | |
| if "customers" in entity_types: | |
| response.customers = self.get_customer_locations(project_id) | |
| if "sales_orders" in entity_types: | |
| response.sales_orders = self.get_sales_order_locations(project_id) | |
| if "tickets" in entity_types: | |
| response.tickets = self.get_ticket_locations(project_id) | |
| if "regions" in entity_types: | |
| response.regions = self.get_region_locations(project_id) | |
| # NO agents - privacy (only during active journey) | |
| return response | |
| # ============================================ | |
| # Distance Calculations | |
| # ============================================ | |
| def calculate_distance_km( | |
| self, | |
| point1: Coordinates, | |
| point2: Coordinates | |
| ) -> float: | |
| """Calculate distance between two points (delegates to geo utils)""" | |
| return haversine_distance( | |
| point1.latitude, point1.longitude, | |
| point2.latitude, point2.longitude | |
| ) | |
| def find_nearest_region( | |
| self, | |
| project_id: UUID, | |
| lat: float, | |
| lon: float | |
| ) -> Optional[Tuple[UUID, float]]: | |
| """ | |
| Find nearest regional hub to given coordinates. | |
| Returns: | |
| Tuple of (region_id, distance_km) or None | |
| """ | |
| regions = self.db.query(ProjectRegion).filter( | |
| ProjectRegion.project_id == project_id, | |
| ProjectRegion.latitude.isnot(None), | |
| ProjectRegion.longitude.isnot(None), | |
| ProjectRegion.is_active == True | |
| ).all() | |
| if not regions: | |
| return None | |
| nearest = None | |
| min_distance = float('inf') | |
| for region in regions: | |
| distance = haversine_distance( | |
| lat, lon, | |
| float(region.latitude), float(region.longitude) | |
| ) | |
| if distance < min_distance: | |
| min_distance = distance | |
| nearest = region | |
| return (nearest.id, min_distance) if nearest else None | |
| ``` | |
| **Migration**: Update `SalesOrderService.auto_assign_region()` to use `LocationService.find_nearest_region()` | |
| --- | |
| ### **Phase 4: JourneyService (Analytics)** | |
| #### Step 4.1: Create `src/app/services/journey_service.py` | |
| **Purpose**: Journey analytics (reads `journey_location_history` JSONB) | |
| ```python | |
| """ | |
| Journey Service | |
| Analytics for agent journey tracking. | |
| READ-ONLY: Analyzes existing journey_location_history data. | |
| """ | |
| from typing import List, Optional | |
| from uuid import UUID | |
| from datetime import datetime, timedelta | |
| from sqlalchemy.orm import Session | |
| from app.models import TicketAssignment, User, Ticket | |
| from app.schemas.map import JourneyDetails, JourneyBreadcrumb, Coordinates | |
| from app.utils.geo import haversine_distance | |
| class JourneyService: | |
| """Journey analytics service""" | |
| def __init__(self, db: Session): | |
| self.db = db | |
| def get_journey_details(self, assignment_id: UUID) -> Optional[JourneyDetails]: | |
| """ | |
| Get complete journey details with breadcrumb trail. | |
| Reads from existing ticket_assignments fields: | |
| - journey_started_at, arrived_at | |
| - journey_start_latitude/longitude | |
| - arrival_latitude/longitude | |
| - journey_location_history (JSONB) | |
| """ | |
| assignment = self.db.query(TicketAssignment).filter( | |
| TicketAssignment.id == assignment_id | |
| ).first() | |
| if not assignment or not assignment.journey_started_at: | |
| return None | |
| # Parse breadcrumbs from JSONB | |
| breadcrumbs = [ | |
| JourneyBreadcrumb(**bc) | |
| for bc in (assignment.journey_location_history or []) | |
| ] | |
| # Calculate journey stats | |
| duration_minutes = None | |
| if assignment.arrived_at: | |
| duration_minutes = int( | |
| (assignment.arrived_at - assignment.journey_started_at).total_seconds() / 60 | |
| ) | |
| # Calculate total distance from breadcrumbs | |
| total_distance = self._calculate_breadcrumb_distance(breadcrumbs) | |
| # Calculate average speed | |
| avg_speed = None | |
| if duration_minutes and duration_minutes > 0 and total_distance: | |
| avg_speed = (total_distance / duration_minutes) * 60 # km/h | |
| # Determine journey status | |
| status = "in_progress" | |
| if assignment.arrived_at: | |
| status = "arrived" | |
| if assignment.ended_at: | |
| status = "completed" | |
| return JourneyDetails( | |
| assignment_id=assignment.id, | |
| ticket_id=assignment.ticket_id, | |
| agent_id=assignment.user_id, | |
| agent_name=assignment.user.full_name if assignment.user else "Unknown", | |
| journey_started_at=assignment.journey_started_at, | |
| arrived_at=assignment.arrived_at, | |
| journey_duration_minutes=duration_minutes, | |
| start_location=Coordinates( | |
| latitude=float(assignment.journey_start_latitude), | |
| longitude=float(assignment.journey_start_longitude) | |
| ) if assignment.journey_start_latitude else None, | |
| end_location=Coordinates( | |
| latitude=float(assignment.arrival_latitude), | |
| longitude=float(assignment.arrival_longitude) | |
| ) if assignment.arrival_latitude else None, | |
| customer_location=self._get_customer_location(assignment.ticket), | |
| breadcrumbs=breadcrumbs, | |
| total_distance_km=total_distance, | |
| average_speed_kmh=avg_speed, | |
| journey_status=status | |
| ) | |
| def _calculate_breadcrumb_distance( | |
| self, | |
| breadcrumbs: List[JourneyBreadcrumb] | |
| ) -> Optional[float]: | |
| """Calculate total distance from breadcrumb trail""" | |
| if len(breadcrumbs) < 2: | |
| return None | |
| total = 0.0 | |
| for i in range(len(breadcrumbs) - 1): | |
| bc1, bc2 = breadcrumbs[i], breadcrumbs[i + 1] | |
| total += haversine_distance(bc1.lat, bc1.lng, bc2.lat, bc2.lng) | |
| return round(total, 2) | |
| def validate_journey_route(self, assignment_id: UUID) -> Dict[str, Any]: | |
| """ | |
| Validate journey for fraud detection. | |
| Checks: | |
| - No teleportation (impossible speed between points) | |
| - Route makes sense (not zigzagging) | |
| - Distance matches expected (not too short/long) | |
| Returns: | |
| Validation report with warnings | |
| """ | |
| # Implementation... | |
| def get_agent_journey_history( | |
| self, | |
| user_id: UUID, | |
| start_date: datetime, | |
| end_date: datetime | |
| ) -> List[JourneyDetails]: | |
| """Get all journeys for agent in date range""" | |
| # Implementation... | |
| ``` | |
| --- | |
| ### **Phase 5: Map API Endpoints** | |
| #### Step 5.1: Create `src/app/api/v1/map.py` | |
| ```python | |
| """ | |
| Map API Endpoints | |
| Endpoints for map visualization and journey tracking. | |
| """ | |
| from fastapi import APIRouter, Depends, Query, HTTPException, status | |
| from sqlalchemy.orm import Session | |
| from typing import List, Optional | |
| from uuid import UUID | |
| from app.core.deps import get_db, get_current_user | |
| from app.models import User | |
| from app.services.location_service import LocationService | |
| from app.services.journey_service import JourneyService | |
| from app.schemas.map import ( | |
| MapEntitiesResponse, MapPoint, JourneyDetails | |
| ) | |
| router = APIRouter() | |
| @router.get("/entities", response_model=MapEntitiesResponse) | |
| def get_map_entities( | |
| project_id: UUID, | |
| entity_types: List[str] = Query( | |
| default=["customers", "sales_orders", "tickets", "regions"], | |
| description="Entity types to include in map" | |
| ), | |
| db: Session = Depends(get_db), | |
| current_user: User = Depends(get_current_user) | |
| ): | |
| """ | |
| Get aggregated entity locations for map visualization. | |
| **Authorization:** Project team members only | |
| **Entity Types:** | |
| - customers: Customer home addresses | |
| - sales_orders: Installation locations | |
| - subscriptions: Active service locations | |
| - tickets: Ticket work locations | |
| - tasks: Infrastructure task locations | |
| - regions: Regional hub locations | |
| **Privacy:** Agent locations NOT included (only during active journey) | |
| """ | |
| service = LocationService(db) | |
| return service.get_entities_map_view(project_id, entity_types) | |
| @router.get("/regions/{project_id}", response_model=List[MapPoint]) | |
| def get_regional_hubs( | |
| project_id: UUID, | |
| db: Session = Depends(get_db), | |
| current_user: User = Depends(get_current_user) | |
| ): | |
| """ | |
| Get regional hub locations for project. | |
| **Use Case:** Show regional hubs on map for inventory distribution | |
| """ | |
| service = LocationService(db) | |
| return service.get_region_locations(project_id) | |
| @router.get("/journeys/{assignment_id}", response_model=JourneyDetails) | |
| def get_journey_details( | |
| assignment_id: UUID, | |
| db: Session = Depends(get_db), | |
| current_user: User = Depends(get_current_user) | |
| ): | |
| """ | |
| Get journey details with breadcrumb trail for playback. | |
| **Authorization:** | |
| - Agent: Own journeys only | |
| - Manager/Admin: All journeys | |
| **Use Cases:** | |
| - Route playback on map | |
| - Journey audit/review | |
| - Travel reimbursement validation | |
| - Performance analysis | |
| **Privacy:** Only journeys during active work (journey_started_at β arrived_at) | |
| """ | |
| service = JourneyService(db) | |
| journey = service.get_journey_details(assignment_id) | |
| if not journey: | |
| raise HTTPException( | |
| status_code=status.HTTP_404_NOT_FOUND, | |
| detail="Journey not found" | |
| ) | |
| # Authorization check | |
| if current_user.role == "field_agent" and journey.agent_id != current_user.id: | |
| raise HTTPException( | |
| status_code=status.HTTP_403_FORBIDDEN, | |
| detail="Can only view own journeys" | |
| ) | |
| return journey | |
| ``` | |
| **Register in router.py:** | |
| ```python | |
| from app.api.v1 import map | |
| api_router.include_router(map.router, prefix="/map", tags=["Map & Location"]) | |
| ``` | |
| --- | |
| ## π Refactoring Steps (No Breaking Changes) | |
| ### **Step 1: Replace distance calculations** | |
| **Before (Scattered):** | |
| ```python | |
| # In TicketAssignment model | |
| @property | |
| def journey_distance_km(self) -> Optional[float]: | |
| # Haversine implementation hardcoded here... | |
| ``` | |
| ```python | |
| # In SalesOrderService | |
| distance = ( | |
| (region.latitude - installation_latitude) ** 2 + | |
| (region.longitude - installation_longitude) ** 2 | |
| ) ** 0.5 # WRONG: Euclidean distance | |
| ``` | |
| **After (Centralized):** | |
| ```python | |
| # In TicketAssignment model | |
| from app.utils.geo import haversine_distance | |
| @property | |
| def journey_distance_km(self) -> Optional[float]: | |
| if len(self.journey_location_history) < 2: | |
| return None | |
| total = 0.0 | |
| for i in range(len(self.journey_location_history) - 1): | |
| p1 = self.journey_location_history[i] | |
| p2 = self.journey_location_history[i + 1] | |
| total += haversine_distance(p1["lat"], p1["lng"], p2["lat"], p2["lng"]) | |
| return round(total, 2) | |
| ``` | |
| ```python | |
| # In SalesOrderService | |
| from app.services.location_service import LocationService | |
| service = LocationService(db) | |
| result = service.find_nearest_region(project_id, installation_latitude, installation_longitude) | |
| if result: | |
| region_id, distance = result | |
| # Use region_id | |
| ``` | |
| --- | |
| ### **Step 2: Update existing services** | |
| **Files to update:** | |
| 1. `src/app/services/sales_order_service.py` - Use LocationService for region assignment | |
| 2. `src/app/models/ticket_assignment.py` - Use geo.haversine_distance() | |
| **Testing checklist:** | |
| - [ ] Region auto-assignment still works (same results) | |
| - [ ] Journey distance calculation matches old values | |
| - [ ] No new DB queries (performance unchanged) | |
| --- | |
| ## β Privacy & Data Integrity | |
| ### **Agent Privacy (Already Implemented)** | |
| β **What we track:** | |
| - Journey start location (when agent clicks "Start Journey") | |
| - Breadcrumb trail (every 1-5 min during journey) | |
| - Arrival location (when agent clicks "Arrived") | |
| β **What we DON'T track:** | |
| - Agent location when not on active journey | |
| - Agent location outside work hours | |
| - Agent location before/after shift | |
| β **Database support:** | |
| - `journey_location_history` JSONB field already exists | |
| - Journey tracking already implemented in `TicketAssignmentService` | |
| - No changes needed to existing privacy model | |
| --- | |
| ## π§ͺ Testing Strategy | |
| ### **Unit Tests** | |
| ```python | |
| # tests/unit/test_geo_utils.py | |
| def test_haversine_distance(): | |
| # Nairobi coordinates | |
| dist = haversine_distance(-1.2921, 36.8219, -1.2965, 36.7809) | |
| assert 4.0 <= dist <= 4.5 # ~4.23 km | |
| def test_extract_coords_from_maps_link(): | |
| link = "https://maps.google.com/?q=-1.2921,36.8219" | |
| lat, lon = extract_coordinates_from_maps_link(link) | |
| assert lat == -1.2921 | |
| assert lon == 36.8219 | |
| ``` | |
| ### **Integration Tests** | |
| ```python | |
| # tests/integration/test_location_service.py | |
| def test_get_entities_map_view(db, project): | |
| service = LocationService(db) | |
| result = service.get_entities_map_view( | |
| project.id, | |
| ["customers", "regions"] | |
| ) | |
| assert result.project_id == project.id | |
| assert len(result.customers) > 0 | |
| assert len(result.regions) > 0 | |
| def test_find_nearest_region(db, project, regions): | |
| service = LocationService(db) | |
| region_id, distance = service.find_nearest_region( | |
| project.id, -1.2921, 36.8219 | |
| ) | |
| assert region_id in [r.id for r in regions] | |
| assert distance >= 0 | |
| ``` | |
| --- | |
| ## π Implementation Checklist | |
| - [ ] **Phase 1: Core Utilities** | |
| - [ ] Create `src/app/utils/geo.py` | |
| - [ ] Write unit tests for geo functions | |
| - [ ] Test Haversine accuracy against known distances | |
| - [ ] **Phase 2: Schemas** | |
| - [ ] Create `src/app/schemas/map.py` | |
| - [ ] Test schema validation | |
| - [ ] **Phase 3: LocationService** | |
| - [ ] Create `src/app/services/location_service.py` | |
| - [ ] Implement entity location extraction | |
| - [ ] Implement distance calculations | |
| - [ ] Write integration tests | |
| - [ ] **Phase 4: JourneyService** | |
| - [ ] Create `src/app/services/journey_service.py` | |
| - [ ] Implement journey details extraction | |
| - [ ] Implement journey validation | |
| - [ ] Test with existing journey data | |
| - [ ] **Phase 5: Map API** | |
| - [ ] Create `src/app/api/v1/map.py` | |
| - [ ] Implement GET /entities endpoint | |
| - [ ] Implement GET /regions endpoint | |
| - [ ] Implement GET /journeys endpoint | |
| - [ ] Register router in `router.py` | |
| - [ ] **Phase 6: Refactor Existing Code** | |
| - [ ] Update `TicketAssignment.journey_distance_km` | |
| - [ ] Update `SalesOrderService.auto_assign_region()` | |
| - [ ] Remove duplicated distance logic | |
| - [ ] Run full test suite | |
| - [ ] **Phase 7: Integration Testing** | |
| - [ ] Test map endpoints with real data | |
| - [ ] Verify journey playback works | |
| - [ ] Confirm region assignment unchanged | |
| - [ ] Test distance calculations match old results | |
| - [ ] Performance testing (no new queries) | |
| --- | |
| ## π― Success Criteria | |
| 1. β **Zero DB changes** - Work with existing schema | |
| 2. β **Zero breaking changes** - All existing features work | |
| 3. β **Centralized logic** - No duplicated distance calculations | |
| 4. β **Privacy maintained** - Only track journey during active work | |
| 5. β **Same performance** - No additional DB queries | |
| 6. β **Clean architecture** - Clear separation of concerns | |
| 7. β **Testable** - Unit tests for utilities, integration tests for services | |
| --- | |
| ## π Benefits After Refactor | |
| | Before | After | | |
| |--------|-------| | |
| | Distance logic in 3 places | Distance logic in 1 place (`geo.py`) | | |
| | Euclidean distance (wrong) | Haversine distance (correct) | | |
| | No map visualization API | Unified map endpoints | | |
| | Journey data hard to query | JourneyService for analytics | | |
| | Location code scattered | LocationService as single source | | |
| | No journey validation | Fraud detection capabilities | | |
| --- | |
| ## π Next Steps After This Refactor | |
| 1. **Frontend map component** - Use new `/map/entities` endpoint | |
| 2. **Journey playback UI** - Visualize routes on map | |
| 3. **Distance-based features** - "Find nearest agent" functionality | |
| 4. **Route optimization** - Multi-stop route planning (future) | |
| 5. **Geofencing** - Alert when agent enters/exits area (future) | |
| --- | |
| ## π Notes | |
| - **No new database migrations** - Uses existing location fields | |
| - **Backward compatible** - Old code continues to work during migration | |
| - **Privacy first** - Agent location only during active journey | |
| - **Read-only services** - LocationService and JourneyService don't write to DB | |
| - **Pure utilities** - `geo.py` has zero dependencies (easy to test) | |