diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..edf679e --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +DJANGO_PORT=8000 +LOGISTICS_SERVICE_PORT=8002 +KAFKA_BROKER_URL=kafka:9092 +IS_DOCKER=True +ALLOWED_HOSTS=localhost,logistics_service,api_gateway diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index dee0d57..503825d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -45,12 +45,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r requirements.txt - - - name: Wait for Kafka to be ready - run: | - echo "Waiting for Kafka..." - sleep 20 # adjust based on startup time + pip install --no-cache-dir -r requirements.txt - name: Run Django tests (including Kafka) run: | 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/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 +``` + +--- 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/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.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/route_optimizer/api/__init__.py b/assignment/models/__init__.py similarity index 100% rename from route_optimizer/api/__init__.py rename to assignment/models/__init__.py 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..dadb016 --- /dev/null +++ b/assignment/models/assignment_item.py @@ -0,0 +1,28 @@ +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 stop, etc. + delivery_location = models.JSONField() # { "lat": ..., "lng": ... } + + role = models.CharField(max_length=10, choices=ROLE_CHOICES, default="delivery") # NEW + + # 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) + + class Meta: + ordering = ['delivery_sequence'] + + def __str__(self): + return f"{self.role.capitalize()} for Shipment {self.shipment.id} in Assignment {self.assignment.id}" diff --git a/assignment/serializers.py b/assignment/serializers.py deleted file mode 100644 index cd28200..0000000 --- a/assignment/serializers.py +++ /dev/null @@ -1,7 +0,0 @@ -from rest_framework import serializers -from .models import Assignment - -class AssignmentSerializer(serializers.ModelSerializer): - class Meta: - model = Assignment - fields = '__all__' diff --git a/route_optimizer/core/__init__.py b/assignment/serializers/__init__.py similarity index 100% rename from route_optimizer/core/__init__.py rename to assignment/serializers/__init__.py diff --git a/assignment/serializers/assignment.py b/assignment/serializers/assignment.py new file mode 100644 index 0000000..2fe7bd2 --- /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) + + 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..72c67a4 --- /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 ShipmentSerializerForAssignment(serializers.ModelSerializer): + class Meta: + model = Shipment + fields = ['id', 'order_id', 'demand', 'status'] # Add more as needed + +class AssignmentItemSerializer(serializers.ModelSerializer): + shipment = ShipmentSerializerForAssignment(read_only=True) + + class Meta: + model = AssignmentItem + fields = ['shipment', 'role', 'delivery_sequence', 'delivery_location', 'is_delivered', 'delivered_at'] diff --git a/route_optimizer/tests/core/__init__.py b/assignment/services/__init__.py similarity index 100% rename from route_optimizer/tests/core/__init__.py rename to assignment/services/__init__.py diff --git a/assignment/services/assignment_planner.py b/assignment/services/assignment_planner.py new file mode 100644 index 0000000..3e2b7cd --- /dev/null +++ b/assignment/services/assignment_planner.py @@ -0,0 +1,97 @@ +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") + + # Implicit mapping of vehicle in this and vehicle in vrp solver + 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' + ) + + # 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: + 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"], + }, + role=role + ) + 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 b4b1d6e..0000000 --- a/assignment/tests.py +++ /dev/null @@ -1,69 +0,0 @@ -from django.test import TestCase -from rest_framework.test import APIClient -from fleet.models import Vehicle -from assignment.models import Assignment - -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") - - def test_create_assignment_success(self): - payload = { - "deliveries": [ - {"location": [77.59, 12.97], "load": 40}, - {"location": [77.61, 12.98], "load": 30} - ] - } - response = self.client.post('/api/assignment/assignments/', payload, format='json') - self.assertEqual(response.status_code, 201) - self.assertEqual(response.data['total_load'], 70) - - def test_create_assignment_insufficient_capacity(self): - payload = { - "deliveries": [{"location": [77.59, 12.97], "load": 150}] - } - 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": [{"location": [77.59, 12.97], "load": 50}]} - 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.objects.create( - vehicle=self.vehicle, - delivery_locations=[[77.59, 12.97]], - total_load=50 - ) - 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": [{"location": [77.59, 12.97], "load": 50}] - } - 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, - delivery_locations=[[77.59, 12.97]], - total_load=50 - ) - expected = f"Assignment #{assignment.id} to {self.vehicle.vehicle_id}" - self.assertEqual(str(assignment), expected) diff --git a/route_optimizer/tests/services/__init__.py b/assignment/tests/__init__.py similarity index 100% rename from route_optimizer/tests/services/__init__.py rename to assignment/tests/__init__.py diff --git a/assignment/tests/test_assignment_api.py b/assignment/tests/test_assignment_api.py new file mode 100644 index 0000000..f91c89e --- /dev/null +++ b/assignment/tests/test_assignment_api.py @@ -0,0 +1,173 @@ +import uuid + +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( + 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="pending" + ) + + 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): + 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): + 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) + + 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/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/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"]), 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/tests/test_assignment_complete.py b/assignment/tests/test_assignment_complete.py new file mode 100644 index 0000000..a36821a --- /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/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/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/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/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/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/assignment/urls.py b/assignment/urls.py index d1d1500..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) +router.register(r'', AssignmentViewSet, basename='assignment') urlpatterns = [ path('', include(router.urls)), diff --git a/assignment/views.py b/assignment/views.py index 2680f55..b1f31e1 100644 --- a/assignment/views.py +++ b/assignment/views.py @@ -1,8 +1,14 @@ +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 import Assignment -from .serializers import AssignmentSerializer -from fleet.models import Vehicle + +from .models.assignment import Assignment +from .models.assignment_item import AssignmentItem +from fleet.models import Vehicle, VehicleLocation +from shipments.models import Shipment +from .serializers.assignment import AssignmentSerializer + class AssignmentViewSet(viewsets.ModelViewSet): queryset = Assignment.objects.all() @@ -23,8 +29,124 @@ def create(self, request, *args, **kwargs): assignment = Assignment.objects.create( vehicle=vehicle, - delivery_locations=[d["location"] for d in deliveries], - total_load=total_load + total_load=total_load, + status='created' ) + + for delivery in deliveries: + shipment_id = delivery.get("shipment_id") + location = delivery.get("location") + 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) + 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, + role=role + ) + 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) + + @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 + }) + + @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) 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} 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/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.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..502acb4 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,55 @@ def test_update_location(self): format='json' ) self.assertEqual(response.status_code, status.HTTP_200_OK) + self.vehicle1.refresh_from_db() + self.assertAlmostEqual(float(self.vehicle1.current_latitude), 42.123456) + self.assertAlmostEqual(float(self.vehicle1.current_longitude), -71.654321) + + # 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) + + 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]) - # Check that location was updated in the vehicle + 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(float(self.vehicle1.current_latitude), 42.123456) - self.assertEqual(float(self.vehicle1.current_longitude), -71.654321) + 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") - # 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) + 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 1592168..8fb24b0 100644 --- a/fleet/views/vehicle.py +++ b/fleet/views/vehicle.py @@ -1,18 +1,20 @@ 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() + 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 +30,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 +42,126 @@ 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) + queryset = queryset.order_by('-updated_at') return queryset @action(detail=True, methods=['post']) - def update_location(self, request, pk=None): - """ - Update vehicle location. - POST /api/fleet/vehicles/{id}/update_location/ - """ + 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') - # Extract location data from request + 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() 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): + def assign_depot(self, request, pk=None): """ - Change vehicle status. - POST /api/fleet/vehicles/{id}/change_status/ + 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() - new_status = request.data.get('status') + depot_id = request.data.get('depot_id') + depot_lat = request.data.get('latitude') + depot_lon = request.data.get('longitude') - if not new_status: - return Response( - {'error': 'Status is required'}, - status=status.HTTP_400_BAD_REQUEST - ) + if depot_id is None: + return Response({'error': 'depot_id 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 - ) - - # Handle status change to maintenance - if new_status == 'maintenance' and vehicle.status != 'maintenance': - # Optionally create a maintenance record - 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()) + vehicle.depot_id = depot_id + if depot_lat is not None and depot_lon is not None: try: - scheduled_date = datetime.fromisoformat(scheduled_date).date() + vehicle.depot_latitude = float(depot_lat) + vehicle.depot_longitude = float(depot_lon) 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 - ) - - # Update vehicle status - vehicle.status = new_status - vehicle.save(update_fields=['status', 'updated_at']) + 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 +171,57 @@ 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}) + + # # 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 diff --git a/logistics_core/settings.py b/logistics_core/settings.py index b0d9a93..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 @@ -44,9 +45,13 @@ 'shipments', 'drf_yasg', 'route_optimizer', + 'corsheaders', + 'django_filters', ] MIDDLEWARE = [ + 'corsheaders.middleware.CorsMiddleware', + 'django.middleware.common.CommonMiddleware', 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', @@ -111,7 +116,7 @@ LANGUAGE_CODE = 'en-us' -TIME_ZONE = 'UTC' +TIME_ZONE = 'Asia/Colombo' USE_I18N = True @@ -121,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 @@ -132,4 +138,11 @@ 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", +] \ No newline at end of file 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')), ] 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 a74e3f6..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,10 +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 \ No newline at end of file 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/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/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 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..ade9f8c --- /dev/null +++ b/route_optimizer/models/vrp_input.py @@ -0,0 +1,138 @@ +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, Tuple[str, str]] # (task_id, "pickup"/"delivery") + demands: List[int] + time_limit: int = 3 + 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 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") + 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: + 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) + 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 + self.distance_matrix[to_index][from_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_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: Dict[int, Tuple[str, str]] = {} + + for task in builder.tasks: + pickup_label = f"{task.id}_pickup" + delivery_label = f"{task.id}_delivery" + 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.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 = label_to_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/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/services/vrp_solver.py b/route_optimizer/services/vrp_solver.py new file mode 100644 index 0000000..abde07e --- /dev/null +++ b/route_optimizer/services/vrp_solver.py @@ -0,0 +1,114 @@ +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" + ) + + # Search parameters + search_parameters = pywrapcp.DefaultRoutingSearchParameters() + search_parameters.first_solution_strategy = routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC + + search_parameters.solution_limit = 1 + + 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) + + return { + "status": "success", + "routes": routes, + "total_distance": total_distance, + } 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/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/tests/core/test_dijkstra.py b/route_optimizer/tests/test_dijkstra.py similarity index 98% rename from route_optimizer/tests/core/test_dijkstra.py rename to route_optimizer/tests/test_dijkstra.py index e09d90e..1a0f480 100644 --- a/route_optimizer/tests/core/test_dijkstra.py +++ b/route_optimizer/tests/test_dijkstra.py @@ -4,7 +4,7 @@ This module contains tests for the DijkstraPathFinder class. """ import unittest -from route_optimizer.core.dijkstra import DijkstraPathFinder +from route_optimizer.utils.dijkstra import DijkstraPathFinder class TestDijkstraPathFinder(unittest.TestCase): diff --git a/route_optimizer/tests/test_vrp_input.py b/route_optimizer/tests/test_vrp_input.py new file mode 100644 index 0000000..d24159c --- /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_id = vrp_input.task_index_map[vrp_input.pickups_deliveries[0][0]][0] + self.assertEqual(task_id, "D1") + + +if __name__ == "__main__": + unittest.main() diff --git a/route_optimizer/tests/test_vrp_solver.py b/route_optimizer/tests/test_vrp_solver.py new file mode 100644 index 0000000..b9832cb --- /dev/null +++ b/route_optimizer/tests/test_vrp_solver.py @@ -0,0 +1,140 @@ +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) + + 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 diff --git a/route_optimizer/core/dijkstra.py b/route_optimizer/utils/dijkstra.py similarity index 100% rename from route_optimizer/core/dijkstra.py rename to route_optimizer/utils/dijkstra.py 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 diff --git a/shipments/admin.py b/shipments/admin.py index 648e9b2..9505d86 100644 --- a/shipments/admin.py +++ b/shipments/admin.py @@ -1,9 +1,28 @@ 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', + 'demand', + '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 + 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 + 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..9a984ec 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,36 @@ 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: - logging.error("Invalid order event payload") + 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: 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=str(order_id), + origin=origin, + destination=destination, + demand=demand, + status='pending' + ) + logging.info(f"Shipment created for order {order_id} with demand {demand}") + def start_order_consumer(): consumer = create_kafka_consumer() @@ -48,6 +64,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/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 70e00d8..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'), @@ -14,8 +15,12 @@ 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() + + 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 5983230..2f0c4e7 100644 --- a/shipments/tests/test_api.py +++ b/shipments/tests/test_api.py @@ -2,9 +2,9 @@ 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): def setUp(self): @@ -12,27 +12,33 @@ def setUp(self): 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}, + 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_warehouse_id": "WH010", - "destination_warehouse_id": "WH020" + "origin": {"lat": 6.9, "lng": 79.8}, + "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): @@ -41,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): @@ -59,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): @@ -78,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): @@ -87,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): @@ -108,6 +114,7 @@ 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}, + demand=20, ) diff --git a/shipments/tests/test_consumer.py b/shipments/tests/test_consumer.py index 92b63a4..6efdc36 100644 --- a/shipments/tests/test_consumer.py +++ b/shipments/tests/test_consumer.py @@ -5,24 +5,25 @@ 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}, + "demand": 25 } 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, event["origin"]) + self.assertEqual(shipment.destination, event["destination"]) + self.assertEqual(shipment.demand, 25) 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}, + "demand": 10 } handle_order_created(event) self.assertEqual(Shipment.objects.count(), 0) @@ -30,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_warehouse_id": "WH2" + "destination": {"lat": 7.2906, "lng": 80.6337}, + "demand": 10 } handle_order_created(event) self.assertEqual(Shipment.objects.count(), 0) @@ -38,55 +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_warehouse_id": "WH1" + "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): - """If order_id is not a string, the handler should not crash.""" + def test_invalid_data_type_for_order_id_is_casted(self): event = { "order_id": 12345, - "origin_warehouse_id": "WH1", - "destination_warehouse_id": "WH2" + "origin": {"lat": 6.9, "lng": 79.8}, + "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): - """If shipment_id is random, even duplicate order_id can create multiple records.""" + def test_duplicate_order_id_creates_multiple_shipments(self): 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}, + "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): - """Extra fields in the event should not break creation.""" + def test_extra_fields_are_ignored_and_demand_saved(self): 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": "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): - """An empty dict should be gracefully ignored.""" + def test_event_with_no_fields_does_nothing(self): handle_order_created({}) self.assertEqual(Shipment.objects.count(), 0) - def test_null_values(self): - """Null values should not create shipments.""" + def test_null_values_are_ignored(self): event = { "order_id": None, - "origin_warehouse_id": None, - "destination_warehouse_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/tests/test_integration_kafka.py b/shipments/tests/test_integration_kafka.py index 1dee1ce..84583fc 100644 --- a/shipments/tests/test_integration_kafka.py +++ b/shipments/tests/test_integration_kafka.py @@ -3,8 +3,8 @@ from django.test import TestCase 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__) @@ -12,14 +12,14 @@ 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" 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 +33,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") 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)