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:
237
backend/alembic/versions/f1a2b3c4d5e6_crm_schema.py
Normal file
237
backend/alembic/versions/f1a2b3c4d5e6_crm_schema.py
Normal 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}")
|
||||||
0
backend/api/v1/modules/crm/__init__.py
Normal file
0
backend/api/v1/modules/crm/__init__.py
Normal file
0
backend/api/v1/modules/crm/accounts/__init__.py
Normal file
0
backend/api/v1/modules/crm/accounts/__init__.py
Normal file
67
backend/api/v1/modules/crm/accounts/dto.py
Normal file
67
backend/api/v1/modules/crm/accounts/dto.py
Normal 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
|
||||||
40
backend/api/v1/modules/crm/accounts/models.py
Normal file
40
backend/api/v1/modules/crm/accounts/models.py
Normal 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)
|
||||||
67
backend/api/v1/modules/crm/accounts/routes.py
Normal file
67
backend/api/v1/modules/crm/accounts/routes.py
Normal 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)
|
||||||
73
backend/api/v1/modules/crm/accounts/service.py
Normal file
73
backend/api/v1/modules/crm/accounts/service.py
Normal 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()
|
||||||
0
backend/api/v1/modules/crm/activities/__init__.py
Normal file
0
backend/api/v1/modules/crm/activities/__init__.py
Normal file
51
backend/api/v1/modules/crm/activities/dto.py
Normal file
51
backend/api/v1/modules/crm/activities/dto.py
Normal 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
|
||||||
40
backend/api/v1/modules/crm/activities/models.py
Normal file
40
backend/api/v1/modules/crm/activities/models.py
Normal 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)
|
||||||
85
backend/api/v1/modules/crm/activities/routes.py
Normal file
85
backend/api/v1/modules/crm/activities/routes.py
Normal 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)
|
||||||
134
backend/api/v1/modules/crm/activities/service.py
Normal file
134
backend/api/v1/modules/crm/activities/service.py
Normal 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()
|
||||||
0
backend/api/v1/modules/crm/contacts/__init__.py
Normal file
0
backend/api/v1/modules/crm/contacts/__init__.py
Normal file
52
backend/api/v1/modules/crm/contacts/dto.py
Normal file
52
backend/api/v1/modules/crm/contacts/dto.py
Normal 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
|
||||||
27
backend/api/v1/modules/crm/contacts/models.py
Normal file
27
backend/api/v1/modules/crm/contacts/models.py
Normal 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)
|
||||||
67
backend/api/v1/modules/crm/contacts/routes.py
Normal file
67
backend/api/v1/modules/crm/contacts/routes.py
Normal 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)
|
||||||
98
backend/api/v1/modules/crm/contacts/service.py
Normal file
98
backend/api/v1/modules/crm/contacts/service.py
Normal 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()
|
||||||
0
backend/api/v1/modules/crm/leads/__init__.py
Normal file
0
backend/api/v1/modules/crm/leads/__init__.py
Normal file
70
backend/api/v1/modules/crm/leads/dto.py
Normal file
70
backend/api/v1/modules/crm/leads/dto.py
Normal 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
|
||||||
35
backend/api/v1/modules/crm/leads/models.py
Normal file
35
backend/api/v1/modules/crm/leads/models.py
Normal 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)
|
||||||
79
backend/api/v1/modules/crm/leads/routes.py
Normal file
79
backend/api/v1/modules/crm/leads/routes.py
Normal 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)
|
||||||
155
backend/api/v1/modules/crm/leads/service.py
Normal file
155
backend/api/v1/modules/crm/leads/service.py
Normal 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,
|
||||||
|
}
|
||||||
0
backend/api/v1/modules/crm/metrics/__init__.py
Normal file
0
backend/api/v1/modules/crm/metrics/__init__.py
Normal file
24
backend/api/v1/modules/crm/metrics/dto.py
Normal file
24
backend/api/v1/modules/crm/metrics/dto.py
Normal 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]
|
||||||
21
backend/api/v1/modules/crm/metrics/routes.py
Normal file
21
backend/api/v1/modules/crm/metrics/routes.py
Normal 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)
|
||||||
98
backend/api/v1/modules/crm/metrics/service.py
Normal file
98
backend/api/v1/modules/crm/metrics/service.py
Normal 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,
|
||||||
|
}
|
||||||
67
backend/api/v1/modules/crm/opportunities/dto.py
Normal file
67
backend/api/v1/modules/crm/opportunities/dto.py
Normal 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
|
||||||
40
backend/api/v1/modules/crm/opportunities/models.py
Normal file
40
backend/api/v1/modules/crm/opportunities/models.py
Normal 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)
|
||||||
88
backend/api/v1/modules/crm/opportunities/routes.py
Normal file
88
backend/api/v1/modules/crm/opportunities/routes.py
Normal 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)
|
||||||
143
backend/api/v1/modules/crm/opportunities/service.py
Normal file
143
backend/api/v1/modules/crm/opportunities/service.py
Normal 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()
|
||||||
44
backend/api/v1/modules/crm/permissions.py
Normal file
44
backend/api/v1/modules/crm/permissions.py
Normal 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()
|
||||||
0
backend/api/v1/modules/crm/pipelines/__init__.py
Normal file
0
backend/api/v1/modules/crm/pipelines/__init__.py
Normal file
58
backend/api/v1/modules/crm/pipelines/dto.py
Normal file
58
backend/api/v1/modules/crm/pipelines/dto.py
Normal 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
|
||||||
36
backend/api/v1/modules/crm/pipelines/models.py
Normal file
36
backend/api/v1/modules/crm/pipelines/models.py
Normal 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"))
|
||||||
110
backend/api/v1/modules/crm/pipelines/routes.py
Normal file
110
backend/api/v1/modules/crm/pipelines/routes.py
Normal 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)
|
||||||
133
backend/api/v1/modules/crm/pipelines/service.py
Normal file
133
backend/api/v1/modules/crm/pipelines/service.py
Normal 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()
|
||||||
27
backend/api/v1/modules/crm/router.py
Normal file
27
backend/api/v1/modules/crm/router.py
Normal 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)
|
||||||
@@ -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"])
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
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