[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)*