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:
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
75
backend/tests/conftest.py
Normal file
75
backend/tests/conftest.py
Normal 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()
|
||||
59
backend/tests/test_accounts.py
Normal file
59
backend/tests/test_accounts.py
Normal 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)
|
||||
38
backend/tests/test_activities.py
Normal file
38
backend/tests/test_activities.py
Normal 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
|
||||
33
backend/tests/test_contacts.py
Normal file
33
backend/tests/test_contacts.py
Normal 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
|
||||
55
backend/tests/test_leads.py
Normal file
55
backend/tests/test_leads.py
Normal 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"
|
||||
42
backend/tests/test_metrics.py
Normal file
42
backend/tests/test_metrics.py
Normal 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
|
||||
77
backend/tests/test_opportunities.py
Normal file
77
backend/tests/test_opportunities.py
Normal 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"
|
||||
Reference in New Issue
Block a user