New enterprise feature

This commit is contained in:
Urtzi Alfaro
2025-11-30 09:12:40 +01:00
parent f9d0eec6ec
commit 972db02f6d
176 changed files with 19741 additions and 1361 deletions

View 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
)

View 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))

View 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)}")

View 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)}")