Docs SalesMind
08
Modelo de Datos — SalesMind
Schema PostgreSQL sl_*, relaciones con sm_*, índices críticos
Versión
v1.0
Fecha
Mayo 2026
Audiencia
Backend Engineering · DBA
Estado
DRAFT
SALESMIND
El cerebro y las manos de tu venta.
Módulo #2 — Cheryx Suite

[CONSEJO — Doc 08] Contrarian: El schema de SalesMind debe ser diseñado asumiendo que StockMind ya existe y tiene su propio schema bien establecido. No duplicar tablas de StockMind en el schema sl_ — usar FKs cruzadas y eventos para mantener consistencia. First Principles: Una venta en POS involucra: cliente, productos, precios al momento de la venta, cajero, medio de pago. Todos esos elementos deben estar inmutablemente registrados. La auditabilidad de cada venta es tan importante como la venta misma. Executor*: El índice más crítico del sistema es sobre (tenant_id, sale_date) en sl_sales — se ejecuta en cada carga del dashboard de ventas del día. Sin ese índice, el dashboard se torna lento con 50k ventas.


1. Convenciones

Prefijo sl_ para todas las tablas SalesMind. Convenciones de tipos, UUIDs, timestamps y soft-delete idénticas a StockMind (ver StockMind Doc 08 §1). Las tablas SalesMind coexisten en el mismo PostgreSQL que StockMind, en el mismo schema público.


2. Tablas principales

2.1 sl_customers — Clientes / Cuentas CRM

CREATE TABLE sl_customers (
    id              UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    tenant_id       UUID NOT NULL REFERENCES sm_tenants(id),
    name            VARCHAR(500) NOT NULL,
    code            VARCHAR(100),
    type            TEXT CHECK (type IN ('person', 'business')),
    email           VARCHAR(320),
    phone           VARCHAR(50),
    address         TEXT,
    sector          VARCHAR(100),   -- ferretería, distribuidora, etc.
    credit_limit    NUMERIC(15, 4) DEFAULT 0,
    payment_terms   INTEGER DEFAULT 0,   -- días crédito
    -- Métricas calculadas por el motor
    ltv             NUMERIC(15, 4),
    churn_risk      NUMERIC(4, 4),   -- 0.0 a 1.0
    last_purchase_at TIMESTAMPTZ,
    purchase_count  INTEGER DEFAULT 0,
    -- Integración
    external_id     VARCHAR(255),    -- ID en Alegra / sistema del cliente
    metadata        JSONB DEFAULT '{}',
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    deleted_at      TIMESTAMPTZ,
    UNIQUE (tenant_id, code)
);

