Improve the frontend 4
This commit is contained in:
82
services/procurement/app/api/analytics.py
Normal file
82
services/procurement/app/api/analytics.py
Normal 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)}")
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
# ============================================================
|
||||
|
||||
Reference in New Issue
Block a user