Saltar a contenido

Especificación Técnica: mae_prospectos

Esquema: clientes
Base de Datos: crm
Tabla Legacy Origen: i286t_prospectos
Propósito: Gestión de oportunidades comerciales y leads capturados en campo por los promotores antes de su conversión y alta formal como clientes.


1. Justificación y Mejoras de Arquitectura

  • Pipeline Comercial y Trazabilidad: Permite el seguimiento del lead a través de estados (cat_estados_prospecto). Al concretarse la conversión a cliente formal, la trazabilidad se registra desde la entidad clientes.mae_clientes mediante la columna prospecto_id.
  • Aislamiento Multi-Tenant (RLS): Integra empresa_id BIGINT NOT NULL con políticas RLS obligatorias.
  • Integridad Referencial Compuesta: Clave única (empresa_id, id) y FKs compuestas hacia fuerza_ventas.mae_promotores y clientes.cat_estados_prospecto.
  • Geolocalización y Precisión: Almacena coordenadas GPS en DOUBLE PRECISION indexadas.
  • Trazabilidad y Auditoría: Auditoría integral (created_at, updated_at, deleted_at, created_by, updated_by).

2. Definiciones de Implementación

Table clientes.mae_prospectos {
    id                              uuid              [pk, default: `gen_random_uuid()`, note: 'Identificador único UUID']
    empresa_id                      bigint            [not null, note: 'Identificador BIGINT de la empresa en core.identidad (RLS)']
    promotor_captador_id            uuid              [not null, note: 'Promotor que capturó el lead']
    estado_prospecto_id             uuid              [not null, note: 'Fase actual del embudo comercial']
    nombre_negocio                  varchar(200)      [not null, note: 'Nombre comercial del establecimiento o prospecto']
    nombre_contacto                 varchar(150)      [not null, note: 'Persona de contacto o encargado']
    telefono                        varchar(50)       [not null, note: 'Número de teléfono de contacto']
    email                           varchar(150)      [note: 'Correo electrónico del prospecto']
    direccion_texto                 text              [not null, note: 'Dirección física o referencia del local']
    latitud                         double precision  [not null, note: 'Coordenada GPS Latitud']
    longitud                        double precision  [not null, note: 'Coordenada GPS Longitud']
    volumen_compra_estimado         numeric(12,2)     [note: 'Potencial estimado mensual de compra']
    motivo_descarte                 varchar(255)      [note: 'Razón de pérdida si fue descartado']

    // Auditoría General
    is_activo                       boolean           [not null, default: `true`, note: 'Estado lógico del registro']
    created_at                      timestamptz       [not null, default: `now()`, note: 'Fecha de creación']
    updated_at                      timestamptz       [not null, default: `now()`, note: 'Fecha de última actualización']
    deleted_at                      timestamptz       [note: 'Fecha de eliminación lógica']
    created_by                      uuid              [note: 'Usuario creador']
    updated_by                      uuid              [note: 'Usuario modificador']

    Indexes {
        (empresa_id, id) [unique, name: 'uq_mae_prospectos_empresa_id']
        (empresa_id, is_activo) [name: 'ix_mae_prospectos_empresa_activo']
        (empresa_id, estado_prospecto_id) [name: 'ix_mae_prospectos_empresa_estado']
        (empresa_id, promotor_captador_id) [name: 'ix_mae_prospectos_empresa_captador']
        (empresa_id, latitud, longitud) [name: 'ix_mae_prospectos_empresa_gps']
    }
}

