- 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>
99 lines
3.4 KiB
Python
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,
|
|
}
|