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,67 @@
from datetime import date, datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field
class OpportunityCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
account_id: int | None = None
contact_id: int | None = None
pipeline_id: int | None = None
stage_id: int | None = None
amount: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
currency: str = Field("MXN", max_length=3)
probability: int | None = Field(None, ge=0, le=100)
expected_close_date: date | None = None
source: str | None = Field(None, max_length=60)
owner_user_id: str | None = Field(None, max_length=64)
notes: str | None = None
class OpportunityUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=255)
account_id: int | None = None
contact_id: int | None = None
pipeline_id: int | None = None
stage_id: int | None = None
amount: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
currency: str | None = Field(None, max_length=3)
probability: int | None = Field(None, ge=0, le=100)
status: str | None = Field(None, max_length=20)
expected_close_date: date | None = None
lost_reason: str | None = Field(None, max_length=255)
source: str | None = Field(None, max_length=60)
owner_user_id: str | None = Field(None, max_length=64)
notes: str | None = None
class OpportunityMove(BaseModel):
"""Mueve la oportunidad a otra etapa (drag & drop del Kanban)."""
stage_id: int
class OpportunityResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
account_id: int | None
contact_id: int | None
pipeline_id: int | None
stage_id: int | None
amount: Decimal | None
currency: str
probability: int | None
status: str
expected_close_date: date | None
closed_at: datetime | None
lost_reason: str | None
source: str | None
owner_user_id: str | None
notes: str | None
tenant_id: int
company_id: int
created_at: datetime
updated_at: datetime

View File

@@ -0,0 +1,40 @@
from datetime import date, datetime
from sqlalchemy import Date, DateTime, 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 Opportunity(Base, TenantScopedMixin, TimestampMixin):
"""Oportunidad (negocio) que avanza por las etapas de un embudo."""
__tablename__ = "opportunities"
__table_args__ = {"schema": "crm"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
account_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.accounts.id"), nullable=True, index=True
)
contact_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.contacts.id"), nullable=True, index=True
)
pipeline_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.pipelines.id"), nullable=True, index=True
)
stage_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.pipeline_stages.id"), nullable=True, index=True
)
amount: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)
currency: Mapped[str] = mapped_column(String(3), nullable=False, server_default=text("'MXN'"))
probability: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Estado del negocio: open | won | lost
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'open'"), index=True)
expected_close_date: Mapped[date | None] = mapped_column(Date, nullable=True)
closed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
lost_reason: Mapped[str | None] = mapped_column(String(255), nullable=True)
source: Mapped[str | None] = mapped_column(String(60), nullable=True)
owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)

View File

