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,58 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class PipelineCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=120)
is_default: bool = False
class PipelineUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=120)
is_default: bool | None = None
class PipelineResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
is_default: bool
tenant_id: int
company_id: int
created_at: datetime
updated_at: datetime
class StageCreate(BaseModel):
pipeline_id: int
name: str = Field(..., min_length=1, max_length=120)
position: int = Field(0, ge=0)
probability: int = Field(0, ge=0, le=100)
is_won: bool = False
is_lost: bool = False
class StageUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=120)
position: int | None = Field(None, ge=0)
probability: int | None = Field(None, ge=0, le=100)
is_won: bool | None = None
is_lost: bool | None = None
class StageResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
pipeline_id: int
name: str
position: int
probability: int
is_won: bool
is_lost: bool
tenant_id: int
company_id: int
created_at: datetime
updated_at: datetime

View File

@@ -0,0 +1,36 @@
from sqlalchemy import Boolean, ForeignKey, Integer, String, text
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
class Pipeline(Base, TenantScopedMixin, TimestampMixin):
"""Embudo de ventas. Cada company puede tener varios embudos."""
__tablename__ = "pipelines"
__table_args__ = {"schema": "crm"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
name: Mapped[str] = mapped_column(String(120), nullable=False)
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
class PipelineStage(Base, TenantScopedMixin, TimestampMixin):
"""Etapa de un embudo (columna del Kanban)."""
__tablename__ = "pipeline_stages"
__table_args__ = {"schema": "crm"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
pipeline_id: Mapped[int] = mapped_column(
Integer, ForeignKey("crm.pipelines.id"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(120), nullable=False)
# Posición de la columna en el Kanban (0..n)
position: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
# Probabilidad de cierre asociada a la etapa (0-100)
probability: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
# Etapas terminales: ganada / perdida
is_won: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
is_lost: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))

View File

@@ -0,0 +1,110 @@
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 (
PipelineCreate,
PipelineResponse,
PipelineUpdate,
StageCreate,
StageResponse,
StageUpdate,
)
router = APIRouter()
# ----- Pipelines -----
@router.get("/pipelines", response_model=list[PipelineResponse])
def list_pipelines(
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_pipelines(db, tenant_id, company_id)
@router.post("/pipelines", response_model=PipelineResponse, status_code=status.HTTP_201_CREATED)
def create_pipeline(
payload: PipelineCreate,
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_pipeline(db, payload, tenant_id, company_id)
@router.patch("/pipelines/{pipeline_id}", response_model=PipelineResponse)
def update_pipeline(
pipeline_id: int,
payload: PipelineUpdate,
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_pipeline(db, pipeline_id, payload, tenant_id, company_id)
@router.delete("/pipelines/{pipeline_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_pipeline(
pipeline_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_pipeline(db, pipeline_id, tenant_id, company_id)
# ----- Stages -----
@router.get("/stages", response_model=list[StageResponse])
def list_stages(
company_id: int = Query(..., description="Company ID"),
pipeline_id: int | None = Query(None, description="Filtrar por embudo"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = current_user["tenant_id"]
return service.get_stages(db, tenant_id, company_id, pipeline_id)
@router.post("/stages", response_model=StageResponse, status_code=status.HTTP_201_CREATED)
def create_stage(
payload: StageCreate,
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_stage(db, payload, tenant_id, company_id)
@router.patch("/stages/{stage_id}", response_model=StageResponse)
def update_stage(
stage_id: int,
payload: StageUpdate,
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_stage(db, stage_id, payload, tenant_id, company_id)
@router.delete("/stages/{stage_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_stage(
stage_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_stage(db, stage_id, tenant_id, company_id)

View File

@@ -0,0 +1,133 @@
from datetime import datetime, timezone
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from .dto import PipelineCreate, PipelineUpdate, StageCreate, StageUpdate
from .models import Pipeline, PipelineStage
# ----- Pipelines -----
def get_pipelines(db: Session, tenant_id: int, company_id: int) -> list[Pipeline]:
return (
db.query(Pipeline)
.filter(
Pipeline.tenant_id == tenant_id,
Pipeline.company_id == company_id,
Pipeline.deleted_at.is_(None),
)
.order_by(Pipeline.is_default.desc(), Pipeline.name.asc())
.all()
)
def get_pipeline(db: Session, pipeline_id: int, tenant_id: int, company_id: int) -> Pipeline:
pipeline = (
db.query(Pipeline)
.filter(
Pipeline.id == pipeline_id,
Pipeline.tenant_id == tenant_id,
Pipeline.company_id == company_id,
Pipeline.deleted_at.is_(None),
)
.first()
)
if not pipeline:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Embudo no encontrado")
return pipeline
def _clear_default(db: Session, tenant_id: int, company_id: int) -> None:
"""Solo un embudo puede ser el predeterminado por company."""
db.query(Pipeline).filter(
Pipeline.tenant_id == tenant_id,
Pipeline.company_id == company_id,
Pipeline.is_default.is_(True),
).update({Pipeline.is_default: False})
def create_pipeline(db: Session, payload: PipelineCreate, tenant_id: int, company_id: int) -> Pipeline:
if payload.is_default:
_clear_default(db, tenant_id, company_id)
pipeline = Pipeline(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id)
db.add(pipeline)
db.commit()
db.refresh(pipeline)
return pipeline
def update_pipeline(
db: Session, pipeline_id: int, payload: PipelineUpdate, tenant_id: int, company_id: int
) -> Pipeline:
pipeline = get_pipeline(db, pipeline_id, tenant_id, company_id)
data = payload.model_dump(exclude_unset=True)
if data.get("is_default"):
_clear_default(db, tenant_id, company_id)
for field, value in data.items():
setattr(pipeline, field, value)
db.commit()
db.refresh(pipeline)
return pipeline
def delete_pipeline(db: Session, pipeline_id: int, tenant_id: int, company_id: int) -> None:
pipeline = get_pipeline(db, pipeline_id, tenant_id, company_id)
pipeline.deleted_at = datetime.now(timezone.utc)
db.commit()
# ----- Stages -----
def get_stages(db: Session, tenant_id: int, company_id: int, pipeline_id: int | None = None) -> list[PipelineStage]:
query = db.query(PipelineStage).filter(
PipelineStage.tenant_id == tenant_id,
PipelineStage.company_id == company_id,
PipelineStage.deleted_at.is_(None),
)
if pipeline_id is not None:
query = query.filter(PipelineStage.pipeline_id == pipeline_id)
return query.order_by(PipelineStage.position.asc()).all()
def get_stage(db: Session, stage_id: int, tenant_id: int, company_id: int) -> PipelineStage:
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")
return stage
def create_stage(db: Session, payload: StageCreate, tenant_id: int, company_id: int) -> PipelineStage:
# La etapa debe pertenecer a un embudo del mismo tenant/company
get_pipeline(db, payload.pipeline_id, tenant_id, company_id)
stage = PipelineStage(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id)
db.add(stage)
db.commit()
db.refresh(stage)
return stage
def update_stage(
db: Session, stage_id: int, payload: StageUpdate, tenant_id: int, company_id: int
) -> PipelineStage:
stage = get_stage(db, stage_id, tenant_id, company_id)
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(stage, field, value)
db.commit()
db.refresh(stage)
return stage
def delete_stage(db: Session, stage_id: int, tenant_id: int, company_id: int) -> None:
stage = get_stage(db, stage_id, tenant_id, company_id)
stage.deleted_at = datetime.now(timezone.utc)
db.commit()