Fix enum mismatch: Update Python enums and seed data to match database uppercase values

- Fixed ProductType enum values from lowercase to uppercase (INGREDIENT, FINISHED_PRODUCT)
- Fixed UnitOfMeasure enum values from lowercase/abbreviated to uppercase (KILOGRAMS, LITERS, etc.)
- Fixed IngredientCategory enum values from lowercase to uppercase (FLOUR, YEAST, etc.)
- Fixed ProductCategory enum values from lowercase to uppercase (BREAD, CROISSANTS, etc.)
- Updated seed data files to use correct uppercase enum values
- Fixed hardcoded enum references throughout the codebase
- This resolves the InvalidTextRepresentationError when inserting inventory data

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Urtzi Alfaro
2025-12-13 16:49:04 +01:00
parent e116ac244c
commit 10c779858a
7 changed files with 1354 additions and 47 deletions

View File

@@ -280,8 +280,8 @@ async def classify_products_batch(
))
# Analyze business model
ingredient_count = sum(1 for s in suggestions if s.product_type.value == 'ingredient')
finished_count = sum(1 for s in suggestions if s.product_type.value == 'finished_product')
ingredient_count = sum(1 for s in suggestions if s.product_type.value == 'INGREDIENT')
finished_count = sum(1 for s in suggestions if s.product_type.value == 'FINISHED_PRODUCT')
semi_finished_count = sum(1 for s in suggestions if 'semi' in s.suggested_name.lower() or 'frozen' in s.suggested_name.lower() or 'pre' in s.suggested_name.lower())
total = len(suggestions)
ingredient_ratio = ingredient_count / total if total > 0 else 0

View File

@@ -17,51 +17,51 @@ from shared.database.base import Base
class UnitOfMeasure(enum.Enum):
"""Standard units of measure for ingredients"""
KILOGRAMS = "kg"
GRAMS = "g"
LITERS = "l"
MILLILITERS = "ml"
UNITS = "units"
PIECES = "pcs"
PACKAGES = "pkg"
BAGS = "bags"
BOXES = "boxes"
KILOGRAMS = "KILOGRAMS"
GRAMS = "GRAMS"
LITERS = "LITERS"
MILLILITERS = "MILLILITERS"
UNITS = "UNITS"
PIECES = "PIECES"
PACKAGES = "PACKAGES"
BAGS = "BAGS"
BOXES = "BOXES"
class IngredientCategory(enum.Enum):
"""Bakery ingredient categories"""
FLOUR = "flour"
YEAST = "yeast"
DAIRY = "dairy"
EGGS = "eggs"
SUGAR = "sugar"
FATS = "fats"
SALT = "salt"
SPICES = "spices"
ADDITIVES = "additives"
PACKAGING = "packaging"
CLEANING = "cleaning"
OTHER = "other"
FLOUR = "FLOUR"
YEAST = "YEAST"
DAIRY = "DAIRY"
EGGS = "EGGS"
SUGAR = "SUGAR"
FATS = "FATS"
SALT = "SALT"
SPICES = "SPICES"
ADDITIVES = "ADDITIVES"
PACKAGING = "PACKAGING"
CLEANING = "CLEANING"
OTHER = "OTHER"
class ProductCategory(enum.Enum):
"""Finished bakery product categories for retail/distribution model"""
BREAD = "bread"
CROISSANTS = "croissants"
PASTRIES = "pastries"
CAKES = "cakes"
COOKIES = "cookies"
MUFFINS = "muffins"
SANDWICHES = "sandwiches"
SEASONAL = "seasonal"
BEVERAGES = "beverages"
OTHER_PRODUCTS = "other_products"
BREAD = "BREAD"
CROISSANTS = "CROISSANTS"
PASTRIES = "PASTRIES"
CAKES = "CAKES"
COOKIES = "COOKIES"
MUFFINS = "MUFFINS"
SANDWICHES = "SANDWICHES"
SEASONAL = "SEASONAL"
BEVERAGES = "BEVERAGES"
OTHER_PRODUCTS = "OTHER_PRODUCTS"
class ProductType(enum.Enum):
"""Type of product in inventory"""
INGREDIENT = "ingredient" # Raw materials (flour, yeast, etc.)
FINISHED_PRODUCT = "finished_product" # Ready-to-sell items (bread, croissants, etc.)
INGREDIENT = "INGREDIENT" # Raw materials (flour, yeast, etc.)
FINISHED_PRODUCT = "FINISHED_PRODUCT" # Ready-to-sell items (bread, croissants, etc.)
class ProductionStage(enum.Enum):

View File

