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>
This commit is contained in:
Aduanasoft
2026-07-14 09:53:56 -06:00
parent af02145332
commit 3b8da8b4cc
5 changed files with 187 additions and 41 deletions

View File

@@ -6,10 +6,62 @@ 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,
@@ -102,10 +154,11 @@ def convert_lead(
db.add(account)
db.flush() # necesitamos el id de la cuenta para enlazar contacto/oportunidad
# 2. Contacto (si el prospecto trae nombre de contacto)
# 2. Contacto (si el prospecto trae nombre de contacto no vacío)
contact = None
if lead.contact_name:
parts = lead.contact_name.split(" ", 1)
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],
@@ -123,6 +176,8 @@ def convert_lead(
# 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,