diff --git a/frontend/src/api/services/inventory.service.ts b/frontend/src/api/services/inventory.service.ts index f4b12f59..1576ca20 100644 --- a/frontend/src/api/services/inventory.service.ts +++ b/frontend/src/api/services/inventory.service.ts @@ -547,6 +547,9 @@ export class InventoryService { */ async getProductsList(tenantId: string): Promise { try { + console.log('🔍 Fetching products for forecasting...', { tenantId }); + + // First try to get finished products (preferred for forecasting) const response = await apiClient.get(`/tenants/${tenantId}/ingredients`, { params: { limit: 100, @@ -606,7 +609,47 @@ export class InventoryService { })) .filter(product => product.inventory_product_id && product.name); - console.log('📋 Processed inventory products:', products); + console.log('📋 Processed finished products:', products); + + // If no finished products found, try to get all products as fallback + if (products.length === 0) { + console.log('⚠️ No finished products found, trying to get all products as fallback...'); + + const fallbackResponse = await apiClient.get(`/tenants/${tenantId}/ingredients`, { + params: { + limit: 100, + // No product_type filter to get all products + }, + }); + + console.log('🔍 Fallback API Response:', fallbackResponse); + + const fallbackDataToProcess = fallbackResponse?.data || fallbackResponse; + let fallbackProductsArray: any[] = []; + + if (Array.isArray(fallbackDataToProcess)) { + fallbackProductsArray = fallbackDataToProcess; + } else if (fallbackDataToProcess && typeof fallbackDataToProcess === 'object') { + const keys = Object.keys(fallbackDataToProcess); + if (keys.length > 0 && keys.every(key => !isNaN(Number(key)))) { + fallbackProductsArray = Object.values(fallbackDataToProcess); + } + } + + const fallbackProducts: ProductInfo[] = fallbackProductsArray + .map((product: any) => ({ + inventory_product_id: product.id || product.inventory_product_id, + name: product.name || product.product_name || `Product ${product.id || ''}`, + category: product.category, + current_stock: product.current_stock, + unit: product.unit, + cost_per_unit: product.cost_per_unit + })) + .filter(product => product.inventory_product_id && product.name); + + console.log('📋 Processed fallback products (all inventory items):', fallbackProducts); + return fallbackProducts; + } return products; diff --git a/frontend/src/pages/forecast/ForecastPage.tsx b/frontend/src/pages/forecast/ForecastPage.tsx index f354400a..d992be88 100644 --- a/frontend/src/pages/forecast/ForecastPage.tsx +++ b/frontend/src/pages/forecast/ForecastPage.tsx @@ -87,12 +87,10 @@ const ForecastPage: React.FC = () => { try { const products = await getProductsList(tenantId); - // If no finished products found, use fallback products with trained models + // Always use real inventory products - no hardcoded fallbacks if (products.length === 0) { - setInventoryItems([ - { id: '3ae0afca-3e75-4f93-b6af-2d24c24bfcd5', name: 'Croissants' }, - { id: 'b2341c5d-db5d-418e-a978-6d24cd9f039e', name: 'Pan de molde' } - ]); + console.warn('⚠️ No finished products found in inventory for forecasting'); + setInventoryItems([]); } else { // Map products to the expected format setInventoryItems(products.map(p => ({ @@ -102,11 +100,8 @@ const ForecastPage: React.FC = () => { } } catch (error) { console.error('Failed to load products:', error); - // Use fallback products with trained models - setInventoryItems([ - { id: '3ae0afca-3e75-4f93-b6af-2d24c24bfcd5', name: 'Croissants' }, - { id: 'b2341c5d-db5d-418e-a978-6d24cd9f039e', name: 'Pan de molde' } - ]); + // Don't use fake fallback products - show empty state instead + setInventoryItems([]); } } }; @@ -462,7 +457,19 @@ const ForecastPage: React.FC = () => { )} - {forecastData.length === 0 && !isLoading && !isGenerating && ( + {inventoryItems.length === 0 && !isLoading && !isGenerating && ( +
+
+ + + No hay productos disponibles para predicciones. +
Para usar esta funcionalidad, necesitas crear productos terminados (como pan, croissants, etc.) en tu inventario con tipo "Producto terminado". +
+
+
+ )} + + {inventoryItems.length > 0 && forecastData.length === 0 && !isLoading && !isGenerating && (
diff --git a/services/inventory/app/repositories/ingredient_repository.py b/services/inventory/app/repositories/ingredient_repository.py index 344d4b33..0dfd7b6c 100644 --- a/services/inventory/app/repositories/ingredient_repository.py +++ b/services/inventory/app/repositories/ingredient_repository.py @@ -30,24 +30,61 @@ class IngredientRepository(BaseRepository[Ingredient, IngredientCreate, Ingredie create_data = ingredient_data.model_dump() create_data['tenant_id'] = tenant_id - # Map 'category' from schema to appropriate model fields + # Handle product_type enum conversion + product_type_value = create_data.get('product_type', 'ingredient') + if 'product_type' in create_data: + from app.models.inventory import ProductType + try: + # Convert string to enum object + if isinstance(product_type_value, str): + for enum_member in ProductType: + if enum_member.value == product_type_value or enum_member.name == product_type_value: + create_data['product_type'] = enum_member + break + else: + # If not found, default to INGREDIENT + create_data['product_type'] = ProductType.INGREDIENT + # If it's already an enum, keep it + except Exception: + # Fallback to INGREDIENT if any issues + create_data['product_type'] = ProductType.INGREDIENT + + # Handle category mapping based on product type if 'category' in create_data: category_value = create_data.pop('category') - # For now, assume all items are ingredients and map to ingredient_category - # Convert string to enum object - from app.models.inventory import IngredientCategory - try: - # Find the enum member by value - for enum_member in IngredientCategory: - if enum_member.value == category_value: - create_data['ingredient_category'] = enum_member - break - else: - # If not found, default to OTHER - create_data['ingredient_category'] = IngredientCategory.OTHER - except Exception: - # Fallback to OTHER if any issues - create_data['ingredient_category'] = IngredientCategory.OTHER + + if product_type_value == 'finished_product' or product_type_value == 'FINISHED_PRODUCT': + # Map to product_category for finished products + from app.models.inventory import ProductCategory + if category_value: + try: + # Find the enum member by value + for enum_member in ProductCategory: + if enum_member.value == category_value: + create_data['product_category'] = enum_member + break + else: + # If not found, default to OTHER + create_data['product_category'] = ProductCategory.OTHER_PRODUCTS + except Exception: + # Fallback to OTHER if any issues + create_data['product_category'] = ProductCategory.OTHER_PRODUCTS + else: + # Map to ingredient_category for ingredients + from app.models.inventory import IngredientCategory + if category_value: + try: + # Find the enum member by value + for enum_member in IngredientCategory: + if enum_member.value == category_value: + create_data['ingredient_category'] = enum_member + break + else: + # If not found, default to OTHER + create_data['ingredient_category'] = IngredientCategory.OTHER + except Exception: + # Fallback to OTHER if any issues + create_data['ingredient_category'] = IngredientCategory.OTHER # Convert unit_of_measure string to enum object if 'unit_of_measure' in create_data: @@ -81,6 +118,92 @@ class IngredientRepository(BaseRepository[Ingredient, IngredientCreate, Ingredie logger.error("Failed to create ingredient", error=str(e), tenant_id=tenant_id) raise + async def update(self, record_id: Any, obj_in: IngredientUpdate, **kwargs) -> Optional[Ingredient]: + """Override update to handle product_type and category enum conversions""" + try: + # Prepare data and map schema fields to model fields + update_data = obj_in.model_dump(exclude_unset=True) + + # Handle product_type enum conversion + if 'product_type' in update_data: + product_type_value = update_data['product_type'] + from app.models.inventory import ProductType + try: + # Convert string to enum object + if isinstance(product_type_value, str): + for enum_member in ProductType: + if enum_member.value == product_type_value or enum_member.name == product_type_value: + update_data['product_type'] = enum_member + break + else: + # If not found, keep original value (don't update) + del update_data['product_type'] + # If it's already an enum, keep it + except Exception: + # Remove invalid product_type to avoid update + del update_data['product_type'] + + # Handle category mapping based on product type + if 'category' in update_data: + category_value = update_data.pop('category') + product_type_value = update_data.get('product_type', 'ingredient') + + # Get current product if we need to determine type + if 'product_type' not in update_data: + current_record = await self.get_by_id(record_id) + if current_record: + product_type_value = current_record.product_type.value if current_record.product_type else 'ingredient' + + if product_type_value == 'finished_product' or product_type_value == 'FINISHED_PRODUCT': + # Map to product_category for finished products + from app.models.inventory import ProductCategory + if category_value: + try: + for enum_member in ProductCategory: + if enum_member.value == category_value: + update_data['product_category'] = enum_member + # Clear ingredient_category when setting product_category + update_data['ingredient_category'] = None + break + except Exception: + pass + else: + # Map to ingredient_category for ingredients + from app.models.inventory import IngredientCategory + if category_value: + try: + for enum_member in IngredientCategory: + if enum_member.value == category_value: + update_data['ingredient_category'] = enum_member + # Clear product_category when setting ingredient_category + update_data['product_category'] = None + break + except Exception: + pass + + # Handle unit_of_measure enum conversion + if 'unit_of_measure' in update_data: + unit_value = update_data['unit_of_measure'] + from app.models.inventory import UnitOfMeasure + try: + if isinstance(unit_value, str): + for enum_member in UnitOfMeasure: + if enum_member.value == unit_value: + update_data['unit_of_measure'] = enum_member + break + else: + # If not found, keep original value + del update_data['unit_of_measure'] + except Exception: + del update_data['unit_of_measure'] + + # Call parent update method + return await super().update(record_id, update_data, **kwargs) + + except Exception as e: + logger.error("Failed to update ingredient", error=str(e), record_id=record_id) + raise + async def get_ingredients_by_tenant( self, tenant_id: UUID, diff --git a/services/inventory/app/schemas/inventory.py b/services/inventory/app/schemas/inventory.py index 6e1dfa1b..19484566 100644 --- a/services/inventory/app/schemas/inventory.py +++ b/services/inventory/app/schemas/inventory.py @@ -10,7 +10,7 @@ from pydantic import BaseModel, Field, validator from typing import Generic, TypeVar from enum import Enum -from app.models.inventory import UnitOfMeasure, IngredientCategory, StockMovementType +from app.models.inventory import UnitOfMeasure, IngredientCategory, StockMovementType, ProductType, ProductCategory T = TypeVar('T') @@ -32,11 +32,12 @@ class InventoryBaseSchema(BaseModel): # ===== INGREDIENT SCHEMAS ===== class IngredientCreate(InventoryBaseSchema): - """Schema for creating ingredients""" - name: str = Field(..., max_length=255, description="Ingredient name") + """Schema for creating ingredients and finished products""" + name: str = Field(..., max_length=255, description="Product name") + product_type: ProductType = Field(ProductType.INGREDIENT, description="Type of product (ingredient or finished_product)") sku: Optional[str] = Field(None, max_length=100, description="SKU code") barcode: Optional[str] = Field(None, max_length=50, description="Barcode") - category: IngredientCategory = Field(..., description="Ingredient category") + category: Optional[str] = Field(None, description="Product category (ingredient or finished product category)") subcategory: Optional[str] = Field(None, max_length=100, description="Subcategory") description: Optional[str] = Field(None, description="Ingredient description") brand: Optional[str] = Field(None, max_length=100, description="Brand name") @@ -83,11 +84,12 @@ class IngredientCreate(InventoryBaseSchema): class IngredientUpdate(InventoryBaseSchema): - """Schema for updating ingredients""" - name: Optional[str] = Field(None, max_length=255, description="Ingredient name") + """Schema for updating ingredients and finished products""" + name: Optional[str] = Field(None, max_length=255, description="Product name") + product_type: Optional[ProductType] = Field(None, description="Type of product (ingredient or finished_product)") sku: Optional[str] = Field(None, max_length=100, description="SKU code") barcode: Optional[str] = Field(None, max_length=50, description="Barcode") - category: Optional[IngredientCategory] = Field(None, description="Ingredient category") + category: Optional[str] = Field(None, description="Product category") subcategory: Optional[str] = Field(None, max_length=100, description="Subcategory") description: Optional[str] = Field(None, description="Ingredient description") brand: Optional[str] = Field(None, max_length=100, description="Brand name") @@ -122,13 +124,14 @@ class IngredientUpdate(InventoryBaseSchema): class IngredientResponse(InventoryBaseSchema): - """Schema for ingredient API responses""" + """Schema for ingredient and finished product API responses""" id: str tenant_id: str name: str + product_type: ProductType sku: Optional[str] barcode: Optional[str] - category: IngredientCategory + category: Optional[str] # Will be populated from ingredient_category or product_category subcategory: Optional[str] description: Optional[str] brand: Optional[str] diff --git a/services/inventory/app/services/inventory_service.py b/services/inventory/app/services/inventory_service.py index a7275e70..dc871438 100644 --- a/services/inventory/app/services/inventory_service.py +++ b/services/inventory/app/services/inventory_service.py @@ -61,7 +61,15 @@ class InventoryService: ingredient = await repository.create_ingredient(ingredient_data, tenant_id) # Convert to response schema - response = IngredientResponse(**ingredient.to_dict()) + ingredient_dict = ingredient.to_dict() + + # Map category field based on product type + if ingredient.product_type and ingredient.product_type.value == 'finished_product': + ingredient_dict['category'] = ingredient.product_category.value if ingredient.product_category else None + else: + ingredient_dict['category'] = ingredient.ingredient_category.value if ingredient.ingredient_category else None + + response = IngredientResponse(**ingredient_dict) # Add computed fields response.current_stock = 0.0 @@ -90,7 +98,15 @@ class InventoryService: stock_totals = await stock_repo.get_total_stock_by_ingredient(tenant_id, ingredient_id) # Convert to response schema - response = IngredientResponse(**ingredient.to_dict()) + ingredient_dict = ingredient.to_dict() + + # Map category field based on product type + if ingredient.product_type and ingredient.product_type.value == 'finished_product': + ingredient_dict['category'] = ingredient.product_category.value if ingredient.product_category else None + else: + ingredient_dict['category'] = ingredient.ingredient_category.value if ingredient.ingredient_category else None + + response = IngredientResponse(**ingredient_dict) response.current_stock = stock_totals['total_available'] response.is_low_stock = stock_totals['total_available'] <= ingredient.low_stock_threshold response.needs_reorder = stock_totals['total_available'] <= ingredient.reorder_point @@ -138,7 +154,15 @@ class InventoryService: stock_totals = await stock_repo.get_total_stock_by_ingredient(tenant_id, ingredient_id) # Convert to response schema - response = IngredientResponse(**updated_ingredient.to_dict()) + ingredient_dict = updated_ingredient.to_dict() + + # Map category field based on product type + if updated_ingredient.product_type and updated_ingredient.product_type.value == 'finished_product': + ingredient_dict['category'] = updated_ingredient.product_category.value if updated_ingredient.product_category else None + else: + ingredient_dict['category'] = updated_ingredient.ingredient_category.value if updated_ingredient.ingredient_category else None + + response = IngredientResponse(**ingredient_dict) response.current_stock = stock_totals['total_available'] response.is_low_stock = stock_totals['total_available'] <= updated_ingredient.low_stock_threshold response.needs_reorder = stock_totals['total_available'] <= updated_ingredient.reorder_point @@ -173,7 +197,15 @@ class InventoryService: stock_totals = await stock_repo.get_total_stock_by_ingredient(tenant_id, ingredient.id) # Convert to response schema - response = IngredientResponse(**ingredient.to_dict()) + ingredient_dict = ingredient.to_dict() + + # Map category field based on product type + if ingredient.product_type and ingredient.product_type.value == 'finished_product': + ingredient_dict['category'] = ingredient.product_category.value if ingredient.product_category else None + else: + ingredient_dict['category'] = ingredient.ingredient_category.value if ingredient.ingredient_category else None + + response = IngredientResponse(**ingredient_dict) response.current_stock = stock_totals['total_available'] response.is_low_stock = stock_totals['total_available'] <= ingredient.low_stock_threshold response.needs_reorder = stock_totals['total_available'] <= ingredient.reorder_point diff --git a/services/training/app/services/training_service.py b/services/training/app/services/training_service.py index d28fc4ab..547dc014 100644 --- a/services/training/app/services/training_service.py +++ b/services/training/app/services/training_service.py @@ -627,13 +627,14 @@ class EnhancedTrainingService: "status": status or "pending", "progress": progress or 0, "current_step": current_step or "initializing", - "start_time": datetime.utcnow() + "start_time": datetime.now(timezone.utc) } if error_message: log_data["error_message"] = error_message if results: - log_data["results"] = results + # Ensure results are JSON-serializable before storing + log_data["results"] = make_json_serializable(results) await self.training_log_repo.create_training_log(log_data) logger.info("Created initial training log", job_id=job_id, tenant_id=tenant_id) @@ -655,9 +656,10 @@ class EnhancedTrainingService: if error_message: update_data["error_message"] = error_message if results: - update_data["results"] = results + # Ensure results are JSON-serializable before storing + update_data["results"] = make_json_serializable(results) if status in ["completed", "failed"]: - update_data["end_time"] = datetime.utcnow() + update_data["end_time"] = datetime.now(timezone.utc) if update_data: await self.training_log_repo.update(existing_log.id, update_data)