@@ -26,8 +26,8 @@ class DashboardRepository:
query = text("""
SELECT
COUNT(*) as total_ingredients,
COUNT(CASE WHEN product_type::text = 'finished_product' THEN 1 END) as finished_products,
COUNT(CASE WHEN product_type::text = 'ingredient' THEN 1 END) as raw_ingredients,
COUNT(CASE WHEN product_type::text = 'FINISHED_PRODUCT' THEN 1 END) as finished_products,
COUNT(CASE WHEN product_type::text = 'INGREDIENT' THEN 1 END) as raw_ingredients,
COUNT(DISTINCT st.supplier_id) as supplier_count,
AVG(CASE WHEN s.available_quantity IS NOT NULL THEN s.available_quantity ELSE 0 END) as avg_stock_level
FROM ingredients i

View File

@@ -40,7 +40,7 @@ class IngredientRepository(BaseRepository[Ingredient, IngredientCreate, Ingredie
ingredient_name=create_data.get('name'),
tenant_id=tenant_id
)
product_type_value = 'ingredient'
product_type_value = 'INGREDIENT'
if 'product_type' in create_data:
from app.models.inventory import ProductType
@@ -73,7 +73,7 @@ class IngredientRepository(BaseRepository[Ingredient, IngredientCreate, Ingredie
if 'category' in create_data:
category_value = create_data.pop('category')
if product_type_value == 'finished_product' or product_type_value == 'FINISHED_PRODUCT':
if product_type_value == 'FINISHED_PRODUCT':
# Map to product_category for finished products
from app.models.inventory import ProductCategory
if category_value:
@@ -166,15 +166,15 @@ class IngredientRepository(BaseRepository[Ingredient, IngredientCreate, Ingredie
# 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')
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'
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':
if product_type_value == 'FINISHED_PRODUCT':
# Map to product_category for finished products
from app.models.inventory import ProductCategory
if category_value:
@@ -559,4 +559,110 @@ class IngredientRepository(BaseRepository[Ingredient, IngredientCreate, Ingredie
except Exception as e:
logger.error("Failed to get active tenants from ingredients", error=str(e))
return []
return []
async def get_critical_stock_shortages(self) -> List[Dict[str, Any]]:
"""
Get critical stock shortages across all tenants using CTE analysis.
Returns ingredients that are critically low on stock.
"""
try:
from sqlalchemy import text
query = text("""
WITH stock_analysis AS (
SELECT
i.id as ingredient_id,
i.name as ingredient_name,
i.tenant_id,
i.reorder_point,
COALESCE(SUM(s.current_quantity), 0) as current_quantity,
i.low_stock_threshold,
GREATEST(0, i.low_stock_threshold - COALESCE(SUM(s.current_quantity), 0)) as shortage_amount,
CASE
WHEN COALESCE(SUM(s.current_quantity), 0) < i.low_stock_threshold THEN 'critical'
WHEN COALESCE(SUM(s.current_quantity), 0) < i.low_stock_threshold * 1.2 THEN 'low'
ELSE 'normal'
END as status
FROM ingredients i
LEFT JOIN stock s ON s.ingredient_id = i.id AND s.is_available = true
WHERE i.is_active = true
GROUP BY i.id, i.name, i.tenant_id, i.reorder_point, i.low_stock_threshold
)
SELECT
ingredient_id,
ingredient_name,
tenant_id,
current_quantity,
reorder_point,
shortage_amount
FROM stock_analysis
WHERE status = 'critical'
ORDER BY shortage_amount DESC
""")
result = await self.session.execute(query)
rows = result.fetchall()
shortages = []
for row in rows:
shortages.append({
'ingredient_id': row.ingredient_id,
'ingredient_name': row.ingredient_name,
'tenant_id': row.tenant_id,
'current_quantity': float(row.current_quantity) if row.current_quantity else 0,
'required_quantity': float(row.reorder_point) if row.reorder_point else 0,
'shortage_amount': float(row.shortage_amount) if row.shortage_amount else 0
})
return shortages
except Exception as e:
logger.error("Failed to get critical stock shortages", error=str(e))
raise
async def get_stock_issues(self, tenant_id: UUID) -> List[Dict[str, Any]]:
"""
Get stock level issues with CTE analysis for a specific tenant
Returns list of critical, low, and overstock situations
"""
try:
from sqlalchemy import text
query = text("""
WITH stock_analysis AS (
SELECT
i.id, i.name, i.tenant_id,
COALESCE(SUM(s.current_quantity), 0) as current_stock,
i.low_stock_threshold as minimum_stock,
i.max_stock_level as maximum_stock,
i.reorder_point,
0 as tomorrow_needed,
0 as avg_daily_usage,
7 as lead_time_days,
CASE
WHEN COALESCE(SUM(s.current_quantity), 0) < i.low_stock_threshold THEN 'critical'
WHEN COALESCE(SUM(s.current_quantity), 0) < i.low_stock_threshold * 1.2 THEN 'low'
WHEN i.max_stock_level IS NOT NULL AND COALESCE(SUM(s.current_quantity), 0) > i.max_stock_level THEN 'overstock'
ELSE 'normal'
END as status,
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 = :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'
ORDER BY
CASE status
WHEN 'critical' THEN 1
WHEN 'low' THEN 2
WHEN 'overstock' THEN 3
END,
shortage_amount DESC
""")
result = await self.session.execute(query, {"tenant_id": tenant_id})
return [dict(row._mapping) for row in result.fetchall()]
except Exception as e:
logger.error("Failed to get stock issues", error=str(e), tenant_id=str(tenant_id))
raise

View File

@@ -1069,14 +1069,14 @@ class InventoryService:
ingredient_create = IngredientCreate(
name=ingredient_data.get('name'),
product_type=ingredient_data.get('type', 'finished_product'),
unit_of_measure=ingredient_data.get('unit', 'units'),
product_type=ingredient_data.get('type', 'FINISHED_PRODUCT'),
unit_of_measure=ingredient_data.get('unit', 'UNITS'),
low_stock_threshold=ingredient_data.get('current_stock', 0),
reorder_point=max(ingredient_data.get('reorder_point', 1),
ingredient_data.get('current_stock', 0) + 1),
average_cost=ingredient_data.get('cost_per_unit', 0.0),
ingredient_category=ingredient_data.get('category') if ingredient_data.get('type') == 'ingredient' else None,
product_category=ingredient_data.get('category') if ingredient_data.get('type') == 'finished_product' else None
ingredient_category=ingredient_data.get('category') if ingredient_data.get('type') == 'INGREDIENT' else None,
product_category=ingredient_data.get('category') if ingredient_data.get('type') == 'FINISHED_PRODUCT' else None
)
ingredient = await repository.create_ingredient(ingredient_create, tenant_id)

View File

@@ -0,0 +1,181 @@
{
"ingredients": [
{
"id": "10000000-0000-0000-0000-000000000001",
"tenant_id": "80000000-0000-4000-a000-000000000001",
"name": "Harina de Trigo T55 - Enterprise Grade",
"sku": "HAR-T55-ENT-001",
"barcode": null,
"product_type": "INGREDIENT",
"ingredient_category": "FLOUR",
"product_category": "BREAD",
"subcategory": null,
"description": "Premium harina de trigo tipo 55 para uso en todas las ubicaciones",
"brand": "Molinos San José - Enterprise",
"unit_of_measure": "KILOGRAMS",
"package_size": null,
"average_cost": 0.80,
"last_purchase_price": null,
"standard_cost": null,
"low_stock_threshold": 500.0,
"reorder_point": 750.0,
"reorder_quantity": null,
"max_stock_level": null,
"shelf_life_days": null,
"display_life_hours": null,
"best_before_hours": null,
"storage_instructions": null,
"central_baker_product_code": null,
"delivery_days": null,
"minimum_order_quantity": null,
"pack_size": null,
"is_active": true,
"is_perishable": false,
"allergen_info": [
"gluten"
],
"nutritional_info": null,
"produced_locally": false,
"recipe_id": null,
"created_at": "2025-01-15T06:00:00Z",
"updated_at": "2025-01-15T06:00:00Z",
"created_by": "d2e3f4a5-b6c7-48d9-e0f1-a2b3c4d5e6f7",
"enterprise_shared": true,
"shared_locations": ["Madrid Centro", "Barcelona Gràcia", "Valencia Ruzafa"]
},
{
"id": "10000000-0000-0000-0000-000000000002",
"tenant_id": "80000000-0000-4000-a000-000000000001",
"name": "Mantequilla Francesa AOP - Enterprise",
"sku": "MAN-FRA-ENT-001",
"barcode": null,
"product_type": "INGREDIENT",
"ingredient_category": "DAIRY",
"product_category": "PASTRY",
"subcategory": null,
"description": "Mantequilla francesa AOP para uso en croissants y pastelería fina",
"brand": "Lescure - Enterprise",
"unit_of_measure": "KILOGRAMS",
"package_size": null,
"average_cost": 4.20,
"last_purchase_price": null,
"standard_cost": null,
"low_stock_threshold": 200.0,
"reorder_point": 300.0,
"reorder_quantity": null,
"max_stock_level": null,
"shelf_life_days": null,
"display_life_hours": null,
"best_before_hours": null,
"storage_instructions": null,
"central_baker_product_code": null,
"delivery_days": null,
"minimum_order_quantity": null,
"pack_size": null,
"is_active": true,
"is_perishable": true,
"allergen_info": [
"milk"
],
"nutritional_info": null,
"produced_locally": false,
"recipe_id": null,
"created_at": "2025-01-15T06:00:00Z",
"updated_at": "2025-01-15T06:00:00Z",
"created_by": "d2e3f4a5-b6c7-48d9-e0f1-a2b3c4d5e6f7",
"enterprise_shared": true,
"shared_locations": ["Madrid Centro", "Barcelona Gràcia", "Valencia Ruzafa"]
},
{
"id": "20000000-0000-0000-0000-000000000001",
"tenant_id": "80000000-0000-4000-a000-000000000001",
"name": "Baguette Premium - Enterprise Standard",
"sku": "BAG-PRE-ENT-001",
"barcode": null,
"product_type": "FINISHED_PRODUCT",
"ingredient_category": null,
"product_category": "BREAD",
"subcategory": "FRENCH",
"description": "Baguette premium estándar para todas las ubicaciones enterprise",
"brand": "Panadería Central",
"unit_of_measure": "UNITS",
"package_size": null,
"average_cost": 1.80,
"last_purchase_price": null,
"standard_cost": null,
"low_stock_threshold": 100.0,
"reorder_point": 150.0,
"reorder_quantity": null,
"max_stock_level": null,
"shelf_life_days": 1,
"display_life_hours": 24,
"best_before_hours": 12,
"storage_instructions": null,
"central_baker_product_code": null,
"delivery_days": null,
"minimum_order_quantity": null,
"pack_size": null,
"is_active": true,
"is_perishable": true,
"allergen_info": [
"gluten",
"may_contain_sesame"
],
"nutritional_info": null,
"produced_locally": true,
"recipe_id": "30000000-0000-0000-0000-000000000001",
"created_at": "2025-01-15T06:00:00Z",
"updated_at": "2025-01-15T06:00:00Z",
"created_by": "d2e3f4a5-b6c7-48d9-e0f1-a2b3c4d5e6f7",
"enterprise_shared": true,
"shared_locations": ["Madrid Centro", "Barcelona Gràcia", "Valencia Ruzafa"]
}
],
"stock": [
{
"id": "10000000-0000-0000-0000-000000001001",
"tenant_id": "80000000-0000-4000-a000-000000000001",
"ingredient_id": "10000000-0000-0000-0000-000000000001",
"quantity": 850.0,
"location": "Central Warehouse - Madrid",
"production_stage": "RAW_MATERIAL",
"quality_status": "APPROVED",
"expiration_date": "2025-07-15T00:00:00Z",
"supplier_id": "40000000-0000-0000-0000-000000000001",
"batch_number": "ENT-HAR-20250115-001",
"created_at": "2025-01-15T06:00:00Z",
"updated_at": "2025-01-15T06:00:00Z",
"enterprise_shared": true
},
{
"id": "10000000-0000-0000-0000-000000001002",
"tenant_id": "80000000-0000-4000-a000-000000000001",
"ingredient_id": "10000000-0000-0000-0000-000000000002",
"quantity": 280.0,
"location": "Central Warehouse - Madrid",
"production_stage": "RAW_MATERIAL",
"quality_status": "APPROVED",
"expiration_date": "2025-02-15T00:00:00Z",
"supplier_id": "40000000-0000-0000-0000-000000000002",
"batch_number": "ENT-MAN-20250115-001",
"created_at": "2025-01-15T06:00:00Z",
"updated_at": "2025-01-15T06:00:00Z",
"enterprise_shared": true
},
{
"id": "20000000-0000-0000-0000-000000001001",
"tenant_id": "80000000-0000-4000-a000-000000000001",
"ingredient_id": "20000000-0000-0000-0000-000000000001",
"quantity": 120.0,
"location": "Central Warehouse - Madrid",
"production_stage": "FINISHED_PRODUCT",
"quality_status": "APPROVED",
"expiration_date": "2025-01-16T06:00:00Z",
"supplier_id": null,
"batch_number": "ENT-BAG-20250115-001",
"created_at": "2025-01-15T06:00:00Z",
"updated_at": "2025-01-15T06:00:00Z",
"enterprise_shared": true
}
]
}

File diff suppressed because it is too large Load Diff