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,237 @@
"""crm schema (accounts, contacts, leads, pipelines, opportunities, activities)
Revision ID: f1a2b3c4d5e6
Revises: g2h3i4j5k6l7
Create Date: 2026-07-14 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "f1a2b3c4d5e6"
down_revision: Union[str, None] = "g2h3i4j5k6l7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
SCHEMA = "crm"
def _scoped_columns() -> list[sa.Column]:
"""Columnas comunes de TenantScopedMixin + TimestampMixin."""
return [
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("company_id", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
sa.Column("deleted_at", sa.DateTime(), nullable=True),
]
def _scoped_indexes(table: str) -> None:
op.create_index(f"ix_{SCHEMA}_{table}_id", table, ["id"], schema=SCHEMA)
op.create_index(f"ix_{SCHEMA}_{table}_tenant_id", table, ["tenant_id"], schema=SCHEMA)
op.create_index(f"ix_{SCHEMA}_{table}_company_id", table, ["company_id"], schema=SCHEMA)
def upgrade() -> None:
op.execute(f"CREATE SCHEMA IF NOT EXISTS {SCHEMA}")
# ----- pipelines -----
op.create_table(
"pipelines",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=120), nullable=False),
sa.Column("is_default", sa.Boolean(), nullable=False, server_default=sa.text("false")),
*_scoped_columns(),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
schema=SCHEMA,
)
_scoped_indexes("pipelines")
# ----- pipeline_stages -----
op.create_table(
"pipeline_stages",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("pipeline_id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=120), nullable=False),
sa.Column("position", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("probability", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("is_won", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("is_lost", sa.Boolean(), nullable=False, server_default=sa.text("false")),
*_scoped_columns(),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
sa.ForeignKeyConstraint(["pipeline_id"], [f"{SCHEMA}.pipelines.id"]),
schema=SCHEMA,
)
_scoped_indexes("pipeline_stages")
op.create_index("ix_crm_pipeline_stages_pipeline_id", "pipeline_stages", ["pipeline_id"], schema=SCHEMA)
# ----- accounts -----
op.create_table(
"accounts",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("trade_name", sa.String(length=255), nullable=True),
sa.Column("rfc", sa.String(length=13), nullable=True),
sa.Column("account_type", sa.String(length=40), nullable=True),
sa.Column("industry", sa.String(length=120), nullable=True),
sa.Column("email", sa.String(length=255), nullable=True),
sa.Column("phone", sa.String(length=40), nullable=True),
sa.Column("website", sa.String(length=255), nullable=True),
sa.Column("address", sa.Text(), nullable=True),
sa.Column("city", sa.String(length=120), nullable=True),
sa.Column("state", sa.String(length=120), nullable=True),
sa.Column("country", sa.String(length=2), nullable=True, server_default=sa.text("'MX'")),
sa.Column("patente_aduanal", sa.String(length=20), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'active'")),
sa.Column("owner_user_id", sa.String(length=64), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
*_scoped_columns(),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
schema=SCHEMA,
)
_scoped_indexes("accounts")
op.create_index("ix_crm_accounts_rfc", "accounts", ["rfc"], schema=SCHEMA)
op.create_index("ix_crm_accounts_owner_user_id", "accounts", ["owner_user_id"], schema=SCHEMA)
# ----- contacts -----
op.create_table(
"contacts",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("account_id", sa.Integer(), nullable=True),
sa.Column("first_name", sa.String(length=120), nullable=False),
sa.Column("last_name", sa.String(length=120), nullable=True),
sa.Column("email", sa.String(length=255), nullable=True),
sa.Column("phone", sa.String(length=40), nullable=True),
sa.Column("mobile", sa.String(length=40), nullable=True),
sa.Column("job_title", sa.String(length=120), nullable=True),
sa.Column("department", sa.String(length=120), nullable=True),
sa.Column("is_primary", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("owner_user_id", sa.String(length=64), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
*_scoped_columns(),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
sa.ForeignKeyConstraint(["account_id"], [f"{SCHEMA}.accounts.id"]),
schema=SCHEMA,
)
_scoped_indexes("contacts")
op.create_index("ix_crm_contacts_account_id", "contacts", ["account_id"], schema=SCHEMA)
op.create_index("ix_crm_contacts_email", "contacts", ["email"], schema=SCHEMA)
op.create_index("ix_crm_contacts_owner_user_id", "contacts", ["owner_user_id"], schema=SCHEMA)
# ----- opportunities -----
op.create_table(
"opportunities",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("account_id", sa.Integer(), nullable=True),
sa.Column("contact_id", sa.Integer(), nullable=True),
sa.Column("pipeline_id", sa.Integer(), nullable=True),
sa.Column("stage_id", sa.Integer(), nullable=True),
sa.Column("amount", sa.Numeric(precision=14, scale=2), nullable=True),
sa.Column("currency", sa.String(length=3), nullable=False, server_default=sa.text("'MXN'")),
sa.Column("probability", sa.Integer(), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'open'")),
sa.Column("expected_close_date", sa.Date(), nullable=True),
sa.Column("closed_at", sa.DateTime(), nullable=True),
sa.Column("lost_reason", sa.String(length=255), nullable=True),
sa.Column("source", sa.String(length=60), nullable=True),
sa.Column("owner_user_id", sa.String(length=64), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
*_scoped_columns(),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
sa.ForeignKeyConstraint(["account_id"], [f"{SCHEMA}.accounts.id"]),
sa.ForeignKeyConstraint(["contact_id"], [f"{SCHEMA}.contacts.id"]),
sa.ForeignKeyConstraint(["pipeline_id"], [f"{SCHEMA}.pipelines.id"]),
sa.ForeignKeyConstraint(["stage_id"], [f"{SCHEMA}.pipeline_stages.id"]),
schema=SCHEMA,
)
_scoped_indexes("opportunities")
op.create_index("ix_crm_opportunities_account_id", "opportunities", ["account_id"], schema=SCHEMA)
op.create_index("ix_crm_opportunities_contact_id", "opportunities", ["contact_id"], schema=SCHEMA)
op.create_index("ix_crm_opportunities_pipeline_id", "opportunities", ["pipeline_id"], schema=SCHEMA)
op.create_index("ix_crm_opportunities_stage_id", "opportunities", ["stage_id"], schema=SCHEMA)
op.create_index("ix_crm_opportunities_status", "opportunities", ["status"], schema=SCHEMA)
op.create_index("ix_crm_opportunities_owner_user_id", "opportunities", ["owner_user_id"], schema=SCHEMA)
# ----- leads -----
op.create_table(
"leads",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("contact_name", sa.String(length=160), nullable=True),
sa.Column("email", sa.String(length=255), nullable=True),
sa.Column("phone", sa.String(length=40), nullable=True),
sa.Column("company_name", sa.String(length=255), nullable=True),
sa.Column("source", sa.String(length=60), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'new'")),
sa.Column("estimated_value", sa.Numeric(precision=14, scale=2), nullable=True),
sa.Column("owner_user_id", sa.String(length=64), nullable=True),
sa.Column("converted_account_id", sa.Integer(), nullable=True),
sa.Column("converted_contact_id", sa.Integer(), nullable=True),
sa.Column("converted_opportunity_id", sa.Integer(), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
*_scoped_columns(),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
sa.ForeignKeyConstraint(["converted_account_id"], [f"{SCHEMA}.accounts.id"]),
sa.ForeignKeyConstraint(["converted_contact_id"], [f"{SCHEMA}.contacts.id"]),
sa.ForeignKeyConstraint(["converted_opportunity_id"], [f"{SCHEMA}.opportunities.id"]),
schema=SCHEMA,
)
_scoped_indexes("leads")
op.create_index("ix_crm_leads_email", "leads", ["email"], schema=SCHEMA)
op.create_index("ix_crm_leads_status", "leads", ["status"], schema=SCHEMA)
op.create_index("ix_crm_leads_owner_user_id", "leads", ["owner_user_id"], schema=SCHEMA)
# ----- activities -----
op.create_table(
"activities",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("activity_type", sa.String(length=20), nullable=False),
sa.Column("subject", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'pending'")),
sa.Column("due_date", sa.DateTime(), nullable=True),
sa.Column("completed_at", sa.DateTime(), nullable=True),
sa.Column("account_id", sa.Integer(), nullable=True),
sa.Column("contact_id", sa.Integer(), nullable=True),
sa.Column("lead_id", sa.Integer(), nullable=True),
sa.Column("opportunity_id", sa.Integer(), nullable=True),
sa.Column("owner_user_id", sa.String(length=64), nullable=True),
*_scoped_columns(),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
sa.ForeignKeyConstraint(["account_id"], [f"{SCHEMA}.accounts.id"]),
sa.ForeignKeyConstraint(["contact_id"], [f"{SCHEMA}.contacts.id"]),
sa.ForeignKeyConstraint(["lead_id"], [f"{SCHEMA}.leads.id"]),
sa.ForeignKeyConstraint(["opportunity_id"], [f"{SCHEMA}.opportunities.id"]),
schema=SCHEMA,
)
_scoped_indexes("activities")
op.create_index("ix_crm_activities_status", "activities", ["status"], schema=SCHEMA)
op.create_index("ix_crm_activities_account_id", "activities", ["account_id"], schema=SCHEMA)
op.create_index("ix_crm_activities_contact_id", "activities", ["contact_id"], schema=SCHEMA)
op.create_index("ix_crm_activities_lead_id", "activities", ["lead_id"], schema=SCHEMA)
op.create_index("ix_crm_activities_opportunity_id", "activities", ["opportunity_id"], schema=SCHEMA)
op.create_index("ix_crm_activities_owner_user_id", "activities", ["owner_user_id"], schema=SCHEMA)
def downgrade() -> None:
# Orden inverso al de creación para respetar las FK.
# DROP TABLE elimina automáticamente los índices asociados en PostgreSQL.
op.drop_table("activities", schema=SCHEMA)
op.drop_table("leads", schema=SCHEMA)
op.drop_table("opportunities", schema=SCHEMA)
op.drop_table("contacts", schema=SCHEMA)
op.drop_table("accounts", schema=SCHEMA)
op.drop_table("pipeline_stages", schema=SCHEMA)
op.drop_table("pipelines", schema=SCHEMA)
op.execute(f"DROP SCHEMA IF EXISTS {SCHEMA}")

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)

View File

@@ -5,12 +5,14 @@ Router principal de API v1
from fastapi import APIRouter from fastapi import APIRouter
from .modules.core.router import router as core_router from .modules.core.router import router as core_router
from .modules.crm.router import router as crm_router
from .modules.example.routes import router as example_router from .modules.example.routes import router as example_router
router = APIRouter() router = APIRouter()
router.include_router(core_router) router.include_router(core_router)
router.include_router(crm_router, prefix="/crm", tags=["crm"])
router.include_router(example_router, prefix="/example", tags=["example"]) router.include_router(example_router, prefix="/example", tags=["example"])

View File

75
backend/tests/conftest.py Normal file
View File

@@ -0,0 +1,75 @@
"""Fixtures de pruebas del módulo CRM.
Las pruebas de servicios corren contra SQLite en memoria usando
``schema_translate_map`` para mapear los schemas ``crm``/``core`` al schema
principal de SQLite. Esto permite ejercitar la lógica de negocio sin depender
de PostgreSQL/Docker en el entorno de desarrollo local.
Nota: la validación estructural del esquema (FKs cross-schema, DDL) se hace
con la migración Alembic contra PostgreSQL en CI (``TEST_DATABASE_URL``).
"""
import os
import sys
from datetime import datetime
import pytest
from sqlalchemy import Column, Integer, String, Table, create_engine, event
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
BACKEND_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if BACKEND_DIR not in sys.path:
sys.path.insert(0, BACKEND_DIR)
from core.database import Base # noqa: E402
# Importar los modelos registra sus tablas en Base.metadata
import api.v1.modules.crm.accounts.models # noqa: E402,F401
import api.v1.modules.crm.activities.models # noqa: E402,F401
import api.v1.modules.crm.contacts.models # noqa: E402,F401
import api.v1.modules.crm.leads.models # noqa: E402,F401
import api.v1.modules.crm.opportunities.models # noqa: E402,F401
import api.v1.modules.crm.pipelines.models # noqa: E402,F401
_SCHEMA_MAP = {"crm": None, "core": None}
# Tabla mínima core.tenants para resolver la FK tenant_id de las tablas crm.
# En CI (PostgreSQL) la tabla real la crea la migración inicial del core.
if "core.tenants" not in Base.metadata.tables:
Table(
"tenants",
Base.metadata,
Column("id", Integer, primary_key=True),
Column("name", String(255)),
schema="core",
)
TENANT_ID = 1
COMPANY_ID = 1
@pytest.fixture()
def db():
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
future=True,
).execution_options(schema_translate_map=_SCHEMA_MAP)
@event.listens_for(engine, "connect")
def _register_now(dbapi_conn, _record):
# Soporta server_default text("now()") de los mixins de timestamp
dbapi_conn.create_function(
"now", 0, lambda: datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S.%f")
)
Base.metadata.create_all(engine)
session_factory = sessionmaker(bind=engine, future=True)
session = session_factory()
try:
yield session
finally:
session.close()
engine.dispose()

View File

@@ -0,0 +1,59 @@
import pytest
from fastapi import HTTPException
from api.v1.modules.crm.accounts import service
from api.v1.modules.crm.accounts.dto import AccountCreate, AccountUpdate
T, C = 1, 1
def test_create_and_get_account(db):
acc = service.create_account(
db,
AccountCreate(name="Importadora Demo", rfc="XAXX010101000", account_type="importador"),
T, C,
)
assert acc.id is not None
assert acc.status == "active"
assert acc.country == "MX"
got = service.get_account(db, acc.id, T, C)
assert got.name == "Importadora Demo"
assert got.rfc == "XAXX010101000"
def test_list_and_search(db):
service.create_account(db, AccountCreate(name="Alpha SA"), T, C)
service.create_account(db, AccountCreate(name="Beta SA"), T, C)
assert len(service.get_accounts(db, T, C)) == 2
found = service.get_accounts(db, T, C, search="alpha")
assert len(found) == 1 and found[0].name == "Alpha SA"
def test_filter_by_status(db):
service.create_account(db, AccountCreate(name="Activa", status="active"), T, C)
service.create_account(db, AccountCreate(name="Prospecto", status="prospect"), T, C)
only_prospect = service.get_accounts(db, T, C, account_status="prospect")
assert len(only_prospect) == 1 and only_prospect[0].name == "Prospecto"
def test_update_account(db):
acc = service.create_account(db, AccountCreate(name="X"), T, C)
upd = service.update_account(db, acc.id, AccountUpdate(status="inactive", phone="5551234567"), T, C)
assert upd.status == "inactive"
assert upd.phone == "5551234567"
def test_soft_delete_hides_account(db):
acc = service.create_account(db, AccountCreate(name="Y"), T, C)
service.delete_account(db, acc.id, T, C)
with pytest.raises(HTTPException) as exc:
service.get_account(db, acc.id, T, C)
assert exc.value.status_code == 404
assert service.get_accounts(db, T, C) == []
def test_tenant_isolation(db):
acc = service.create_account(db, AccountCreate(name="Z"), T, C)
assert service.get_accounts(db, tenant_id=999, company_id=C) == []
with pytest.raises(HTTPException):
service.get_account(db, acc.id, 999, C)

View File

@@ -0,0 +1,38 @@
import pytest
from fastapi import HTTPException
from api.v1.modules.crm.activities import service
from api.v1.modules.crm.activities.dto import ActivityCreate
T, C = 1, 1
def test_create_rejects_invalid_type(db):
with pytest.raises(HTTPException) as exc:
service.create_activity(db, ActivityCreate(activity_type="invalido", subject="x"), T, C)
assert exc.value.status_code == 422
def test_complete_activity_sets_timestamp(db):
activity = service.create_activity(db, ActivityCreate(activity_type="call", subject="Llamar al cliente"), T, C)
assert activity.status == "pending"
done = service.complete_activity(db, activity.id, T, C)
assert done.status == "completed"
assert done.completed_at is not None
def test_create_rejects_unknown_related_entity(db):
with pytest.raises(HTTPException) as exc:
service.create_activity(
db, ActivityCreate(activity_type="task", subject="x", opportunity_id=999), T, C
)
assert exc.value.status_code == 422
def test_filter_by_type_and_status(db):
service.create_activity(db, ActivityCreate(activity_type="call", subject="a"), T, C)
service.create_activity(db, ActivityCreate(activity_type="meeting", subject="b"), T, C)
calls = service.get_activities(db, T, C, activity_type="call")
assert len(calls) == 1 and calls[0].activity_type == "call"
pending = service.get_activities(db, T, C, activity_status="pending")
assert len(pending) == 2

View File

@@ -0,0 +1,33 @@
import pytest
from fastapi import HTTPException
from api.v1.modules.crm.accounts import service as accounts_service
from api.v1.modules.crm.accounts.dto import AccountCreate
from api.v1.modules.crm.contacts import service
from api.v1.modules.crm.contacts.dto import ContactCreate
T, C = 1, 1
def test_create_contact_rejects_unknown_account(db):
with pytest.raises(HTTPException) as exc:
service.create_contact(db, ContactCreate(first_name="Juan", account_id=999), T, C)
assert exc.value.status_code == 422
def test_create_and_list_by_account(db):
acc = accounts_service.create_account(db, AccountCreate(name="Empresa"), T, C)
contact = service.create_contact(
db,
ContactCreate(first_name="Ana", last_name="López", account_id=acc.id, is_primary=True),
T, C,
)
assert contact.account_id == acc.id
assert contact.is_primary is True
listed = service.get_contacts(db, T, C, account_id=acc.id)
assert len(listed) == 1 and listed[0].first_name == "Ana"
def test_contact_without_account_is_allowed(db):
contact = service.create_contact(db, ContactCreate(first_name="Suelto"), T, C)
assert contact.account_id is None

View File

@@ -0,0 +1,55 @@
from api.v1.modules.crm.leads import service
from api.v1.modules.crm.leads.dto import LeadConvert, LeadCreate
from api.v1.modules.crm.pipelines import service as pipelines_service
from api.v1.modules.crm.pipelines.dto import PipelineCreate, StageCreate
T, C = 1, 1
def test_create_lead_defaults_to_new(db):
lead = service.create_lead(db, LeadCreate(name="Prospecto X", company_name="XYZ SA"), T, C)
assert lead.status == "new"
def test_convert_lead_creates_account_contact_opportunity(db):
pipeline = pipelines_service.create_pipeline(db, PipelineCreate(name="Ventas", is_default=True), T, C)
stage = pipelines_service.create_stage(db, StageCreate(pipeline_id=pipeline.id, name="Prospecto"), T, C)
lead = service.create_lead(
db,
LeadCreate(
name="Oportunidad IMMEX",
company_name="Maquiladora del Norte",
contact_name="María Pérez",
email="maria@example.com",
estimated_value=50000,
),
T, C,
)
result = service.convert_lead(
db, lead.id, LeadConvert(create_opportunity=True, pipeline_id=pipeline.id, stage_id=stage.id), T, C
)
assert result["account_id"] is not None
assert result["contact_id"] is not None
assert result["opportunity_id"] is not None
assert result["lead"].status == "converted"
assert result["lead"].converted_account_id == result["account_id"]
def test_convert_is_idempotent(db):
lead = service.create_lead(db, LeadCreate(name="P", company_name="Empresa"), T, C)
first = service.convert_lead(db, lead.id, LeadConvert(create_opportunity=False), T, C)
second = service.convert_lead(db, lead.id, LeadConvert(create_opportunity=False), T, C)
assert first["account_id"] == second["account_id"]
def test_convert_splits_contact_name(db):
lead = service.create_lead(db, LeadCreate(name="P", contact_name="Juan Carlos Ramírez"), T, C)
result = service.convert_lead(db, lead.id, LeadConvert(create_opportunity=False), T, C)
from api.v1.modules.crm.contacts import service as contacts_service
contact = contacts_service.get_contact(db, result["contact_id"], T, C)
assert contact.first_name == "Juan"
assert contact.last_name == "Carlos Ramírez"

View File

@@ -0,0 +1,42 @@
from api.v1.modules.crm.accounts import service as accounts_service
from api.v1.modules.crm.accounts.dto import AccountCreate
from api.v1.modules.crm.metrics import service as metrics_service
from api.v1.modules.crm.opportunities import service as opportunities_service
from api.v1.modules.crm.opportunities.dto import OpportunityCreate
from api.v1.modules.crm.pipelines import service as pipelines_service
from api.v1.modules.crm.pipelines.dto import PipelineCreate, StageCreate
T, C = 1, 1
def test_metrics_counts_and_pipeline(db):
accounts_service.create_account(db, AccountCreate(name="Cuenta 1"), T, C)
pipeline = pipelines_service.create_pipeline(db, PipelineCreate(name="Ventas", is_default=True), T, C)
s_open = pipelines_service.create_stage(
db, StageCreate(pipeline_id=pipeline.id, name="Prospecto", position=0, probability=20), T, C
)
s_won = pipelines_service.create_stage(
db, StageCreate(pipeline_id=pipeline.id, name="Ganada", position=1, is_won=True), T, C
)
opportunities_service.create_opportunity(
db, OpportunityCreate(name="Abierta", pipeline_id=pipeline.id, stage_id=s_open.id, amount=1000), T, C
)
won = opportunities_service.create_opportunity(
db, OpportunityCreate(name="Cerrada", pipeline_id=pipeline.id, stage_id=s_open.id, amount=2000), T, C
)
opportunities_service.move_opportunity(db, won.id, s_won.id, T, C)
metrics = metrics_service.get_metrics(db, T, C)
assert metrics["total_accounts"] == 1
assert metrics["open_opportunities"] == 1
assert float(metrics["open_pipeline_value"]) == 1000.0
assert metrics["won_opportunities"] == 1
assert float(metrics["won_value"]) == 2000.0
# El embudo reporta ambas etapas; la abierta tiene 1 oportunidad
by_stage = {row["stage_name"]: row for row in metrics["by_stage"]}
assert by_stage["Prospecto"]["count"] == 1
assert float(by_stage["Prospecto"]["value"]) == 1000.0
assert by_stage["Ganada"]["count"] == 0

View File

@@ -0,0 +1,77 @@
import pytest
from fastapi import HTTPException
from api.v1.modules.crm.opportunities import service
from api.v1.modules.crm.opportunities.dto import OpportunityCreate
from api.v1.modules.crm.pipelines import service as pipelines_service
from api.v1.modules.crm.pipelines.dto import PipelineCreate, StageCreate
T, C = 1, 1
def _pipeline_with_stages(db):
pipeline = pipelines_service.create_pipeline(db, PipelineCreate(name="Ventas", is_default=True), T, C)
s_open = pipelines_service.create_stage(
db, StageCreate(pipeline_id=pipeline.id, name="Prospecto", position=0, probability=10), T, C
)
s_won = pipelines_service.create_stage(
db, StageCreate(pipeline_id=pipeline.id, name="Ganada", position=1, probability=100, is_won=True), T, C
)
s_lost = pipelines_service.create_stage(
db, StageCreate(pipeline_id=pipeline.id, name="Perdida", position=2, is_lost=True), T, C
)
return pipeline, s_open, s_won, s_lost
def test_create_opportunity(db):
pipeline, s_open, _, _ = _pipeline_with_stages(db)
opp = service.create_opportunity(
db,
OpportunityCreate(name="Licencia Aduanasoft", pipeline_id=pipeline.id, stage_id=s_open.id, amount=15000),
T, C,
)
assert opp.status == "open"
assert opp.currency == "MXN"
def test_move_to_won_closes_and_sets_probability(db):
pipeline, s_open, s_won, _ = _pipeline_with_stages(db)
opp = service.create_opportunity(db, OpportunityCreate(name="Deal", pipeline_id=pipeline.id, stage_id=s_open.id), T, C)
moved = service.move_opportunity(db, opp.id, s_won.id, T, C)
assert moved.status == "won"
assert moved.probability == 100
assert moved.closed_at is not None
assert moved.stage_id == s_won.id
def test_move_to_lost(db):
pipeline, s_open, _, s_lost = _pipeline_with_stages(db)
opp = service.create_opportunity(db, OpportunityCreate(name="Deal", pipeline_id=pipeline.id, stage_id=s_open.id), T, C)
moved = service.move_opportunity(db, opp.id, s_lost.id, T, C)
assert moved.status == "lost"
assert moved.probability == 0
assert moved.closed_at is not None
def test_move_back_to_open_reopens(db):
pipeline, s_open, s_won, _ = _pipeline_with_stages(db)
opp = service.create_opportunity(db, OpportunityCreate(name="Deal", pipeline_id=pipeline.id, stage_id=s_open.id), T, C)
service.move_opportunity(db, opp.id, s_won.id, T, C)
reopened = service.move_opportunity(db, opp.id, s_open.id, T, C)
assert reopened.status == "open"
assert reopened.probability == 10
assert reopened.closed_at is None
def test_create_rejects_unknown_account(db):
with pytest.raises(HTTPException) as exc:
service.create_opportunity(db, OpportunityCreate(name="X", account_id=999), T, C)
assert exc.value.status_code == 422
def test_only_one_default_pipeline(db):
pipelines_service.create_pipeline(db, PipelineCreate(name="P1", is_default=True), T, C)
pipelines_service.create_pipeline(db, PipelineCreate(name="P2", is_default=True), T, C)
pipelines = pipelines_service.get_pipelines(db, T, C)
defaults = [p for p in pipelines if p.is_default]
assert len(defaults) == 1 and defaults[0].name == "P2"