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,51 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class ActivityCreate(BaseModel):
activity_type: str = Field(..., max_length=20)
subject: str = Field(..., min_length=1, max_length=255)
description: str | None = None
status: str = Field("pending", max_length=20)
due_date: datetime | None = None
account_id: int | None = None
contact_id: int | None = None
lead_id: int | None = None
opportunity_id: int | None = None
owner_user_id: str | None = Field(None, max_length=64)
class ActivityUpdate(BaseModel):
activity_type: str | None = Field(None, max_length=20)
subject: str | None = Field(None, min_length=1, max_length=255)
description: str | None = None
status: str | None = Field(None, max_length=20)
due_date: datetime | None = None
completed_at: datetime | None = None
account_id: int | None = None
contact_id: int | None = None
lead_id: int | None = None
opportunity_id: int | None = None
owner_user_id: str | None = Field(None, max_length=64)
class ActivityResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
activity_type: str
subject: str
description: str | None
status: str
due_date: datetime | None
completed_at: datetime | None
account_id: int | None
contact_id: int | None
lead_id: int | None
opportunity_id: int | None
owner_user_id: str | None
tenant_id: int
company_id: int
created_at: datetime
updated_at: datetime

View File

@@ -0,0 +1,40 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, 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 Activity(Base, TenantScopedMixin, TimestampMixin):
"""Actividad CRM: llamada, reunión, tarea, correo o nota.
Puede enlazarse a cualquier entidad del CRM mediante las FK opcionales.
"""
__tablename__ = "activities"
__table_args__ = {"schema": "crm"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
# Tipo: call | meeting | task | email | note
activity_type: Mapped[str] = mapped_column(String(20), nullable=False)
subject: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
# Estado: pending | completed | canceled
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'pending'"), index=True)
due_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
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
)
lead_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.leads.id"), nullable=True, index=True
)
opportunity_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.opportunities.id"), nullable=True, index=True
)
owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)

View File

@@ -0,0 +1,85 @@
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 ActivityCreate, ActivityResponse, ActivityUpdate
router = APIRouter()
@router.get("/activities", response_model=list[ActivityResponse])
def list_activities(
company_id: int = Query(..., description="Company ID"),
activity_type: str | None = Query(None, description="Filtrar por tipo"),
activity_status: str | None = Query(None, alias="status", description="Filtrar por estado"),
account_id: int | None = Query(None),
contact_id: int | None = Query(None),
lead_id: int | None = Query(None),
opportunity_id: int | None = Query(None),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = current_user["tenant_id"]
return service.get_activities(
db, tenant_id, company_id, activity_type, activity_status,
account_id, contact_id, lead_id, opportunity_id,
)
@router.get("/activities/{activity_id}", response_model=ActivityResponse)
def get_activity(
activity_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_activity(db, activity_id, tenant_id, company_id)
@router.post("/activities", response_model=ActivityResponse, status_code=status.HTTP_201_CREATED)
def create_activity(
payload: ActivityCreate,
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_activity(db, payload, tenant_id, company_id)
@router.patch("/activities/{activity_id}", response_model=ActivityResponse)
def update_activity(
activity_id: int,
payload: ActivityUpdate,
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_activity(db, activity_id, payload, tenant_id, company_id)
@router.patch("/activities/{activity_id}/complete", response_model=ActivityResponse)
def complete_activity(
activity_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.complete_activity(db, activity_id, tenant_id, company_id)
@router.delete("/activities/{activity_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_activity(
activity_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_activity(db, activity_id, tenant_id, company_id)

View File

@@ -0,0 +1,134 @@
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 ..leads.models import Lead
from ..opportunities.models import Opportunity
from .dto import ActivityCreate, ActivityUpdate
from .models import Activity
_ALLOWED_TYPES = {"call", "meeting", "task", "email", "note"}
def _validate_refs(db: Session, data: dict, tenant_id: int, company_id: int) -> None:
"""Valida las entidades relacionadas opcionales dentro del tenant/company."""
def scope(model, _id):
return (
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"),
("lead_id", Lead, "El prospecto asociado no existe"),
("opportunity_id", Opportunity, "La oportunidad 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_activities(
db: Session,
tenant_id: int,
company_id: int,
activity_type: str | None = None,
activity_status: str | None = None,
account_id: int | None = None,
contact_id: int | None = None,
lead_id: int | None = None,
opportunity_id: int | None = None,
) -> list[Activity]:
query = db.query(Activity).filter(
Activity.tenant_id == tenant_id,
Activity.company_id == company_id,
Activity.deleted_at.is_(None),
)
if activity_type:
query = query.filter(Activity.activity_type == activity_type)
if activity_status:
query = query.filter(Activity.status == activity_status)
if account_id is not None:
query = query.filter(Activity.account_id == account_id)
if contact_id is not None:
query = query.filter(Activity.contact_id == contact_id)
if lead_id is not None:
query = query.filter(Activity.lead_id == lead_id)
if opportunity_id is not None:
query = query.filter(Activity.opportunity_id == opportunity_id)
return query.order_by(Activity.due_date.asc().nullslast(), Activity.created_at.desc()).all()
def get_activity(db: Session, activity_id: int, tenant_id: int, company_id: int) -> Activity:
activity = (
db.query(Activity)
.filter(
Activity.id == activity_id,
Activity.tenant_id == tenant_id,
Activity.company_id == company_id,
Activity.deleted_at.is_(None),
)
.first()
)
if not activity:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Actividad no encontrada")
return activity
def create_activity(db: Session, payload: ActivityCreate, tenant_id: int, company_id: int) -> Activity:
if payload.activity_type not in _ALLOWED_TYPES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Tipo de actividad inválido",
)
data = payload.model_dump()
_validate_refs(db, data, tenant_id, company_id)
activity = Activity(**data, tenant_id=tenant_id, company_id=company_id)
db.add(activity)
db.commit()
db.refresh(activity)
return activity
def update_activity(
db: Session, activity_id: int, payload: ActivityUpdate, tenant_id: int, company_id: int
) -> Activity:
activity = get_activity(db, activity_id, tenant_id, company_id)
data = payload.model_dump(exclude_unset=True)
if data.get("activity_type") and data["activity_type"] not in _ALLOWED_TYPES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Tipo de actividad inválido",
)
_validate_refs(db, data, tenant_id, company_id)
for field, value in data.items():
setattr(activity, field, value)
db.commit()
db.refresh(activity)
return activity
def complete_activity(db: Session, activity_id: int, tenant_id: int, company_id: int) -> Activity:
activity = get_activity(db, activity_id, tenant_id, company_id)
activity.status = "completed"
activity.completed_at = datetime.now(timezone.utc)
db.commit()
db.refresh(activity)
return activity
def delete_activity(db: Session, activity_id: int, tenant_id: int, company_id: int) -> None:
activity = get_activity(db, activity_id, tenant_id, company_id)
activity.deleted_at = datetime.now(timezone.utc)
db.commit()