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

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"