Files
CRM_AGENTES_CARGA/backend/api/v1/modules/crm/metrics/service.py
Aduanasoft 088a8fc4df feat(crm): dominio backend (cuentas, contactos, prospectos, embudos, oportunidades, actividades)
- Nuevo schema `crm` con 7 tablas multi-tenant (TenantScopedMixin + soft delete)
- Módulos FastAPI por dominio: models/dto/service/routes (patrón example)
- Métricas del dashboard (KPIs + embudo por etapa)
- Conversión de prospecto → cuenta/contacto/oportunidad (idempotente)
- Movimiento de oportunidad entre etapas (Kanban) con estado/probabilidad derivados
- 25 permisos registrados en PermissionRegistry
- Migración Alembic con upgrade/downgrade completos
- 24 tests de servicios (pytest) en verde

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 09:32:05 -06:00

99 lines
3.4 KiB
Python

from decimal import Decimal
from sqlalchemy import and_, func
from sqlalchemy.orm import Session
from ..accounts.models import Account
from ..activities.models import Activity
from ..contacts.models import Contact
from ..leads.models import Lead
from ..opportunities.models import Opportunity
from ..pipelines.models import PipelineStage
def _count(db: Session, model, tenant_id: int, company_id: int, *extra) -> int:
query = db.query(func.count(model.id)).filter(
model.tenant_id == tenant_id,
model.company_id == company_id,
model.deleted_at.is_(None),
*extra,
)
return int(query.scalar() or 0)
def _sum_amount(db: Session, tenant_id: int, company_id: int, *extra) -> Decimal:
total = (
db.query(func.coalesce(func.sum(Opportunity.amount), 0))
.filter(
Opportunity.tenant_id == tenant_id,
Opportunity.company_id == company_id,
Opportunity.deleted_at.is_(None),
*extra,
)
.scalar()
)
return Decimal(total or 0)
def get_metrics(db: Session, tenant_id: int, company_id: int, pipeline_id: int | None = None) -> dict:
"""KPIs y embudo por etapa para el dashboard del CRM."""
open_opps = _count(db, Opportunity, tenant_id, company_id, Opportunity.status == "open")
won_opps = _count(db, Opportunity, tenant_id, company_id, Opportunity.status == "won")
# Embudo: oportunidades abiertas agrupadas por etapa
stage_filters = [
PipelineStage.tenant_id == tenant_id,
PipelineStage.company_id == company_id,
PipelineStage.deleted_at.is_(None),
]
if pipeline_id is not None:
stage_filters.append(PipelineStage.pipeline_id == pipeline_id)
rows = (
db.query(
PipelineStage.id,
PipelineStage.name,
PipelineStage.position,
func.count(Opportunity.id),
func.coalesce(func.sum(Opportunity.amount), 0),
)
.outerjoin(
Opportunity,
and_(
Opportunity.stage_id == PipelineStage.id,
Opportunity.status == "open",
Opportunity.deleted_at.is_(None),
Opportunity.tenant_id == tenant_id,
Opportunity.company_id == company_id,
),
)
.filter(*stage_filters)
.group_by(PipelineStage.id, PipelineStage.name, PipelineStage.position)
.order_by(PipelineStage.position.asc())
.all()
)
by_stage = [
{
"stage_id": row[0],
"stage_name": row[1],
"position": row[2],
"count": int(row[3] or 0),
"value": Decimal(row[4] or 0),
}
for row in rows
]
return {
"total_accounts": _count(db, Account, tenant_id, company_id),
"total_contacts": _count(db, Contact, tenant_id, company_id),
"total_leads": _count(db, Lead, tenant_id, company_id),
"open_leads": _count(db, Lead, tenant_id, company_id, Lead.status != "converted"),
"open_opportunities": open_opps,
"open_pipeline_value": _sum_amount(db, tenant_id, company_id, Opportunity.status == "open"),
"won_opportunities": won_opps,
"won_value": _sum_amount(db, tenant_id, company_id, Opportunity.status == "won"),
"pending_activities": _count(db, Activity, tenant_id, company_id, Activity.status == "pending"),
"by_stage": by_stage,
}