Files
CRM_AGENTES_CARGA/backend/api/v1/modules/crm/leads/service.py
Aduanasoft 3b8da8b4cc fix(crm): correcciones de revisión adversarial en servicios de dominio
- convert_lead valida embudo/etapa en el scope tenant/company (evita fuga multi-tenant y 500)
- oportunidades: estado/probabilidad/cierre se derivan de la etapa también en create/update (no solo move)
- oportunidades: valida que la etapa pertenezca al embudo indicado
- cuentas: country por defecto 'MX' (el server_default no aplicaba con NULL explícito)
- convert_lead: contact_name se normaliza (evita first_name vacío)
- +4 tests que fijan el comportamiento corregido (28 en verde)

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

211 lines
6.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 ..pipelines.models import Pipeline, PipelineStage
from .dto import LeadConvert, LeadCreate, LeadUpdate
from .models import Lead
def _validate_pipeline_stage(
db: Session,
pipeline_id: int | None,
stage_id: int | None,
tenant_id: int,
company_id: int,
) -> None:
"""Valida que embudo/etapa existan en el tenant/company y sean coherentes.
Evita enlazar la oportunidad convertida a un embudo/etapa de otro tenant
(fuga multi-tenant) o a un id inexistente (que produciría un 500).
"""
if pipeline_id is not None:
exists = (
db.query(Pipeline.id)
.filter(
Pipeline.id == pipeline_id,
Pipeline.tenant_id == tenant_id,
Pipeline.company_id == company_id,
Pipeline.deleted_at.is_(None),
)
.first()
)
if not exists:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="El embudo indicado no existe",
)
if stage_id is not None:
stage = (
db.query(PipelineStage)
.filter(
PipelineStage.id == stage_id,
PipelineStage.tenant_id == tenant_id,
PipelineStage.company_id == company_id,
PipelineStage.deleted_at.is_(None),
)
.first()
)
if not stage:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="La etapa indicada no existe",
)
if pipeline_id is not None and stage.pipeline_id != pipeline_id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="La etapa no pertenece al embudo indicado",
)
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 no vacío)
contact = None
contact_name = (lead.contact_name or "").strip()
if contact_name:
parts = 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:
# Valida embudo/etapa en el scope del tenant/company antes de enlazar
_validate_pipeline_stage(db, payload.pipeline_id, payload.stage_id, tenant_id, company_id)
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,
}