Improve the frontend and fix TODOs
This commit is contained in:
@@ -100,6 +100,9 @@ class DashboardService:
|
||||
stock_value_trend = await self._get_stock_value_trend(db, tenant_id, days=30)
|
||||
alert_trend = await dashboard_repo.get_alert_trend(tenant_id, days=30)
|
||||
|
||||
# Get stock summary for total stock items
|
||||
stock_summary = await repos['stock_repo'].get_stock_summary_by_tenant(tenant_id)
|
||||
|
||||
# Recent activity
|
||||
recent_activity = await self.get_recent_activity(db, tenant_id, limit=10)
|
||||
|
||||
@@ -108,7 +111,7 @@ class DashboardService:
|
||||
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),
|
||||
total_stock_items=stock_summary.get('total_stock_items', 0),
|
||||
|
||||
# Stock status breakdown
|
||||
in_stock_items=await self._get_in_stock_count(db, tenant_id),
|
||||
@@ -872,6 +875,201 @@ class DashboardService:
|
||||
"temperature_compliance_rate": Decimal("100")
|
||||
}
|
||||
|
||||
async def _get_in_stock_count(self, db, tenant_id: UUID) -> int:
|
||||
"""Get count of items currently in stock"""
|
||||
try:
|
||||
repos = self._get_repositories(db)
|
||||
stock_repo = repos['stock_repo']
|
||||
|
||||
# Get stock summary and extract in-stock count
|
||||
stock_summary = await stock_repo.get_stock_summary_by_tenant(tenant_id)
|
||||
return stock_summary.get('in_stock_items', 0)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get in-stock count", error=str(e))
|
||||
return 0
|
||||
|
||||
async def _get_ingredient_metrics(self, db, tenant_id: UUID) -> Dict[str, Any]:
|
||||
"""Get ingredient metrics for business model analysis"""
|
||||
try:
|
||||
repos = self._get_repositories(db)
|
||||
ingredient_repo = repos['ingredient_repo']
|
||||
|
||||
# Get all ingredients for the tenant
|
||||
ingredients = await ingredient_repo.get_ingredients_by_tenant(tenant_id, limit=1000)
|
||||
|
||||
if not ingredients:
|
||||
return {
|
||||
"total_types": 0,
|
||||
"avg_stock": 0.0,
|
||||
"finished_product_ratio": 0.0,
|
||||
"supplier_count": 0
|
||||
}
|
||||
|
||||
# Calculate metrics
|
||||
total_types = len(ingredients)
|
||||
|
||||
# Calculate average stock per ingredient
|
||||
total_stock = sum(float(i.current_stock_level or 0) for i in ingredients)
|
||||
avg_stock = total_stock / total_types if total_types > 0 else 0
|
||||
|
||||
# Calculate finished product ratio
|
||||
finished_products = len([i for i in ingredients if hasattr(i, 'product_type') and i.product_type and i.product_type.value == 'finished_product'])
|
||||
finished_ratio = finished_products / total_types if total_types > 0 else 0
|
||||
|
||||
# Estimate supplier diversity (simplified)
|
||||
supplier_count = len(set(str(i.supplier_id) for i in ingredients if hasattr(i, 'supplier_id') and i.supplier_id)) or 1
|
||||
|
||||
return {
|
||||
"total_types": total_types,
|
||||
"avg_stock": avg_stock,
|
||||
"finished_product_ratio": finished_ratio,
|
||||
"supplier_count": supplier_count
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get ingredient metrics", error=str(e))
|
||||
return {
|
||||
"total_types": 0,
|
||||
"avg_stock": 0.0,
|
||||
"finished_product_ratio": 0.0,
|
||||
"supplier_count": 0
|
||||
}
|
||||
|
||||
async def _analyze_operational_patterns(self, db, tenant_id: UUID) -> Dict[str, Any]:
|
||||
"""Analyze operational patterns for business model insights"""
|
||||
try:
|
||||
repos = self._get_repositories(db)
|
||||
|
||||
# Get ingredients to analyze patterns
|
||||
ingredients = await repos['ingredient_repo'].get_ingredients_by_tenant(tenant_id, limit=1000)
|
||||
|
||||
if not ingredients:
|
||||
return {
|
||||
"order_frequency": "unknown",
|
||||
"seasonal_variation": "low",
|
||||
"bulk_indicator": "unknown",
|
||||
"scale_indicator": "small"
|
||||
}
|
||||
|
||||
# Analyze order frequency based on reorder patterns
|
||||
frequent_reorders = len([i for i in ingredients if hasattr(i, 'reorder_frequency') and i.reorder_frequency and i.reorder_frequency > 5])
|
||||
infrequent_reorders = len([i for i in ingredients if hasattr(i, 'reorder_frequency') and i.reorder_frequency and i.reorder_frequency <= 2])
|
||||
|
||||
if frequent_reorders > len(ingredients) * 0.3:
|
||||
order_frequency = "high"
|
||||
elif infrequent_reorders > len(ingredients) * 0.4:
|
||||
order_frequency = "low"
|
||||
else:
|
||||
order_frequency = "moderate"
|
||||
|
||||
# Analyze seasonal variation (simplified estimation)
|
||||
seasonal_variation = "moderate" # Default assumption for bakery business
|
||||
|
||||
# Analyze bulk purchasing indicator
|
||||
bulk_items = len([i for i in ingredients if hasattr(i, 'bulk_order_quantity') and i.bulk_order_quantity and i.bulk_order_quantity > 100])
|
||||
if bulk_items > len(ingredients) * 0.2:
|
||||
bulk_indicator = "high"
|
||||
elif bulk_items < len(ingredients) * 0.05:
|
||||
bulk_indicator = "low"
|
||||
else:
|
||||
bulk_indicator = "moderate"
|
||||
|
||||
# Analyze production scale
|
||||
total_ingredients = len(ingredients)
|
||||
if total_ingredients > 500:
|
||||
scale_indicator = "large"
|
||||
elif total_ingredients > 100:
|
||||
scale_indicator = "medium"
|
||||
else:
|
||||
scale_indicator = "small"
|
||||
|
||||
return {
|
||||
"order_frequency": order_frequency,
|
||||
"seasonal_variation": seasonal_variation,
|
||||
"bulk_indicator": bulk_indicator,
|
||||
"scale_indicator": scale_indicator
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to analyze operational patterns", error=str(e))
|
||||
return {
|
||||
"order_frequency": "unknown",
|
||||
"seasonal_variation": "low",
|
||||
"bulk_indicator": "unknown",
|
||||
"scale_indicator": "small"
|
||||
}
|
||||
|
||||
async def _generate_model_recommendations(
|
||||
self,
|
||||
model: str,
|
||||
ingredient_metrics: Dict[str, Any],
|
||||
operational_patterns: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""Generate business model specific recommendations"""
|
||||
try:
|
||||
recommendations = {
|
||||
"specific": [],
|
||||
"optimization": []
|
||||
}
|
||||
|
||||
# Model-specific recommendations
|
||||
if model == "central_bakery":
|
||||
recommendations["specific"].extend([
|
||||
"Optimize distribution network for multi-location delivery",
|
||||
"Implement centralized procurement for bulk discounts",
|
||||
"Standardize recipes across all production facilities"
|
||||
])
|
||||
|
||||
if operational_patterns.get("scale_indicator") == "large":
|
||||
recommendations["optimization"].extend([
|
||||
"Automate inter-facility transfers",
|
||||
"Implement predictive demand forecasting",
|
||||
"Optimize fleet routing for distribution"
|
||||
])
|
||||
|
||||
elif model == "individual_bakery":
|
||||
recommendations["specific"].extend([
|
||||
"Focus on local sourcing to reduce costs",
|
||||
"Implement just-in-time production scheduling",
|
||||
"Optimize single-location workflow efficiency"
|
||||
])
|
||||
|
||||
recommendations["optimization"].extend([
|
||||
"Reduce waste through better portion control",
|
||||
"Implement daily production planning",
|
||||
"Optimize oven scheduling for energy efficiency"
|
||||
])
|
||||
|
||||
elif model == "mixed":
|
||||
recommendations["specific"].extend([
|
||||
"Balance centralized and decentralized operations",
|
||||
"Implement hybrid sourcing strategy",
|
||||
"Maintain flexibility in production planning"
|
||||
])
|
||||
|
||||
recommendations["optimization"].extend([
|
||||
"Optimize batch sizes for efficiency",
|
||||
"Implement cross-training for staff flexibility",
|
||||
"Balance inventory across multiple locations"
|
||||
])
|
||||
|
||||
# Generic recommendations based on metrics
|
||||
if ingredient_metrics.get("finished_product_ratio", 0) > 0.5:
|
||||
recommendations["optimization"].append("Focus on finished product quality control")
|
||||
|
||||
if operational_patterns.get("order_frequency") == "high":
|
||||
recommendations["optimization"].append("Streamline ordering process with automated reordering")
|
||||
|
||||
return recommendations
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to generate model recommendations", error=str(e))
|
||||
return {
|
||||
"specific": ["Review business model configuration"],
|
||||
"optimization": ["Analyze operational data for insights"]
|
||||
}
|
||||
|
||||
async def _analyze_inventory_performance(self, db, tenant_id: UUID, days_back: int) -> Dict[str, Any]:
|
||||
"""Analyze overall inventory performance metrics using real data"""
|
||||
try:
|
||||
|
||||
@@ -412,11 +412,11 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin):
|
||||
for rec in recommendations:
|
||||
await self._generate_stock_recommendation(tenant_id, rec)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error generating recommendations for tenant",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error generating recommendations for tenant",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Inventory recommendations failed", error=str(e))
|
||||
self._errors_count += 1
|
||||
@@ -510,11 +510,11 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin):
|
||||
for waste in waste_data:
|
||||
await self._generate_waste_recommendation(tenant_id, waste)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error generating waste recommendations",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error generating waste recommendations",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Waste reduction recommendations failed", error=str(e))
|
||||
self._errors_count += 1
|
||||
@@ -885,4 +885,4 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin):
|
||||
except Exception as e:
|
||||
logger.error("Error generating expired batch summary alert",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
error=str(e))
|
||||
|
||||
@@ -419,15 +419,36 @@ class InventoryService:
|
||||
) -> List[StockMovementResponse]:
|
||||
"""Get stock movements with filtering"""
|
||||
logger.info("📈 Getting stock movements",
|
||||
tenant_id=tenant_id,
|
||||
ingredient_id=ingredient_id,
|
||||
tenant_id=str(tenant_id),
|
||||
ingredient_id=str(ingredient_id) if ingredient_id else None,
|
||||
skip=skip,
|
||||
limit=limit)
|
||||
limit=limit,
|
||||
movement_type=movement_type)
|
||||
try:
|
||||
async with get_db_transaction() as db:
|
||||
movement_repo = StockMovementRepository(db)
|
||||
ingredient_repo = IngredientRepository(db)
|
||||
|
||||
# Validate ingredient exists if filtering by ingredient
|
||||
if ingredient_id:
|
||||
ingredient = await ingredient_repo.get_by_id(ingredient_id)
|
||||
if not ingredient:
|
||||
logger.warning("Ingredient not found for movements query",
|
||||
ingredient_id=str(ingredient_id),
|
||||
tenant_id=str(tenant_id))
|
||||
raise ValueError(f"Ingredient {ingredient_id} not found")
|
||||
|
||||
if ingredient.tenant_id != tenant_id:
|
||||
logger.error("Ingredient does not belong to tenant",
|
||||
ingredient_id=str(ingredient_id),
|
||||
ingredient_tenant=str(ingredient.tenant_id),
|
||||
requested_tenant=str(tenant_id))
|
||||
raise ValueError(f"Ingredient {ingredient_id} does not belong to tenant {tenant_id}")
|
||||
|
||||
logger.info("Ingredient validated for movements query",
|
||||
ingredient_name=ingredient.name,
|
||||
ingredient_id=str(ingredient_id))
|
||||
|
||||
# Get filtered movements
|
||||
movements = await movement_repo.get_movements(
|
||||
tenant_id=tenant_id,
|
||||
@@ -454,8 +475,14 @@ class InventoryService:
|
||||
logger.info("✅ Returning movements", response_count=len(responses))
|
||||
return responses
|
||||
|
||||
except ValueError:
|
||||
# Re-raise validation errors as-is
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("❌ Failed to get stock movements", error=str(e), tenant_id=tenant_id)
|
||||
logger.error("❌ Failed to get stock movements",
|
||||
error=str(e),
|
||||
error_type=type(e).__name__,
|
||||
tenant_id=str(tenant_id))
|
||||
raise
|
||||
|
||||
# ===== ALERTS AND NOTIFICATIONS =====
|
||||
@@ -577,7 +604,7 @@ class InventoryService:
|
||||
low_stock_alerts=len(low_stock_items),
|
||||
expiring_soon_items=len(expiring_items),
|
||||
expired_items=len(expired_items),
|
||||
out_of_stock_items=0, # TODO: Calculate this
|
||||
out_of_stock_items=stock_summary.get('out_of_stock_count', 0),
|
||||
stock_by_category=stock_by_category,
|
||||
recent_movements=recent_activity.get('total_movements', 0),
|
||||
recent_purchases=recent_activity.get('purchase', {}).get('count', 0),
|
||||
|
||||
@@ -16,6 +16,7 @@ from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.core.config import settings
|
||||
from app.repositories.stock_movement_repository import StockMovementRepository
|
||||
from app.repositories.inventory_alert_repository import InventoryAlertRepository
|
||||
from shared.clients.production_client import create_production_client
|
||||
|
||||
logger = structlog.get_logger()
|
||||
@@ -170,6 +171,13 @@ class SustainabilityService:
|
||||
'damaged_inventory': inventory_waste * 0.3, # Estimate: 30% damaged
|
||||
}
|
||||
|
||||
# Get waste incidents from inventory alert repository
|
||||
alert_repo = InventoryAlertRepository(db)
|
||||
waste_opportunities = await alert_repo.get_waste_opportunities(tenant_id)
|
||||
|
||||
# Sum up all waste incidents for the period
|
||||
total_waste_incidents = sum(item['waste_incidents'] for item in waste_opportunities) if waste_opportunities else 0
|
||||
|
||||
return {
|
||||
'total_waste_kg': total_waste,
|
||||
'production_waste_kg': production_waste + defect_waste,
|
||||
@@ -177,7 +185,7 @@ class SustainabilityService:
|
||||
'waste_percentage': waste_percentage,
|
||||
'total_production_kg': total_production,
|
||||
'waste_by_reason': waste_by_reason,
|
||||
'waste_incidents': int(inv_data.waste_incidents or 0)
|
||||
'waste_incidents': total_waste_incidents
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
@@ -492,29 +500,54 @@ class SustainabilityService:
|
||||
return areas
|
||||
|
||||
def _assess_grant_readiness(self, sdg_compliance: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Assess readiness for various grant programs"""
|
||||
"""
|
||||
Assess readiness for EU grant programs accessible to Spanish bakeries and retail.
|
||||
Based on 2025 research and Spain's Law 1/2025 on food waste prevention.
|
||||
"""
|
||||
reduction = sdg_compliance['sdg_12_3']['reduction_achieved']
|
||||
|
||||
grants = {
|
||||
'eu_horizon_europe': {
|
||||
'eligible': reduction >= 30,
|
||||
'confidence': 'high' if reduction >= 50 else 'medium' if reduction >= 30 else 'low',
|
||||
'requirements_met': reduction >= 30
|
||||
},
|
||||
'eu_farm_to_fork': {
|
||||
'eligible': reduction >= 20,
|
||||
'confidence': 'high' if reduction >= 40 else 'medium' if reduction >= 20 else 'low',
|
||||
'requirements_met': reduction >= 20
|
||||
},
|
||||
'national_circular_economy': {
|
||||
'life_circular_economy': {
|
||||
'eligible': reduction >= 15,
|
||||
'confidence': 'high' if reduction >= 25 else 'medium' if reduction >= 15 else 'low',
|
||||
'requirements_met': reduction >= 15
|
||||
'requirements_met': reduction >= 15,
|
||||
'funding_eur': 73_000_000, # €73M available for circular economy
|
||||
'deadline': '2025-09-23',
|
||||
'program_type': 'grant'
|
||||
},
|
||||
'horizon_europe_cluster_6': {
|
||||
'eligible': reduction >= 20,
|
||||
'confidence': 'high' if reduction >= 35 else 'medium' if reduction >= 20 else 'low',
|
||||
'requirements_met': reduction >= 20,
|
||||
'funding_eur': 880_000_000, # €880M+ annually for food systems
|
||||
'deadline': 'rolling_2025',
|
||||
'program_type': 'grant'
|
||||
},
|
||||
'fedima_sustainability_grant': {
|
||||
'eligible': reduction >= 15,
|
||||
'confidence': 'high' if reduction >= 20 else 'medium' if reduction >= 15 else 'low',
|
||||
'requirements_met': reduction >= 15,
|
||||
'funding_eur': 20_000, # €20k bi-annual
|
||||
'deadline': '2025-06-30',
|
||||
'program_type': 'grant',
|
||||
'sector_specific': 'bakery'
|
||||
},
|
||||
'eit_food_retail': {
|
||||
'eligible': reduction >= 20,
|
||||
'confidence': 'high' if reduction >= 30 else 'medium' if reduction >= 20 else 'low',
|
||||
'requirements_met': reduction >= 20,
|
||||
'funding_eur': 45_000, # €15-45k range
|
||||
'deadline': 'rolling',
|
||||
'program_type': 'grant',
|
||||
'sector_specific': 'retail'
|
||||
},
|
||||
'un_sdg_certified': {
|
||||
'eligible': reduction >= 50,
|
||||
'confidence': 'high' if reduction >= 50 else 'low',
|
||||
'requirements_met': reduction >= 50
|
||||
'requirements_met': reduction >= 50,
|
||||
'funding_eur': 0, # Certification, not funding
|
||||
'deadline': 'ongoing',
|
||||
'program_type': 'certification'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -525,7 +558,11 @@ class SustainabilityService:
|
||||
'grant_programs': grants,
|
||||
'recommended_applications': [
|
||||
name for name, details in grants.items() if details['eligible']
|
||||
]
|
||||
],
|
||||
'spain_compliance': {
|
||||
'law_1_2025': True, # Spanish food waste prevention law
|
||||
'circular_economy_strategy': True # Spanish Circular Economy Strategy
|
||||
}
|
||||
}
|
||||
|
||||
async def export_grant_report(
|
||||
|
||||
Reference in New Issue
Block a user