Ref: clientes.mae_prospectos.(empresa_id, promotor_captador_id) > fuerza_ventas.mae_promotores.(empresa_id, id)
Ref: clientes.mae_prospectos.(empresa_id, estado_prospecto_id) > clientes.cat_estados_prospecto.(empresa_id, id)
CREATE TABLE IF NOT EXISTS clientes.mae_prospectos (
    id UUID NOT NULL DEFAULT gen_random_uuid(),
    empresa_id BIGINT NOT NULL,
    promotor_captador_id UUID NOT NULL,
    estado_prospecto_id UUID NOT NULL,  -- Este controla si es nuevo, en negociación, convertido o descartado.
    nombre_negocio VARCHAR(200) NOT NULL,
    nombre_contacto VARCHAR(150) NOT NULL,
    telefono VARCHAR(50) NOT NULL,
    email VARCHAR(150),
    direccion_texto TEXT NOT NULL,
    latitud DOUBLE PRECISION NOT NULL,
    longitud DOUBLE PRECISION NOT NULL,
    volumen_compra_estimado NUMERIC(12,2),
    motivo_descarte VARCHAR(255),

    -- Auditoría General
    is_activo BOOLEAN NOT NULL DEFAULT true,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    deleted_at TIMESTAMPTZ,
    created_by UUID,
    updated_by UUID,

    CONSTRAINT pk_mae_prospectos
        PRIMARY KEY (id),

    CONSTRAINT uq_mae_prospectos_empresa_id
        UNIQUE (empresa_id, id),

    CONSTRAINT fk_mae_prospectos_promotor
        FOREIGN KEY (empresa_id, promotor_captador_id)
        REFERENCES fuerza_ventas.mae_promotores (empresa_id, id),

    CONSTRAINT fk_mae_prospectos_estado
        FOREIGN KEY (empresa_id, estado_prospecto_id)
        REFERENCES clientes.cat_estados_prospecto (empresa_id, id)
);

CREATE INDEX IF NOT EXISTS ix_mae_prospectos_empresa_activo
    ON clientes.mae_prospectos (empresa_id, is_activo);

CREATE INDEX IF NOT EXISTS ix_mae_prospectos_empresa_estado
    ON clientes.mae_prospectos (empresa_id, estado_prospecto_id);

CREATE INDEX IF NOT EXISTS ix_mae_prospectos_empresa_captador
    ON clientes.mae_prospectos (empresa_id, promotor_captador_id);

CREATE INDEX IF NOT EXISTS ix_mae_prospectos_empresa_gps
    ON clientes.mae_prospectos (empresa_id, latitud, longitud);

COMMENT ON TABLE clientes.mae_prospectos IS
    'Maestro de Prospectos (Leads): Gestión de oportunidades de negocio capturadas en campo antes de ser aprobadas como clientes formales.';

COMMENT ON COLUMN clientes.mae_prospectos.id IS
    'Identificador único UUID';

COMMENT ON COLUMN clientes.mae_prospectos.empresa_id IS
    'Identificador UUID de la empresa en core.identidad (RLS)';

COMMENT ON COLUMN clientes.mae_prospectos.promotor_captador_id IS
    'Promotor que capturó el lead';

COMMENT ON COLUMN clientes.mae_prospectos.estado_prospecto_id IS
    'Fase actual del embudo comercial';

COMMENT ON COLUMN clientes.mae_prospectos.nombre_negocio IS
    'Nombre comercial del establecimiento o prospecto';

COMMENT ON COLUMN clientes.mae_prospectos.nombre_contacto IS
    'Persona de contacto o encargado';

COMMENT ON COLUMN clientes.mae_prospectos.telefono IS
    'Número de teléfono de contacto';

COMMENT ON COLUMN clientes.mae_prospectos.email IS
    'Correo electrónico del prospecto';

COMMENT ON COLUMN clientes.mae_prospectos.direccion_texto IS
    'Dirección física o referencia del local';

COMMENT ON COLUMN clientes.mae_prospectos.latitud IS
    'Coordenada GPS Latitud';

COMMENT ON COLUMN clientes.mae_prospectos.longitud IS
    'Coordenada GPS Longitud';

COMMENT ON COLUMN clientes.mae_prospectos.volumen_compra_estimado IS
    'Potencial estimado mensual de compra';

COMMENT ON COLUMN clientes.mae_prospectos.motivo_descarte IS
    'Razón de pérdida si fue descartado';

COMMENT ON COLUMN clientes.mae_prospectos.is_activo IS
    'Estado lógico del registro';

COMMENT ON COLUMN clientes.mae_prospectos.created_at IS
    'Fecha de creación';

COMMENT ON COLUMN clientes.mae_prospectos.updated_at IS
    'Fecha de última actualización';

COMMENT ON COLUMN clientes.mae_prospectos.deleted_at IS
    'Fecha de eliminación lógica';

