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:
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
181
shared/demo/fixtures/enterprise/parent/03-inventory.json
Normal file
181
shared/demo/fixtures/enterprise/parent/03-inventory.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
1020
shared/demo/fixtures/professional/03-inventory.json
Normal file
1020
shared/demo/fixtures/professional/03-inventory.json
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user