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>
This commit is contained in:
98
backend/api/v1/modules/crm/metrics/service.py
Normal file
98
backend/api/v1/modules/crm/metrics/service.py
Normal file
@@ -0,0 +1,98 @@
|
||||
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,
|
||||
}
|
||||
Reference in New Issue
Block a user