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:
67
backend/api/v1/modules/crm/opportunities/dto.py
Normal file
67
backend/api/v1/modules/crm/opportunities/dto.py
Normal 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
|
||||
40
backend/api/v1/modules/crm/opportunities/models.py
Normal file
40
backend/api/v1/modules/crm/opportunities/models.py
Normal 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)
|
||||
88
backend/api/v1/modules/crm/opportunities/routes.py
Normal file
88
backend/api/v1/modules/crm/opportunities/routes.py
Normal 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)
|
||||
143
backend/api/v1/modules/crm/opportunities/service.py
Normal file
143
backend/api/v1/modules/crm/opportunities/service.py
Normal 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()
|
||||
Reference in New Issue
Block a user