Improve the frontend and repository layer

This commit is contained in:
Urtzi Alfaro
2025-10-23 07:44:54 +02:00
parent 8d30172483
commit 07c33fa578
112 changed files with 14726 additions and 2733 deletions

View File

@@ -10,6 +10,7 @@ from decimal import Decimal
from typing import List, Optional, Dict, Any
from uuid import UUID
import structlog
from sqlalchemy import text
from app.core.config import settings
from app.services.inventory_service import InventoryService
@@ -17,6 +18,7 @@ from app.services.food_safety_service import FoodSafetyService
from app.repositories.ingredient_repository import IngredientRepository
from app.repositories.stock_repository import StockRepository
from app.repositories.stock_movement_repository import StockMovementRepository
from app.repositories.dashboard_repository import DashboardRepository
from app.schemas.dashboard import (
InventoryDashboardSummary,
BusinessModelInsights,
@@ -40,20 +42,23 @@ class DashboardService:
food_safety_service: FoodSafetyService,
ingredient_repository: Optional[IngredientRepository] = None,
stock_repository: Optional[StockRepository] = None,
stock_movement_repository: Optional[StockMovementRepository] = None
stock_movement_repository: Optional[StockMovementRepository] = None,
dashboard_repository: Optional[DashboardRepository] = None
):
self.inventory_service = inventory_service
self.food_safety_service = food_safety_service
self._ingredient_repository = ingredient_repository
self._stock_repository = stock_repository
self._stock_movement_repository = stock_movement_repository
self._dashboard_repository = dashboard_repository
def _get_repositories(self, db):
"""Get repository instances for the current database session"""
return {
'ingredient_repo': self._ingredient_repository or IngredientRepository(db),
'stock_repo': self._stock_repository or StockRepository(db),
'stock_movement_repo': self._stock_movement_repository or StockMovementRepository(db)
'stock_movement_repo': self._stock_movement_repository or StockMovementRepository(db),
'dashboard_repo': self._dashboard_repository or DashboardRepository(db)
}
async def get_inventory_dashboard_summary(
@@ -75,22 +80,26 @@ class DashboardService:
# Get business model insights
business_model = await self._detect_business_model(db, tenant_id)
# Get dashboard repository
repos = self._get_repositories(db)
dashboard_repo = repos['dashboard_repo']
# Get category breakdown
stock_by_category = await self._get_stock_by_category(db, tenant_id)
stock_by_category = await dashboard_repo.get_stock_by_category(tenant_id)
# Get alerts breakdown
alerts_by_severity = await self._get_alerts_by_severity(db, tenant_id)
alerts_by_severity = await dashboard_repo.get_alerts_by_severity(tenant_id)
# Get movements breakdown
movements_by_type = await self._get_movements_by_type(db, tenant_id)
movements_by_type = await dashboard_repo.get_movements_by_type(tenant_id)
# Get performance indicators
performance_metrics = await self._calculate_performance_indicators(db, tenant_id)
# Get trending data
stock_value_trend = await self._get_stock_value_trend(db, tenant_id, days=30)
alert_trend = await self._get_alert_trend(db, tenant_id, days=30)
alert_trend = await dashboard_repo.get_alert_trend(tenant_id, days=30)
# Recent activity
recent_activity = await self.get_recent_activity(db, tenant_id, limit=10)
@@ -200,26 +209,10 @@ class DashboardService:
ingredients = await repos['ingredient_repo'].get_ingredients_by_tenant(tenant_id, limit=1000)
stock_summary = await repos['stock_repo'].get_stock_summary_by_tenant(tenant_id)
# Get current stock levels for all ingredients using a direct query
# Get current stock levels for all ingredients using repository
ingredient_stock_levels = {}
try:
from sqlalchemy import text
# Query to get current stock for all ingredients
stock_query = text("""
SELECT
i.id as ingredient_id,
COALESCE(SUM(s.available_quantity), 0) as current_stock
FROM ingredients i
LEFT JOIN stock s ON i.id = s.ingredient_id AND s.is_available = true
WHERE i.tenant_id = :tenant_id AND i.is_active = true
GROUP BY i.id
""")
result = await db.execute(stock_query, {"tenant_id": tenant_id})
for row in result.fetchall():
ingredient_stock_levels[str(row.ingredient_id)] = float(row.current_stock)
ingredient_stock_levels = await dashboard_repo.get_ingredient_stock_levels(tenant_id)
except Exception as e:
logger.warning(f"Could not fetch current stock levels: {e}")
@@ -320,50 +313,29 @@ class DashboardService:
) -> List[StockStatusSummary]:
"""Get stock status breakdown by category"""
try:
query = """
SELECT
COALESCE(i.ingredient_category::text, i.product_category::text, 'other') as category,
COUNT(DISTINCT i.id) as total_ingredients,
COUNT(CASE WHEN s.available_quantity > i.low_stock_threshold THEN 1 END) as in_stock,
COUNT(CASE WHEN s.available_quantity <= i.low_stock_threshold AND s.available_quantity > 0 THEN 1 END) as low_stock,
COUNT(CASE WHEN COALESCE(s.available_quantity, 0) = 0 THEN 1 END) as out_of_stock,
COALESCE(SUM(s.available_quantity * s.unit_cost), 0) as total_value
FROM ingredients i
LEFT JOIN (
SELECT
ingredient_id,
SUM(available_quantity) as available_quantity,
AVG(unit_cost) as unit_cost
FROM stock
WHERE tenant_id = :tenant_id AND is_available = true
GROUP BY ingredient_id
) s ON i.id = s.ingredient_id
WHERE i.tenant_id = :tenant_id AND i.is_active = true
GROUP BY category
ORDER BY total_value DESC
"""
result = await db.execute(query, {"tenant_id": tenant_id})
rows = result.fetchall()
repos = self._get_repositories(db)
dashboard_repo = repos['dashboard_repo']
rows = await dashboard_repo.get_stock_status_by_category(tenant_id)
summaries = []
total_value = sum(row.total_value for row in rows)
total_value = sum(row["total_value"] for row in rows)
for row in rows:
percentage = (row.total_value / total_value * 100) if total_value > 0 else 0
percentage = (row["total_value"] / total_value * 100) if total_value > 0 else 0
summaries.append(StockStatusSummary(
category=row.category,
total_ingredients=row.total_ingredients,
in_stock=row.in_stock,
low_stock=row.low_stock,
out_of_stock=row.out_of_stock,
total_value=Decimal(str(row.total_value)),
category=row["category"],
total_ingredients=row["total_ingredients"],
in_stock=row["in_stock"],
low_stock=row["low_stock"],
out_of_stock=row["out_of_stock"],
total_value=Decimal(str(row["total_value"])),
percentage_of_total=Decimal(str(percentage))
))
return summaries
except Exception as e:
logger.error("Failed to get stock status by category", error=str(e))
raise
@@ -376,58 +348,30 @@ class DashboardService:
) -> List[AlertSummary]:
"""Get alerts summary by type and severity"""
try:
# Build query with filters
where_conditions = ["tenant_id = :tenant_id", "status = 'active'"]
params = {"tenant_id": tenant_id}
if filters:
if filters.alert_types:
where_conditions.append("alert_type = ANY(:alert_types)")
params["alert_types"] = filters.alert_types
if filters.severities:
where_conditions.append("severity = ANY(:severities)")
params["severities"] = filters.severities
if filters.date_from:
where_conditions.append("created_at >= :date_from")
params["date_from"] = filters.date_from
if filters.date_to:
where_conditions.append("created_at <= :date_to")
params["date_to"] = filters.date_to
where_clause = " AND ".join(where_conditions)
query = f"""
SELECT
alert_type,
severity,
COUNT(*) as count,
MIN(EXTRACT(EPOCH FROM (NOW() - created_at))/3600)::int as oldest_alert_age_hours,
AVG(CASE WHEN resolved_at IS NOT NULL
THEN EXTRACT(EPOCH FROM (resolved_at - created_at))/3600
ELSE NULL END)::int as avg_resolution_hours
FROM food_safety_alerts
WHERE {where_clause}
GROUP BY alert_type, severity
ORDER BY severity DESC, count DESC
"""
result = await db.execute(query, params)
rows = result.fetchall()
repos = self._get_repositories(db)
dashboard_repo = repos['dashboard_repo']
# Extract filter parameters
alert_types = filters.alert_types if filters else None
severities = filters.severities if filters else None
date_from = filters.date_from if filters else None
date_to = filters.date_to if filters else None
rows = await dashboard_repo.get_alerts_summary(
tenant_id, alert_types, severities, date_from, date_to
)
return [
AlertSummary(
alert_type=row.alert_type,
severity=row.severity,
count=row.count,
oldest_alert_age_hours=row.oldest_alert_age_hours,
average_resolution_time_hours=row.avg_resolution_hours
alert_type=row["alert_type"],
severity=row["severity"],
count=row["count"],
oldest_alert_age_hours=row["oldest_alert_age_hours"],
average_resolution_time_hours=row["average_resolution_time_hours"]
)
for row in rows
]
except Exception as e:
logger.error("Failed to get alerts summary", error=str(e))
raise
@@ -441,81 +385,39 @@ class DashboardService:
) -> List[RecentActivity]:
"""Get recent inventory activity"""
try:
repos = self._get_repositories(db)
dashboard_repo = repos['dashboard_repo']
activities = []
# Get recent stock movements
stock_query = """
SELECT
'stock_movement' as activity_type,
CASE
WHEN movement_type = 'PURCHASE' THEN 'Stock added: ' || i.name || ' (' || sm.quantity || ' ' || i.unit_of_measure::text || ')'
WHEN movement_type = 'PRODUCTION_USE' THEN 'Stock consumed: ' || i.name || ' (' || sm.quantity || ' ' || i.unit_of_measure::text || ')'
WHEN movement_type = 'WASTE' THEN 'Stock wasted: ' || i.name || ' (' || sm.quantity || ' ' || i.unit_of_measure::text || ')'
WHEN movement_type = 'ADJUSTMENT' THEN 'Stock adjusted: ' || i.name || ' (' || sm.quantity || ' ' || i.unit_of_measure::text || ')'
ELSE 'Stock movement: ' || i.name
END as description,
sm.movement_date as timestamp,
sm.created_by as user_id,
CASE
WHEN movement_type = 'WASTE' THEN 'high'
WHEN movement_type = 'ADJUSTMENT' THEN 'medium'
ELSE 'low'
END as impact_level,
sm.id as entity_id,
'stock_movement' as entity_type
FROM stock_movements sm
JOIN ingredients i ON sm.ingredient_id = i.id
WHERE i.tenant_id = :tenant_id
ORDER BY sm.movement_date DESC
LIMIT :limit
"""
result = await db.execute(stock_query, {"tenant_id": tenant_id, "limit": limit // 2})
for row in result.fetchall():
stock_movements = await dashboard_repo.get_recent_stock_movements(tenant_id, limit // 2)
for row in stock_movements:
activities.append(RecentActivity(
activity_type=row.activity_type,
description=row.description,
timestamp=row.timestamp,
impact_level=row.impact_level,
entity_id=row.entity_id,
entity_type=row.entity_type
activity_type=row["activity_type"],
description=row["description"],
timestamp=row["timestamp"],
impact_level=row["impact_level"],
entity_id=row["entity_id"],
entity_type=row["entity_type"]
))
# Get recent food safety alerts
alert_query = """
SELECT
'food_safety_alert' as activity_type,
title as description,
created_at as timestamp,
created_by as user_id,
CASE
WHEN severity = 'critical' THEN 'high'
WHEN severity = 'high' THEN 'medium'
ELSE 'low'
END as impact_level,
id as entity_id,
'food_safety_alert' as entity_type
FROM food_safety_alerts
WHERE tenant_id = :tenant_id
ORDER BY created_at DESC
LIMIT :limit
"""
result = await db.execute(alert_query, {"tenant_id": tenant_id, "limit": limit // 2})
for row in result.fetchall():
safety_alerts = await dashboard_repo.get_recent_food_safety_alerts(tenant_id, limit // 2)
for row in safety_alerts:
activities.append(RecentActivity(
activity_type=row.activity_type,
description=row.description,
timestamp=row.timestamp,
impact_level=row.impact_level,
entity_id=row.entity_id,
entity_type=row.entity_type
activity_type=row["activity_type"],
description=row["description"],
timestamp=row["timestamp"],
impact_level=row["impact_level"],
entity_id=row["entity_id"],
entity_type=row["entity_type"]
))
# Sort by timestamp and limit
activities.sort(key=lambda x: x.timestamp, reverse=True)
return activities[:limit]
except Exception as e:
logger.error("Failed to get recent activity", error=str(e))
raise
@@ -523,34 +425,11 @@ class DashboardService:
async def get_live_metrics(self, db, tenant_id: UUID) -> Dict[str, Any]:
"""Get real-time inventory metrics"""
try:
query = """
SELECT
COUNT(DISTINCT i.id) as total_ingredients,
COUNT(CASE WHEN s.available_quantity > i.low_stock_threshold THEN 1 END) as in_stock,
COUNT(CASE WHEN s.available_quantity <= i.low_stock_threshold THEN 1 END) as low_stock,
COUNT(CASE WHEN s.available_quantity = 0 THEN 1 END) as out_of_stock,
COALESCE(SUM(s.available_quantity * s.unit_cost), 0) as total_value,
COUNT(CASE WHEN s.expiration_date < NOW() THEN 1 END) as expired_items,
COUNT(CASE WHEN s.expiration_date BETWEEN NOW() AND NOW() + INTERVAL '7 days' THEN 1 END) as expiring_soon
FROM ingredients i
LEFT JOIN stock s ON i.id = s.ingredient_id AND s.is_available = true
WHERE i.tenant_id = :tenant_id AND i.is_active = true
"""
result = await db.execute(query, {"tenant_id": tenant_id})
metrics = result.fetchone()
return {
"total_ingredients": metrics.total_ingredients,
"in_stock": metrics.in_stock,
"low_stock": metrics.low_stock,
"out_of_stock": metrics.out_of_stock,
"total_value": float(metrics.total_value),
"expired_items": metrics.expired_items,
"expiring_soon": metrics.expiring_soon,
"last_updated": datetime.now().isoformat()
}
repos = self._get_repositories(db)
dashboard_repo = repos['dashboard_repo']
return await dashboard_repo.get_live_metrics(tenant_id)
except Exception as e:
logger.error("Failed to get live metrics", error=str(e))
raise
@@ -607,34 +486,16 @@ class DashboardService:
try:
if not settings.ENABLE_BUSINESS_MODEL_DETECTION:
return {"model": "unknown", "confidence": Decimal("0")}
repos = self._get_repositories(db)
dashboard_repo = repos['dashboard_repo']
# Get ingredient metrics
query = """
SELECT
COUNT(*) as total_ingredients,
COUNT(CASE WHEN product_type = 'finished_product' THEN 1 END) as finished_products,
COUNT(CASE WHEN product_type = '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
LEFT JOIN (
SELECT ingredient_id, SUM(available_quantity) as available_quantity
FROM stock WHERE tenant_id = :tenant_id GROUP BY ingredient_id
) s ON i.id = s.ingredient_id
LEFT JOIN (
SELECT ingredient_id, supplier_id
FROM stock WHERE tenant_id = :tenant_id AND supplier_id IS NOT NULL
GROUP BY ingredient_id, supplier_id
) st ON i.id = st.ingredient_id
WHERE i.tenant_id = :tenant_id AND i.is_active = true
"""
result = await db.execute(query, {"tenant_id": tenant_id})
metrics = result.fetchone()
metrics = await dashboard_repo.get_business_model_metrics(tenant_id)
# Business model detection logic
total_ingredients = metrics.total_ingredients
finished_ratio = metrics.finished_products / total_ingredients if total_ingredients > 0 else 0
total_ingredients = metrics["total_ingredients"]
finished_ratio = metrics["finished_products"] / total_ingredients if total_ingredients > 0 else 0
if total_ingredients >= settings.CENTRAL_BAKERY_THRESHOLD_INGREDIENTS:
if finished_ratio > 0.3: # More than 30% finished products
@@ -659,31 +520,11 @@ class DashboardService:
async def _get_stock_by_category(self, db, tenant_id: UUID) -> Dict[str, Any]:
"""Get stock breakdown by category"""
try:
query = """
SELECT
COALESCE(i.ingredient_category::text, i.product_category::text, 'other') as category,
COUNT(*) as count,
COALESCE(SUM(s.available_quantity * s.unit_cost), 0) as total_value
FROM ingredients i
LEFT JOIN (
SELECT ingredient_id, SUM(available_quantity) as available_quantity, AVG(unit_cost) as unit_cost
FROM stock WHERE tenant_id = :tenant_id GROUP BY ingredient_id
) s ON i.id = s.ingredient_id
WHERE i.tenant_id = :tenant_id AND i.is_active = true
GROUP BY category
"""
result = await db.execute(query, {"tenant_id": tenant_id})
categories = {}
for row in result.fetchall():
categories[row.category] = {
"count": row.count,
"total_value": float(row.total_value)
}
return categories
repos = self._get_repositories(db)
dashboard_repo = repos['dashboard_repo']
return await dashboard_repo.get_stock_by_category(tenant_id)
except Exception as e:
logger.error("Failed to get stock by category", error=str(e))
return {}
@@ -691,21 +532,11 @@ class DashboardService:
async def _get_alerts_by_severity(self, db, tenant_id: UUID) -> Dict[str, int]:
"""Get alerts breakdown by severity"""
try:
query = """
SELECT severity, COUNT(*) as count
FROM food_safety_alerts
WHERE tenant_id = :tenant_id AND status = 'active'
GROUP BY severity
"""
result = await db.execute(query, {"tenant_id": tenant_id})
alerts = {"critical": 0, "high": 0, "medium": 0, "low": 0}
for row in result.fetchall():
alerts[row.severity] = row.count
return alerts
repos = self._get_repositories(db)
dashboard_repo = repos['dashboard_repo']
return await dashboard_repo.get_alerts_by_severity(tenant_id)
except Exception as e:
logger.error("Failed to get alerts by severity", error=str(e))
return {"critical": 0, "high": 0, "medium": 0, "low": 0}
@@ -713,23 +544,11 @@ class DashboardService:
async def _get_movements_by_type(self, db, tenant_id: UUID) -> Dict[str, int]:
"""Get movements breakdown by type"""
try:
query = """
SELECT sm.movement_type, COUNT(*) as count
FROM stock_movements sm
JOIN ingredients i ON sm.ingredient_id = i.id
WHERE i.tenant_id = :tenant_id
AND sm.movement_date > NOW() - INTERVAL '7 days'
GROUP BY sm.movement_type
"""
result = await db.execute(query, {"tenant_id": tenant_id})
movements = {}
for row in result.fetchall():
movements[row.movement_type] = row.count
return movements
repos = self._get_repositories(db)
dashboard_repo = repos['dashboard_repo']
return await dashboard_repo.get_movements_by_type(tenant_id)
except Exception as e:
logger.error("Failed to get movements by type", error=str(e))
return {}
@@ -773,29 +592,11 @@ class DashboardService:
async def _get_alert_trend(self, db, tenant_id: UUID, days: int) -> List[Dict[str, Any]]:
"""Get alert trend over time"""
try:
query = """
SELECT
DATE(created_at) as alert_date,
COUNT(*) as alert_count,
COUNT(CASE WHEN severity IN ('high', 'critical') THEN 1 END) as high_severity_count
FROM food_safety_alerts
WHERE tenant_id = :tenant_id
AND created_at > NOW() - INTERVAL '%s days'
GROUP BY DATE(created_at)
ORDER BY alert_date
""" % days
result = await db.execute(query, {"tenant_id": tenant_id})
return [
{
"date": row.alert_date.isoformat(),
"total_alerts": row.alert_count,
"high_severity_alerts": row.high_severity_count
}
for row in result.fetchall()
]
repos = self._get_repositories(db)
dashboard_repo = repos['dashboard_repo']
return await dashboard_repo.get_alert_trend(tenant_id, days)
except Exception as e:
logger.error("Failed to get alert trend", error=str(e))
return []
@@ -870,26 +671,10 @@ class DashboardService:
# Get ingredients to analyze costs by category
ingredients = await repos['ingredient_repo'].get_ingredients_by_tenant(tenant_id, limit=1000)
# Get current stock levels for all ingredients using a direct query
# Get current stock levels for all ingredients using repository
ingredient_stock_levels = {}
try:
from sqlalchemy import text
# Query to get current stock for all ingredients
stock_query = text("""
SELECT
i.id as ingredient_id,
COALESCE(SUM(s.available_quantity), 0) as current_stock
FROM ingredients i
LEFT JOIN stock s ON i.id = s.ingredient_id AND s.is_available = true
WHERE i.tenant_id = :tenant_id AND i.is_active = true
GROUP BY i.id
""")
result = await db.execute(stock_query, {"tenant_id": tenant_id})
for row in result.fetchall():
ingredient_stock_levels[str(row.ingredient_id)] = float(row.current_stock)
ingredient_stock_levels = await repos['dashboard_repo'].get_ingredient_stock_levels(tenant_id)
except Exception as e:
logger.warning(f"Could not fetch current stock levels for cost analysis: {e}")