COMMENT ON COLUMN clientes.mae_prospectos.created_by IS
    'Usuario creador';

COMMENT ON COLUMN clientes.mae_prospectos.updated_by IS
    'Usuario modificador';

ALTER TABLE clientes.mae_prospectos
    ENABLE ROW LEVEL SECURITY;

CREATE POLICY rls_mae_prospectos_empresa
    ON clientes.mae_prospectos
    FOR ALL
    USING (
        empresa_id = current_setting(
            'app.current_empresa_id',
            true
        )::BIGINT
    )
    WITH CHECK (
        empresa_id = current_setting(
            'app.current_empresa_id',
            true
        )::BIGINT
    );
import uuid
from datetime import datetime
from decimal import Decimal
from typing import Optional

from sqlalchemy import (
    BigInteger,
    Boolean,
    Double,
    ForeignKeyConstraint,
    Index,
    Numeric,
    String,
    Text,
    UniqueConstraint,
    text,
)
from sqlalchemy.dialects.postgresql import TIMESTAMPTZ, UUID
from sqlalchemy.orm import Mapped, mapped_column

from app.db.base import Base


class MaeProspectos(Base):
    """
    Maestro de Prospectos (Leads).

    Gestión de oportunidades de negocio capturadas en campo antes de
    ser aprobadas como clientes formales.

    Legacy:
        c063t_agenda_actividades (registros sin cliente_id)
    """

    __tablename__ = "mae_prospectos"

    __table_args__ = (
        UniqueConstraint(
            "empresa_id",
            "id",
            name="uq_mae_prospectos_empresa_id",
        ),
        ForeignKeyConstraint(
            ["empresa_id", "promotor_captador_id"],
            ["fuerza_ventas.mae_promotores.empresa_id", "fuerza_ventas.mae_promotores.id"],
            name="fk_mae_prospectos_promotor",
        ),
        ForeignKeyConstraint(
            ["empresa_id", "estado_prospecto_id"],
            ["clientes.cat_estados_prospecto.empresa_id", "clientes.cat_estados_prospecto.id"],
            name="fk_mae_prospectos_estado",
        ),
        Index(
            "ix_mae_prospectos_empresa_activo",
            "empresa_id",
            "is_activo",
        ),
        Index(
            "ix_mae_prospectos_empresa_estado",
            "empresa_id",
            "estado_prospecto_id",
        ),
        Index(
            "ix_mae_prospectos_empresa_captador",
            "empresa_id",
            "promotor_captador_id",
        ),
        Index(
            "ix_mae_prospectos_empresa_gps",
            "empresa_id",
            "latitud",
            "longitud",
        ),
        {
            "schema": "clientes",
            "comment": "Maestro de Prospectos (Leads)",
        },
    )

    id: Mapped[uuid.UUID] = mapped_column(
        UUID(as_uuid=True),
        primary_key=True,
        server_default=text("gen_random_uuid()"),
        comment="Identificador único UUID",
    )

    empresa_id: Mapped[int] = mapped_column(
        BigInteger,
        nullable=False,
        comment="Identificador UUID de la empresa en core.identidad (RLS)",
    )

    promotor_captador_id: Mapped[uuid.UUID] = mapped_column(
        UUID(as_uuid=True),
        nullable=False,
        comment="Promotor que capturó el lead",
    )

    estado_prospecto_id: Mapped[uuid.UUID] = mapped_column(
        UUID(as_uuid=True),
        nullable=False,
        comment="Fase actual del embudo comercial",
    )

    nombre_negocio: Mapped[str] = mapped_column(
        String(200),
        nullable=False,
        comment="Nombre comercial del establecimiento o prospecto",
    )

    nombre_contacto: Mapped[str] = mapped_column(
        String(150),
        nullable=False,
        comment="Persona de contacto o encargado",
    )

    telefono: Mapped[str] = mapped_column(
        String(50),
        nullable=False,
        comment="Número de teléfono de contacto",
    )

    email: Mapped[Optional[str]] = mapped_column(
        String(150),
        nullable=True,
        comment="Correo electrónico del prospecto",
    )

    direccion_texto: Mapped[str] = mapped_column(
        Text,
        nullable=False,
        comment="Dirección física o referencia del local",
    )

    latitud: Mapped[float] = mapped_column(
        Double,
        nullable=False,
        comment="Coordenada GPS Latitud",
    )

    longitud: Mapped[float] = mapped_column(
        Double,
        nullable=False,
        comment="Coordenada GPS Longitud",
    )

    volumen_compra_estimado: Mapped[Optional[Decimal]] = mapped_column(
        Numeric(12, 2),
        nullable=True,
        comment="Potencial estimado mensual de compra",
    )

    motivo_descarte: Mapped[Optional[str]] = mapped_column(
        String(255),
        nullable=True,
        comment="Razón de pérdida si fue descartado",
    )

    is_activo: Mapped[bool] = mapped_column(
        Boolean,
        nullable=False,
        server_default=text("true"),
        comment="Estado lógico del registro",
    )

    created_at: Mapped[datetime] = mapped_column(
        TIMESTAMPTZ,
        nullable=False,
        server_default=text("now()"),
        comment="Fecha de creación",
    )

    updated_at: Mapped[datetime] = mapped_column(
        TIMESTAMPTZ,
        nullable=False,
        server_default=text("now()"),
        comment="Fecha de última actualización",
    )

    deleted_at: Mapped[Optional[datetime]] = mapped_column(
        TIMESTAMPTZ,
        nullable=True,
        comment="Fecha de eliminación lógica",
    )

    created_by: Mapped[Optional[uuid.UUID]] = mapped_column(
        UUID(as_uuid=True),
        nullable=True,
        comment="Usuario creador",
    )

    updated_by: Mapped[Optional[uuid.UUID]] = mapped_column(
        UUID(as_uuid=True),
        nullable=True,
        comment="Usuario modificador",
    )
