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, }