Files
CRM_AGENTES_CARGA/backend/api/v1/modules/crm/leads/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

156 lines
4.9 KiB
Python

from datetime import datetime, timezone
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from ..accounts.models import Account
from ..contacts.models import Contact
from ..opportunities.models import Opportunity
from .dto import LeadConvert, LeadCreate, LeadUpdate
from .models import Lead
def get_leads(
db: Session,
tenant_id: int,
company_id: int,
search: str | None = None,
lead_status: str | None = None,
) -> list[Lead]:
query = db.query(Lead).filter(
Lead.tenant_id == tenant_id,
Lead.company_id == company_id,
Lead.deleted_at.is_(None),
)
if lead_status:
query = query.filter(Lead.status == lead_status)
if search:
pattern = f"%{search}%"
query = query.filter(
Lead.name.ilike(pattern)
| Lead.company_name.ilike(pattern)
| Lead.email.ilike(pattern)
)
return query.order_by(Lead.created_at.desc()).all()
def get_lead(db: Session, lead_id: int, tenant_id: int, company_id: int) -> Lead:
lead = (
db.query(Lead)
.filter(
Lead.id == lead_id,
Lead.tenant_id == tenant_id,
Lead.company_id == company_id,
Lead.deleted_at.is_(None),
)
.first()
)
if not lead:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Prospecto no encontrado")
return lead
def create_lead(db: Session, payload: LeadCreate, tenant_id: int, company_id: int) -> Lead:
lead = Lead(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id)
db.add(lead)
db.commit()
db.refresh(lead)
return lead
def update_lead(db: Session, lead_id: int, payload: LeadUpdate, tenant_id: int, company_id: int) -> Lead:
lead = get_lead(db, lead_id, tenant_id, company_id)
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(lead, field, value)
db.commit()
db.refresh(lead)
return lead
def delete_lead(db: Session, lead_id: int, tenant_id: int, company_id: int) -> None:
lead = get_lead(db, lead_id, tenant_id, company_id)
lead.deleted_at = datetime.now(timezone.utc)
db.commit()
def convert_lead(
db: Session, lead_id: int, payload: LeadConvert, tenant_id: int, company_id: int
) -> dict:
"""Convierte un prospecto en cuenta (+ contacto y oportunidad opcionales).
Es idempotente: si el prospecto ya fue convertido, retorna las referencias existentes.
"""
lead = get_lead(db, lead_id, tenant_id, company_id)
if lead.status == "converted" and lead.converted_account_id:
return {
"lead": lead,
"account_id": lead.converted_account_id,
"contact_id": lead.converted_contact_id,
"opportunity_id": lead.converted_opportunity_id,
}
# 1. Cuenta a partir de la empresa (o nombre) del prospecto
account = Account(
name=lead.company_name or lead.name,
email=lead.email,
phone=lead.phone,
status="active",
owner_user_id=lead.owner_user_id,
tenant_id=tenant_id,
company_id=company_id,
)
db.add(account)
db.flush() # necesitamos el id de la cuenta para enlazar contacto/oportunidad
# 2. Contacto (si el prospecto trae nombre de contacto)
contact = None
if lead.contact_name:
parts = lead.contact_name.split(" ", 1)
contact = Contact(
account_id=account.id,
first_name=parts[0],
last_name=parts[1] if len(parts) > 1 else None,
email=lead.email,
phone=lead.phone,
is_primary=True,
owner_user_id=lead.owner_user_id,
tenant_id=tenant_id,
company_id=company_id,
)
db.add(contact)
db.flush()
# 3. Oportunidad (opcional)
opportunity = None
if payload.create_opportunity:
opportunity = Opportunity(
name=payload.opportunity_name or lead.name,
account_id=account.id,
contact_id=contact.id if contact else None,
pipeline_id=payload.pipeline_id,
stage_id=payload.stage_id,
amount=payload.amount if payload.amount is not None else lead.estimated_value,
source=lead.source,
status="open",
owner_user_id=lead.owner_user_id,
tenant_id=tenant_id,
company_id=company_id,
)
db.add(opportunity)
db.flush()
# 4. Marcar el prospecto como convertido y enlazar
lead.status = "converted"
lead.converted_account_id = account.id
lead.converted_contact_id = contact.id if contact else None
lead.converted_opportunity_id = opportunity.id if opportunity else None
db.commit()
db.refresh(lead)
return {
"lead": lead,
"account_id": account.id,
"contact_id": contact.id if contact else None,
"opportunity_id": opportunity.id if opportunity else None,
}