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>
This commit is contained in:
Aduanasoft
2026-07-14 09:32:05 -06:00
parent c3d0eedc8d
commit 088a8fc4df
47 changed files with 2647 additions and 0 deletions

View File

@@ -0,0 +1,70 @@
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, EmailStr, Field
class LeadCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
contact_name: str | None = Field(None, max_length=160)
email: EmailStr | None = None
phone: str | None = Field(None, max_length=40)
company_name: str | None = Field(None, max_length=255)
source: str | None = Field(None, max_length=60)
status: str = Field("new", max_length=20)
estimated_value: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
owner_user_id: str | None = Field(None, max_length=64)
notes: str | None = None
class LeadUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=255)
contact_name: str | None = Field(None, max_length=160)
email: EmailStr | None = None
phone: str | None = Field(None, max_length=40)
company_name: str | None = Field(None, max_length=255)
source: str | None = Field(None, max_length=60)
status: str | None = Field(None, max_length=20)
estimated_value: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
owner_user_id: str | None = Field(None, max_length=64)
notes: str | None = None
class LeadConvert(BaseModel):
"""Parámetros de conversión de un prospecto."""
create_opportunity: bool = True
opportunity_name: str | None = Field(None, max_length=255)
pipeline_id: int | None = None
stage_id: int | None = None
amount: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
class LeadResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
contact_name: str | None
email: str | None
phone: str | None
company_name: str | None
source: str | None
status: str
estimated_value: Decimal | None
owner_user_id: str | None
converted_account_id: int | None
converted_contact_id: int | None
converted_opportunity_id: int | None
notes: str | None
tenant_id: int
company_id: int
created_at: datetime
updated_at: datetime
class LeadConvertResult(BaseModel):
lead: LeadResponse
account_id: int
contact_id: int | None
opportunity_id: int | None

View File

@@ -0,0 +1,35 @@
from sqlalchemy import ForeignKey, Integer, Numeric, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
class Lead(Base, TenantScopedMixin, TimestampMixin):
"""Prospecto sin calificar. Al calificarse se convierte en cuenta/contacto/oportunidad."""
__tablename__ = "leads"
__table_args__ = {"schema": "crm"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
contact_name: Mapped[str | None] = mapped_column(String(160), nullable=True)
email: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
phone: Mapped[str | None] = mapped_column(String(40), nullable=True)
company_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Origen: web | referido | evento | llamada | email | otro
source: Mapped[str | None] = mapped_column(String(60), nullable=True)
# Estado: new | contacted | qualified | unqualified | converted
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'new'"), index=True)
estimated_value: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)
owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
converted_account_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.accounts.id"), nullable=True
)
converted_contact_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.contacts.id"), nullable=True
)
converted_opportunity_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.opportunities.id"), nullable=True
)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)

View File

@@ -0,0 +1,79 @@
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.orm import Session
from core.database import get_core_db
from core.security import get_current_user
from . import service
from .dto import LeadConvert, LeadConvertResult, LeadCreate, LeadResponse, LeadUpdate
router = APIRouter()
@router.get("/leads", response_model=list[LeadResponse])
def list_leads(
company_id: int = Query(..., description="Company ID"),
search: str | None = Query(None, description="Búsqueda por nombre, empresa o email"),
lead_status: str | None = Query(None, alias="status", description="Filtrar por estado"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = current_user["tenant_id"]
return service.get_leads(db, tenant_id, company_id, search, lead_status)
@router.get("/leads/{lead_id}", response_model=LeadResponse)
def get_lead(
lead_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = current_user["tenant_id"]
return service.get_lead(db, lead_id, tenant_id, company_id)
@router.post("/leads", response_model=LeadResponse, status_code=status.HTTP_201_CREATED)
def create_lead(
payload: LeadCreate,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = current_user["tenant_id"]
return service.create_lead(db, payload, tenant_id, company_id)
@router.patch("/leads/{lead_id}", response_model=LeadResponse)
def update_lead(
lead_id: int,
payload: LeadUpdate,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = current_user["tenant_id"]
return service.update_lead(db, lead_id, payload, tenant_id, company_id)
@router.post("/leads/{lead_id}/convert", response_model=LeadConvertResult)
def convert_lead(
lead_id: int,
payload: LeadConvert,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = current_user["tenant_id"]
return service.convert_lead(db, lead_id, payload, tenant_id, company_id)
@router.delete("/leads/{lead_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_lead(
lead_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = current_user["tenant_id"]
service.delete_lead(db, lead_id, tenant_id, company_id)

View File

@@ -0,0 +1,155 @@
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,
}