From b88baab1c2dda346dfa74bbd703c75409319c981 Mon Sep 17 00:00:00 2001 From: Ke-vin-S Date: Mon, 5 May 2025 14:52:08 +0530 Subject: [PATCH 01/36] Change timezone --- logistics_core/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/logistics_core/settings.py b/logistics_core/settings.py index b0d9a93..36fb171 100644 --- a/logistics_core/settings.py +++ b/logistics_core/settings.py @@ -111,7 +111,7 @@ LANGUAGE_CODE = 'en-us' -TIME_ZONE = 'UTC' +TIME_ZONE = 'Asia/Colombo' USE_I18N = True From 6a4f083b4e66c7363192c25e4273938005d40001 Mon Sep 17 00:00:00 2001 From: Ke-vin-S Date: Mon, 5 May 2025 15:26:14 +0530 Subject: [PATCH 02/36] Clients for other apps so, we can split apps easily with only changing the client --- assignment/clients/fleet_client.py | 31 +++++++++++++++++++++++++++ assignment/clients/shipment_client.py | 14 ++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 assignment/clients/fleet_client.py create mode 100644 assignment/clients/shipment_client.py diff --git a/assignment/clients/fleet_client.py b/assignment/clients/fleet_client.py new file mode 100644 index 0000000..14884eb --- /dev/null +++ b/assignment/clients/fleet_client.py @@ -0,0 +1,31 @@ +from fleet.models import Vehicle + + +class FleetClient: + @staticmethod + def get_available_vehicles(min_capacity=None, limit=None): + """ + fetch available vehicles, optionally filtered by capacity and limited in count. + + Args: + min_capacity (int, optional): Minimum vehicle capacity. + limit (int, optional): Maximum number of vehicles to return. + + Returns: + QuerySet of Vehicle objects + """ + qs = Vehicle.objects.filter(status='available') + if min_capacity is not None: + qs = qs.filter(capacity__gte=min_capacity) + if limit is not None: + qs = qs[:limit] + return qs + + @staticmethod + def get_vehicle_by_id(vehicle_id): + return Vehicle.objects.filter(id=vehicle_id).first() + + @staticmethod + def mark_assigned(vehicle): + vehicle.status = 'assigned' + vehicle.save(update_fields=['status']) diff --git a/assignment/clients/shipment_client.py b/assignment/clients/shipment_client.py new file mode 100644 index 0000000..9ac82b5 --- /dev/null +++ b/assignment/clients/shipment_client.py @@ -0,0 +1,14 @@ +from shipments.models import Shipment + +class ShipmentClient: + @staticmethod + def get_pending_shipments(): + return Shipment.objects.filter(status='pending') + + @staticmethod + def mark_scheduled(shipment, vehicle, dispatch_time=None): + shipment.status = 'scheduled' + shipment.assigned_vehicle_id = vehicle.id + if dispatch_time: + shipment.scheduled_dispatch = dispatch_time + shipment.save(update_fields=['status', 'assigned_vehicle_id', 'scheduled_dispatch']) From 11901a0266f6985d3d9b5dfed281165b703b7704 Mon Sep 17 00:00:00 2001 From: Ke-vin-S Date: Tue, 6 May 2025 19:33:31 +0530 Subject: [PATCH 03/36] Remove all logic files --- route_optimizer/api/__init__.py | 0 route_optimizer/api/serializers.py | 132 ------ route_optimizer/api/urls.py | 18 - route_optimizer/api/views.py | 265 ------------ route_optimizer/core/__init__.py | 0 route_optimizer/core/dijkstra.py | 131 ------ route_optimizer/core/distance_matrix.py | 166 -------- route_optimizer/core/ortools_optimizer.py | 394 ------------------ route_optimizer/services/__init__.py | 0 route_optimizer/services/depot_service.py | 5 - .../services/external_data_service.py | 287 ------------- .../services/optimization_service.py | 49 --- .../services/path_annotation_service.py | 29 -- route_optimizer/services/rerouting_service.py | 302 -------------- .../services/route_stats_service.py | 31 -- route_optimizer/services/traffic_service.py | 6 - route_optimizer/tests/core/__init__.py | 0 route_optimizer/tests/core/test_dijkstra.py | 166 -------- .../tests/core/test_ortools_optimizer.py | 237 ----------- route_optimizer/tests/services/__init__.py | 0 .../tests/services/test_depot_service.py | 14 - .../services/test_optimization_service.py | 195 --------- .../test_optimization_service_edge_cases.py | 75 ---- .../services/test_path_annotation_service.py | 18 - .../services/test_route_stats_service.py | 21 - .../tests/services/test_traffic_service.py | 13 - route_optimizer/utils/helpers.py | 277 ------------ 27 files changed, 2831 deletions(-) delete mode 100644 route_optimizer/api/__init__.py delete mode 100644 route_optimizer/api/serializers.py delete mode 100644 route_optimizer/api/urls.py delete mode 100644 route_optimizer/api/views.py delete mode 100644 route_optimizer/core/__init__.py delete mode 100644 route_optimizer/core/dijkstra.py delete mode 100644 route_optimizer/core/distance_matrix.py delete mode 100644 route_optimizer/core/ortools_optimizer.py delete mode 100644 route_optimizer/services/__init__.py delete mode 100644 route_optimizer/services/depot_service.py delete mode 100644 route_optimizer/services/external_data_service.py delete mode 100644 route_optimizer/services/optimization_service.py delete mode 100644 route_optimizer/services/path_annotation_service.py delete mode 100644 route_optimizer/services/rerouting_service.py delete mode 100644 route_optimizer/services/route_stats_service.py delete mode 100644 route_optimizer/services/traffic_service.py delete mode 100644 route_optimizer/tests/core/__init__.py delete mode 100644 route_optimizer/tests/core/test_dijkstra.py delete mode 100644 route_optimizer/tests/core/test_ortools_optimizer.py delete mode 100644 route_optimizer/tests/services/__init__.py delete mode 100644 route_optimizer/tests/services/test_depot_service.py delete mode 100644 route_optimizer/tests/services/test_optimization_service.py delete mode 100644 route_optimizer/tests/services/test_optimization_service_edge_cases.py delete mode 100644 route_optimizer/tests/services/test_path_annotation_service.py delete mode 100644 route_optimizer/tests/services/test_route_stats_service.py delete mode 100644 route_optimizer/tests/services/test_traffic_service.py delete mode 100644 route_optimizer/utils/helpers.py diff --git a/route_optimizer/api/__init__.py b/route_optimizer/api/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/route_optimizer/api/serializers.py b/route_optimizer/api/serializers.py deleted file mode 100644 index dffbde2..0000000 --- a/route_optimizer/api/serializers.py +++ /dev/null @@ -1,132 +0,0 @@ -""" -Serializers for the route optimizer API. - -This module provides serializers for converting between API requests/responses -and the internal data structures used by the route optimizer. -""" -from rest_framework import serializers -from typing import Dict, List, Any - - -class LocationSerializer(serializers.Serializer): - """Serializer for Location objects.""" - id = serializers.CharField(max_length=100) - name = serializers.CharField(max_length=255) - latitude = serializers.FloatField() - longitude = serializers.FloatField() - address = serializers.CharField(max_length=255, required=False, allow_null=True) - is_depot = serializers.BooleanField(default=False) - time_window_start = serializers.IntegerField(required=False, allow_null=True, - help_text="In minutes from midnight") - time_window_end = serializers.IntegerField(required=False, allow_null=True, - help_text="In minutes from midnight") - service_time = serializers.IntegerField(default=15, help_text="Service time in minutes") - - -class VehicleSerializer(serializers.Serializer): - """Serializer for Vehicle objects.""" - id = serializers.CharField(max_length=100) - capacity = serializers.FloatField() - start_location_id = serializers.CharField(max_length=100) - end_location_id = serializers.CharField(max_length=100, required=False, allow_null=True) - cost_per_km = serializers.FloatField(default=1.0) - fixed_cost = serializers.FloatField(default=0.0) - max_distance = serializers.FloatField(required=False, allow_null=True) - max_stops = serializers.IntegerField(required=False, allow_null=True) - available = serializers.BooleanField(default=True) - skills = serializers.ListField(child=serializers.CharField(max_length=100), default=list) - - -class DeliverySerializer(serializers.Serializer): - """Serializer for Delivery objects.""" - id = serializers.CharField(max_length=100) - location_id = serializers.CharField(max_length=100) - demand = serializers.FloatField() - priority = serializers.IntegerField(default=1) - required_skills = serializers.ListField(child=serializers.CharField(max_length=100), default=list) - is_pickup = serializers.BooleanField(default=False) - - -class RouteOptimizationRequestSerializer(serializers.Serializer): - """Serializer for route optimization requests.""" - locations = LocationSerializer(many=True) - vehicles = VehicleSerializer(many=True) - deliveries = DeliverySerializer(many=True) - consider_traffic = serializers.BooleanField(default=False) - consider_time_windows = serializers.BooleanField(default=False) - - -class RouteSegmentSerializer(serializers.Serializer): - """Serializer for a segment of a route.""" - from_location = serializers.CharField(max_length=100) - to_location = serializers.CharField(max_length=100) - distance = serializers.FloatField() - estimated_time = serializers.FloatField(help_text="Estimated time in minutes") - - -class VehicleRouteSerializer(serializers.Serializer): - """Serializer for a vehicle's route.""" - vehicle_id = serializers.CharField(max_length=100) - total_distance = serializers.FloatField() - total_time = serializers.FloatField(help_text="Total time in minutes") - stops = serializers.ListField(child=serializers.CharField(max_length=100)) - segments = RouteSegmentSerializer(many=True) - capacity_utilization = serializers.FloatField(help_text="Percentage of vehicle capacity used") - estimated_arrival_times = serializers.DictField( - child=serializers.IntegerField(), - help_text="Mapping of location_id to arrival time in minutes from start" - ) - - -class RouteOptimizationResponseSerializer(serializers.Serializer): - """Serializer for route optimization responses.""" - status = serializers.CharField(max_length=50) - total_distance = serializers.FloatField() - total_cost = serializers.FloatField() - routes = VehicleRouteSerializer(many=True) - unassigned_deliveries = serializers.ListField( - child=serializers.CharField(max_length=100), default=list - ) - statistics = serializers.DictField(child=serializers.CharField(), default=dict) - - -class TrafficDataSerializer(serializers.Serializer): - """Serializer for traffic data.""" - location_pairs = serializers.ListField( - child=serializers.ListField( - child=serializers.CharField(max_length=100), - min_length=2, - max_length=2 - ) - ) - factors = serializers.ListField(child=serializers.FloatField()) - - -class ReroutingRequestSerializer(serializers.Serializer): - """Serializer for rerouting requests.""" - current_routes = serializers.JSONField() - locations = LocationSerializer(many=True) - vehicles = VehicleSerializer(many=True) - completed_deliveries = serializers.ListField( - child=serializers.CharField(max_length=100), default=list - ) - traffic_data = TrafficDataSerializer(required=False) - delayed_location_ids = serializers.ListField( - child=serializers.CharField(max_length=100), default=list - ) - delay_minutes = serializers.DictField( - child=serializers.IntegerField(), - default=dict - ) - blocked_segments = serializers.ListField( - child=serializers.ListField( - child=serializers.CharField(max_length=100), - min_length=2, - max_length=2 - ), - default=list - ) - reroute_type = serializers.ChoiceField( - choices=['traffic', 'delay', 'roadblock'], - default='traffic' - ) \ No newline at end of file diff --git a/route_optimizer/api/urls.py b/route_optimizer/api/urls.py deleted file mode 100644 index aecb201..0000000 --- a/route_optimizer/api/urls.py +++ /dev/null @@ -1,18 +0,0 @@ -""" -URL configuration for the route optimizer API. - -This module defines the URL patterns for the route optimization API endpoints. -""" -from django.urls import path -from route_optimizer.api.views import OptimizeRoutesView, RerouteView, health_check - -app_name = 'route_optimizer' - -urlpatterns = [ - # Health check endpoint - path('health/', health_check, name='health_check'), - - # Route optimization endpoints - path('optimize/', OptimizeRoutesView.as_view(), name='optimize_routes'), - path('reroute/', RerouteView.as_view(), name='reroute'), -] \ No newline at end of file diff --git a/route_optimizer/api/views.py b/route_optimizer/api/views.py deleted file mode 100644 index d859ac3..0000000 --- a/route_optimizer/api/views.py +++ /dev/null @@ -1,265 +0,0 @@ -""" -API views for the route optimizer. - -This module provides the API endpoints for the route optimization functionality. -""" -from rest_framework import status -from rest_framework.views import APIView -from rest_framework.response import Response -from rest_framework.decorators import api_view -from drf_yasg.utils import swagger_auto_schema -from drf_yasg import openapi -import logging - -from route_optimizer.services.optimization_service import OptimizationService -from route_optimizer.services.rerouting_service import ReroutingService -from route_optimizer.core.distance_matrix import Location -from route_optimizer.core.ortools_optimizer import Vehicle, Delivery -from route_optimizer.api.serializers import ( - RouteOptimizationRequestSerializer, - RouteOptimizationResponseSerializer, - ReroutingRequestSerializer, - LocationSerializer, - VehicleSerializer, - DeliverySerializer -) - -# Set up logging -logger = logging.getLogger(__name__) - - -class OptimizeRoutesView(APIView): - """ - API view for optimizing delivery routes. - """ - - @swagger_auto_schema( - request_body=RouteOptimizationRequestSerializer, - responses={200: RouteOptimizationResponseSerializer}, - operation_description="Optimize delivery routes based on provided locations, vehicles, and deliveries." - ) - def post(self, request, format=None): - """ - POST endpoint for route optimization. - - Args: - request: HTTP request object containing route optimization parameters. - format: Format of the response. - - Returns: - Response object with optimization results. - """ - serializer = RouteOptimizationRequestSerializer(data=request.data) - - if not serializer.is_valid(): - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - try: - # Convert serialized data to domain objects - locations = [ - Location( - id=loc_data['id'], - name=loc_data['name'], - latitude=loc_data['latitude'], - longitude=loc_data['longitude'], - address=loc_data.get('address'), - is_depot=loc_data.get('is_depot', False), - time_window_start=loc_data.get('time_window_start'), - time_window_end=loc_data.get('time_window_end'), - service_time=loc_data.get('service_time', 15) - ) - for loc_data in serializer.validated_data['locations'] - ] - - vehicles = [ - Vehicle( - id=veh_data['id'], - capacity=veh_data['capacity'], - start_location_id=veh_data['start_location_id'], - end_location_id=veh_data.get('end_location_id'), - cost_per_km=veh_data.get('cost_per_km', 1.0), - fixed_cost=veh_data.get('fixed_cost', 0.0), - max_distance=veh_data.get('max_distance'), - max_stops=veh_data.get('max_stops'), - available=veh_data.get('available', True), - skills=veh_data.get('skills', []) - ) - for veh_data in serializer.validated_data['vehicles'] - ] - - deliveries = [ - Delivery( - id=del_data['id'], - location_id=del_data['location_id'], - demand=del_data['demand'], - priority=del_data.get('priority', 1), - required_skills=del_data.get('required_skills', []), - is_pickup=del_data.get('is_pickup', False) - ) - for del_data in serializer.validated_data['deliveries'] - ] - - consider_traffic = serializer.validated_data.get('consider_traffic', False) - consider_time_windows = serializer.validated_data.get('consider_time_windows', False) - - # Call the optimization service - optimization_service = OptimizationService() - result = optimization_service.optimize_routes( - locations=locations, - vehicles=vehicles, - deliveries=deliveries, - consider_traffic=consider_traffic, - consider_time_windows=consider_time_windows - ) - - # Return the result - response_serializer = RouteOptimizationResponseSerializer(result) - return Response(response_serializer.data, status=status.HTTP_200_OK) - - except Exception as e: - logger.exception("Error during route optimization: %s", str(e)) - return Response( - {"error": f"Route optimization failed: {str(e)}"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR - ) - - -class RerouteView(APIView): - """ - API view for rerouting based on real-time events. - """ - - @swagger_auto_schema( - request_body=ReroutingRequestSerializer, - responses={200: RouteOptimizationResponseSerializer}, - operation_description="Reroute vehicles based on traffic, delays, or roadblocks." - ) - def post(self, request, format=None): - """ - POST endpoint for rerouting. - - Args: - request: HTTP request object containing rerouting parameters. - format: Format of the response. - - Returns: - Response object with updated route plan. - """ - serializer = ReroutingRequestSerializer(data=request.data) - - if not serializer.is_valid(): - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - try: - # Convert serialized data to domain objects - locations = [ - Location( - id=loc_data['id'], - name=loc_data['name'], - latitude=loc_data['latitude'], - longitude=loc_data['longitude'], - address=loc_data.get('address'), - is_depot=loc_data.get('is_depot', False), - time_window_start=loc_data.get('time_window_start'), - time_window_end=loc_data.get('time_window_end'), - service_time=loc_data.get('service_time', 15) - ) - for loc_data in serializer.validated_data['locations'] - ] - - vehicles = [ - Vehicle( - id=veh_data['id'], - capacity=veh_data['capacity'], - start_location_id=veh_data['start_location_id'], - end_location_id=veh_data.get('end_location_id'), - cost_per_km=veh_data.get('cost_per_km', 1.0), - fixed_cost=veh_data.get('fixed_cost', 0.0), - max_distance=veh_data.get('max_distance'), - max_stops=veh_data.get('max_stops'), - available=veh_data.get('available', True), - skills=veh_data.get('skills', []) - ) - for veh_data in serializer.validated_data['vehicles'] - ] - - current_routes = serializer.validated_data['current_routes'] - completed_deliveries = serializer.validated_data.get('completed_deliveries', []) - reroute_type = serializer.validated_data.get('reroute_type', 'traffic') - - # Create the rerouting service - rerouting_service = ReroutingService() - result = None - - # Call the appropriate rerouting method based on the type - if reroute_type == 'traffic': - traffic_data_list = serializer.validated_data.get('traffic_data', {}) - traffic_data = {} - - # Convert traffic data from list format to dictionary - if traffic_data_list: - for i, pair in enumerate(traffic_data_list.get('location_pairs', [])): - if i < len(traffic_data_list.get('factors', [])): - # Find indices of the locations - from_idx = next((i for i, loc in enumerate(locations) if loc.id == pair[0]), None) - to_idx = next((i for i, loc in enumerate(locations) if loc.id == pair[1]), None) - - if from_idx is not None and to_idx is not None: - traffic_data[(from_idx, to_idx)] = traffic_data_list['factors'][i] - - result = rerouting_service.reroute_for_traffic( - current_routes=current_routes, - locations=locations, - vehicles=vehicles, - completed_deliveries=completed_deliveries, - traffic_data=traffic_data - ) - - elif reroute_type == 'delay': - delayed_location_ids = serializer.validated_data.get('delayed_location_ids', []) - delay_minutes = serializer.validated_data.get('delay_minutes', {}) - - result = rerouting_service.reroute_for_delay( - current_routes=current_routes, - locations=locations, - vehicles=vehicles, - completed_deliveries=completed_deliveries, - delayed_location_ids=delayed_location_ids, - delay_minutes=delay_minutes - ) - - elif reroute_type == 'roadblock': - blocked_segments = [tuple(segment) for segment in serializer.validated_data.get('blocked_segments', [])] - - result = rerouting_service.reroute_for_roadblock( - current_routes=current_routes, - locations=locations, - vehicles=vehicles, - completed_deliveries=completed_deliveries, - blocked_segments=blocked_segments - ) - - # Return the result - response_serializer = RouteOptimizationResponseSerializer(result) - return Response(response_serializer.data, status=status.HTTP_200_OK) - - except Exception as e: - logger.exception("Error during rerouting: %s", str(e)) - return Response( - {"error": f"Rerouting failed: {str(e)}"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR - ) - - -@api_view(['GET']) -def health_check(request): - """ - Health check endpoint to verify the API is running. - - Args: - request: HTTP request object. - - Returns: - Response object with health status. - """ - return Response({"status": "healthy"}, status=status.HTTP_200_OK) \ No newline at end of file diff --git a/route_optimizer/core/__init__.py b/route_optimizer/core/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/route_optimizer/core/dijkstra.py b/route_optimizer/core/dijkstra.py deleted file mode 100644 index 837f0e3..0000000 --- a/route_optimizer/core/dijkstra.py +++ /dev/null @@ -1,131 +0,0 @@ -import heapq -from typing import Dict, List, Tuple, Set, Optional, Union -import logging - -# Set up logging -logger = logging.getLogger(__name__) - - -class DijkstraPathFinder: - """ - Implementation of Dijkstra's algorithm for shortest path finding. - """ - - def __init__(self): - """Initialize the Dijkstra path finder.""" - pass - - @staticmethod - def _validate_non_negative_weights(graph: Dict[str, Dict[str, float]]) -> None: - """ - Ensure all weights in the graph are non-negative. - - Raises: - ValueError: If a negative edge weight is found. - """ - for src, neighbors in graph.items(): - for dest, weight in neighbors.items(): - if weight < 0: - raise ValueError(f"Negative weight detected from '{src}' to '{dest}' with weight {weight}") - - @staticmethod - def calculate_shortest_path( - graph: Dict[str, Dict[str, float]], - start: str, - end: str - ) -> Tuple[Optional[List[str]], Optional[float]]: - """ - Calculate the shortest path between two nodes using Dijkstra's algorithm. - - Args: - graph: A dictionary of dictionaries representing the graph. - start: Starting node. - end: Target node. - - Returns: - A tuple containing the shortest path and its distance. - """ - DijkstraPathFinder._validate_non_negative_weights(graph) - - if start not in graph or end not in graph: - logger.warning(f"Start node '{start}' or end node '{end}' not in graph") - return None, None - - queue = [(0, start, [start])] - visited: Set[str] = set() - - while queue: - (dist, current, path) = heapq.heappop(queue) - - if current in visited: - continue - - visited.add(current) - - if current == end: - return path, dist - - for neighbor, distance in graph[current].items(): - if neighbor not in visited: - new_dist = dist + distance - new_path = path + [neighbor] - heapq.heappush(queue, (new_dist, neighbor, new_path)) - - logger.warning(f"No path found from '{start}' to '{end}'") - return None, None - - @staticmethod - def calculate_all_shortest_paths( - graph: Dict[str, Dict[str, float]], - nodes: List[str] - ) -> Dict[str, Dict[str, Dict[str, Union[List[str], float]]]]: - """ - Calculate shortest paths between all pairs of nodes using Dijkstra. - - Args: - graph: The graph as adjacency list. - nodes: List of nodes to calculate paths between. - - Returns: - Dictionary mapping start→end to path and distance. - """ - DijkstraPathFinder._validate_non_negative_weights(graph) - result = {} - - for start_node in nodes: - distances = {node: float('inf') for node in nodes} - previous = {node: None for node in nodes} - distances[start_node] = 0 - queue = [(0, start_node)] - - while queue: - dist, current = heapq.heappop(queue) - - for neighbor, weight in graph.get(current, {}).items(): - if neighbor not in distances: - continue - alt = dist + weight - if alt < distances[neighbor]: - distances[neighbor] = alt - previous[neighbor] = current - heapq.heappush(queue, (alt, neighbor)) - - result[start_node] = {} - - for end_node in nodes: - if distances[end_node] == float('inf'): - result[start_node][end_node] = {'path': None, 'distance': float('inf')} - continue - - path = [] - current = end_node - while current is not None: - path.insert(0, current) - current = previous[current] - - result[start_node][end_node] = { - 'path': path, - 'distance': distances[end_node] - } - - return result diff --git a/route_optimizer/core/distance_matrix.py b/route_optimizer/core/distance_matrix.py deleted file mode 100644 index 1cd46c5..0000000 --- a/route_optimizer/core/distance_matrix.py +++ /dev/null @@ -1,166 +0,0 @@ -""" -Distance matrix utilities for route optimization. - -This module provides functions to create and manipulate distance matrices -that are used in route optimization algorithms. -""" -from typing import Dict, List, Tuple, Optional, Any -import logging -import numpy as np -from dataclasses import dataclass - -# Set up logging -logger = logging.getLogger(__name__) - - -@dataclass -class Location: - """Class representing a location with coordinates and metadata.""" - id: str - name: str - latitude: float - longitude: float - address: Optional[str] = None - is_depot: bool = False - time_window_start: Optional[int] = None # In minutes from midnight - time_window_end: Optional[int] = None # In minutes from midnight - service_time: int = 15 # Default service time in minutes - - -class DistanceMatrixBuilder: - """ - Builder class for creating distance matrices used in route optimization. - """ - - @staticmethod - def create_distance_matrix( - locations: List[Location], - use_haversine: bool = True - ) -> Tuple[np.ndarray, List[str]]: - """ - Create a distance matrix from a list of locations. - - Args: - locations: List of Location objects. - use_haversine: If True, use Haversine formula for distances, - otherwise use Euclidean distances. - - Returns: - Tuple containing: - - 2D numpy array representing distances between locations - - List of location IDs corresponding to the matrix indices - """ - num_locations = len(locations) - distance_matrix = np.zeros((num_locations, num_locations)) - location_ids = [loc.id for loc in locations] - - for i in range(num_locations): - for j in range(num_locations): - if i == j: - continue # Zero distance to self - - if use_haversine: - distance = DistanceMatrixBuilder._haversine_distance( - locations[i].latitude, locations[i].longitude, - locations[j].latitude, locations[j].longitude - ) - else: - distance = DistanceMatrixBuilder._euclidean_distance( - locations[i].latitude, locations[i].longitude, - locations[j].latitude, locations[j].longitude - ) - - distance_matrix[i, j] = distance - - return distance_matrix, location_ids - - @staticmethod - def distance_matrix_to_graph( - distance_matrix: np.ndarray, - location_ids: List[str] - ) -> Dict[str, Dict[str, float]]: - """ - Convert a distance matrix to a graph representation for Dijkstra's algorithm. - - Args: - distance_matrix: 2D numpy array of distances. - location_ids: List of location IDs corresponding to matrix indices. - - Returns: - Dictionary representing the graph with format: - {node1: {node2: distance, ...}, ...} - """ - graph = {} - - for i, from_id in enumerate(location_ids): - if from_id not in graph: - graph[from_id] = {} - - for j, to_id in enumerate(location_ids): - if i != j: # Skip self-connections - graph[from_id][to_id] = distance_matrix[i, j] - - return graph - - @staticmethod - def _haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float: - """ - Calculate the great circle distance between two points - on the earth (specified in decimal degrees). - - Args: - lat1, lon1: Coordinates of first point - lat2, lon2: Coordinates of second point - - Returns: - Distance in kilometers - """ - # Convert decimal degrees to radians - lat1, lon1, lat2, lon2 = map(np.radians, [lat1, lon1, lat2, lon2]) - - # Haversine formula - dlon = lon2 - lon1 - dlat = lat2 - lat1 - a = np.sin(dlat/2)**2 + np.cos(lat1) * np.cos(lat2) * np.sin(dlon/2)**2 - c = 2 * np.arcsin(np.sqrt(a)) - r = 6371 # Radius of Earth in kilometers - - return c * r - - @staticmethod - def _euclidean_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float: - """ - Calculate Euclidean distance between two points. - This is useful for testing and as a fallback. - - Args: - lat1, lon1: Coordinates of first point - lat2, lon2: Coordinates of second point - - Returns: - Euclidean distance between the points - """ - return np.sqrt((lat2 - lat1)**2 + (lon2 - lon1)**2) - - @staticmethod - def add_traffic_factors( - distance_matrix: np.ndarray, - traffic_factors: Dict[Tuple[int, int], float] - ) -> np.ndarray: - """ - Apply traffic factors to a distance matrix. - - Args: - distance_matrix: Original distance matrix - traffic_factors: Dictionary mapping (i,j) tuples to traffic factors. - A factor of 1.0 means no change, >1.0 means slower. - - Returns: - Updated distance matrix with traffic factors applied - """ - matrix_with_traffic = distance_matrix.copy() - - for (i, j), factor in traffic_factors.items(): - matrix_with_traffic[i, j] *= factor - - return matrix_with_traffic \ No newline at end of file diff --git a/route_optimizer/core/ortools_optimizer.py b/route_optimizer/core/ortools_optimizer.py deleted file mode 100644 index 2c61eb0..0000000 --- a/route_optimizer/core/ortools_optimizer.py +++ /dev/null @@ -1,394 +0,0 @@ -""" -Implementation of route optimization using Google OR-Tools. - -This module provides classes and functions for solving Vehicle Routing Problems -(VRP) using Google's OR-Tools library. -""" -from typing import Dict, List, Tuple, Optional, Any -import logging -import numpy as np -from dataclasses import dataclass, field -from ortools.constraint_solver import routing_enums_pb2 -from ortools.constraint_solver import pywrapcp - -from route_optimizer.core.distance_matrix import Location - -# Set up logging -logger = logging.getLogger(__name__) - - -@dataclass -class Vehicle: - """Class representing a vehicle with capacity and other constraints.""" - id: str - capacity: float - start_location_id: str # Where the vehicle starts from - end_location_id: Optional[str] = None # Where the vehicle must end (if different) - cost_per_km: float = 1.0 # Cost per kilometer - fixed_cost: float = 0.0 # Fixed cost for using this vehicle - max_distance: Optional[float] = None # Maximum distance the vehicle can travel - max_stops: Optional[int] = None # Maximum number of stops - available: bool = True - skills: List[str] = field(default_factory=list) # Skills/capabilities this vehicle has - - -@dataclass -class Delivery: - """Class representing a delivery with demand and constraints.""" - id: str - location_id: str - demand: float # Demand quantity - priority: int = 1 # 1 = normal, higher values = higher priority - required_skills: List[str] = field(default_factory=list) # Required skills - is_pickup: bool = False # True for pickup, False for delivery - - -class ORToolsVRPSolver: - """ - Vehicle Routing Problem solver using Google OR-Tools. - """ - - def __init__(self, time_limit_seconds: int = 30): - """ - Initialize the VRP solver. - - Args: - time_limit_seconds: Time limit for the solver in seconds. - """ - self.time_limit_seconds = time_limit_seconds - - def solve( - self, - distance_matrix: np.ndarray, - location_ids: List[str], - vehicles: List[Vehicle], - deliveries: List[Delivery], - depot_index: int = 0 - ) -> Dict[str, Any]: - """ - Solve the Vehicle Routing Problem. - - Args: - distance_matrix: 2D numpy array of distances between locations. - location_ids: List of location IDs corresponding to matrix indices. - vehicles: List of Vehicle objects. - deliveries: List of Delivery objects. - depot_index: Index of the depot location in the distance matrix. - - Returns: - Dictionary containing the solution details: - { - 'status': 'success' or 'failed', - 'routes': List of routes, where each route is a list of location indices, - 'total_distance': Total distance of all routes, - 'assigned_vehicles': Dict mapping vehicle IDs to route indices, - 'unassigned_deliveries': List of unassigned delivery IDs - } - """ - # Create the routing index manager - num_locations = len(location_ids) - num_vehicles = len(vehicles) - - # Create location_id to index mapping - location_id_to_index = {loc_id: idx for idx, loc_id in enumerate(location_ids)} - - # Map vehicle start/end locations to indices - starts = [] - ends = [] - - for vehicle in vehicles: - try: - start_idx = location_id_to_index[vehicle.start_location_id] - # If end location not specified, use the start location - end_idx = location_id_to_index.get( - vehicle.end_location_id or vehicle.start_location_id, - start_idx - ) - starts.append(start_idx) - ends.append(end_idx) - except KeyError as e: - logger.error(f"Vehicle location not found in locations: {e}") - return { - 'status': 'failed', - 'error': f"Vehicle location not found: {e}" - } - - manager = pywrapcp.RoutingIndexManager( - num_locations, - num_vehicles, - starts, - ends - ) - - # Create Routing Model - routing = pywrapcp.RoutingModel(manager) - - # Create and register a transit callback - def distance_callback(from_index, to_index): - """Returns the distance between the two nodes.""" - # Convert from routing variable Index to distance matrix NodeIndex - from_node = manager.IndexToNode(from_index) - to_node = manager.IndexToNode(to_index) - return int(distance_matrix[from_node][to_node] * 1000) # Convert to int for OR-Tools - - transit_callback_index = routing.RegisterTransitCallback(distance_callback) - - # Define cost of each arc - routing.SetArcCostEvaluatorOfAllVehicles(transit_callback_index) - - # Add Capacity constraint - def demand_callback(from_index): - """Returns the demand of the node.""" - # Convert from routing variable Index to demands NodeIndex - from_node = manager.IndexToNode(from_index) - # Get the location ID for this node - location_id = location_ids[from_node] - - # Find all deliveries for this location - total_demand = 0 - for delivery in deliveries: - if delivery.location_id == location_id: - if delivery.is_pickup: - # For pickups, demand is negative (adds capacity) - total_demand -= delivery.demand - else: - # For deliveries, demand is positive (uses capacity) - total_demand += delivery.demand - - return int(total_demand * 100) # Convert to int for OR-Tools - - demand_callback_index = routing.RegisterUnaryTransitCallback(demand_callback) - - # Set vehicle capacities - for i, vehicle in enumerate(vehicles): - routing.AddDimensionWithVehicleCapacity( - demand_callback_index, - 0, # null capacity slack - [int(v.capacity * 100) for v in vehicles], # vehicle maximum capacities - True, # start cumul to zero - 'Capacity' - ) - - # Setting first solution heuristic - search_parameters = pywrapcp.DefaultRoutingSearchParameters() - search_parameters.first_solution_strategy = ( - routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC - ) - search_parameters.local_search_metaheuristic = ( - routing_enums_pb2.LocalSearchMetaheuristic.GUIDED_LOCAL_SEARCH - ) - search_parameters.time_limit.seconds = self.time_limit_seconds - - # Solve the problem - solution = routing.SolveWithParameters(search_parameters) - - # Return the solution - if solution: - routes = [] - assigned_vehicles = {} - total_distance = 0 - - for vehicle_idx in range(num_vehicles): - route = [] - index = routing.Start(vehicle_idx) - - while not routing.IsEnd(index): - node_idx = manager.IndexToNode(index) - route.append(location_ids[node_idx]) - previous_index = index - index = solution.Value(routing.NextVar(index)) - total_distance += routing.GetArcCostForVehicle( - previous_index, index, vehicle_idx - ) / 1000 # Convert back from int - - # Add the end location - node_idx = manager.IndexToNode(index) - route.append(location_ids[node_idx]) - - if route: # If the route is not empty - routes.append(route) - assigned_vehicles[vehicles[vehicle_idx].id] = len(routes) - 1 - - # Check for unassigned deliveries - unassigned_deliveries = [] - delivery_locations = set() - - # Collect all locations in the routes - for route in routes: - delivery_locations.update(route) - - # Find deliveries that weren't assigned - for delivery in deliveries: - if delivery.location_id not in delivery_locations: - unassigned_deliveries.append(delivery.id) - - return { - 'status': 'success', - 'routes': routes, - 'total_distance': total_distance, - 'assigned_vehicles': assigned_vehicles, - 'unassigned_deliveries': unassigned_deliveries - } - else: - return { - 'status': 'failed', - 'error': 'No solution found!' - } - - def solve_with_time_windows( - self, - distance_matrix: np.ndarray, - location_ids: List[str], - vehicles: List[Vehicle], - deliveries: List[Delivery], - locations: List[Location], - depot_index: int = 0, - speed_km_per_hour: float = 50.0 - ) -> Dict[str, Any]: - """ - Solve the Vehicle Routing Problem with Time Windows. - - Returns: - Dictionary containing the solution details with route time information. - """ - num_locations = len(location_ids) - num_vehicles = len(vehicles) - - # Mappings - location_id_to_index = {loc_id: idx for idx, loc_id in enumerate(location_ids)} - location_index_to_location = { - idx: next((loc for loc in locations if loc.id == loc_id), None) - for idx, loc_id in enumerate(location_ids) - } - - # Set up start and end indices - starts = [] - ends = [] - - for vehicle in vehicles: - try: - start_idx = location_id_to_index[vehicle.start_location_id] - end_idx = location_id_to_index.get(vehicle.end_location_id or vehicle.start_location_id, start_idx) - starts.append(start_idx) - ends.append(end_idx) - except KeyError as e: - logger.error(f"Vehicle location not found in locations: {e}") - return {'status': 'failed', 'error': f"Vehicle location not found: {e}"} - - manager = pywrapcp.RoutingIndexManager(num_locations, num_vehicles, starts, ends) - routing = pywrapcp.RoutingModel(manager) - - # Distance callback - def distance_callback(from_index, to_index): - from_node = manager.IndexToNode(from_index) - to_node = manager.IndexToNode(to_index) - return int(distance_matrix[from_node][to_node] * 1000) # meters - - transit_callback_index = routing.RegisterTransitCallback(distance_callback) - routing.SetArcCostEvaluatorOfAllVehicles(transit_callback_index) - - # Time callback - def time_callback(from_index, to_index): - from_node = manager.IndexToNode(from_index) - to_node = manager.IndexToNode(to_index) - distance_km = distance_matrix[from_node][to_node] - travel_minutes = (distance_km / speed_km_per_hour) * 60 - to_loc = location_index_to_location.get(to_node) - service_time = to_loc.service_time if to_loc else 0 - return int((travel_minutes + service_time) * 60) # seconds - - time_callback_index = routing.RegisterTransitCallback(time_callback) - - # Time Dimension - routing.AddDimension( - time_callback_index, - 3600, # wait time allowed - 86400, # max time (24hr) per vehicle - False, - 'Time' - ) - time_dimension = routing.GetDimensionOrDie('Time') - - # Add time windows to each location - for idx, location_id in enumerate(location_ids): - loc = location_index_to_location.get(idx) - if loc and loc.time_window_start is not None and loc.time_window_end is not None: - start = loc.time_window_start * 60 - end = loc.time_window_end * 60 - index = manager.NodeToIndex(idx) - time_dimension.CumulVar(index).SetRange(start, end) - - # Add capacity constraints - def demand_callback(from_index): - from_node = manager.IndexToNode(from_index) - location_id = location_ids[from_node] - total_demand = 0 - for d in deliveries: - if d.location_id == location_id: - total_demand += -d.demand if d.is_pickup else d.demand - return int(total_demand * 100) - - demand_callback_index = routing.RegisterUnaryTransitCallback(demand_callback) - routing.AddDimensionWithVehicleCapacity( - demand_callback_index, - 0, - [int(v.capacity * 100) for v in vehicles], - True, - 'Capacity' - ) - - # Search parameters - search_parameters = pywrapcp.DefaultRoutingSearchParameters() - search_parameters.first_solution_strategy = routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC - search_parameters.local_search_metaheuristic = routing_enums_pb2.LocalSearchMetaheuristic.GUIDED_LOCAL_SEARCH - search_parameters.time_limit.seconds = self.time_limit_seconds - - solution = routing.SolveWithParameters(search_parameters) - - if solution: - routes = [] - assigned_vehicles = {} - total_distance = 0 - delivery_locations = set() - - for vehicle_idx in range(num_vehicles): - route = [] - index = routing.Start(vehicle_idx) - while not routing.IsEnd(index): - node_index = manager.IndexToNode(index) - time_var = time_dimension.CumulVar(index) - time_val = solution.Min(time_var) - route.append({ - 'location_id': location_ids[node_index], - 'arrival_time_seconds': time_val - }) - delivery_locations.add(location_ids[node_index]) - prev_index = index - index = solution.Value(routing.NextVar(index)) - total_distance += routing.GetArcCostForVehicle(prev_index, index, vehicle_idx) / 1000 - node_index = manager.IndexToNode(index) - time_val = solution.Min(time_dimension.CumulVar(index)) - route.append({ - 'location_id': location_ids[node_index], - 'arrival_time_seconds': time_val - }) - if route: - routes.append(route) - assigned_vehicles[vehicles[vehicle_idx].id] = len(routes) - 1 - - unassigned_deliveries = [ - d.id for d in deliveries if d.location_id not in delivery_locations - ] - - return { - 'status': 'success', - 'routes': routes, - 'total_distance': total_distance, - 'assigned_vehicles': assigned_vehicles, - 'unassigned_deliveries': unassigned_deliveries - } - else: - return { - 'status': 'failed', - 'error': 'No solution found with time window constraints!' - } diff --git a/route_optimizer/services/__init__.py b/route_optimizer/services/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/route_optimizer/services/depot_service.py b/route_optimizer/services/depot_service.py deleted file mode 100644 index dd8b76a..0000000 --- a/route_optimizer/services/depot_service.py +++ /dev/null @@ -1,5 +0,0 @@ -class DepotService: - @staticmethod - def find_depot_index(locations): - depots = [i for i, loc in enumerate(locations) if loc.is_depot] - return depots[0] if depots else 0 diff --git a/route_optimizer/services/external_data_service.py b/route_optimizer/services/external_data_service.py deleted file mode 100644 index 3836395..0000000 --- a/route_optimizer/services/external_data_service.py +++ /dev/null @@ -1,287 +0,0 @@ -""" -Service for handling external data like traffic, weather, and roadblocks. - -This module provides functionality to fetch and process external data -that affects route optimization. -""" -import logging -import datetime -import random -from typing import Dict, List, Tuple, Optional, Any, Set -import json -import requests -from urllib.parse import urlencode - -from route_optimizer.core.distance_matrix import Location - -# Set up logging -logger = logging.getLogger(__name__) - - -class ExternalDataService: - """ - Service for fetching and processing external data like traffic, weather, and roadblocks. - """ - - def __init__( - self, - traffic_api_key: Optional[str] = None, - weather_api_key: Optional[str] = None, - use_mocks: bool = False - ): - """ - Initialize the external data service. - - Args: - traffic_api_key: API key for traffic data service. - weather_api_key: API key for weather data service. - use_mocks: Whether to use mock data instead of real API calls. - """ - self.traffic_api_key = traffic_api_key - self.weather_api_key = weather_api_key - self.use_mocks = use_mocks - - def get_traffic_data( - self, - locations: List[Location] - ) -> Dict[Tuple[int, int], float]: - """ - Get current traffic data for the routes between locations. - - Args: - locations: List of Location objects. - - Returns: - Dictionary mapping (from_idx, to_idx) tuples to traffic factors. - A factor of 1.0 means normal traffic, >1.0 means slower. - """ - if self.use_mocks: - return self._mock_traffic_data(locations) - - try: - # In a real implementation, this would call an external traffic API - # For now, we'll just return mock data - logger.warning("Real traffic API not implemented, using mock data") - return self._mock_traffic_data(locations) - except Exception as e: - logger.error(f"Error fetching traffic data: {str(e)}") - # Return empty data in case of error - return {} - - def get_weather_data( - self, - locations: List[Location] - ) -> Dict[str, Dict[str, Any]]: - """ - Get current weather data for the given locations. - - Args: - locations: List of Location objects. - - Returns: - Dictionary mapping location IDs to weather data. - """ - if self.use_mocks: - return self._mock_weather_data(locations) - - try: - # In a real implementation, this would call an external weather API - # For now, we'll just return mock data - logger.warning("Real weather API not implemented, using mock data") - return self._mock_weather_data(locations) - except Exception as e: - logger.error(f"Error fetching weather data: {str(e)}") - # Return empty data in case of error - return {} - - def get_roadblock_data( - self, - locations: List[Location] - ) -> List[Tuple[str, str]]: - """ - Get current roadblock data for the routes between locations. - - Args: - locations: List of Location objects. - - Returns: - List of tuples (location_id1, location_id2) representing blocked roads. - """ - if self.use_mocks: - return self._mock_roadblock_data(locations) - - try: - # In a real implementation, this would call an external roadblock API - # For now, we'll just return mock data - logger.warning("Real roadblock API not implemented, using mock data") - return self._mock_roadblock_data(locations) - except Exception as e: - logger.error(f"Error fetching roadblock data: {str(e)}") - # Return empty data in case of error - return [] - - def _mock_traffic_data( - self, - locations: List[Location] - ) -> Dict[Tuple[int, int], float]: - """ - Generate mock traffic data for testing. - - Args: - locations: List of Location objects. - - Returns: - Dictionary mapping (from_idx, to_idx) tuples to traffic factors. - """ - traffic_data = {} - num_locations = len(locations) - - # Generate random traffic factors for about 30% of the routes - num_traffic_entries = int(0.3 * num_locations * (num_locations - 1)) - - for _ in range(num_traffic_entries): - from_idx = random.randint(0, num_locations - 1) - to_idx = random.randint(0, num_locations - 1) - - # Ensure we don't have self-loops - if from_idx != to_idx: - # Traffic factor between 1.0 (normal) and 2.0 (heavy traffic) - traffic_factor = 1.0 + random.random() - traffic_data[(from_idx, to_idx)] = traffic_factor - - return traffic_data - - def _mock_weather_data( - self, - locations: List[Location] - ) -> Dict[str, Dict[str, Any]]: - """ - Generate mock weather data for testing. - - Args: - locations: List of Location objects. - - Returns: - Dictionary mapping location IDs to weather data. - """ - weather_conditions = ['Clear', 'Cloudy', 'Rain', 'Snow', 'Thunderstorm'] - weather_data = {} - - for location in locations: - condition = random.choice(weather_conditions) - temperature = random.uniform(-5, 35) # Temperature in Celsius - - # Determine weather impacts on travel - impact_factor = 1.0 - if condition == 'Rain': - impact_factor = 1.2 - elif condition == 'Snow': - impact_factor = 1.5 - elif condition == 'Thunderstorm': - impact_factor = 1.8 - - weather_data[location.id] = { - 'condition': condition, - 'temperature': round(temperature, 1), - 'impact_factor': impact_factor - } - - return weather_data - - def _mock_roadblock_data( - self, - locations: List[Location] - ) -> List[Tuple[str, str]]: - """ - Generate mock roadblock data for testing. - - Args: - locations: List of Location objects. - - Returns: - List of tuples (location_id1, location_id2) representing blocked roads. - """ - roadblocks = [] - num_locations = len(locations) - - # Generate random roadblocks for about 5% of the routes - num_roadblocks = int(0.05 * num_locations * (num_locations - 1)) - num_roadblocks = min(num_roadblocks, 3) # Limit the number of roadblocks - - for _ in range(num_roadblocks): - idx1 = random.randint(0, num_locations - 1) - idx2 = random.randint(0, num_locations - 1) - - # Ensure we don't have self-loops - if idx1 != idx2: - location1 = locations[idx1] - location2 = locations[idx2] - roadblocks.append((location1.id, location2.id)) - - return roadblocks - - def calculate_weather_impact( - self, - weather_data: Dict[str, Dict[str, Any]], - locations: List[Location] - ) -> Dict[Tuple[int, int], float]: - """ - Calculate the impact of weather on travel times. - - Args: - weather_data: Weather data dictionary. - locations: List of Location objects. - - Returns: - Dictionary mapping (from_idx, to_idx) tuples to weather impact factors. - """ - weather_impact = {} - num_locations = len(locations) - - for i in range(num_locations): - for j in range(num_locations): - if i == j: - continue # Skip self-loops - - from_loc = locations[i] - to_loc = locations[j] - - # Get weather impact factors for both locations - from_factor = weather_data.get(from_loc.id, {}).get('impact_factor', 1.0) - to_factor = weather_data.get(to_loc.id, {}).get('impact_factor', 1.0) - - # Take the worse of the two weather conditions - impact_factor = max(from_factor, to_factor) - - # Only add if there's actually some impact - if impact_factor > 1.0: - weather_impact[(i, j)] = impact_factor - - return weather_impact - - def combine_traffic_and_weather( - self, - traffic_data: Dict[Tuple[int, int], float], - weather_impact: Dict[Tuple[int, int], float] - ) -> Dict[Tuple[int, int], float]: - """ - Combine traffic and weather impact data. - - Args: - traffic_data: Traffic factor dictionary. - weather_impact: Weather impact factor dictionary. - - Returns: - Combined impact factors. - """ - combined_data = traffic_data.copy() - - # Add weather impacts - for (i, j), factor in weather_impact.items(): - if (i, j) in combined_data: - # Multiply the impacts - combined_data[(i, j)] *= factor - else: - combined_data[(i, j)] = factor - - return combined_data \ No newline at end of file diff --git a/route_optimizer/services/optimization_service.py b/route_optimizer/services/optimization_service.py deleted file mode 100644 index 991aa4b..0000000 --- a/route_optimizer/services/optimization_service.py +++ /dev/null @@ -1,49 +0,0 @@ -import logging -from route_optimizer.services.traffic_service import TrafficService -from route_optimizer.services.depot_service import DepotService -from route_optimizer.services.path_annotation_service import PathAnnotator -from route_optimizer.services.route_stats_service import RouteStatsService -from route_optimizer.core.dijkstra import DijkstraPathFinder -from route_optimizer.core.ortools_optimizer import ORToolsVRPSolver -from route_optimizer.core.distance_matrix import DistanceMatrixBuilder - -logger = logging.getLogger(__name__) - -class OptimizationService: - def __init__(self, time_limit_seconds=30): - self.vrp_solver = ORToolsVRPSolver(time_limit_seconds) - self.path_finder = DijkstraPathFinder() - - def optimize_routes(self, locations, vehicles, deliveries, consider_traffic=False, consider_time_windows=False, traffic_data=None): - distance_matrix, location_ids = DistanceMatrixBuilder.create_distance_matrix(locations, use_haversine=True) - - if consider_traffic and traffic_data: - distance_matrix = TrafficService.apply_traffic_factors(distance_matrix, traffic_data) - - depot_index = DepotService.find_depot_index(locations) - - if consider_time_windows: - result = self.vrp_solver.solve_with_time_windows( - distance_matrix=distance_matrix, - location_ids=location_ids, - vehicles=vehicles, - deliveries=deliveries, - locations=locations, - depot_index=depot_index - ) - else: - result = self.vrp_solver.solve( - distance_matrix=distance_matrix, - location_ids=location_ids, - vehicles=vehicles, - deliveries=deliveries, - depot_index=depot_index - ) - - if result['status'] == 'success': - graph = DistanceMatrixBuilder.distance_matrix_to_graph(distance_matrix, location_ids) - annotator = PathAnnotator(self.path_finder) - annotator.annotate(result, graph) - RouteStatsService.add_statistics(result, vehicles) - - return result diff --git a/route_optimizer/services/path_annotation_service.py b/route_optimizer/services/path_annotation_service.py deleted file mode 100644 index c9ab79f..0000000 --- a/route_optimizer/services/path_annotation_service.py +++ /dev/null @@ -1,29 +0,0 @@ -class PathAnnotator: - def __init__(self, path_finder): - self.path_finder = path_finder - - def annotate(self, result, graph): - detailed_routes = [] - - for route in result['routes']: - detailed_route = { - 'stops': route, - 'segments': [] - } - - for i in range(len(route) - 1): - from_location = route[i] - to_location = route[i + 1] - - path, distance = self.path_finder.calculate_shortest_path(graph, from_location, to_location) - - if path: - detailed_route['segments'].append({ - 'from': from_location, - 'to': to_location, - 'path': path, - 'distance': distance - }) - detailed_routes.append(detailed_route) - - result['detailed_routes'] = detailed_routes diff --git a/route_optimizer/services/rerouting_service.py b/route_optimizer/services/rerouting_service.py deleted file mode 100644 index 4eae3bb..0000000 --- a/route_optimizer/services/rerouting_service.py +++ /dev/null @@ -1,302 +0,0 @@ -""" -Service for dynamic rerouting based on real-time events. - -This module provides functionality to dynamically adjust routes -based on unexpected events like traffic, delays, or roadblocks. -""" -import logging -from typing import Dict, List, Tuple, Optional, Any, Set -import copy -import numpy as np - -from route_optimizer.core.distance_matrix import Location, DistanceMatrixBuilder -from route_optimizer.core.ortools_optimizer import Vehicle, Delivery -from route_optimizer.services.optimization_service import OptimizationService - -# Set up logging -logger = logging.getLogger(__name__) - - -class ReroutingService: - """ - Service for dynamic rerouting of vehicles based on real-time events. - """ - - def __init__(self, optimization_service: Optional[OptimizationService] = None): - """ - Initialize the rerouting service. - - Args: - optimization_service: Optimization service to use for rerouting. - If None, a new service will be created. - """ - self.optimization_service = optimization_service or OptimizationService() - - def reroute_for_traffic( - self, - current_routes: Dict[str, Any], - locations: List[Location], - vehicles: List[Vehicle], - completed_deliveries: List[str], - traffic_data: Dict[Tuple[int, int], float] - ) -> Dict[str, Any]: - """ - Reroute vehicles based on updated traffic data. - - Args: - current_routes: Current route plan. - locations: List of all locations. - vehicles: List of all vehicles. - completed_deliveries: IDs of deliveries that have been completed. - traffic_data: Dictionary mapping (location_idx, location_idx) to traffic factors. - A factor > 1.0 means slower traffic. - - Returns: - Updated route plan. - """ - # Filter out completed deliveries - remaining_deliveries = self._get_remaining_deliveries( - current_routes, completed_deliveries - ) - - # Update vehicle positions - updated_vehicles = self._update_vehicle_positions( - vehicles, current_routes, completed_deliveries - ) - - # Re-optimize with traffic data - new_routes = self.optimization_service.optimize_routes( - locations=locations, - vehicles=updated_vehicles, - deliveries=remaining_deliveries, - consider_traffic=True, - traffic_data=traffic_data - ) - - # Add rerouting metadata - new_routes['rerouting_info'] = { - 'reason': 'traffic', - 'traffic_factors': len(traffic_data), - 'completed_deliveries': len(completed_deliveries), - 'remaining_deliveries': len(remaining_deliveries) - } - - return new_routes - - def reroute_for_delay( - self, - current_routes: Dict[str, Any], - locations: List[Location], - vehicles: List[Vehicle], - completed_deliveries: List[str], - delayed_location_ids: List[str], - delay_minutes: Dict[str, int] - ) -> Dict[str, Any]: - """ - Reroute vehicles based on loading/unloading delays. - - Args: - current_routes: Current route plan. - locations: List of all locations. - vehicles: List of all vehicles. - completed_deliveries: IDs of deliveries that have been completed. - delayed_location_ids: IDs of locations experiencing delays. - delay_minutes: Dictionary mapping location IDs to delay in minutes. - - Returns: - Updated route plan. - """ - # Update service times for delayed locations - updated_locations = copy.deepcopy(locations) - for location in updated_locations: - if location.id in delayed_location_ids: - # Add delay to service time - location.service_time += delay_minutes.get(location.id, 0) - - # Filter out completed deliveries - remaining_deliveries = self._get_remaining_deliveries( - current_routes, completed_deliveries - ) - - # Update vehicle positions - updated_vehicles = self._update_vehicle_positions( - vehicles, current_routes, completed_deliveries - ) - - # Re-optimize with updated service times - new_routes = self.optimization_service.optimize_routes( - locations=updated_locations, - vehicles=updated_vehicles, - deliveries=remaining_deliveries, - consider_time_windows=True - ) - - # Add rerouting metadata - new_routes['rerouting_info'] = { - 'reason': 'service_delay', - 'delayed_locations': len(delayed_location_ids), - 'completed_deliveries': len(completed_deliveries), - 'remaining_deliveries': len(remaining_deliveries) - } - - return new_routes - - def reroute_for_roadblock( - self, - current_routes: Dict[str, Any], - locations: List[Location], - vehicles: List[Vehicle], - completed_deliveries: List[str], - blocked_segments: List[Tuple[str, str]] - ) -> Dict[str, Any]: - """ - Reroute vehicles based on road blocks. - - Args: - current_routes: Current route plan. - locations: List of all locations. - vehicles: List of all vehicles. - completed_deliveries: IDs of deliveries that have been completed. - blocked_segments: List of tuples (location_id1, location_id2) representing blocked roads. - - Returns: - Updated route plan. - """ - # Create distance matrix - distance_matrix, location_ids = DistanceMatrixBuilder.create_distance_matrix( - locations, use_haversine=True - ) - - # Create location ID to index mapping - location_id_to_index = {loc_id: i for i, loc_id in enumerate(location_ids)} - - # Apply roadblocks by setting distances to infinity - for from_id, to_id in blocked_segments: - try: - from_idx = location_id_to_index[from_id] - to_idx = location_id_to_index[to_id] - - # Set both directions to infinity (very high value) - distance_matrix[from_idx, to_idx] = float('inf') - distance_matrix[to_idx, from_idx] = float('inf') - except KeyError: - logger.warning(f"Location ID not found when applying roadblock: {from_id} or {to_id}") - - # Filter out completed deliveries - remaining_deliveries = self._get_remaining_deliveries( - current_routes, completed_deliveries - ) - - # Update vehicle positions - updated_vehicles = self._update_vehicle_positions( - vehicles, current_routes, completed_deliveries - ) - - # Create a custom traffic data structure for the modified distances - traffic_data = {} - for i in range(len(location_ids)): - for j in range(len(location_ids)): - if distance_matrix[i, j] == float('inf'): - traffic_data[(i, j)] = float('inf') - - # Re-optimize with roadblock data - new_routes = self.optimization_service.optimize_routes( - locations=locations, - vehicles=updated_vehicles, - deliveries=remaining_deliveries, - consider_traffic=True, - traffic_data=traffic_data - ) - - # Add rerouting metadata - new_routes['rerouting_info'] = { - 'reason': 'roadblock', - 'blocked_segments': len(blocked_segments), - 'completed_deliveries': len(completed_deliveries), - 'remaining_deliveries': len(remaining_deliveries) - } - - return new_routes - - def _get_remaining_deliveries( - self, - current_routes: Dict[str, Any], - completed_delivery_ids: List[str] - ) -> List[Delivery]: - """ - Extract the remaining deliveries that need to be completed. - - Args: - current_routes: Current route plan. - completed_delivery_ids: IDs of deliveries that have been completed. - - Returns: - List of remaining Delivery objects. - """ - # This is a placeholder implementation - # In a real system, you'd need to map from the route structure to actual Delivery objects - # For this example, we'll assume the system stores deliveries in current_routes['deliveries'] - - completed_set = set(completed_delivery_ids) - remaining_deliveries = [] - - if 'deliveries' in current_routes: - for delivery in current_routes['deliveries']: - if delivery.id not in completed_set: - remaining_deliveries.append(delivery) - - return remaining_deliveries - - def _update_vehicle_positions( - self, - vehicles: List[Vehicle], - current_routes: Dict[str, Any], - completed_delivery_ids: List[str] - ) -> List[Vehicle]: - """ - Update vehicle positions based on completed deliveries. - - Args: - vehicles: List of original Vehicle objects. - current_routes: Current route plan. - completed_delivery_ids: IDs of deliveries that have been completed. - - Returns: - Updated list of Vehicle objects with new start locations. - """ - # Create a mapping from delivery IDs to location IDs - delivery_to_location = {} - if 'deliveries' in current_routes: - for delivery in current_routes['deliveries']: - delivery_to_location[delivery.id] = delivery.location_id - - # Create a deep copy of vehicles to modify - updated_vehicles = copy.deepcopy(vehicles) - - # Map vehicle IDs to their assigned routes - vehicle_routes = {} - if 'assigned_vehicles' in current_routes: - for vehicle_id, route_idx in current_routes['assigned_vehicles'].items(): - if 'detailed_routes' in current_routes and route_idx < len(current_routes['detailed_routes']): - vehicle_routes[vehicle_id] = current_routes['detailed_routes'][route_idx]['stops'] - - # Update each vehicle's starting position - for vehicle in updated_vehicles: - route = vehicle_routes.get(vehicle.id) - if not route: - continue - - # Find the last completed delivery for this vehicle - last_completed_idx = -1 - for i, location_id in enumerate(route): - # Check if any completed delivery is at this location - for delivery_id in completed_delivery_ids: - if delivery_to_location.get(delivery_id) == location_id: - last_completed_idx = max(last_completed_idx, i) - - # Update vehicle starting location if deliveries have been completed - if last_completed_idx >= 0 and last_completed_idx < len(route) - 1: - # Set new starting location to the location after the last completed one - vehicle.start_location_id = route[last_completed_idx + 1] - - return updated_vehicles \ No newline at end of file diff --git a/route_optimizer/services/route_stats_service.py b/route_optimizer/services/route_stats_service.py deleted file mode 100644 index 0fcea21..0000000 --- a/route_optimizer/services/route_stats_service.py +++ /dev/null @@ -1,31 +0,0 @@ -class RouteStatsService: - @staticmethod - def add_statistics(result, vehicles): - vehicle_costs = {} - total_cost = 0 - - for vehicle_id, route_idx in result['assigned_vehicles'].items(): - vehicle = next((v for v in vehicles if v.id == vehicle_id), None) - if vehicle: - route_distance = sum( - segment['distance'] - for segment in result['detailed_routes'][route_idx]['segments'] - ) - cost = vehicle.fixed_cost + (route_distance * vehicle.cost_per_km) - vehicle_costs[vehicle_id] = { - 'distance': route_distance, - 'cost': cost - } - total_cost += cost - - result['vehicle_costs'] = vehicle_costs - result['total_cost'] = total_cost - - total_stops = sum(len(route['stops']) for route in result['detailed_routes']) - result['total_stops'] = total_stops - - if total_stops > 0: - result['avg_distance_per_stop'] = result['total_distance'] / total_stops - - result['vehicles_used'] = len(result['assigned_vehicles']) - result['vehicles_unused'] = len(vehicles) - result['vehicles_used'] diff --git a/route_optimizer/services/traffic_service.py b/route_optimizer/services/traffic_service.py deleted file mode 100644 index b732572..0000000 --- a/route_optimizer/services/traffic_service.py +++ /dev/null @@ -1,6 +0,0 @@ -from route_optimizer.core.distance_matrix import DistanceMatrixBuilder - -class TrafficService: - @staticmethod - def apply_traffic_factors(distance_matrix, traffic_data): - return DistanceMatrixBuilder.add_traffic_factors(distance_matrix, traffic_data) diff --git a/route_optimizer/tests/core/__init__.py b/route_optimizer/tests/core/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/route_optimizer/tests/core/test_dijkstra.py b/route_optimizer/tests/core/test_dijkstra.py deleted file mode 100644 index e09d90e..0000000 --- a/route_optimizer/tests/core/test_dijkstra.py +++ /dev/null @@ -1,166 +0,0 @@ -""" -Tests for Dijkstra's algorithm implementation. - -This module contains tests for the DijkstraPathFinder class. -""" -import unittest -from route_optimizer.core.dijkstra import DijkstraPathFinder - - -class TestDijkstraPathFinder(unittest.TestCase): - """Test cases for DijkstraPathFinder.""" - - def setUp(self): - """Set up test fixtures.""" - self.path_finder = DijkstraPathFinder() - - # Simple test graph - # A -- 1 --> B -- 2 --> C - # | | - # 4 3 - # | | - # v v - # D -- 5 --> E - self.simple_graph = { - 'A': {'B': 1.0, 'D': 4.0}, - 'B': {'C': 2.0, 'E': 3.0}, - 'C': {}, - 'D': {'E': 5.0}, - 'E': {} - } - - # More complex graph with cycles and different paths - self.complex_graph = { - 'A': {'B': 1.0, 'C': 4.0}, - 'B': {'C': 2.0, 'D': 5.0}, - 'C': {'D': 1.0, 'E': 3.0}, - 'D': {'B': 1.0, 'E': 2.0, 'F': 5.0}, - 'E': {'F': 1.0}, - 'F': {'A': 10.0} - } - - def test_shortest_path_simple(self): - """Test finding shortest path in a simple graph.""" - # Test A to C: A -> B -> C - path, distance = self.path_finder.calculate_shortest_path( - self.simple_graph, 'A', 'C' - ) - self.assertEqual(path, ['A', 'B', 'C']) - self.assertEqual(distance, 3.0) - - # Test A to E: A -> B -> E - path, distance = self.path_finder.calculate_shortest_path( - self.simple_graph, 'A', 'E' - ) - self.assertEqual(path, ['A', 'B', 'E']) - self.assertEqual(distance, 4.0) - - # Test D to C: no path exists - path, distance = self.path_finder.calculate_shortest_path( - self.simple_graph, 'D', 'C' - ) - self.assertIsNone(path) - self.assertIsNone(distance) - - def test_shortest_path_complex(self): - """Test finding shortest path in a more complex graph.""" - # Test A to F: A -> C -> E -> F - path, distance = self.path_finder.calculate_shortest_path( - self.complex_graph, 'A', 'F' - ) - # self.assertEqual(path, ['A', 'B', 'C', 'E', 'F']) - self.assertEqual(distance, 7.0) # 1 + 2 + 3 + 1 - - # Test D to A: D -> B -> C -> E -> F -> A - path, distance = self.path_finder.calculate_shortest_path( - self.complex_graph, 'D', 'A' - ) - # self.assertEqual(path, ['D', 'B', 'C', 'E', 'F', 'A']) - self.assertEqual(distance, 13.0) # 2 + 1 + 10 - - - def test_edge_cases(self): - """Test edge cases for the shortest path algorithm.""" - # Test path from a node to itself - path, distance = self.path_finder.calculate_shortest_path( - self.simple_graph, 'A', 'A' - ) - self.assertEqual(path, ['A']) - self.assertEqual(distance, 0.0) - - # Test with non-existent nodes - path, distance = self.path_finder.calculate_shortest_path( - self.simple_graph, 'X', 'Y' - ) - self.assertIsNone(path) - self.assertIsNone(distance) - - # Test with start node exists but end doesn't - path, distance = self.path_finder.calculate_shortest_path( - self.simple_graph, 'A', 'Z' - ) - self.assertIsNone(path) - self.assertIsNone(distance) - - def test_all_shortest_paths(self): - """Test calculating all shortest paths between nodes.""" - nodes = ['A', 'B', 'C', 'D', 'E'] - all_paths = self.path_finder.calculate_all_shortest_paths(self.simple_graph, nodes) - - # Structure checks - for start in nodes: - for end in nodes: - self.assertIn(end, all_paths[start]) - - # Shortest path from A to C: A → B → C - self.assertEqual(all_paths['A']['C']['path'], ['A', 'B', 'C']) - self.assertEqual(all_paths['A']['C']['distance'], 3.0) - - # Shortest path from A to E: A → B → E (1 + 3 = 4.0) - self.assertEqual(all_paths['A']['E']['path'], ['A', 'B', 'E']) - self.assertEqual(all_paths['A']['E']['distance'], 4.0) - - # Shortest path from D to E: D → E - self.assertEqual(all_paths['D']['E']['path'], ['D', 'E']) - self.assertEqual(all_paths['D']['E']['distance'], 5.0) - - # Path from E to any node is impossible (E has no outbound edges) - for target in ['A', 'B', 'C', 'D']: - self.assertIsNone(all_paths['E'][target]['path']) - self.assertEqual(all_paths['E'][target]['distance'], float('inf')) - - # Self-paths - for node in nodes: - self.assertEqual(all_paths[node][node]['path'], [node]) - self.assertEqual(all_paths[node][node]['distance'], 0) - - - def test_negative_weights_error(self): - """Test that Dijkstra raises an error when negative weights are present.""" - graph_with_negative = { - 'A': {'B': 1.0, 'C': 4.0}, - 'B': {'C': -2.0} # Negative weight - } - - with self.assertRaises(ValueError) as context: - self.path_finder.calculate_shortest_path(graph_with_negative, 'A', 'C') - - self.assertIn('Negative weight detected', str(context.exception)) - - def test_all_shortest_paths_negative_weight_error(self): - """Test that calculate_all_shortest_paths raises an error with negative weights.""" - graph_with_negative = { - 'A': {'B': 2.0}, - 'B': {'C': -3.0}, # Negative weight - 'C': {'A': 1.0} - } - nodes = ['A', 'B', 'C'] - - with self.assertRaises(ValueError) as context: - self.path_finder.calculate_all_shortest_paths(graph_with_negative, nodes) - - self.assertIn('Negative weight detected', str(context.exception)) - - -if __name__ == '__main__': - unittest.main() \ No newline at end of file diff --git a/route_optimizer/tests/core/test_ortools_optimizer.py b/route_optimizer/tests/core/test_ortools_optimizer.py deleted file mode 100644 index e90a274..0000000 --- a/route_optimizer/tests/core/test_ortools_optimizer.py +++ /dev/null @@ -1,237 +0,0 @@ -""" -Tests for OR-Tools VRP solver implementation. - -This module contains tests for the ORToolsVRPSolver class. -""" -import unittest -import numpy as np -from route_optimizer.core.ortools_optimizer import ORToolsVRPSolver, Vehicle, Delivery -from route_optimizer.core.distance_matrix import Location - - -class TestORToolsVRPSolver(unittest.TestCase): - """Test cases for ORToolsVRPSolver.""" - - def setUp(self): - """Set up test fixtures.""" - self.solver = ORToolsVRPSolver(time_limit_seconds=1) # Short time limit for tests - - # Sample locations - self.locations = [ - Location(id="depot", name="Depot", latitude=0.0, longitude=0.0, is_depot=True), - Location(id="customer1", name="Customer 1", latitude=1.0, longitude=0.0), - Location(id="customer2", name="Customer 2", latitude=0.0, longitude=1.0), - Location(id="customer3", name="Customer 3", latitude=1.0, longitude=1.0) - ] - - # Sample location IDs - self.location_ids = [loc.id for loc in self.locations] - - # Sample distance matrix (in km) - self.distance_matrix = np.array([ - [0.0, 1.0, 1.0, 1.4], # Depot to others - [1.0, 0.0, 1.4, 1.0], # Customer 1 to others - [1.0, 1.4, 0.0, 1.0], # Customer 2 to others - [1.4, 1.0, 1.0, 0.0] # Customer 3 to others - ]) - - # Sample vehicles - self.vehicles = [ - Vehicle( - id="vehicle1", - capacity=10.0, - start_location_id="depot", - end_location_id="depot" - ), - Vehicle( - id="vehicle2", - capacity=15.0, - start_location_id="depot", - end_location_id="depot" - ) - ] - - # Sample deliveries - self.deliveries = [ - Delivery(id="delivery1", location_id="customer1", demand=5.0), - Delivery(id="delivery2", location_id="customer2", demand=3.0), - Delivery(id="delivery3", location_id="customer3", demand=6.0) - ] - - def test_basic_routing(self): - """Test basic routing functionality.""" - solution = self.solver.solve( - distance_matrix=self.distance_matrix, - location_ids=self.location_ids, - vehicles=self.vehicles, - deliveries=self.deliveries, - depot_index=0 - ) - # Verify solution structure - self.assertIn('status', solution) - self.assertIn('routes', solution) - self.assertIn('total_distance', solution) - self.assertIn('assigned_vehicles', solution) - self.assertIn('unassigned_deliveries', solution) - - # Verify all deliveries are assigned - self.assertEqual(len(solution['unassigned_deliveries']), 0) - - # Verify correct number of routes - # We expect at most 2 routes (one per vehicle) - self.assertLessEqual(len(solution['routes']), 2) - - # Verify each route starts and ends at the depot - for route in solution['routes']: - self.assertEqual(route[0], 'depot') # Start at depot - self.assertEqual(route[-1], 'depot') # End at depot - - # All customers should be visited exactly once - all_visits = [] - for route in solution['routes']: - all_visits.extend(route[1:-1]) # Exclude depot at start and end - - self.assertEqual(set(all_visits), {'customer1', 'customer2', 'customer3'}) # Indices of customer1, customer2, customer3 - - # def test_capacity_constraints(self): - # """Test that vehicle capacity constraints are respected.""" - - # # Force high-demand scenario - # self.deliveries = [ - # Delivery(id="d1", location_id="customer1", demand=6.0), - # Delivery(id="d2", location_id="customer2", demand=5.0), - # Delivery(id="d3", location_id="customer3", demand=4.0) - # ] - - # small_vehicle = [ - # Vehicle( - # id="small_vehicle", - # capacity=8.0, - # start_location_id="depot", - # end_location_id="depot" - # ) - # ] - - # solution = self.solver.solve( - # distance_matrix=self.distance_matrix, - # location_ids=self.location_ids, - # vehicles=small_vehicle, - # deliveries=self.deliveries, - # depot_index=0 - # ) - - # print("Unassigned deliveries:", solution.get('unassigned_deliveries', [])) - - # self.assertEqual(solution['status'], 'success') - # self.assertTrue( - # len(solution['unassigned_deliveries']) > 0, - # "Expected some deliveries to be unassigned due to capacity limits." - # ) - - def test_multi_vehicle_assignment(self): - """Test that deliveries are assigned to multiple vehicles when needed.""" - solution = self.solver.solve( - distance_matrix=self.distance_matrix, - location_ids=self.location_ids, - vehicles=self.vehicles, - deliveries=self.deliveries, - depot_index=0 - ) - - # All deliveries should be assigned - self.assertEqual(len(solution['unassigned_deliveries']), 0) - - # The solver might use one or two vehicles depending on the best solution - # If the total demand (14.0) is split, we should have two routes - if len(solution['routes']) == 2: - # Verify both vehicles are used - self.assertEqual(len(solution['assigned_vehicles']), 2) - - def test_empty_problem(self): - """Test handling of empty problem (no deliveries).""" - solution = self.solver.solve( - distance_matrix=self.distance_matrix, - location_ids=self.location_ids, - vehicles=self.vehicles, - deliveries=[], # No deliveries - depot_index=0 - ) - - # Should have valid solution with no routes - self.assertEqual(solution['status'], 'success') - self.assertEqual(len(solution['routes']), 2) - self.assertEqual(solution['total_distance'], 4.0) - -def test_time_windows(self): - """Test routing with time windows.""" - locations_with_tw = [ - Location( - id="depot", - name="Depot", - latitude=0.0, - longitude=0.0, - is_depot=True, - time_window_start=0, # 00:00 - time_window_end=1440 # 24:00 - ), - Location( - id="customer1", - name="Customer 1", - latitude=1.0, - longitude=0.0, - time_window_start=480, # 08:00 - time_window_end=600 # 10:00 - ), - Location( - id="customer2", - name="Customer 2", - latitude=0.0, - longitude=1.0, - time_window_start=540, # 09:00 - time_window_end=660 # 11:00 - ), - Location( - id="customer3", - name="Customer 3", - latitude=1.0, - longitude=1.0, - time_window_start=600, # 10:00 - time_window_end=720 # 12:00 - ) - ] - - solution = self.solver.solve_with_time_windows( - distance_matrix=self.distance_matrix, - location_ids=self.location_ids, - vehicles=self.vehicles, - deliveries=self.deliveries, - locations=locations_with_tw, - depot_index=0, - speed_km_per_hour=60.0 - ) - - # Check required keys - self.assertIn('status', solution) - self.assertIn('routes', solution) - - # If successful, check time windows are respected - if solution['status'] == 'success': - for route in solution['routes']: - for stop in route: - loc_id = stop['location_id'] - arrival_minutes = stop['arrival_time_seconds'] // 60 - location = next((l for l in locations_with_tw if l.id == loc_id), None) - - if location and location.time_window_start is not None and location.time_window_end is not None: - self.assertGreaterEqual( - arrival_minutes, location.time_window_start, - f"Arrival at {loc_id} too early: {arrival_minutes} < {location.time_window_start}" - ) - self.assertLessEqual( - arrival_minutes, location.time_window_end, - f"Arrival at {loc_id} too late: {arrival_minutes} > {location.time_window_end}" - ) - - -if __name__ == '__main__': - unittest.main() \ No newline at end of file diff --git a/route_optimizer/tests/services/__init__.py b/route_optimizer/tests/services/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/route_optimizer/tests/services/test_depot_service.py b/route_optimizer/tests/services/test_depot_service.py deleted file mode 100644 index eba85b8..0000000 --- a/route_optimizer/tests/services/test_depot_service.py +++ /dev/null @@ -1,14 +0,0 @@ -from django.test import TestCase -from collections import namedtuple -from route_optimizer.services.depot_service import DepotService - -Location = namedtuple('Location', ['is_depot']) - -class DepotServiceTest(TestCase): - def test_find_depot_index_with_depot(self): - locations = [Location(False), Location(True), Location(False)] - self.assertEqual(DepotService.find_depot_index(locations), 1) - - def test_find_depot_index_without_depot(self): - locations = [Location(False), Location(False)] - self.assertEqual(DepotService.find_depot_index(locations), 0) diff --git a/route_optimizer/tests/services/test_optimization_service.py b/route_optimizer/tests/services/test_optimization_service.py deleted file mode 100644 index 011f955..0000000 --- a/route_optimizer/tests/services/test_optimization_service.py +++ /dev/null @@ -1,195 +0,0 @@ -""" -Tests for the optimization service. - -This module contains tests for the OptimizationService class. -""" -import unittest -from unittest.mock import patch, MagicMock -import numpy as np - -from route_optimizer.services.optimization_service import OptimizationService -from route_optimizer.core.ortools_optimizer import Vehicle, Delivery -from route_optimizer.core.distance_matrix import Location, DistanceMatrixBuilder - - -class TestOptimizationService(unittest.TestCase): - """Test cases for OptimizationService.""" - - def setUp(self): - """Set up test fixtures.""" - self.service = OptimizationService() - - # Sample locations - self.locations = [ - Location(id="depot", name="Depot", latitude=0.0, longitude=0.0, is_depot=True), - Location(id="customer1", name="Customer 1", latitude=1.0, longitude=0.0), - Location(id="customer2", name="Customer 2", latitude=0.0, longitude=1.0), - Location(id="customer3", name="Customer 3", latitude=1.0, longitude=1.0) - ] - - # Sample vehicles - self.vehicles = [ - Vehicle( - id="vehicle1", - capacity=10.0, - start_location_id="depot", - end_location_id="depot" - ), - Vehicle( - id="vehicle2", - capacity=15.0, - start_location_id="depot", - end_location_id="depot" - ) - ] - - # Sample deliveries - self.deliveries = [ - Delivery(id="delivery1", location_id="customer1", demand=5.0), - Delivery(id="delivery2", location_id="customer2", demand=3.0), - Delivery(id="delivery3", location_id="customer3", demand=6.0) - ] - - # Mock distance matrix and location IDs - self.distance_matrix = np.array([ - [0.0, 1.0, 1.0, 1.4], - [1.0, 0.0, 1.4, 1.0], - [1.0, 1.4, 0.0, 1.0], - [1.4, 1.0, 1.0, 0.0] - ]) - self.location_ids = ["depot", "customer1", "customer2", "customer3"] - - @patch('route_optimizer.core.distance_matrix.DistanceMatrixBuilder.create_distance_matrix') - @patch('route_optimizer.core.ortools_optimizer.ORToolsVRPSolver.solve') - def test_optimize_routes_basic(self, mock_solve, mock_create_matrix): - """Test basic route optimization without traffic or time windows.""" - # Set up mocks - mock_create_matrix.return_value = (self.distance_matrix, self.location_ids) - mock_solve.return_value = { - 'status': 'success', - 'routes': [[0, 1, 2, 0], [0, 3, 0]], - 'total_distance': 6.0, - 'assigned_vehicles': {'vehicle1': 0, 'vehicle2': 1}, - 'unassigned_deliveries': [] - } - - # Call the service - result = self.service.optimize_routes( - locations=self.locations, - vehicles=self.vehicles, - deliveries=self.deliveries - ) - - # Verify the result - self.assertEqual(result['status'], 'success') - self.assertEqual(result['total_distance'], 6.0) - self.assertEqual(len(result['routes']), 2) - self.assertEqual(len(result['unassigned_deliveries']), 0) - - # Verify the mocks were called correctly - mock_create_matrix.assert_called_once() - mock_solve.assert_called_once() - - @patch('route_optimizer.core.distance_matrix.DistanceMatrixBuilder.create_distance_matrix') - @patch('route_optimizer.core.distance_matrix.DistanceMatrixBuilder.add_traffic_factors') - @patch('route_optimizer.core.ortools_optimizer.ORToolsVRPSolver.solve') - def test_optimize_routes_with_traffic(self, mock_solve, mock_add_traffic, mock_create_matrix): - """Test route optimization with traffic data.""" - # Set up mocks - mock_create_matrix.return_value = (self.distance_matrix, self.location_ids) - mock_add_traffic.return_value = self.distance_matrix # Just return the same matrix for simplicity - mock_solve.return_value = { - 'status': 'success', - 'routes': [[0, 1, 2, 0], [0, 3, 0]], - 'total_distance': 6.0, - 'assigned_vehicles': {'vehicle1': 0, 'vehicle2': 1}, - 'unassigned_deliveries': [] - } - - # Sample traffic data - traffic_data = {(0, 1): 1.5, (1, 2): 1.2} - - # Call the service - result = self.service.optimize_routes( - locations=self.locations, - vehicles=self.vehicles, - deliveries=self.deliveries, - consider_traffic=True, - traffic_data=traffic_data - ) - - # Verify the result - self.assertEqual(result['status'], 'success') - - # Verify the mocks were called correctly - mock_create_matrix.assert_called_once() - mock_add_traffic.assert_called_once_with(self.distance_matrix, traffic_data) - mock_solve.assert_called_once() - - @patch('route_optimizer.core.distance_matrix.DistanceMatrixBuilder.create_distance_matrix') - @patch('route_optimizer.core.ortools_optimizer.ORToolsVRPSolver.solve_with_time_windows') - def test_optimize_routes_with_time_windows(self, mock_solve_tw, mock_create_matrix): - """Test route optimization with time windows.""" - # Set up mocks - mock_create_matrix.return_value = (self.distance_matrix, self.location_ids) - mock_solve_tw.return_value = { - 'status': 'success', - 'routes': [[0, 1, 2, 0], [0, 3, 0]], - 'total_distance': 6.0, - 'assigned_vehicles': {'vehicle1': 0, 'vehicle2': 1}, - 'unassigned_deliveries': [], - 'arrival_times': {0: [0, 20, 40], 1: [0, 30]} - } - - # Create locations with time windows - locations_with_tw = [ - Location( - id="depot", - name="Depot", - latitude=0.0, - longitude=0.0, - is_depot=True, - time_window_start=0, - time_window_end=1440 - ), - Location( - id="customer1", - name="Customer 1", - latitude=1.0, - longitude=0.0, - time_window_start=480, - time_window_end=600 - ), - Location( - id="customer2", - name="Customer 2", - latitude=0.0, - longitude=1.0, - time_window_start=540, - time_window_end=660 - ), - Location( - id="customer3", - name="Customer 3", - latitude=1.0, - longitude=1.0, - time_window_start=600, - time_window_end=720 - ) - ] - - # Call the service - result = self.service.optimize_routes( - locations=locations_with_tw, - vehicles=self.vehicles, - deliveries=self.deliveries, - consider_time_windows=True - ) - - # Verify the result - self.assertEqual(result['status'], 'success') - self.assertIn('arrival_times', result) - - # Verify the mocks were called correctly - mock_create_matrix.assert_called_once() - mock_solve_tw.assert_called_once() diff --git a/route_optimizer/tests/services/test_optimization_service_edge_cases.py b/route_optimizer/tests/services/test_optimization_service_edge_cases.py deleted file mode 100644 index ac9b479..0000000 --- a/route_optimizer/tests/services/test_optimization_service_edge_cases.py +++ /dev/null @@ -1,75 +0,0 @@ -import unittest -from unittest.mock import patch, MagicMock -import numpy as np - -from route_optimizer.services.optimization_service import OptimizationService -from route_optimizer.core.ortools_optimizer import Vehicle, Delivery -from route_optimizer.core.distance_matrix import Location - -class OptimizationServiceEdgeCaseTests(unittest.TestCase): - def setUp(self): - self.service = OptimizationService() - self.locations = [ - Location(id="depot", name="Depot", latitude=0.0, longitude=0.0, is_depot=True), - ] - self.vehicles = [ - Vehicle(id="vehicle1", capacity=10.0, start_location_id="depot", end_location_id="depot") - ] - self.deliveries = [] - - @patch('route_optimizer.core.distance_matrix.DistanceMatrixBuilder.create_distance_matrix') - @patch('route_optimizer.core.ortools_optimizer.ORToolsVRPSolver.solve') - def test_optimize_with_no_deliveries(self, mock_solve, mock_create_matrix): - """Should handle when there are no deliveries.""" - mock_create_matrix.return_value = (np.array([[0.0]]), ["depot"]) - mock_solve.return_value = { - 'status': 'success', - 'routes': [[0]], - 'total_distance': 0.0, - 'assigned_vehicles': {'vehicle1': 0}, - 'unassigned_deliveries': [] - } - result = self.service.optimize_routes( - locations=self.locations, - vehicles=self.vehicles, - deliveries=[] - ) - self.assertEqual(result['status'], 'success') - self.assertEqual(result['total_distance'], 0.0) - self.assertEqual(result['routes'][0], [0]) - - @patch('route_optimizer.core.distance_matrix.DistanceMatrixBuilder.create_distance_matrix') - @patch('route_optimizer.core.ortools_optimizer.ORToolsVRPSolver.solve') - def test_optimize_invalid_depot_index(self, mock_solve, mock_create_matrix): - """Should fall back to index 0 when no depot is marked.""" - locations = [Location(id="node0", name="Node 0", latitude=0.0, longitude=0.0)] - mock_create_matrix.return_value = (np.array([[0.0]]), ["node0"]) - mock_solve.return_value = { - 'status': 'success', - 'routes': [[0]], - 'total_distance': 0.0, - 'assigned_vehicles': {'vehicle1': 0}, - 'unassigned_deliveries': [] - } - result = self.service.optimize_routes( - locations=locations, - vehicles=self.vehicles, - deliveries=[] - ) - self.assertEqual(result['status'], 'success') - self.assertEqual(result['routes'][0], [0]) - - @patch('route_optimizer.core.distance_matrix.DistanceMatrixBuilder.create_distance_matrix') - @patch('route_optimizer.core.ortools_optimizer.ORToolsVRPSolver.solve') - def test_optimize_failure_case(self, mock_solve, mock_create_matrix): - """Should return failure and skip enrichment if solver fails.""" - mock_create_matrix.return_value = (np.array([[0.0]]), ["depot"]) - mock_solve.return_value = {'status': 'failure'} - result = self.service.optimize_routes( - locations=self.locations, - vehicles=self.vehicles, - deliveries=self.deliveries - ) - self.assertEqual(result['status'], 'failure') - self.assertNotIn('detailed_routes', result) - diff --git a/route_optimizer/tests/services/test_path_annotation_service.py b/route_optimizer/tests/services/test_path_annotation_service.py deleted file mode 100644 index 1e4f483..0000000 --- a/route_optimizer/tests/services/test_path_annotation_service.py +++ /dev/null @@ -1,18 +0,0 @@ -from django.test import TestCase -from route_optimizer.services.path_annotation_service import PathAnnotator - -class DummyPathFinder: - def calculate_shortest_path(self, graph, from_node, to_node): - return [from_node, to_node], 5 - -class PathAnnotatorTest(TestCase): - def test_annotate(self): - graph = {'A': {'B': 5}, 'B': {'A': 5}} - result = {'routes': [['A', 'B']]} - - annotator = PathAnnotator(DummyPathFinder()) - annotator.annotate(result, graph) - - self.assertIn('detailed_routes', result) - self.assertEqual(len(result['detailed_routes']), 1) - self.assertEqual(result['detailed_routes'][0]['segments'][0]['distance'], 5) diff --git a/route_optimizer/tests/services/test_route_stats_service.py b/route_optimizer/tests/services/test_route_stats_service.py deleted file mode 100644 index 35d4cbf..0000000 --- a/route_optimizer/tests/services/test_route_stats_service.py +++ /dev/null @@ -1,21 +0,0 @@ -from django.test import TestCase -from collections import namedtuple -from route_optimizer.services.route_stats_service import RouteStatsService - -Vehicle = namedtuple('Vehicle', ['id', 'fixed_cost', 'cost_per_km']) - -class RouteStatsServiceTest(TestCase): - def test_add_statistics(self): - result = { - 'assigned_vehicles': {1: 0}, - 'detailed_routes': [ - {'stops': ['A', 'B'], 'segments': [{'distance': 5}, {'distance': 7}]} - ], - 'total_distance': 12 - } - vehicles = [Vehicle(id=1, fixed_cost=100, cost_per_km=10)] - - RouteStatsService.add_statistics(result, vehicles) - - self.assertIn('vehicle_costs', result) - self.assertEqual(result['total_cost'], 100 + (12 * 10)) diff --git a/route_optimizer/tests/services/test_traffic_service.py b/route_optimizer/tests/services/test_traffic_service.py deleted file mode 100644 index f6a932c..0000000 --- a/route_optimizer/tests/services/test_traffic_service.py +++ /dev/null @@ -1,13 +0,0 @@ -from django.test import TestCase -import numpy as np -from route_optimizer.services.traffic_service import TrafficService - -class TrafficServiceTest(TestCase): - def test_apply_traffic_factors(self): - matrix = np.array([[0, 10], [10, 0]]) - traffic_data = {(0, 1): 1.5, (1, 0): 2.0} - - adjusted_matrix = TrafficService.apply_traffic_factors(matrix.copy(), traffic_data) - - self.assertEqual(adjusted_matrix[0,1], 15) - self.assertEqual(adjusted_matrix[1,0], 20) diff --git a/route_optimizer/utils/helpers.py b/route_optimizer/utils/helpers.py deleted file mode 100644 index 9c74415..0000000 --- a/route_optimizer/utils/helpers.py +++ /dev/null @@ -1,277 +0,0 @@ -""" -Helper functions for the route optimizer module. - -This module provides various utility functions used across the route optimizer. -""" -import logging -from typing import Dict, List, Tuple, Optional, Any, Set, Union -import numpy as np -import datetime -import json -from math import radians, cos, sin, asin, sqrt - -# Set up logging -logger = logging.getLogger(__name__) - - -def convert_minutes_to_time_str(minutes_from_midnight: int) -> str: - """ - Convert minutes from midnight to a time string (HH:MM). - - Args: - minutes_from_midnight: Minutes from midnight. - - Returns: - Time string in HH:MM format. - """ - hours, minutes = divmod(minutes_from_midnight, 60) - return f"{hours:02d}:{minutes:02d}" - - -def convert_time_str_to_minutes(time_str: str) -> int: - """ - Convert a time string (HH:MM) to minutes from midnight. - - Args: - time_str: Time string in HH:MM format. - - Returns: - Minutes from midnight. - """ - try: - hours, minutes = map(int, time_str.split(':')) - return hours * 60 + minutes - except (ValueError, AttributeError): - logger.error(f"Invalid time string format: {time_str}") - return 0 - - -def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float: - """ - Calculate the great circle distance between two points - on the earth (specified in decimal degrees). - - Args: - lat1, lon1: Coordinates of first point - lat2, lon2: Coordinates of second point - - Returns: - Distance in kilometers - """ - # Convert decimal degrees to radians - lat1, lon1, lat2, lon2 = map(radians, [lat1, lon1, lat2, lon2]) - - # Haversine formula - dlon = lon2 - lon1 - dlat = lat2 - lat1 - a = sin(dlat/2)**2 + cos(lat1) * cos(lat2) * sin(dlon/2)**2 - c = 2 * asin(sqrt(a)) - r = 6371 # Radius of earth in kilometers - return c * r - - -def format_route_for_display(route: List[str], location_names: Dict[str, str]) -> str: - """ - Format a route for display, converting location IDs to names. - - Args: - route: List of location IDs in the route. - location_names: Dictionary mapping location IDs to names. - - Returns: - Formatted route string. - """ - route_with_names = [f"{location_names.get(loc_id, loc_id)}" for loc_id in route] - return " → ".join(route_with_names) - - -def calculate_route_statistics( - routes: List[Dict[str, Any]], - vehicles: Dict[str, Any] -) -> Dict[str, Any]: - """ - Calculate statistics for the routes. - - Args: - routes: List of route dictionaries. - vehicles: Dictionary of vehicles with capacities. - - Returns: - Dictionary of route statistics. - """ - statistics = { - "total_distance": 0.0, - "total_cost": 0.0, - "total_time": 0.0, - "vehicle_utilization": 0.0, - "average_capacity_utilization": 0.0, - "num_vehicles_used": 0, - } - - capacity_utils = [] - - for route in routes: - statistics["total_distance"] += route.get("total_distance", 0.0) - statistics["total_cost"] += route.get("total_cost", 0.0) - statistics["total_time"] += route.get("total_time", 0.0) - - if route.get("capacity_utilization") is not None: - capacity_utils.append(route["capacity_utilization"]) - - statistics["num_vehicles_used"] = len(routes) - - if statistics["num_vehicles_used"] > 0: - statistics["vehicle_utilization"] = statistics["num_vehicles_used"] / len(vehicles) - - if capacity_utils: - statistics["average_capacity_utilization"] = sum(capacity_utils) / len(capacity_utils) - - return statistics - - -def create_distance_time_matrices( - locations: List[Any], - speed_km_per_hour: float = 50.0, - use_haversine: bool = True -) -> Tuple[np.ndarray, np.ndarray, List[str]]: - """ - Create distance and time matrices from a list of locations. - - Args: - locations: List of Location objects. - speed_km_per_hour: Average speed in km/h. - use_haversine: If True, use haversine formula for calculating distances. - - Returns: - Tuple containing: - - 2D numpy array representing distances between locations in km - - 2D numpy array representing times between locations in minutes - - List of location IDs corresponding to the matrix indices - """ - num_locations = len(locations) - distance_matrix = np.zeros((num_locations, num_locations)) - time_matrix = np.zeros((num_locations, num_locations)) - location_ids = [loc.id for loc in locations] - - for i in range(num_locations): - for j in range(num_locations): - if i != j: - if use_haversine: - distance = haversine_distance( - locations[i].latitude, locations[i].longitude, - locations[j].latitude, locations[j].longitude - ) - else: - # Euclidean distance as a fallback - distance = sqrt( - (locations[i].latitude - locations[j].latitude)**2 + - (locations[i].longitude - locations[j].longitude)**2 - ) - - distance_matrix[i, j] = distance - - # Calculate time in minutes - time_matrix[i, j] = (distance / speed_km_per_hour) * 60 - - return distance_matrix, time_matrix, location_ids - - -def apply_external_factors( - distance_matrix: np.ndarray, - time_matrix: np.ndarray, - external_factors: Dict[Tuple[int, int], float] -) -> Tuple[np.ndarray, np.ndarray]: - """ - Apply external factors like traffic or weather to distance and time matrices. - - Args: - distance_matrix: Original distance matrix. - time_matrix: Original time matrix. - external_factors: Dictionary mapping (i,j) tuples to factors. - A factor of 1.0 means no change, >1.0 means slower. - - Returns: - Tuple containing updated distance and time matrices. - """ - # Create copies to avoid modifying the originals - updated_distance_matrix = distance_matrix.copy() - updated_time_matrix = time_matrix.copy() - - for (i, j), factor in external_factors.items(): - # Distance doesn't change with traffic/weather, only time does - updated_time_matrix[i, j] *= factor - - return updated_distance_matrix, updated_time_matrix - - -def detect_isolated_nodes(graph: Dict[str, Dict[str, float]]) -> List[str]: - """ - Detect nodes in the graph that are isolated (have no connections). - - Args: - graph: Dictionary representing the graph with format: - {node1: {node2: distance, ...}, ...} - - Returns: - List of isolated node IDs. - """ - isolated_nodes = [] - - for node, connections in graph.items(): - if not connections: # No outgoing connections - # Check if there are any incoming connections - has_incoming = any(node in neighbors for neighbors in graph.values()) - if not has_incoming: - isolated_nodes.append(node) - - return isolated_nodes - - -def safe_json_dumps(obj: Any) -> str: - """ - Safely convert an object to a JSON string, handling non-serializable types. - - Args: - obj: Object to convert to JSON. - - Returns: - JSON string representation of the object. - """ - def handle_non_serializable(o): - if isinstance(o, (datetime.datetime, datetime.date)): - return o.isoformat() - if isinstance(o, np.ndarray): - return o.tolist() - if isinstance(o, np.integer): - return int(o) - if isinstance(o, np.floating): - return float(o) - if hasattr(o, '__dict__'): - return o.__dict__ - return str(o) - - return json.dumps(obj, default=handle_non_serializable) - - -def format_duration(seconds: float) -> str: - """ - Format a duration in seconds to a human-readable string. - - Args: - seconds: Duration in seconds. - - Returns: - Human-readable duration string. - """ - hours, remainder = divmod(seconds, 3600) - minutes, seconds = divmod(remainder, 60) - - parts = [] - if hours > 0: - parts.append(f"{int(hours)}h") - if minutes > 0 or not parts: # Always show minutes if there are no hours - parts.append(f"{int(minutes)}m") - if not parts or seconds > 0: # Show seconds if no larger units or non-zero - parts.append(f"{int(seconds)}s") - - return " ".join(parts) \ No newline at end of file From 49428dfeab86e7c7dfe17b0d7069386c676dcade Mon Sep 17 00:00:00 2001 From: Ke-vin-S Date: Thu, 8 May 2025 08:00:59 +0530 Subject: [PATCH 04/36] Input model for VRP solver class --- route_optimizer/models.py | 3 - route_optimizer/models/__init__.py | 0 route_optimizer/models/vrp_input.py | 133 ++++++++++++++++++++++++ route_optimizer/tests/test_vrp_input.py | 62 +++++++++++ 4 files changed, 195 insertions(+), 3 deletions(-) delete mode 100644 route_optimizer/models.py create mode 100644 route_optimizer/models/__init__.py create mode 100644 route_optimizer/models/vrp_input.py create mode 100644 route_optimizer/tests/test_vrp_input.py diff --git a/route_optimizer/models.py b/route_optimizer/models.py deleted file mode 100644 index 71a8362..0000000 --- a/route_optimizer/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/route_optimizer/models/__init__.py b/route_optimizer/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/route_optimizer/models/vrp_input.py b/route_optimizer/models/vrp_input.py new file mode 100644 index 0000000..fd330bd --- /dev/null +++ b/route_optimizer/models/vrp_input.py @@ -0,0 +1,133 @@ +from dataclasses import dataclass, field +from typing import List, Tuple, Dict + + +# === DTOs === + +@dataclass(frozen=True) +class Location: + lat: float + lon: float + + +@dataclass +class Vehicle: + id: str + depot: Location + capacity: int + + +@dataclass +class DeliveryTask: + id: str + pickup: Location + delivery: Location + demand: int + + +# === VRP Input Model === + +@dataclass +class VRPInput: + location_ids: List[str] + distance_matrix: List[List[int]] + starts: List[int] + ends: List[int] + vehicle_capacities: List[int] + num_vehicles: int + task_index_map: Dict[int, DeliveryTask] + demands: List[int] + time_limit: int = 30 + pickups_deliveries: List[Tuple[int, int]] = field(default_factory=list) + vehicles: List[Vehicle] = field(default_factory=list) + + location_id_to_index: Dict[str, int] = field(init=False) + + def __post_init__(self): + self.location_id_to_index = {loc_id: i for i, loc_id in enumerate(self.location_ids)} + + def validate(self): + n = len(self.location_ids) + assert len(self.demands) == n, "Mismatch: demands vs location_ids" + assert len(self.distance_matrix) == n, "Mismatch: matrix rows vs location_ids" + assert all(len(row) == n for row in self.distance_matrix), "Matrix must be square" + for v in self.vehicles: + depot_index = self.location_id_to_index.get(f"{v.id}_depot") + assert depot_index is not None, f"Depot location for vehicle {v.id} not found" + + +# === Builder === + +class VRPInputBuilder: + def __init__(self): + self.locations: List[Location] = [] + self.distance_matrix: List[List[int]] = [] + self.vehicles: List[Vehicle] = [] + self.tasks: List[DeliveryTask] = [] + self.location_labels: List[str] = [] + + def _add_location(self, loc: Location, label: str) -> int: + index = len(self.locations) + self.locations.append(loc) + self.location_labels.append(label) + for row in self.distance_matrix: + row.append(0) + self.distance_matrix.append([0] * (index + 1)) + return index + + def set_distance(self, from_index: int, to_index: int, distance: int): + self.distance_matrix[from_index][to_index] = distance + + def add_vehicle(self, vehicle: Vehicle): + self._add_location(vehicle.depot, f"{vehicle.id}_depot") + self.vehicles.append(vehicle) + + def add_delivery_task(self, task: DeliveryTask): + self._add_location(task.pickup, f"{task.id}_pickup") + self._add_location(task.delivery, f"{task.id}_delivery") + self.tasks.append(task) + + +# === Compiler === + +class VRPCompiler: + @staticmethod + def compile(builder: VRPInputBuilder) -> VRPInput: + location_index = {loc: i for i, loc in enumerate(builder.locations)} + location_ids = builder.location_labels.copy() + + demands = [0] * len(builder.locations) + deliveries = [] + task_index_map = {} + + for task in builder.tasks: + pickup_label = f"{task.id}_pickup" + delivery_label = f"{task.id}_delivery" + pickup_idx = location_ids.index(pickup_label) + delivery_idx = location_ids.index(delivery_label) + + demands[pickup_idx] += task.demand + demands[delivery_idx] -= task.demand + deliveries.append((pickup_idx, delivery_idx)) + task_index_map[pickup_idx] = task + task_index_map[delivery_idx] = task + + starts, ends = [], [] + for v in builder.vehicles: + depot_label = f"{v.id}_depot" + depot_idx = location_ids.index(depot_label) + starts.append(depot_idx) + ends.append(depot_idx) + + return VRPInput( + location_ids=location_ids, + distance_matrix=builder.distance_matrix, + demands=demands, + vehicle_capacities=[v.capacity for v in builder.vehicles], + num_vehicles=len(builder.vehicles), + starts=starts, + ends=ends, + pickups_deliveries=deliveries, + task_index_map=task_index_map, + vehicles=builder.vehicles + ) diff --git a/route_optimizer/tests/test_vrp_input.py b/route_optimizer/tests/test_vrp_input.py new file mode 100644 index 0000000..83910f7 --- /dev/null +++ b/route_optimizer/tests/test_vrp_input.py @@ -0,0 +1,62 @@ +import unittest + +from route_optimizer.models.vrp_input import VRPInputBuilder, Vehicle, Location, DeliveryTask, VRPCompiler + + +class TestVRPModel(unittest.TestCase): + + def setUp(self): + self.builder = VRPInputBuilder() + self.vehicle = Vehicle(id="V1", depot=Location(6.9, 79.8), capacity=15) + self.task1 = DeliveryTask(id="D1", pickup=Location(7.0, 80.0), delivery=Location(7.1, 80.1), demand=5) + self.task2 = DeliveryTask(id="D2", pickup=Location(7.2, 80.2), delivery=Location(7.3, 80.3), demand=10) + + def test_add_vehicle(self): + self.builder.add_vehicle(self.vehicle) + self.assertEqual(len(self.builder.vehicles), 1) + self.assertIn("V1_depot", self.builder.location_labels) + + def test_add_delivery_task(self): + self.builder.add_delivery_task(self.task1) + self.assertEqual(len(self.builder.tasks), 1) + self.assertIn("D1_pickup", self.builder.location_labels) + self.assertIn("D1_delivery", self.builder.location_labels) + + def test_distance_matrix_growth(self): + self.builder.add_vehicle(self.vehicle) + self.builder.add_delivery_task(self.task1) + self.assertEqual(len(self.builder.distance_matrix), 3) # depot + pickup + delivery + for row in self.builder.distance_matrix: + self.assertEqual(len(row), 3) + + def test_set_distance(self): + self.builder.add_vehicle(self.vehicle) + self.builder.add_delivery_task(self.task1) + self.builder.set_distance(0, 1, 42) + self.assertEqual(self.builder.distance_matrix[0][1], 42) + + def test_compile_vrp_input(self): + self.builder.add_vehicle(self.vehicle) + self.builder.add_delivery_task(self.task1) + self.builder.add_delivery_task(self.task2) + vrp_input = VRPCompiler.compile(self.builder) + vrp_input.validate() + + self.assertEqual(vrp_input.num_vehicles, 1) + self.assertEqual(len(vrp_input.pickups_deliveries), 2) + self.assertEqual(len(vrp_input.demands), len(vrp_input.location_ids)) + self.assertIn(5, vrp_input.demands) + self.assertIn(-5, vrp_input.demands) + self.assertIn(10, vrp_input.demands) + self.assertIn(-10, vrp_input.demands) + + def test_task_index_map_consistency(self): + self.builder.add_vehicle(self.vehicle) + self.builder.add_delivery_task(self.task1) + vrp_input = VRPCompiler.compile(self.builder) + task = vrp_input.task_index_map[vrp_input.pickups_deliveries[0][0]] + self.assertEqual(task.id, "D1") + + +if __name__ == "__main__": + unittest.main() From 92f98ae83360e3d87f9effd0e9648e5ecb558573 Mon Sep 17 00:00:00 2001 From: Ke-vin-S Date: Thu, 8 May 2025 08:08:10 +0530 Subject: [PATCH 05/36] fix --- route_optimizer/models/vrp_input.py | 18 ++++++++++-------- route_optimizer/tests/test_vrp_input.py | 4 ++-- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/route_optimizer/models/vrp_input.py b/route_optimizer/models/vrp_input.py index fd330bd..1a4f370 100644 --- a/route_optimizer/models/vrp_input.py +++ b/route_optimizer/models/vrp_input.py @@ -35,7 +35,7 @@ class VRPInput: ends: List[int] vehicle_capacities: List[int] num_vehicles: int - task_index_map: Dict[int, DeliveryTask] + task_index_map: Dict[int, Tuple[str, str]] # (task_id, "pickup"/"delivery") demands: List[int] time_limit: int = 30 pickups_deliveries: List[Tuple[int, int]] = field(default_factory=list) @@ -67,6 +67,8 @@ def __init__(self): self.location_labels: List[str] = [] def _add_location(self, loc: Location, label: str) -> int: + if label in self.location_labels: + raise ValueError(f"Duplicate label detected: {label}") index = len(self.locations) self.locations.append(loc) self.location_labels.append(label) @@ -93,29 +95,29 @@ def add_delivery_task(self, task: DeliveryTask): class VRPCompiler: @staticmethod def compile(builder: VRPInputBuilder) -> VRPInput: - location_index = {loc: i for i, loc in enumerate(builder.locations)} location_ids = builder.location_labels.copy() + label_to_index = {label: i for i, label in enumerate(location_ids)} demands = [0] * len(builder.locations) deliveries = [] - task_index_map = {} + task_index_map: Dict[int, Tuple[str, str]] = {} for task in builder.tasks: pickup_label = f"{task.id}_pickup" delivery_label = f"{task.id}_delivery" - pickup_idx = location_ids.index(pickup_label) - delivery_idx = location_ids.index(delivery_label) + pickup_idx = label_to_index[pickup_label] + delivery_idx = label_to_index[delivery_label] demands[pickup_idx] += task.demand demands[delivery_idx] -= task.demand deliveries.append((pickup_idx, delivery_idx)) - task_index_map[pickup_idx] = task - task_index_map[delivery_idx] = task + task_index_map[pickup_idx] = (task.id, "pickup") + task_index_map[delivery_idx] = (task.id, "delivery") starts, ends = [], [] for v in builder.vehicles: depot_label = f"{v.id}_depot" - depot_idx = location_ids.index(depot_label) + depot_idx = label_to_index[depot_label] starts.append(depot_idx) ends.append(depot_idx) diff --git a/route_optimizer/tests/test_vrp_input.py b/route_optimizer/tests/test_vrp_input.py index 83910f7..d24159c 100644 --- a/route_optimizer/tests/test_vrp_input.py +++ b/route_optimizer/tests/test_vrp_input.py @@ -54,8 +54,8 @@ def test_task_index_map_consistency(self): self.builder.add_vehicle(self.vehicle) self.builder.add_delivery_task(self.task1) vrp_input = VRPCompiler.compile(self.builder) - task = vrp_input.task_index_map[vrp_input.pickups_deliveries[0][0]] - self.assertEqual(task.id, "D1") + task_id = vrp_input.task_index_map[vrp_input.pickups_deliveries[0][0]][0] + self.assertEqual(task_id, "D1") if __name__ == "__main__": From ba3871239150febb23feb0b963d54943d4bfcd8e Mon Sep 17 00:00:00 2001 From: Ke-vin-S Date: Thu, 8 May 2025 08:20:13 +0530 Subject: [PATCH 06/36] VRP solver with - minimizing distance - capacity constraints - pickup and delivery management - dropping deliveries if solution is infeasible --- route_optimizer/services/__init__.py | 0 route_optimizer/services/vrp_solver.py | 133 +++++++++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 route_optimizer/services/__init__.py create mode 100644 route_optimizer/services/vrp_solver.py diff --git a/route_optimizer/services/__init__.py b/route_optimizer/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/route_optimizer/services/vrp_solver.py b/route_optimizer/services/vrp_solver.py new file mode 100644 index 0000000..e6fd18b --- /dev/null +++ b/route_optimizer/services/vrp_solver.py @@ -0,0 +1,133 @@ +from typing import Dict, Any +from ortools.constraint_solver import pywrapcp, routing_enums_pb2 + +from route_optimizer.models.vrp_input import VRPInput + + +def solve_cvrp(vrp_input: VRPInput) -> Dict[str, Any]: + """ + Solves a Capacitated Vehicle Routing Problem (CVRP) using OR-Tools. + + Args: + vrp_input (VRPInput): Prepared VRP input object. + + Returns: + dict: { + 'status': 'success' or 'failed', + 'routes': list of routes (each a list of node indices), + 'total_distance': total distance across all routes + } + """ + vrp_input.validate() + + manager = pywrapcp.RoutingIndexManager( + len(vrp_input.distance_matrix), + vrp_input.num_vehicles, + vrp_input.starts, + vrp_input.ends + ) + + routing = pywrapcp.RoutingModel(manager) + + # Define distance callback + def distance_callback(from_index, to_index): + from_node = manager.IndexToNode(from_index) + to_node = manager.IndexToNode(to_index) + return vrp_input.distance_matrix[from_node][to_node] + + transit_callback_index = routing.RegisterTransitCallback(distance_callback) + routing.SetArcCostEvaluatorOfAllVehicles(transit_callback_index) + + # Add Distance constraint. + dimension_name = "Distance" + routing.AddDimension( + transit_callback_index, + 0, # no slack + 3000, # vehicle maximum travel distance + True, # start cumul to zero + dimension_name, + ) + + distance_dimension = routing.GetDimensionOrDie(dimension_name) + distance_dimension.SetGlobalSpanCostCoefficient(100) + + for request in vrp_input.pickups_deliveries: + pickup_index = manager.NodeToIndex(request[0]) + delivery_index = manager.NodeToIndex(request[1]) + routing.AddPickupAndDelivery(pickup_index, delivery_index) + # The same vehicle should do pickup and delivery + routing.solver().Add( + routing.VehicleVar(pickup_index) == routing.VehicleVar(delivery_index) + ) + # Should pick up before deliver + routing.solver().Add( + distance_dimension.CumulVar(pickup_index) + <= distance_dimension.CumulVar(delivery_index) + ) + + # Define demand callback + def demand_callback(from_index): + from_node = manager.IndexToNode(from_index) + return vrp_input.demands[from_node] + + demand_callback_index = routing.RegisterUnaryTransitCallback(demand_callback) + + routing.AddDimensionWithVehicleCapacity( + demand_callback_index, + 0, + vrp_input.vehicle_capacities, + True, + "Capacity" + ) + + # Allow dropping deliveries with penalty + penalty = 1000 + for node in range(1, len(vrp_input.distance_matrix)): + routing.AddDisjunction([manager.NodeToIndex(node)], penalty) + + # Search parameters + search_parameters = pywrapcp.DefaultRoutingSearchParameters() + search_parameters.first_solution_strategy = routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC + search_parameters.local_search_metaheuristic = routing_enums_pb2.LocalSearchMetaheuristic.GUIDED_LOCAL_SEARCH + search_parameters.time_limit.FromSeconds(vrp_input.time_limit) + + solution = routing.SolveWithParameters(search_parameters) + + if not solution: + return {"status": "failed", "error": "No solution found."} + + # Extract routes + routes = [] + total_distance = 0 + for vehicle_id in range(vrp_input.num_vehicles): + index = routing.Start(vehicle_id) + route = [] + while not routing.IsEnd(index): + node_index = manager.IndexToNode(index) + route.append(node_index) + next_index = solution.Value(routing.NextVar(index)) + total_distance += routing.GetArcCostForVehicle(index, next_index, vehicle_id) + index = next_index + route.append(manager.IndexToNode(index)) + if len(route) > 2: + routes.append(route) + + # After the solution is found + dropped_locations = [] + for node in range(len(vrp_input.distance_matrix)): + index = manager.NodeToIndex(node) + if routing.IsStart(index) or routing.IsEnd(index): + continue + if solution.Value(routing.NextVar(index)) == index: + dropped_locations.append({ + "index": node, + "label": vrp_input.location_ids[node], + "task": vrp_input.task_index_map.get(node) + }) + + return { + "status": "success", + "routes": routes, + "total_distance": total_distance, + "dropped_locations": dropped_locations + } From 2c7c913df235f5bb6355ad69f2428c8b2d7a2f9e Mon Sep 17 00:00:00 2001 From: Ke-vin-S Date: Thu, 8 May 2025 09:09:10 +0530 Subject: [PATCH 07/36] Removed dropping deliveries --- route_optimizer/models/vrp_input.py | 3 +- route_optimizer/services/vrp_solver.py | 23 ++------------- route_optimizer/tests/test_vrp_solver.py | 37 ++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 22 deletions(-) create mode 100644 route_optimizer/tests/test_vrp_solver.py diff --git a/route_optimizer/models/vrp_input.py b/route_optimizer/models/vrp_input.py index 1a4f370..2cffe4e 100644 --- a/route_optimizer/models/vrp_input.py +++ b/route_optimizer/models/vrp_input.py @@ -37,7 +37,7 @@ class VRPInput: num_vehicles: int task_index_map: Dict[int, Tuple[str, str]] # (task_id, "pickup"/"delivery") demands: List[int] - time_limit: int = 30 + time_limit: int = 3 pickups_deliveries: List[Tuple[int, int]] = field(default_factory=list) vehicles: List[Vehicle] = field(default_factory=list) @@ -79,6 +79,7 @@ def _add_location(self, loc: Location, label: str) -> int: def set_distance(self, from_index: int, to_index: int, distance: int): self.distance_matrix[from_index][to_index] = distance + self.distance_matrix[to_index][from_index] = distance def add_vehicle(self, vehicle: Vehicle): self._add_location(vehicle.depot, f"{vehicle.id}_depot") diff --git a/route_optimizer/services/vrp_solver.py b/route_optimizer/services/vrp_solver.py index e6fd18b..10127f9 100644 --- a/route_optimizer/services/vrp_solver.py +++ b/route_optimizer/services/vrp_solver.py @@ -80,16 +80,11 @@ def demand_callback(from_index): "Capacity" ) - # Allow dropping deliveries with penalty - penalty = 1000 - for node in range(1, len(vrp_input.distance_matrix)): - routing.AddDisjunction([manager.NodeToIndex(node)], penalty) - # Search parameters search_parameters = pywrapcp.DefaultRoutingSearchParameters() search_parameters.first_solution_strategy = routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC - search_parameters.local_search_metaheuristic = routing_enums_pb2.LocalSearchMetaheuristic.GUIDED_LOCAL_SEARCH - search_parameters.time_limit.FromSeconds(vrp_input.time_limit) + # search_parameters.time_limit.FromSeconds(vrp_input.time_limit) + search_parameters.solution_limit = 1 solution = routing.SolveWithParameters(search_parameters) @@ -112,22 +107,8 @@ def demand_callback(from_index): if len(route) > 2: routes.append(route) - # After the solution is found - dropped_locations = [] - for node in range(len(vrp_input.distance_matrix)): - index = manager.NodeToIndex(node) - if routing.IsStart(index) or routing.IsEnd(index): - continue - if solution.Value(routing.NextVar(index)) == index: - dropped_locations.append({ - "index": node, - "label": vrp_input.location_ids[node], - "task": vrp_input.task_index_map.get(node) - }) - return { "status": "success", "routes": routes, "total_distance": total_distance, - "dropped_locations": dropped_locations } diff --git a/route_optimizer/tests/test_vrp_solver.py b/route_optimizer/tests/test_vrp_solver.py new file mode 100644 index 0000000..e6c3c5a --- /dev/null +++ b/route_optimizer/tests/test_vrp_solver.py @@ -0,0 +1,37 @@ +import unittest +from route_optimizer.models.vrp_input import VRPInputBuilder, VRPCompiler, Location, Vehicle, DeliveryTask +from route_optimizer.services.vrp_solver import solve_cvrp + + +class TestSolveCVRP(unittest.TestCase): + def test_basic_functionality(self): + # Define locations + depot = Location(0, 0) + pickup = Location(1, 0) + delivery = Location(2, 0) + + # Create vehicle and task + vehicle = Vehicle(id="V1", depot=depot, capacity=10) + task = DeliveryTask(id="T1", pickup=pickup, delivery=delivery, demand=5) + + # Build VRP input + builder = VRPInputBuilder() + builder.add_vehicle(vehicle) + builder.add_delivery_task(task) + + # Set distances + builder.set_distance(0, 1, 10) + builder.set_distance(1, 2, 10) + builder.set_distance(2, 0, 10) + + vrp_input = VRPCompiler.compile(builder) + + # Solve CVRP + result = solve_cvrp(vrp_input) + + # Assertions + self.assertEqual(result["status"], "success") + self.assertIn("routes", result) + self.assertIn("total_distance", result) + self.assertEqual(len(result["routes"]), 1) + self.assertEqual(result["total_distance"], 30) From 3089f2f8e6f6bf4a6a46dffd7ed206ff55ac74e0 Mon Sep 17 00:00:00 2001 From: Ke-vin-S Date: Thu, 8 May 2025 09:28:23 +0530 Subject: [PATCH 08/36] more tests for vrp solver --- route_optimizer/tests/test_vrp_solver.py | 103 +++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/route_optimizer/tests/test_vrp_solver.py b/route_optimizer/tests/test_vrp_solver.py index e6c3c5a..b9832cb 100644 --- a/route_optimizer/tests/test_vrp_solver.py +++ b/route_optimizer/tests/test_vrp_solver.py @@ -35,3 +35,106 @@ def test_basic_functionality(self): self.assertIn("total_distance", result) self.assertEqual(len(result["routes"]), 1) self.assertEqual(result["total_distance"], 30) + + def test_multiple_vehicles_and_tasks(self): + # Define locations + depot1 = Location(0, 0) + depot2 = Location(0, 1) + pickup1 = Location(1, 0) + delivery1 = Location(2, 0) + pickup2 = Location(1, 1) + delivery2 = Location(2, 1) + + # Create vehicles and tasks + vehicle1 = Vehicle(id="V1", depot=depot1, capacity=10) + vehicle2 = Vehicle(id="V2", depot=depot2, capacity=10) + task1 = DeliveryTask(id="T1", pickup=pickup1, delivery=delivery1, demand=5) + task2 = DeliveryTask(id="T2", pickup=pickup2, delivery=delivery2, demand=5) + + # Build VRP input + builder = VRPInputBuilder() + builder.add_vehicle(vehicle1) + builder.add_vehicle(vehicle2) + builder.add_delivery_task(task1) + builder.add_delivery_task(task2) + + # Set distances (symmetric for simplicity) + num_locations = len(builder.locations) + for i in range(num_locations): + for j in range(num_locations): + if i != j: + builder.set_distance(i, j, 10) + + vrp_input = VRPCompiler.compile(builder) + + # Solve CVRP + result = solve_cvrp(vrp_input) + print(result) + # Assertions + self.assertEqual(result["status"], "success") + self.assertLessEqual(len(result["routes"]), 2) + self.assertLessEqual(result["total_distance"], 60) + + def test_insufficient_vehicle_capacity(self): + # Define locations + depot = Location(0, 0) + pickup = Location(1, 0) + delivery = Location(2, 0) + + # Create vehicle and task + vehicle = Vehicle(id="V1", depot=depot, capacity=5) + task = DeliveryTask(id="T1", pickup=pickup, delivery=delivery, demand=10) + + # Build VRP input + builder = VRPInputBuilder() + builder.add_vehicle(vehicle) + builder.add_delivery_task(task) + + # Set distances + builder.set_distance(0, 1, 10) + builder.set_distance(1, 2, 10) + builder.set_distance(2, 0, 10) + + vrp_input = VRPCompiler.compile(builder) + + # Solve CVRP + result = solve_cvrp(vrp_input) + + # Assertions + self.assertEqual(result["status"], "failed") + self.assertIn("error", result) + + def test_zero_demand_task(self): + depot = Location(0, 0) + pickup = Location(1, 1) + delivery = Location(2, 2) + + vehicle = Vehicle(id="V1", depot=depot, capacity=5) + task = DeliveryTask(id="T1", pickup=pickup, delivery=delivery, demand=0) + + builder = VRPInputBuilder() + builder.add_vehicle(vehicle) + builder.add_delivery_task(task) + + builder.set_distance(0, 1, 5) + builder.set_distance(1, 2, 5) + builder.set_distance(2, 0, 5) + + vrp_input = VRPCompiler.compile(builder) + result = solve_cvrp(vrp_input) + + self.assertEqual(result["status"], "success") + self.assertEqual(len(result["routes"]), 1) + + def test_no_tasks(self): + depot = Location(0, 0) + vehicle = Vehicle(id="V1", depot=depot, capacity=10) + + builder = VRPInputBuilder() + builder.add_vehicle(vehicle) + + vrp_input = VRPCompiler.compile(builder) + result = solve_cvrp(vrp_input) + + self.assertEqual(result["status"], "success") + self.assertEqual(len(result["routes"]), 0) # No tasks, so no useful routes From 22ca5a0c0c5af5d0313da802dc4f725d340f56ce Mon Sep 17 00:00:00 2001 From: Ke-vin-S Date: Thu, 8 May 2025 10:01:03 +0530 Subject: [PATCH 09/36] Restore dijkstra's algorithm --- route_optimizer/tests/test_dijkstra.py | 166 +++++++++++++++++++++++++ route_optimizer/utils/dijkstra.py | 131 +++++++++++++++++++ 2 files changed, 297 insertions(+) create mode 100644 route_optimizer/tests/test_dijkstra.py create mode 100644 route_optimizer/utils/dijkstra.py diff --git a/route_optimizer/tests/test_dijkstra.py b/route_optimizer/tests/test_dijkstra.py new file mode 100644 index 0000000..1a0f480 --- /dev/null +++ b/route_optimizer/tests/test_dijkstra.py @@ -0,0 +1,166 @@ +""" +Tests for Dijkstra's algorithm implementation. + +This module contains tests for the DijkstraPathFinder class. +""" +import unittest +from route_optimizer.utils.dijkstra import DijkstraPathFinder + + +class TestDijkstraPathFinder(unittest.TestCase): + """Test cases for DijkstraPathFinder.""" + + def setUp(self): + """Set up test fixtures.""" + self.path_finder = DijkstraPathFinder() + + # Simple test graph + # A -- 1 --> B -- 2 --> C + # | | + # 4 3 + # | | + # v v + # D -- 5 --> E + self.simple_graph = { + 'A': {'B': 1.0, 'D': 4.0}, + 'B': {'C': 2.0, 'E': 3.0}, + 'C': {}, + 'D': {'E': 5.0}, + 'E': {} + } + + # More complex graph with cycles and different paths + self.complex_graph = { + 'A': {'B': 1.0, 'C': 4.0}, + 'B': {'C': 2.0, 'D': 5.0}, + 'C': {'D': 1.0, 'E': 3.0}, + 'D': {'B': 1.0, 'E': 2.0, 'F': 5.0}, + 'E': {'F': 1.0}, + 'F': {'A': 10.0} + } + + def test_shortest_path_simple(self): + """Test finding shortest path in a simple graph.""" + # Test A to C: A -> B -> C + path, distance = self.path_finder.calculate_shortest_path( + self.simple_graph, 'A', 'C' + ) + self.assertEqual(path, ['A', 'B', 'C']) + self.assertEqual(distance, 3.0) + + # Test A to E: A -> B -> E + path, distance = self.path_finder.calculate_shortest_path( + self.simple_graph, 'A', 'E' + ) + self.assertEqual(path, ['A', 'B', 'E']) + self.assertEqual(distance, 4.0) + + # Test D to C: no path exists + path, distance = self.path_finder.calculate_shortest_path( + self.simple_graph, 'D', 'C' + ) + self.assertIsNone(path) + self.assertIsNone(distance) + + def test_shortest_path_complex(self): + """Test finding shortest path in a more complex graph.""" + # Test A to F: A -> C -> E -> F + path, distance = self.path_finder.calculate_shortest_path( + self.complex_graph, 'A', 'F' + ) + # self.assertEqual(path, ['A', 'B', 'C', 'E', 'F']) + self.assertEqual(distance, 7.0) # 1 + 2 + 3 + 1 + + # Test D to A: D -> B -> C -> E -> F -> A + path, distance = self.path_finder.calculate_shortest_path( + self.complex_graph, 'D', 'A' + ) + # self.assertEqual(path, ['D', 'B', 'C', 'E', 'F', 'A']) + self.assertEqual(distance, 13.0) # 2 + 1 + 10 + + + def test_edge_cases(self): + """Test edge cases for the shortest path algorithm.""" + # Test path from a node to itself + path, distance = self.path_finder.calculate_shortest_path( + self.simple_graph, 'A', 'A' + ) + self.assertEqual(path, ['A']) + self.assertEqual(distance, 0.0) + + # Test with non-existent nodes + path, distance = self.path_finder.calculate_shortest_path( + self.simple_graph, 'X', 'Y' + ) + self.assertIsNone(path) + self.assertIsNone(distance) + + # Test with start node exists but end doesn't + path, distance = self.path_finder.calculate_shortest_path( + self.simple_graph, 'A', 'Z' + ) + self.assertIsNone(path) + self.assertIsNone(distance) + + def test_all_shortest_paths(self): + """Test calculating all shortest paths between nodes.""" + nodes = ['A', 'B', 'C', 'D', 'E'] + all_paths = self.path_finder.calculate_all_shortest_paths(self.simple_graph, nodes) + + # Structure checks + for start in nodes: + for end in nodes: + self.assertIn(end, all_paths[start]) + + # Shortest path from A to C: A → B → C + self.assertEqual(all_paths['A']['C']['path'], ['A', 'B', 'C']) + self.assertEqual(all_paths['A']['C']['distance'], 3.0) + + # Shortest path from A to E: A → B → E (1 + 3 = 4.0) + self.assertEqual(all_paths['A']['E']['path'], ['A', 'B', 'E']) + self.assertEqual(all_paths['A']['E']['distance'], 4.0) + + # Shortest path from D to E: D → E + self.assertEqual(all_paths['D']['E']['path'], ['D', 'E']) + self.assertEqual(all_paths['D']['E']['distance'], 5.0) + + # Path from E to any node is impossible (E has no outbound edges) + for target in ['A', 'B', 'C', 'D']: + self.assertIsNone(all_paths['E'][target]['path']) + self.assertEqual(all_paths['E'][target]['distance'], float('inf')) + + # Self-paths + for node in nodes: + self.assertEqual(all_paths[node][node]['path'], [node]) + self.assertEqual(all_paths[node][node]['distance'], 0) + + + def test_negative_weights_error(self): + """Test that Dijkstra raises an error when negative weights are present.""" + graph_with_negative = { + 'A': {'B': 1.0, 'C': 4.0}, + 'B': {'C': -2.0} # Negative weight + } + + with self.assertRaises(ValueError) as context: + self.path_finder.calculate_shortest_path(graph_with_negative, 'A', 'C') + + self.assertIn('Negative weight detected', str(context.exception)) + + def test_all_shortest_paths_negative_weight_error(self): + """Test that calculate_all_shortest_paths raises an error with negative weights.""" + graph_with_negative = { + 'A': {'B': 2.0}, + 'B': {'C': -3.0}, # Negative weight + 'C': {'A': 1.0} + } + nodes = ['A', 'B', 'C'] + + with self.assertRaises(ValueError) as context: + self.path_finder.calculate_all_shortest_paths(graph_with_negative, nodes) + + self.assertIn('Negative weight detected', str(context.exception)) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/route_optimizer/utils/dijkstra.py b/route_optimizer/utils/dijkstra.py new file mode 100644 index 0000000..837f0e3 --- /dev/null +++ b/route_optimizer/utils/dijkstra.py @@ -0,0 +1,131 @@ +import heapq +from typing import Dict, List, Tuple, Set, Optional, Union +import logging + +# Set up logging +logger = logging.getLogger(__name__) + + +class DijkstraPathFinder: + """ + Implementation of Dijkstra's algorithm for shortest path finding. + """ + + def __init__(self): + """Initialize the Dijkstra path finder.""" + pass + + @staticmethod + def _validate_non_negative_weights(graph: Dict[str, Dict[str, float]]) -> None: + """ + Ensure all weights in the graph are non-negative. + + Raises: + ValueError: If a negative edge weight is found. + """ + for src, neighbors in graph.items(): + for dest, weight in neighbors.items(): + if weight < 0: + raise ValueError(f"Negative weight detected from '{src}' to '{dest}' with weight {weight}") + + @staticmethod + def calculate_shortest_path( + graph: Dict[str, Dict[str, float]], + start: str, + end: str + ) -> Tuple[Optional[List[str]], Optional[float]]: + """ + Calculate the shortest path between two nodes using Dijkstra's algorithm. + + Args: + graph: A dictionary of dictionaries representing the graph. + start: Starting node. + end: Target node. + + Returns: + A tuple containing the shortest path and its distance. + """ + DijkstraPathFinder._validate_non_negative_weights(graph) + + if start not in graph or end not in graph: + logger.warning(f"Start node '{start}' or end node '{end}' not in graph") + return None, None + + queue = [(0, start, [start])] + visited: Set[str] = set() + + while queue: + (dist, current, path) = heapq.heappop(queue) + + if current in visited: + continue + + visited.add(current) + + if current == end: + return path, dist + + for neighbor, distance in graph[current].items(): + if neighbor not in visited: + new_dist = dist + distance + new_path = path + [neighbor] + heapq.heappush(queue, (new_dist, neighbor, new_path)) + + logger.warning(f"No path found from '{start}' to '{end}'") + return None, None + + @staticmethod + def calculate_all_shortest_paths( + graph: Dict[str, Dict[str, float]], + nodes: List[str] + ) -> Dict[str, Dict[str, Dict[str, Union[List[str], float]]]]: + """ + Calculate shortest paths between all pairs of nodes using Dijkstra. + + Args: + graph: The graph as adjacency list. + nodes: List of nodes to calculate paths between. + + Returns: + Dictionary mapping start→end to path and distance. + """ + DijkstraPathFinder._validate_non_negative_weights(graph) + result = {} + + for start_node in nodes: + distances = {node: float('inf') for node in nodes} + previous = {node: None for node in nodes} + distances[start_node] = 0 + queue = [(0, start_node)] + + while queue: + dist, current = heapq.heappop(queue) + + for neighbor, weight in graph.get(current, {}).items(): + if neighbor not in distances: + continue + alt = dist + weight + if alt < distances[neighbor]: + distances[neighbor] = alt + previous[neighbor] = current + heapq.heappush(queue, (alt, neighbor)) + + result[start_node] = {} + + for end_node in nodes: + if distances[end_node] == float('inf'): + result[start_node][end_node] = {'path': None, 'distance': float('inf')} + continue + + path = [] + current = end_node + while current is not None: + path.insert(0, current) + current = previous[current] + + result[start_node][end_node] = { + 'path': path, + 'distance': distances[end_node] + } + + return result From 574878789e100d00d44cdc95d6061b02dbc640b6 Mon Sep 17 00:00:00 2001 From: Kevin Sanjula Date: Thu, 8 May 2025 14:21:19 +0530 Subject: [PATCH 10/36] Update route_optimizer/services/vrp_solver.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- route_optimizer/services/vrp_solver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/route_optimizer/services/vrp_solver.py b/route_optimizer/services/vrp_solver.py index 10127f9..abde07e 100644 --- a/route_optimizer/services/vrp_solver.py +++ b/route_optimizer/services/vrp_solver.py @@ -83,7 +83,7 @@ def demand_callback(from_index): # Search parameters search_parameters = pywrapcp.DefaultRoutingSearchParameters() search_parameters.first_solution_strategy = routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC - # search_parameters.time_limit.FromSeconds(vrp_input.time_limit) + search_parameters.solution_limit = 1 solution = routing.SolveWithParameters(search_parameters) From 77215c7d6f131e6bafd89a6b42af09c34ee597e1 Mon Sep 17 00:00:00 2001 From: Ke-vin-S Date: Thu, 8 May 2025 16:01:36 +0530 Subject: [PATCH 11/36] change shipment model --- shipments/admin.py | 22 +++++++++- shipments/consumers/order_events.py | 38 ++++++++++------ ...pment_destination_warehouse_id_and_more.py | 34 +++++++++++++++ shipments/models.py | 6 ++- shipments/tests/test_api.py | 13 +++--- shipments/tests/test_consumer.py | 43 ++++++++----------- shipments/tests/test_integration_kafka.py | 8 ++-- 7 files changed, 112 insertions(+), 52 deletions(-) create mode 100644 shipments/migrations/0002_remove_shipment_destination_warehouse_id_and_more.py diff --git a/shipments/admin.py b/shipments/admin.py index 648e9b2..30b1d50 100644 --- a/shipments/admin.py +++ b/shipments/admin.py @@ -1,9 +1,27 @@ from django.contrib import admin from .models import Shipment + @admin.register(Shipment) class ShipmentAdmin(admin.ModelAdmin): - list_display = ('shipment_id', 'order_id', 'origin_warehouse_id', 'destination_warehouse_id', 'status', 'created_at') - list_filter = ('status', 'origin_warehouse_id', 'destination_warehouse_id') + list_display = ( + 'shipment_id', + 'order_id', + 'get_origin', + 'get_destination', + 'status', + 'created_at' + ) + list_filter = ('status',) search_fields = ('shipment_id', 'order_id') ordering = ('-created_at',) + + @admin.display(description="Origin") + def get_origin(self, obj): + loc = obj.origin_location + return f"{loc.get('lat')}, {loc.get('lng')}" if loc else "N/A" + + @admin.display(description="Destination") + def get_destination(self, obj): + loc = obj.destination_location + return f"{loc.get('lat')}, {loc.get('lng')}" if loc else "N/A" diff --git a/shipments/consumers/order_events.py b/shipments/consumers/order_events.py index f566009..d2b1a8b 100644 --- a/shipments/consumers/order_events.py +++ b/shipments/consumers/order_events.py @@ -1,9 +1,11 @@ import json import logging +import uuid + from confluent_kafka import Consumer, KafkaException from django.conf import settings from shipments.models import Shipment -import uuid + def create_kafka_consumer(): return Consumer({ @@ -12,22 +14,29 @@ def create_kafka_consumer(): 'auto.offset.reset': 'earliest', }) + def handle_order_created(event): order_id = event.get("order_id") - origin = event.get("origin_warehouse_id") - destination = event.get("destination_warehouse_id") - - if order_id and origin and destination: - Shipment.objects.create( - shipment_id=str(uuid.uuid4())[:12], - order_id=order_id, - origin_warehouse_id=origin, - destination_warehouse_id=destination, - status='pending' - ) - logging.info(f"Shipment created for order {order_id}") - else: + origin = event.get("origin") # Expecting {"lat": ..., "lng": ...} + destination = event.get("destination") + + if not (order_id and origin and destination): logging.error("Invalid order event payload") + return + + if not all(k in origin for k in ("lat", "lng")) or not all(k in destination for k in ("lat", "lng")): + logging.error("Origin/destination must include lat/lng") + return + + Shipment.objects.create( + shipment_id=str(uuid.uuid4())[:12], + order_id=order_id, + origin=origin, + destination=destination, + status='pending' + ) + logging.info(f"Shipment created for order {order_id}") + def start_order_consumer(): consumer = create_kafka_consumer() @@ -48,6 +57,7 @@ def start_order_consumer(): finally: consumer.close() + def run_consumer_once(): consumer = create_kafka_consumer() consumer.subscribe(['orders.created']) diff --git a/shipments/migrations/0002_remove_shipment_destination_warehouse_id_and_more.py b/shipments/migrations/0002_remove_shipment_destination_warehouse_id_and_more.py new file mode 100644 index 0000000..ee53c4d --- /dev/null +++ b/shipments/migrations/0002_remove_shipment_destination_warehouse_id_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 5.2 on 2025-05-08 10:07 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shipments', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='shipment', + name='destination_warehouse_id', + ), + migrations.RemoveField( + model_name='shipment', + name='origin_warehouse_id', + ), + migrations.AddField( + model_name='shipment', + name='destination', + field=models.JSONField(), + preserve_default=False, + ), + migrations.AddField( + model_name='shipment', + name='origin', + field=models.JSONField(), + preserve_default=False, + ), + ] diff --git a/shipments/models.py b/shipments/models.py index 70e00d8..a277e2d 100644 --- a/shipments/models.py +++ b/shipments/models.py @@ -14,8 +14,10 @@ class Shipment(models.Model): shipment_id = models.CharField(max_length=32, unique=True) order_id = models.CharField(max_length=32) # Reference to order service - origin_warehouse_id = models.CharField(max_length=36) - destination_warehouse_id = models.CharField(max_length=36) + + origin = models.JSONField() + destination = models.JSONField() + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending') scheduled_dispatch = models.DateTimeField(null=True, blank=True) actual_dispatch = models.DateTimeField(null=True, blank=True) diff --git a/shipments/tests/test_api.py b/shipments/tests/test_api.py index 5983230..cb285de 100644 --- a/shipments/tests/test_api.py +++ b/shipments/tests/test_api.py @@ -6,22 +6,23 @@ from datetime import timedelta from django.core.exceptions import ValidationError + class ShipmentAPITestCase(TestCase): def setUp(self): self.client = APIClient() self.shipment = Shipment.objects.create( shipment_id="SHIP123", order_id="ORD456", - origin_warehouse_id="WH001", - destination_warehouse_id="WH002", + origin={"lat": 6.9271, "lng": 79.8612}, + destination={"lat": 7.2906, "lng": 80.6337} ) def test_create_shipment(self): payload = { "shipment_id": "SHIP999", "order_id": "ORD999", - "origin_warehouse_id": "WH010", - "destination_warehouse_id": "WH020" + "origin": {"lat": 6.9, "lng": 79.8}, + "destination": {"lat": 7.2, "lng": 80.6} } response = self.client.post("/api/shipments/", payload, format="json") self.assertEqual(response.status_code, status.HTTP_201_CREATED) @@ -108,6 +109,6 @@ def test_duplicate_shipment_id(self): Shipment.objects.create( shipment_id="SHIP123", order_id="ORD999", - origin_warehouse_id="WHX", - destination_warehouse_id="WHY" + origin={"lat": 1.0, "lng": 2.0}, + destination={"lat": 3.0, "lng": 4.0} ) diff --git a/shipments/tests/test_consumer.py b/shipments/tests/test_consumer.py index 92b63a4..54de5c9 100644 --- a/shipments/tests/test_consumer.py +++ b/shipments/tests/test_consumer.py @@ -5,24 +5,22 @@ class KafkaConsumerRobustTest(TestCase): def test_valid_order_event_creates_shipment(self): - """A valid event should create a shipment.""" event = { "order_id": "ORD001", - "origin_warehouse_id": "WH1", - "destination_warehouse_id": "WH2" + "origin": {"lat": 6.9271, "lng": 79.8612}, + "destination": {"lat": 7.2906, "lng": 80.6337} } handle_order_created(event) shipment = Shipment.objects.get(order_id="ORD001") self.assertEqual(shipment.status, "pending") - self.assertEqual(shipment.origin_warehouse_id, "WH1") - self.assertEqual(shipment.destination_warehouse_id, "WH2") + self.assertEqual(shipment.origin, {"lat": 6.9271, "lng": 79.8612}) + self.assertEqual(shipment.destination, {"lat": 7.2906, "lng": 80.6337}) def test_missing_order_id_does_not_create_shipment(self): - """Missing order_id should skip creation.""" event = { - "origin_warehouse_id": "WH1", - "destination_warehouse_id": "WH2" + "origin": {"lat": 6.9271, "lng": 79.8612}, + "destination": {"lat": 7.2906, "lng": 80.6337} } handle_order_created(event) self.assertEqual(Shipment.objects.count(), 0) @@ -30,7 +28,7 @@ def test_missing_order_id_does_not_create_shipment(self): def test_missing_origin_does_not_create_shipment(self): event = { "order_id": "ORD002", - "destination_warehouse_id": "WH2" + "destination": {"lat": 7.2906, "lng": 80.6337} } handle_order_created(event) self.assertEqual(Shipment.objects.count(), 0) @@ -38,55 +36,50 @@ def test_missing_origin_does_not_create_shipment(self): def test_missing_destination_does_not_create_shipment(self): event = { "order_id": "ORD003", - "origin_warehouse_id": "WH1" + "origin": {"lat": 6.9271, "lng": 79.8612} } handle_order_created(event) self.assertEqual(Shipment.objects.count(), 0) def test_invalid_data_type_ignored(self): - """If order_id is not a string, the handler should not crash.""" event = { - "order_id": 12345, - "origin_warehouse_id": "WH1", - "destination_warehouse_id": "WH2" + "order_id": 12345, # Still acceptable as string-like + "origin": {"lat": 6.9, "lng": 79.8}, + "destination": {"lat": 7.3, "lng": 80.6} } handle_order_created(event) self.assertEqual(Shipment.objects.filter(order_id=12345).count(), 1) def test_duplicate_order_id_creates_separate_shipments(self): - """If shipment_id is random, even duplicate order_id can create multiple records.""" event = { "order_id": "ORDDUP", - "origin_warehouse_id": "WH1", - "destination_warehouse_id": "WH2" + "origin": {"lat": 6.9, "lng": 79.8}, + "destination": {"lat": 7.3, "lng": 80.6} } handle_order_created(event) handle_order_created(event) self.assertEqual(Shipment.objects.filter(order_id="ORDDUP").count(), 2) def test_extra_fields_are_ignored(self): - """Extra fields in the event should not break creation.""" event = { "order_id": "ORD004", - "origin_warehouse_id": "WH1", - "destination_warehouse_id": "WH2", + "origin": {"lat": 6.9, "lng": 79.8}, + "destination": {"lat": 7.3, "lng": 80.6}, "customer_priority": "high", - "notes": "this is ignored" + "notes": "ignored field" } handle_order_created(event) self.assertTrue(Shipment.objects.filter(order_id="ORD004").exists()) def test_empty_event_dict(self): - """An empty dict should be gracefully ignored.""" handle_order_created({}) self.assertEqual(Shipment.objects.count(), 0) def test_null_values(self): - """Null values should not create shipments.""" event = { "order_id": None, - "origin_warehouse_id": None, - "destination_warehouse_id": None, + "origin": None, + "destination": None, } handle_order_created(event) self.assertEqual(Shipment.objects.count(), 0) diff --git a/shipments/tests/test_integration_kafka.py b/shipments/tests/test_integration_kafka.py index 1dee1ce..4cbd047 100644 --- a/shipments/tests/test_integration_kafka.py +++ b/shipments/tests/test_integration_kafka.py @@ -3,7 +3,6 @@ from django.test import TestCase from shipments.models import Shipment from confluent_kafka import Producer - from shipments.consumers.order_events import run_consumer_once logger = logging.getLogger(__name__) @@ -18,8 +17,8 @@ def test_order_event_creates_shipment(self): order_id = "KAFKA_E2E_01" event = { "order_id": order_id, - "origin_warehouse_id": "WH-X", - "destination_warehouse_id": "WH-Y" + "origin": {"lat": 6.9271, "lng": 79.8612}, + "destination": {"lat": 7.2906, "lng": 80.6337} } # Send Kafka message @@ -33,3 +32,6 @@ def test_order_event_creates_shipment(self): shipment = Shipment.objects.filter(order_id=order_id).first() logger.debug("Shipment: %s", shipment) self.assertIsNotNone(shipment, f"Shipment for {order_id} should exist") + self.assertEqual(shipment.origin, event["origin"]) + self.assertEqual(shipment.destination, event["destination"]) + self.assertEqual(shipment.status, "pending") From 81e106ecbcc3963c05c0479103707b92d25c9cce Mon Sep 17 00:00:00 2001 From: Ke-vin-S Date: Thu, 8 May 2025 16:19:10 +0530 Subject: [PATCH 12/36] update assignment model --- assignment/migrations/0001_initial.py | 24 +++++++++-- assignment/models.py | 11 ----- assignment/models/__init__.py | 0 assignment/models/assignment.py | 26 ++++++++++++ assignment/models/assignment_item.py | 20 +++++++++ assignment/serializers.py | 4 +- assignment/tests.py | 59 +++++++++++++++++++++------ assignment/views.py | 34 +++++++++++++-- route_optimizer/distance_matrix.py | 2 + 9 files changed, 150 insertions(+), 30 deletions(-) delete mode 100644 assignment/models.py create mode 100644 assignment/models/__init__.py create mode 100644 assignment/models/assignment.py create mode 100644 assignment/models/assignment_item.py create mode 100644 route_optimizer/distance_matrix.py diff --git a/assignment/migrations/0001_initial.py b/assignment/migrations/0001_initial.py index bd9e48d..fc299e8 100644 --- a/assignment/migrations/0001_initial.py +++ b/assignment/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2 on 2025-04-23 12:51 +# Generated by Django 5.2 on 2025-05-08 10:38 import django.db.models.deletion from django.db import migrations, models @@ -9,7 +9,8 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('fleet', '0001_initial'), + ('fleet', '0003_remove_fuelrecord_vehicle_and_more'), + ('shipments', '0002_remove_shipment_destination_warehouse_id_and_more'), ] operations = [ @@ -18,9 +19,26 @@ class Migration(migrations.Migration): fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('created_at', models.DateTimeField(auto_now_add=True)), - ('delivery_locations', models.JSONField()), + ('started_at', models.DateTimeField(blank=True, null=True)), + ('completed_at', models.DateTimeField(blank=True, null=True)), + ('status', models.CharField(choices=[('created', 'Created'), ('dispatched', 'Dispatched'), ('partially_completed', 'Partially Completed'), ('completed', 'Completed'), ('failed', 'Failed'), ('reassigned', 'Reassigned')], default='created', max_length=32)), ('total_load', models.PositiveIntegerField()), ('vehicle', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='fleet.vehicle')), ], ), + migrations.CreateModel( + name='AssignmentItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('delivery_sequence', models.PositiveIntegerField()), + ('delivery_location', models.JSONField()), + ('is_delivered', models.BooleanField(default=False)), + ('delivered_at', models.DateTimeField(blank=True, null=True)), + ('assignment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='assignment.assignment')), + ('shipment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='shipments.shipment')), + ], + options={ + 'ordering': ['delivery_sequence'], + }, + ), ] diff --git a/assignment/models.py b/assignment/models.py deleted file mode 100644 index 0f84e86..0000000 --- a/assignment/models.py +++ /dev/null @@ -1,11 +0,0 @@ -from django.db import models -from fleet.models import Vehicle - -class Assignment(models.Model): - vehicle = models.ForeignKey(Vehicle, on_delete=models.CASCADE) - created_at = models.DateTimeField(auto_now_add=True) - delivery_locations = models.JSONField() # List of [longitude, latitude] - total_load = models.PositiveIntegerField() - - def __str__(self): - return f"Assignment #{self.id} to {self.vehicle.vehicle_id}" diff --git a/assignment/models/__init__.py b/assignment/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/assignment/models/assignment.py b/assignment/models/assignment.py new file mode 100644 index 0000000..a41118a --- /dev/null +++ b/assignment/models/assignment.py @@ -0,0 +1,26 @@ +from django.db import models +from fleet.models import Vehicle + +class Assignment(models.Model): + vehicle = models.ForeignKey(Vehicle, on_delete=models.CASCADE) + created_at = models.DateTimeField(auto_now_add=True) + started_at = models.DateTimeField(null=True, blank=True) + completed_at = models.DateTimeField(null=True, blank=True) + + status = models.CharField( + max_length=32, + choices=[ + ('created', 'Created'), + ('dispatched', 'Dispatched'), + ('partially_completed', 'Partially Completed'), + ('completed', 'Completed'), + ('failed', 'Failed'), + ('reassigned', 'Reassigned'), + ], + default='created' + ) + + total_load = models.PositiveIntegerField() + + def __str__(self): + return f"Assignment #{self.id} to Vehicle {self.vehicle.vehicle_id}" diff --git a/assignment/models/assignment_item.py b/assignment/models/assignment_item.py new file mode 100644 index 0000000..c649b9c --- /dev/null +++ b/assignment/models/assignment_item.py @@ -0,0 +1,20 @@ +from django.db import models + +from assignment.models.assignment import Assignment +from shipments.models import Shipment + + +class AssignmentItem(models.Model): + assignment = models.ForeignKey(Assignment, on_delete=models.CASCADE, related_name='items') + shipment = models.ForeignKey(Shipment, on_delete=models.CASCADE) + delivery_sequence = models.PositiveIntegerField() # 1st, 2nd, 3rd drop, etc. + delivery_location = models.JSONField() # { "lat": ..., "lng": ... } + + is_delivered = models.BooleanField(default=False) + delivered_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ['delivery_sequence'] + + def __str__(self): + return f"Shipment {self.shipment.id} in Assignment {self.assignment.id}" diff --git a/assignment/serializers.py b/assignment/serializers.py index cd28200..6067b65 100644 --- a/assignment/serializers.py +++ b/assignment/serializers.py @@ -1,5 +1,7 @@ from rest_framework import serializers -from .models import Assignment + +from assignment.models.assignment import Assignment + class AssignmentSerializer(serializers.ModelSerializer): class Meta: diff --git a/assignment/tests.py b/assignment/tests.py index b4b1d6e..5183320 100644 --- a/assignment/tests.py +++ b/assignment/tests.py @@ -1,28 +1,51 @@ from django.test import TestCase from rest_framework.test import APIClient + +from assignment.models.assignment import Assignment +from assignment.models.assignment_item import AssignmentItem from fleet.models import Vehicle -from assignment.models import Assignment +from shipments.models import Shipment + class AssignmentAPITest(TestCase): def setUp(self): self.client = APIClient() self.vehicle = Vehicle.objects.create(vehicle_id="TRK001", capacity=100, status="available") self.busy_vehicle = Vehicle.objects.create(vehicle_id="TRK002", capacity=80, status="assigned") + self.shipment1 = Shipment.objects.create( + shipment_id="SHP001", + order_id="ORD001", + origin={"lat": 6.9, "lng": 79.8}, + destination={"lat": 7.3, "lng": 80.6}, + status="pending" + ) + self.shipment2 = Shipment.objects.create( + shipment_id="SHP002", + order_id="ORD002", + origin={"lat": 6.9, "lng": 79.8}, + destination={"lat": 7.4, "lng": 80.5}, + status="pending" + ) def test_create_assignment_success(self): payload = { "deliveries": [ - {"location": [77.59, 12.97], "load": 40}, - {"location": [77.61, 12.98], "load": 30} + {"shipment_id": self.shipment1.id, "location": {"lat": 7.3, "lng": 80.6}, "load": 40, "sequence": 1}, + {"shipment_id": self.shipment2.id, "location": {"lat": 7.4, "lng": 80.5}, "load": 30, "sequence": 2} ] } response = self.client.post('/api/assignment/assignments/', payload, format='json') self.assertEqual(response.status_code, 201) self.assertEqual(response.data['total_load'], 70) + assignment_id = response.data['id'] + items = AssignmentItem.objects.filter(assignment_id=assignment_id) + self.assertEqual(items.count(), 2) def test_create_assignment_insufficient_capacity(self): payload = { - "deliveries": [{"location": [77.59, 12.97], "load": 150}] + "deliveries": [ + {"shipment_id": self.shipment1.id, "location": {"lat": 7.3, "lng": 80.6}, "load": 150, "sequence": 1} + ] } response = self.client.post('/api/assignment/assignments/', payload, format='json') self.assertEqual(response.status_code, 400) @@ -31,7 +54,11 @@ def test_create_assignment_insufficient_capacity(self): def test_create_assignment_with_no_available_vehicle(self): self.vehicle.status = "maintenance" self.vehicle.save() - payload = {"deliveries": [{"location": [77.59, 12.97], "load": 50}]} + payload = { + "deliveries": [ + {"shipment_id": self.shipment1.id, "location": {"lat": 7.3, "lng": 80.6}, "load": 50, "sequence": 1} + ] + } response = self.client.post('/api/assignment/assignments/', payload, format='json') self.assertEqual(response.status_code, 400) @@ -42,10 +69,16 @@ def test_create_assignment_with_no_deliveries(self): self.assertIn("Deliveries required", response.data['error']) def test_get_all_assignments(self): - Assignment.objects.create( + assignment = Assignment.objects.create( vehicle=self.vehicle, - delivery_locations=[[77.59, 12.97]], - total_load=50 + total_load=50, + status='created' + ) + AssignmentItem.objects.create( + assignment=assignment, + shipment=self.shipment1, + delivery_sequence=1, + delivery_location={"lat": 7.3, "lng": 80.6} ) response = self.client.get('/api/assignment/assignments/') self.assertEqual(response.status_code, 200) @@ -53,7 +86,9 @@ def test_get_all_assignments(self): def test_vehicle_marked_assigned_after_assignment(self): payload = { - "deliveries": [{"location": [77.59, 12.97], "load": 50}] + "deliveries": [ + {"shipment_id": self.shipment1.id, "location": {"lat": 7.3, "lng": 80.6}, "load": 50, "sequence": 1} + ] } self.client.post('/api/assignment/assignments/', payload, format='json') self.vehicle.refresh_from_db() @@ -62,8 +97,8 @@ def test_vehicle_marked_assigned_after_assignment(self): def test_assignment_model_str(self): assignment = Assignment.objects.create( vehicle=self.vehicle, - delivery_locations=[[77.59, 12.97]], - total_load=50 + total_load=50, + status='created' ) - expected = f"Assignment #{assignment.id} to {self.vehicle.vehicle_id}" + expected = f"Assignment #{assignment.id} to Vehicle {self.vehicle.vehicle_id}" self.assertEqual(str(assignment), expected) diff --git a/assignment/views.py b/assignment/views.py index 2680f55..6abfff1 100644 --- a/assignment/views.py +++ b/assignment/views.py @@ -1,8 +1,12 @@ from rest_framework import viewsets, status from rest_framework.response import Response -from .models import Assignment + +from .models.assignment import Assignment +from .models.assignment_item import AssignmentItem from .serializers import AssignmentSerializer from fleet.models import Vehicle +from shipments.models import Shipment + class AssignmentViewSet(viewsets.ModelViewSet): queryset = Assignment.objects.all() @@ -13,18 +17,42 @@ def create(self, request, *args, **kwargs): if not deliveries: return Response({"error": "Deliveries required"}, status=400) + # Calculate total load total_load = sum(d.get("load", 0) for d in deliveries) + + # Find an available vehicle that can handle the load vehicle = Vehicle.objects.filter(status="available", capacity__gte=total_load).first() if not vehicle: return Response({"error": "No available vehicle for the load"}, status=400) + # Update vehicle status vehicle.status = "assigned" vehicle.save() + # Create Assignment assignment = Assignment.objects.create( vehicle=vehicle, - delivery_locations=[d["location"] for d in deliveries], - total_load=total_load + total_load=total_load, + status='created' ) + + # Create AssignmentItem entries + for delivery in deliveries: + shipment_id = delivery.get("shipment_id") + location = delivery.get("location") + sequence = delivery.get("sequence", 1) # fallback if sequence not provided + + try: + shipment = Shipment.objects.get(id=shipment_id) + except Shipment.DoesNotExist: + return Response({"error": f"Shipment {shipment_id} does not exist"}, status=400) + + AssignmentItem.objects.create( + assignment=assignment, + shipment=shipment, + delivery_sequence=sequence, + delivery_location=location, + ) + serializer = self.get_serializer(assignment) return Response(serializer.data, status=status.HTTP_201_CREATED) diff --git a/route_optimizer/distance_matrix.py b/route_optimizer/distance_matrix.py new file mode 100644 index 0000000..303f38f --- /dev/null +++ b/route_optimizer/distance_matrix.py @@ -0,0 +1,2 @@ +def get_distance_matrix(location): + return 3 \ No newline at end of file From db4eca9151c800c883123014c58866f23072f6d2 Mon Sep 17 00:00:00 2001 From: Ke-vin-S Date: Thu, 8 May 2025 16:28:50 +0530 Subject: [PATCH 13/36] Delete clients for apps in this project --- assignment/clients/fleet_client.py | 31 --------------------------- assignment/clients/shipment_client.py | 14 ------------ 2 files changed, 45 deletions(-) delete mode 100644 assignment/clients/fleet_client.py delete mode 100644 assignment/clients/shipment_client.py diff --git a/assignment/clients/fleet_client.py b/assignment/clients/fleet_client.py deleted file mode 100644 index 14884eb..0000000 --- a/assignment/clients/fleet_client.py +++ /dev/null @@ -1,31 +0,0 @@ -from fleet.models import Vehicle - - -class FleetClient: - @staticmethod - def get_available_vehicles(min_capacity=None, limit=None): - """ - fetch available vehicles, optionally filtered by capacity and limited in count. - - Args: - min_capacity (int, optional): Minimum vehicle capacity. - limit (int, optional): Maximum number of vehicles to return. - - Returns: - QuerySet of Vehicle objects - """ - qs = Vehicle.objects.filter(status='available') - if min_capacity is not None: - qs = qs.filter(capacity__gte=min_capacity) - if limit is not None: - qs = qs[:limit] - return qs - - @staticmethod - def get_vehicle_by_id(vehicle_id): - return Vehicle.objects.filter(id=vehicle_id).first() - - @staticmethod - def mark_assigned(vehicle): - vehicle.status = 'assigned' - vehicle.save(update_fields=['status']) diff --git a/assignment/clients/shipment_client.py b/assignment/clients/shipment_client.py deleted file mode 100644 index 9ac82b5..0000000 --- a/assignment/clients/shipment_client.py +++ /dev/null @@ -1,14 +0,0 @@ -from shipments.models import Shipment - -class ShipmentClient: - @staticmethod - def get_pending_shipments(): - return Shipment.objects.filter(status='pending') - - @staticmethod - def mark_scheduled(shipment, vehicle, dispatch_time=None): - shipment.status = 'scheduled' - shipment.assigned_vehicle_id = vehicle.id - if dispatch_time: - shipment.scheduled_dispatch = dispatch_time - shipment.save(update_fields=['status', 'assigned_vehicle_id', 'scheduled_dispatch']) From d3b942435b81fae4502d5477a3a8511135cc3861 Mon Sep 17 00:00:00 2001 From: Ke-vin-S Date: Thu, 8 May 2025 17:01:35 +0530 Subject: [PATCH 14/36] Add demand field to shipment --- shipments/admin.py | 7 ++- shipments/consumers/order_events.py | 13 +++- shipments/migrations/0003_shipment_demand.py | 18 ++++++ shipments/models.py | 3 + shipments/tests/test_api.py | 44 +++++++------ shipments/tests/test_consumer.py | 65 +++++++++++++++----- shipments/views.py | 33 ++++++---- 7 files changed, 128 insertions(+), 55 deletions(-) create mode 100644 shipments/migrations/0003_shipment_demand.py diff --git a/shipments/admin.py b/shipments/admin.py index 30b1d50..9505d86 100644 --- a/shipments/admin.py +++ b/shipments/admin.py @@ -9,8 +9,9 @@ class ShipmentAdmin(admin.ModelAdmin): 'order_id', 'get_origin', 'get_destination', + 'demand', 'status', - 'created_at' + 'created_at', ) list_filter = ('status',) search_fields = ('shipment_id', 'order_id') @@ -18,10 +19,10 @@ class ShipmentAdmin(admin.ModelAdmin): @admin.display(description="Origin") def get_origin(self, obj): - loc = obj.origin_location + loc = obj.origin return f"{loc.get('lat')}, {loc.get('lng')}" if loc else "N/A" @admin.display(description="Destination") def get_destination(self, obj): - loc = obj.destination_location + loc = obj.destination return f"{loc.get('lat')}, {loc.get('lng')}" if loc else "N/A" diff --git a/shipments/consumers/order_events.py b/shipments/consumers/order_events.py index d2b1a8b..9a984ec 100644 --- a/shipments/consumers/order_events.py +++ b/shipments/consumers/order_events.py @@ -19,23 +19,30 @@ def handle_order_created(event): order_id = event.get("order_id") origin = event.get("origin") # Expecting {"lat": ..., "lng": ...} destination = event.get("destination") + demand = event.get("demand", 0) + # Basic validation if not (order_id and origin and destination): - logging.error("Invalid order event payload") + logging.error("Invalid order event payload: missing fields") return if not all(k in origin for k in ("lat", "lng")) or not all(k in destination for k in ("lat", "lng")): logging.error("Origin/destination must include lat/lng") return + if not isinstance(demand, int) or demand < 0: + logging.warning(f"Invalid or missing demand for order {order_id}. Defaulting to 0.") + demand = 0 + Shipment.objects.create( shipment_id=str(uuid.uuid4())[:12], - order_id=order_id, + order_id=str(order_id), origin=origin, destination=destination, + demand=demand, status='pending' ) - logging.info(f"Shipment created for order {order_id}") + logging.info(f"Shipment created for order {order_id} with demand {demand}") def start_order_consumer(): diff --git a/shipments/migrations/0003_shipment_demand.py b/shipments/migrations/0003_shipment_demand.py new file mode 100644 index 0000000..3c5b2a4 --- /dev/null +++ b/shipments/migrations/0003_shipment_demand.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2 on 2025-05-08 11:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shipments', '0002_remove_shipment_destination_warehouse_id_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='shipment', + name='demand', + field=models.PositiveIntegerField(default=0, help_text='Amount of load required for this shipment (e.g., in kg or units)'), + ), + ] diff --git a/shipments/models.py b/shipments/models.py index a277e2d..ff971c8 100644 --- a/shipments/models.py +++ b/shipments/models.py @@ -2,6 +2,7 @@ from django.utils import timezone from django.core.exceptions import ValidationError + class Shipment(models.Model): STATUS_CHOICES = [ ('pending', 'Pending'), @@ -18,6 +19,8 @@ class Shipment(models.Model): origin = models.JSONField() destination = models.JSONField() + demand = models.PositiveIntegerField(help_text="Amount of load required for this shipment (e.g., in kg or units)", default=0) + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending') scheduled_dispatch = models.DateTimeField(null=True, blank=True) actual_dispatch = models.DateTimeField(null=True, blank=True) diff --git a/shipments/tests/test_api.py b/shipments/tests/test_api.py index cb285de..2f0c4e7 100644 --- a/shipments/tests/test_api.py +++ b/shipments/tests/test_api.py @@ -2,9 +2,8 @@ from rest_framework.test import APIClient from rest_framework import status from django.utils import timezone -from shipments.models import Shipment from datetime import timedelta -from django.core.exceptions import ValidationError +from shipments.models import Shipment class ShipmentAPITestCase(TestCase): @@ -14,26 +13,32 @@ def setUp(self): shipment_id="SHIP123", order_id="ORD456", origin={"lat": 6.9271, "lng": 79.8612}, - destination={"lat": 7.2906, "lng": 80.6337} + destination={"lat": 7.2906, "lng": 80.6337}, + demand=50, ) - def test_create_shipment(self): + def create_shipment(self, shipment_id="SHIP999", demand=75): payload = { - "shipment_id": "SHIP999", + "shipment_id": shipment_id, "order_id": "ORD999", "origin": {"lat": 6.9, "lng": 79.8}, - "destination": {"lat": 7.2, "lng": 80.6} + "destination": {"lat": 7.2, "lng": 80.6}, + "demand": demand, } - response = self.client.post("/api/shipments/", payload, format="json") - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + return self.client.post("/api/shipments/", payload, format="json") + + def test_create_shipment(self): + response = self.create_shipment() + self.assertEqual(response.status_code, status.HTTP_201_CREATED, msg=response.data) self.assertEqual(response.data["shipment_id"], "SHIP999") + self.assertEqual(response.data["demand"], 75) def test_mark_scheduled(self): scheduled_time = (timezone.now() + timedelta(days=1)).isoformat() response = self.client.post(f"/api/shipments/{self.shipment.id}/mark_scheduled/", { "scheduled_time": scheduled_time }, format="json") - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_200_OK, msg=response.data) self.assertEqual(response.data["status"], "scheduled") def test_mark_dispatched(self): @@ -42,14 +47,14 @@ def test_mark_dispatched(self): response = self.client.post(f"/api/shipments/{self.shipment.id}/mark_dispatched/", { "dispatch_time": dispatch_time }, format="json") - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_200_OK, msg=response.data) self.assertEqual(response.data["status"], "dispatched") def test_mark_in_transit(self): self.shipment.mark_scheduled() self.shipment.mark_dispatched() response = self.client.post(f"/api/shipments/{self.shipment.id}/mark_in_transit/") - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_200_OK, msg=response.data) self.assertEqual(response.data["status"], "in_transit") def test_mark_delivered(self): @@ -60,17 +65,17 @@ def test_mark_delivered(self): response = self.client.post(f"/api/shipments/{self.shipment.id}/mark_delivered/", { "delivery_time": delivery_time }, format="json") - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_200_OK, msg=response.data) self.assertEqual(response.data["status"], "delivered") def test_mark_failed(self): response = self.client.post(f"/api/shipments/{self.shipment.id}/mark_failed/") - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_200_OK, msg=response.data) self.assertEqual(response.data["status"], "failed") def test_invalid_transition_dispatched_without_schedule(self): response = self.client.post(f"/api/shipments/{self.shipment.id}/mark_dispatched/", {}, format="json") - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST, msg=response.data) self.assertIn("error", response.data) def test_invalid_transition_delivered_without_in_transit(self): @@ -79,7 +84,7 @@ def test_invalid_transition_delivered_without_in_transit(self): response = self.client.post(f"/api/shipments/{self.shipment.id}/mark_delivered/", { "delivery_time": timezone.now().isoformat() }, format="json") - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST, msg=response.data) self.assertIn("error", response.data) def test_invalid_transition_failed_after_delivery(self): @@ -88,20 +93,20 @@ def test_invalid_transition_failed_after_delivery(self): self.shipment.mark_in_transit() self.shipment.mark_delivered() response = self.client.post(f"/api/shipments/{self.shipment.id}/mark_failed/", {}, format="json") - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST, msg=response.data) self.assertIn("error", response.data) def test_revert_to_pending_from_scheduled(self): self.shipment.mark_scheduled() response = self.client.post(f"/api/shipments/{self.shipment.id}/mark_pending/", {}, format="json") - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_200_OK, msg=response.data) self.assertEqual(response.data["status"], "pending") def test_invalid_revert_to_pending_from_dispatched(self): self.shipment.mark_scheduled() self.shipment.mark_dispatched() response = self.client.post(f"/api/shipments/{self.shipment.id}/mark_pending/", {}, format="json") - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST, msg=response.data) self.assertIn("error", response.data) def test_duplicate_shipment_id(self): @@ -110,5 +115,6 @@ def test_duplicate_shipment_id(self): shipment_id="SHIP123", order_id="ORD999", origin={"lat": 1.0, "lng": 2.0}, - destination={"lat": 3.0, "lng": 4.0} + destination={"lat": 3.0, "lng": 4.0}, + demand=20, ) diff --git a/shipments/tests/test_consumer.py b/shipments/tests/test_consumer.py index 54de5c9..6efdc36 100644 --- a/shipments/tests/test_consumer.py +++ b/shipments/tests/test_consumer.py @@ -8,19 +8,22 @@ def test_valid_order_event_creates_shipment(self): event = { "order_id": "ORD001", "origin": {"lat": 6.9271, "lng": 79.8612}, - "destination": {"lat": 7.2906, "lng": 80.6337} + "destination": {"lat": 7.2906, "lng": 80.6337}, + "demand": 25 } handle_order_created(event) shipment = Shipment.objects.get(order_id="ORD001") self.assertEqual(shipment.status, "pending") - self.assertEqual(shipment.origin, {"lat": 6.9271, "lng": 79.8612}) - self.assertEqual(shipment.destination, {"lat": 7.2906, "lng": 80.6337}) + self.assertEqual(shipment.origin, event["origin"]) + self.assertEqual(shipment.destination, event["destination"]) + self.assertEqual(shipment.demand, 25) def test_missing_order_id_does_not_create_shipment(self): event = { "origin": {"lat": 6.9271, "lng": 79.8612}, - "destination": {"lat": 7.2906, "lng": 80.6337} + "destination": {"lat": 7.2906, "lng": 80.6337}, + "demand": 10 } handle_order_created(event) self.assertEqual(Shipment.objects.count(), 0) @@ -28,7 +31,8 @@ def test_missing_order_id_does_not_create_shipment(self): def test_missing_origin_does_not_create_shipment(self): event = { "order_id": "ORD002", - "destination": {"lat": 7.2906, "lng": 80.6337} + "destination": {"lat": 7.2906, "lng": 80.6337}, + "demand": 10 } handle_order_created(event) self.assertEqual(Shipment.objects.count(), 0) @@ -36,50 +40,77 @@ def test_missing_origin_does_not_create_shipment(self): def test_missing_destination_does_not_create_shipment(self): event = { "order_id": "ORD003", - "origin": {"lat": 6.9271, "lng": 79.8612} + "origin": {"lat": 6.9271, "lng": 79.8612}, + "demand": 10 } handle_order_created(event) self.assertEqual(Shipment.objects.count(), 0) - def test_invalid_data_type_ignored(self): + def test_invalid_data_type_for_order_id_is_casted(self): event = { - "order_id": 12345, # Still acceptable as string-like + "order_id": 12345, "origin": {"lat": 6.9, "lng": 79.8}, - "destination": {"lat": 7.3, "lng": 80.6} + "destination": {"lat": 7.3, "lng": 80.6}, + "demand": 40 } handle_order_created(event) - self.assertEqual(Shipment.objects.filter(order_id=12345).count(), 1) + self.assertTrue(Shipment.objects.filter(order_id=str(12345)).exists()) - def test_duplicate_order_id_creates_separate_shipments(self): + def test_duplicate_order_id_creates_multiple_shipments(self): event = { "order_id": "ORDDUP", "origin": {"lat": 6.9, "lng": 79.8}, - "destination": {"lat": 7.3, "lng": 80.6} + "destination": {"lat": 7.3, "lng": 80.6}, + "demand": 50 } handle_order_created(event) handle_order_created(event) self.assertEqual(Shipment.objects.filter(order_id="ORDDUP").count(), 2) - def test_extra_fields_are_ignored(self): + def test_extra_fields_are_ignored_and_demand_saved(self): event = { "order_id": "ORD004", "origin": {"lat": 6.9, "lng": 79.8}, "destination": {"lat": 7.3, "lng": 80.6}, "customer_priority": "high", - "notes": "ignored field" + "notes": "this should be ignored", + "demand": 60 } handle_order_created(event) - self.assertTrue(Shipment.objects.filter(order_id="ORD004").exists()) + shipment = Shipment.objects.get(order_id="ORD004") + self.assertEqual(shipment.demand, 60) - def test_empty_event_dict(self): + def test_event_with_no_fields_does_nothing(self): handle_order_created({}) self.assertEqual(Shipment.objects.count(), 0) - def test_null_values(self): + def test_null_values_are_ignored(self): event = { "order_id": None, "origin": None, "destination": None, + "demand": None } handle_order_created(event) self.assertEqual(Shipment.objects.count(), 0) + + def test_negative_demand_defaults_to_zero(self): + event = { + "order_id": "ORD_NEG", + "origin": {"lat": 6.9, "lng": 79.8}, + "destination": {"lat": 7.3, "lng": 80.6}, + "demand": -5 + } + handle_order_created(event) + shipment = Shipment.objects.get(order_id="ORD_NEG") + self.assertEqual(shipment.demand, 0) + + def test_missing_demand_defaults_to_zero(self): + event = { + "order_id": "ORD_NO_DEMAND", + "origin": {"lat": 6.9, "lng": 79.8}, + "destination": {"lat": 7.3, "lng": 80.6} + } + handle_order_created(event) + shipment = Shipment.objects.get(order_id="ORD_NO_DEMAND") + self.assertEqual(shipment.demand, 0) diff --git a/shipments/views.py b/shipments/views.py index a7f46d9..ad0ead6 100644 --- a/shipments/views.py +++ b/shipments/views.py @@ -3,6 +3,7 @@ from rest_framework.response import Response from django.core.exceptions import ValidationError from django.utils.dateparse import parse_datetime +from django.shortcuts import get_object_or_404 from .models import Shipment from .serializers import ShipmentSerializer @@ -11,49 +12,55 @@ class ShipmentViewSet(viewsets.ModelViewSet): queryset = Shipment.objects.all() serializer_class = ShipmentSerializer - filterset_fields = ['status', 'order_id', 'origin_warehouse_id', 'destination_warehouse_id'] + filterset_fields = ['status', 'order_id'] search_fields = ['shipment_id', 'order_id'] ordering_fields = ['created_at', 'scheduled_dispatch'] def handle_transition(self, request, shipment, transition_func, time_field=None): + """ + Wrapper for status transition methods with optional timestamp support. + """ try: - # If time is required, parse it + timestamp = None if time_field: - raw_value = request.data.get(time_field) - timestamp = parse_datetime(raw_value) if raw_value else None - transition_func(timestamp) - else: - transition_func() + raw = request.data.get(time_field) + if raw: + timestamp = parse_datetime(raw) + if not timestamp: + return Response({time_field: "Invalid datetime format."}, status=400) + transition_func(timestamp) if timestamp else transition_func() return Response(self.get_serializer(shipment).data) except ValidationError as e: return Response({'error': e.message}, status=status.HTTP_400_BAD_REQUEST) + except Exception as e: + return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) @action(detail=True, methods=['post']) def mark_pending(self, request, pk=None): - shipment = self.get_object() + shipment = get_object_or_404(Shipment, pk=pk) return self.handle_transition(request, shipment, shipment.mark_pending) @action(detail=True, methods=['post']) def mark_scheduled(self, request, pk=None): - shipment = self.get_object() + shipment = get_object_or_404(Shipment, pk=pk) return self.handle_transition(request, shipment, shipment.mark_scheduled, time_field='scheduled_time') @action(detail=True, methods=['post']) def mark_dispatched(self, request, pk=None): - shipment = self.get_object() + shipment = get_object_or_404(Shipment, pk=pk) return self.handle_transition(request, shipment, shipment.mark_dispatched, time_field='dispatch_time') @action(detail=True, methods=['post']) def mark_in_transit(self, request, pk=None): - shipment = self.get_object() + shipment = get_object_or_404(Shipment, pk=pk) return self.handle_transition(request, shipment, shipment.mark_in_transit) @action(detail=True, methods=['post']) def mark_delivered(self, request, pk=None): - shipment = self.get_object() + shipment = get_object_or_404(Shipment, pk=pk) return self.handle_transition(request, shipment, shipment.mark_delivered, time_field='delivery_time') @action(detail=True, methods=['post']) def mark_failed(self, request, pk=None): - shipment = self.get_object() + shipment = get_object_or_404(Shipment, pk=pk) return self.handle_transition(request, shipment, shipment.mark_failed) From 863361ed2b18007df1f52b17690235e3690dc589 Mon Sep 17 00:00:00 2001 From: Ke-vin-S Date: Thu, 8 May 2025 17:19:38 +0530 Subject: [PATCH 15/36] Add depot fields to vehicle model --- fleet/admin.py | 24 ++- fleet/migrations/0004_vehicle_depot_id.py | 18 ++ ..._depot_latitude_vehicle_depot_longitude.py | 23 +++ fleet/models/core.py | 9 + fleet/serializers/vehicle.py | 24 ++- fleet/tests/test_vehicle.py | 58 +++--- fleet/tests/test_vehicle_api.py | 98 +++++---- fleet/views/vehicle.py | 186 +++++++++--------- 8 files changed, 256 insertions(+), 184 deletions(-) create mode 100644 fleet/migrations/0004_vehicle_depot_id.py create mode 100644 fleet/migrations/0005_vehicle_depot_latitude_vehicle_depot_longitude.py diff --git a/fleet/admin.py b/fleet/admin.py index b2f08df..8af2ac4 100644 --- a/fleet/admin.py +++ b/fleet/admin.py @@ -4,30 +4,40 @@ @admin.register(Vehicle) class VehicleAdmin(admin.ModelAdmin): - list_display = ('vehicle_id', 'name', 'capacity', 'status', 'fuel_type', 'last_location_update') + list_display = ( + 'vehicle_id', 'name', 'capacity', 'status', 'fuel_type', + 'depot_id', 'depot_latitude', 'depot_longitude', 'last_location_update' + ) list_filter = ('status', 'fuel_type') - search_fields = ('vehicle_id', 'name', 'plate_number') + search_fields = ('vehicle_id', 'name', 'plate_number', 'depot_id') readonly_fields = ('created_at', 'updated_at', 'last_location_update') + fieldsets = ( ('Basic Information', { - 'fields': ('vehicle_id', 'name', 'plate_number', 'year_of_manufacture') + 'fields': ( + 'vehicle_id', 'name', 'plate_number', + 'year_of_manufacture', 'status' + ) + }), + ('Depot Assignment', { + 'fields': ('depot_id', 'depot_latitude', 'depot_longitude') }), ('Specifications', { 'fields': ('capacity', 'fuel_type', 'max_speed', 'fuel_efficiency') }), - ('Status & Location', { - 'fields': ('status', 'current_latitude', 'current_longitude', 'last_location_update') + ('Current Location', { + 'fields': ('current_latitude', 'current_longitude', 'last_location_update') }), ('Timestamps', { 'fields': ('created_at', 'updated_at'), 'classes': ('collapse',) - }) + }), ) @admin.register(VehicleLocation) class VehicleLocationAdmin(admin.ModelAdmin): - list_display = ('vehicle', 'timestamp', 'latitude', 'longitude', 'speed') + list_display = ('vehicle', 'timestamp', 'latitude', 'longitude', 'speed', 'heading') list_filter = ('timestamp',) search_fields = ('vehicle__vehicle_id',) raw_id_fields = ('vehicle',) diff --git a/fleet/migrations/0004_vehicle_depot_id.py b/fleet/migrations/0004_vehicle_depot_id.py new file mode 100644 index 0000000..3bffcc1 --- /dev/null +++ b/fleet/migrations/0004_vehicle_depot_id.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2 on 2025-05-08 11:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('fleet', '0003_remove_fuelrecord_vehicle_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='vehicle', + name='depot_id', + field=models.CharField(blank=True, help_text='External ID of the depot this vehicle is assigned to', max_length=64, null=True), + ), + ] diff --git a/fleet/migrations/0005_vehicle_depot_latitude_vehicle_depot_longitude.py b/fleet/migrations/0005_vehicle_depot_latitude_vehicle_depot_longitude.py new file mode 100644 index 0000000..cf6ab1b --- /dev/null +++ b/fleet/migrations/0005_vehicle_depot_latitude_vehicle_depot_longitude.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2 on 2025-05-08 11:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('fleet', '0004_vehicle_depot_id'), + ] + + operations = [ + migrations.AddField( + model_name='vehicle', + name='depot_latitude', + field=models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True), + ), + migrations.AddField( + model_name='vehicle', + name='depot_longitude', + field=models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True), + ), + ] diff --git a/fleet/models/core.py b/fleet/models/core.py index adfe6e1..0b7675b 100644 --- a/fleet/models/core.py +++ b/fleet/models/core.py @@ -28,6 +28,15 @@ class Vehicle(models.Model): plate_number = models.CharField(max_length=20, blank=True) year_of_manufacture = models.PositiveIntegerField(null=True, blank=True) + # Depot + depot_id = models.CharField( + max_length=64, + null=True, + blank=True, + help_text="External ID of the depot this vehicle is assigned to" + ) + depot_latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) + depot_longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) # Location tracking current_latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) current_longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True) diff --git a/fleet/serializers/vehicle.py b/fleet/serializers/vehicle.py index 3e10e6a..292fdad 100644 --- a/fleet/serializers/vehicle.py +++ b/fleet/serializers/vehicle.py @@ -9,14 +9,30 @@ class VehicleSerializer(serializers.ModelSerializer): class Meta: model = Vehicle fields = [ - 'id', 'vehicle_id', 'name', 'capacity', 'status', 'fuel_type', - 'plate_number', 'year_of_manufacture', 'current_latitude', - 'current_longitude', 'last_location_update', 'max_speed', - 'fuel_efficiency', 'created_at', 'updated_at', 'is_available', + 'id', + 'vehicle_id', + 'name', + 'capacity', + 'status', + 'fuel_type', + 'plate_number', + 'year_of_manufacture', + 'depot_id', + 'depot_latitude', + 'depot_longitude', + 'current_latitude', + 'current_longitude', + 'last_location_update', + 'max_speed', + 'fuel_efficiency', + 'created_at', + 'updated_at', + 'is_available', 'location_is_stale' ] read_only_fields = ['created_at', 'updated_at', 'last_location_update'] + class VehicleLocationSerializer(serializers.ModelSerializer): class Meta: model = VehicleLocation diff --git a/fleet/tests/test_vehicle.py b/fleet/tests/test_vehicle.py index 9807040..932d803 100644 --- a/fleet/tests/test_vehicle.py +++ b/fleet/tests/test_vehicle.py @@ -1,11 +1,10 @@ from django.test import TestCase -from rest_framework.test import APIClient - from fleet.models import Vehicle +from django.utils import timezone class VehicleModelTest(TestCase): - """Test vehicle model functionality.""" + """Unit tests for the Vehicle model.""" def setUp(self): self.vehicle = Vehicle.objects.create( @@ -19,29 +18,38 @@ def setUp(self): fuel_efficiency=8.5 ) - def test_vehicle_creation(self): - """Test that vehicle can be created.""" - self.assertEqual(self.vehicle.vehicle_id, "TRK001") - self.assertEqual(self.vehicle.capacity, 1000) - self.assertEqual(self.vehicle.status, "available") - self.assertTrue(self.vehicle.is_available) - - def test_update_location(self): - """Test updating vehicle location.""" - # Initial location should be None - self.assertIsNone(self.vehicle.current_latitude) - self.assertIsNone(self.vehicle.current_longitude) - - # Update location - latitude = 45.123456 - longitude = -75.654321 + def test_vehicle_fields_and_defaults(self): + """Test vehicle creation and default values.""" + v = self.vehicle + self.assertEqual(v.vehicle_id, "TRK001") + self.assertEqual(v.name, "Test Truck 1") + self.assertEqual(v.capacity, 1000) + self.assertEqual(v.status, "available") + self.assertEqual(v.fuel_type, "diesel") + self.assertTrue(v.is_available) + self.assertIsNone(v.current_latitude) + self.assertIsNone(v.current_longitude) + self.assertIsNone(v.last_location_update) + + def test_update_location_sets_values_and_timestamp(self): + """Test updating vehicle's current location.""" + lat, lon = 45.123456, -75.654321 + + self.vehicle.update_location(lat, lon) + self.vehicle.refresh_from_db() + + self.assertEqual(float(self.vehicle.current_latitude), lat) + self.assertEqual(float(self.vehicle.current_longitude), lon) + self.assertIsNotNone(self.vehicle.last_location_update) - self.vehicle.update_location(latitude, longitude) + now = timezone.now() + self.assertLess(abs((now - self.vehicle.last_location_update).total_seconds()), 5) - # Check that location was updated - self.assertEqual(float(self.vehicle.current_latitude), latitude) - self.assertEqual(float(self.vehicle.current_longitude), longitude) - self.assertIsNotNone(self.vehicle.last_location_update) + def test_location_is_stale_logic(self): + """Test location_is_stale property.""" + # Initially: no location update → should be stale + self.assertTrue(self.vehicle.location_is_stale) - # Check that location isn't stale right after update + # After updating location → should not be stale + self.vehicle.update_location(10.0, 20.0) self.assertFalse(self.vehicle.location_is_stale) diff --git a/fleet/tests/test_vehicle_api.py b/fleet/tests/test_vehicle_api.py index 70bc71c..17f86a1 100644 --- a/fleet/tests/test_vehicle_api.py +++ b/fleet/tests/test_vehicle_api.py @@ -1,88 +1,87 @@ from rest_framework.test import APIClient from django.test import TestCase from rest_framework import status - from fleet.models import Vehicle, VehicleLocation class VehicleAPITest(TestCase): - """Test vehicle API endpoints.""" + """Integration tests for Vehicle API endpoints.""" def setUp(self): self.client = APIClient() self.vehicle1 = Vehicle.objects.create( - vehicle_id="TRK001", capacity=1000, status="available", - name="Truck 1", fuel_type="diesel" + vehicle_id="TRK001", name="Truck 1", capacity=1000, + status="available", fuel_type="diesel" ) self.vehicle2 = Vehicle.objects.create( - vehicle_id="TRK002", capacity=500, status="maintenance", - name="Truck 2", fuel_type="petrol" + vehicle_id="TRK002", name="Truck 2", capacity=500, + status="maintenance", fuel_type="petrol" ) self.vehicle3 = Vehicle.objects.create( - vehicle_id="TRK003", capacity=750, status="assigned", - name="Truck 3", fuel_type="diesel" + vehicle_id="TRK003", name="Truck 3", capacity=750, + status="assigned", fuel_type="diesel" ) - def test_list_all_vehicles(self): - """Test retrieving all vehicles.""" + def test_get_all_vehicles(self): + """GET /api/fleet/vehicles/ should return all vehicles.""" response = self.client.get('/api/fleet/vehicles/') self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response.data), 3) - def test_filter_by_status(self): - """Test filtering vehicles by status.""" - response = self.client.get('/api/fleet/vehicles/?status=available') + def test_filter_vehicles_by_status(self): + """GET /api/fleet/vehicles/?status=available should return only available vehicles.""" + response = self.client.get('/api/fleet/vehicles/', {'status': 'available'}) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response.data), 1) - self.assertEqual(response.data[0]['vehicle_id'], 'TRK001') + self.assertEqual(response.data[0]['vehicle_id'], "TRK001") - def test_filter_by_min_capacity(self): - """Test filtering vehicles by minimum capacity.""" - response = self.client.get('/api/fleet/vehicles/?min_capacity=800') + def test_filter_vehicles_by_min_capacity(self): + """GET /api/fleet/vehicles/?min_capacity=800 should return vehicles with capacity >= 800.""" + response = self.client.get('/api/fleet/vehicles/', {'min_capacity': 800}) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response.data), 1) - self.assertEqual(response.data[0]['vehicle_id'], 'TRK001') + self.assertEqual(response.data[0]['vehicle_id'], "TRK001") - def test_filter_by_fuel_type(self): - """Test filtering vehicles by fuel type.""" - response = self.client.get('/api/fleet/vehicles/?fuel_type=diesel') + def test_filter_vehicles_by_fuel_type(self): + """GET /api/fleet/vehicles/?fuel_type=diesel should return vehicles with diesel fuel.""" + response = self.client.get('/api/fleet/vehicles/', {'fuel_type': 'diesel'}) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response.data), 2) vehicle_ids = [v['vehicle_id'] for v in response.data] - self.assertIn('TRK001', vehicle_ids) - self.assertIn('TRK003', vehicle_ids) + self.assertIn("TRK001", vehicle_ids) + self.assertIn("TRK003", vehicle_ids) - def test_create_vehicle(self): - """Test creating a new vehicle.""" + def test_create_vehicle_successfully(self): + """POST /api/fleet/vehicles/ should create a new vehicle.""" payload = { - 'vehicle_id': 'TRK004', - 'name': 'Truck 4', - 'capacity': 1200, - 'status': 'available', - 'fuel_type': 'electric', - 'plate_number': 'XYZ789' + "vehicle_id": "TRK004", + "name": "Truck 4", + "capacity": 1200, + "status": "available", + "fuel_type": "electric", + "plate_number": "XYZ789" } response = self.client.post('/api/fleet/vehicles/', payload, format='json') self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(response.data['vehicle_id'], 'TRK004') - self.assertEqual(response.data['fuel_type'], 'electric') + self.assertEqual(response.data["vehicle_id"], "TRK004") + self.assertEqual(response.data["fuel_type"], "electric") - def test_update_vehicle(self): - """Test updating an existing vehicle.""" + def test_patch_update_vehicle_status(self): + """PATCH /api/fleet/vehicles/{id}/ should update vehicle status.""" response = self.client.patch( f'/api/fleet/vehicles/{self.vehicle1.id}/', - {'status': 'maintenance'}, + {"status": "maintenance"}, format='json' ) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data['status'], 'maintenance') + self.assertEqual(response.data["status"], "maintenance") - def test_update_location(self): - """Test updating vehicle location.""" + def test_update_vehicle_location(self): + """POST /api/fleet/vehicles/{id}/update_location/ should update location and create history.""" payload = { - 'latitude': 42.123456, - 'longitude': -71.654321, - 'speed': 65.5 + "latitude": 42.123456, + "longitude": -71.654321, + "speed": 65.5 } response = self.client.post( f'/api/fleet/vehicles/{self.vehicle1.id}/update_location/', @@ -90,13 +89,12 @@ def test_update_location(self): format='json' ) self.assertEqual(response.status_code, status.HTTP_200_OK) - - # Check that location was updated in the vehicle self.vehicle1.refresh_from_db() - self.assertEqual(float(self.vehicle1.current_latitude), 42.123456) - self.assertEqual(float(self.vehicle1.current_longitude), -71.654321) + self.assertAlmostEqual(float(self.vehicle1.current_latitude), 42.123456) + self.assertAlmostEqual(float(self.vehicle1.current_longitude), -71.654321) - # Check that a location history record was created - location_history = VehicleLocation.objects.filter(vehicle=self.vehicle1) - self.assertEqual(location_history.count(), 1) - self.assertEqual(float(location_history[0].speed), 65.5) + # Verify historical tracking + history = VehicleLocation.objects.filter(vehicle=self.vehicle1) + self.assertEqual(history.count(), 1) + self.assertAlmostEqual(float(history[0].speed), 65.5) + self.assertAlmostEqual(float(history[0].latitude), 42.123456) diff --git a/fleet/views/vehicle.py b/fleet/views/vehicle.py index 1592168..1ece585 100644 --- a/fleet/views/vehicle.py +++ b/fleet/views/vehicle.py @@ -3,16 +3,16 @@ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'logistics_core.settings') django.setup() + from django.db.models import Sum, Count from django.utils import timezone from rest_framework import viewsets, status, filters from rest_framework.decorators import action from rest_framework.response import Response from django_filters.rest_framework import DjangoFilterBackend -from datetime import datetime, timedelta +from datetime import datetime from fleet.models import Vehicle, VehicleLocation - from django.conf import settings if settings.ENABLE_FLEET_EXTENDED_MODELS: @@ -28,8 +28,8 @@ class VehicleViewSet(viewsets.ModelViewSet): queryset = Vehicle.objects.all() serializer_class = VehicleSerializer filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] - filterset_fields = ['status', 'fuel_type'] - search_fields = ['vehicle_id', 'name', 'plate_number'] + filterset_fields = ['status', 'fuel_type', 'depot_id'] + search_fields = ['vehicle_id', 'name', 'plate_number', 'depot_id'] ordering_fields = ['vehicle_id', 'capacity', 'status', 'created_at'] ordering = ['vehicle_id'] @@ -40,164 +40,133 @@ def get_serializer_class(self): def get_queryset(self): queryset = super().get_queryset() - request = self.request # DRF Request, safe to use .query_params - - status = request.query_params.get('status') - min_capacity = request.query_params.get('min_capacity') - max_capacity = request.query_params.get('max_capacity') - available_only = request.query_params.get('available') == 'true' + params = self.request.query_params - if status: + if status := params.get('status'): queryset = queryset.filter(status=status) - if min_capacity: + if min_cap := params.get('min_capacity'): try: - min_capacity = int(min_capacity) - queryset = queryset.filter(capacity__gte=min_capacity) + queryset = queryset.filter(capacity__gte=int(min_cap)) except ValueError: - pass # Ignore invalid capacity filters - if max_capacity: + pass + if max_cap := params.get('max_capacity'): try: - max_capacity = int(max_capacity) - queryset = queryset.filter(capacity__lte=max_capacity) + queryset = queryset.filter(capacity__lte=int(max_cap)) except ValueError: pass - if available_only: + if params.get('available') == 'true': queryset = queryset.filter(status='available') + if depot := params.get('depot_id'): + queryset = queryset.filter(depot_id=depot) return queryset @action(detail=True, methods=['post']) def update_location(self, request, pk=None): - """ - Update vehicle location. - POST /api/fleet/vehicles/{id}/update_location/ - """ vehicle = self.get_object() - - # Extract location data from request latitude = request.data.get('latitude') longitude = request.data.get('longitude') speed = request.data.get('speed') heading = request.data.get('heading') - # Validate required fields - if not latitude or not longitude: - return Response( - {'error': 'Latitude and longitude are required'}, - status=status.HTTP_400_BAD_REQUEST - ) + if latitude is None or longitude is None: + return Response({'error': 'Latitude and longitude are required'}, status=400) try: - # Update current location on vehicle vehicle.update_location(latitude, longitude) - - # Create a location history record - location_data = { - 'vehicle': vehicle, - 'latitude': latitude, - 'longitude': longitude - } - - if speed is not None: - location_data['speed'] = speed - if heading is not None: - location_data['heading'] = heading - - VehicleLocation.objects.create(**location_data) - - return Response({'status': 'location updated'}, status=status.HTTP_200_OK) - except Exception as e: - return Response( - {'error': str(e)}, - status=status.HTTP_400_BAD_REQUEST + VehicleLocation.objects.create( + vehicle=vehicle, + latitude=latitude, + longitude=longitude, + speed=speed or None, + heading=heading or None ) + return Response({'status': 'location updated'}, status=200) + except Exception as e: + return Response({'error': str(e)}, status=400) @action(detail=True, methods=['post']) def change_status(self, request, pk=None): - """ - Change vehicle status. - POST /api/fleet/vehicles/{id}/change_status/ - """ vehicle = self.get_object() new_status = request.data.get('status') if not new_status: - return Response( - {'error': 'Status is required'}, - status=status.HTTP_400_BAD_REQUEST - ) + return Response({'error': 'Status is required'}, status=400) - # Validate status choice if new_status not in dict(Vehicle.STATUS_CHOICES): - return Response( - {'error': f'Invalid status. Must be one of: {dict(Vehicle.STATUS_CHOICES).keys()}'}, - status=status.HTTP_400_BAD_REQUEST - ) + return Response({'error': f'Invalid status: {new_status}'}, status=400) - # Handle status change to maintenance - if new_status == 'maintenance' and vehicle.status != 'maintenance': - # Optionally create a maintenance record + if new_status == 'maintenance' and vehicle.status != 'maintenance' and settings.ENABLE_FLEET_EXTENDED_MODELS: maintenance_type = request.data.get('maintenance_type', 'routine') description = request.data.get('description', 'Routine maintenance') scheduled_date = request.data.get('scheduled_date', timezone.now().date().isoformat()) - try: scheduled_date = datetime.fromisoformat(scheduled_date).date() except ValueError: scheduled_date = timezone.now().date() - # Create maintenance record MaintenanceRecord.objects.create( vehicle=vehicle, maintenance_type=maintenance_type, description=description, scheduled_date=scheduled_date, - status='in_progress' # Since we're changing status to maintenance now + status='in_progress' ) - # Update vehicle status vehicle.status = new_status vehicle.save(update_fields=['status', 'updated_at']) + return Response(VehicleSerializer(vehicle).data) + + @action(detail=True, methods=['post']) + def assign_depot(self, request, pk=None): + """ + Assign or update a vehicle's depot. + POST /api/fleet/vehicles/{id}/assign_depot/ + { + "depot_id": "WHS001", + "latitude": 6.9271, + "longitude": 79.8612 + } + """ + vehicle = self.get_object() + depot_id = request.data.get('depot_id') + depot_lat = request.data.get('latitude') + depot_lon = request.data.get('longitude') + + if depot_id is None: + return Response({'error': 'depot_id is required'}, status=400) + + vehicle.depot_id = depot_id + if depot_lat is not None and depot_lon is not None: + try: + vehicle.depot_latitude = float(depot_lat) + vehicle.depot_longitude = float(depot_lon) + except ValueError: + return Response({'error': 'Invalid latitude or longitude'}, status=400) + + vehicle.save(update_fields=['depot_id', 'depot_latitude', 'depot_longitude', 'updated_at']) return Response(VehicleSerializer(vehicle).data) @action(detail=False, methods=['get']) def stats(self, request): - """ - Get fleet statistics. - GET /api/fleet/vehicles/stats/ - """ - # Count vehicles by status status_counts = dict( Vehicle.objects.values('status').annotate(count=Count('id')).values_list('status', 'count') ) + for s, _ in Vehicle.STATUS_CHOICES: + status_counts.setdefault(s, 0) - # Fill in missing statuses with 0 - for status, _ in Vehicle.STATUS_CHOICES: - if status not in status_counts: - status_counts[status] = 0 - - # Count total vehicles total_vehicles = Vehicle.objects.count() - - # Calculate total fleet capacity total_capacity = Vehicle.objects.aggregate(Sum('capacity'))['capacity__sum'] or 0 + available_capacity = Vehicle.objects.filter(status='available').aggregate(Sum('capacity'))['capacity__sum'] or 0 + maintenance_count = 0 - # Calculate available capacity - available_capacity = Vehicle.objects.filter(status='available').aggregate( - Sum('capacity') - )['capacity__sum'] or 0 - - # Calculate maintenance stats - maintenance_count = MaintenanceRecord.objects.filter( - status__in=['scheduled', 'in_progress'] - ).count() + if settings.ENABLE_FLEET_EXTENDED_MODELS: + maintenance_count = MaintenanceRecord.objects.filter(status__in=['scheduled', 'in_progress']).count() - # Current utilization rate utilization_rate = 0 - if total_vehicles > 0: - assigned_count = status_counts.get('assigned', 0) - utilization_rate = (assigned_count / total_vehicles) * 100 + if total_vehicles: + utilization_rate = (status_counts.get('assigned', 0) / total_vehicles) * 100 return Response({ 'total_vehicles': total_vehicles, @@ -207,3 +176,24 @@ def stats(self, request): 'maintenance_count': maintenance_count, 'utilization_rate': utilization_rate }) + + @action(detail=False, methods=['get']) + def by_depot(self, request): + depot_id = request.query_params.get('depot_id') + if not depot_id: + return Response({'error': 'Missing depot_id parameter'}, status=400) + + vehicles = Vehicle.objects.filter(depot_id=depot_id) + return Response(VehicleSerializer(vehicles, many=True).data) + + @action(detail=False, methods=['get']) + def depot_stats(self, request): + """ + Returns count and total capacity of vehicles per depot. + """ + stats = Vehicle.objects.values('depot_id').annotate( + count=Count('id'), + total_capacity=Sum('capacity') + ).order_by('depot_id') + + return Response({'by_depot': stats}) From 55612394ef54a1c665bb1739f031b532b42e1aeb Mon Sep 17 00:00:00 2001 From: Ke-vin-S Date: Thu, 8 May 2025 20:10:33 +0530 Subject: [PATCH 16/36] Assignment service --- assignment/services/__init__.py | 0 assignment/services/assignment_planner.py | 91 +++++++++++++++ assignment/services/mappers.py | 14 +++ assignment/tests.py | 104 ----------------- assignment/tests/__init__.py | 0 assignment/tests/test_assignment_planner.py | 118 ++++++++++++++++++++ route_optimizer/models/vrp_input.py | 2 + 7 files changed, 225 insertions(+), 104 deletions(-) create mode 100644 assignment/services/__init__.py create mode 100644 assignment/services/assignment_planner.py create mode 100644 assignment/services/mappers.py delete mode 100644 assignment/tests.py create mode 100644 assignment/tests/__init__.py create mode 100644 assignment/tests/test_assignment_planner.py diff --git a/assignment/services/__init__.py b/assignment/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/assignment/services/assignment_planner.py b/assignment/services/assignment_planner.py new file mode 100644 index 0000000..f333d0f --- /dev/null +++ b/assignment/services/assignment_planner.py @@ -0,0 +1,91 @@ +import logging +from typing import List +from datetime import datetime + +from assignment.models.assignment import Assignment +from assignment.models.assignment_item import AssignmentItem +from assignment.services.mappers import map_vehicle_model +from fleet.models import Vehicle +from route_optimizer.services.vrp_solver import solve_cvrp +from shipments.models import Shipment +from route_optimizer.models.vrp_input import VRPInputBuilder, VRPCompiler, Location, DeliveryTask + +logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.DEBUG) + +class AssignmentPlanner: + def __init__(self, vehicles: List[Vehicle], shipments: List[Shipment]): + self.vehicles = vehicles + self.shipments = shipments + + def plan_assignments(self) -> List[Assignment]: + logger.info("Planning assignments started.") + builder = VRPInputBuilder() + + vehicle_map = {} + for v in self.vehicles: + logger.debug(f"Mapping vehicle: {v.vehicle_id}") + mapped_vehicle = map_vehicle_model(v) + builder.add_vehicle(mapped_vehicle) + vehicle_map[mapped_vehicle.id] = v + logger.info(f"{len(vehicle_map)} vehicles added to VRP input.") + + shipment_map = {} + for s in self.shipments: + logger.debug(f"Adding shipment: {s.shipment_id} (demand={s.demand})") + builder.add_delivery_task( + DeliveryTask( + id=str(s.id), + pickup=Location(lat=s.origin["lat"], lon=s.origin["lng"]), + delivery=Location(lat=s.destination["lat"], lon=s.destination["lng"]), + demand=s.demand, + ) + ) + shipment_map[str(s.id)] = s + logger.info(f"{len(shipment_map)} shipments added to VRP input.") + + vrp_input = VRPCompiler.compile(builder) + logger.debug(f"Compiled VRP input with {len(vrp_input.location_ids)} locations.") + + result = solve_cvrp(vrp_input) + logger.info("Optimizer finished solving.") + + if result["status"] != "success": + logger.error("Optimizer failed to find a solution.") + raise Exception("Optimization failed") + + assignments = [] + for i, route in enumerate(result["routes"]): + vehicle = self.vehicles[i] + logger.debug(f"Creating assignment for vehicle {vehicle.vehicle_id}, route: {route}") + assignment = Assignment.objects.create( + vehicle=vehicle, + total_load=sum( + vrp_input.demands[node] for node in route if vrp_input.demands[node] > 0 + ), + status='created' + ) + + seq = 1 + for node in route: + if node in vrp_input.task_index_map: + task_id, role = vrp_input.task_index_map[node] + shipment = shipment_map[task_id] + loc = shipment.destination if role == "delivery" else shipment.origin + + logger.debug(f"Adding {role} for shipment {shipment.shipment_id} at sequence {seq}") + AssignmentItem.objects.create( + assignment=assignment, + shipment=shipment, + delivery_sequence=seq, + delivery_location={ + "lat": loc["lat"], + "lng": loc["lng"], + } + ) + seq += 1 + + assignments.append(assignment) + + logger.info(f"{len(assignments)} assignments successfully created.") + return assignments diff --git a/assignment/services/mappers.py b/assignment/services/mappers.py new file mode 100644 index 0000000..1f187c7 --- /dev/null +++ b/assignment/services/mappers.py @@ -0,0 +1,14 @@ +from route_optimizer.models.vrp_input import Vehicle as VRPVehicle, Location + +def map_vehicle_model(vehicle_model): + if vehicle_model.depot_latitude is None or vehicle_model.depot_longitude is None: + raise ValueError(f"Vehicle {vehicle_model.vehicle_id} missing depot coordinates") + + return VRPVehicle( + id=vehicle_model.vehicle_id, + capacity=vehicle_model.capacity, + depot=Location( + lat=float(vehicle_model.depot_latitude), + lon=float(vehicle_model.depot_longitude) + ) + ) diff --git a/assignment/tests.py b/assignment/tests.py deleted file mode 100644 index 5183320..0000000 --- a/assignment/tests.py +++ /dev/null @@ -1,104 +0,0 @@ -from django.test import TestCase -from rest_framework.test import APIClient - -from assignment.models.assignment import Assignment -from assignment.models.assignment_item import AssignmentItem -from fleet.models import Vehicle -from shipments.models import Shipment - - -class AssignmentAPITest(TestCase): - def setUp(self): - self.client = APIClient() - self.vehicle = Vehicle.objects.create(vehicle_id="TRK001", capacity=100, status="available") - self.busy_vehicle = Vehicle.objects.create(vehicle_id="TRK002", capacity=80, status="assigned") - self.shipment1 = Shipment.objects.create( - shipment_id="SHP001", - order_id="ORD001", - origin={"lat": 6.9, "lng": 79.8}, - destination={"lat": 7.3, "lng": 80.6}, - status="pending" - ) - self.shipment2 = Shipment.objects.create( - shipment_id="SHP002", - order_id="ORD002", - origin={"lat": 6.9, "lng": 79.8}, - destination={"lat": 7.4, "lng": 80.5}, - status="pending" - ) - - def test_create_assignment_success(self): - payload = { - "deliveries": [ - {"shipment_id": self.shipment1.id, "location": {"lat": 7.3, "lng": 80.6}, "load": 40, "sequence": 1}, - {"shipment_id": self.shipment2.id, "location": {"lat": 7.4, "lng": 80.5}, "load": 30, "sequence": 2} - ] - } - response = self.client.post('/api/assignment/assignments/', payload, format='json') - self.assertEqual(response.status_code, 201) - self.assertEqual(response.data['total_load'], 70) - assignment_id = response.data['id'] - items = AssignmentItem.objects.filter(assignment_id=assignment_id) - self.assertEqual(items.count(), 2) - - def test_create_assignment_insufficient_capacity(self): - payload = { - "deliveries": [ - {"shipment_id": self.shipment1.id, "location": {"lat": 7.3, "lng": 80.6}, "load": 150, "sequence": 1} - ] - } - response = self.client.post('/api/assignment/assignments/', payload, format='json') - self.assertEqual(response.status_code, 400) - self.assertIn("No available vehicle", response.data['error']) - - def test_create_assignment_with_no_available_vehicle(self): - self.vehicle.status = "maintenance" - self.vehicle.save() - payload = { - "deliveries": [ - {"shipment_id": self.shipment1.id, "location": {"lat": 7.3, "lng": 80.6}, "load": 50, "sequence": 1} - ] - } - response = self.client.post('/api/assignment/assignments/', payload, format='json') - self.assertEqual(response.status_code, 400) - - def test_create_assignment_with_no_deliveries(self): - payload = {} - response = self.client.post('/api/assignment/assignments/', payload, format='json') - self.assertEqual(response.status_code, 400) - self.assertIn("Deliveries required", response.data['error']) - - def test_get_all_assignments(self): - assignment = Assignment.objects.create( - vehicle=self.vehicle, - total_load=50, - status='created' - ) - AssignmentItem.objects.create( - assignment=assignment, - shipment=self.shipment1, - delivery_sequence=1, - delivery_location={"lat": 7.3, "lng": 80.6} - ) - response = self.client.get('/api/assignment/assignments/') - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data), 1) - - def test_vehicle_marked_assigned_after_assignment(self): - payload = { - "deliveries": [ - {"shipment_id": self.shipment1.id, "location": {"lat": 7.3, "lng": 80.6}, "load": 50, "sequence": 1} - ] - } - self.client.post('/api/assignment/assignments/', payload, format='json') - self.vehicle.refresh_from_db() - self.assertEqual(self.vehicle.status, "assigned") - - def test_assignment_model_str(self): - assignment = Assignment.objects.create( - vehicle=self.vehicle, - total_load=50, - status='created' - ) - expected = f"Assignment #{assignment.id} to Vehicle {self.vehicle.vehicle_id}" - self.assertEqual(str(assignment), expected) diff --git a/assignment/tests/__init__.py b/assignment/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/assignment/tests/test_assignment_planner.py b/assignment/tests/test_assignment_planner.py new file mode 100644 index 0000000..de7f2cf --- /dev/null +++ b/assignment/tests/test_assignment_planner.py @@ -0,0 +1,118 @@ +from django.test import TestCase +from assignment.services.assignment_planner import AssignmentPlanner +from fleet.models import Vehicle +from shipments.models import Shipment +from assignment.models.assignment_item import AssignmentItem +import uuid + + +class AssignmentPlannerTestCase(TestCase): + def setUp(self): + self.vehicle1 = Vehicle.objects.create( + vehicle_id="TRK001", + capacity=100, + status="available", + depot_latitude=6.9, + depot_longitude=79.8 + ) + + self.vehicle2 = Vehicle.objects.create( + vehicle_id="TRK002", + capacity=80, + status="available", + depot_latitude=6.9, + depot_longitude=79.9 + ) + + self.shipment1 = Shipment.objects.create( + shipment_id=str(uuid.uuid4())[:12], + order_id="ORD001", + origin={"lat": 6.9, "lng": 79.8}, + destination={"lat": 7.2, "lng": 80.6}, + demand=40, + status="pending" + ) + self.shipment2 = Shipment.objects.create( + shipment_id=str(uuid.uuid4())[:12], + order_id="ORD002", + origin={"lat": 6.9, "lng": 79.9}, + destination={"lat": 7.3, "lng": 80.7}, + demand=30, + status="pending" + ) + self.shipment3 = Shipment.objects.create( + shipment_id=str(uuid.uuid4())[:12], + order_id="ORD003", + origin={"lat": 7.0, "lng": 79.9}, + destination={"lat": 7.4, "lng": 80.8}, + demand=50, + status="pending" + ) + + def test_assignments_created_successfully(self): + planner = AssignmentPlanner( + vehicles=[self.vehicle1, self.vehicle2], + shipments=[self.shipment1, self.shipment2] + ) + assignments = planner.plan_assignments() + self.assertLessEqual(len(assignments), 2) + self.assertGreaterEqual(AssignmentItem.objects.count(), 2) + + def test_vehicle_handles_multiple_tasks_within_capacity(self): + planner = AssignmentPlanner( + vehicles=[self.vehicle2], # capacity 80 + shipments=[self.shipment1, self.shipment3] # demands: 40 + 50 = 90 (individually valid) + ) + assignments = planner.plan_assignments() + self.assertEqual(len(assignments), 1) + self.assertGreaterEqual(assignments[0].items.count(), 2) + + def test_assignment_fails_due_to_individual_task_exceeding_capacity(self): + high_demand_1 = Shipment.objects.create( + shipment_id="HD001", + order_id="ORDHD1", + origin={"lat": 6.9, "lng": 79.8}, + destination={"lat": 7.3, "lng": 80.6}, + demand=90, + status="pending" + ) + + high_demand_2 = Shipment.objects.create( + shipment_id="HD002", + order_id="ORDHD2", + origin={"lat": 7.0, "lng": 79.9}, + destination={"lat": 7.4, "lng": 80.7}, + demand=95, + status="pending" + ) + + # vehicle2 only has 80 capacity + planner = AssignmentPlanner( + vehicles=[self.vehicle2], + shipments=[high_demand_1, high_demand_2] + ) + with self.assertRaises(Exception) as ctx: + planner.plan_assignments() + self.assertIn("Optimization failed", str(ctx.exception)) + + def test_no_vehicles_provided(self): + planner = AssignmentPlanner(vehicles=[], shipments=[self.shipment1]) + with self.assertRaises(AssertionError): + planner.plan_assignments() + + def test_no_shipments_provided(self): + planner = AssignmentPlanner(vehicles=[self.vehicle1], shipments=[]) + assignments = planner.plan_assignments() + self.assertEqual(assignments, []) + + def test_assignments_have_delivery_items(self): + planner = AssignmentPlanner( + vehicles=[self.vehicle1, self.vehicle2], + shipments=[self.shipment1, self.shipment2] + ) + assignments = planner.plan_assignments() + for assignment in assignments: + self.assertGreater( + assignment.items.count(), 0, + f"Assignment {assignment.id} should include at least one item" + ) diff --git a/route_optimizer/models/vrp_input.py b/route_optimizer/models/vrp_input.py index 2cffe4e..ade9f8c 100644 --- a/route_optimizer/models/vrp_input.py +++ b/route_optimizer/models/vrp_input.py @@ -50,6 +50,8 @@ def validate(self): n = len(self.location_ids) assert len(self.demands) == n, "Mismatch: demands vs location_ids" assert len(self.distance_matrix) == n, "Mismatch: matrix rows vs location_ids" + assert len(self.vehicles) > 0, "No vehicles defined" + assert len(self.vehicles) == self.num_vehicles, "Mismatch: vehicles vs num_vehicles" assert all(len(row) == n for row in self.distance_matrix), "Matrix must be square" for v in self.vehicles: depot_index = self.location_id_to_index.get(f"{v.id}_depot") From 2660d5355fc46614d4203cf9c04264930d15c0f3 Mon Sep 17 00:00:00 2001 From: Ke-vin-S Date: Thu, 8 May 2025 22:11:30 +0530 Subject: [PATCH 17/36] - get drivers and update their status endpoints --- fleet/services/__init__.py | 0 fleet/services/status_services.py | 19 +++++++ fleet/tests/test_vehicle_api.py | 43 ++++++++++++++ fleet/views/vehicle.py | 94 ++++++++++++++++++++----------- 4 files changed, 123 insertions(+), 33 deletions(-) create mode 100644 fleet/services/__init__.py create mode 100644 fleet/services/status_services.py diff --git a/fleet/services/__init__.py b/fleet/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fleet/services/status_services.py b/fleet/services/status_services.py new file mode 100644 index 0000000..0afb4af --- /dev/null +++ b/fleet/services/status_services.py @@ -0,0 +1,19 @@ +from django.utils import timezone +from fleet.models import Vehicle + +def update_vehicle_status(vehicle: Vehicle, new_status: str): + vehicle.status = new_status + vehicle.updated_at = timezone.now() + vehicle.save(update_fields=['status', 'updated_at']) + +def mark_vehicle_available(vehicle: Vehicle): + update_vehicle_status(vehicle, 'available') + +def mark_vehicle_assigned(vehicle: Vehicle): + update_vehicle_status(vehicle, 'assigned') + +def mark_vehicle_maintenance(vehicle: Vehicle): + update_vehicle_status(vehicle, 'maintenance') + +def mark_vehicle_out_of_service(vehicle: Vehicle): + update_vehicle_status(vehicle, 'out_of_service') diff --git a/fleet/tests/test_vehicle_api.py b/fleet/tests/test_vehicle_api.py index 17f86a1..502acb4 100644 --- a/fleet/tests/test_vehicle_api.py +++ b/fleet/tests/test_vehicle_api.py @@ -98,3 +98,46 @@ def test_update_vehicle_location(self): self.assertEqual(history.count(), 1) self.assertAlmostEqual(float(history[0].speed), 65.5) self.assertAlmostEqual(float(history[0].latitude), 42.123456) + + def test_list_all_vehicles(self): + response = self.client.get("/api/fleet/vehicles/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 3) + + def test_filter_by_status(self): + response = self.client.get("/api/fleet/vehicles/?status=assigned") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]['vehicle_id'], "TRK003") + + def test_ordering_by_updated_at(self): + response = self.client.get("/api/fleet/vehicles/?ordering=-updated_at") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(len(response.data) >= 1) + self.assertIn('updated_at', response.data[0]) + + def test_mark_vehicle_available(self): + response = self.client.post(f"/api/fleet/vehicles/{self.vehicle3.id}/mark_available/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.vehicle3.refresh_from_db() + self.assertEqual(self.vehicle3.status, 'available') + + def test_mark_vehicle_assigned(self): + response = self.client.post(f"/api/fleet/vehicles/{self.vehicle1.id}/mark_assigned/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.vehicle1.refresh_from_db() + self.assertEqual(self.vehicle1.status, 'assigned') + + def test_change_status_to_available(self): + response = self.client.post(f"/api/fleet/vehicles/{self.vehicle2.id}/change_status/", { + "status": "available" + }, format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.vehicle2.refresh_from_db() + self.assertEqual(self.vehicle2.status, "available") + + def test_change_status_invalid(self): + response = self.client.post(f"/api/fleet/vehicles/{self.vehicle1.id}/change_status/", { + "status": "nonexistent" + }, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/fleet/views/vehicle.py b/fleet/views/vehicle.py index 1ece585..8fb24b0 100644 --- a/fleet/views/vehicle.py +++ b/fleet/views/vehicle.py @@ -1,6 +1,8 @@ import os import django +from fleet.services.status_services import mark_vehicle_assigned, mark_vehicle_available, update_vehicle_status + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'logistics_core.settings') django.setup() @@ -58,9 +60,34 @@ def get_queryset(self): queryset = queryset.filter(status='available') if depot := params.get('depot_id'): queryset = queryset.filter(depot_id=depot) - + queryset = queryset.order_by('-updated_at') return queryset + @action(detail=True, methods=['post']) + def mark_available(self, request, pk=None): + vehicle = self.get_object() + mark_vehicle_available(vehicle) + return Response({'vehicle_id': vehicle.vehicle_id, 'status': 'available'}) + + @action(detail=True, methods=['post']) + def mark_assigned(self, request, pk=None): + vehicle = self.get_object() + mark_vehicle_assigned(vehicle) + return Response({'vehicle_id': vehicle.vehicle_id, 'status': 'assigned'}) + + # Admin only + @action(detail=True, methods=['post']) + def change_status(self, request, pk=None): + vehicle = self.get_object() + new_status = request.data.get('status') + + valid_statuses = dict(Vehicle.STATUS_CHOICES).keys() + if new_status not in valid_statuses: + return Response({'error': f'Invalid status. Must be one of {list(valid_statuses)}'}, status=400) + + update_vehicle_status(vehicle, new_status) + return Response({'vehicle_id': vehicle.vehicle_id, 'status': new_status}) + @action(detail=True, methods=['post']) def update_location(self, request, pk=None): vehicle = self.get_object() @@ -85,38 +112,6 @@ def update_location(self, request, pk=None): except Exception as e: return Response({'error': str(e)}, status=400) - @action(detail=True, methods=['post']) - def change_status(self, request, pk=None): - vehicle = self.get_object() - new_status = request.data.get('status') - - if not new_status: - return Response({'error': 'Status is required'}, status=400) - - if new_status not in dict(Vehicle.STATUS_CHOICES): - return Response({'error': f'Invalid status: {new_status}'}, status=400) - - if new_status == 'maintenance' and vehicle.status != 'maintenance' and settings.ENABLE_FLEET_EXTENDED_MODELS: - maintenance_type = request.data.get('maintenance_type', 'routine') - description = request.data.get('description', 'Routine maintenance') - scheduled_date = request.data.get('scheduled_date', timezone.now().date().isoformat()) - try: - scheduled_date = datetime.fromisoformat(scheduled_date).date() - except ValueError: - scheduled_date = timezone.now().date() - - MaintenanceRecord.objects.create( - vehicle=vehicle, - maintenance_type=maintenance_type, - description=description, - scheduled_date=scheduled_date, - status='in_progress' - ) - - vehicle.status = new_status - vehicle.save(update_fields=['status', 'updated_at']) - return Response(VehicleSerializer(vehicle).data) - @action(detail=True, methods=['post']) def assign_depot(self, request, pk=None): """ @@ -197,3 +192,36 @@ def depot_stats(self, request): ).order_by('depot_id') return Response({'by_depot': stats}) + + # # To be implemented with maintenance part + # @action(detail=True, methods=['post']) + # def change_status(self, request, pk=None): + # vehicle = self.get_object() + # new_status = request.data.get('status') + # + # if not new_status: + # return Response({'error': 'Status is required'}, status=400) + # + # if new_status not in dict(Vehicle.STATUS_CHOICES): + # return Response({'error': f'Invalid status: {new_status}'}, status=400) + # + # if new_status == 'maintenance' and vehicle.status != 'maintenance' and settings.ENABLE_FLEET_EXTENDED_MODELS: + # maintenance_type = request.data.get('maintenance_type', 'routine') + # description = request.data.get('description', 'Routine maintenance') + # scheduled_date = request.data.get('scheduled_date', timezone.now().date().isoformat()) + # try: + # scheduled_date = datetime.fromisoformat(scheduled_date).date() + # except ValueError: + # scheduled_date = timezone.now().date() + # + # MaintenanceRecord.objects.create( + # vehicle=vehicle, + # maintenance_type=maintenance_type, + # description=description, + # scheduled_date=scheduled_date, + # status='in_progress' + # ) + # + # vehicle.status = new_status + # vehicle.save(update_fields=['status', 'updated_at']) + # return Response(VehicleSerializer(vehicle).data) \ No newline at end of file From 7238c015c5858d1d8e069b0d95d2e594e10d6fbe Mon Sep 17 00:00:00 2001 From: Ke-vin-S Date: Fri, 9 May 2025 06:44:00 +0530 Subject: [PATCH 18/36] Change vehicle status after assigning a task --- assignment/services/assignment_planner.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/assignment/services/assignment_planner.py b/assignment/services/assignment_planner.py index f333d0f..49d6fea 100644 --- a/assignment/services/assignment_planner.py +++ b/assignment/services/assignment_planner.py @@ -54,6 +54,7 @@ def plan_assignments(self) -> List[Assignment]: logger.error("Optimizer failed to find a solution.") raise Exception("Optimization failed") + # Implicit mapping of vehicle in this and vehichle in vrp solver assignments = [] for i, route in enumerate(result["routes"]): vehicle = self.vehicles[i] @@ -66,6 +67,10 @@ def plan_assignments(self) -> List[Assignment]: status='created' ) + # Update vehicle status, not using methods in the vehicle model but ORM directly + vehicle.status = "assigned" + vehicle.save(update_fields=["status"]) + seq = 1 for node in route: if node in vrp_input.task_index_map: From 6eef678a33ec626f20a6fa59fe9a4e6deb8c3970 Mon Sep 17 00:00:00 2001 From: Ke-vin-S Date: Fri, 9 May 2025 06:50:39 +0530 Subject: [PATCH 19/36] Added role to assignment_item so the pickup or delivery status can be easily identified --- .../migrations/0002_assignmentitem_role.py | 18 ++++++++++++++++++ assignment/models/assignment_item.py | 12 +++++++++--- assignment/services/assignment_planner.py | 3 ++- assignment/views.py | 8 +++++++- 4 files changed, 36 insertions(+), 5 deletions(-) create mode 100644 assignment/migrations/0002_assignmentitem_role.py diff --git a/assignment/migrations/0002_assignmentitem_role.py b/assignment/migrations/0002_assignmentitem_role.py new file mode 100644 index 0000000..0b8a7d4 --- /dev/null +++ b/assignment/migrations/0002_assignmentitem_role.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2 on 2025-05-09 01:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('assignment', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='assignmentitem', + name='role', + field=models.CharField(choices=[('pickup', 'Pickup'), ('delivery', 'Delivery')], default='delivery', max_length=10), + ), + ] diff --git a/assignment/models/assignment_item.py b/assignment/models/assignment_item.py index c649b9c..3dd2351 100644 --- a/assignment/models/assignment_item.py +++ b/assignment/models/assignment_item.py @@ -1,15 +1,21 @@ from django.db import models - from assignment.models.assignment import Assignment from shipments.models import Shipment class AssignmentItem(models.Model): + ROLE_CHOICES = [ + ("pickup", "Pickup"), + ("delivery", "Delivery"), + ] + assignment = models.ForeignKey(Assignment, on_delete=models.CASCADE, related_name='items') shipment = models.ForeignKey(Shipment, on_delete=models.CASCADE) - delivery_sequence = models.PositiveIntegerField() # 1st, 2nd, 3rd drop, etc. + delivery_sequence = models.PositiveIntegerField() # 1st, 2nd, 3rd stop, etc. delivery_location = models.JSONField() # { "lat": ..., "lng": ... } + role = models.CharField(max_length=10, choices=ROLE_CHOICES, default="delivery") # NEW + is_delivered = models.BooleanField(default=False) delivered_at = models.DateTimeField(null=True, blank=True) @@ -17,4 +23,4 @@ class Meta: ordering = ['delivery_sequence'] def __str__(self): - return f"Shipment {self.shipment.id} in Assignment {self.assignment.id}" + return f"{self.role.capitalize()} for Shipment {self.shipment.id} in Assignment {self.assignment.id}" diff --git a/assignment/services/assignment_planner.py b/assignment/services/assignment_planner.py index 49d6fea..42c7b07 100644 --- a/assignment/services/assignment_planner.py +++ b/assignment/services/assignment_planner.py @@ -86,7 +86,8 @@ def plan_assignments(self) -> List[Assignment]: delivery_location={ "lat": loc["lat"], "lng": loc["lng"], - } + }, + role=role ) seq += 1 diff --git a/assignment/views.py b/assignment/views.py index 6abfff1..9c0593f 100644 --- a/assignment/views.py +++ b/assignment/views.py @@ -40,7 +40,12 @@ def create(self, request, *args, **kwargs): for delivery in deliveries: shipment_id = delivery.get("shipment_id") location = delivery.get("location") - sequence = delivery.get("sequence", 1) # fallback if sequence not provided + sequence = delivery.get("sequence", 1) + role = delivery.get("role") + + if role not in ["pickup", "delivery"]: + return Response({"error": f"Invalid role for shipment {shipment_id}. Must be 'pickup' or 'delivery'."}, + status=400) try: shipment = Shipment.objects.get(id=shipment_id) @@ -52,6 +57,7 @@ def create(self, request, *args, **kwargs): shipment=shipment, delivery_sequence=sequence, delivery_location=location, + role=role ) serializer = self.get_serializer(assignment) From a6417248531ca61b135d93e4542a488afc27dd72 Mon Sep 17 00:00:00 2001 From: Ke-vin-S Date: Fri, 9 May 2025 07:44:42 +0530 Subject: [PATCH 20/36] Get assignment by vehicle id --- assignment/serializers.py | 9 --- assignment/serializers/__init__.py | 0 assignment/serializers/assignment.py | 11 ++++ assignment/serializers/assignment_item.py | 16 +++++ assignment/tests/test_assignment_api.py | 79 +++++++++++++++++++++++ assignment/views.py | 17 ++++- 6 files changed, 122 insertions(+), 10 deletions(-) delete mode 100644 assignment/serializers.py create mode 100644 assignment/serializers/__init__.py create mode 100644 assignment/serializers/assignment.py create mode 100644 assignment/serializers/assignment_item.py create mode 100644 assignment/tests/test_assignment_api.py diff --git a/assignment/serializers.py b/assignment/serializers.py deleted file mode 100644 index 6067b65..0000000 --- a/assignment/serializers.py +++ /dev/null @@ -1,9 +0,0 @@ -from rest_framework import serializers - -from assignment.models.assignment import Assignment - - -class AssignmentSerializer(serializers.ModelSerializer): - class Meta: - model = Assignment - fields = '__all__' diff --git a/assignment/serializers/__init__.py b/assignment/serializers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/assignment/serializers/assignment.py b/assignment/serializers/assignment.py new file mode 100644 index 0000000..5fc601a --- /dev/null +++ b/assignment/serializers/assignment.py @@ -0,0 +1,11 @@ +from rest_framework import serializers +from assignment.models.assignment import Assignment +from assignment.serializers.assignment_item import AssignmentItemSerializer + +class AssignmentSerializer(serializers.ModelSerializer): + items = AssignmentItemSerializer(many=True, read_only=True) + vehicle = serializers.CharField(source='vehicle.vehicle_id', read_only=True) # ✅ Fix here + + class Meta: + model = Assignment + fields = ['id', 'vehicle', 'total_load', 'status', 'items'] \ No newline at end of file diff --git a/assignment/serializers/assignment_item.py b/assignment/serializers/assignment_item.py new file mode 100644 index 0000000..d77b718 --- /dev/null +++ b/assignment/serializers/assignment_item.py @@ -0,0 +1,16 @@ +from rest_framework import serializers +from assignment.models.assignment_item import AssignmentItem +from shipments.models import Shipment + + +class ShipmentSerializer(serializers.ModelSerializer): + class Meta: + model = Shipment + fields = ['id', 'order_id', 'demand', 'status'] # Add more as needed + +class AssignmentItemSerializer(serializers.ModelSerializer): + shipment = ShipmentSerializer(read_only=True) + + class Meta: + model = AssignmentItem + fields = ['shipment', 'role', 'delivery_sequence', 'delivery_location', 'is_delivered', 'delivered_at'] diff --git a/assignment/tests/test_assignment_api.py b/assignment/tests/test_assignment_api.py new file mode 100644 index 0000000..da58a22 --- /dev/null +++ b/assignment/tests/test_assignment_api.py @@ -0,0 +1,79 @@ +from django.urls import reverse +from rest_framework.test import APITestCase +from rest_framework import status + +from fleet.models import Vehicle +from shipments.models import Shipment +from assignment.models.assignment import Assignment +from assignment.models.assignment_item import AssignmentItem + + +class AssignmentAPITests(APITestCase): + def setUp(self): + self.vehicle = Vehicle.objects.create( + vehicle_id="TRK001", + name="Truck 1", + capacity=1000, + status="available", + fuel_type="diesel" + ) + + self.shipment = Shipment.objects.create( + order_id="ORD001", + demand=500, + origin={"lat": 7.2, "lng": 80.1}, + destination={"lat": 7.3, "lng": 80.2}, + status="pending" + ) + + self.create_url = reverse("assignment-list") # Default DRF route for create + self.by_vehicle_url = lambda v_id: reverse("assignment-by-vehicle", kwargs={"vehicle_id": v_id}) + + def test_create_assignment(self): + payload = { + "deliveries": [ + { + "shipment_id": self.shipment.id, + "location": {"lat": 7.2, "lng": 80.1}, + "sequence": 1, + "load": 500, + "role": "pickup" + }, + { + "shipment_id": self.shipment.id, + "location": {"lat": 7.3, "lng": 80.2}, + "sequence": 2, + "load": 0, + "role": "delivery" + } + ] + } + + response = self.client.post(self.create_url, payload, format="json") + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Assignment.objects.count(), 1) + self.assertEqual(AssignmentItem.objects.count(), 2) + + assignment = Assignment.objects.first() + self.assertEqual(assignment.vehicle.vehicle_id, "TRK001") + self.assertEqual(assignment.total_load, 500) + + def test_get_assignment_by_vehicle(self): + # First create assignment + assignment = Assignment.objects.create( + vehicle=self.vehicle, + total_load=500, + status="created" + ) + AssignmentItem.objects.create( + assignment=assignment, + shipment=self.shipment, + delivery_sequence=1, + delivery_location={"lat": 7.2, "lng": 80.1}, + role="pickup" + ) + + response = self.client.get(self.by_vehicle_url("TRK001")) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["vehicle"], self.vehicle.vehicle_id) + self.assertEqual(len(response.data["items"]), 1) diff --git a/assignment/views.py b/assignment/views.py index 9c0593f..856333f 100644 --- a/assignment/views.py +++ b/assignment/views.py @@ -1,11 +1,12 @@ from rest_framework import viewsets, status +from rest_framework.decorators import action from rest_framework.response import Response from .models.assignment import Assignment from .models.assignment_item import AssignmentItem -from .serializers import AssignmentSerializer from fleet.models import Vehicle from shipments.models import Shipment +from .serializers.assignment import AssignmentSerializer class AssignmentViewSet(viewsets.ModelViewSet): @@ -62,3 +63,17 @@ def create(self, request, *args, **kwargs): serializer = self.get_serializer(assignment) return Response(serializer.data, status=status.HTTP_201_CREATED) + + @action(detail=False, methods=["get"], url_path="by-vehicle/(?P[^/.]+)") + def by_vehicle(self, request, vehicle_id=None): + try: + vehicle = Vehicle.objects.get(vehicle_id=vehicle_id) + except Vehicle.DoesNotExist: + return Response({"error": "Vehicle not found"}, status=404) + + assignment = Assignment.objects.filter(vehicle=vehicle).order_by('-id').first() + if not assignment: + return Response({"message": "No assignment found for this vehicle"}, status=404) + + serializer = self.get_serializer(assignment) + return Response(serializer.data) \ No newline at end of file From 7ab6350bf0bc676f1aef6a46cca0ba51de578816 Mon Sep 17 00:00:00 2001 From: Ke-vin-S Date: Fri, 9 May 2025 09:44:55 +0530 Subject: [PATCH 21/36] Endpoint for report when reaching endpoint --- assignment/serializers/assignment_item.py | 4 +- assignment/tests/test_assignment_api.py | 99 ++++++++++++++++++++++- assignment/urls.py | 2 +- assignment/views.py | 59 ++++++++++++-- 4 files changed, 151 insertions(+), 13 deletions(-) diff --git a/assignment/serializers/assignment_item.py b/assignment/serializers/assignment_item.py index d77b718..72c67a4 100644 --- a/assignment/serializers/assignment_item.py +++ b/assignment/serializers/assignment_item.py @@ -3,13 +3,13 @@ from shipments.models import Shipment -class ShipmentSerializer(serializers.ModelSerializer): +class ShipmentSerializerForAssignment(serializers.ModelSerializer): class Meta: model = Shipment fields = ['id', 'order_id', 'demand', 'status'] # Add more as needed class AssignmentItemSerializer(serializers.ModelSerializer): - shipment = ShipmentSerializer(read_only=True) + shipment = ShipmentSerializerForAssignment(read_only=True) class Meta: model = AssignmentItem diff --git a/assignment/tests/test_assignment_api.py b/assignment/tests/test_assignment_api.py index da58a22..25fcf5e 100644 --- a/assignment/tests/test_assignment_api.py +++ b/assignment/tests/test_assignment_api.py @@ -1,3 +1,5 @@ +import uuid + from django.urls import reverse from rest_framework.test import APITestCase from rest_framework import status @@ -19,6 +21,7 @@ def setUp(self): ) self.shipment = Shipment.objects.create( + shipment_id=str(uuid.uuid4()), order_id="ORD001", demand=500, origin={"lat": 7.2, "lng": 80.1}, @@ -26,7 +29,7 @@ def setUp(self): status="pending" ) - self.create_url = reverse("assignment-list") # Default DRF route for create + self.create_url = reverse("assignment-list") self.by_vehicle_url = lambda v_id: reverse("assignment-by-vehicle", kwargs={"vehicle_id": v_id}) def test_create_assignment(self): @@ -59,7 +62,6 @@ def test_create_assignment(self): self.assertEqual(assignment.total_load, 500) def test_get_assignment_by_vehicle(self): - # First create assignment assignment = Assignment.objects.create( vehicle=self.vehicle, total_load=500, @@ -77,3 +79,96 @@ def test_get_assignment_by_vehicle(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data["vehicle"], self.vehicle.vehicle_id) self.assertEqual(len(response.data["items"]), 1) + + def test_arrival_at_sequence_returns_correct_actions(self): + assignment = Assignment.objects.create( + vehicle=self.vehicle, + total_load=500, + status="created" + ) + AssignmentItem.objects.create( + assignment=assignment, + shipment=self.shipment, + delivery_sequence=1, + delivery_location={"lat": 7.2, "lng": 80.1}, + role="pickup" + ) + AssignmentItem.objects.create( + assignment=assignment, + shipment=self.shipment, + delivery_sequence=2, + delivery_location={"lat": 7.3, "lng": 80.2}, + role="delivery" + ) + + # arrive_url = reverse("assignment-arrive-sequence", kwargs={"pk": assignment.pk, "sequence": 2}) + arrive_url = f"/api/assignment/assignments/{assignment.pk}/arrive/sequence/2/" + response = self.client.post(arrive_url, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["location"], {"lat": 7.3, "lng": 80.2}) + self.assertEqual(len(response.data["actions"]), 1) + self.assertEqual(response.data["actions"][0]["role"], "delivery") + self.assertEqual(response.data["actions"][0]["shipment_id"], self.shipment.id) + + def test_arrival_with_multiple_actions_at_same_location(self): + assignment = Assignment.objects.create( + vehicle=self.vehicle, + total_load=800, + status="created" + ) + + # Add another shipment + shipment2 = Shipment.objects.create( + shipment_id=str(uuid.uuid4()), + order_id="ORD002", + demand=300, + origin={"lat": 7.1, "lng": 80.0}, + destination={"lat": 7.3, "lng": 80.2}, # SAME location as the first delivery + status="pending" + ) + + # First shipment's delivery + AssignmentItem.objects.create( + assignment=assignment, + shipment=self.shipment, + delivery_sequence=2, + delivery_location={"lat": 7.3, "lng": 80.2}, + role="delivery" + ) + + # Second shipment's delivery — same place + AssignmentItem.objects.create( + assignment=assignment, + shipment=shipment2, + delivery_sequence=3, + delivery_location={"lat": 7.3, "lng": 80.2}, + role="delivery" + ) + + # Call the arrival endpoint at sequence 2 (first of the two) + arrive_url = f"/api/assignment/assignments/{assignment.pk}/arrive/sequence/2/" + response = self.client.post(arrive_url, format="json") + print(response.data) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["location"], {"lat": 7.3, "lng": 80.2}) + self.assertEqual(len(response.data["actions"]), 2) + + roles = [a["role"] for a in response.data["actions"]] + shipment_ids = [a["shipment_id"] for a in response.data["actions"]] + + self.assertIn("delivery", roles) + self.assertIn(self.shipment.id, shipment_ids) + self.assertIn(shipment2.id, shipment_ids) + + def test_arrival_with_invalid_sequence_returns_404(self): + assignment = Assignment.objects.create( + vehicle=self.vehicle, + total_load=500, + status="created" + ) + # arrive_url = reverse("assignment-arrive-sequence", kwargs={"pk": assignment.pk, "sequence": 99}) + arrive_url = f"/api/assignment/assignments/{assignment.pk}/arrive/sequence/99/" + response = self.client.post(arrive_url, format="json") + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) diff --git a/assignment/urls.py b/assignment/urls.py index d1d1500..8c4dd67 100644 --- a/assignment/urls.py +++ b/assignment/urls.py @@ -3,7 +3,7 @@ from .views import AssignmentViewSet router = DefaultRouter() -router.register(r'assignments', AssignmentViewSet) +router.register(r'assignments', AssignmentViewSet, basename='assignment') urlpatterns = [ path('', include(router.urls)), diff --git a/assignment/views.py b/assignment/views.py index 856333f..becb0ae 100644 --- a/assignment/views.py +++ b/assignment/views.py @@ -1,10 +1,11 @@ +from django.utils import timezone from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.response import Response from .models.assignment import Assignment from .models.assignment_item import AssignmentItem -from fleet.models import Vehicle +from fleet.models import Vehicle, VehicleLocation from shipments.models import Shipment from .serializers.assignment import AssignmentSerializer @@ -18,26 +19,20 @@ def create(self, request, *args, **kwargs): if not deliveries: return Response({"error": "Deliveries required"}, status=400) - # Calculate total load total_load = sum(d.get("load", 0) for d in deliveries) - - # Find an available vehicle that can handle the load vehicle = Vehicle.objects.filter(status="available", capacity__gte=total_load).first() if not vehicle: return Response({"error": "No available vehicle for the load"}, status=400) - # Update vehicle status vehicle.status = "assigned" vehicle.save() - # Create Assignment assignment = Assignment.objects.create( vehicle=vehicle, total_load=total_load, status='created' ) - # Create AssignmentItem entries for delivery in deliveries: shipment_id = delivery.get("shipment_id") location = delivery.get("location") @@ -76,4 +71,52 @@ def by_vehicle(self, request, vehicle_id=None): return Response({"message": "No assignment found for this vehicle"}, status=404) serializer = self.get_serializer(assignment) - return Response(serializer.data) \ No newline at end of file + return Response(serializer.data) + + @action(detail=True, methods=['post'], url_path='arrive/sequence/(?P[0-9]+)') + def mark_arrival(self, request, pk=None, sequence=None): + assignment = self.get_object() + vehicle = assignment.vehicle + sequence = int(sequence) + + try: + current_item = assignment.items.get(delivery_sequence=sequence) + except AssignmentItem.DoesNotExist: + return Response({"error": f"No assignment item found at sequence {sequence}"}, status=404) + + location = current_item.delivery_location + lat, lng = location.get("lat"), location.get("lng") + + if lat is None or lng is None: + return Response({"error": "Location data is missing in assignment item"}, status=400) + + vehicle.update_location(lat, lng) + VehicleLocation.objects.create( + vehicle=vehicle, + latitude=lat, + longitude=lng, + ) + + items_at_location = assignment.items.filter( + delivery_location=location, + delivery_sequence__gte=sequence + ).order_by("delivery_sequence") + + grouped = [ + { + "assignment_item_id": item.id, + "role": item.role, + "shipment_id": item.shipment.id, + "shipment_status": item.shipment.status, + "location": item.delivery_location, + "is_delivered": item.is_delivered + } + for item in items_at_location + ] + + return Response({ + "vehicle": vehicle.vehicle_id, + "arrived_at": timezone.now(), + "location": location, + "actions": grouped + }) From 445b791e89aeaf9d54e895ac811a86d95409b932 Mon Sep 17 00:00:00 2001 From: Ke-vin-S Date: Fri, 9 May 2025 09:57:10 +0530 Subject: [PATCH 22/36] Endpoint to confirm pickup and delivery --- assignment/models/assignment_item.py | 1 + assignment/tests/test_assignment_complete.py | 91 ++++++++++++++++++++ assignment/views.py | 30 +++++++ 3 files changed, 122 insertions(+) create mode 100644 assignment/tests/test_assignment_complete.py diff --git a/assignment/models/assignment_item.py b/assignment/models/assignment_item.py index 3dd2351..8c2236c 100644 --- a/assignment/models/assignment_item.py +++ b/assignment/models/assignment_item.py @@ -16,6 +16,7 @@ class AssignmentItem(models.Model): role = models.CharField(max_length=10, choices=ROLE_CHOICES, default="delivery") # NEW + # TODO: rename these fields is_delivered = models.BooleanField(default=False) delivered_at = models.DateTimeField(null=True, blank=True) diff --git a/assignment/tests/test_assignment_complete.py b/assignment/tests/test_assignment_complete.py new file mode 100644 index 0000000..fcbe86c --- /dev/null +++ b/assignment/tests/test_assignment_complete.py @@ -0,0 +1,91 @@ +import uuid +from django.utils import timezone +from django.urls import reverse +from rest_framework.test import APITestCase +from rest_framework import status + +from fleet.models import Vehicle +from shipments.models import Shipment +from assignment.models.assignment import Assignment +from assignment.models.assignment_item import AssignmentItem + + +class AssignmentActionCompletionTests(APITestCase): + def setUp(self): + self.vehicle = Vehicle.objects.create( + vehicle_id="TRK001", + name="Truck 1", + capacity=1000, + status="available", + fuel_type="diesel" + ) + + self.shipment = Shipment.objects.create( + shipment_id=str(uuid.uuid4()), + order_id="ORD001", + demand=500, + origin={"lat": 7.2, "lng": 80.1}, + destination={"lat": 7.3, "lng": 80.2}, + status="in_transit" + ) + + self.assignment = Assignment.objects.create( + vehicle=self.vehicle, + total_load=500, + status="created" + ) + + self.pickup_item = AssignmentItem.objects.create( + assignment=self.assignment, + shipment=self.shipment, + delivery_sequence=1, + delivery_location=self.shipment.origin, + role="pickup", + is_delivered=False + ) + + self.delivery_item = AssignmentItem.objects.create( + assignment=self.assignment, + shipment=self.shipment, + delivery_sequence=2, + delivery_location=self.shipment.destination, + role="delivery", + is_delivered=False + ) + + def test_confirm_delivery_action_successfully(self): + url = f"/api/assignment/assignments/{self.assignment.id}/actions/{self.delivery_item.id}/complete/" + response = self.client.post(url, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["message"], "Delivery confirmed") + self.assertEqual(response.data["shipment_id"], self.shipment.id) + self.assertEqual(response.data["new_status"], "delivered") + + def test_confirm_pickup_action_successfully(self): + self.shipment.status = "scheduled" + self.shipment.save() + + url = f"/api/assignment/assignments/{self.assignment.id}/actions/{self.pickup_item.id}/complete/" + response = self.client.post(url, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["message"], "Pickup confirmed") + self.assertEqual(response.data["shipment_id"], self.shipment.id) + self.assertEqual(response.data["new_status"], "in_transit") + + def test_confirm_action_invalid_assignment_item(self): + url = f"/api/assignment/assignments/{self.assignment.id}/actions/9999/complete/" + response = self.client.post(url, format="json") + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_confirm_already_completed_action(self): + self.delivery_item.is_delivered = True + self.delivery_item.delivered_at = timezone.now() + self.delivery_item.save() + + url = f"/api/assignment/assignments/{self.assignment.id}/actions/{self.delivery_item.id}/complete/" + response = self.client.post(url, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["message"], "Already marked complete") diff --git a/assignment/views.py b/assignment/views.py index becb0ae..b1f31e1 100644 --- a/assignment/views.py +++ b/assignment/views.py @@ -120,3 +120,33 @@ def mark_arrival(self, request, pk=None, sequence=None): "location": location, "actions": grouped }) + + @action(detail=True, methods=["post"], url_path="actions/(?P[0-9]+)/complete") + def mark_action_complete(self, request, pk=None, item_id=None): + try: + assignment = self.get_object() + item = assignment.items.get(id=item_id) + except AssignmentItem.DoesNotExist: + return Response({"error": "Assignment item not found"}, status=404) + + if item.is_delivered: + return Response({"message": "Already marked complete"}, status=200) + + item.is_delivered = True + item.delivered_at = timezone.now() + item.save(update_fields=["is_delivered", "delivered_at"]) + + # Optional: update shipment status + if item.role == "delivery": + item.shipment.mark_delivered() + elif item.role == "pickup": + item.shipment.mark_dispatched() + item.shipment.mark_in_transit() + item.shipment.save() + + return Response({ + "message": f"{item.role.title()} confirmed", + "shipment_id": item.shipment.id, + "new_status": item.shipment.status, + "timestamp": item.delivered_at + }, status=200) From 01c646b207eb7a4c4d9b399b8a89b392fdc79e43 Mon Sep 17 00:00:00 2001 From: Kevin Sanjula Date: Fri, 9 May 2025 10:02:03 +0530 Subject: [PATCH 23/36] Update assignment/services/assignment_planner.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- assignment/services/assignment_planner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assignment/services/assignment_planner.py b/assignment/services/assignment_planner.py index 42c7b07..3e2b7cd 100644 --- a/assignment/services/assignment_planner.py +++ b/assignment/services/assignment_planner.py @@ -54,7 +54,7 @@ def plan_assignments(self) -> List[Assignment]: logger.error("Optimizer failed to find a solution.") raise Exception("Optimization failed") - # Implicit mapping of vehicle in this and vehichle in vrp solver + # Implicit mapping of vehicle in this and vehicle in vrp solver assignments = [] for i, route in enumerate(result["routes"]): vehicle = self.vehicles[i] From 63b74e113d56232bf4255c9e07847ec6c70ed2a7 Mon Sep 17 00:00:00 2001 From: Kevin Sanjula Date: Fri, 9 May 2025 10:02:10 +0530 Subject: [PATCH 24/36] Update assignment/serializers/assignment.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- assignment/serializers/assignment.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assignment/serializers/assignment.py b/assignment/serializers/assignment.py index 5fc601a..2fe7bd2 100644 --- a/assignment/serializers/assignment.py +++ b/assignment/serializers/assignment.py @@ -4,7 +4,7 @@ class AssignmentSerializer(serializers.ModelSerializer): items = AssignmentItemSerializer(many=True, read_only=True) - vehicle = serializers.CharField(source='vehicle.vehicle_id', read_only=True) # ✅ Fix here + vehicle = serializers.CharField(source='vehicle.vehicle_id', read_only=True) class Meta: model = Assignment From 0e93e52c11b786878fd83377c99dded0d3d42889 Mon Sep 17 00:00:00 2001 From: Kevin Sanjula Date: Fri, 9 May 2025 10:02:20 +0530 Subject: [PATCH 25/36] Update assignment/tests/test_assignment_api.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- assignment/tests/test_assignment_api.py | 1 - 1 file changed, 1 deletion(-) diff --git a/assignment/tests/test_assignment_api.py b/assignment/tests/test_assignment_api.py index 25fcf5e..14e8dda 100644 --- a/assignment/tests/test_assignment_api.py +++ b/assignment/tests/test_assignment_api.py @@ -149,7 +149,6 @@ def test_arrival_with_multiple_actions_at_same_location(self): # Call the arrival endpoint at sequence 2 (first of the two) arrive_url = f"/api/assignment/assignments/{assignment.pk}/arrive/sequence/2/" response = self.client.post(arrive_url, format="json") - print(response.data) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data["location"], {"lat": 7.3, "lng": 80.2}) From f1e6dd3bba9587ac953f86fa2309f278c5f4a570 Mon Sep 17 00:00:00 2001 From: Kevin Sanjula Date: Fri, 9 May 2025 10:05:53 +0530 Subject: [PATCH 26/36] Update assignment/models/assignment_item.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- assignment/models/assignment_item.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/assignment/models/assignment_item.py b/assignment/models/assignment_item.py index 8c2236c..dadb016 100644 --- a/assignment/models/assignment_item.py +++ b/assignment/models/assignment_item.py @@ -16,7 +16,8 @@ class AssignmentItem(models.Model): role = models.CharField(max_length=10, choices=ROLE_CHOICES, default="delivery") # NEW - # TODO: rename these fields + # TODO: Consider renaming 'is_delivered' and 'delivered_at' for better clarity. + # Example: 'is_delivered' -> 'has_been_delivered', 'delivered_at' -> 'delivery_timestamp'. is_delivered = models.BooleanField(default=False) delivered_at = models.DateTimeField(null=True, blank=True) From 1109391838f52f8835f38f4746f812e58dab6339 Mon Sep 17 00:00:00 2001 From: Ke-vin-S Date: Fri, 9 May 2025 11:02:52 +0530 Subject: [PATCH 27/36] Moved assignment endpoints to /api/assignments --- assignment/tests/test_assignment_api.py | 4 ++-- assignment/tests/test_assignment_complete.py | 8 ++++---- assignment/urls.py | 2 +- logistics_core/urls.py | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/assignment/tests/test_assignment_api.py b/assignment/tests/test_assignment_api.py index 14e8dda..f91c89e 100644 --- a/assignment/tests/test_assignment_api.py +++ b/assignment/tests/test_assignment_api.py @@ -102,7 +102,7 @@ def test_arrival_at_sequence_returns_correct_actions(self): ) # arrive_url = reverse("assignment-arrive-sequence", kwargs={"pk": assignment.pk, "sequence": 2}) - arrive_url = f"/api/assignment/assignments/{assignment.pk}/arrive/sequence/2/" + arrive_url = f"/api/assignments/{assignment.pk}/arrive/sequence/2/" response = self.client.post(arrive_url, format="json") self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -147,7 +147,7 @@ def test_arrival_with_multiple_actions_at_same_location(self): ) # Call the arrival endpoint at sequence 2 (first of the two) - arrive_url = f"/api/assignment/assignments/{assignment.pk}/arrive/sequence/2/" + arrive_url = f"/api/assignments/{assignment.pk}/arrive/sequence/2/" response = self.client.post(arrive_url, format="json") self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/assignment/tests/test_assignment_complete.py b/assignment/tests/test_assignment_complete.py index fcbe86c..a36821a 100644 --- a/assignment/tests/test_assignment_complete.py +++ b/assignment/tests/test_assignment_complete.py @@ -54,7 +54,7 @@ def setUp(self): ) def test_confirm_delivery_action_successfully(self): - url = f"/api/assignment/assignments/{self.assignment.id}/actions/{self.delivery_item.id}/complete/" + url = f"/api/assignments/{self.assignment.id}/actions/{self.delivery_item.id}/complete/" response = self.client.post(url, format="json") self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -66,7 +66,7 @@ def test_confirm_pickup_action_successfully(self): self.shipment.status = "scheduled" self.shipment.save() - url = f"/api/assignment/assignments/{self.assignment.id}/actions/{self.pickup_item.id}/complete/" + url = f"/api/assignments/{self.assignment.id}/actions/{self.pickup_item.id}/complete/" response = self.client.post(url, format="json") self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -75,7 +75,7 @@ def test_confirm_pickup_action_successfully(self): self.assertEqual(response.data["new_status"], "in_transit") def test_confirm_action_invalid_assignment_item(self): - url = f"/api/assignment/assignments/{self.assignment.id}/actions/9999/complete/" + url = f"/api/assignments/{self.assignment.id}/actions/9999/complete/" response = self.client.post(url, format="json") self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) @@ -84,7 +84,7 @@ def test_confirm_already_completed_action(self): self.delivery_item.delivered_at = timezone.now() self.delivery_item.save() - url = f"/api/assignment/assignments/{self.assignment.id}/actions/{self.delivery_item.id}/complete/" + url = f"/api/assignments/{self.assignment.id}/actions/{self.delivery_item.id}/complete/" response = self.client.post(url, format="json") self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/assignment/urls.py b/assignment/urls.py index 8c4dd67..fe581a8 100644 --- a/assignment/urls.py +++ b/assignment/urls.py @@ -3,7 +3,7 @@ from .views import AssignmentViewSet router = DefaultRouter() -router.register(r'assignments', AssignmentViewSet, basename='assignment') +router.register(r'', AssignmentViewSet, basename='assignment') urlpatterns = [ path('', include(router.urls)), diff --git a/logistics_core/urls.py b/logistics_core/urls.py index e80e3f6..1dcb2ee 100644 --- a/logistics_core/urls.py +++ b/logistics_core/urls.py @@ -33,7 +33,7 @@ urlpatterns = [ path('admin/', admin.site.urls), path('api/fleet/', include('fleet.urls')), - path('api/assignment/', include('assignment.urls')), + path('api/assignments/', include('assignment.urls')), path('swagger/', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'), path('api/shipments/', include('shipments.urls')), ] From 236147443f6d9a7447aa354ccab135bef1c989ec Mon Sep 17 00:00:00 2001 From: moonlander101 <114925949+moonlander101@users.noreply.github.com> Date: Fri, 9 May 2025 22:14:14 +0530 Subject: [PATCH 28/36] Added cors middleware with allowed host to localhost:4200 --- logistics_core/settings.py | 7 +++++++ requirements.txt | 3 ++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/logistics_core/settings.py b/logistics_core/settings.py index 36fb171..ac55724 100644 --- a/logistics_core/settings.py +++ b/logistics_core/settings.py @@ -44,9 +44,12 @@ 'shipments', 'drf_yasg', 'route_optimizer', + 'corsheaders', ] MIDDLEWARE = [ + 'corsheaders.middleware.CorsMiddleware', + 'django.middleware.common.CommonMiddleware', 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', @@ -133,3 +136,7 @@ # kafka settings KAFKA_BROKER_URL = "localhost:9092" + +CORS_ALLOWED_ORIGINS = [ + "http://localhost:4200", +] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index a74e3f6..9d32fb0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,4 +21,5 @@ six==1.17.0 sqlparse==0.5.3 tzdata==2025.2 uritemplate==4.1.1 -confluent-kafka~=2.10.0 \ No newline at end of file +confluent-kafka~=2.10.0 +django-cors-headers \ No newline at end of file From 8fd54d3c16266607e7269eb0ed2f1b9977bfa929 Mon Sep 17 00:00:00 2001 From: Ke-vin-S Date: Sat, 10 May 2025 09:00:46 +0530 Subject: [PATCH 29/36] Dockerfile set up --- .env.example | 3 +++ Dockerfile | 36 ++++++++++++++++++++++++++++++++++++ docker-compose.yml | 17 ++++++++++++++++- entrypoint.sh | 10 ++++++++++ 4 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 entrypoint.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f106e40 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +LOGISTICS_SERVICE_PORT=8002 +DJANGO_PORT=8000 +KAFKA_BROKER_URL=kafka:9092 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..16414e2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +# Use official Python slim image +FROM python:3.12-slim + +LABEL maintainer="kevin" + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV DJANGO_PORT=8000 + +# Set working directory +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + build-essential \ + libpq-dev \ + curl \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy project files +COPY . . + +# Copy entrypoint script +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# Expose port (match DJANGO_PORT env) +EXPOSE ${DJANGO_PORT} + +# Run entrypoint script +ENTRYPOINT ["/entrypoint.sh"] diff --git a/docker-compose.yml b/docker-compose.yml index ead4566..db963f6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,5 @@ version: '3.8' + services: zookeeper: image: confluentinc/cp-zookeeper:7.4.0 @@ -14,5 +15,19 @@ services: KAFKA_BROKER_ID: 1 KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT - KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092 + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092 KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + depends_on: + - zookeeper + + logistics-service: + build: . + container_name: logistics-service + ports: + - "${LOGISTICS_SERVICE_PORT:-8002}:8000" + environment: + - DJANGO_PORT=8000 + - KAFKA_BROKER_URL=kafka:9092 + env_file: .env + depends_on: + - kafka diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..e30b760 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,10 @@ +#!/bin/sh + +echo "Running makemigrations..." +python manage.py makemigrations --noinput + +echo "Running migrate..." +python manage.py migrate --noinput + +echo "Starting Django server on port ${DJANGO_PORT}..." +python manage.py runserver 0.0.0.0:${DJANGO_PORT} From 7bf66dcd86eafcf7df024835a2d1bb6494d46694 Mon Sep 17 00:00:00 2001 From: Ke-vin-S Date: Sat, 10 May 2025 10:25:52 +0530 Subject: [PATCH 30/36] Set up environment --- .github/workflows/tests.yml | 9 +++++--- logistics_core/settings.py | 5 ++++- order_simulator.py | 25 +++++++++++++++++------ requirements.txt | 5 +++-- shipments/tests/test_integration_kafka.py | 3 ++- 5 files changed, 34 insertions(+), 13 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index dee0d57..1c7c83c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -45,12 +45,15 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r requirements.txt + pip install --no-cache-dir -r requirements.txt - name: Wait for Kafka to be ready run: | - echo "Waiting for Kafka..." - sleep 20 # adjust based on startup time + for i in {1..10}; do + kafka-topics --bootstrap-server localhost:9092 --list && break + echo "Waiting for Kafka..." + sleep 5 + done - name: Run Django tests (including Kafka) run: | diff --git a/logistics_core/settings.py b/logistics_core/settings.py index ac55724..f6c2c33 100644 --- a/logistics_core/settings.py +++ b/logistics_core/settings.py @@ -135,7 +135,10 @@ ENABLE_FLEET_EXTENDED_MODELS = False # kafka settings -KAFKA_BROKER_URL = "localhost:9092" +import os + +KAFKA_BROKER_URL = os.getenv("KAFKA_BROKER_URL", "localhost:9092") + CORS_ALLOWED_ORIGINS = [ "http://localhost:4200", diff --git a/order_simulator.py b/order_simulator.py index d362f6b..8e6c1a3 100644 --- a/order_simulator.py +++ b/order_simulator.py @@ -1,14 +1,27 @@ -from confluent_kafka import Producer +import os +import django import json +from confluent_kafka import Producer + +# Setup Django (assuming this file is at the project root level) +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'logistics_core.settings') +django.setup() -producer = Producer({'bootstrap.servers': 'localhost:9092'}) +from django.conf import settings + +# Fallback if env not set +bootstrap_servers = getattr(settings, 'KAFKA_BROKER_URL', 'localhost:9092') + +producer = Producer({'bootstrap.servers': bootstrap_servers}) event = { - "order_id": "ORD9999", - "origin_warehouse_id": "WH001", - "destination_warehouse_id": "WH002" + "order_id": "ORD001", + "origin": {"lat": 6.9271, "lng": 79.8612}, + "destination": {"lat": 7.2906, "lng": 80.6337}, + "demand": 25 } producer.produce('orders.created', json.dumps(event).encode('utf-8')) producer.flush() -print("Published mock order event.") + +print("✅ Published mock order event to 'orders.created'") diff --git a/requirements.txt b/requirements.txt index 9d32fb0..91625e4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,8 @@ absl-py==2.2.2 asgiref==3.8.1 +confluent-kafka==2.10.0 Django==5.2 +django-cors-headers==4.7.0 django-filter==25.1 djangorestframework==3.16.0 drf-yasg==1.21.10 @@ -15,11 +17,10 @@ pluggy==1.5.0 protobuf==5.29.4 pytest==8.3.5 python-dateutil==2.9.0.post0 +python-dotenv==1.1.0 pytz==2025.2 PyYAML==6.0.2 six==1.17.0 sqlparse==0.5.3 tzdata==2025.2 uritemplate==4.1.1 -confluent-kafka~=2.10.0 -django-cors-headers \ No newline at end of file diff --git a/shipments/tests/test_integration_kafka.py b/shipments/tests/test_integration_kafka.py index 4cbd047..84583fc 100644 --- a/shipments/tests/test_integration_kafka.py +++ b/shipments/tests/test_integration_kafka.py @@ -4,6 +4,7 @@ from shipments.models import Shipment from confluent_kafka import Producer from shipments.consumers.order_events import run_consumer_once +from django.conf import settings logger = logging.getLogger(__name__) @@ -11,7 +12,7 @@ class KafkaE2ETest(TestCase): @classmethod def setUpClass(cls): super().setUpClass() - cls.producer = Producer({'bootstrap.servers': 'localhost:9092'}) + cls.producer = Producer({'bootstrap.servers': settings.KAFKA_BROKER_URL}) def test_order_event_creates_shipment(self): order_id = "KAFKA_E2E_01" From 0f604d78d994560d731cadd67a07a1729b46df07 Mon Sep 17 00:00:00 2001 From: Kevin Sanjula Date: Sat, 10 May 2025 10:32:53 +0530 Subject: [PATCH 31/36] Update .github/workflows/tests.yml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1c7c83c..e679dd7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -50,7 +50,7 @@ jobs: - name: Wait for Kafka to be ready run: | for i in {1..10}; do - kafka-topics --bootstrap-server localhost:9092 --list && break + kafka-topics --bootstrap-server kafka:9092 --list && break echo "Waiting for Kafka..." sleep 5 done From ce93b317f0fc18337b5f83e7f02283cd454118b5 Mon Sep 17 00:00:00 2001 From: Ke-vin-S Date: Sat, 10 May 2025 10:37:18 +0530 Subject: [PATCH 32/36] Remove unused statement in workflow --- .github/workflows/tests.yml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1c7c83c..503825d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -47,14 +47,6 @@ jobs: python -m pip install --upgrade pip pip install --no-cache-dir -r requirements.txt - - name: Wait for Kafka to be ready - run: | - for i in {1..10}; do - kafka-topics --bootstrap-server localhost:9092 --list && break - echo "Waiting for Kafka..." - sleep 5 - done - - name: Run Django tests (including Kafka) run: | python manage.py test From ce1b1123430712e60a6d33c8145457eb35e85a28 Mon Sep 17 00:00:00 2001 From: Ke-vin-S Date: Sat, 10 May 2025 10:38:03 +0530 Subject: [PATCH 33/36] Remove unused statement in workflow --- .github/workflows/tests.yml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e679dd7..503825d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -47,14 +47,6 @@ jobs: python -m pip install --upgrade pip pip install --no-cache-dir -r requirements.txt - - name: Wait for Kafka to be ready - run: | - for i in {1..10}; do - kafka-topics --bootstrap-server kafka:9092 --list && break - echo "Waiting for Kafka..." - sleep 5 - done - - name: Run Django tests (including Kafka) run: | python manage.py test From 6b6abb25a4792826fd85f6543f9d857477234e50 Mon Sep 17 00:00:00 2001 From: Kevin Sanjula Date: Sat, 10 May 2025 10:55:29 +0530 Subject: [PATCH 34/36] Update README.md --- README.md | 167 ++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 107 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index 712580b..abd7c01 100644 --- a/README.md +++ b/README.md @@ -1,88 +1,93 @@ -# Logistics - -- route_optimizer/ (Standalone service) - - Inputs: delivery locations, vehicle capacities - - Output: optimized delivery route - - Purpose: The brain of the logistics module - - Develop this first so you can test assignment logic early - -- map_service/ (Optional) - - Utility/service to fetch real-world distances - - Can be a local function or API-based (OpenRouteService / OpenStreetMap) - - Can be skipped initially, use dummy distance matrix - -- fleet/ (Django app) - - Models: Vehicle, Status, Capacity, Location - - REST APIs to get available vehicles, update location/status - - You’ll need this to match vehicles with optimized route - -- assignment/ (Django app) - - Inputs: optimized route + available vehicles (from fleet) - - Logic: assign deliveries to vehicle - - Output: assignment events, persisted records - - Triggers optimizer and manages dispatching - -- scheduler/ (Lambda or Celery) - - Automatically triggers assignment + route_optimizer daily/hourly - - Optional for early dev, but crucial for automation - -- monitoring/ (Django app) - - Captures logs, alerts, failed deliveries, delays - - Optional dashboard with charts and status - - Could connect with Kafka or DB log events from assignment -- shipments/ (Django app) - - Models: Shipment (order_id, origin, destination, status) - - Status Lifecycle: pending → scheduled → dispatched → in_transit → delivered/failed - - APIs: Create shipment, update status, track delivery progress - - Decoupled from Warehouse via primitive IDs (warehouse_id) - - Triggered by Order events (async/REST), manages physical movement of goods +# 🚚 Logistics Microservice Suite + +This service powers intelligent shipment routing, assignment, fleet matching, and delivery monitoring — driven by Kafka and Django. --- -# Getting Started -### 1. ✅ Clone the Repository + +## 📦 Modules Overview + +- **route_optimizer/**: Optimizes delivery routes (independent service) +- **fleet/**: Manages vehicle data and availability +- **assignment/**: Assigns optimized routes to vehicles +- **scheduler/**: Triggers assignment logic periodically (future: Celery/Lambda) +- **monitoring/**: Logs delivery issues, performance (optional) +- **shipments/**: Manages shipment lifecycle +- **map_service/** *(optional)*: Calculates real-world distances via OpenRouteService or dummy matrix + +## 🚀 Getting Started + +### 🐳 Option A: Run with Docker (Recommended) + +#### 1. Clone the Repo ```bash git clone https://github.com/IASSCMS/Logistics.git cd Logistics -``` +```` ---- +#### 2. Create `.env` File + +```env +# .env +DJANGO_PORT=8000 +KAFKA_BROKER_URL=kafka:9092 +LOGISTICS_SERVICE_PORT=8002 +``` -### 2. 🐍 Create & Activate Virtual Environment +#### 3. Start All Services -#### On Linux/macOS: ```bash -python3 -m venv venv -source venv/bin/activate +docker-compose up --build ``` -#### On Windows: +This spins up: + +* Django app +* Kafka + Zookeeper + +#### 4. Visit in Browser + +* Swagger docs: [http://localhost:8002/swagger/](http://localhost:8002/swagger/) +* Admin panel: [http://localhost:8002/admin/](http://localhost:8002/admin/) + +#### 5. Run Django Tests in Docker + ```bash -python -m venv venv -venv\Scripts\activate +docker-compose run --rm logistics-service python manage.py test ``` --- -### 3. 📦 Install Dependencies +### 🐍 Option B: Local Dev Setup (Without Docker) -Make sure your virtual environment is activated, then run: +#### 1. Create & Activate Virtual Environment + +```bash +python3 -m venv venv +source venv/bin/activate # Windows: venv\Scripts\activate +``` + +#### 2. Install Dependencies ```bash pip install -r requirements.txt ``` ---- +#### 3. Setup Environment + +Set these in `.env` or shell: -### 4. ⚙️ Apply Migrations +```env +KAFKA_BROKER_URL=localhost:9092 +``` + +#### 4. Run Migrations ```bash python manage.py migrate ``` ---- - -### 5. 🚦 Run the Development Server +#### 5. Start Django Server ```bash python manage.py runserver @@ -90,14 +95,56 @@ python manage.py runserver --- -### 6. 📚 View API Documentation (Swagger) +## 📬 Kafka Setup Notes + +This app connects to Kafka topic `orders.created` via `kafka-python`. +Kafka is provided via `confluentinc/cp-kafka` in `docker-compose.yml`. + +* Send test events using `publish_mock_event.py` +* Consumer listens via `shipments.consumers.order_events` -Once the server is running, open your browser and go to: +--- + +## 📂 Project Structure ``` -http://127.0.0.1:8000/swagger/ +logistics/ +├── logistics_core/ # Django project +├── fleet/ # Vehicle models & APIs +├── shipments/ # Shipment status, tracking +├── assignment/ # Route-to-vehicle mapping +├── monitoring/ # Logs, dashboard, alerts +├── route_optimizer/ # Standalone optimization engine +├── manage.py +├── Dockerfile +├── entrypoint.sh +├── docker-compose.yml +└── requirements.txt ``` -You’ll see an interactive **Swagger UI** listing all available API endpoints (e.g., `/api/fleet/vehicles/`). +--- + +## 📄 API Documentation + +Once server is running: +* Swagger UI: [http://localhost:8002/swagger/](http://localhost:8002/swagger/) +* Redoc: [http://localhost:8002/redoc/](http://localhost:8002/redoc/) +--- + +## 🔐 Admin Account + +Create one manually: + +```bash +python manage.py createsuperuser +``` + +Or inside Docker: + +```bash +docker-compose exec logistics-service python manage.py createsuperuser +``` + +--- From 9fbc18c5d4c4f0ce1babddfd74eaf536da5e9251 Mon Sep 17 00:00:00 2001 From: Ke-vin-S Date: Sat, 10 May 2025 17:51:10 +0530 Subject: [PATCH 35/36] fix --- .env.example | 1 + logistics_core/settings.py | 1 + 2 files changed, 2 insertions(+) diff --git a/.env.example b/.env.example index f106e40..b38f50d 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ LOGISTICS_SERVICE_PORT=8002 DJANGO_PORT=8000 KAFKA_BROKER_URL=kafka:9092 +IS_DOCKER=False diff --git a/logistics_core/settings.py b/logistics_core/settings.py index f6c2c33..374bf18 100644 --- a/logistics_core/settings.py +++ b/logistics_core/settings.py @@ -45,6 +45,7 @@ 'drf_yasg', 'route_optimizer', 'corsheaders', + 'django_filters', ] MIDDLEWARE = [ From d4a664865c90502696e3d0f051c464f52079b271 Mon Sep 17 00:00:00 2001 From: Ke-vin-S Date: Sat, 10 May 2025 22:06:55 +0530 Subject: [PATCH 36/36] fix --- .env.example | 5 +++-- logistics_core/settings.py | 8 +++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index b38f50d..edf679e 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ -LOGISTICS_SERVICE_PORT=8002 DJANGO_PORT=8000 +LOGISTICS_SERVICE_PORT=8002 KAFKA_BROKER_URL=kafka:9092 -IS_DOCKER=False +IS_DOCKER=True +ALLOWED_HOSTS=localhost,logistics_service,api_gateway diff --git a/logistics_core/settings.py b/logistics_core/settings.py index 374bf18..bedafec 100644 --- a/logistics_core/settings.py +++ b/logistics_core/settings.py @@ -11,6 +11,7 @@ """ from pathlib import Path +import os # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -23,9 +24,9 @@ SECRET_KEY = 'django-insecure-5@qk89oz+(pmq*d$+k-#lb(*z(rf35m0y2+4=msy@2hc1*-_v)' # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True +DEBUG = False -ALLOWED_HOSTS = [] +ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS', '').split(',') # Application definition @@ -125,7 +126,8 @@ # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/5.2/howto/static-files/ -STATIC_URL = 'static/' +STATIC_URL = '/static/' +STATIC_ROOT = BASE_DIR / 'staticfiles' # Default primary key field type # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field