Start integrating the onboarding flow with backend 17

This commit is contained in:
Urtzi Alfaro
2025-09-08 16:02:20 +02:00
parent 54102f7f4b
commit 201817a1be
6 changed files with 353 additions and 103 deletions

View File

@@ -85,6 +85,7 @@ export const SmartInventorySetupStep: React.FC<OnboardingStepProps> = ({
// Use onboarding hooks // Use onboarding hooks
const { const {
processSalesFile, processSalesFile,
generateProductSuggestions, // New separated function
createInventoryFromSuggestions, createInventoryFromSuggestions,
importSalesData, importSalesData,
salesProcessing: { salesProcessing: {
@@ -120,8 +121,11 @@ export const SmartInventorySetupStep: React.FC<OnboardingStepProps> = ({
const tenantCreatedSuccessfully = tenantCreation.isSuccess; const tenantCreatedSuccessfully = tenantCreation.isSuccess;
const tenantCreatedInOnboarding = data.bakery?.tenantCreated === true; 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; return result;
}; };
@@ -152,20 +156,55 @@ export const SmartInventorySetupStep: React.FC<OnboardingStepProps> = ({
productsLength: products.length, productsLength: products.length,
suggestionsIsArray: Array.isArray(suggestions), suggestionsIsArray: Array.isArray(suggestions),
suggestionsType: typeof 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'); console.log('✅ Converting suggestions to products and setting stage to review');
const newProducts = convertSuggestionsToCards(suggestions); try {
setProducts(newProducts); const newProducts = convertSuggestionsToCards(suggestions);
setLocalStage('review'); 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 // Derive current stage
const stage = (localStage === 'completed' || localStage === 'error') const stage = (() => {
? localStage // If local stage is explicitly set to completed or error, use it
: (onboardingStage === 'completed' ? 'review' : onboardingStage || localStage); 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 progress = onboardingProgress || 0;
const currentMessage = onboardingMessage || ''; const currentMessage = onboardingMessage || '';
@@ -176,7 +215,10 @@ export const SmartInventorySetupStep: React.FC<OnboardingStepProps> = ({
onboardingStage, onboardingStage,
finalStage: stage, finalStage: stage,
productsLength: products.length, 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]); }, [localStage, onboardingStage, stage, products.length, suggestions?.length]);
@@ -275,22 +317,14 @@ export const SmartInventorySetupStep: React.FC<OnboardingStepProps> = ({
console.log('🔄 SmartInventorySetup - Processing result:', { success }); console.log('🔄 SmartInventorySetup - Processing result:', { success });
if (success) { if (success) {
console.log('✅ File processed successfully, setting stage to review'); console.log('✅ File validation completed successfully');
setLocalStage('review'); // 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) toast.addToast(`Archivo validado correctamente. Se encontraron ${validationResults?.product_list?.length || 0} productos.`, {
const stepData = data.allStepData?.['smart-inventory-setup']; title: 'Validación completada',
if (stepData?.suggestionError) { type: 'success'
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'
});
}
} else { } else {
console.error('❌ File processing failed - processSalesFile returned false'); console.error('❌ File processing failed - processSalesFile returned false');
throw new Error('Error procesando el archivo'); throw new Error('Error procesando el archivo');
@@ -364,6 +398,40 @@ export const SmartInventorySetupStep: React.FC<OnboardingStepProps> = ({
setEditingProduct(null); 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 // Update parent data
useEffect(() => { useEffect(() => {
const updatedData = { const updatedData = {
@@ -529,6 +597,57 @@ export const SmartInventorySetupStep: React.FC<OnboardingStepProps> = ({
</> </>
)} )}
{/* File Validated - User Confirmation Required */}
{stage === 'validated' && validationResults && (
<Card className="p-8">
<div className="text-center">
<div className="w-20 h-20 bg-[var(--color-success)]/10 rounded-full flex items-center justify-center mx-auto mb-6">
<CheckCircle className="w-10 h-10 text-[var(--color-success)]" />
</div>
<h3 className="text-2xl font-semibold text-[var(--color-success)] mb-4">
¡Archivo Validado Correctamente!
</h3>
<p className="text-[var(--text-secondary)] text-lg mb-6 max-w-2xl mx-auto">
Hemos encontrado <strong>{validationResults.product_list?.length || 0} productos únicos</strong> en tu archivo de ventas.
</p>
<div className="bg-[var(--bg-secondary)] rounded-lg p-6 mb-8 max-w-2xl mx-auto">
<h4 className="font-semibold text-[var(--text-primary)] mb-3">Lo que haremos a continuación:</h4>
<ul className="text-[var(--text-secondary)] text-left space-y-2">
<li>🤖 <strong>Análisis con IA:</strong> Clasificaremos automáticamente tus productos</li>
<li>📦 <strong>Configuración inteligente:</strong> Calcularemos niveles de stock óptimos</li>
<li> <strong>Tu aprobación:</strong> Podrás revisar y aprobar cada sugerencia</li>
<li>🎯 <strong>Inventario personalizado:</strong> Crearemos tu inventario final</li>
</ul>
</div>
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
<Button
onClick={handleGenerateSuggestions}
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary)]/90 text-white shadow-lg px-8 py-3 text-lg"
>
<Brain className="w-5 h-5 mr-2" />
Generar Sugerencias con IA
</Button>
<Button
variant="outline"
onClick={() => {
setLocalStage('upload');
setUploadedFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}}
className="text-[var(--text-secondary)] border-[var(--border-secondary)] hover:bg-[var(--bg-tertiary)] px-6 py-3"
>
Subir otro archivo
</Button>
</div>
</div>
</Card>
)}
{/* Processing Stages */} {/* Processing Stages */}
{(stage === 'validating' || stage === 'analyzing') && ( {(stage === 'validating' || stage === 'analyzing') && (
<Card className="p-8"> <Card className="p-8">
@@ -589,6 +708,23 @@ export const SmartInventorySetupStep: React.FC<OnboardingStepProps> = ({
</Card> </Card>
)} )}
{/* Waiting for Suggestions to Load */}
{(stage === 'review' || (onboardingStage === 'completed' && suggestions)) && products.length === 0 && suggestions && suggestions.length > 0 && (
<Card className="p-8">
<div className="text-center">
<div className="w-16 h-16 bg-[var(--color-info)]/10 rounded-full flex items-center justify-center mx-auto mb-6 animate-pulse">
<Brain className="w-8 h-8 text-[var(--color-info)]" />
</div>
<h3 className="text-2xl font-semibold text-[var(--text-primary)] mb-2">
Preparando sugerencias...
</h3>
<p className="text-[var(--text-secondary)]">
Convirtiendo {suggestions.length} productos en sugerencias personalizadas
</p>
</div>
</Card>
)}
{/* Review & Configure Stage */} {/* Review & Configure Stage */}
{(stage === 'review') && products.length > 0 && ( {(stage === 'review') && products.length > 0 && (
<div className="space-y-8"> <div className="space-y-8">

View File

@@ -184,6 +184,25 @@ export const useOnboardingActions = () => {
return result.success; return result.success;
}, [store, salesProcessing]); }, [store, salesProcessing]);
const generateProductSuggestions = useCallback(async (productList: string[]): Promise<boolean> => {
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 ( const createInventoryFromSuggestions = useCallback(async (
suggestions: ProductSuggestionResponse[] suggestions: ProductSuggestionResponse[]
): Promise<boolean> => { ): Promise<boolean> => {
@@ -306,6 +325,7 @@ export const useOnboardingActions = () => {
// Step-specific actions // Step-specific actions
createTenant, createTenant,
processSalesFile, processSalesFile,
generateProductSuggestions, // New function for separated suggestion generation
createInventoryFromSuggestions, createInventoryFromSuggestions,
importSalesData, importSalesData,
startTraining, startTraining,

View File

@@ -73,7 +73,7 @@ export interface OnboardingData {
files?: { files?: {
salesData?: File; salesData?: File;
}; };
processingStage?: 'upload' | 'validating' | 'analyzing' | 'review' | 'completed' | 'error'; processingStage?: 'upload' | 'validating' | 'validated' | 'analyzing' | 'review' | 'completed' | 'error';
processingResults?: { processingResults?: {
is_valid: boolean; is_valid: boolean;
total_records: number; total_records: number;
@@ -161,7 +161,7 @@ export interface TenantCreationState extends ServiceState<BakeryRegistration> {
} }
export interface SalesProcessingState extends ServiceState<any> { export interface SalesProcessingState extends ServiceState<any> {
stage: 'idle' | 'validating' | 'analyzing' | 'completed' | 'error'; stage: 'idle' | 'validating' | 'validated' | 'analyzing' | 'completed' | 'error';
progress: number; progress: number;
currentMessage: string; currentMessage: string;
validationResults: any | null; validationResults: any | null;

View File

@@ -17,15 +17,15 @@ export const useSalesProcessing = () => {
// Simple, direct state management // Simple, direct state management
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [suggestions, setSuggestions] = useState<ProductSuggestionResponse[] | null>(null); const [suggestions, setSuggestions] = useState<ProductSuggestionResponse[]>([]);
const [stage, setStage] = useState<'idle' | 'validating' | 'analyzing' | 'completed' | 'error'>('idle'); const [stage, setStage] = useState<'idle' | 'validating' | 'validated' | 'analyzing' | 'completed' | 'error'>('idle');
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const [currentMessage, setCurrentMessage] = useState(''); const [currentMessage, setCurrentMessage] = useState('');
const [validationResults, setValidationResults] = useState<any | null>(null); const [validationResults, setValidationResults] = useState<any | null>(null);
const updateProgress = useCallback(( const updateProgress = useCallback((
progressValue: number, progressValue: number,
stageValue: 'idle' | 'validating' | 'analyzing' | 'completed' | 'error', stageValue: 'idle' | 'validating' | 'validated' | 'analyzing' | 'completed' | 'error',
message: string, message: string,
onProgress?: ProgressCallback onProgress?: ProgressCallback
) => { ) => {
@@ -158,18 +158,68 @@ export const useSalesProcessing = () => {
throw new Error('No se encontraron productos válidos en el archivo'); throw new Error('No se encontraron productos válidos en el archivo');
} }
// Stage 2: Generate AI suggestions // Stage 2: File validation completed - WAIT FOR USER CONFIRMATION
updateProgress(60, 'analyzing', 'Identificando productos únicos...', onProgress); updateProgress(100, 'validated', 'Archivo validado correctamente. Esperando confirmación del usuario...', onProgress);
updateProgress(70, 'analyzing', 'Generando sugerencias de IA...', 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 suggestions: ProductSuggestionResponse[] = [];
let suggestionError: string | null = null; let suggestionError: string | null = null;
try { try {
updateProgress(70, 'analyzing', 'Generando sugerencias de IA...', onProgress); updateProgress(70, 'analyzing', 'Consultando servicios de IA...', onProgress);
suggestions = await generateSuggestions(validationResult.product_list); suggestions = await generateSuggestions(productList);
console.log('🔍 After generateSuggestions call:', { console.log('🔍 Generated suggestions:', {
suggestionsReceived: suggestions?.length || 0, suggestionsReceived: suggestions?.length || 0,
}); });
} catch (error) { } catch (error) {
@@ -177,11 +227,10 @@ export const useSalesProcessing = () => {
suggestionError = errorMessage; suggestionError = errorMessage;
console.error('❌ Suggestions generation failed:', 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); updateProgress(80, 'analyzing', 'Preparando productos básicos...', onProgress);
// Create basic suggestions from product names as fallback suggestions = productList.map((productName, index) => ({
suggestions = validationResult.product_list.map((productName, index) => ({
suggestion_id: `manual-${index}`, suggestion_id: `manual-${index}`,
original_name: productName, original_name: productName,
suggested_name: productName, suggested_name: productName,
@@ -200,42 +249,40 @@ export const useSalesProcessing = () => {
} }
updateProgress(90, 'analyzing', 'Analizando patrones de venta...', onProgress); 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 || []); 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 // Update onboarding store with suggestions
setStepData('smart-inventory-setup', { setStepData('smart-inventory-setup', (prevData) => ({
files: { salesData: file }, ...prevData,
processingStage: 'completed', processingStage: 'completed',
processingResults: validationResult,
suggestions: suggestions || [], suggestions: suggestions || [],
suggestionError: suggestionError, suggestionError: suggestionError,
}); }));
console.log('📊 Updated onboarding store with suggestions');
return { return {
success: true, success: true,
validationResults: validationResult,
suggestions: suggestions || [], suggestions: suggestions || [],
error: suggestionError || undefined,
}; };
} catch (error) { } 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); setError(errorMessage);
updateProgress(0, 'error', errorMessage, onProgress); updateProgress(0, 'error', errorMessage, onProgress);
return { return {
success: false, success: false,
error: errorMessage,
}; };
} finally {
setIsLoading(false);
} }
}, [updateProgress, currentTenant, validateFile, extractProductList, generateSuggestions, setStepData]); }, [updateProgress, generateSuggestions, setStepData]);
const clearError = useCallback(() => { const clearError = useCallback(() => {
setError(null); setError(null);
@@ -244,7 +291,7 @@ export const useSalesProcessing = () => {
const reset = useCallback(() => { const reset = useCallback(() => {
setIsLoading(false); setIsLoading(false);
setError(null); setError(null);
setSuggestions(null); setSuggestions([]);
setStage('idle'); setStage('idle');
setProgress(0); setProgress(0);
setCurrentMessage(''); setCurrentMessage('');
@@ -263,7 +310,7 @@ export const useSalesProcessing = () => {
// Actions // Actions
processFile, processFile,
generateSuggestions, generateProductSuggestions, // New separated function
clearError, clearError,
reset, reset,
}; };

View File

@@ -67,7 +67,7 @@ export const useOnboarding = () => {
progress: salesProcessing.progress, progress: salesProcessing.progress,
currentMessage: salesProcessing.currentMessage, currentMessage: salesProcessing.currentMessage,
validationResults: salesProcessing.validationResults, validationResults: salesProcessing.validationResults,
suggestions: salesProcessing.suggestions, suggestions: Array.isArray(salesProcessing.suggestions) ? salesProcessing.suggestions : [],
}, },
inventorySetup: { inventorySetup: {
@@ -123,6 +123,7 @@ export const useOnboarding = () => {
// Step-specific actions // Step-specific actions
createTenant: actions.createTenant, createTenant: actions.createTenant,
processSalesFile: actions.processSalesFile, processSalesFile: actions.processSalesFile,
generateProductSuggestions: actions.generateProductSuggestions, // New separated function
createInventoryFromSuggestions: actions.createInventoryFromSuggestions, createInventoryFromSuggestions: actions.createInventoryFromSuggestions,
importSalesData: actions.importSalesData, importSalesData: actions.importSalesData,
startTraining: actions.startTraining, startTraining: actions.startTraining,

View File

@@ -11,6 +11,7 @@ from uuid import UUID
from datetime import datetime, timedelta from datetime import datetime, timedelta
import structlog import structlog
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.cron import CronTrigger
from sqlalchemy import text
from shared.alerts.base_service import BaseAlertService, AlertServiceMixin from shared.alerts.base_service import BaseAlertService, AlertServiceMixin
from shared.alerts.templates import format_item_message from shared.alerts.templates import format_item_message
@@ -23,46 +24,48 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin):
def setup_scheduled_checks(self): def setup_scheduled_checks(self):
"""Inventory-specific scheduled checks for alerts and recommendations""" """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.scheduler.add_job(
self.check_stock_levels, 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', id='stock_levels',
misfire_grace_time=30, misfire_grace_time=30,
max_instances=1 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.scheduler.add_job(
self.check_expiring_products, 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', id='expiry_check',
misfire_grace_time=30, misfire_grace_time=30,
max_instances=1 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.scheduler.add_job(
self.check_temperature_breaches, self.check_temperature_breaches,
CronTrigger(minute='*/2'), CronTrigger(minute='2,12,22,32,42,52'), # Every 10 minutes, offset by 2
id='temperature_check', id='temperature_check',
misfire_grace_time=30, misfire_grace_time=30,
max_instances=1 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.scheduler.add_job(
self.generate_inventory_recommendations, self.generate_inventory_recommendations,
CronTrigger(minute='*/30'), CronTrigger(minute='15,45'), # Offset to avoid conflicts
id='inventory_recs', id='inventory_recs',
misfire_grace_time=120, misfire_grace_time=120,
max_instances=1 max_instances=1
) )
# Waste reduction analysis - every hour (recommendations) # Waste reduction analysis - every hour (recommendations) - Start at minute 30
self.scheduler.add_job( self.scheduler.add_job(
self.generate_waste_reduction_recommendations, self.generate_waste_reduction_recommendations,
CronTrigger(minute='0'), CronTrigger(minute='30'), # Offset to avoid conflicts
id='waste_reduction_recs', id='waste_reduction_recs',
misfire_grace_time=300, misfire_grace_time=300,
max_instances=1 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 GREATEST(0, i.low_stock_threshold - COALESCE(SUM(s.current_quantity), 0)) as shortage_amount
FROM ingredients i FROM ingredients i
LEFT JOIN stock s ON s.ingredient_id = i.id AND s.is_available = true 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 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' SELECT * FROM stock_analysis WHERE status != 'normal'
@@ -113,13 +116,16 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin):
for tenant_id in tenants: for tenant_id in tenants:
try: try:
from sqlalchemy import text # Add timeout to prevent hanging connections
async with self.db_manager.get_session() as session: async with asyncio.timeout(30): # 30 second timeout
result = await session.execute(text(query), {"tenant_id": tenant_id}) async with self.db_manager.get_background_session() as session:
issues = result.fetchall() result = await session.execute(text(query), {"tenant_id": tenant_id})
issues = result.fetchall()
for issue in issues: 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: except Exception as e:
logger.error("Error checking stock for tenant", logger.error("Error checking stock for tenant",
@@ -227,18 +233,21 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin):
ORDER BY s.expiration_date ASC ORDER BY s.expiration_date ASC
""" """
from sqlalchemy import text # Add timeout to prevent hanging connections
async with self.db_manager.get_session() as session: async with asyncio.timeout(30): # 30 second timeout
result = await session.execute(text(query)) async with self.db_manager.get_background_session() as session:
expiring_items = result.fetchall() result = await session.execute(text(query))
expiring_items = result.fetchall()
# Group by tenant # Group by tenant
by_tenant = {} by_tenant = {}
for item in expiring_items: 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: if tenant_id not in by_tenant:
by_tenant[tenant_id] = [] 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(): for tenant_id, items in by_tenant.items():
await self._process_expiring_items(tenant_id, 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 ORDER BY t.temperature_celsius DESC, t.deviation_minutes DESC
""" """
from sqlalchemy import text # Add timeout to prevent hanging connections
async with self.db_manager.get_session() as session: async with asyncio.timeout(30): # 30 second timeout
result = await session.execute(text(query)) async with self.db_manager.get_background_session() as session:
breaches = result.fetchall() result = await session.execute(text(query))
breaches = result.fetchall()
for breach in breaches: 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: except Exception as e:
logger.error("Temperature check failed", error=str(e)) logger.error("Temperature check failed", error=str(e))
@@ -378,13 +390,13 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin):
}, item_type='alert') }, item_type='alert')
# Update alert triggered flag to avoid spam # Update alert triggered flag to avoid spam
from sqlalchemy import text # Add timeout to prevent hanging connections
async with self.db_manager.get_session() as session: async with asyncio.timeout(10): # 10 second timeout for simple update
await session.execute( async with self.db_manager.get_background_session() as session:
text("UPDATE temperature_logs SET alert_triggered = true WHERE id = :id"), await session.execute(
{"id": breach['id']} text("UPDATE temperature_logs SET alert_triggered = true WHERE id = :id"),
) {"id": breach['id']}
await session.commit() )
except Exception as e: except Exception as e:
logger.error("Error processing temperature breach", logger.error("Error processing temperature breach",
@@ -412,7 +424,7 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin):
FROM ingredients i FROM ingredients i
LEFT JOIN stock s ON s.ingredient_id = i.id AND s.is_available = true 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 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 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' HAVING COUNT(sm.id) FILTER (WHERE sm.movement_type = 'production_use'
AND sm.created_at > CURRENT_DATE - INTERVAL '30 days') >= 3 AND sm.created_at > CURRENT_DATE - INTERVAL '30 days') >= 3
@@ -438,12 +450,16 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin):
for tenant_id in tenants: for tenant_id in tenants:
try: try:
from sqlalchemy import text from sqlalchemy import text
async with self.db_manager.get_session() as session: # Add timeout to prevent hanging connections
result = await session.execute(text(query), {"tenant_id": tenant_id}) 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() recommendations = result.fetchall()
for rec in recommendations: 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: except Exception as e:
logger.error("Error generating recommendations for tenant", 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 JOIN stock_movements sm ON sm.ingredient_id = i.id
WHERE sm.movement_type = 'waste' WHERE sm.movement_type = 'waste'
AND sm.created_at > CURRENT_DATE - INTERVAL '30 days' 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 GROUP BY i.id, i.name, i.tenant_id, sm.reason_code
HAVING SUM(sm.quantity) > 5 -- More than 5kg wasted HAVING SUM(sm.quantity) > 5 -- More than 5kg wasted
ORDER BY total_waste_30d DESC ORDER BY total_waste_30d DESC
@@ -535,12 +551,16 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin):
for tenant_id in tenants: for tenant_id in tenants:
try: try:
from sqlalchemy import text from sqlalchemy import text
async with self.db_manager.get_session() as session: # Add timeout to prevent hanging connections
result = await session.execute(text(query), {"tenant_id": tenant_id}) 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() waste_data = result.fetchall()
for waste in waste_data: 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: except Exception as e:
logger.error("Error generating waste recommendations", logger.error("Error generating waste recommendations",
@@ -703,6 +723,28 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin):
except Exception as e: except Exception as e:
logger.error("Error handling order placed event", error=str(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]]: async def get_stock_after_order(self, ingredient_id: str, order_quantity: float) -> Optional[Dict[str, Any]]:
"""Get stock information after hypothetical order""" """Get stock information after hypothetical order"""
try: try:
@@ -710,15 +752,19 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin):
SELECT i.id, i.name, SELECT i.id, i.name,
COALESCE(SUM(s.current_quantity), 0) as current_stock, COALESCE(SUM(s.current_quantity), 0) as current_stock,
i.low_stock_threshold as minimum_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 FROM ingredients i
LEFT JOIN stock s ON s.ingredient_id = i.id AND s.is_available = true 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 GROUP BY i.id, i.name, i.low_stock_threshold
""" """
result = await self.db_manager.fetchrow(query, ingredient_id, order_quantity) # Add timeout to prevent hanging connections
return dict(result) if result else None 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: except Exception as e:
logger.error("Error getting stock after order", logger.error("Error getting stock after order",