diff --git a/frontend/src/api/hooks/useEnterpriseDashboard.ts b/frontend/src/api/hooks/useEnterpriseDashboard.ts index 3d843e1d..88679fe0 100644 --- a/frontend/src/api/hooks/useEnterpriseDashboard.ts +++ b/frontend/src/api/hooks/useEnterpriseDashboard.ts @@ -307,7 +307,7 @@ export const useChildrenPerformance = ( export const useDistributionOverview = ( parentTenantId: string, date: string, - options?: { enabled?: boolean } + options?: { enabled?: boolean; refetchInterval?: number } ): UseQueryResult => { return useQuery({ queryKey: ['enterprise', 'distribution-overview', parentTenantId, date], @@ -331,6 +331,7 @@ export const useDistributionOverview = ( }, staleTime: 30000, // 30s cache enabled: options?.enabled ?? true, + refetchInterval: options?.refetchInterval, }); }; diff --git a/frontend/src/components/dashboard/DistributionTab.tsx b/frontend/src/components/dashboard/DistributionTab.tsx index 29bdcbe5..592b4474 100644 --- a/frontend/src/components/dashboard/DistributionTab.tsx +++ b/frontend/src/components/dashboard/DistributionTab.tsx @@ -22,7 +22,7 @@ interface DistributionTabProps { const DistributionTab: React.FC = ({ tenantId, selectedDate, onDateChange }) => { const { t } = useTranslation('dashboard'); const { currencySymbol } = useTenantCurrency(); - + // Get distribution data const { data: distributionOverview, @@ -34,8 +34,8 @@ const DistributionTab: React.FC = ({ tenantId, selectedDat }); // Real-time SSE events - const { events: sseEvents, isConnected: sseConnected } = useSSEEvents({ - channels: ['*.alerts', '*.notifications', 'recommendations'] + const { events: sseEvents, isConnected: sseConnected } = useSSEEvents({ + channels: ['*.alerts', '*.notifications', 'recommendations'] }); // State for real-time delivery status @@ -63,8 +63,8 @@ const DistributionTab: React.FC = ({ tenantId, selectedDat if (sseEvents.length === 0) return; // Filter delivery and distribution-related events - const deliveryEvents = sseEvents.filter(event => - event.event_type.includes('delivery_') || + const deliveryEvents = sseEvents.filter((event: any) => + event.event_type.includes('delivery_') || event.event_type.includes('route_') || event.event_type.includes('shipment_') || event.entity_type === 'delivery' || @@ -125,39 +125,7 @@ const DistributionTab: React.FC = ({ tenantId, selectedDat const isLoading = isDistributionLoading; - // Mock route data - in Phase 2 this will come from real API - const mockRoutes = [ - { - id: 'route-1', - name: 'Madrid → Barcelona', - status: 'in_transit', - distance: '620 km', - duration: '6h 30m', - stops: 3, - optimizationSavings: '12 km (1.9%)', - vehicles: ['TRUCK-001', 'TRUCK-002'] - }, - { - id: 'route-2', - name: 'Barcelona → Valencia', - status: 'completed', - distance: '350 km', - duration: '4h 15m', - stops: 2, - optimizationSavings: '8 km (2.3%)', - vehicles: ['VAN-005'] - }, - { - id: 'route-3', - name: 'Central → Outlets (Daily)', - status: 'pending', - distance: '180 km', - duration: '3h 00m', - stops: 5, - optimizationSavings: '25 km (13.9%)', - vehicles: ['TRUCK-003', 'VAN-006', 'VAN-007'] - } - ]; + // No mockRoutes anymore, using distributionOverview.route_sequences return (
@@ -167,7 +135,7 @@ const DistributionTab: React.FC = ({ tenantId, selectedDat {t('enterprise.distribution_summary')} - + {/* Date selector */}
@@ -219,7 +187,7 @@ const DistributionTab: React.FC = ({ tenantId, selectedDat {deliveryStatus.onTime}

- {deliveryStatus.total > 0 + {deliveryStatus.total > 0 ? `${Math.round((deliveryStatus.onTime / deliveryStatus.total) * 100)}% ${t('enterprise.on_time_rate')}` : t('enterprise.no_deliveries')}

@@ -354,7 +322,7 @@ const DistributionTab: React.FC = ({ tenantId, selectedDat {t('enterprise.active_routes')}
- {mockRoutes.map((route) => { + {(distributionOverview?.route_sequences || []).map((route: any) => { // Determine status configuration const getStatusConfig = () => { switch (route.status) { @@ -364,15 +332,15 @@ const DistributionTab: React.FC = ({ tenantId, selectedDat text: t('enterprise.route_completed'), icon: CheckCircle2 }; - case 'delayed': - case 'overdue': + case 'failed': + case 'cancelled': return { color: '#ef4444', // red-500 text: t('enterprise.route_delayed'), icon: AlertTriangle, isCritical: true }; - case 'in_transit': + case 'in_progress': return { color: '#3b82f6', // blue-500 text: t('enterprise.route_in_transit'), @@ -390,29 +358,35 @@ const DistributionTab: React.FC = ({ tenantId, selectedDat const statusConfig = getStatusConfig(); + // Format optimization savings + const savings = route.vrp_optimization_savings || {}; + const distanceSavedText = savings.distance_saved_km + ? `${savings.distance_saved_km.toFixed(1)} km` + : '0 km'; + return ( = ({ tenantId, selectedDat icon: Map, variant: 'outline', onClick: () => { - // In Phase 2, this will navigate to route tracking page - console.log(`Track route ${route.name}`); + console.log(`Track route ${route.route_number}`); }, priority: 'primary' } ]} onClick={() => { - // In Phase 2, this will navigate to route detail page - console.log(`View route ${route.name}`); + console.log(`View route ${route.route_number}`); }} /> ); })} + {(!distributionOverview?.route_sequences || distributionOverview.route_sequences.length === 0) && ( +
+
+ +

{t('enterprise.no_active_routes')}

+
+
+ )}
diff --git a/frontend/src/components/maps/DistributionMap.tsx b/frontend/src/components/maps/DistributionMap.tsx index 96b8660b..e5d6512d 100644 --- a/frontend/src/components/maps/DistributionMap.tsx +++ b/frontend/src/components/maps/DistributionMap.tsx @@ -82,26 +82,25 @@ const DistributionMap: React.FC = ({ shipments = { pending: 0, in_transit: 0, delivered: 0, failed: 0 } }) => { const { t } = useTranslation('dashboard'); - const [selectedRoute, setSelectedRoute] = useState(null); const renderMapVisualization = () => { if (!routes || routes.length === 0) { return ( -
+
- +
-

- {t('enterprise.no_active_routes')} +

+ {t('enterprise.no_active_routes', 'Sin rutas activas')}

- {t('enterprise.no_shipments_today')} + {t('enterprise.no_shipments_today', 'No hay envíos programados para hoy')}

@@ -118,21 +117,21 @@ const DistributionMap: React.FC = ({ if (activeRoutes.length === 0) { return ( -
+
- +
-

- {t('enterprise.all_routes_completed')} +

+ {t('enterprise.all_routes_completed', 'Todas las rutas completadas')}

- {t('enterprise.no_active_deliveries')} + {t('enterprise.no_active_deliveries', 'No hay entregas pendientes en este momento')}

@@ -146,11 +145,11 @@ const DistributionMap: React.FC = ({ // Real Leaflet map with routes return ( -
+
= ({ /> {/* Render each route */} - {activeRoutes.map((route, routeIdx) => { + {activeRoutes.map((route) => { const points = route.route_sequence || []; const routeColor = route.status === 'in_progress' ? '#3b82f6' : '#f59e0b'; @@ -191,13 +190,32 @@ const DistributionMap: React.FC = ({ icon={createRouteMarker(markerColor, point.sequence)} > -
-
{point.name}
-
{point.address}
-
-
Route: {route.route_number}
-
Stop {point.sequence} of {points.length}
-
Status: {point.status}
+
+
{point.name}
+
{point.address}
+
+
+ Ruta: + {route.route_number} +
+
+ Parada: + {point.sequence} / {points.length} +
+
+ Estado: + + {point.status} + +
@@ -209,398 +227,66 @@ const DistributionMap: React.FC = ({ })} - {/* Status Legend Overlay */} + {/* Status Legend Overlay - Refined alignment */}
-
- {activeRoutes.length} {t('enterprise.active_routes')} +
+ {activeRoutes.length} {t('enterprise.active_routes', 'Rutas Activas')}
-
-
- - {t('enterprise.delivered')}: {shipments.delivered} - -
-
-
- - {t('enterprise.in_transit')}: {shipments.in_transit} - -
-
-
- - {t('enterprise.pending')}: {shipments.pending} - -
- {shipments.failed > 0 && ( -
-
- - {t('enterprise.failed')}: {shipments.failed} - +
+
+
+
+ + {t('enterprise.delivered', 'Entregadas')} + +
+ {shipments.delivered}
- )} +
+
+
+ + {t('enterprise.in_transit', 'En Tránsito')} + +
+ {shipments.in_transit} +
+
+
+
+ + {t('enterprise.pending', 'Pendientes')} + +
+ {shipments.pending} +
+ {shipments.failed > 0 && ( +
+
+
+ + {t('enterprise.failed', 'Fallidas')} + +
+ {shipments.failed} +
+ )} +
); }; - const getStatusIcon = (status: string) => { - switch (status) { - case 'delivered': - return ; - case 'in_transit': - return ; - case 'pending': - return ; - case 'failed': - return ; - default: - return ; - } - }; - - const getStatusColor = (status: string) => { - switch (status) { - case 'delivered': - return 'bg-green-100 text-green-800 border-green-200'; - case 'in_transit': - return 'bg-blue-100 text-blue-800 border-blue-200'; - case 'pending': - return 'bg-yellow-100 text-yellow-800 border-yellow-200'; - case 'failed': - return 'bg-red-100 text-red-800 border-red-200'; - default: - return 'bg-gray-100 text-gray-800 border-gray-200'; - } - }; - return ( -
- {/* Shipment Status Summary - Hero Icon Pattern */} -
- {/* Pending Status Card */} -
-
-
- -
-

- {shipments?.pending || 0} -

-

- {t('enterprise.pending')} -

-
-
- - {/* In Transit Status Card */} -
-
-
- -
-

- {shipments?.in_transit || 0} -

-

- {t('enterprise.in_transit')} -

-
-
- - {/* Delivered Status Card */} -
-
-
- -
-

- {shipments?.delivered || 0} -

-

- {t('enterprise.delivered')} -

-
-
- - {/* Failed Status Card */} -
-
-
- -
-

- {shipments?.failed || 0} -

-

- {t('enterprise.failed')} -

-
-
-
- - {/* Map Visualization */} +
{renderMapVisualization()} - - {/* Route Details Panel - Timeline Pattern */} -
-

- {t('enterprise.active_routes')} ({routes.filter(r => r.status === 'in_progress' || r.status === 'planned').length}) -

- - {routes.length > 0 ? ( -
- {routes - .filter(route => route.status === 'in_progress' || route.status === 'planned') - .map(route => ( -
- {/* Route Header */} -
-
-
- -
-
-

- {t('enterprise.route')} {route.route_number} -

-

- {route.total_distance_km.toFixed(1)} km • {Math.ceil(route.estimated_duration_minutes / 60)}h -

-
-
- - - {getStatusIcon(route.status)} - - {t(`enterprise.route_status.${route.status}`) || route.status.replace('_', ' ')} - - -
- - {/* Timeline of Stops */} - {route.route_sequence && route.route_sequence.length > 0 && ( -
- {route.route_sequence.map((point, idx) => { - const getPointStatusColor = (status: string) => { - switch (status) { - case 'delivered': - return 'var(--color-success)'; - case 'in_transit': - return 'var(--color-info)'; - case 'failed': - return 'var(--color-error)'; - default: - return 'var(--color-warning)'; - } - }; - - const getPointBadgeStyle = (status: string) => { - switch (status) { - case 'delivered': - return { - backgroundColor: 'var(--color-success-100)', - color: 'var(--color-success-900)', - borderColor: 'var(--color-success-300)' - }; - case 'in_transit': - return { - backgroundColor: 'var(--color-info-100)', - color: 'var(--color-info-900)', - borderColor: 'var(--color-info-300)' - }; - case 'failed': - return { - backgroundColor: 'var(--color-error-100)', - color: 'var(--color-error-900)', - borderColor: 'var(--color-error-300)' - }; - default: - return { - backgroundColor: 'var(--color-warning-100)', - color: 'var(--color-warning-900)', - borderColor: 'var(--color-warning-300)' - }; - } - }; - - return ( -
- {/* Timeline dot */} -
- - {/* Stop info */} -
-
-

- {point.sequence}. {point.name} -

-

- {point.address} -

-
- - {getStatusIcon(point.status)} - - {t(`enterprise.stop_status.${point.status}`) || point.status} - - -
-
- ); - })} -
- )} -
- ))} -
- ) : ( -
-

- {t('enterprise.no_routes_planned')} -

-
- )} -
- - {/* Selected Route Detail Panel (would be modal in real implementation) */} - {selectedRoute && ( -
-
-
-

{t('enterprise.route_details')}

- -
- -
-
- {t('enterprise.route_number')} - {selectedRoute.route_number} -
-
- {t('enterprise.total_distance')} - {selectedRoute.total_distance_km.toFixed(1)} km -
-
- {t('enterprise.estimated_duration')} - {Math.ceil(selectedRoute.estimated_duration_minutes / 60)}h {selectedRoute.estimated_duration_minutes % 60}m -
-
- {t('enterprise.status')} - - {getStatusIcon(selectedRoute.status)} - - {t(`enterprise.route_status.${selectedRoute.status}`) || selectedRoute.status} - - -
-
- - -
-
- )}
); }; diff --git a/frontend/src/pages/app/operations/distribution/DistributionPage.tsx b/frontend/src/pages/app/operations/distribution/DistributionPage.tsx index 9fcea957..d09f9315 100644 --- a/frontend/src/pages/app/operations/distribution/DistributionPage.tsx +++ b/frontend/src/pages/app/operations/distribution/DistributionPage.tsx @@ -11,7 +11,9 @@ import { MoreVertical, CheckCircle, AlertTriangle, - X + X, + Clock, + Eye, } from 'lucide-react'; import { Button, @@ -23,6 +25,11 @@ import { Badge, Input, Tabs, + EmptyState, + StatusCard, + SearchAndFilter, + type FilterConfig, + getStatusColor, } from '../../../../components/ui'; import { TabsList, TabsTrigger, TabsContent } from '../../../../components/ui/Tabs'; import { PageHeader } from '../../../../components/layout'; @@ -71,11 +78,75 @@ const DistributionPage: React.FC = () => { }, ]; + const [searchTerm, setSearchTerm] = useState(''); + const [statusFilter, setStatusFilter] = useState(''); + const handleNewRoute = () => { // Navigate to create route page or open modal console.log('New route clicked'); }; + // Route status configuration + const getRouteStatusConfig = (status: string) => { + const configs: Record = { + pending: { + color: getStatusColor('warning'), + text: t('operations:status.pending', 'Pendiente'), + icon: Clock, + isCritical: true + }, + in_progress: { + color: getStatusColor('info'), + text: t('operations:status.in_progress', 'En Progreso'), + icon: Truck, + isCritical: false + }, + completed: { + color: getStatusColor('success'), + text: t('operations:status.completed', 'Completada'), + icon: CheckCircle, + isCritical: false + }, + failed: { + color: getStatusColor('error'), + text: t('operations:status.failed', 'Fallida'), + icon: AlertTriangle, + isCritical: true + } + }; + + return configs[status] || { + color: getStatusColor('neutral'), + text: status, + icon: Package, + isCritical: false + }; + }; + + // Filter routes + const filteredRoutes = distributionData?.route_sequences?.filter((route: any) => { + const matchesSearch = route.route_number?.toLowerCase().includes(searchTerm.toLowerCase()); + const matchesStatus = !statusFilter || route.status === statusFilter; + return matchesSearch && matchesStatus; + }) || []; + + const filterConfig: FilterConfig[] = [ + { + key: 'status', + type: 'dropdown', + label: t('operations:filters.status', 'Estado'), + value: statusFilter, + onChange: (value) => setStatusFilter(value as string), + placeholder: t('operations:filters.all_statuses', 'Todos los estados'), + options: [ + { value: 'pending', label: t('operations:status.pending', 'Pendiente') }, + { value: 'in_progress', label: t('operations:status.in_progress', 'En Progreso') }, + { value: 'completed', label: t('operations:status.completed', 'Completada') }, + { value: 'failed', label: t('operations:status.failed', 'Fallida') } + ] + } + ]; + if (!tenant) return null; // Prepare shipment status data safely @@ -112,270 +183,141 @@ const DistributionPage: React.FC = () => { ]} /> - {/* Stats Grid */} + {/* Stats Grid - Follows Procurement style */} - {/* Main Content Areas */} -
- {/* Tabs Navigation */} - setActiveTab(value)} - className="w-full" - > - - - {t('operations:distribution.tabs.overview', 'Vista General')} - - - {t('operations:distribution.tabs.routes', 'Listado de Rutas')} - - - {t('operations:distribution.tabs.shipments', 'Listado de Envíos')} - - + {/* Tabs Navigation - Simplified and cleaned up */} + setActiveTab(value as 'overview' | 'routes' | 'shipments')} + className="w-full" + > + + + {t('operations:distribution.tabs.overview', 'Vista General')} + + + {t('operations:distribution.tabs.routes', 'Listado de Rutas')} + + + {t('operations:distribution.tabs.shipments', 'Listado de Envíos')} + + - {/* Content based on Active Tab */} - - {/* Map Section */} - - -
-
-
- -
-
- - {t('operations:map.title', 'Mapa de Distribución')} - -

- {t('operations:map.description', 'Visualización en tiempo real de la flota')} -

-
+ {/* Content based on Active Tab */} + + {/* Map Section - Integrated with Card Header style */} + + +
+
+
+
-
- -
- {t('operations:map.live', 'En Vivo')} - -
-
- - -
-
- -
-
-
- - - {/* Recent Activity / Quick List */} -
- - - - {t('operations:distribution.active_routes', 'Rutas en Progreso')} - - - - {distributionData?.route_sequences?.filter((r: any) => r.status === 'in_progress').length > 0 ? ( -
- {distributionData.route_sequences - .filter((r: any) => r.status === 'in_progress') - .map((route: any) => ( -
-
-
- -
-
-

- {t('operations:distribution.route_prefix', 'Ruta')} {route.route_number} -

-

- {route.formatted_driver_name || t('operations:distribution.no_driver', 'Sin conductor asignado')} -

-
-
- - {t('operations:distribution.status.in_progress', 'En Ruta')} - -
- ))} -
- ) : ( -
- {t('operations:distribution.no_active_routes', 'No hay rutas en progreso actualmente.')} -
- )} -
-
- - - - - {t('operations:distribution.pending_deliveries', 'Entregas Pendientes')} - - - - {distributionData?.status_counts?.pending > 0 ? ( -
-
-
-
- -
-
-

- {t('operations:distribution.pending_count', 'Entregas Pendientes')} -

-

- {t('operations:distribution.pending_desc', 'Aún por distribuir')} -

-
-
- - {distributionData.status_counts.pending} - -
-
- ) : ( -
- {t('operations:distribution.no_pending', 'No hay entregas pendientes.')} -
- )} -
-
-
- - - - - -
- - {t('operations:distribution.routes_list', 'Listado de Rutas')} + + {t('operations:map.title', 'Mapa de Distribución')} -

- {t('operations:distribution.routes_desc', 'Gestión y seguimiento de rutas de distribución')} +

+ {t('operations:map.description', 'Visualización en tiempo real de la flota')}

-
- } - className="w-full sm:w-64" - /> - -
-
- - {(distributionData?.route_sequences?.length || 0) > 0 ? ( -
- - - - - - - - - - - - - {distributionData.route_sequences.map((route: any) => ( - - - - - - - - - ))} - -
{t('operations:distribution.table.route', 'Ruta')}{t('operations:distribution.table.status', 'Estado')}{t('operations:distribution.table.distance', 'Distancia')}{t('operations:distribution.table.duration', 'Duración Est.')}{t('operations:distribution.table.stops', 'Paradas')}{t('operations:distribution.table.actions', 'Acciones')}
- {t('operations:distribution.route_prefix', 'Ruta')} {route.route_number} - - - {route.status} - - - {route.total_distance_km?.toFixed(1) || '-'} km - - {route.estimated_duration_minutes || '-'} min - - {route.route_points?.length || 0} - -
-
- ) : ( -
-

- {t('operations:distribution.no_routes_found', 'No se encontraron rutas para esta fecha.')} -

-
- )} -
-
-
+ +
+ {t('operations:map.live', 'En Vivo')} + +
+ + +
+ +
+
+ - {/* Similar structure for Shipments tab, simplified for now */} - - - - - {t('operations:distribution.shipments_list', 'Gestión de Envíos')} - -

- {t('operations:distribution.shipments_desc', 'Funcionalidad de listado detallado de envíos próximamente.')} -

-
- -
- -

- {t('operations:distribution.shipments_title', 'Gestión de Envíos')} -

-

- {t('operations:distribution.shipments_desc', 'Funcionalidad de listado detallado de envíos próximamente.')} -

-
+ {/* Secondary sections at the bottom if needed, but keeping it clean for now */} +
+ {/* Summary of important routes or alerts could go here */} +
+
+ + + + + {filteredRoutes.length > 0 ? ( +
+ {filteredRoutes.map((route: any) => { + const statusConfig = getRouteStatusConfig(route.status); + return ( + console.log('View route', route.id), + variant: 'primary' + } + ]} + /> + ); + })} +
+ ) : ( + + + -
- -
+ )} + + + + { }} + searchPlaceholder={t('operations:distribution.search_shipments', 'Buscar envíos...')} + filters={[]} + /> + + + + + + + +
); }; diff --git a/services/inventory/app/api/internal.py b/services/inventory/app/api/internal.py new file mode 100644 index 00000000..9f88867b --- /dev/null +++ b/services/inventory/app/api/internal.py @@ -0,0 +1,55 @@ +""" +Internal API for Inventory Service +Handles internal service-to-service operations +""" + +from fastapi import APIRouter, Depends, HTTPException, Header +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func +from uuid import UUID +import structlog + +from app.core.database import get_db +from app.core.config import settings +from app.models import Ingredient + +logger = structlog.get_logger() +router = APIRouter(prefix="/internal", tags=["internal"]) + + +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.get("/count") +async def get_ingredient_count( + tenant_id: str, + db: AsyncSession = Depends(get_db), + _: bool = Depends(verify_internal_api_key) +): + """ + Get count of active ingredients for onboarding status check. + Internal endpoint for tenant service. + """ + try: + count = await db.scalar( + select(func.count()).select_from(Ingredient) + .where( + Ingredient.tenant_id == UUID(tenant_id), + Ingredient.is_active == True + ) + ) + + return { + "count": count or 0, + "tenant_id": tenant_id + } + + except Exception as e: + logger.error("Failed to get ingredient count", tenant_id=tenant_id, error=str(e)) + raise HTTPException(status_code=500, detail=f"Failed to get ingredient count: {str(e)}") diff --git a/services/inventory/app/api/internal_demo.py b/services/inventory/app/api/internal_demo.py index 42b93388..048d80ac 100644 --- a/services/inventory/app/api/internal_demo.py +++ b/services/inventory/app/api/internal_demo.py @@ -610,35 +610,4 @@ async def delete_demo_tenant_data( raise HTTPException( status_code=500, detail=f"Failed to delete demo data: {str(e)}" - ) - - -@router.get("/count") -async def get_ingredient_count( - tenant_id: str, - db: AsyncSession = Depends(get_db), - _: bool = Depends(verify_internal_api_key) -): - """ - Get count of active ingredients for onboarding status check. - Internal endpoint for tenant service. - """ - try: - from sqlalchemy import select, func - - count = await db.scalar( - select(func.count()).select_from(Ingredient) - .where( - Ingredient.tenant_id == UUID(tenant_id), - Ingredient.is_active == True - ) - ) - - return { - "count": count or 0, - "tenant_id": tenant_id - } - - except Exception as e: - logger.error("Failed to get ingredient count", tenant_id=tenant_id, error=str(e)) - raise HTTPException(status_code=500, detail=f"Failed to get ingredient count: {str(e)}") \ No newline at end of file + ) \ No newline at end of file diff --git a/services/inventory/app/main.py b/services/inventory/app/main.py index 0722b26a..36591837 100644 --- a/services/inventory/app/main.py +++ b/services/inventory/app/main.py @@ -33,7 +33,8 @@ from app.api import ( sustainability, audit, ml_insights, - enterprise_inventory + enterprise_inventory, + internal ) from app.api.internal_alert_trigger import router as internal_alert_trigger_router from app.api.internal_demo import router as internal_demo_router @@ -215,6 +216,7 @@ service.add_router(dashboard.router) service.add_router(analytics.router) service.add_router(sustainability.router) service.add_router(internal_demo.router, tags=["internal-demo"]) +service.add_router(internal.router) service.add_router(ml_insights.router) # ML insights endpoint service.add_router(ml_insights.internal_router) # Internal ML insights endpoint for demo cloning service.add_router(internal_alert_trigger_router) # Internal alert trigger for demo cloning diff --git a/services/orchestrator/app/api/internal_demo.py b/services/orchestrator/app/api/internal_demo.py index 3184229c..f6252ef2 100644 --- a/services/orchestrator/app/api/internal_demo.py +++ b/services/orchestrator/app/api/internal_demo.py @@ -231,7 +231,7 @@ async def clone_demo_data( raise HTTPException(status_code=500, detail=f"Failed to clone orchestration runs: {str(e)}") -@router.delete("/internal/demo/tenant/{virtual_tenant_id}") +@router.delete("/tenant/{virtual_tenant_id}") async def delete_demo_data( virtual_tenant_id: str, db: AsyncSession = Depends(get_db), @@ -280,7 +280,7 @@ async def delete_demo_data( raise HTTPException(status_code=500, detail=str(e)) -@router.get("/internal/demo/clone/health") +@router.get("/clone/health") async def health_check(_: bool = Depends(verify_internal_api_key)): """Health check for demo cloning endpoint""" return {"status": "healthy", "service": "orchestrator"} diff --git a/services/recipes/app/api/internal.py b/services/recipes/app/api/internal.py new file mode 100644 index 00000000..8ce80315 --- /dev/null +++ b/services/recipes/app/api/internal.py @@ -0,0 +1,56 @@ +""" +Internal API for Recipes Service +Handles internal service-to-service operations +""" + +from fastapi import APIRouter, Depends, HTTPException, Header +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func +from uuid import UUID +import structlog + +from app.core.database import get_db +from app.core.config import settings +from app.models.recipes import Recipe, RecipeStatus + +logger = structlog.get_logger() +router = APIRouter(prefix="/internal", tags=["internal"]) + + +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.get("/count") +async def get_recipe_count( + tenant_id: str, + db: AsyncSession = Depends(get_db), + _: bool = Depends(verify_internal_api_key) +): + """ + Get count of recipes for onboarding status check. + Counts DRAFT and ACTIVE recipes (excludes ARCHIVED/DISCONTINUED). + Internal endpoint for tenant service. + """ + try: + count = await db.scalar( + select(func.count()).select_from(Recipe) + .where( + Recipe.tenant_id == UUID(tenant_id), + Recipe.status.in_([RecipeStatus.DRAFT, RecipeStatus.ACTIVE, RecipeStatus.TESTING]) + ) + ) + + return { + "count": count or 0, + "tenant_id": tenant_id + } + + except Exception as e: + logger.error("Failed to get recipe count", tenant_id=tenant_id, error=str(e)) + raise HTTPException(status_code=500, detail=f"Failed to get recipe count: {str(e)}") diff --git a/services/recipes/app/api/internal_demo.py b/services/recipes/app/api/internal_demo.py index d8911dc3..df76ceec 100644 --- a/services/recipes/app/api/internal_demo.py +++ b/services/recipes/app/api/internal_demo.py @@ -434,34 +434,3 @@ async def delete_demo_tenant_data( ) -@router.get("/count") -async def get_recipe_count( - tenant_id: str, - db: AsyncSession = Depends(get_db), - _: bool = Depends(verify_internal_api_key) -): - """ - Get count of recipes for onboarding status check. - Counts DRAFT and ACTIVE recipes (excludes ARCHIVED/DISCONTINUED). - Internal endpoint for tenant service. - """ - try: - from sqlalchemy import select, func - from app.models.recipes import RecipeStatus - - count = await db.scalar( - select(func.count()).select_from(Recipe) - .where( - Recipe.tenant_id == UUID(tenant_id), - Recipe.status.in_([RecipeStatus.DRAFT, RecipeStatus.ACTIVE, RecipeStatus.TESTING]) - ) - ) - - return { - "count": count or 0, - "tenant_id": tenant_id - } - - except Exception as e: - logger.error("Failed to get recipe count", tenant_id=tenant_id, error=str(e)) - raise HTTPException(status_code=500, detail=f"Failed to get recipe count: {str(e)}") diff --git a/services/recipes/app/main.py b/services/recipes/app/main.py index 6a62cb9b..cf3d840b 100644 --- a/services/recipes/app/main.py +++ b/services/recipes/app/main.py @@ -14,7 +14,7 @@ from .core.database import db_manager from shared.service_base import StandardFastAPIService # Import API routers -from .api import recipes, recipe_quality_configs, recipe_operations, audit, internal_demo +from .api import recipes, recipe_quality_configs, recipe_operations, audit, internal_demo, internal # Import models to register them with SQLAlchemy metadata from .models import recipes as recipe_models @@ -122,6 +122,7 @@ service.add_router(recipes.router) service.add_router(recipe_quality_configs.router) service.add_router(recipe_operations.router) service.add_router(internal_demo.router, tags=["internal-demo"]) +service.add_router(internal.router) if __name__ == "__main__": diff --git a/services/suppliers/app/api/internal.py b/services/suppliers/app/api/internal.py new file mode 100644 index 00000000..2a489b95 --- /dev/null +++ b/services/suppliers/app/api/internal.py @@ -0,0 +1,55 @@ +""" +Internal API for Suppliers Service +Handles internal service-to-service operations +""" + +from fastapi import APIRouter, Depends, HTTPException, Header +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func +from uuid import UUID +import structlog + +from app.core.database import get_db +from app.core.config import settings +from app.models.suppliers import Supplier, SupplierStatus + +logger = structlog.get_logger() +router = APIRouter(prefix="/internal", tags=["internal"]) + + +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.get("/count") +async def get_supplier_count( + tenant_id: str, + db: AsyncSession = Depends(get_db), + _: bool = Depends(verify_internal_api_key) +): + """ + Get count of active suppliers for onboarding status check. + Internal endpoint for tenant service. + """ + try: + count = await db.scalar( + select(func.count()).select_from(Supplier) + .where( + Supplier.tenant_id == UUID(tenant_id), + Supplier.status == SupplierStatus.active + ) + ) + + return { + "count": count or 0, + "tenant_id": tenant_id + } + + except Exception as e: + logger.error("Failed to get supplier count", tenant_id=tenant_id, error=str(e)) + raise HTTPException(status_code=500, detail=f"Failed to get supplier count: {str(e)}") diff --git a/services/suppliers/app/api/internal_demo.py b/services/suppliers/app/api/internal_demo.py index b4132e39..a5d0d9a9 100644 --- a/services/suppliers/app/api/internal_demo.py +++ b/services/suppliers/app/api/internal_demo.py @@ -409,33 +409,3 @@ async def delete_demo_tenant_data( ) -@router.get("/count") -async def get_supplier_count( - tenant_id: str, - db: AsyncSession = Depends(get_db), - _: bool = Depends(verify_internal_api_key) -): - """ - Get count of active suppliers for onboarding status check. - Internal endpoint for tenant service. - """ - try: - from sqlalchemy import select, func - from app.models.suppliers import SupplierStatus - - count = await db.scalar( - select(func.count()).select_from(Supplier) - .where( - Supplier.tenant_id == UUID(tenant_id), - Supplier.status == SupplierStatus.active - ) - ) - - return { - "count": count or 0, - "tenant_id": tenant_id - } - - except Exception as e: - logger.error("Failed to get supplier count", tenant_id=tenant_id, error=str(e)) - raise HTTPException(status_code=500, detail=f"Failed to get supplier count: {str(e)}") diff --git a/services/suppliers/app/main.py b/services/suppliers/app/main.py index 09e078aa..8cff9c21 100644 --- a/services/suppliers/app/main.py +++ b/services/suppliers/app/main.py @@ -11,7 +11,7 @@ from app.core.database import database_manager from shared.service_base import StandardFastAPIService # Import API routers -from app.api import suppliers, supplier_operations, analytics, audit, internal_demo +from app.api import suppliers, supplier_operations, analytics, audit, internal_demo, internal # REMOVED: purchase_orders, deliveries - PO and delivery management moved to Procurement Service # from app.api import purchase_orders, deliveries @@ -110,6 +110,7 @@ service.add_router(supplier_operations.router) # /suppliers/operations/... service.add_router(analytics.router) # /suppliers/analytics/... service.add_router(suppliers.router) # /suppliers/{supplier_id} - catch-all, must be last service.add_router(internal_demo.router, tags=["internal-demo"]) +service.add_router(internal.router) if __name__ == "__main__":