@@ -0,0 +1,88 @@
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 (
OpportunityCreate,
OpportunityMove,
OpportunityResponse,
OpportunityUpdate,
)
router = APIRouter()
@router.get("/opportunities", response_model=list[OpportunityResponse])
def list_opportunities(
company_id: int = Query(..., description="Company ID"),
pipeline_id: int | None = Query(None, description="Filtrar por embudo"),
stage_id: int | None = Query(None, description="Filtrar por etapa"),
opp_status: str | None = Query(None, alias="status", description="Filtrar por estado"),
search: str | None = Query(None, description="Búsqueda por nombre"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = current_user["tenant_id"]
return service.get_opportunities(
db, tenant_id, company_id, pipeline_id, stage_id, opp_status, search
)
@router.get("/opportunities/{opportunity_id}", response_model=OpportunityResponse)
def get_opportunity(
opportunity_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_opportunity(db, opportunity_id, tenant_id, company_id)
@router.post("/opportunities", response_model=OpportunityResponse, status_code=status.HTTP_201_CREATED)
def create_opportunity(
payload: OpportunityCreate,
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_opportunity(db, payload, tenant_id, company_id)
@router.patch("/opportunities/{opportunity_id}", response_model=OpportunityResponse)
def update_opportunity(
opportunity_id: int,
payload: OpportunityUpdate,
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_opportunity(db, opportunity_id, payload, tenant_id, company_id)
@router.patch("/opportunities/{opportunity_id}/move", response_model=OpportunityResponse)
def move_opportunity(
opportunity_id: int,
payload: OpportunityMove,
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.move_opportunity(db, opportunity_id, payload.stage_id, tenant_id, company_id)
@router.delete("/opportunities/{opportunity_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_opportunity(
opportunity_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_opportunity(db, opportunity_id, tenant_id, company_id)

View File

@@ -0,0 +1,143 @@
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 ..pipelines.models import Pipeline, PipelineStage
from .dto import OpportunityCreate, OpportunityUpdate
from .models import Opportunity
def _validate_refs(db: Session, data: dict, tenant_id: int, company_id: int) -> None:
"""Valida que las referencias (cuenta, contacto, embudo, etapa) existan en el tenant/company."""
scope = lambda model, _id: ( # noqa: E731
db.query(model.id)
.filter(
model.id == _id,
model.tenant_id == tenant_id,
model.company_id == company_id,
model.deleted_at.is_(None),
)
.first()
)
checks = [
("account_id", Account, "La cuenta asociada no existe"),
("contact_id", Contact, "El contacto asociado no existe"),
("pipeline_id", Pipeline, "El embudo asociado no existe"),
("stage_id", PipelineStage, "La etapa asociada no existe"),
]
for field, model, message in checks:
value = data.get(field)
if value is not None and not scope(model, value):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=message)
def get_opportunities(
db: Session,
tenant_id: int,
company_id: int,
pipeline_id: int | None = None,
stage_id: int | None = None,
opp_status: str | None = None,
search: str | None = None,
) -> list[Opportunity]:
query = db.query(Opportunity).filter(
Opportunity.tenant_id == tenant_id,
Opportunity.company_id == company_id,
Opportunity.deleted_at.is_(None),
)
if pipeline_id is not None:
query = query.filter(Opportunity.pipeline_id == pipeline_id)
if stage_id is not None:
query = query.filter(Opportunity.stage_id == stage_id)
if opp_status:
query = query.filter(Opportunity.status == opp_status)
if search:
query = query.filter(Opportunity.name.ilike(f"%{search}%"))
return query.order_by(Opportunity.created_at.desc()).all()
def get_opportunity(db: Session, opportunity_id: int, tenant_id: int, company_id: int) -> Opportunity:
opportunity = (
db.query(Opportunity)
.filter(
Opportunity.id == opportunity_id,
Opportunity.tenant_id == tenant_id,
Opportunity.company_id == company_id,
Opportunity.deleted_at.is_(None),
)
.first()
)
if not opportunity:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Oportunidad no encontrada")
return opportunity
def create_opportunity(
db: Session, payload: OpportunityCreate, tenant_id: int, company_id: int
) -> Opportunity:
data = payload.model_dump()
_validate_refs(db, data, tenant_id, company_id)
opportunity = Opportunity(**data, tenant_id=tenant_id, company_id=company_id)
db.add(opportunity)
db.commit()
db.refresh(opportunity)
return opportunity
def update_opportunity(
db: Session, opportunity_id: int, payload: OpportunityUpdate, tenant_id: int, company_id: int
) -> Opportunity:
opportunity = get_opportunity(db, opportunity_id, tenant_id, company_id)
data = payload.model_dump(exclude_unset=True)
_validate_refs(db, data, tenant_id, company_id)
for field, value in data.items():
setattr(opportunity, field, value)
db.commit()
db.refresh(opportunity)
return opportunity
def move_opportunity(
db: Session, opportunity_id: int, stage_id: int, tenant_id: int, company_id: int
) -> Opportunity:
"""Mueve la oportunidad a una etapa y deriva estado/probabilidad de la etapa destino."""
opportunity = get_opportunity(db, opportunity_id, tenant_id, company_id)
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_404_NOT_FOUND, detail="Etapa no encontrada")
opportunity.stage_id = stage.id
opportunity.pipeline_id = stage.pipeline_id
if stage.is_won:
opportunity.status = "won"
opportunity.probability = 100
opportunity.closed_at = datetime.now(timezone.utc)
elif stage.is_lost:
opportunity.status = "lost"
opportunity.probability = 0
opportunity.closed_at = datetime.now(timezone.utc)
else:
opportunity.status = "open"
opportunity.probability = stage.probability
opportunity.closed_at = None
db.commit()
db.refresh(opportunity)
return opportunity
def delete_opportunity(db: Session, opportunity_id: int, tenant_id: int, company_id: int) -> None:
opportunity = get_opportunity(db, opportunity_id, tenant_id, company_id)
opportunity.deleted_at = datetime.now(timezone.utc)
db.commit()