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:
0
backend/api/v1/modules/crm/pipelines/__init__.py
Normal file
0
backend/api/v1/modules/crm/pipelines/__init__.py
Normal file
58
backend/api/v1/modules/crm/pipelines/dto.py
Normal file
58
backend/api/v1/modules/crm/pipelines/dto.py
Normal 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
|
||||
36
backend/api/v1/modules/crm/pipelines/models.py
Normal file
36
backend/api/v1/modules/crm/pipelines/models.py
Normal 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"))
|
||||
110
backend/api/v1/modules/crm/pipelines/routes.py
Normal file
110
backend/api/v1/modules/crm/pipelines/routes.py
Normal 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)
|
||||
133
backend/api/v1/modules/crm/pipelines/service.py
Normal file
133
backend/api/v1/modules/crm/pipelines/service.py
Normal 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()
|
||||
Reference in New Issue
Block a user