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

View File

@@ -0,0 +1,67 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, EmailStr, Field
class AccountCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
trade_name: str | None = Field(None, max_length=255)
rfc: str | None = Field(None, max_length=13)
account_type: str | None = Field(None, max_length=40)
industry: str | None = Field(None, max_length=120)
email: EmailStr | None = None
phone: str | None = Field(None, max_length=40)
website: str | None = Field(None, max_length=255)
address: str | None = None
city: str | None = Field(None, max_length=120)
state: str | None = Field(None, max_length=120)
country: str | None = Field(None, max_length=2)
patente_aduanal: str | None = Field(None, max_length=20)
status: str = Field("active", max_length=20)
owner_user_id: str | None = Field(None, max_length=64)
notes: str | None = None
class AccountUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=255)
trade_name: str | None = Field(None, max_length=255)
rfc: str | None = Field(None, max_length=13)
account_type: str | None = Field(None, max_length=40)
industry: str | None = Field(None, max_length=120)
email: EmailStr | None = None
phone: str | None = Field(None, max_length=40)
website: str | None = Field(None, max_length=255)
address: str | None = None
city: str | None = Field(None, max_length=120)
state: str | None = Field(None, max_length=120)
country: str | None = Field(None, max_length=2)
patente_aduanal: str | None = Field(None, max_length=20)
status: str | None = Field(None, max_length=20)
owner_user_id: str | None = Field(None, max_length=64)
notes: str | None = None
class AccountResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
trade_name: str | None
rfc: str | None
account_type: str | None
industry: str | None
email: str | None
phone: str | None
website: str | None
address: str | None
city: str | None
state: str | None
country: str | None
patente_aduanal: str | None
status: str
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 sqlalchemy import 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 Account(Base, TenantScopedMixin, TimestampMixin):
"""Cuenta CRM: empresa cliente o prospecto.
Modela importadores, IMMEX, agencias aduanales, transportistas, etc.
Los campos aduaneros (RFC, patente) son opcionales para no forzar datos
en prospectos que aún no comparten información fiscal.
"""
__tablename__ = "accounts"
__table_args__ = {"schema": "crm"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
# Razón social (nombre legal) y nombre comercial
name: Mapped[str] = mapped_column(String(255), nullable=False)
trade_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
rfc: Mapped[str | None] = mapped_column(String(13), nullable=True, index=True)
# Tipo de cuenta: immex | agencia_aduanal | importador | exportador | transportista | otro
account_type: Mapped[str | None] = mapped_column(String(40), nullable=True)
industry: Mapped[str | None] = mapped_column(String(120), nullable=True)
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
phone: Mapped[str | None] = mapped_column(String(40), nullable=True)
website: Mapped[str | None] = mapped_column(String(255), nullable=True)
address: Mapped[str | None] = mapped_column(Text, nullable=True)
city: Mapped[str | None] = mapped_column(String(120), nullable=True)
state: Mapped[str | None] = mapped_column(String(120), nullable=True)
country: Mapped[str | None] = mapped_column(String(2), nullable=True, server_default=text("'MX'"))
# Patente del agente aduanal (dato aduanero, no se traduce)
patente_aduanal: Mapped[str | None] = mapped_column(String(20), nullable=True)
# Estado comercial: active | inactive | prospect
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'active'"))
# Vendedor responsable (id de usuario Keycloak / sub)
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,67 @@
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 AccountCreate, AccountResponse, AccountUpdate
router = APIRouter()
@router.get("/accounts", response_model=list[AccountResponse])
def list_accounts(
company_id: int = Query(..., description="Company ID"),
search: str | None = Query(None, description="Búsqueda por nombre, nombre comercial o RFC"),
account_status: str | None = Query(None, alias="status", description="Filtrar por estado"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = current_user["tenant_id"]
return service.get_accounts(db, tenant_id, company_id, search, account_status)
@router.get("/accounts/{account_id}", response_model=AccountResponse)
def get_account(
account_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_account(db, account_id, tenant_id, company_id)
@router.post("/accounts", response_model=AccountResponse, status_code=status.HTTP_201_CREATED)
def create_account(
payload: AccountCreate,
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_account(db, payload, tenant_id, company_id)
@router.patch("/accounts/{account_id}", response_model=AccountResponse)
def update_account(
account_id: int,
payload: AccountUpdate,
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_account(db, account_id, payload, tenant_id, company_id)
@router.delete("/accounts/{account_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_account(
account_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_account(db, account_id, tenant_id, company_id)

View File

@@ -0,0 +1,73 @@
from datetime import datetime, timezone
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from .dto import AccountCreate, AccountUpdate
from .models import Account
def get_accounts(
db: Session,
tenant_id: int,
company_id: int,
search: str | None = None,
account_status: str | None = None,
) -> list[Account]:
query = db.query(Account).filter(
Account.tenant_id == tenant_id,
Account.company_id == company_id,
Account.deleted_at.is_(None),
)
if search:
pattern = f"%{search}%"
query = query.filter(
Account.name.ilike(pattern)
| Account.trade_name.ilike(pattern)
| Account.rfc.ilike(pattern)
)
if account_status:
query = query.filter(Account.status == account_status)
return query.order_by(Account.name.asc()).all()
def get_account(db: Session, account_id: int, tenant_id: int, company_id: int) -> Account:
account = (
db.query(Account)
.filter(
Account.id == account_id,
Account.tenant_id == tenant_id,
Account.company_id == company_id,
Account.deleted_at.is_(None),
)
.first()
)
if not account:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cuenta no encontrada")
return account
def create_account(db: Session, payload: AccountCreate, tenant_id: int, company_id: int) -> Account:
account = Account(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id)
db.add(account)
db.commit()
db.refresh(account)
return account
def update_account(
db: Session, account_id: int, payload: AccountUpdate, tenant_id: int, company_id: int
) -> Account:
account = get_account(db, account_id, tenant_id, company_id)
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(account, field, value)
db.commit()
db.refresh(account)
return account
def delete_account(db: Session, account_id: int, tenant_id: int, company_id: int) -> None:
account = get_account(db, account_id, tenant_id, company_id)
# Soft delete: conserva el histórico comercial de la cuenta
account.deleted_at = datetime.now(timezone.utc)
db.commit()

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()

View File

@@ -0,0 +1,52 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, EmailStr, Field
class ContactCreate(BaseModel):
account_id: int | None = None
first_name: str = Field(..., min_length=1, max_length=120)
last_name: str | None = Field(None, max_length=120)
email: EmailStr | None = None
phone: str | None = Field(None, max_length=40)
mobile: str | None = Field(None, max_length=40)
job_title: str | None = Field(None, max_length=120)
department: str | None = Field(None, max_length=120)
is_primary: bool = False
owner_user_id: str | None = Field(None, max_length=64)
notes: str | None = None
class ContactUpdate(BaseModel):
account_id: int | None = None
first_name: str | None = Field(None, min_length=1, max_length=120)
last_name: str | None = Field(None, max_length=120)
email: EmailStr | None = None
phone: str | None = Field(None, max_length=40)
mobile: str | None = Field(None, max_length=40)
job_title: str | None = Field(None, max_length=120)
department: str | None = Field(None, max_length=120)
is_primary: bool | None = None
owner_user_id: str | None = Field(None, max_length=64)
notes: str | None = None
class ContactResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
account_id: int | None
first_name: str
last_name: str | None
email: str | None
phone: str | None
mobile: str | None
job_title: str | None
department: str | None
is_primary: bool
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,27 @@
from sqlalchemy import Boolean, 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 Contact(Base, TenantScopedMixin, TimestampMixin):
"""Contacto CRM: persona asociada (opcionalmente) a una cuenta."""
__tablename__ = "contacts"
__table_args__ = {"schema": "crm"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
account_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.accounts.id"), nullable=True, index=True
)
first_name: Mapped[str] = mapped_column(String(120), nullable=False)
last_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
email: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
phone: Mapped[str | None] = mapped_column(String(40), nullable=True)
mobile: Mapped[str | None] = mapped_column(String(40), nullable=True)
job_title: Mapped[str | None] = mapped_column(String(120), nullable=True)
department: Mapped[str | None] = mapped_column(String(120), nullable=True)
is_primary: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
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,67 @@
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 ContactCreate, ContactResponse, ContactUpdate
router = APIRouter()
@router.get("/contacts", response_model=list[ContactResponse])
def list_contacts(
company_id: int = Query(..., description="Company ID"),
search: str | None = Query(None, description="Búsqueda por nombre o email"),
account_id: int | None = Query(None, description="Filtrar por cuenta"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = current_user["tenant_id"]
return service.get_contacts(db, tenant_id, company_id, search, account_id)
@router.get("/contacts/{contact_id}", response_model=ContactResponse)
def get_contact(
contact_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_contact(db, contact_id, tenant_id, company_id)
@router.post("/contacts", response_model=ContactResponse, status_code=status.HTTP_201_CREATED)
def create_contact(
payload: ContactCreate,
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_contact(db, payload, tenant_id, company_id)
@router.patch("/contacts/{contact_id}", response_model=ContactResponse)
def update_contact(
contact_id: int,
payload: ContactUpdate,
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_contact(db, contact_id, payload, tenant_id, company_id)
@router.delete("/contacts/{contact_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_contact(
contact_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_contact(db, contact_id, tenant_id, company_id)

View File

@@ -0,0 +1,98 @@
from datetime import datetime, timezone
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from ..accounts.models import Account
from .dto import ContactCreate, ContactUpdate
from .models import Contact
def _validate_account(db: Session, account_id: int | None, tenant_id: int, company_id: int) -> None:
"""Verifica que la cuenta referenciada exista dentro del tenant/company."""
if account_id is None:
return
exists = (
db.query(Account.id)
.filter(
Account.id == account_id,
Account.tenant_id == tenant_id,
Account.company_id == company_id,
Account.deleted_at.is_(None),
)
.first()
)
if not exists:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="La cuenta asociada no existe",
)
def get_contacts(
db: Session,
tenant_id: int,
company_id: int,
search: str | None = None,
account_id: int | None = None,
) -> list[Contact]:
query = db.query(Contact).filter(
Contact.tenant_id == tenant_id,
Contact.company_id == company_id,
Contact.deleted_at.is_(None),
)
if account_id is not None:
query = query.filter(Contact.account_id == account_id)
if search:
pattern = f"%{search}%"
query = query.filter(
Contact.first_name.ilike(pattern)
| Contact.last_name.ilike(pattern)
| Contact.email.ilike(pattern)
)
return query.order_by(Contact.first_name.asc()).all()
def get_contact(db: Session, contact_id: int, tenant_id: int, company_id: int) -> Contact:
contact = (
db.query(Contact)
.filter(
Contact.id == contact_id,
Contact.tenant_id == tenant_id,
Contact.company_id == company_id,
Contact.deleted_at.is_(None),
)
.first()
)
if not contact:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Contacto no encontrado")
return contact
def create_contact(db: Session, payload: ContactCreate, tenant_id: int, company_id: int) -> Contact:
_validate_account(db, payload.account_id, tenant_id, company_id)
contact = Contact(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id)
db.add(contact)
db.commit()
db.refresh(contact)
return contact
def update_contact(
db: Session, contact_id: int, payload: ContactUpdate, tenant_id: int, company_id: int
) -> Contact:
contact = get_contact(db, contact_id, tenant_id, company_id)
data = payload.model_dump(exclude_unset=True)
if "account_id" in data:
_validate_account(db, data["account_id"], tenant_id, company_id)
for field, value in data.items():
setattr(contact, field, value)
db.commit()
db.refresh(contact)
return contact
def delete_contact(db: Session, contact_id: int, tenant_id: int, company_id: int) -> None:
contact = get_contact(db, contact_id, tenant_id, company_id)
contact.deleted_at = datetime.now(timezone.utc)
db.commit()

View File

@@ -0,0 +1,70 @@
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, EmailStr, Field
class LeadCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
contact_name: str | None = Field(None, max_length=160)
email: EmailStr | None = None
phone: str | None = Field(None, max_length=40)
company_name: str | None = Field(None, max_length=255)
source: str | None = Field(None, max_length=60)
status: str = Field("new", max_length=20)
estimated_value: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
owner_user_id: str | None = Field(None, max_length=64)
notes: str | None = None
class LeadUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=255)
contact_name: str | None = Field(None, max_length=160)
email: EmailStr | None = None
phone: str | None = Field(None, max_length=40)
company_name: str | None = Field(None, max_length=255)
source: str | None = Field(None, max_length=60)
status: str | None = Field(None, max_length=20)
estimated_value: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
owner_user_id: str | None = Field(None, max_length=64)
notes: str | None = None
class LeadConvert(BaseModel):
"""Parámetros de conversión de un prospecto."""
create_opportunity: bool = True
opportunity_name: str | None = Field(None, max_length=255)
pipeline_id: int | None = None
stage_id: int | None = None
amount: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
class LeadResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
contact_name: str | None
email: str | None
phone: str | None
company_name: str | None
source: str | None
status: str
estimated_value: Decimal | None
owner_user_id: str | None
converted_account_id: int | None
converted_contact_id: int | None
converted_opportunity_id: int | None
notes: str | None
tenant_id: int
company_id: int
created_at: datetime
updated_at: datetime
class LeadConvertResult(BaseModel):
lead: LeadResponse
account_id: int
contact_id: int | None
opportunity_id: int | None

View File

@@ -0,0 +1,35 @@
from sqlalchemy import 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 Lead(Base, TenantScopedMixin, TimestampMixin):
"""Prospecto sin calificar. Al calificarse se convierte en cuenta/contacto/oportunidad."""
__tablename__ = "leads"
__table_args__ = {"schema": "crm"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
contact_name: Mapped[str | None] = mapped_column(String(160), nullable=True)
email: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
phone: Mapped[str | None] = mapped_column(String(40), nullable=True)
company_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Origen: web | referido | evento | llamada | email | otro
source: Mapped[str | None] = mapped_column(String(60), nullable=True)
# Estado: new | contacted | qualified | unqualified | converted
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'new'"), index=True)
estimated_value: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)
owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
converted_account_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.accounts.id"), nullable=True
)
converted_contact_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.contacts.id"), nullable=True
)
converted_opportunity_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.opportunities.id"), nullable=True
)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)

View File

@@ -0,0 +1,79 @@
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 LeadConvert, LeadConvertResult, LeadCreate, LeadResponse, LeadUpdate
router = APIRouter()
@router.get("/leads", response_model=list[LeadResponse])
def list_leads(
company_id: int = Query(..., description="Company ID"),
search: str | None = Query(None, description="Búsqueda por nombre, empresa o email"),
lead_status: str | None = Query(None, alias="status", description="Filtrar por estado"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = current_user["tenant_id"]
return service.get_leads(db, tenant_id, company_id, search, lead_status)
@router.get("/leads/{lead_id}", response_model=LeadResponse)
def get_lead(
lead_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_lead(db, lead_id, tenant_id, company_id)
@router.post("/leads", response_model=LeadResponse, status_code=status.HTTP_201_CREATED)
def create_lead(
payload: LeadCreate,
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_lead(db, payload, tenant_id, company_id)
@router.patch("/leads/{lead_id}", response_model=LeadResponse)
def update_lead(
lead_id: int,
payload: LeadUpdate,
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_lead(db, lead_id, payload, tenant_id, company_id)
@router.post("/leads/{lead_id}/convert", response_model=LeadConvertResult)
def convert_lead(
lead_id: int,
payload: LeadConvert,
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.convert_lead(db, lead_id, payload, tenant_id, company_id)
@router.delete("/leads/{lead_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_lead(
lead_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_lead(db, lead_id, tenant_id, company_id)

View File

@@ -0,0 +1,155 @@
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 ..opportunities.models import Opportunity
from .dto import LeadConvert, LeadCreate, LeadUpdate
from .models import Lead
def get_leads(
db: Session,
tenant_id: int,
company_id: int,
search: str | None = None,
lead_status: str | None = None,
) -> list[Lead]:
query = db.query(Lead).filter(
Lead.tenant_id == tenant_id,
Lead.company_id == company_id,
Lead.deleted_at.is_(None),
)
if lead_status:
query = query.filter(Lead.status == lead_status)
if search:
pattern = f"%{search}%"
query = query.filter(
Lead.name.ilike(pattern)
| Lead.company_name.ilike(pattern)
| Lead.email.ilike(pattern)
)
return query.order_by(Lead.created_at.desc()).all()
def get_lead(db: Session, lead_id: int, tenant_id: int, company_id: int) -> Lead:
lead = (
db.query(Lead)
.filter(
Lead.id == lead_id,
Lead.tenant_id == tenant_id,
Lead.company_id == company_id,
Lead.deleted_at.is_(None),
)
.first()
)
if not lead:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Prospecto no encontrado")
return lead
def create_lead(db: Session, payload: LeadCreate, tenant_id: int, company_id: int) -> Lead:
lead = Lead(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id)
db.add(lead)
db.commit()
db.refresh(lead)
return lead
def update_lead(db: Session, lead_id: int, payload: LeadUpdate, tenant_id: int, company_id: int) -> Lead:
lead = get_lead(db, lead_id, tenant_id, company_id)
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(lead, field, value)
db.commit()
db.refresh(lead)
return lead
def delete_lead(db: Session, lead_id: int, tenant_id: int, company_id: int) -> None:
lead = get_lead(db, lead_id, tenant_id, company_id)
lead.deleted_at = datetime.now(timezone.utc)
db.commit()
def convert_lead(
db: Session, lead_id: int, payload: LeadConvert, tenant_id: int, company_id: int
) -> dict:
"""Convierte un prospecto en cuenta (+ contacto y oportunidad opcionales).
Es idempotente: si el prospecto ya fue convertido, retorna las referencias existentes.
"""
lead = get_lead(db, lead_id, tenant_id, company_id)
if lead.status == "converted" and lead.converted_account_id:
return {
"lead": lead,
"account_id": lead.converted_account_id,
"contact_id": lead.converted_contact_id,
"opportunity_id": lead.converted_opportunity_id,
}
# 1. Cuenta a partir de la empresa (o nombre) del prospecto
account = Account(
name=lead.company_name or lead.name,
email=lead.email,
phone=lead.phone,
status="active",
owner_user_id=lead.owner_user_id,
tenant_id=tenant_id,
company_id=company_id,
)
db.add(account)
db.flush() # necesitamos el id de la cuenta para enlazar contacto/oportunidad
# 2. Contacto (si el prospecto trae nombre de contacto)
contact = None
if lead.contact_name:
parts = lead.contact_name.split(" ", 1)
contact = Contact(
account_id=account.id,
first_name=parts[0],
last_name=parts[1] if len(parts) > 1 else None,
email=lead.email,
phone=lead.phone,
is_primary=True,
owner_user_id=lead.owner_user_id,
tenant_id=tenant_id,
company_id=company_id,
)
db.add(contact)
db.flush()
# 3. Oportunidad (opcional)
opportunity = None
if payload.create_opportunity:
opportunity = Opportunity(
name=payload.opportunity_name or lead.name,
account_id=account.id,
contact_id=contact.id if contact else None,
pipeline_id=payload.pipeline_id,
stage_id=payload.stage_id,
amount=payload.amount if payload.amount is not None else lead.estimated_value,
source=lead.source,
status="open",
owner_user_id=lead.owner_user_id,
tenant_id=tenant_id,
company_id=company_id,
)
db.add(opportunity)
db.flush()
# 4. Marcar el prospecto como convertido y enlazar
lead.status = "converted"
lead.converted_account_id = account.id
lead.converted_contact_id = contact.id if contact else None
lead.converted_opportunity_id = opportunity.id if opportunity else None
db.commit()
db.refresh(lead)
return {
"lead": lead,
"account_id": account.id,
"contact_id": contact.id if contact else None,
"opportunity_id": opportunity.id if opportunity else None,
}

View File

@@ -0,0 +1,24 @@
from decimal import Decimal
from pydantic import BaseModel
class StageMetric(BaseModel):
stage_id: int
stage_name: str
position: int
count: int
value: Decimal
class CrmMetricsResponse(BaseModel):
total_accounts: int
total_contacts: int
total_leads: int
open_leads: int
open_opportunities: int
open_pipeline_value: Decimal
won_opportunities: int
won_value: Decimal
pending_activities: int
by_stage: list[StageMetric]

View File

@@ -0,0 +1,21 @@
from fastapi import APIRouter, Depends, Query
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 CrmMetricsResponse
router = APIRouter()
@router.get("/metrics", response_model=CrmMetricsResponse)
def get_metrics(
company_id: int = Query(..., description="Company ID"),
pipeline_id: int | None = Query(None, description="Filtrar embudo del reporte por etapa"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = current_user["tenant_id"]
return service.get_metrics(db, tenant_id, company_id, pipeline_id)

View File

@@ -0,0 +1,98 @@
from decimal import Decimal
from sqlalchemy import and_, func
from sqlalchemy.orm import Session
from ..accounts.models import Account
from ..activities.models import Activity
from ..contacts.models import Contact
from ..leads.models import Lead
from ..opportunities.models import Opportunity
from ..pipelines.models import PipelineStage
def _count(db: Session, model, tenant_id: int, company_id: int, *extra) -> int:
query = db.query(func.count(model.id)).filter(
model.tenant_id == tenant_id,
model.company_id == company_id,
model.deleted_at.is_(None),
*extra,
)
return int(query.scalar() or 0)
def _sum_amount(db: Session, tenant_id: int, company_id: int, *extra) -> Decimal:
total = (
db.query(func.coalesce(func.sum(Opportunity.amount), 0))
.filter(
Opportunity.tenant_id == tenant_id,
Opportunity.company_id == company_id,
Opportunity.deleted_at.is_(None),
*extra,
)
.scalar()
)
return Decimal(total or 0)
def get_metrics(db: Session, tenant_id: int, company_id: int, pipeline_id: int | None = None) -> dict:
"""KPIs y embudo por etapa para el dashboard del CRM."""
open_opps = _count(db, Opportunity, tenant_id, company_id, Opportunity.status == "open")
won_opps = _count(db, Opportunity, tenant_id, company_id, Opportunity.status == "won")
# Embudo: oportunidades abiertas agrupadas por etapa
stage_filters = [
PipelineStage.tenant_id == tenant_id,
PipelineStage.company_id == company_id,
PipelineStage.deleted_at.is_(None),
]
if pipeline_id is not None:
stage_filters.append(PipelineStage.pipeline_id == pipeline_id)
rows = (
db.query(
PipelineStage.id,
PipelineStage.name,
PipelineStage.position,
func.count(Opportunity.id),
func.coalesce(func.sum(Opportunity.amount), 0),
)
.outerjoin(
Opportunity,
and_(
Opportunity.stage_id == PipelineStage.id,
Opportunity.status == "open",
Opportunity.deleted_at.is_(None),
Opportunity.tenant_id == tenant_id,
Opportunity.company_id == company_id,
),
)
.filter(*stage_filters)
.group_by(PipelineStage.id, PipelineStage.name, PipelineStage.position)
.order_by(PipelineStage.position.asc())
.all()
)
by_stage = [
{
"stage_id": row[0],
"stage_name": row[1],
"position": row[2],
"count": int(row[3] or 0),
"value": Decimal(row[4] or 0),
}
for row in rows
]
return {
"total_accounts": _count(db, Account, tenant_id, company_id),
"total_contacts": _count(db, Contact, tenant_id, company_id),
"total_leads": _count(db, Lead, tenant_id, company_id),
"open_leads": _count(db, Lead, tenant_id, company_id, Lead.status != "converted"),
"open_opportunities": open_opps,
"open_pipeline_value": _sum_amount(db, tenant_id, company_id, Opportunity.status == "open"),
"won_opportunities": won_opps,
"won_value": _sum_amount(db, tenant_id, company_id, Opportunity.status == "won"),
"pending_activities": _count(db, Activity, tenant_id, company_id, Activity.status == "pending"),
"by_stage": by_stage,
}

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()

View File

@@ -0,0 +1,44 @@
"""Registro de permisos del módulo CRM.
Se importa desde ``router.py`` para que los permisos queden registrados en el
``PermissionRegistry`` al arrancar la app. Persistir en BD se hace con el
endpoint ``POST /v1/core/permissions/sync`` o el CLI de sincronización.
"""
from api.v1.modules.core.permissions.registry import registry
MODULE = "crm"
# (entidad, etiqueta legible)
_ENTITIES = [
("account", "cuentas"),
("contact", "contactos"),
("lead", "prospectos"),
("opportunity", "oportunidades"),
("pipeline", "embudos"),
("activity", "actividades"),
]
# (acción, verbo para la descripción)
_ACTIONS = [
("view", "Ver"),
("create", "Crear"),
("edit", "Editar"),
("delete", "Eliminar"),
]
def register_permissions() -> None:
"""Da de alta los permisos del CRM en el registro central."""
registry.register(code=f"{MODULE}.access", description="Acceso al módulo CRM", module=MODULE, action="access")
for entity, label in _ENTITIES:
for action, verb in _ACTIONS:
registry.register(
code=f"{MODULE}.{entity}.{action}",
description=f"{verb} {label}",
module=MODULE,
action=action,
)
register_permissions()

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()

View File

@@ -0,0 +1,27 @@
"""Router agregador del módulo CRM.
Se monta bajo el prefijo ``/crm`` en ``api/v1/router.py``.
Importar este módulo también registra los permisos del CRM (side-effect de
``permissions``), siguiendo el patrón del ``PermissionRegistry``.
"""
from fastapi import APIRouter
from . import permissions # noqa: F401 (side-effect: registra permisos del CRM)
from .accounts.routes import router as accounts_router
from .activities.routes import router as activities_router
from .contacts.routes import router as contacts_router
from .leads.routes import router as leads_router
from .metrics.routes import router as metrics_router
from .opportunities.routes import router as opportunities_router
from .pipelines.routes import router as pipelines_router
router = APIRouter()
router.include_router(accounts_router)
router.include_router(contacts_router)
router.include_router(leads_router)
router.include_router(pipelines_router)
router.include_router(opportunities_router)
router.include_router(activities_router)
router.include_router(metrics_router)