New enterprise feature
This commit is contained in:
81
services/distribution/app/api/dependencies.py
Normal file
81
services/distribution/app/api/dependencies.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""
|
||||
Dependency Injection for Distribution Service
|
||||
"""
|
||||
|
||||
from typing import AsyncGenerator
|
||||
from fastapi import Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.config import settings
|
||||
from app.repositories.delivery_route_repository import DeliveryRouteRepository
|
||||
from app.repositories.shipment_repository import ShipmentRepository
|
||||
from app.repositories.delivery_schedule_repository import DeliveryScheduleRepository
|
||||
from app.services.distribution_service import DistributionService
|
||||
from app.services.routing_optimizer import RoutingOptimizer
|
||||
from shared.clients.tenant_client import TenantServiceClient
|
||||
from shared.clients.inventory_client import InventoryServiceClient
|
||||
from shared.clients.procurement_client import ProcurementServiceClient
|
||||
|
||||
|
||||
|
||||
async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Get database session dependency"""
|
||||
async for session in get_db():
|
||||
yield session
|
||||
|
||||
|
||||
async def get_route_repository(db_session: AsyncSession = Depends(get_db_session)) -> DeliveryRouteRepository:
|
||||
"""Get delivery route repository dependency"""
|
||||
return DeliveryRouteRepository(db_session)
|
||||
|
||||
|
||||
async def get_shipment_repository(db_session: AsyncSession = Depends(get_db_session)) -> ShipmentRepository:
|
||||
"""Get shipment repository dependency"""
|
||||
return ShipmentRepository(db_session)
|
||||
|
||||
|
||||
async def get_delivery_schedule_repository(db_session: AsyncSession = Depends(get_db_session)) -> DeliveryScheduleRepository:
|
||||
"""Get delivery schedule repository dependency"""
|
||||
return DeliveryScheduleRepository(db_session)
|
||||
|
||||
|
||||
def get_tenant_client() -> TenantServiceClient:
|
||||
"""Get tenant service client dependency"""
|
||||
return TenantServiceClient(settings)
|
||||
|
||||
|
||||
def get_inventory_client() -> InventoryServiceClient:
|
||||
"""Get inventory service client dependency"""
|
||||
return InventoryServiceClient(settings)
|
||||
|
||||
|
||||
def get_procurement_client() -> ProcurementServiceClient:
|
||||
"""Get procurement service client dependency"""
|
||||
return ProcurementServiceClient(settings)
|
||||
|
||||
|
||||
def get_routing_optimizer() -> RoutingOptimizer:
|
||||
"""Get routing optimizer service dependency"""
|
||||
return RoutingOptimizer()
|
||||
|
||||
|
||||
def get_distribution_service(
|
||||
route_repository: DeliveryRouteRepository = Depends(get_route_repository),
|
||||
shipment_repository: ShipmentRepository = Depends(get_shipment_repository),
|
||||
schedule_repository: DeliveryScheduleRepository = Depends(get_delivery_schedule_repository),
|
||||
tenant_client: TenantServiceClient = Depends(get_tenant_client),
|
||||
inventory_client: InventoryServiceClient = Depends(get_inventory_client),
|
||||
procurement_client: ProcurementServiceClient = Depends(get_procurement_client),
|
||||
routing_optimizer: RoutingOptimizer = Depends(get_routing_optimizer)
|
||||
) -> DistributionService:
|
||||
"""Get distribution service dependency with all required clients"""
|
||||
return DistributionService(
|
||||
route_repository=route_repository,
|
||||
shipment_repository=shipment_repository,
|
||||
schedule_repository=schedule_repository,
|
||||
tenant_client=tenant_client,
|
||||
inventory_client=inventory_client,
|
||||
procurement_client=procurement_client,
|
||||
routing_optimizer=routing_optimizer
|
||||
)
|
||||
452
services/distribution/app/api/internal_demo.py
Normal file
452
services/distribution/app/api/internal_demo.py
Normal file
@@ -0,0 +1,452 @@
|
||||
"""
|
||||
Internal Demo API for Distribution Service
|
||||
Handles internal demo setup for enterprise tier
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header
|
||||
from typing import Dict, Any, List
|
||||
import structlog
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from app.services.distribution_service import DistributionService
|
||||
from app.api.dependencies import get_distribution_service
|
||||
from app.core.config import settings
|
||||
|
||||
logger = structlog.get_logger()
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def verify_internal_api_key(x_internal_api_key: str = Header(None)):
|
||||
"""Verify internal API key for service-to-service communication"""
|
||||
required_key = settings.INTERNAL_API_KEY
|
||||
if x_internal_api_key != required_key:
|
||||
logger.warning("Unauthorized internal API access attempted")
|
||||
raise HTTPException(status_code=403, detail="Invalid internal API key")
|
||||
return True
|
||||
|
||||
|
||||
@router.post("/internal/demo/setup")
|
||||
async def setup_demo_distribution(
|
||||
setup_request: dict, # Contains parent_tenant_id, child_tenant_ids, session_id
|
||||
distribution_service: DistributionService = Depends(get_distribution_service),
|
||||
_: bool = Depends(verify_internal_api_key)
|
||||
):
|
||||
"""
|
||||
Internal endpoint to setup distribution for enterprise demo
|
||||
|
||||
Args:
|
||||
setup_request: Contains parent_tenant_id, child_tenant_ids, session_id
|
||||
"""
|
||||
try:
|
||||
parent_tenant_id = setup_request.get('parent_tenant_id')
|
||||
child_tenant_ids = setup_request.get('child_tenant_ids', [])
|
||||
session_id = setup_request.get('session_id')
|
||||
|
||||
if not all([parent_tenant_id, child_tenant_ids, session_id]):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Missing required parameters: parent_tenant_id, child_tenant_ids, session_id"
|
||||
)
|
||||
|
||||
logger.info("Setting up demo distribution",
|
||||
parent=parent_tenant_id,
|
||||
children=child_tenant_ids,
|
||||
session_id=session_id)
|
||||
|
||||
# Get locations for parent and children to set up delivery routes
|
||||
parent_locations_response = await distribution_service.tenant_client.get_tenant_locations(parent_tenant_id)
|
||||
|
||||
# Check if parent_locations_response is None (which happens when the API call fails)
|
||||
if not parent_locations_response:
|
||||
logger.warning(f"No locations found for parent tenant {parent_tenant_id}")
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"No locations found for parent tenant {parent_tenant_id}. "
|
||||
f"Ensure the tenant exists and has locations configured."
|
||||
)
|
||||
|
||||
# Extract the actual locations array from the response object
|
||||
# The response format is {"locations": [...], "total": N}
|
||||
parent_locations = parent_locations_response.get("locations", []) if isinstance(parent_locations_response, dict) else parent_locations_response
|
||||
|
||||
# Look for central production or warehouse location as fallback
|
||||
parent_location = next((loc for loc in parent_locations if loc.get('location_type') == 'central_production'), None)
|
||||
if not parent_location:
|
||||
parent_location = next((loc for loc in parent_locations if loc.get('location_type') == 'warehouse'), None)
|
||||
if not parent_location:
|
||||
parent_location = next((loc for loc in parent_locations if loc.get('name', '').lower().startswith('central')), None)
|
||||
if not parent_location:
|
||||
parent_location = next((loc for loc in parent_locations if loc.get('name', '').lower().startswith('main')), None)
|
||||
|
||||
# If no specific central location found, use first available location
|
||||
if not parent_location and parent_locations:
|
||||
parent_location = parent_locations[0]
|
||||
logger.warning(f"No central production location found for parent tenant {parent_tenant_id}, using first location: {parent_location.get('name', 'unnamed')}")
|
||||
|
||||
# BUG-013 FIX: Use HTTPException instead of ValueError
|
||||
if not parent_location:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"No location found for parent tenant {parent_tenant_id} to use as distribution center. "
|
||||
f"Ensure the parent tenant has at least one location configured."
|
||||
)
|
||||
|
||||
# Create delivery schedules for each child
|
||||
for child_id in child_tenant_ids:
|
||||
try:
|
||||
child_locations_response = await distribution_service.tenant_client.get_tenant_locations(child_id)
|
||||
|
||||
# Check if child_locations_response is None (which happens when the API call fails)
|
||||
if not child_locations_response:
|
||||
logger.warning(f"No locations found for child tenant {child_id}")
|
||||
continue # Skip this child tenant and continue with the next one
|
||||
|
||||
# Extract the actual locations array from the response object
|
||||
# The response format is {"locations": [...], "total": N}
|
||||
child_locations = child_locations_response.get("locations", []) if isinstance(child_locations_response, dict) else child_locations_response
|
||||
|
||||
# Look for retail outlet or store location as first choice
|
||||
child_location = next((loc for loc in child_locations if loc.get('location_type') == 'retail_outlet'), None)
|
||||
if not child_location:
|
||||
child_location = next((loc for loc in child_locations if loc.get('location_type') == 'store'), None)
|
||||
if not child_location:
|
||||
child_location = next((loc for loc in child_locations if loc.get('location_type') == 'branch'), None)
|
||||
|
||||
# If no specific retail location found, use first available location
|
||||
if not child_location and child_locations:
|
||||
child_location = child_locations[0]
|
||||
logger.warning(f"No retail outlet location found for child tenant {child_id}, using first location: {child_location.get('name', 'unnamed')}")
|
||||
|
||||
if not child_location:
|
||||
logger.warning(f"No location found for child tenant {child_id}")
|
||||
continue
|
||||
|
||||
# Create delivery schedule
|
||||
schedule_data = {
|
||||
'tenant_id': child_id, # The child tenant that will receive deliveries
|
||||
'target_parent_tenant_id': parent_tenant_id, # The parent tenant that supplies
|
||||
'target_child_tenant_ids': [child_id], # Array of child tenant IDs in this schedule
|
||||
'name': f"Demo Schedule: {child_location.get('name', f'Child {child_id}')}",
|
||||
'delivery_days': "Mon,Wed,Fri", # Tri-weekly delivery
|
||||
'delivery_time': "09:00", # Morning delivery
|
||||
'auto_generate_orders': True,
|
||||
'lead_time_days': 1,
|
||||
'is_active': True,
|
||||
'created_by': parent_tenant_id, # BUG FIX: Add required created_by field
|
||||
'updated_by': parent_tenant_id # BUG FIX: Add required updated_by field
|
||||
}
|
||||
|
||||
# Create the delivery schedule record
|
||||
schedule = await distribution_service.create_delivery_schedule(schedule_data)
|
||||
logger.info(f"Created delivery schedule for {parent_tenant_id} to {child_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating delivery schedule for child {child_id}: {e}", exc_info=True)
|
||||
continue # Continue with the next child
|
||||
|
||||
# BUG-012 FIX: Use demo reference date instead of actual today
|
||||
from datetime import date
|
||||
from shared.utils.demo_dates import BASE_REFERENCE_DATE
|
||||
|
||||
# Get demo reference date from session metadata if available
|
||||
session_metadata = setup_request.get('session_metadata', {})
|
||||
session_created_at = session_metadata.get('session_created_at')
|
||||
|
||||
if session_created_at:
|
||||
# Use the BASE_REFERENCE_DATE for consistent demo data dating
|
||||
# All demo data is anchored to this date (November 25, 2025)
|
||||
demo_today = BASE_REFERENCE_DATE
|
||||
logger.info(f"Using demo reference date: {demo_today}")
|
||||
else:
|
||||
# Fallback to today if no session metadata (shouldn't happen in production)
|
||||
demo_today = date.today()
|
||||
logger.warning(f"No session_created_at in metadata, using today: {demo_today}")
|
||||
|
||||
delivery_data = []
|
||||
|
||||
# Prepare delivery information for each child
|
||||
for child_id in child_tenant_ids:
|
||||
try:
|
||||
child_locations_response = await distribution_service.tenant_client.get_tenant_locations(child_id)
|
||||
|
||||
# Check if child_locations_response is None (which happens when the API call fails)
|
||||
if not child_locations_response:
|
||||
logger.warning(f"No locations found for child delivery {child_id}")
|
||||
continue # Skip this child tenant and continue with the next one
|
||||
|
||||
# Extract the actual locations array from the response object
|
||||
# The response format is {"locations": [...], "total": N}
|
||||
child_locations = child_locations_response.get("locations", []) if isinstance(child_locations_response, dict) else child_locations_response
|
||||
|
||||
# Look for retail outlet or store location as first choice
|
||||
child_location = next((loc for loc in child_locations if loc.get('location_type') == 'retail_outlet'), None)
|
||||
if not child_location:
|
||||
child_location = next((loc for loc in child_locations if loc.get('location_type') == 'store'), None)
|
||||
if not child_location:
|
||||
child_location = next((loc for loc in child_locations if loc.get('location_type') == 'branch'), None)
|
||||
|
||||
# If no specific retail location found, use first available location
|
||||
if not child_location and child_locations:
|
||||
child_location = child_locations[0]
|
||||
logger.warning(f"No retail outlet location found for child delivery {child_id}, using first location: {child_location.get('name', 'unnamed')}")
|
||||
|
||||
if child_location:
|
||||
# Ensure we have valid coordinates
|
||||
latitude = child_location.get('latitude')
|
||||
longitude = child_location.get('longitude')
|
||||
|
||||
if latitude is not None and longitude is not None:
|
||||
try:
|
||||
lat = float(latitude)
|
||||
lng = float(longitude)
|
||||
delivery_data.append({
|
||||
'id': f"demo_delivery_{child_id}",
|
||||
'child_tenant_id': child_id,
|
||||
'location': (lat, lng),
|
||||
'weight_kg': 150.0, # Fixed weight for demo
|
||||
'po_id': f"demo_po_{child_id}", # Would be actual PO ID in real implementation
|
||||
'items_count': 20
|
||||
})
|
||||
except (ValueError, TypeError):
|
||||
logger.warning(f"Invalid coordinates for child {child_id}, skipping: lat={latitude}, lng={longitude}")
|
||||
else:
|
||||
logger.warning(f"Missing coordinates for child {child_id}, skipping: lat={latitude}, lng={longitude}")
|
||||
else:
|
||||
logger.warning(f"No location found for child delivery {child_id}, skipping")
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing child location for {child_id}: {e}", exc_info=True)
|
||||
|
||||
# Optimize routes using VRP - ensure we have valid coordinates
|
||||
parent_latitude = parent_location.get('latitude')
|
||||
parent_longitude = parent_location.get('longitude')
|
||||
|
||||
# BUG-013 FIX: Use HTTPException for coordinate validation errors
|
||||
if parent_latitude is None or parent_longitude is None:
|
||||
logger.error(f"Missing coordinates for parent location {parent_tenant_id}: lat={parent_latitude}, lng={parent_longitude}")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Parent location {parent_tenant_id} missing coordinates. "
|
||||
f"Latitude and longitude must be provided for distribution planning."
|
||||
)
|
||||
|
||||
try:
|
||||
depot_location = (float(parent_latitude), float(parent_longitude))
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.error(f"Invalid coordinates for parent location {parent_tenant_id}: lat={parent_latitude}, lng={parent_longitude}, error: {e}")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Parent location {parent_tenant_id} has invalid coordinates: {e}"
|
||||
)
|
||||
|
||||
optimization_result = await distribution_service.routing_optimizer.optimize_daily_routes(
|
||||
deliveries=delivery_data,
|
||||
depot_location=depot_location,
|
||||
vehicle_capacity_kg=1000.0 # Standard vehicle capacity
|
||||
)
|
||||
|
||||
# BUG-012 FIX: Create the delivery route using demo reference date
|
||||
routes = optimization_result.get('routes', [])
|
||||
route_sequence = routes[0].get('route_sequence', []) if routes else []
|
||||
|
||||
# Use session_id suffix to ensure unique route numbers for concurrent demo sessions
|
||||
session_suffix = session_id.split('_')[-1][:8] if session_id else '001'
|
||||
route = await distribution_service.route_repository.create_route({
|
||||
'tenant_id': uuid.UUID(parent_tenant_id),
|
||||
'route_number': f"DEMO-{demo_today.strftime('%Y%m%d')}-{session_suffix}",
|
||||
'route_date': datetime.combine(demo_today, datetime.min.time()),
|
||||
'total_distance_km': optimization_result.get('total_distance_km', 0),
|
||||
'estimated_duration_minutes': optimization_result.get('estimated_duration_minutes', 0),
|
||||
'route_sequence': route_sequence,
|
||||
'status': 'planned'
|
||||
})
|
||||
|
||||
# BUG-012 FIX: Create shipment records using demo reference date
|
||||
# Use session_id suffix to ensure unique shipment numbers
|
||||
shipments = []
|
||||
for idx, delivery in enumerate(delivery_data):
|
||||
shipment = await distribution_service.shipment_repository.create_shipment({
|
||||
'tenant_id': uuid.UUID(parent_tenant_id),
|
||||
'parent_tenant_id': uuid.UUID(parent_tenant_id),
|
||||
'child_tenant_id': uuid.UUID(delivery['child_tenant_id']),
|
||||
'shipment_number': f"DEMOSHP-{demo_today.strftime('%Y%m%d')}-{session_suffix}-{idx+1:03d}",
|
||||
'shipment_date': datetime.combine(demo_today, datetime.min.time()),
|
||||
'status': 'pending',
|
||||
'total_weight_kg': delivery['weight_kg']
|
||||
})
|
||||
shipments.append(shipment)
|
||||
|
||||
logger.info(f"Demo distribution setup completed: 1 route, {len(shipments)} shipments")
|
||||
|
||||
return {
|
||||
"status": "completed",
|
||||
"route_id": str(route['id']),
|
||||
"shipment_count": len(shipments),
|
||||
"total_distance_km": optimization_result.get('total_distance_km', 0),
|
||||
"session_id": session_id
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting up demo distribution: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to setup demo distribution: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/internal/demo/cleanup")
|
||||
async def cleanup_demo_distribution(
|
||||
cleanup_request: dict, # Contains parent_tenant_id, child_tenant_ids, session_id
|
||||
distribution_service: DistributionService = Depends(get_distribution_service),
|
||||
_: bool = Depends(verify_internal_api_key)
|
||||
):
|
||||
"""
|
||||
Internal endpoint to cleanup distribution data for enterprise demo
|
||||
|
||||
Args:
|
||||
cleanup_request: Contains parent_tenant_id, child_tenant_ids, session_id
|
||||
"""
|
||||
try:
|
||||
parent_tenant_id = cleanup_request.get('parent_tenant_id')
|
||||
child_tenant_ids = cleanup_request.get('child_tenant_ids', [])
|
||||
session_id = cleanup_request.get('session_id')
|
||||
|
||||
if not all([parent_tenant_id, session_id]):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Missing required parameters: parent_tenant_id, session_id"
|
||||
)
|
||||
|
||||
logger.info("Cleaning up demo distribution",
|
||||
parent=parent_tenant_id,
|
||||
session_id=session_id)
|
||||
|
||||
# Delete all demo routes and shipments for this parent tenant
|
||||
deleted_routes_count = await distribution_service.route_repository.delete_demo_routes_for_tenant(
|
||||
tenant_id=parent_tenant_id
|
||||
)
|
||||
|
||||
deleted_shipments_count = await distribution_service.shipment_repository.delete_demo_shipments_for_tenant(
|
||||
tenant_id=parent_tenant_id
|
||||
)
|
||||
|
||||
logger.info(f"Demo distribution cleanup completed: {deleted_routes_count} routes, {deleted_shipments_count} shipments deleted")
|
||||
|
||||
return {
|
||||
"status": "completed",
|
||||
"routes_deleted": deleted_routes_count,
|
||||
"shipments_deleted": deleted_shipments_count,
|
||||
"session_id": session_id
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error cleaning up demo distribution: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to cleanup demo distribution: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/internal/health")
|
||||
async def internal_health_check(
|
||||
_: bool = Depends(verify_internal_api_key)
|
||||
):
|
||||
"""
|
||||
Internal health check endpoint
|
||||
"""
|
||||
return {
|
||||
"service": "distribution-service",
|
||||
"endpoint": "internal-demo",
|
||||
"status": "healthy",
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
|
||||
@router.post("/internal/demo/clone")
|
||||
async def clone_demo_data(
|
||||
clone_request: dict,
|
||||
distribution_service: DistributionService = Depends(get_distribution_service),
|
||||
_: bool = Depends(verify_internal_api_key)
|
||||
):
|
||||
"""
|
||||
Clone/Setup distribution data for a virtual demo tenant
|
||||
|
||||
Args:
|
||||
clone_request: Contains base_tenant_id, virtual_tenant_id, session_id, demo_account_type
|
||||
"""
|
||||
try:
|
||||
virtual_tenant_id = clone_request.get('virtual_tenant_id')
|
||||
session_id = clone_request.get('session_id')
|
||||
|
||||
if not all([virtual_tenant_id, session_id]):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Missing required parameters: virtual_tenant_id, session_id"
|
||||
)
|
||||
|
||||
logger.info("Cloning distribution data",
|
||||
virtual_tenant_id=virtual_tenant_id,
|
||||
session_id=session_id)
|
||||
|
||||
# 1. Fetch child tenants for the new virtual parent
|
||||
child_tenants = await distribution_service.tenant_client.get_child_tenants(virtual_tenant_id)
|
||||
|
||||
if not child_tenants:
|
||||
logger.warning(f"No child tenants found for virtual parent {virtual_tenant_id}, skipping distribution setup")
|
||||
return {
|
||||
"status": "skipped",
|
||||
"reason": "no_child_tenants",
|
||||
"virtual_tenant_id": virtual_tenant_id
|
||||
}
|
||||
|
||||
child_tenant_ids = [child['id'] for child in child_tenants]
|
||||
|
||||
# 2. Call existing setup logic
|
||||
result = await distribution_service.setup_demo_enterprise_distribution(
|
||||
parent_tenant_id=virtual_tenant_id,
|
||||
child_tenant_ids=child_tenant_ids,
|
||||
session_id=session_id
|
||||
)
|
||||
|
||||
return {
|
||||
"service": "distribution",
|
||||
"status": "completed",
|
||||
"records_cloned": result.get('shipment_count', 0) + 1, # shipments + 1 route
|
||||
"details": result
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error cloning distribution data: {e}", exc_info=True)
|
||||
# Don't fail the entire cloning process if distribution fails
|
||||
return {
|
||||
"service": "distribution",
|
||||
"status": "failed",
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/internal/demo/tenant/{virtual_tenant_id}")
|
||||
async def delete_demo_data(
|
||||
virtual_tenant_id: str,
|
||||
distribution_service: DistributionService = Depends(get_distribution_service),
|
||||
_: bool = Depends(verify_internal_api_key)
|
||||
):
|
||||
"""Delete all distribution data for a virtual demo tenant"""
|
||||
try:
|
||||
logger.info("Deleting distribution data", virtual_tenant_id=virtual_tenant_id)
|
||||
|
||||
# Reuse existing cleanup logic
|
||||
deleted_routes = await distribution_service.route_repository.delete_demo_routes_for_tenant(
|
||||
tenant_id=virtual_tenant_id
|
||||
)
|
||||
|
||||
deleted_shipments = await distribution_service.shipment_repository.delete_demo_shipments_for_tenant(
|
||||
tenant_id=virtual_tenant_id
|
||||
)
|
||||
|
||||
return {
|
||||
"service": "distribution",
|
||||
"status": "deleted",
|
||||
"virtual_tenant_id": virtual_tenant_id,
|
||||
"records_deleted": {
|
||||
"routes": deleted_routes,
|
||||
"shipments": deleted_shipments
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting distribution data: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
225
services/distribution/app/api/routes.py
Normal file
225
services/distribution/app/api/routes.py
Normal file
@@ -0,0 +1,225 @@
|
||||
"""
|
||||
API Routes for Distribution Service
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Header
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import date, timedelta
|
||||
import structlog
|
||||
import os
|
||||
|
||||
from app.api.dependencies import get_distribution_service
|
||||
from shared.auth.tenant_access import verify_tenant_permission_dep
|
||||
from app.core.config import settings
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
async def verify_internal_api_key(x_internal_api_key: str = Header(None)):
|
||||
"""Verify internal API key for service-to-service communication"""
|
||||
required_key = settings.INTERNAL_API_KEY
|
||||
if x_internal_api_key != required_key:
|
||||
logger.warning("Unauthorized internal API access attempted")
|
||||
raise HTTPException(status_code=403, detail="Invalid internal API key")
|
||||
return True
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/tenants/{tenant_id}/distribution/plans/generate")
|
||||
async def generate_daily_distribution_plan(
|
||||
tenant_id: str,
|
||||
target_date: date = Query(..., description="Date for which to generate distribution plan"),
|
||||
vehicle_capacity_kg: float = Query(1000.0, description="Vehicle capacity in kg"),
|
||||
distribution_service: object = Depends(get_distribution_service),
|
||||
verified_tenant: str = Depends(verify_tenant_permission_dep)
|
||||
):
|
||||
"""
|
||||
Generate daily distribution plan for internal transfers
|
||||
|
||||
**Enterprise Tier Feature**: Distribution and routing require Enterprise subscription.
|
||||
"""
|
||||
try:
|
||||
# Validate subscription tier for distribution features
|
||||
from shared.subscription.plans import PlanFeatures
|
||||
from shared.clients import get_tenant_client
|
||||
|
||||
tenant_client = get_tenant_client(config=settings, service_name="distribution-service")
|
||||
subscription = await tenant_client.get_tenant_subscription(tenant_id)
|
||||
|
||||
if not subscription:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="No active subscription found. Distribution routing requires Enterprise tier."
|
||||
)
|
||||
|
||||
# Check if tier has distribution feature (enterprise only)
|
||||
tier = subscription.get("plan", "starter")
|
||||
if not PlanFeatures.has_feature(tier, "distribution_management"):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Distribution routing requires Enterprise tier. Current tier: {tier}"
|
||||
)
|
||||
|
||||
result = await distribution_service.generate_daily_distribution_plan(
|
||||
parent_tenant_id=tenant_id,
|
||||
target_date=target_date,
|
||||
vehicle_capacity_kg=vehicle_capacity_kg
|
||||
)
|
||||
return result
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error generating distribution plan", error=str(e), exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to generate distribution plan: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/distribution/routes")
|
||||
async def get_delivery_routes(
|
||||
tenant_id: str,
|
||||
date_from: Optional[date] = Query(None, description="Start date for route filtering"),
|
||||
date_to: Optional[date] = Query(None, description="End date for route filtering"),
|
||||
status: Optional[str] = Query(None, description="Filter by route status"),
|
||||
distribution_service: object = Depends(get_distribution_service),
|
||||
verified_tenant: str = Depends(verify_tenant_permission_dep)
|
||||
):
|
||||
"""
|
||||
Get delivery routes with optional filtering
|
||||
"""
|
||||
try:
|
||||
# If no date range specified, default to today
|
||||
if not date_from and not date_to:
|
||||
date_from = date.today()
|
||||
date_to = date.today()
|
||||
elif not date_to:
|
||||
date_to = date_from
|
||||
|
||||
routes = []
|
||||
current_date = date_from
|
||||
while current_date <= date_to:
|
||||
daily_routes = await distribution_service.get_delivery_routes_for_date(tenant_id, current_date)
|
||||
routes.extend(daily_routes)
|
||||
current_date = current_date + timedelta(days=1)
|
||||
|
||||
if status:
|
||||
routes = [r for r in routes if r.get('status') == status]
|
||||
|
||||
return {"routes": routes}
|
||||
except Exception as e:
|
||||
logger.error("Error getting delivery routes", error=str(e), exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get delivery routes: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/distribution/shipments")
|
||||
async def get_shipments(
|
||||
tenant_id: str,
|
||||
date_from: Optional[date] = Query(None, description="Start date for shipment filtering"),
|
||||
date_to: Optional[date] = Query(None, description="End date for shipment filtering"),
|
||||
status: Optional[str] = Query(None, description="Filter by shipment status"),
|
||||
distribution_service: object = Depends(get_distribution_service),
|
||||
verified_tenant: str = Depends(verify_tenant_permission_dep)
|
||||
):
|
||||
"""
|
||||
Get shipments with optional filtering
|
||||
"""
|
||||
try:
|
||||
# If no date range specified, default to today
|
||||
if not date_from and not date_to:
|
||||
date_from = date.today()
|
||||
date_to = date.today()
|
||||
elif not date_to:
|
||||
date_to = date_from
|
||||
|
||||
shipments = []
|
||||
current_date = date_from
|
||||
while current_date <= date_to:
|
||||
daily_shipments = await distribution_service.get_shipments_for_date(tenant_id, current_date)
|
||||
shipments.extend(daily_shipments)
|
||||
current_date = current_date + timedelta(days=1)
|
||||
|
||||
if status:
|
||||
shipments = [s for s in shipments if s.get('status') == status]
|
||||
|
||||
return {"shipments": shipments}
|
||||
except Exception as e:
|
||||
logger.error("Error getting shipments", error=str(e), exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get shipments: {str(e)}")
|
||||
|
||||
|
||||
@router.put("/tenants/{tenant_id}/distribution/shipments/{shipment_id}/status")
|
||||
async def update_shipment_status(
|
||||
tenant_id: str,
|
||||
shipment_id: str,
|
||||
status_update: dict, # Should be a Pydantic model in production
|
||||
distribution_service: object = Depends(get_distribution_service),
|
||||
verified_tenant: str = Depends(verify_tenant_permission_dep)
|
||||
):
|
||||
"""
|
||||
Update shipment status
|
||||
"""
|
||||
try:
|
||||
new_status = status_update.get('status')
|
||||
if not new_status:
|
||||
raise HTTPException(status_code=400, detail="Status is required")
|
||||
|
||||
user_id = "temp_user" # Would come from auth context
|
||||
result = await distribution_service.update_shipment_status(
|
||||
shipment_id=shipment_id,
|
||||
new_status=new_status,
|
||||
user_id=user_id,
|
||||
metadata=status_update.get('metadata')
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error("Error updating shipment status", error=str(e), exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to update shipment status: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/tenants/{tenant_id}/distribution/shipments/{shipment_id}/delivery-proof")
|
||||
async def upload_delivery_proof(
|
||||
tenant_id: str,
|
||||
shipment_id: str,
|
||||
delivery_proof: dict, # Should be a Pydantic model in production
|
||||
distribution_service: object = Depends(get_distribution_service),
|
||||
verified_tenant: str = Depends(verify_tenant_permission_dep)
|
||||
):
|
||||
"""
|
||||
Upload delivery proof (signature, photo, etc.)
|
||||
"""
|
||||
try:
|
||||
# Implementation would handle signature/photo upload
|
||||
# This is a placeholder until proper models are created
|
||||
raise HTTPException(status_code=501, detail="Delivery proof upload endpoint not yet implemented")
|
||||
except Exception as e:
|
||||
logger.error("Error uploading delivery proof", error=str(e), exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to upload delivery proof: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/distribution/routes/{route_id}")
|
||||
async def get_route_detail(
|
||||
tenant_id: str,
|
||||
route_id: str,
|
||||
distribution_service: object = Depends(get_distribution_service),
|
||||
verified_tenant: str = Depends(verify_tenant_permission_dep)
|
||||
):
|
||||
"""
|
||||
Get delivery route details
|
||||
"""
|
||||
try:
|
||||
# Implementation would fetch detailed route information
|
||||
# For now, return a simple response
|
||||
routes = await distribution_service.get_delivery_routes_for_date(tenant_id, date.today())
|
||||
route = next((r for r in routes if r.get('id') == route_id), None)
|
||||
|
||||
if not route:
|
||||
raise HTTPException(status_code=404, detail="Route not found")
|
||||
|
||||
return route
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error getting route detail", error=str(e), exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get route detail: {str(e)}")
|
||||
|
||||
|
||||
112
services/distribution/app/api/shipments.py
Normal file
112
services/distribution/app/api/shipments.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
Shipment API endpoints for distribution service
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from typing import List, Optional
|
||||
from datetime import date, timedelta
|
||||
|
||||
from app.api.dependencies import get_distribution_service
|
||||
from shared.auth.tenant_access import verify_tenant_permission_dep
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/distribution/shipments")
|
||||
async def get_shipments(
|
||||
tenant_id: str,
|
||||
date_from: Optional[date] = Query(None, description="Start date for shipment filtering"),
|
||||
date_to: Optional[date] = Query(None, description="End date for shipment filtering"),
|
||||
status: Optional[str] = Query(None, description="Filter by shipment status"),
|
||||
distribution_service: object = Depends(get_distribution_service),
|
||||
verified_tenant: str = Depends(verify_tenant_permission_dep)
|
||||
):
|
||||
"""
|
||||
List shipments with optional filtering
|
||||
"""
|
||||
try:
|
||||
# If no date range specified, default to today
|
||||
if not date_from and not date_to:
|
||||
date_from = date.today()
|
||||
date_to = date.today()
|
||||
elif not date_to:
|
||||
date_to = date_from
|
||||
|
||||
shipments = []
|
||||
current_date = date_from
|
||||
while current_date <= date_to:
|
||||
daily_shipments = await distribution_service.get_shipments_for_date(tenant_id, current_date)
|
||||
shipments.extend(daily_shipments)
|
||||
current_date = current_date + timedelta(days=1)
|
||||
|
||||
if status:
|
||||
shipments = [s for s in shipments if s.get('status') == status]
|
||||
|
||||
return {"shipments": shipments}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get shipments: {str(e)}")
|
||||
|
||||
|
||||
@router.put("/tenants/{tenant_id}/distribution/shipments/{shipment_id}/status")
|
||||
async def update_shipment_status(
|
||||
tenant_id: str,
|
||||
shipment_id: str,
|
||||
status_update: dict, # Should be a proper Pydantic model
|
||||
distribution_service: object = Depends(get_distribution_service),
|
||||
verified_tenant: str = Depends(verify_tenant_permission_dep)
|
||||
):
|
||||
"""
|
||||
Update shipment status
|
||||
"""
|
||||
try:
|
||||
new_status = status_update.get('status')
|
||||
if not new_status:
|
||||
raise HTTPException(status_code=400, detail="Status is required")
|
||||
|
||||
user_id = "temp_user_id" # Would come from auth context
|
||||
result = await distribution_service.update_shipment_status(
|
||||
shipment_id=shipment_id,
|
||||
new_status=new_status,
|
||||
user_id=user_id,
|
||||
metadata=status_update.get('metadata')
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to update shipment status: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/tenants/{tenant_id}/distribution/shipments/{shipment_id}/delivery-proof")
|
||||
async def upload_delivery_proof(
|
||||
tenant_id: str,
|
||||
shipment_id: str,
|
||||
delivery_proof: dict, # Should be a proper Pydantic model
|
||||
distribution_service: object = Depends(get_distribution_service),
|
||||
verified_tenant: str = Depends(verify_tenant_permission_dep)
|
||||
):
|
||||
"""
|
||||
Upload delivery proof (signature, photo, etc.)
|
||||
"""
|
||||
try:
|
||||
# Implementation would handle signature/photo upload
|
||||
# This is a placeholder until proper models are created
|
||||
raise HTTPException(status_code=501, detail="Delivery proof upload endpoint not yet implemented")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to upload delivery proof: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/distribution/shipments/{shipment_id}")
|
||||
async def get_shipment_detail(
|
||||
tenant_id: str,
|
||||
shipment_id: str,
|
||||
distribution_service: object = Depends(get_distribution_service),
|
||||
verified_tenant: str = Depends(verify_tenant_permission_dep)
|
||||
):
|
||||
"""
|
||||
Get detailed information about a specific shipment
|
||||
"""
|
||||
try:
|
||||
# Implementation would fetch detailed shipment information
|
||||
# This is a placeholder until repositories are created
|
||||
raise HTTPException(status_code=501, detail="Shipment detail endpoint not yet implemented")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get shipment details: {str(e)}")
|
||||
Reference in New Issue
Block a user