Files
bakery-ia/services/inventory/app/repositories/dashboard_repository.py
2025-10-23 07:44:54 +02:00

465 lines
19 KiB
Python

# services/inventory/app/repositories/dashboard_repository.py
"""
Dashboard Repository for complex dashboard queries
"""
from typing import List, Optional, Dict, Any
from uuid import UUID
from datetime import datetime
from decimal import Decimal
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
import structlog
logger = structlog.get_logger()
class DashboardRepository:
"""Repository for dashboard-specific database queries"""
def __init__(self, session: AsyncSession):
self.session = session
async def get_business_model_metrics(self, tenant_id: UUID) -> Dict[str, Any]:
"""Get ingredient metrics for business model detection"""
try:
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(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 self.session.execute(query, {"tenant_id": tenant_id})
row = result.fetchone()
if not row:
return {
"total_ingredients": 0,
"finished_products": 0,
"raw_ingredients": 0,
"supplier_count": 0,
"avg_stock_level": 0
}
return {
"total_ingredients": row.total_ingredients,
"finished_products": row.finished_products,
"raw_ingredients": row.raw_ingredients,
"supplier_count": row.supplier_count,
"avg_stock_level": float(row.avg_stock_level) if row.avg_stock_level else 0
}
except Exception as e:
logger.error("Failed to get business model metrics", error=str(e), tenant_id=str(tenant_id))
raise
async def get_stock_by_category(self, tenant_id: UUID) -> Dict[str, Dict[str, Any]]:
"""Get stock breakdown by category"""
try:
query = text("""
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 self.session.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
except Exception as e:
logger.error("Failed to get stock by category", error=str(e), tenant_id=str(tenant_id))
raise
async def get_alerts_by_severity(self, tenant_id: UUID) -> Dict[str, int]:
"""Get active alerts breakdown by severity"""
try:
query = text("""
SELECT severity, COUNT(*) as count
FROM food_safety_alerts
WHERE tenant_id = :tenant_id AND status = 'active'
GROUP BY severity
""")
result = await self.session.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
except Exception as e:
logger.error("Failed to get alerts by severity", error=str(e), tenant_id=str(tenant_id))
raise
async def get_movements_by_type(self, tenant_id: UUID, days: int = 7) -> Dict[str, int]:
"""Get stock movements breakdown by type for recent period"""
try:
query = text("""
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 self.session.execute(query, {"tenant_id": tenant_id})
movements = {}
for row in result.fetchall():
movements[row.movement_type] = row.count
return movements
except Exception as e:
logger.error("Failed to get movements by type", error=str(e), tenant_id=str(tenant_id))
raise
async def get_alert_trend(self, tenant_id: UUID, days: int = 30) -> List[Dict[str, Any]]:
"""Get alert trend over time"""
try:
query = text(f"""
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 '{days} days'
GROUP BY DATE(created_at)
ORDER BY alert_date
""")
result = await self.session.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()
]
except Exception as e:
logger.error("Failed to get alert trend", error=str(e), tenant_id=str(tenant_id))
raise
async def get_recent_stock_movements(
self,
tenant_id: UUID,
limit: int = 20
) -> List[Dict[str, Any]]:
"""Get recent stock movements"""
try:
query = text("""
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 self.session.execute(query, {"tenant_id": tenant_id, "limit": limit})
return [
{
"activity_type": row.activity_type,
"description": row.description,
"timestamp": row.timestamp,
"user_id": row.user_id,
"impact_level": row.impact_level,
"entity_id": row.entity_id,
"entity_type": row.entity_type
}
for row in result.fetchall()
]
except Exception as e:
logger.error("Failed to get recent stock movements", error=str(e), tenant_id=str(tenant_id))
raise
async def get_recent_food_safety_alerts(
self,
tenant_id: UUID,
limit: int = 20
) -> List[Dict[str, Any]]:
"""Get recent food safety alerts"""
try:
query = text("""
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 self.session.execute(query, {"tenant_id": tenant_id, "limit": limit})
return [
{
"activity_type": row.activity_type,
"description": row.description,
"timestamp": row.timestamp,
"user_id": row.user_id,
"impact_level": row.impact_level,
"entity_id": row.entity_id,
"entity_type": row.entity_type
}
for row in result.fetchall()
]
except Exception as e:
logger.error("Failed to get recent food safety alerts", error=str(e), tenant_id=str(tenant_id))
raise
async def get_live_metrics(self, tenant_id: UUID) -> Dict[str, Any]:
"""Get real-time inventory metrics"""
try:
query = text("""
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 self.session.execute(query, {"tenant_id": tenant_id})
metrics = result.fetchone()
if not metrics:
return {
"total_ingredients": 0,
"in_stock": 0,
"low_stock": 0,
"out_of_stock": 0,
"total_value": 0.0,
"expired_items": 0,
"expiring_soon": 0,
"last_updated": datetime.now().isoformat()
}
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()
}
except Exception as e:
logger.error("Failed to get live metrics", error=str(e), tenant_id=str(tenant_id))
raise
async def get_stock_status_by_category(
self,
tenant_id: UUID
) -> List[Dict[str, Any]]:
"""Get stock status breakdown by category"""
try:
query = text("""
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 self.session.execute(query, {"tenant_id": tenant_id})
return [
{
"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": float(row.total_value)
}
for row in result.fetchall()
]
except Exception as e:
logger.error("Failed to get stock status by category", error=str(e), tenant_id=str(tenant_id))
raise
async def get_alerts_summary(
self,
tenant_id: UUID,
alert_types: Optional[List[str]] = None,
severities: Optional[List[str]] = None,
date_from: Optional[datetime] = None,
date_to: Optional[datetime] = None
) -> List[Dict[str, Any]]:
"""Get alerts summary by type and severity with filters"""
try:
# Build query with filters
where_conditions = ["tenant_id = :tenant_id", "status = 'active'"]
params = {"tenant_id": tenant_id}
if alert_types:
where_conditions.append("alert_type = ANY(:alert_types)")
params["alert_types"] = alert_types
if severities:
where_conditions.append("severity = ANY(:severities)")
params["severities"] = severities
if date_from:
where_conditions.append("created_at >= :date_from")
params["date_from"] = date_from
if date_to:
where_conditions.append("created_at <= :date_to")
params["date_to"] = date_to
where_clause = " AND ".join(where_conditions)
query = text(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 self.session.execute(query, params)
return [
{
"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
}
for row in result.fetchall()
]
except Exception as e:
logger.error("Failed to get alerts summary", error=str(e), tenant_id=str(tenant_id))
raise
async def get_ingredient_stock_levels(self, tenant_id: UUID) -> Dict[str, float]:
"""
Get current stock levels for all ingredients
Args:
tenant_id: Tenant UUID
Returns:
Dictionary mapping ingredient_id to current stock level
"""
try:
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 self.session.execute(stock_query, {"tenant_id": tenant_id})
stock_levels = {}
for row in result.fetchall():
stock_levels[str(row.ingredient_id)] = float(row.current_stock)
return stock_levels
except Exception as e:
logger.error("Failed to get ingredient stock levels", error=str(e), tenant_id=str(tenant_id))
raise