feat(dashboard): add dashboard module for business metrics and statistics
- Implemented DTOs for KPIs, activity items, chart data points, and dashboard statistics. - Created API routes for fetching dashboard statistics, operations overview, and inventory metrics. - Developed a service layer to handle the logic for generating dashboard statistics, including invoice metrics, client/provider metrics, and recent activity. - Added frontend API client methods for fetching dashboard data. - Created TypeScript types for dashboard data structures. - Developed Svelte components for displaying recent activity, KPI cards, trend charts, and donut charts.
This commit is contained in:
7
backend/api/v1/modules/core/dashboard/__init__.py
Normal file
7
backend/api/v1/modules/core/dashboard/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
Módulo de dashboard para estadísticas y métricas empresariales
|
||||
"""
|
||||
|
||||
from .routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
108
backend/api/v1/modules/core/dashboard/dto.py
Normal file
108
backend/api/v1/modules/core/dashboard/dto.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
DTOs para el dashboard de estadísticas y métricas empresariales
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class KPIMetric(BaseModel):
|
||||
"""Métrica individual de KPI"""
|
||||
|
||||
label: str = Field(..., description="Nombre del indicador")
|
||||
value: int | float = Field(..., description="Valor actual")
|
||||
previous_value: Optional[int | float] = Field(
|
||||
None, description="Valor anterior para comparación"
|
||||
)
|
||||
percentage_change: Optional[float] = Field(None, description="Porcentaje de cambio")
|
||||
trend: Optional[str] = Field(None, description="up, down, stable")
|
||||
unit: Optional[str] = Field(None, description="Unidad de medida (%, USD, etc)")
|
||||
|
||||
|
||||
class ActivityItem(BaseModel):
|
||||
"""Item de actividad reciente"""
|
||||
|
||||
id: int
|
||||
type: str = Field(
|
||||
..., description="Tipo de actividad: invoice, pedimento, client, etc"
|
||||
)
|
||||
title: str = Field(..., description="Título descriptivo")
|
||||
description: Optional[str] = Field(None, description="Descripción adicional")
|
||||
timestamp: datetime
|
||||
status: Optional[str] = Field(None, description="Estado del item")
|
||||
icon: Optional[str] = Field(None, description="Icono a mostrar")
|
||||
|
||||
|
||||
class ChartDataPoint(BaseModel):
|
||||
"""Punto de datos para gráficas"""
|
||||
|
||||
label: str
|
||||
value: float
|
||||
category: Optional[str] = None
|
||||
|
||||
|
||||
class DashboardStats(BaseModel):
|
||||
"""Estadísticas generales del dashboard"""
|
||||
|
||||
# KPIs principales
|
||||
total_invoices: KPIMetric
|
||||
total_pedimentos: KPIMetric
|
||||
total_clients: KPIMetric
|
||||
total_providers: KPIMetric
|
||||
active_items: KPIMetric
|
||||
pending_approvals: KPIMetric
|
||||
|
||||
# Estadísticas financieras
|
||||
total_value_imports: Optional[float] = Field(
|
||||
None, description="Valor total de importaciones"
|
||||
)
|
||||
total_value_exports: Optional[float] = Field(
|
||||
None, description="Valor total de exportaciones"
|
||||
)
|
||||
|
||||
# Datos para gráficas
|
||||
invoices_by_month: List[ChartDataPoint] = Field(default_factory=list)
|
||||
operations_by_type: List[ChartDataPoint] = Field(default_factory=list)
|
||||
top_clients: List[ChartDataPoint] = Field(default_factory=list)
|
||||
top_providers: List[ChartDataPoint] = Field(default_factory=list)
|
||||
|
||||
# Actividad reciente
|
||||
recent_activity: List[ActivityItem] = Field(default_factory=list)
|
||||
|
||||
# Metadata
|
||||
generated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
company_id: int
|
||||
company_name: Optional[str] = None
|
||||
|
||||
|
||||
class OperationsOverview(BaseModel):
|
||||
"""Vista general de operaciones"""
|
||||
|
||||
total_operations: int
|
||||
by_type: Dict[str, int] = Field(default_factory=dict)
|
||||
by_status: Dict[str, int] = Field(default_factory=dict)
|
||||
avg_processing_time: Optional[float] = Field(
|
||||
None, description="Tiempo promedio en días"
|
||||
)
|
||||
|
||||
|
||||
class InventoryMetrics(BaseModel):
|
||||
"""Métricas de inventario"""
|
||||
|
||||
total_items: int
|
||||
items_in_stock: int
|
||||
items_low_stock: int
|
||||
total_value: Optional[float] = None
|
||||
by_category: Dict[str, int] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ComplianceMetrics(BaseModel):
|
||||
"""Métricas de cumplimiento normativo"""
|
||||
|
||||
pending_documents: int
|
||||
expired_permits: int
|
||||
upcoming_deadlines: int
|
||||
compliance_score: Optional[float] = Field(
|
||||
None, description="Score de cumplimiento 0-100"
|
||||
)
|
||||
84
backend/api/v1/modules/core/dashboard/routes.py
Normal file
84
backend/api/v1/modules/core/dashboard/routes.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
Endpoints del dashboard para estadísticas y métricas empresariales
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user, validate_access_to_resource
|
||||
|
||||
from .dto import DashboardStats, OperationsOverview, InventoryMetrics
|
||||
from .service import DashboardService
|
||||
|
||||
router = APIRouter(prefix="/dashboard", tags=["Dashboard"])
|
||||
|
||||
|
||||
@router.get("/stats", response_model=DashboardStats)
|
||||
async def get_dashboard_stats(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Obtiene estadísticas completas del dashboard para la compañía especificada.
|
||||
|
||||
Incluye:
|
||||
- KPIs principales (facturas, pedimentos, clientes, proveedores, items)
|
||||
- Gráficas de tendencias (facturas por mes, operaciones por tipo)
|
||||
- Top clientes y proveedores
|
||||
- Actividad reciente
|
||||
"""
|
||||
# Validar acceso
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
|
||||
# Generar estadísticas
|
||||
service = DashboardService(db, tenant_id, company_id)
|
||||
stats = service.get_complete_dashboard_stats()
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
@router.get("/operations-overview", response_model=OperationsOverview)
|
||||
async def get_operations_overview(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Obtiene una vista general de las operaciones
|
||||
"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
|
||||
service = DashboardService(db, tenant_id, company_id)
|
||||
|
||||
# Implementación básica
|
||||
ops_by_type = service.get_operations_by_type()
|
||||
|
||||
return OperationsOverview(
|
||||
total_operations=sum(int(op.value) for op in ops_by_type),
|
||||
by_type={op.label: int(op.value) for op in ops_by_type},
|
||||
by_status={},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/inventory-metrics", response_model=InventoryMetrics)
|
||||
async def get_inventory_metrics(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Obtiene métricas de inventario
|
||||
"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
|
||||
service = DashboardService(db, tenant_id, company_id)
|
||||
items_kpi = service.get_items_metrics()
|
||||
|
||||
return InventoryMetrics(
|
||||
total_items=int(items_kpi.value),
|
||||
items_in_stock=int(items_kpi.value), # Simplificado
|
||||
items_low_stock=0,
|
||||
by_category={},
|
||||
)
|
||||
362
backend/api/v1/modules/core/dashboard/service.py
Normal file
362
backend/api/v1/modules/core/dashboard/service.py
Normal file
@@ -0,0 +1,362 @@
|
||||
"""
|
||||
Servicio para generar estadísticas y métricas del dashboard
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Optional
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, and_, desc
|
||||
|
||||
from api.v1.modules.a76.invoices.models import (
|
||||
InvoiceHeader,
|
||||
InvoiceComplianceMx,
|
||||
OperationType,
|
||||
)
|
||||
from api.v1.modules.a76.items.models import Item
|
||||
from api.v1.modules.a76.clients_and_providers.models import (
|
||||
ClientProvider,
|
||||
ClientOrProviderEnum,
|
||||
)
|
||||
from api.v1.modules.a76.general_catalogs.company.models import Company
|
||||
|
||||
from .dto import (
|
||||
DashboardStats,
|
||||
KPIMetric,
|
||||
ActivityItem,
|
||||
ChartDataPoint,
|
||||
OperationsOverview,
|
||||
InventoryMetrics,
|
||||
)
|
||||
|
||||
|
||||
class DashboardService:
|
||||
"""Servicio para generar estadísticas del dashboard"""
|
||||
|
||||
def __init__(self, db: Session, tenant_id: int, company_id: int):
|
||||
self.db = db
|
||||
self.tenant_id = tenant_id
|
||||
self.company_id = company_id
|
||||
|
||||
def _calculate_trend(self, current: int, previous: int) -> tuple[float, str]:
|
||||
"""Calcula el cambio porcentual y la tendencia"""
|
||||
if previous == 0:
|
||||
return 0.0, "stable" if current == 0 else "up"
|
||||
|
||||
change = ((current - previous) / previous) * 100
|
||||
|
||||
if abs(change) < 1:
|
||||
trend = "stable"
|
||||
elif change > 0:
|
||||
trend = "up"
|
||||
else:
|
||||
trend = "down"
|
||||
|
||||
return round(change, 2), trend
|
||||
|
||||
def get_invoice_metrics(self) -> tuple[KPIMetric, List[ChartDataPoint]]:
|
||||
"""Obtiene métricas de facturas"""
|
||||
# Total de facturas actuales
|
||||
current_total = (
|
||||
self.db.query(func.count(InvoiceHeader.id))
|
||||
.filter(
|
||||
InvoiceHeader.tenant_id == self.tenant_id,
|
||||
InvoiceHeader.company_id == self.company_id,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# Total del mes anterior para comparación
|
||||
last_month = datetime.utcnow() - timedelta(days=30)
|
||||
previous_total = (
|
||||
self.db.query(func.count(InvoiceHeader.id))
|
||||
.filter(
|
||||
InvoiceHeader.tenant_id == self.tenant_id,
|
||||
InvoiceHeader.company_id == self.company_id,
|
||||
InvoiceHeader.created_at < last_month,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
percentage, trend = self._calculate_trend(current_total, previous_total)
|
||||
|
||||
kpi = KPIMetric(
|
||||
label="Total Facturas",
|
||||
value=current_total,
|
||||
previous_value=previous_total,
|
||||
percentage_change=percentage,
|
||||
trend=trend,
|
||||
)
|
||||
|
||||
# Facturas por mes (últimos 6 meses)
|
||||
six_months_ago = datetime.utcnow() - timedelta(days=180)
|
||||
|
||||
monthly_data = (
|
||||
self.db.query(
|
||||
func.date_trunc("month", InvoiceHeader.created_at).label("month"),
|
||||
func.count(InvoiceHeader.id).label("count"),
|
||||
)
|
||||
.filter(
|
||||
InvoiceHeader.tenant_id == self.tenant_id,
|
||||
InvoiceHeader.company_id == self.company_id,
|
||||
InvoiceHeader.created_at >= six_months_ago,
|
||||
)
|
||||
.group_by("month")
|
||||
.order_by("month")
|
||||
.all()
|
||||
)
|
||||
|
||||
chart_data = [
|
||||
ChartDataPoint(
|
||||
label=row.month.strftime("%b %Y") if row.month else "Unknown",
|
||||
value=float(row.count),
|
||||
)
|
||||
for row in monthly_data
|
||||
]
|
||||
|
||||
return kpi, chart_data
|
||||
|
||||
def get_client_provider_metrics(
|
||||
self,
|
||||
) -> tuple[KPIMetric, KPIMetric, List[ChartDataPoint], List[ChartDataPoint]]:
|
||||
"""Obtiene métricas de clientes y proveedores"""
|
||||
# Total clientes
|
||||
total_clients = (
|
||||
self.db.query(func.count(ClientProvider.id))
|
||||
.filter(
|
||||
ClientProvider.tenant_id == self.tenant_id,
|
||||
ClientProvider.company_id == self.company_id,
|
||||
ClientProvider.client_or_provider.in_(
|
||||
[ClientOrProviderEnum.CLIENT, ClientOrProviderEnum.BOTH]
|
||||
),
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# Total proveedores
|
||||
total_providers = (
|
||||
self.db.query(func.count(ClientProvider.id))
|
||||
.filter(
|
||||
ClientProvider.tenant_id == self.tenant_id,
|
||||
ClientProvider.company_id == self.company_id,
|
||||
ClientProvider.client_or_provider.in_(
|
||||
[ClientOrProviderEnum.PROVIDER, ClientOrProviderEnum.BOTH]
|
||||
),
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
client_kpi = KPIMetric(
|
||||
label="Total Clientes", value=total_clients, trend="stable"
|
||||
)
|
||||
|
||||
provider_kpi = KPIMetric(
|
||||
label="Total Proveedores", value=total_providers, trend="stable"
|
||||
)
|
||||
|
||||
# Top 5 clientes por número de facturas
|
||||
# Los clientes están en InvoiceComplianceMx.sold_to_id
|
||||
top_clients = (
|
||||
self.db.query(
|
||||
ClientProvider.name, func.count(InvoiceHeader.id).label("invoice_count")
|
||||
)
|
||||
.join(
|
||||
InvoiceComplianceMx,
|
||||
InvoiceComplianceMx.sold_to_id == ClientProvider.id,
|
||||
isouter=True,
|
||||
)
|
||||
.join(
|
||||
InvoiceHeader,
|
||||
and_(
|
||||
InvoiceHeader.id == InvoiceComplianceMx.invoice_id,
|
||||
InvoiceHeader.tenant_id == self.tenant_id,
|
||||
InvoiceHeader.company_id == self.company_id,
|
||||
),
|
||||
isouter=True,
|
||||
)
|
||||
.filter(
|
||||
ClientProvider.tenant_id == self.tenant_id,
|
||||
ClientProvider.company_id == self.company_id,
|
||||
ClientProvider.client_or_provider.in_(
|
||||
[ClientOrProviderEnum.CLIENT, ClientOrProviderEnum.BOTH]
|
||||
),
|
||||
)
|
||||
.group_by(ClientProvider.id, ClientProvider.name)
|
||||
.order_by(desc("invoice_count"))
|
||||
.limit(5)
|
||||
.all()
|
||||
)
|
||||
|
||||
top_clients_data = [
|
||||
ChartDataPoint(
|
||||
label=row.name or f"Cliente {i+1}", value=float(row.invoice_count or 0)
|
||||
)
|
||||
for i, row in enumerate(top_clients)
|
||||
]
|
||||
|
||||
# Top 5 proveedores
|
||||
# Los proveedores están en InvoiceComplianceMx.provider_id
|
||||
top_providers = (
|
||||
self.db.query(
|
||||
ClientProvider.name, func.count(InvoiceHeader.id).label("invoice_count")
|
||||
)
|
||||
.join(
|
||||
InvoiceComplianceMx,
|
||||
InvoiceComplianceMx.provider_id == ClientProvider.id,
|
||||
isouter=True,
|
||||
)
|
||||
.join(
|
||||
InvoiceHeader,
|
||||
and_(
|
||||
InvoiceHeader.id == InvoiceComplianceMx.invoice_id,
|
||||
InvoiceHeader.tenant_id == self.tenant_id,
|
||||
InvoiceHeader.company_id == self.company_id,
|
||||
),
|
||||
isouter=True,
|
||||
)
|
||||
.filter(
|
||||
ClientProvider.tenant_id == self.tenant_id,
|
||||
ClientProvider.company_id == self.company_id,
|
||||
ClientProvider.client_or_provider.in_(
|
||||
[ClientOrProviderEnum.PROVIDER, ClientOrProviderEnum.BOTH]
|
||||
),
|
||||
)
|
||||
.group_by(ClientProvider.id, ClientProvider.name)
|
||||
.order_by(desc("invoice_count"))
|
||||
.limit(5)
|
||||
.all()
|
||||
)
|
||||
|
||||
top_providers_data = [
|
||||
ChartDataPoint(
|
||||
label=row.name or f"Proveedor {i+1}",
|
||||
value=float(row.invoice_count or 0),
|
||||
)
|
||||
for i, row in enumerate(top_providers)
|
||||
]
|
||||
|
||||
return client_kpi, provider_kpi, top_clients_data, top_providers_data
|
||||
|
||||
def get_items_metrics(self) -> KPIMetric:
|
||||
"""Obtiene métricas de items/productos"""
|
||||
total_items = (
|
||||
self.db.query(func.count(Item.id))
|
||||
.filter(
|
||||
Item.tenant_id == self.tenant_id, Item.company_id == self.company_id
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
return KPIMetric(label="Total Items", value=total_items, trend="stable")
|
||||
|
||||
def get_operations_by_type(self) -> List[ChartDataPoint]:
|
||||
"""Obtiene distribución de operaciones por tipo"""
|
||||
ops_data = (
|
||||
self.db.query(
|
||||
InvoiceHeader.operation_type,
|
||||
func.count(InvoiceHeader.id).label("count"),
|
||||
)
|
||||
.filter(
|
||||
InvoiceHeader.tenant_id == self.tenant_id,
|
||||
InvoiceHeader.company_id == self.company_id,
|
||||
)
|
||||
.group_by(InvoiceHeader.operation_type)
|
||||
.all()
|
||||
)
|
||||
|
||||
type_labels = {
|
||||
OperationType.IMP: "Importación",
|
||||
OperationType.EXP: "Exportación",
|
||||
OperationType.SM_IN: "Entrada SM",
|
||||
OperationType.SM_OUT: "Salida SM",
|
||||
OperationType.CTM_SEND: "Envío CTM",
|
||||
OperationType.CTM_RECEIVE: "Recibo CTM",
|
||||
}
|
||||
|
||||
return [
|
||||
ChartDataPoint(
|
||||
label=type_labels.get(row.operation_type, str(row.operation_type)),
|
||||
value=float(row.count),
|
||||
)
|
||||
for row in ops_data
|
||||
if row.operation_type
|
||||
]
|
||||
|
||||
def get_recent_activity(self, limit: int = 10) -> List[ActivityItem]:
|
||||
"""Obtiene actividad reciente del sistema"""
|
||||
activities = []
|
||||
|
||||
# Facturas recientes
|
||||
recent_invoices = (
|
||||
self.db.query(InvoiceHeader)
|
||||
.filter(
|
||||
InvoiceHeader.tenant_id == self.tenant_id,
|
||||
InvoiceHeader.company_id == self.company_id,
|
||||
)
|
||||
.order_by(desc(InvoiceHeader.created_at))
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
for invoice in recent_invoices:
|
||||
activities.append(
|
||||
ActivityItem(
|
||||
id=invoice.id,
|
||||
type="invoice",
|
||||
title=f"Factura {invoice.invoice_number or invoice.id}",
|
||||
description=f"Operación: {invoice.operation_type if invoice.operation_type else 'N/A'}",
|
||||
timestamp=invoice.created_at,
|
||||
status="active",
|
||||
icon="FileText",
|
||||
)
|
||||
)
|
||||
|
||||
# Ordenar por timestamp
|
||||
activities.sort(key=lambda x: x.timestamp, reverse=True)
|
||||
|
||||
return activities[:limit]
|
||||
|
||||
def get_complete_dashboard_stats(self) -> DashboardStats:
|
||||
"""Genera estadísticas completas para el dashboard"""
|
||||
|
||||
# Obtener nombre de la compañía
|
||||
company = (
|
||||
self.db.query(Company)
|
||||
.filter(Company.id == self.company_id, Company.tenant_id == self.tenant_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
# Obtener todas las métricas
|
||||
invoice_kpi, invoices_chart = self.get_invoice_metrics()
|
||||
client_kpi, provider_kpi, top_clients, top_providers = (
|
||||
self.get_client_provider_metrics()
|
||||
)
|
||||
items_kpi = self.get_items_metrics()
|
||||
operations_chart = self.get_operations_by_type()
|
||||
recent_activity = self.get_recent_activity()
|
||||
|
||||
# KPI de pedimentos (placeholder - implementar cuando exista el modelo)
|
||||
pedimento_kpi = KPIMetric(label="Total Pedimentos", value=0, trend="stable")
|
||||
|
||||
# KPI de aprobaciones pendientes
|
||||
pending_kpi = KPIMetric(label="Pendientes", value=0, trend="stable")
|
||||
|
||||
return DashboardStats(
|
||||
total_invoices=invoice_kpi,
|
||||
total_pedimentos=pedimento_kpi,
|
||||
total_clients=client_kpi,
|
||||
total_providers=provider_kpi,
|
||||
active_items=items_kpi,
|
||||
pending_approvals=pending_kpi,
|
||||
invoices_by_month=invoices_chart,
|
||||
operations_by_type=operations_chart,
|
||||
top_clients=top_clients,
|
||||
top_providers=top_providers,
|
||||
recent_activity=recent_activity,
|
||||
company_id=self.company_id,
|
||||
company_name=company.name if company else None,
|
||||
)
|
||||
@@ -2,6 +2,7 @@ from .auth.routes import router as auth_router
|
||||
from .licenses.routes import router as licenses_router
|
||||
from .tenants.routes import router as tenants_router
|
||||
from .user_tenant.routes import router as user_tenant_router
|
||||
from .dashboard.routes import router as dashboard_router
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
@@ -9,4 +10,5 @@ router = APIRouter()
|
||||
router.include_router(auth_router)
|
||||
router.include_router(tenants_router, prefix="/core", tags=["core / tenants"])
|
||||
router.include_router(user_tenant_router, prefix="/core", tags=["core / user-tenants"])
|
||||
router.include_router(licenses_router, prefix="/core", tags=["core / licenses"])
|
||||
router.include_router(licenses_router, prefix="/core", tags=["core / licenses"])
|
||||
router.include_router(dashboard_router, prefix="/core", tags=["core / dashboard"])
|
||||
|
||||
Reference in New Issue
Block a user