"""create mae_prospectos

Revision ID: mae_0006
Revises: mae_0005
Create Date: 2026-09-09
"""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql


# revision identifiers, used by Alembic.
revision: str = "mae_0006"
down_revision: Union[str, Sequence[str], None] = "mae_0005"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:

    op.create_table(
        "mae_prospectos",

        sa.Column(
            "id",
            postgresql.UUID(as_uuid=True),
            nullable=False,
            server_default=sa.text("gen_random_uuid()"),
            comment="Identificador único UUID",
        ),

        sa.Column(
            "empresa_id",
            sa.BigInteger(),
            nullable=False,
            comment="Identificador UUID de la empresa en core.identidad (RLS)",
        ),

        sa.Column(
            "promotor_captador_id",
            postgresql.UUID(as_uuid=True),
            nullable=False,
            comment="Promotor que capturó el lead",
        ),

        sa.Column(
            "estado_prospecto_id",
            postgresql.UUID(as_uuid=True),
            nullable=False,
            comment="Fase actual del embudo comercial",
        ),

        sa.Column(
            "nombre_negocio",
            sa.String(length=200),
            nullable=False,
            comment="Nombre comercial del establecimiento o prospecto",
        ),

        sa.Column(
            "nombre_contacto",
            sa.String(length=150),
            nullable=False,
            comment="Persona de contacto o encargado",
        ),

        sa.Column(
            "telefono",
            sa.String(length=50),
            nullable=False,
            comment="Número de teléfono de contacto",
        ),

        sa.Column(
            "email",
            sa.String(length=150),
            nullable=True,
            comment="Correo electrónico del prospecto",
        ),

        sa.Column(
            "direccion_texto",
            sa.Text(),
            nullable=False,
            comment="Dirección física o referencia del local",
        ),

        sa.Column(
            "latitud",
            sa.Float(precision=53),
            nullable=False,
            comment="Coordenada GPS Latitud",
        ),

        sa.Column(
            "longitud",
            sa.Float(precision=53),
            nullable=False,
            comment="Coordenada GPS Longitud",
        ),

        sa.Column(
            "volumen_compra_estimado",
            sa.Numeric(precision=12, scale=2),
            nullable=True,
            comment="Potencial estimado mensual de compra",
        ),

        sa.Column(
            "motivo_descarte",
            sa.String(length=255),
            nullable=True,
            comment="Razón de pérdida si fue descartado",
        ),

        # Auditoría General
        sa.Column(
            "is_activo",
            sa.Boolean(),
            nullable=False,
            server_default=sa.text("true"),
            comment="Estado lógico del registro",
        ),

        sa.Column(
            "created_at",
            sa.TIMESTAMP(timezone=True),
            nullable=False,
            server_default=sa.text("now()"),
            comment="Fecha de creación",
        ),

        sa.Column(
            "updated_at",
            sa.TIMESTAMP(timezone=True),
            nullable=False,
            server_default=sa.text("now()"),
            comment="Fecha de última actualización",
        ),

        sa.Column(
            "deleted_at",
            sa.TIMESTAMP(timezone=True),
            nullable=True,
            comment="Fecha de eliminación lógica",
        ),

        sa.Column(
            "created_by",
            postgresql.UUID(as_uuid=True),
            nullable=True,
            comment="Usuario creador",
        ),

        sa.Column(
            "updated_by",
            postgresql.UUID(as_uuid=True),
            nullable=True,
            comment="Usuario modificador",
        ),

        sa.PrimaryKeyConstraint(
            "id",
            name="pk_mae_prospectos",
        ),

        sa.UniqueConstraint(
            "empresa_id",
            "id",
            name="uq_mae_prospectos_empresa_id",
        ),

        sa.ForeignKeyConstraint(
            ["empresa_id", "promotor_captador_id"],
            ["fuerza_ventas.mae_promotores.empresa_id", "fuerza_ventas.mae_promotores.id"],
            name="fk_mae_prospectos_promotor",
        ),

        sa.ForeignKeyConstraint(
            ["empresa_id", "estado_prospecto_id"],
            ["clientes.cat_estados_prospecto.empresa_id", "clientes.cat_estados_prospecto.id"],
            name="fk_mae_prospectos_estado",
        ),

        comment="Maestro de Prospectos (Leads)",
        schema="clientes",
    )

    op.create_index(
        "ix_mae_prospectos_empresa_activo",
        "mae_prospectos",
        ["empresa_id", "is_activo"],
        unique=False,
        schema="clientes",
    )

    op.create_index(
        "ix_mae_prospectos_empresa_estado",
        "mae_prospectos",
        ["empresa_id", "estado_prospecto_id"],
        unique=False,
        schema="clientes",
    )

    op.create_index(
        "ix_mae_prospectos_empresa_captador",
        "mae_prospectos",
        ["empresa_id", "promotor_captador_id"],
        unique=False,
        schema="clientes",
    )

    op.create_index(
        "ix_mae_prospectos_empresa_gps",
        "mae_prospectos",
        ["empresa_id", "latitud", "longitud"],
        unique=False,
        schema="clientes",
    )

    op.execute(
        """
        ALTER TABLE clientes.mae_prospectos
        ENABLE ROW LEVEL SECURITY;
        """
    )

    op.execute(
        """
        CREATE POLICY rls_mae_prospectos_empresa
        ON clientes.mae_prospectos
        FOR ALL
        USING (
            empresa_id = current_setting(
                'app.current_empresa_id',
                true
            )::BIGINT
        )
        WITH CHECK (
            empresa_id = current_setting(
                'app.current_empresa_id',
                true
            )::BIGINT
        );
        """
    )


def downgrade() -> None:

    op.execute(
        """
        DROP POLICY IF EXISTS rls_mae_prospectos_empresa
        ON clientes.mae_prospectos;
        """
    )

    op.execute(
        """
        ALTER TABLE clientes.mae_prospectos
        DISABLE ROW LEVEL SECURITY;
        """
    )

    op.drop_index(
        "ix_mae_prospectos_empresa_gps",
        table_name="mae_prospectos",
        schema="clientes",
    )

    op.drop_index(
        "ix_mae_prospectos_empresa_captador",
        table_name="mae_prospectos",
        schema="clientes",
    )

    op.drop_index(
        "ix_mae_prospectos_empresa_estado",
        table_name="mae_prospectos",
        schema="clientes",
    )

    op.drop_index(
        "ix_mae_prospectos_empresa_activo",
        table_name="mae_prospectos",
        schema="clientes",
    )

    op.drop_table(
        "mae_prospectos",
        schema="clientes",
    )