CREATE INDEX idx_sl_customers_tenant ON sl_customers(tenant_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_sl_customers_churn ON sl_customers(tenant_id, churn_risk DESC) WHERE deleted_at IS NULL;

2.2 sl_sales — Cabecera de ventas (POS)

CREATE TABLE sl_sales (
    id              UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    tenant_id       UUID NOT NULL REFERENCES sm_tenants(id),
    customer_id     UUID REFERENCES sl_customers(id),
    cashier_id      UUID REFERENCES sm_users(id),

    -- Totales (inmutables post-cierre)
    subtotal        NUMERIC(15, 4) NOT NULL,
    tax_amount      NUMERIC(15, 4) NOT NULL DEFAULT 0,
    discount_amount NUMERIC(15, 4) NOT NULL DEFAULT 0,
    total           NUMERIC(15, 4) NOT NULL,
    currency_code   CHAR(3) NOT NULL DEFAULT 'CRC',

    -- Pago
    payment_method  TEXT NOT NULL CHECK (payment_method IN (
                        'cash', 'card', 'sinpe', 'transfer', 'credit', 'mixed'
                    )),
    payment_status  TEXT NOT NULL DEFAULT 'paid' CHECK (payment_status IN (
                        'paid', 'pending', 'partial', 'refunded'
                    )),

    -- Estado
    status          TEXT NOT NULL DEFAULT 'completed' CHECK (status IN (
                        'completed', 'void', 'refunded'
                    )),

    -- Fechas
    sale_date       DATE NOT NULL DEFAULT CURRENT_DATE,
    sale_time       TIMESTAMPTZ NOT NULL DEFAULT now(),

    -- Sync con StockMind
    stockmind_synced_at TIMESTAMPTZ,  -- cuando los movimientos se confirmaron en StockMind

    -- Facturación electrónica (cuando se integre)
    fe_number       VARCHAR(50),   -- número de comprobante FE
    fe_xml          TEXT,          -- XML FE completo (comprimido)

    -- Origen (online vs offline)
    origin          TEXT NOT NULL DEFAULT 'pos' CHECK (origin IN ('pos', 'offline_sync', 'api')),
    offline_id      VARCHAR(255),  -- ID local del IndexedDB en modo offline

    notes           TEXT,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
    -- No updated_at: las ventas son inmutables. Si hay error, crear void o refund.
);

CREATE INDEX idx_sl_sales_tenant_date ON sl_sales(tenant_id, sale_date DESC);
CREATE INDEX idx_sl_sales_customer ON sl_sales(customer_id);
CREATE INDEX idx_sl_sales_status ON sl_sales(tenant_id, status);
CREATE INDEX idx_sl_sales_sync ON sl_sales(tenant_id) WHERE stockmind_synced_at IS NULL;

2.3 sl_sale_items — Líneas de venta

CREATE TABLE sl_sale_items (
    id              UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    sale_id         UUID NOT NULL REFERENCES sl_sales(id),
    tenant_id       UUID NOT NULL REFERENCES sm_tenants(id),
    sku_id          UUID NOT NULL REFERENCES sm_skus(id),

    -- Datos congelados al momento de la venta (inmutables)
    sku_code        VARCHAR(100) NOT NULL,   -- congelado (no FK actualizable)
    sku_name        VARCHAR(500) NOT NULL,   -- congelado
    quantity        NUMERIC(15, 4) NOT NULL,
    price_at_sale   NUMERIC(15, 4) NOT NULL,  -- precio final cobrado
    list_price      NUMERIC(15, 4),           -- precio de lista antes de descuento
    discount_pct    NUMERIC(5, 4) DEFAULT 0,  -- % de descuento aplicado
    unit_cost       NUMERIC(15, 4),           -- costo al momento de la venta
    line_total      NUMERIC(15, 4) NOT NULL,

    -- Pricing motor: ¿era precio sugerido por el motor?
    motor_price_used BOOLEAN DEFAULT FALSE,

    created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_sl_sale_items_sale ON sl_sale_items(sale_id);
CREATE INDEX idx_sl_sale_items_sku ON sl_sale_items(sku_id, created_at DESC);

2.4 sl_leads — Oportunidades CRM

CREATE TABLE sl_leads (
    id              UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    tenant_id       UUID NOT NULL REFERENCES sm_tenants(id),
    customer_id     UUID REFERENCES sl_customers(id),
    rep_id          UUID REFERENCES sm_users(id),

    title           VARCHAR(500) NOT NULL,
    stage           TEXT NOT NULL DEFAULT 'prospect' CHECK (stage IN (
                        'prospect', 'contacted', 'demo', 'proposal', 'closed_won', 'closed_lost'
                    )),
    value           NUMERIC(15, 4),   -- MRR estimado si cierra
    probability     NUMERIC(4, 4),    -- 0.0 a 1.0 (del motor)

    -- Scoring del motor
    score           NUMERIC(4, 4),    -- 0.0 a 1.0 (propensity-to-buy)
    score_features  JSONB DEFAULT '{}',  -- features usados en el scoring
    score_updated_at TIMESTAMPTZ,

    expected_close_date DATE,
    lost_reason     TEXT,
    notes           TEXT,

    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    deleted_at      TIMESTAMPTZ
);

CREATE INDEX idx_sl_leads_tenant_stage ON sl_leads(tenant_id, stage) WHERE deleted_at IS NULL;
CREATE INDEX idx_sl_leads_rep ON sl_leads(rep_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_sl_leads_score ON sl_leads(tenant_id, score DESC) WHERE deleted_at IS NULL;

2.5 sl_pricing_suggestions — Sugerencias de precio del motor

CREATE TABLE sl_pricing_suggestions (
    id              UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    tenant_id       UUID NOT NULL REFERENCES sm_tenants(id),
    sku_id          UUID NOT NULL REFERENCES sm_skus(id),
    run_date        DATE NOT NULL DEFAULT CURRENT_DATE,

    current_price   NUMERIC(15, 4) NOT NULL,
    suggested_price NUMERIC(15, 4) NOT NULL,
    price_change_pct NUMERIC(6, 4),     -- % de cambio sugerido
    confidence      NUMERIC(4, 4),      -- 0.0 a 1.0

    reason          JSONB NOT NULL DEFAULT '{}',  -- features que explican la sugerencia
    explanation_html TEXT,

    status          TEXT NOT NULL DEFAULT 'pending' CHECK (status IN (
                        'pending', 'approved', 'rejected'
                    )),
    resolved_at     TIMESTAMPTZ,
    resolved_by     UUID REFERENCES sm_users(id),

    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE (tenant_id, sku_id, run_date)
);

CREATE INDEX idx_sl_pricing_pending ON sl_pricing_suggestions(tenant_id, run_date)
    WHERE status = 'pending';

2.6 sl_reps — Representantes comerciales

CREATE TABLE sl_reps (
    id              UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    tenant_id       UUID NOT NULL REFERENCES sm_tenants(id),
    user_id         UUID NOT NULL REFERENCES sm_users(id),
    quota_monthly   NUMERIC(15, 4),   -- cuota de ventas mensual
    commission_rate NUMERIC(5, 4),    -- % de comisión base
    territory       TEXT,             -- descripción del territorio/zona
    is_active       BOOLEAN NOT NULL DEFAULT TRUE,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE (tenant_id, user_id)
);

3. Relaciones clave entre schemas

sm_tenants    ←── sl_customers (tenant_id)
sm_tenants    ←── sl_sales (tenant_id)
sm_tenants    ←── sl_leads (tenant_id)
sm_skus       ←── sl_sale_items (sku_id)    ← FK cruzada StockMind → SalesMind
sm_skus       ←── sl_pricing_suggestions (sku_id)
sm_users      ←── sl_leads (rep_id)
sm_users      ←── sl_sales (cashier_id)
sl_sales      ←── sl_sale_items (sale_id)
sl_customers  ←── sl_leads (customer_id)

Las FK cruzadas entre schemas sm_* y sl_* son intencionales y permitidas — están en el mismo database PostgreSQL y el mismo proceso de aplicación.


4. Queries críticas

4.1 Ventas del día por tenant (dashboard)

SELECT
    COUNT(*) AS total_sales,
    SUM(total) AS total_revenue,
    AVG(total) AS avg_ticket
FROM sl_sales
WHERE tenant_id = :tenant_id
    AND sale_date = CURRENT_DATE
    AND status = 'completed';

4.2 Top SKUs por revenue (últimos 30 días)

SELECT
    si.sku_id,
    si.sku_name,
    SUM(si.line_total) AS total_revenue,
    SUM(si.quantity) AS total_units
FROM sl_sale_items si
JOIN sl_sales s ON s.id = si.sale_id
WHERE si.tenant_id = :tenant_id
    AND s.sale_date >= CURRENT_DATE - INTERVAL '30 days'
    AND s.status = 'completed'
GROUP BY si.sku_id, si.sku_name
ORDER BY total_revenue DESC
LIMIT 10;

Ver también: Doc 07 (SRD Backend — queries ejecutadas en estos índices) · Doc 09 (Motor Core — cómo lee sl_sales para entrenar el motor de elasticidad) · Doc 08 StockMind (schema sm_ con el que se relaciona este schema)*