diff --git a/frontend/src/components/domain/onboarding/steps/SmartInventorySetupStep.tsx b/frontend/src/components/domain/onboarding/steps/SmartInventorySetupStep.tsx index 502e138f..5af00b2a 100644 --- a/frontend/src/components/domain/onboarding/steps/SmartInventorySetupStep.tsx +++ b/frontend/src/components/domain/onboarding/steps/SmartInventorySetupStep.tsx @@ -85,6 +85,7 @@ export const SmartInventorySetupStep: React.FC = ({ // Use onboarding hooks const { processSalesFile, + generateProductSuggestions, // New separated function createInventoryFromSuggestions, importSalesData, salesProcessing: { @@ -120,8 +121,11 @@ export const SmartInventorySetupStep: React.FC = ({ const tenantCreatedSuccessfully = tenantCreation.isSuccess; const tenantCreatedInOnboarding = data.bakery?.tenantCreated === true; - const result = Boolean(hasAuth && (hasTenantId || tenantCreatedSuccessfully || tenantCreatedInOnboarding)); + // If user has already uploaded a file or has processing data, assume tenant is available + // This prevents blocking the UI due to temporary state inconsistencies after successful progress + const hasProgressData = !!(data.files?.salesData || data.processingResults || data.processingStage); + const result = Boolean(hasAuth && (hasTenantId || tenantCreatedSuccessfully || tenantCreatedInOnboarding || isAlreadyInStep)); return result; }; @@ -152,20 +156,55 @@ export const SmartInventorySetupStep: React.FC = ({ productsLength: products.length, suggestionsIsArray: Array.isArray(suggestions), suggestionsType: typeof suggestions, + shouldConvert: suggestions && suggestions.length > 0 && products.length === 0 }); - if (suggestions && suggestions.length > 0 && products.length === 0) { + if (suggestions && Array.isArray(suggestions) && suggestions.length > 0 && products.length === 0) { console.log('✅ Converting suggestions to products and setting stage to review'); - const newProducts = convertSuggestionsToCards(suggestions); - setProducts(newProducts); - setLocalStage('review'); + try { + const newProducts = convertSuggestionsToCards(suggestions); + console.log('📦 Converted products:', newProducts.length); + setProducts(newProducts); + setLocalStage('review'); + + // Force update parent data immediately + const updatedData = { + ...data, + detectedProducts: newProducts, + processingStage: 'review' + }; + onDataChange(updatedData); + } catch (error) { + console.error('❌ Error converting suggestions to products:', error); + } } - }, [suggestions, products.length]); + }, [suggestions, products.length, data, onDataChange]); // Derive current stage - const stage = (localStage === 'completed' || localStage === 'error') - ? localStage - : (onboardingStage === 'completed' ? 'review' : onboardingStage || localStage); + const stage = (() => { + // If local stage is explicitly set to completed or error, use it + if (localStage === 'completed' || localStage === 'error') { + return localStage; + } + + // If we have products to review, always show review stage + if (products.length > 0) { + return 'review'; + } + + // If onboarding processing completed but no products yet, wait for conversion + if (onboardingStage === 'completed' && suggestions && suggestions.length > 0) { + return 'review'; + } + + // If file is validated but no suggestions generated yet, show confirmation stage + if (onboardingStage === 'validated' || localStage === 'validated') { + return 'validated'; + } + + // Otherwise use the onboarding stage or local stage + return onboardingStage || localStage; + })(); const progress = onboardingProgress || 0; const currentMessage = onboardingMessage || ''; @@ -176,7 +215,10 @@ export const SmartInventorySetupStep: React.FC = ({ onboardingStage, finalStage: stage, productsLength: products.length, - suggestionsLength: suggestions?.length || 0 + suggestionsLength: suggestions?.length || 0, + hasSuggestions: !!suggestions, + suggestionsArray: Array.isArray(suggestions), + willShowReview: stage === 'review' && products.length > 0 }); }, [localStage, onboardingStage, stage, products.length, suggestions?.length]); @@ -275,22 +317,14 @@ export const SmartInventorySetupStep: React.FC = ({ console.log('🔄 SmartInventorySetup - Processing result:', { success }); if (success) { - console.log('✅ File processed successfully, setting stage to review'); - setLocalStage('review'); + console.log('✅ File validation completed successfully'); + // Don't set to review stage anymore - let the 'validated' stage show first + setLocalStage('validated'); - // Check if there was a suggestion error (AI service timeout) - const stepData = data.allStepData?.['smart-inventory-setup']; - if (stepData?.suggestionError) { - toast.addToast(`Archivo procesado. ${stepData.suggestionError} Se crearon sugerencias básicas que puedes editar.`, { - title: 'Procesamiento completado con advertencias', - type: 'warning' - }); - } else { - toast.addToast('El archivo se procesó correctamente. Revisa los productos detectados.', { - title: 'Procesamiento completado', - type: 'success' - }); - } + toast.addToast(`Archivo validado correctamente. Se encontraron ${validationResults?.product_list?.length || 0} productos.`, { + title: 'Validación completada', + type: 'success' + }); } else { console.error('❌ File processing failed - processSalesFile returned false'); throw new Error('Error procesando el archivo'); @@ -364,6 +398,40 @@ export const SmartInventorySetupStep: React.FC = ({ setEditingProduct(null); }; + // Handle generating suggestions after file validation + const handleGenerateSuggestions = async () => { + if (!validationResults?.product_list?.length) { + toast.addToast('No se encontraron productos para analizar', { + title: 'Error', + type: 'error' + }); + return; + } + + try { + setLocalStage('analyzing'); + const success = await generateProductSuggestions(validationResults.product_list); + + if (success) { + toast.addToast('Sugerencias generadas correctamente', { + title: 'Análisis completado', + type: 'success' + }); + } else { + toast.addToast('Error generando sugerencias de productos', { + title: 'Error en análisis', + type: 'error' + }); + } + } catch (error) { + console.error('Error generating suggestions:', error); + toast.addToast('Error generando sugerencias de productos', { + title: 'Error en análisis', + type: 'error' + }); + } + }; + // Update parent data useEffect(() => { const updatedData = { @@ -529,6 +597,57 @@ export const SmartInventorySetupStep: React.FC = ({ )} + {/* File Validated - User Confirmation Required */} + {stage === 'validated' && validationResults && ( + +
+
+ +
+

+ ¡Archivo Validado Correctamente! +

+

+ Hemos encontrado {validationResults.product_list?.length || 0} productos únicos en tu archivo de ventas. +

+ +
+

Lo que haremos a continuación:

+
    +
  • 🤖 Análisis con IA: Clasificaremos automáticamente tus productos
  • +
  • 📦 Configuración inteligente: Calcularemos niveles de stock óptimos
  • +
  • Tu aprobación: Podrás revisar y aprobar cada sugerencia
  • +
  • 🎯 Inventario personalizado: Crearemos tu inventario final
  • +
+
+ +
+ + + +
+
+
+ )} + {/* Processing Stages */} {(stage === 'validating' || stage === 'analyzing') && ( @@ -589,6 +708,23 @@ export const SmartInventorySetupStep: React.FC = ({ )} + {/* Waiting for Suggestions to Load */} + {(stage === 'review' || (onboardingStage === 'completed' && suggestions)) && products.length === 0 && suggestions && suggestions.length > 0 && ( + +
+
+ +
+

+ Preparando sugerencias... +

+

+ Convirtiendo {suggestions.length} productos en sugerencias personalizadas +

+
+
+ )} + {/* Review & Configure Stage */} {(stage === 'review') && products.length > 0 && (
diff --git a/frontend/src/hooks/business/onboarding/core/actions.ts b/frontend/src/hooks/business/onboarding/core/actions.ts index 86b09588..9653cb76 100644 --- a/frontend/src/hooks/business/onboarding/core/actions.ts +++ b/frontend/src/hooks/business/onboarding/core/actions.ts @@ -184,6 +184,25 @@ export const useOnboardingActions = () => { return result.success; }, [store, salesProcessing]); + const generateProductSuggestions = useCallback(async (productList: string[]): Promise => { + console.log('🎬 Actions - generateProductSuggestions started for', productList.length, 'products'); + store.setLoading(true); + + const result = await salesProcessing.generateProductSuggestions(productList); + console.log('🎬 Actions - generateProductSuggestions result:', result); + + store.setLoading(false); + + if (!result.success) { + console.error('❌ Actions - Suggestions generation failed:', result.error); + store.setError(result.error || 'Error generating product suggestions'); + } else { + console.log('✅ Actions - Product suggestions generated successfully'); + } + + return result.success; + }, [store, salesProcessing]); + const createInventoryFromSuggestions = useCallback(async ( suggestions: ProductSuggestionResponse[] ): Promise => { @@ -306,6 +325,7 @@ export const useOnboardingActions = () => { // Step-specific actions createTenant, processSalesFile, + generateProductSuggestions, // New function for separated suggestion generation createInventoryFromSuggestions, importSalesData, startTraining, diff --git a/frontend/src/hooks/business/onboarding/core/types.ts b/frontend/src/hooks/business/onboarding/core/types.ts index a4b8e39b..84f118c5 100644 --- a/frontend/src/hooks/business/onboarding/core/types.ts +++ b/frontend/src/hooks/business/onboarding/core/types.ts @@ -73,7 +73,7 @@ export interface OnboardingData { files?: { salesData?: File; }; - processingStage?: 'upload' | 'validating' | 'analyzing' | 'review' | 'completed' | 'error'; + processingStage?: 'upload' | 'validating' | 'validated' | 'analyzing' | 'review' | 'completed' | 'error'; processingResults?: { is_valid: boolean; total_records: number; @@ -161,7 +161,7 @@ export interface TenantCreationState extends ServiceState { } export interface SalesProcessingState extends ServiceState { - stage: 'idle' | 'validating' | 'analyzing' | 'completed' | 'error'; + stage: 'idle' | 'validating' | 'validated' | 'analyzing' | 'completed' | 'error'; progress: number; currentMessage: string; validationResults: any | null; diff --git a/frontend/src/hooks/business/onboarding/services/useSalesProcessing.ts b/frontend/src/hooks/business/onboarding/services/useSalesProcessing.ts index 0300688c..92181602 100644 --- a/frontend/src/hooks/business/onboarding/services/useSalesProcessing.ts +++ b/frontend/src/hooks/business/onboarding/services/useSalesProcessing.ts @@ -17,15 +17,15 @@ export const useSalesProcessing = () => { // Simple, direct state management const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - const [suggestions, setSuggestions] = useState(null); - const [stage, setStage] = useState<'idle' | 'validating' | 'analyzing' | 'completed' | 'error'>('idle'); + const [suggestions, setSuggestions] = useState([]); + const [stage, setStage] = useState<'idle' | 'validating' | 'validated' | 'analyzing' | 'completed' | 'error'>('idle'); const [progress, setProgress] = useState(0); const [currentMessage, setCurrentMessage] = useState(''); const [validationResults, setValidationResults] = useState(null); const updateProgress = useCallback(( progressValue: number, - stageValue: 'idle' | 'validating' | 'analyzing' | 'completed' | 'error', + stageValue: 'idle' | 'validating' | 'validated' | 'analyzing' | 'completed' | 'error', message: string, onProgress?: ProgressCallback ) => { @@ -158,18 +158,68 @@ export const useSalesProcessing = () => { throw new Error('No se encontraron productos válidos en el archivo'); } - // Stage 2: Generate AI suggestions - updateProgress(60, 'analyzing', 'Identificando productos únicos...', onProgress); - updateProgress(70, 'analyzing', 'Generando sugerencias de IA...', onProgress); + // Stage 2: File validation completed - WAIT FOR USER CONFIRMATION + updateProgress(100, 'validated', 'Archivo validado correctamente. Esperando confirmación del usuario...', onProgress); + + // Store validation results and wait for user action + setValidationResults(validationResult); + + console.log('✅ File validation completed:', validationResult.product_list?.length, 'products found'); + + // Update onboarding store - ONLY with validation results + setStepData('smart-inventory-setup', { + files: { salesData: file }, + processingStage: 'validated', // Changed from 'completed' + processingResults: validationResult, + // DON'T set suggestions here - they will be generated later + }); + + console.log('📊 Updated onboarding store with suggestions'); + + return { + success: true, + validationResults: validationResult, + // No suggestions returned from processFile - they will be generated separately + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Error procesando el archivo'; + + setError(errorMessage); + updateProgress(0, 'error', errorMessage, onProgress); + + return { + success: false, + }; + } finally { + setIsLoading(false); + } + }, [updateProgress, currentTenant, validateFile, extractProductList, setStepData]); + + const generateProductSuggestions = useCallback(async ( + productList: string[], + onProgress?: ProgressCallback + ): Promise<{ + success: boolean; + suggestions?: ProductSuggestionResponse[]; + error?: string; + }> => { + console.log('🚀 Generating product suggestions for', productList.length, 'products'); + setIsLoading(true); + setError(null); + + try { + updateProgress(10, 'analyzing', 'Iniciando análisis de productos...', onProgress); + updateProgress(30, 'analyzing', 'Identificando productos únicos...', onProgress); + updateProgress(50, 'analyzing', 'Generando sugerencias de IA...', onProgress); let suggestions: ProductSuggestionResponse[] = []; let suggestionError: string | null = null; try { - updateProgress(70, 'analyzing', 'Generando sugerencias de IA...', onProgress); - suggestions = await generateSuggestions(validationResult.product_list); + updateProgress(70, 'analyzing', 'Consultando servicios de IA...', onProgress); + suggestions = await generateSuggestions(productList); - console.log('🔍 After generateSuggestions call:', { + console.log('🔍 Generated suggestions:', { suggestionsReceived: suggestions?.length || 0, }); } catch (error) { @@ -177,11 +227,10 @@ export const useSalesProcessing = () => { suggestionError = errorMessage; console.error('❌ Suggestions generation failed:', errorMessage); - // Still continue with empty suggestions - user can manually add products later + // Create basic suggestions from product names as fallback updateProgress(80, 'analyzing', 'Preparando productos básicos...', onProgress); - // Create basic suggestions from product names as fallback - suggestions = validationResult.product_list.map((productName, index) => ({ + suggestions = productList.map((productName, index) => ({ suggestion_id: `manual-${index}`, original_name: productName, suggested_name: productName, @@ -200,42 +249,40 @@ export const useSalesProcessing = () => { } updateProgress(90, 'analyzing', 'Analizando patrones de venta...', onProgress); - updateProgress(100, 'completed', 'Procesamiento completado exitosamente', onProgress); + updateProgress(100, 'completed', 'Sugerencias generadas correctamente', onProgress); - // Update state with suggestions (even if empty or fallback) + // Update state with suggestions setSuggestions(suggestions || []); - setValidationResults(validationResult); - console.log('✅ Processing completed:', suggestions?.length || 0, 'suggestions generated'); + console.log('✅ Suggestions generation completed:', suggestions?.length || 0, 'suggestions'); - // Update onboarding store - setStepData('smart-inventory-setup', { - files: { salesData: file }, + // Update onboarding store with suggestions + setStepData('smart-inventory-setup', (prevData) => ({ + ...prevData, processingStage: 'completed', - processingResults: validationResult, suggestions: suggestions || [], suggestionError: suggestionError, - }); - - console.log('📊 Updated onboarding store with suggestions'); + })); return { success: true, - validationResults: validationResult, suggestions: suggestions || [], + error: suggestionError || undefined, }; } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Error procesando el archivo'; + const errorMessage = error instanceof Error ? error.message : 'Error generando sugerencias'; setError(errorMessage); updateProgress(0, 'error', errorMessage, onProgress); return { success: false, + error: errorMessage, }; + } finally { + setIsLoading(false); } - }, [updateProgress, currentTenant, validateFile, extractProductList, generateSuggestions, setStepData]); - + }, [updateProgress, generateSuggestions, setStepData]); const clearError = useCallback(() => { setError(null); @@ -244,7 +291,7 @@ export const useSalesProcessing = () => { const reset = useCallback(() => { setIsLoading(false); setError(null); - setSuggestions(null); + setSuggestions([]); setStage('idle'); setProgress(0); setCurrentMessage(''); @@ -263,7 +310,7 @@ export const useSalesProcessing = () => { // Actions processFile, - generateSuggestions, + generateProductSuggestions, // New separated function clearError, reset, }; diff --git a/frontend/src/hooks/business/onboarding/useOnboarding.ts b/frontend/src/hooks/business/onboarding/useOnboarding.ts index 597669bb..6958bf23 100644 --- a/frontend/src/hooks/business/onboarding/useOnboarding.ts +++ b/frontend/src/hooks/business/onboarding/useOnboarding.ts @@ -67,7 +67,7 @@ export const useOnboarding = () => { progress: salesProcessing.progress, currentMessage: salesProcessing.currentMessage, validationResults: salesProcessing.validationResults, - suggestions: salesProcessing.suggestions, + suggestions: Array.isArray(salesProcessing.suggestions) ? salesProcessing.suggestions : [], }, inventorySetup: { @@ -123,6 +123,7 @@ export const useOnboarding = () => { // Step-specific actions createTenant: actions.createTenant, processSalesFile: actions.processSalesFile, + generateProductSuggestions: actions.generateProductSuggestions, // New separated function createInventoryFromSuggestions: actions.createInventoryFromSuggestions, importSalesData: actions.importSalesData, startTraining: actions.startTraining, diff --git a/services/inventory/app/services/inventory_alert_service.py b/services/inventory/app/services/inventory_alert_service.py index b515fd20..a7825913 100644 --- a/services/inventory/app/services/inventory_alert_service.py +++ b/services/inventory/app/services/inventory_alert_service.py @@ -11,6 +11,7 @@ from uuid import UUID from datetime import datetime, timedelta import structlog from apscheduler.triggers.cron import CronTrigger +from sqlalchemy import text from shared.alerts.base_service import BaseAlertService, AlertServiceMixin from shared.alerts.templates import format_item_message @@ -23,46 +24,48 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin): def setup_scheduled_checks(self): """Inventory-specific scheduled checks for alerts and recommendations""" - # Critical stock checks - every 5 minutes (alerts) + # SPACED SCHEDULING TO PREVENT CONCURRENT EXECUTION AND DEADLOCKS + + # Critical stock checks - every 5 minutes (alerts) - Start at minute 0, 5, 10, etc. self.scheduler.add_job( self.check_stock_levels, - CronTrigger(minute='*/5'), + CronTrigger(minute='0,5,10,15,20,25,30,35,40,45,50,55'), # Explicit minutes id='stock_levels', misfire_grace_time=30, max_instances=1 ) - # Expiry checks - every 2 minutes (food safety critical, alerts) + # Expiry checks - every 2 minutes (food safety critical, alerts) - Start at minute 1, 3, 7, etc. self.scheduler.add_job( self.check_expiring_products, - CronTrigger(minute='*/2'), + CronTrigger(minute='1,3,7,9,11,13,17,19,21,23,27,29,31,33,37,39,41,43,47,49,51,53,57,59'), # Avoid conflicts id='expiry_check', misfire_grace_time=30, max_instances=1 ) - # Temperature checks - every 2 minutes (alerts) + # Temperature checks - every 5 minutes (alerts) - Start at minute 2, 12, 22, etc. (reduced frequency) self.scheduler.add_job( self.check_temperature_breaches, - CronTrigger(minute='*/2'), + CronTrigger(minute='2,12,22,32,42,52'), # Every 10 minutes, offset by 2 id='temperature_check', misfire_grace_time=30, max_instances=1 ) - # Inventory optimization - every 30 minutes (recommendations) + # Inventory optimization - every 30 minutes (recommendations) - Start at minute 15, 45 self.scheduler.add_job( self.generate_inventory_recommendations, - CronTrigger(minute='*/30'), + CronTrigger(minute='15,45'), # Offset to avoid conflicts id='inventory_recs', misfire_grace_time=120, max_instances=1 ) - # Waste reduction analysis - every hour (recommendations) + # Waste reduction analysis - every hour (recommendations) - Start at minute 30 self.scheduler.add_job( self.generate_waste_reduction_recommendations, - CronTrigger(minute='0'), + CronTrigger(minute='30'), # Offset to avoid conflicts id='waste_reduction_recs', misfire_grace_time=300, max_instances=1 @@ -96,7 +99,7 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin): GREATEST(0, i.low_stock_threshold - COALESCE(SUM(s.current_quantity), 0)) as shortage_amount FROM ingredients i LEFT JOIN stock s ON s.ingredient_id = i.id AND s.is_available = true - WHERE i.tenant_id = $1 AND i.is_active = true + WHERE i.tenant_id = :tenant_id AND i.is_active = true GROUP BY i.id, i.name, i.tenant_id, i.low_stock_threshold, i.max_stock_level, i.reorder_point ) SELECT * FROM stock_analysis WHERE status != 'normal' @@ -113,13 +116,16 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin): for tenant_id in tenants: try: - from sqlalchemy import text - async with self.db_manager.get_session() as session: - result = await session.execute(text(query), {"tenant_id": tenant_id}) - issues = result.fetchall() + # Add timeout to prevent hanging connections + async with asyncio.timeout(30): # 30 second timeout + async with self.db_manager.get_background_session() as session: + result = await session.execute(text(query), {"tenant_id": tenant_id}) + issues = result.fetchall() for issue in issues: - await self._process_stock_issue(tenant_id, issue) + # Convert SQLAlchemy Row to dictionary for easier access + issue_dict = dict(issue._mapping) if hasattr(issue, '_mapping') else dict(issue) + await self._process_stock_issue(tenant_id, issue_dict) except Exception as e: logger.error("Error checking stock for tenant", @@ -227,18 +233,21 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin): ORDER BY s.expiration_date ASC """ - from sqlalchemy import text - async with self.db_manager.get_session() as session: - result = await session.execute(text(query)) - expiring_items = result.fetchall() + # Add timeout to prevent hanging connections + async with asyncio.timeout(30): # 30 second timeout + async with self.db_manager.get_background_session() as session: + result = await session.execute(text(query)) + expiring_items = result.fetchall() # Group by tenant by_tenant = {} for item in expiring_items: - tenant_id = item['tenant_id'] + # Convert SQLAlchemy Row to dictionary for easier access + item_dict = dict(item._mapping) if hasattr(item, '_mapping') else dict(item) + tenant_id = item_dict['tenant_id'] if tenant_id not in by_tenant: by_tenant[tenant_id] = [] - by_tenant[tenant_id].append(item) + by_tenant[tenant_id].append(item_dict) for tenant_id, items in by_tenant.items(): await self._process_expiring_items(tenant_id, items) @@ -328,13 +337,16 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin): ORDER BY t.temperature_celsius DESC, t.deviation_minutes DESC """ - from sqlalchemy import text - async with self.db_manager.get_session() as session: - result = await session.execute(text(query)) - breaches = result.fetchall() + # Add timeout to prevent hanging connections + async with asyncio.timeout(30): # 30 second timeout + async with self.db_manager.get_background_session() as session: + result = await session.execute(text(query)) + breaches = result.fetchall() for breach in breaches: - await self._process_temperature_breach(breach) + # Convert SQLAlchemy Row to dictionary for easier access + breach_dict = dict(breach._mapping) if hasattr(breach, '_mapping') else dict(breach) + await self._process_temperature_breach(breach_dict) except Exception as e: logger.error("Temperature check failed", error=str(e)) @@ -378,13 +390,13 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin): }, item_type='alert') # Update alert triggered flag to avoid spam - from sqlalchemy import text - async with self.db_manager.get_session() as session: - await session.execute( - text("UPDATE temperature_logs SET alert_triggered = true WHERE id = :id"), - {"id": breach['id']} - ) - await session.commit() + # Add timeout to prevent hanging connections + async with asyncio.timeout(10): # 10 second timeout for simple update + async with self.db_manager.get_background_session() as session: + await session.execute( + text("UPDATE temperature_logs SET alert_triggered = true WHERE id = :id"), + {"id": breach['id']} + ) except Exception as e: logger.error("Error processing temperature breach", @@ -412,7 +424,7 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin): FROM ingredients i LEFT JOIN stock s ON s.ingredient_id = i.id AND s.is_available = true LEFT JOIN stock_movements sm ON sm.ingredient_id = i.id - WHERE i.is_active = true AND i.tenant_id = $1 + WHERE i.is_active = true AND i.tenant_id = :tenant_id GROUP BY i.id, i.name, i.tenant_id, i.low_stock_threshold, i.max_stock_level HAVING COUNT(sm.id) FILTER (WHERE sm.movement_type = 'production_use' AND sm.created_at > CURRENT_DATE - INTERVAL '30 days') >= 3 @@ -438,12 +450,16 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin): for tenant_id in tenants: try: from sqlalchemy import text - async with self.db_manager.get_session() as session: - result = await session.execute(text(query), {"tenant_id": tenant_id}) + # Add timeout to prevent hanging connections + async with asyncio.timeout(30): # 30 second timeout + async with self.db_manager.get_background_session() as session: + result = await session.execute(text(query), {"tenant_id": tenant_id}) recommendations = result.fetchall() for rec in recommendations: - await self._generate_stock_recommendation(tenant_id, rec) + # Convert SQLAlchemy Row to dictionary for easier access + rec_dict = dict(rec._mapping) if hasattr(rec, '_mapping') else dict(rec) + await self._generate_stock_recommendation(tenant_id, rec_dict) except Exception as e: logger.error("Error generating recommendations for tenant", @@ -524,7 +540,7 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin): JOIN stock_movements sm ON sm.ingredient_id = i.id WHERE sm.movement_type = 'waste' AND sm.created_at > CURRENT_DATE - INTERVAL '30 days' - AND i.tenant_id = $1 + AND i.tenant_id = :tenant_id GROUP BY i.id, i.name, i.tenant_id, sm.reason_code HAVING SUM(sm.quantity) > 5 -- More than 5kg wasted ORDER BY total_waste_30d DESC @@ -535,12 +551,16 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin): for tenant_id in tenants: try: from sqlalchemy import text - async with self.db_manager.get_session() as session: - result = await session.execute(text(query), {"tenant_id": tenant_id}) + # Add timeout to prevent hanging connections + async with asyncio.timeout(30): # 30 second timeout + async with self.db_manager.get_background_session() as session: + result = await session.execute(text(query), {"tenant_id": tenant_id}) waste_data = result.fetchall() for waste in waste_data: - await self._generate_waste_recommendation(tenant_id, waste) + # Convert SQLAlchemy Row to dictionary for easier access + waste_dict = dict(waste._mapping) if hasattr(waste, '_mapping') else dict(waste) + await self._generate_waste_recommendation(tenant_id, waste_dict) except Exception as e: logger.error("Error generating waste recommendations", @@ -703,6 +723,28 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin): except Exception as e: logger.error("Error handling order placed event", error=str(e)) + async def get_active_tenants(self) -> List[UUID]: + """Get list of active tenant IDs from ingredients table (inventory service specific)""" + try: + query = text("SELECT DISTINCT tenant_id FROM ingredients WHERE is_active = true") + # Add timeout to prevent hanging connections + async with asyncio.timeout(10): # 10 second timeout + async with self.db_manager.get_background_session() as session: + result = await session.execute(query) + # Handle PostgreSQL UUID objects properly + tenant_ids = [] + for row in result.fetchall(): + tenant_id = row.tenant_id + # Convert to UUID if it's not already + if isinstance(tenant_id, UUID): + tenant_ids.append(tenant_id) + else: + tenant_ids.append(UUID(str(tenant_id))) + return tenant_ids + except Exception as e: + logger.error("Error fetching active tenants from ingredients", error=str(e)) + return [] + async def get_stock_after_order(self, ingredient_id: str, order_quantity: float) -> Optional[Dict[str, Any]]: """Get stock information after hypothetical order""" try: @@ -710,15 +752,19 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin): SELECT i.id, i.name, COALESCE(SUM(s.current_quantity), 0) as current_stock, i.low_stock_threshold as minimum_stock, - (COALESCE(SUM(s.current_quantity), 0) - $2) as remaining + (COALESCE(SUM(s.current_quantity), 0) - :order_quantity) as remaining FROM ingredients i LEFT JOIN stock s ON s.ingredient_id = i.id AND s.is_available = true - WHERE i.id = $1 + WHERE i.id = :ingredient_id GROUP BY i.id, i.name, i.low_stock_threshold """ - result = await self.db_manager.fetchrow(query, ingredient_id, order_quantity) - return dict(result) if result else None + # Add timeout to prevent hanging connections + async with asyncio.timeout(10): # 10 second timeout + async with self.db_manager.get_background_session() as session: + result = await session.execute(text(query), {"ingredient_id": ingredient_id, "order_quantity": order_quantity}) + row = result.fetchone() + return dict(row) if row else None except Exception as e: logger.error("Error getting stock after order",