Improve the frontend 4

This commit is contained in:
Urtzi Alfaro
2025-11-01 21:35:03 +01:00
parent f44d235c6d
commit 0220da1725
59 changed files with 5785 additions and 1870 deletions

View File

@@ -0,0 +1,82 @@
# services/procurement/app/api/analytics.py
"""
Procurement Analytics API - Reporting, statistics, and insights
Professional+ tier subscription required
"""
from fastapi import APIRouter, Depends, HTTPException, Query, Path
from typing import Optional, Dict, Any
from uuid import UUID
from datetime import datetime
import structlog
from app.services.procurement_service import ProcurementService
from shared.routing import RouteBuilder
from shared.auth.access_control import analytics_tier_required
from shared.auth.decorators import get_current_user_dep
from app.core.database import get_db
from app.core.config import settings
from sqlalchemy.ext.asyncio import AsyncSession
route_builder = RouteBuilder('procurement')
router = APIRouter(tags=["procurement-analytics"])
logger = structlog.get_logger()
def get_procurement_service(db: AsyncSession = Depends(get_db)) -> ProcurementService:
"""Dependency injection for ProcurementService"""
return ProcurementService(db, settings)
@router.get(
route_builder.build_analytics_route("procurement")
)
@analytics_tier_required
async def get_procurement_analytics(
tenant_id: UUID = Path(..., description="Tenant ID"),
start_date: Optional[datetime] = Query(None, description="Start date filter"),
end_date: Optional[datetime] = Query(None, description="End date filter"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
"""Get procurement analytics dashboard for a tenant (Professional+ tier required)"""
try:
# Call the service method to get actual analytics data
analytics_data = await procurement_service.get_procurement_analytics(
tenant_id=tenant_id,
start_date=start_date,
end_date=end_date
)
logger.info("Retrieved procurement analytics", tenant_id=tenant_id)
return analytics_data
except Exception as e:
logger.error("Failed to get procurement analytics", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to get procurement analytics: {str(e)}")
@router.get(
route_builder.build_analytics_route("procurement/trends")
)
@analytics_tier_required
async def get_procurement_trends(
tenant_id: UUID = Path(..., description="Tenant ID"),
days: int = Query(7, description="Number of days to retrieve trends for", ge=1, le=90),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
"""Get procurement time-series trends for charts (Professional+ tier required)"""
try:
# Call the service method to get trends data
trends_data = await procurement_service.get_procurement_trends(
tenant_id=tenant_id,
days=days
)
logger.info("Retrieved procurement trends", tenant_id=tenant_id, days=days)
return trends_data
except Exception as e:
logger.error("Failed to get procurement trends", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=f"Failed to get procurement trends: {str(e)}")

View File

@@ -94,11 +94,13 @@ service.setup_standard_endpoints()
from app.api.procurement_plans import router as procurement_plans_router
from app.api.purchase_orders import router as purchase_orders_router
from app.api import replenishment # Enhanced Replenishment Planning Routes
from app.api import analytics # Procurement Analytics Routes
from app.api import internal_demo
service.add_router(procurement_plans_router)
service.add_router(purchase_orders_router)
service.add_router(replenishment.router, prefix="/api/v1/tenants/{tenant_id}", tags=["replenishment"])
service.add_router(analytics.router, tags=["analytics"]) # RouteBuilder already includes full path
service.add_router(internal_demo.router)

View File

@@ -90,6 +90,30 @@ class ProcurementPlanRepository(BaseRepository):
result = await self.db.execute(stmt)
return result.scalars().all()
async def get_plans_by_tenant(
self,
tenant_id: uuid.UUID,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None
) -> List[ProcurementPlan]:
"""Get all procurement plans for a tenant with optional date filters"""
conditions = [ProcurementPlan.tenant_id == tenant_id]
if start_date:
conditions.append(ProcurementPlan.created_at >= start_date)
if end_date:
conditions.append(ProcurementPlan.created_at <= end_date)
stmt = (
select(ProcurementPlan)
.where(and_(*conditions))
.order_by(desc(ProcurementPlan.created_at))
.options(selectinload(ProcurementPlan.requirements))
)
result = await self.db.execute(stmt)
return result.scalars().all()
async def update_plan(self, plan_id: uuid.UUID, tenant_id: uuid.UUID, updates: Dict[str, Any]) -> Optional[ProcurementPlan]:
"""Update procurement plan"""
plan = await self.get_plan_by_id(plan_id, tenant_id)
@@ -204,3 +228,27 @@ class ProcurementRequirementRepository(BaseRepository):
count = result.scalar() or 0
return f"REQ-{count + 1:05d}"
async def get_requirements_by_tenant(
self,
tenant_id: uuid.UUID,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None
) -> List[ProcurementRequirement]:
"""Get all procurement requirements for a tenant with optional date filters"""
conditions = [ProcurementPlan.tenant_id == tenant_id]
if start_date:
conditions.append(ProcurementRequirement.created_at >= start_date)
if end_date:
conditions.append(ProcurementRequirement.created_at <= end_date)
stmt = (
select(ProcurementRequirement)
.join(ProcurementPlan)
.where(and_(*conditions))
.order_by(desc(ProcurementRequirement.created_at))
)
result = await self.db.execute(stmt)
return result.scalars().all()

View File

@@ -100,15 +100,19 @@ class ProcurementService:
# Initialize Recipe Explosion Service
self.recipe_explosion_service = RecipeExplosionService(
config=config,
recipes_client=self.recipes_client,
inventory_client=self.inventory_client
)
# Initialize Smart Calculator (keep for backward compatibility)
self.smart_calculator = SmartProcurementCalculator(
inventory_client=self.inventory_client,
forecast_client=self.forecast_client
procurement_settings={
'use_reorder_rules': True,
'economic_rounding': True,
'respect_storage_limits': True,
'use_supplier_minimums': True,
'optimize_price_tiers': True
}
)
# NEW: Initialize advanced planning services
@@ -351,6 +355,325 @@ class ProcurementService:
errors=[str(e)]
)
async def get_procurement_analytics(self, tenant_id: uuid.UUID, start_date: Optional[datetime] = None, end_date: Optional[datetime] = None):
"""
Get procurement analytics dashboard data with real supplier data and trends
"""
try:
logger.info("Retrieving procurement analytics", tenant_id=tenant_id)
# Set default date range if not provided
if not end_date:
end_date = datetime.now()
if not start_date:
start_date = end_date - timedelta(days=30)
# Get procurement plans summary
plans = await self.plan_repo.get_plans_by_tenant(tenant_id, start_date, end_date)
total_plans = len(plans)
# Calculate summary metrics
total_estimated_cost = sum(float(plan.total_estimated_cost or 0) for plan in plans)
total_approved_cost = sum(float(plan.total_approved_cost or 0) for plan in plans)
cost_variance = total_approved_cost - total_estimated_cost
# Get requirements for performance metrics
requirements = await self.requirement_repo.get_requirements_by_tenant(tenant_id, start_date, end_date)
# Calculate performance metrics
fulfilled_requirements = [r for r in requirements if r.status == 'received']
on_time_deliveries = [r for r in fulfilled_requirements if r.delivery_status == 'delivered']
fulfillment_rate = len(fulfilled_requirements) / len(requirements) if requirements else 0
on_time_rate = len(on_time_deliveries) / len(fulfilled_requirements) if fulfilled_requirements else 0
# Calculate cost accuracy
cost_accuracy = 0
if requirements:
cost_variance_items = [r for r in requirements if r.estimated_total_cost and r.estimated_total_cost != 0]
if cost_variance_items:
cost_accuracy = 1.0 - (sum(
abs(float(r.estimated_total_cost or 0) - float(r.approved_cost or 0)) / float(r.estimated_total_cost or 1)
for r in cost_variance_items
) / len(cost_variance_items))
# ============================================================
# TREND CALCULATIONS (7-day comparison)
# ============================================================
trend_start = end_date - timedelta(days=7)
previous_period_end = trend_start
previous_period_start = previous_period_end - timedelta(days=7)
# Get previous period data
prev_requirements = await self.requirement_repo.get_requirements_by_tenant(
tenant_id, previous_period_start, previous_period_end
)
# Calculate previous period metrics
prev_fulfilled = [r for r in prev_requirements if r.status == 'received']
prev_on_time = [r for r in prev_fulfilled if r.delivery_status == 'delivered']
prev_fulfillment_rate = len(prev_fulfilled) / len(prev_requirements) if prev_requirements else 0
prev_on_time_rate = len(prev_on_time) / len(prev_fulfilled) if prev_fulfilled else 0
prev_cost_accuracy = 0
if prev_requirements:
prev_cost_items = [r for r in prev_requirements if r.estimated_total_cost and r.estimated_total_cost != 0]
if prev_cost_items:
prev_cost_accuracy = 1.0 - (sum(
abs(float(r.estimated_total_cost or 0) - float(r.approved_cost or 0)) / float(r.estimated_total_cost or 1)
for r in prev_cost_items
) / len(prev_cost_items))
# Calculate trend percentages
fulfillment_trend = self._calculate_trend_percentage(fulfillment_rate, prev_fulfillment_rate)
on_time_trend = self._calculate_trend_percentage(on_time_rate, prev_on_time_rate)
cost_variance_trend = self._calculate_trend_percentage(cost_accuracy, prev_cost_accuracy)
# Plan status distribution
status_counts = {}
for plan in plans:
status = plan.status
status_counts[status] = status_counts.get(status, 0) + 1
plan_status_distribution = [
{"status": status, "count": count}
for status, count in status_counts.items()
]
# ============================================================
# CRITICAL REQUIREMENTS with REAL INVENTORY DATA
# ============================================================
try:
inventory_items = await self.inventory_client.get_all_ingredients(str(tenant_id))
inventory_map = {str(item.get('id')): item for item in inventory_items}
low_stock_count = 0
for req in requirements:
ingredient_id = str(req.ingredient_id)
if ingredient_id in inventory_map:
inv_item = inventory_map[ingredient_id]
current_stock = float(inv_item.get('quantity_available', 0))
reorder_point = float(inv_item.get('reorder_point', 0))
if current_stock <= reorder_point:
low_stock_count += 1
except Exception as e:
logger.warning("Failed to get inventory data for critical requirements", error=str(e))
low_stock_count = len([r for r in requirements if r.priority == 'high'])
critical_requirements = {
"low_stock": low_stock_count,
"overdue": len([r for r in requirements if r.status == 'pending' and r.required_by_date < datetime.now().date()]),
"high_priority": len([r for r in requirements if r.priority == 'high'])
}
# Recent plans
recent_plans = []
for plan in sorted(plans, key=lambda x: x.created_at, reverse=True)[:5]:
recent_plans.append({
"id": str(plan.id),
"plan_number": plan.plan_number,
"plan_date": plan.plan_date.isoformat() if plan.plan_date else None,
"status": plan.status,
"total_requirements": plan.total_requirements or 0,
"total_estimated_cost": float(plan.total_estimated_cost or 0),
"created_at": plan.created_at.isoformat() if plan.created_at else None
})
# ============================================================
# SUPPLIER PERFORMANCE with REAL SUPPLIER DATA
# ============================================================
supplier_performance = []
supplier_reqs = {}
for req in requirements:
if req.preferred_supplier_id:
supplier_id = str(req.preferred_supplier_id)
if supplier_id not in supplier_reqs:
supplier_reqs[supplier_id] = []
supplier_reqs[supplier_id].append(req)
# Fetch real supplier data
try:
suppliers_data = await self.suppliers_client.get_all_suppliers(str(tenant_id))
suppliers_map = {str(s.get('id')): s for s in suppliers_data}
for supplier_id, reqs in supplier_reqs.items():
fulfilled = len([r for r in reqs if r.status == 'received'])
on_time = len([r for r in reqs if r.delivery_status == 'delivered'])
# Get real supplier info
supplier_info = suppliers_map.get(supplier_id, {})
supplier_name = supplier_info.get('name', f'Unknown Supplier')
# Use real quality rating from supplier data
quality_score = supplier_info.get('quality_rating', 0)
delivery_rating = supplier_info.get('delivery_rating', 0)
supplier_performance.append({
"id": supplier_id,
"name": supplier_name,
"total_orders": len(reqs),
"fulfillment_rate": fulfilled / len(reqs) if reqs else 0,
"on_time_rate": on_time / fulfilled if fulfilled else 0,
"quality_score": quality_score
})
except Exception as e:
logger.warning("Failed to get supplier data, using fallback", error=str(e))
for supplier_id, reqs in supplier_reqs.items():
fulfilled = len([r for r in reqs if r.status == 'received'])
on_time = len([r for r in reqs if r.delivery_status == 'delivered'])
supplier_performance.append({
"id": supplier_id,
"name": f"Supplier {supplier_id[:8]}...",
"total_orders": len(reqs),
"fulfillment_rate": fulfilled / len(reqs) if reqs else 0,
"on_time_rate": on_time / fulfilled if fulfilled else 0,
"quality_score": 0
})
# Cost by category
cost_by_category = []
category_costs = {}
for req in requirements:
category = req.product_category or "Uncategorized"
category_costs[category] = category_costs.get(category, 0) + float(req.estimated_total_cost or 0)
for category, amount in category_costs.items():
cost_by_category.append({
"name": category,
"amount": amount
})
# Quality metrics
quality_reqs = [r for r in requirements if hasattr(r, 'quality_rating') and r.quality_rating]
avg_quality = sum(r.quality_rating for r in quality_reqs) / len(quality_reqs) if quality_reqs else 0
high_quality_count = len([r for r in quality_reqs if r.quality_rating >= 4.0])
low_quality_count = len([r for r in quality_reqs if r.quality_rating <= 2.0])
analytics_data = {
"summary": {
"total_plans": total_plans,
"total_estimated_cost": total_estimated_cost,
"total_approved_cost": total_approved_cost,
"cost_variance": cost_variance
},
"performance_metrics": {
"average_fulfillment_rate": fulfillment_rate,
"average_on_time_delivery": on_time_rate,
"cost_accuracy": cost_accuracy,
"supplier_performance": avg_quality if quality_reqs else 0,
"fulfillment_trend": fulfillment_trend,
"on_time_trend": on_time_trend,
"cost_variance_trend": cost_variance_trend
},
"plan_status_distribution": plan_status_distribution,
"critical_requirements": critical_requirements,
"recent_plans": recent_plans,
"supplier_performance": supplier_performance,
"cost_by_category": cost_by_category,
"quality_metrics": {
"avg_score": avg_quality,
"high_quality_count": high_quality_count,
"low_quality_count": low_quality_count
}
}
return analytics_data
except Exception as e:
logger.error("Failed to get procurement analytics", error=str(e), tenant_id=tenant_id)
raise
def _calculate_trend_percentage(self, current_value: float, previous_value: float) -> float:
"""
Calculate percentage change between current and previous values
Returns percentage change (e.g., 0.05 for 5% increase, -0.03 for 3% decrease)
"""
if previous_value == 0:
return 0.0 if current_value == 0 else 1.0
change = ((current_value - previous_value) / previous_value)
return round(change, 4)
async def get_procurement_trends(self, tenant_id: uuid.UUID, days: int = 7):
"""
Get time-series procurement trends for charts (last N days)
Returns daily metrics for performance and quality trends
"""
try:
logger.info("Retrieving procurement trends", tenant_id=tenant_id, days=days)
end_date = datetime.now()
start_date = end_date - timedelta(days=days)
# Get requirements for the period
requirements = await self.requirement_repo.get_requirements_by_tenant(tenant_id, start_date, end_date)
# Group requirements by day
daily_data = {}
for day_offset in range(days):
day_date = (start_date + timedelta(days=day_offset)).date()
daily_data[day_date] = {
'date': day_date.isoformat(),
'requirements': [],
'fulfillment_rate': 0,
'on_time_rate': 0,
'quality_score': 0
}
# Assign requirements to days based on creation date
for req in requirements:
req_date = req.created_at.date() if req.created_at else None
if req_date and req_date in daily_data:
daily_data[req_date]['requirements'].append(req)
# Calculate daily metrics
performance_trend = []
quality_trend = []
for day_date in sorted(daily_data.keys()):
day_reqs = daily_data[day_date]['requirements']
if day_reqs:
# Calculate fulfillment rate
fulfilled = [r for r in day_reqs if r.status == 'received']
fulfillment_rate = len(fulfilled) / len(day_reqs) if day_reqs else 0
# Calculate on-time rate
on_time = [r for r in fulfilled if r.delivery_status == 'delivered']
on_time_rate = len(on_time) / len(fulfilled) if fulfilled else 0
# Calculate quality score
quality_reqs = [r for r in day_reqs if hasattr(r, 'quality_rating') and r.quality_rating]
avg_quality = sum(r.quality_rating for r in quality_reqs) / len(quality_reqs) if quality_reqs else 0
else:
fulfillment_rate = 0
on_time_rate = 0
avg_quality = 0
performance_trend.append({
'date': day_date.isoformat(),
'fulfillment_rate': round(fulfillment_rate, 4),
'on_time_rate': round(on_time_rate, 4)
})
quality_trend.append({
'date': day_date.isoformat(),
'quality_score': round(avg_quality, 2)
})
return {
'performance_trend': performance_trend,
'quality_trend': quality_trend,
'period_days': days,
'start_date': start_date.date().isoformat(),
'end_date': end_date.date().isoformat()
}
except Exception as e:
logger.error("Failed to get procurement trends", error=str(e), tenant_id=tenant_id)
raise
# ============================================================
# Helper Methods
# ============================================================