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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user