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:
2026-01-12 18:09:06 -06:00
parent 962e0a057b
commit 3865a53c5c
15 changed files with 1282 additions and 79 deletions

View File

@@ -0,0 +1,7 @@
"""
Módulo de dashboard para estadísticas y métricas empresariales
"""
from .routes import router
__all__ = ["router"]

View 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"
)

View 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={},
)

View 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,
)

View File

@@ -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"])