Add more services

This commit is contained in:
Urtzi Alfaro
2025-08-21 20:28:14 +02:00
parent d6fd53e461
commit c6dd6fd1de
85 changed files with 17842 additions and 1828 deletions

View File

@@ -0,0 +1,715 @@
# ================================================================
# services/inventory/app/services/dashboard_service.py
# ================================================================
"""
Dashboard Service - Orchestrates data from multiple sources for dashboard views
"""
from datetime import datetime, timedelta
from decimal import Decimal
from typing import List, Optional, Dict, Any
from uuid import UUID
import structlog
from shared.database.transactions import transactional
from app.core.config import settings
from app.services.inventory_service import InventoryService
from app.services.food_safety_service import FoodSafetyService
from app.schemas.dashboard import (
InventoryDashboardSummary,
BusinessModelInsights,
InventoryAnalytics,
DashboardFilter,
AlertsFilter,
StockStatusSummary,
AlertSummary,
RecentActivity
)
logger = structlog.get_logger()
class DashboardService:
"""Service for dashboard data aggregation and analytics"""
def __init__(self, inventory_service: InventoryService, food_safety_service: FoodSafetyService):
self.inventory_service = inventory_service
self.food_safety_service = food_safety_service
@transactional
async def get_inventory_dashboard_summary(
self,
db,
tenant_id: UUID,
filters: Optional[DashboardFilter] = None
) -> InventoryDashboardSummary:
"""Get comprehensive inventory dashboard summary"""
try:
logger.info("Building dashboard summary", tenant_id=str(tenant_id))
# Get basic inventory metrics
inventory_summary = await self.inventory_service.get_inventory_summary(tenant_id)
# Get food safety metrics
food_safety_dashboard = await self.food_safety_service.get_food_safety_dashboard(db, tenant_id)
# Get business model insights
business_model = await self._detect_business_model(db, tenant_id)
# Get category breakdown
stock_by_category = await self._get_stock_by_category(db, tenant_id)
# Get alerts breakdown
alerts_by_severity = await self._get_alerts_by_severity(db, tenant_id)
# Get movements breakdown
movements_by_type = await self._get_movements_by_type(db, 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)
# Recent activity
recent_activity = await self.get_recent_activity(db, tenant_id, limit=10)
return InventoryDashboardSummary(
# Current inventory metrics
total_ingredients=inventory_summary.total_ingredients,
active_ingredients=inventory_summary.total_ingredients, # Assuming all are active
total_stock_value=inventory_summary.total_stock_value,
total_stock_items=await self._get_total_stock_items(db, tenant_id),
# Stock status breakdown
in_stock_items=await self._get_in_stock_count(db, tenant_id),
low_stock_items=inventory_summary.low_stock_alerts,
out_of_stock_items=inventory_summary.out_of_stock_items,
expired_items=inventory_summary.expired_items,
expiring_soon_items=inventory_summary.expiring_soon_items,
# Food safety metrics
food_safety_alerts_active=food_safety_dashboard.critical_alerts + food_safety_dashboard.high_risk_items,
temperature_violations_today=food_safety_dashboard.temperature_violations_24h,
compliance_issues=food_safety_dashboard.non_compliant_items + food_safety_dashboard.pending_review_items,
certifications_expiring_soon=food_safety_dashboard.certifications_expiring_soon,
# Recent activity
recent_stock_movements=inventory_summary.recent_movements,
recent_purchases=inventory_summary.recent_purchases,
recent_waste=inventory_summary.recent_waste,
recent_adjustments=0, # Would need to calculate
# Business model context
business_model=business_model.get("model"),
business_model_confidence=business_model.get("confidence"),
# Category breakdown
stock_by_category=stock_by_category,
alerts_by_severity=alerts_by_severity,
movements_by_type=movements_by_type,
# Performance indicators
inventory_turnover_ratio=performance_metrics.get("turnover_ratio"),
waste_percentage=performance_metrics.get("waste_percentage"),
compliance_score=performance_metrics.get("compliance_score"),
cost_per_unit_avg=performance_metrics.get("avg_cost_per_unit"),
# Trending data
stock_value_trend=stock_value_trend,
alert_trend=alert_trend
)
except Exception as e:
logger.error("Failed to build dashboard summary", error=str(e))
raise
async def get_business_model_insights(
self,
db,
tenant_id: UUID
) -> BusinessModelInsights:
"""Get business model insights based on inventory patterns"""
try:
# Get ingredient metrics
ingredient_metrics = await self._get_ingredient_metrics(db, tenant_id)
# Get operational patterns
operational_patterns = await self._analyze_operational_patterns(db, tenant_id)
# Detect business model
model_detection = await self._detect_business_model(db, tenant_id)
# Generate recommendations
recommendations = await self._generate_model_recommendations(
model_detection["model"],
ingredient_metrics,
operational_patterns
)
return BusinessModelInsights(
detected_model=model_detection["model"],
confidence_score=model_detection["confidence"],
total_ingredient_types=ingredient_metrics["total_types"],
average_stock_per_ingredient=ingredient_metrics["avg_stock"],
finished_product_ratio=ingredient_metrics["finished_product_ratio"],
supplier_diversity=ingredient_metrics["supplier_count"],
order_frequency_pattern=operational_patterns["order_frequency"],
seasonal_variation=operational_patterns["seasonal_variation"],
bulk_purchasing_indicator=operational_patterns["bulk_indicator"],
production_scale_indicator=operational_patterns["scale_indicator"],
model_specific_recommendations=recommendations["specific"],
optimization_opportunities=recommendations["optimization"]
)
except Exception as e:
logger.error("Failed to get business model insights", error=str(e))
raise
async def get_inventory_analytics(
self,
db,
tenant_id: UUID,
days_back: int = 30
) -> InventoryAnalytics:
"""Get advanced inventory analytics"""
try:
# Get turnover analysis
turnover_data = await self._analyze_inventory_turnover(db, tenant_id, days_back)
# Get cost analysis
cost_analysis = await self._analyze_costs(db, tenant_id, days_back)
# Get efficiency metrics
efficiency_metrics = await self._calculate_efficiency_metrics(db, tenant_id, days_back)
# Get quality and safety metrics
quality_metrics = await self._calculate_quality_metrics(db, tenant_id, days_back)
# Get supplier performance
supplier_performance = await self._analyze_supplier_performance(db, tenant_id, days_back)
return InventoryAnalytics(
inventory_turnover_rate=turnover_data["turnover_rate"],
fast_moving_items=turnover_data["fast_moving"],
slow_moving_items=turnover_data["slow_moving"],
dead_stock_items=turnover_data["dead_stock"],
total_inventory_cost=cost_analysis["total_cost"],
cost_by_category=cost_analysis["by_category"],
average_unit_cost_trend=cost_analysis["cost_trend"],
waste_cost_analysis=cost_analysis["waste_analysis"],
stockout_frequency=efficiency_metrics["stockouts"],
overstock_frequency=efficiency_metrics["overstocks"],
reorder_accuracy=efficiency_metrics["reorder_accuracy"],
forecast_accuracy=efficiency_metrics["forecast_accuracy"],
quality_incidents_rate=quality_metrics["incidents_rate"],
food_safety_score=quality_metrics["safety_score"],
compliance_score_by_standard=quality_metrics["compliance_scores"],
temperature_compliance_rate=quality_metrics["temperature_compliance"],
supplier_performance=supplier_performance["performance"],
delivery_reliability=supplier_performance["delivery_reliability"],
quality_consistency=supplier_performance["quality_consistency"]
)
except Exception as e:
logger.error("Failed to get inventory analytics", error=str(e))
raise
async def get_stock_status_by_category(
self,
db,
tenant_id: UUID
) -> 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()
summaries = []
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
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)),
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
async def get_alerts_summary(
self,
db,
tenant_id: UUID,
filters: Optional[AlertsFilter] = None
) -> 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()
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
)
for row in rows
]
except Exception as e:
logger.error("Failed to get alerts summary", error=str(e))
raise
async def get_recent_activity(
self,
db,
tenant_id: UUID,
limit: int = 20,
activity_types: Optional[List[str]] = None
) -> List[RecentActivity]:
"""Get recent inventory activity"""
try:
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():
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
))
# 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():
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
))
# 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
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()
}
except Exception as e:
logger.error("Failed to get live metrics", error=str(e))
raise
async def export_dashboard_data(
self,
db,
tenant_id: UUID,
format: str,
date_from: Optional[datetime] = None,
date_to: Optional[datetime] = None
) -> Dict[str, Any]:
"""Export dashboard data in specified format"""
try:
# Get dashboard summary
summary = await self.get_inventory_dashboard_summary(db, tenant_id)
# Get analytics
analytics = await self.get_inventory_analytics(db, tenant_id)
export_data = {
"export_info": {
"generated_at": datetime.now().isoformat(),
"tenant_id": str(tenant_id),
"format": format,
"date_range": {
"from": date_from.isoformat() if date_from else None,
"to": date_to.isoformat() if date_to else None
}
},
"dashboard_summary": summary.dict(),
"analytics": analytics.dict()
}
if format.lower() == "json":
return export_data
elif format.lower() in ["csv", "excel"]:
# For CSV/Excel, flatten the data structure
return {
"message": f"Export in {format} format would be generated here",
"data_preview": export_data
}
else:
raise ValueError(f"Unsupported export format: {format}")
except Exception as e:
logger.error("Failed to export dashboard data", error=str(e))
raise
# ===== PRIVATE HELPER METHODS =====
async def _detect_business_model(self, db, tenant_id: UUID) -> Dict[str, Any]:
"""Detect business model based on inventory patterns"""
try:
if not settings.ENABLE_BUSINESS_MODEL_DETECTION:
return {"model": "unknown", "confidence": Decimal("0")}
# 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 supplier_name) 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
WHERE i.tenant_id = :tenant_id AND i.is_active = true
"""
result = await db.execute(query, {"tenant_id": tenant_id})
metrics = result.fetchone()
# Business model detection logic
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
model = "central_bakery"
confidence = Decimal("85")
else:
model = "central_bakery"
confidence = Decimal("70")
elif total_ingredients <= settings.INDIVIDUAL_BAKERY_THRESHOLD_INGREDIENTS:
model = "individual_bakery"
confidence = Decimal("80")
else:
model = "mixed"
confidence = Decimal("60")
return {"model": model, "confidence": confidence}
except Exception as e:
logger.error("Failed to detect business model", error=str(e))
return {"model": "unknown", "confidence": Decimal("0")}
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
except Exception as e:
logger.error("Failed to get stock by category", error=str(e))
return {}
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
except Exception as e:
logger.error("Failed to get alerts by severity", error=str(e))
return {"critical": 0, "high": 0, "medium": 0, "low": 0}
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
except Exception as e:
logger.error("Failed to get movements by type", error=str(e))
return {}
async def _calculate_performance_indicators(self, db, tenant_id: UUID) -> Dict[str, Decimal]:
"""Calculate performance indicators"""
try:
# This would involve complex calculations
# For now, return placeholder values
return {
"turnover_ratio": Decimal("4.2"),
"waste_percentage": Decimal("2.1"),
"compliance_score": Decimal("8.5"),
"avg_cost_per_unit": Decimal("12.45")
}
except Exception as e:
logger.error("Failed to calculate performance indicators", error=str(e))
return {}
async def _get_stock_value_trend(self, db, tenant_id: UUID, days: int) -> List[Dict[str, Any]]:
"""Get stock value trend over time"""
try:
# This would track stock value changes over time
# For now, return sample data
trend_data = []
base_date = datetime.now() - timedelta(days=days)
for i in range(0, days, 7): # Weekly data points
trend_data.append({
"date": (base_date + timedelta(days=i)).isoformat(),
"value": float(Decimal("50000") + Decimal(str(i * 100)))
})
return trend_data
except Exception as e:
logger.error("Failed to get stock value trend", error=str(e))
return []
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()
]
except Exception as e:
logger.error("Failed to get alert trend", error=str(e))
return []
# Additional helper methods would be implemented here for:
# - _get_total_stock_items
# - _get_in_stock_count
# - _get_ingredient_metrics
# - _analyze_operational_patterns
# - _generate_model_recommendations
# - _analyze_inventory_turnover
# - _analyze_costs
# - _calculate_efficiency_metrics
# - _calculate_quality_metrics
# - _analyze_supplier_performance
# These are complex analytical methods that would require detailed implementation
# based on specific business requirements and data structures