Migración desde erp-core/database - Estándar multi-repo v2

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rckrdmrd 2026-01-16 08:10:45 -06:00
parent 7e8b72d47f
commit ad24cc2f10
29 changed files with 11597 additions and 0 deletions

271
ddl/01-auth-profiles.sql Normal file
View File

@ -0,0 +1,271 @@
-- =============================================================
-- ARCHIVO: 01-auth-profiles.sql
-- DESCRIPCION: Perfiles de usuario, herramientas y personas responsables
-- VERSION: 1.0.0
-- PROYECTO: ERP-Core V2
-- FECHA: 2026-01-10
-- =============================================================
-- =====================
-- SCHEMA: auth (si no existe)
-- =====================
CREATE SCHEMA IF NOT EXISTS auth;
-- =====================
-- TABLA: persons
-- Personas fisicas responsables de cuentas (Persona Fisica/Moral)
-- =====================
CREATE TABLE IF NOT EXISTS auth.persons (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Datos personales
full_name VARCHAR(200) NOT NULL,
first_name VARCHAR(100),
last_name VARCHAR(100),
maternal_name VARCHAR(100),
-- Contacto
email VARCHAR(255) NOT NULL,
phone VARCHAR(20),
mobile_phone VARCHAR(20),
-- Identificacion oficial
identification_type VARCHAR(50), -- INE, pasaporte, cedula_profesional
identification_number VARCHAR(50),
identification_expiry DATE,
-- Direccion
address JSONB DEFAULT '{}',
-- Metadata
is_verified BOOLEAN DEFAULT FALSE,
verified_at TIMESTAMPTZ,
verified_by UUID,
is_responsible_for_tenant BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMPTZ
);
-- Indices para persons
CREATE INDEX IF NOT EXISTS idx_persons_email ON auth.persons(email);
CREATE INDEX IF NOT EXISTS idx_persons_identification ON auth.persons(identification_type, identification_number);
-- =====================
-- TABLA: user_profiles
-- Perfiles de usuario del sistema (ADM, CNT, VNT, etc.)
-- =====================
CREATE TABLE IF NOT EXISTS auth.user_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES auth.tenants(id) ON DELETE CASCADE,
code VARCHAR(10) NOT NULL,
name VARCHAR(100) NOT NULL,
description TEXT,
is_system BOOLEAN DEFAULT FALSE,
color VARCHAR(20),
icon VARCHAR(50),
-- Permisos base
base_permissions JSONB DEFAULT '[]',
available_modules TEXT[] DEFAULT '{}',
-- Precios y plataformas
monthly_price DECIMAL(10,2) DEFAULT 0,
included_platforms TEXT[] DEFAULT '{web}',
-- Configuracion de herramientas
default_tools TEXT[] DEFAULT '{}',
-- Feature flags especificos del perfil
feature_flags JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
UNIQUE(tenant_id, code)
);
-- Indices para user_profiles
CREATE INDEX IF NOT EXISTS idx_user_profiles_tenant ON auth.user_profiles(tenant_id);
CREATE INDEX IF NOT EXISTS idx_user_profiles_code ON auth.user_profiles(code);
-- =====================
-- TABLA: profile_tools
-- Herramientas disponibles por perfil
-- =====================
CREATE TABLE IF NOT EXISTS auth.profile_tools (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
profile_id UUID NOT NULL REFERENCES auth.user_profiles(id) ON DELETE CASCADE,
tool_code VARCHAR(50) NOT NULL,
tool_name VARCHAR(100) NOT NULL,
description TEXT,
category VARCHAR(50),
is_mobile_only BOOLEAN DEFAULT FALSE,
is_web_only BOOLEAN DEFAULT FALSE,
icon VARCHAR(50),
configuration JSONB DEFAULT '{}',
sort_order INTEGER DEFAULT 0,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE(profile_id, tool_code)
);
-- Indices para profile_tools
CREATE INDEX IF NOT EXISTS idx_profile_tools_profile ON auth.profile_tools(profile_id);
CREATE INDEX IF NOT EXISTS idx_profile_tools_code ON auth.profile_tools(tool_code);
-- =====================
-- TABLA: profile_modules
-- Modulos accesibles por perfil
-- =====================
CREATE TABLE IF NOT EXISTS auth.profile_modules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
profile_id UUID NOT NULL REFERENCES auth.user_profiles(id) ON DELETE CASCADE,
module_code VARCHAR(50) NOT NULL,
access_level VARCHAR(20) NOT NULL DEFAULT 'read', -- read, write, admin
can_export BOOLEAN DEFAULT FALSE,
can_print BOOLEAN DEFAULT TRUE,
UNIQUE(profile_id, module_code)
);
-- Indices para profile_modules
CREATE INDEX IF NOT EXISTS idx_profile_modules_profile ON auth.profile_modules(profile_id);
-- =====================
-- TABLA: user_profile_assignments
-- Asignacion de perfiles a usuarios
-- =====================
CREATE TABLE IF NOT EXISTS auth.user_profile_assignments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
profile_id UUID NOT NULL REFERENCES auth.user_profiles(id) ON DELETE CASCADE,
is_primary BOOLEAN DEFAULT FALSE,
assigned_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
assigned_by UUID REFERENCES auth.users(id),
expires_at TIMESTAMPTZ,
UNIQUE(user_id, profile_id)
);
-- Indices para user_profile_assignments
CREATE INDEX IF NOT EXISTS idx_user_profile_assignments_user ON auth.user_profile_assignments(user_id);
CREATE INDEX IF NOT EXISTS idx_user_profile_assignments_profile ON auth.user_profile_assignments(profile_id);
-- =====================
-- RLS POLICIES
-- =====================
ALTER TABLE auth.user_profiles ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_profiles ON auth.user_profiles
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid OR tenant_id IS NULL);
ALTER TABLE auth.profile_tools ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_profile_tools ON auth.profile_tools
USING (profile_id IN (
SELECT id FROM auth.user_profiles
WHERE tenant_id = current_setting('app.current_tenant_id', true)::uuid OR tenant_id IS NULL
));
ALTER TABLE auth.profile_modules ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_profile_modules ON auth.profile_modules
USING (profile_id IN (
SELECT id FROM auth.user_profiles
WHERE tenant_id = current_setting('app.current_tenant_id', true)::uuid OR tenant_id IS NULL
));
-- =====================
-- SEED DATA: Perfiles del Sistema
-- =====================
INSERT INTO auth.user_profiles (id, tenant_id, code, name, description, is_system, monthly_price, included_platforms, available_modules, icon, color) VALUES
('00000000-0000-0000-0000-000000000001', NULL, 'ADM', 'Administrador', 'Control total del sistema', TRUE, 500, '{web,mobile,desktop}', '{all}', 'shield', '#dc2626'),
('00000000-0000-0000-0000-000000000002', NULL, 'CNT', 'Contabilidad', 'Operaciones contables y fiscales', TRUE, 350, '{web}', '{financial,reports,partners,audit}', 'calculator', '#059669'),
('00000000-0000-0000-0000-000000000003', NULL, 'VNT', 'Ventas', 'Punto de venta y CRM', TRUE, 250, '{web,mobile}', '{sales,crm,inventory,partners,reports}', 'shopping-cart', '#2563eb'),
('00000000-0000-0000-0000-000000000004', NULL, 'CMP', 'Compras', 'Gestion de proveedores y compras', TRUE, 200, '{web}', '{purchases,inventory,partners}', 'truck', '#7c3aed'),
('00000000-0000-0000-0000-000000000005', NULL, 'ALM', 'Almacen', 'Inventario y logistica', TRUE, 150, '{mobile}', '{inventory}', 'package', '#ea580c'),
('00000000-0000-0000-0000-000000000006', NULL, 'HRH', 'Recursos Humanos', 'Gestion de personal', TRUE, 300, '{web}', '{hr,partners,reports}', 'users', '#db2777'),
('00000000-0000-0000-0000-000000000007', NULL, 'PRD', 'Produccion', 'Manufactura y proyectos', TRUE, 200, '{web,mobile}', '{projects,inventory}', 'factory', '#ca8a04'),
('00000000-0000-0000-0000-000000000008', NULL, 'EMP', 'Empleado', 'Acceso self-service basico', TRUE, 50, '{mobile}', '{hr}', 'user', '#64748b'),
('00000000-0000-0000-0000-000000000009', NULL, 'GER', 'Gerente', 'Reportes y dashboards ejecutivos', TRUE, 400, '{web,mobile}', '{reports,dashboards,financial,sales,inventory}', 'bar-chart', '#0891b2'),
('00000000-0000-0000-0000-00000000000A', NULL, 'AUD', 'Auditor', 'Acceso de solo lectura para auditorias', TRUE, 150, '{web}', '{audit,reports,financial}', 'search', '#4b5563')
ON CONFLICT DO NOTHING;
-- =====================
-- SEED DATA: Herramientas por Perfil
-- =====================
-- Herramientas para CONTABILIDAD (CNT)
INSERT INTO auth.profile_tools (profile_id, tool_code, tool_name, description, category, is_web_only, icon, sort_order) VALUES
('00000000-0000-0000-0000-000000000002', 'calculadora_fiscal', 'Calculadora Fiscal', 'Calculo de impuestos y retenciones', 'fiscal', TRUE, 'calculator', 1),
('00000000-0000-0000-0000-000000000002', 'generador_cfdi', 'Generador CFDI', 'Generacion de comprobantes fiscales', 'fiscal', TRUE, 'file-text', 2),
('00000000-0000-0000-0000-000000000002', 'conciliacion_bancaria', 'Conciliacion Bancaria', 'Conciliar movimientos bancarios', 'contabilidad', TRUE, 'git-merge', 3),
('00000000-0000-0000-0000-000000000002', 'reportes_sat', 'Reportes SAT', 'Generacion de reportes para SAT', 'fiscal', TRUE, 'file-spreadsheet', 4),
('00000000-0000-0000-0000-000000000002', 'balance_general', 'Balance General', 'Generacion de balance general', 'contabilidad', TRUE, 'scale', 5),
('00000000-0000-0000-0000-000000000002', 'estado_resultados', 'Estado de Resultados', 'Generacion de estado de resultados', 'contabilidad', TRUE, 'trending-up', 6)
ON CONFLICT DO NOTHING;
-- Herramientas para VENTAS (VNT)
INSERT INTO auth.profile_tools (profile_id, tool_code, tool_name, description, category, is_mobile_only, icon, sort_order) VALUES
('00000000-0000-0000-0000-000000000003', 'pos_movil', 'POS Movil', 'Punto de venta en dispositivo movil', 'ventas', TRUE, 'smartphone', 1),
('00000000-0000-0000-0000-000000000003', 'cotizador_rapido', 'Cotizador Rapido', 'Generar cotizaciones rapidamente', 'ventas', FALSE, 'file-plus', 2),
('00000000-0000-0000-0000-000000000003', 'catalogo_productos', 'Catalogo de Productos', 'Consultar catalogo con precios', 'ventas', FALSE, 'book-open', 3),
('00000000-0000-0000-0000-000000000003', 'terminal_pago', 'Terminal de Pago', 'Cobrar con terminal Clip/MercadoPago', 'ventas', TRUE, 'credit-card', 4),
('00000000-0000-0000-0000-000000000003', 'registro_visitas', 'Registro de Visitas', 'Registrar visitas a clientes con GPS', 'crm', TRUE, 'map-pin', 5)
ON CONFLICT DO NOTHING;
-- Herramientas para ALMACEN (ALM)
INSERT INTO auth.profile_tools (profile_id, tool_code, tool_name, description, category, is_mobile_only, icon, sort_order) VALUES
('00000000-0000-0000-0000-000000000005', 'escaner_barcode', 'Escaner Codigo de Barras', 'Escanear productos por codigo de barras', 'inventario', TRUE, 'scan-line', 1),
('00000000-0000-0000-0000-000000000005', 'escaner_qr', 'Escaner QR', 'Escanear codigos QR', 'inventario', TRUE, 'qr-code', 2),
('00000000-0000-0000-0000-000000000005', 'conteo_fisico', 'Conteo Fisico', 'Realizar conteos de inventario', 'inventario', TRUE, 'clipboard-list', 3),
('00000000-0000-0000-0000-000000000005', 'recepcion_mercancia', 'Recepcion de Mercancia', 'Registrar recepciones de compras', 'inventario', TRUE, 'package-check', 4),
('00000000-0000-0000-0000-000000000005', 'transferencias', 'Transferencias', 'Transferir entre ubicaciones', 'inventario', TRUE, 'repeat', 5),
('00000000-0000-0000-0000-000000000005', 'etiquetado', 'Etiquetado', 'Imprimir etiquetas de productos', 'inventario', FALSE, 'tag', 6)
ON CONFLICT DO NOTHING;
-- Herramientas para RRHH (HRH)
INSERT INTO auth.profile_tools (profile_id, tool_code, tool_name, description, category, icon, sort_order) VALUES
('00000000-0000-0000-0000-000000000006', 'reloj_checador', 'Reloj Checador', 'Control de asistencia con biometrico', 'asistencia', 'clock', 1),
('00000000-0000-0000-0000-000000000006', 'control_asistencia', 'Control de Asistencia', 'Reportes de asistencia', 'asistencia', 'calendar-check', 2),
('00000000-0000-0000-0000-000000000006', 'nomina', 'Nomina', 'Gestion de nomina', 'nomina', 'dollar-sign', 3),
('00000000-0000-0000-0000-000000000006', 'expedientes', 'Expedientes', 'Gestion de expedientes de empleados', 'personal', 'folder', 4),
('00000000-0000-0000-0000-000000000006', 'vacaciones_permisos', 'Vacaciones y Permisos', 'Gestion de ausencias', 'personal', 'calendar-x', 5),
('00000000-0000-0000-0000-000000000006', 'organigrama', 'Organigrama', 'Visualizar estructura organizacional', 'personal', 'git-branch', 6)
ON CONFLICT DO NOTHING;
-- Herramientas para EMPLEADO (EMP)
INSERT INTO auth.profile_tools (profile_id, tool_code, tool_name, description, category, is_mobile_only, icon, sort_order) VALUES
('00000000-0000-0000-0000-000000000008', 'checada_entrada', 'Checada Entrada/Salida', 'Registrar entrada y salida con GPS y biometrico', 'asistencia', TRUE, 'log-in', 1),
('00000000-0000-0000-0000-000000000008', 'mis_recibos', 'Mis Recibos de Nomina', 'Consultar recibos de nomina', 'nomina', TRUE, 'file-text', 2),
('00000000-0000-0000-0000-000000000008', 'solicitar_permiso', 'Solicitar Permiso', 'Solicitar permisos o vacaciones', 'personal', TRUE, 'calendar-plus', 3),
('00000000-0000-0000-0000-000000000008', 'mi_horario', 'Mi Horario', 'Consultar mi horario asignado', 'asistencia', TRUE, 'clock', 4)
ON CONFLICT DO NOTHING;
-- Herramientas para GERENTE (GER)
INSERT INTO auth.profile_tools (profile_id, tool_code, tool_name, description, category, icon, sort_order) VALUES
('00000000-0000-0000-0000-000000000009', 'dashboard_ejecutivo', 'Dashboard Ejecutivo', 'Vista general de KPIs del negocio', 'reportes', 'layout-dashboard', 1),
('00000000-0000-0000-0000-000000000009', 'reportes_ventas', 'Reportes de Ventas', 'Analisis de ventas y tendencias', 'reportes', 'trending-up', 2),
('00000000-0000-0000-0000-000000000009', 'reportes_financieros', 'Reportes Financieros', 'Estados financieros resumidos', 'reportes', 'pie-chart', 3),
('00000000-0000-0000-0000-000000000009', 'alertas_negocio', 'Alertas de Negocio', 'Notificaciones de eventos importantes', 'alertas', 'bell', 4)
ON CONFLICT DO NOTHING;
-- Herramientas para AUDITOR (AUD)
INSERT INTO auth.profile_tools (profile_id, tool_code, tool_name, description, category, is_web_only, icon, sort_order) VALUES
('00000000-0000-0000-0000-00000000000A', 'visor_auditoria', 'Visor de Auditoria', 'Consultar logs de auditoria', 'auditoria', TRUE, 'search', 1),
('00000000-0000-0000-0000-00000000000A', 'exportador_datos', 'Exportador de Datos', 'Exportar datos para analisis', 'auditoria', TRUE, 'download', 2),
('00000000-0000-0000-0000-00000000000A', 'comparador_periodos', 'Comparador de Periodos', 'Comparar datos entre periodos', 'auditoria', TRUE, 'git-compare', 3)
ON CONFLICT DO NOTHING;
-- =====================
-- COMENTARIOS DE TABLAS
-- =====================
COMMENT ON TABLE auth.persons IS 'Personas fisicas responsables de cuentas (representante legal de Persona Moral o titular de Persona Fisica)';
COMMENT ON TABLE auth.user_profiles IS 'Perfiles de usuario del sistema con precios y configuraciones';
COMMENT ON TABLE auth.profile_tools IS 'Herramientas disponibles para cada perfil';
COMMENT ON TABLE auth.profile_modules IS 'Modulos del sistema accesibles por perfil';
COMMENT ON TABLE auth.user_profile_assignments IS 'Asignacion de perfiles a usuarios';

252
ddl/02-auth-devices.sql Normal file
View File

@ -0,0 +1,252 @@
-- =============================================================
-- ARCHIVO: 02-auth-devices.sql
-- DESCRIPCION: Dispositivos, credenciales biometricas y sesiones
-- VERSION: 1.0.0
-- PROYECTO: ERP-Core V2
-- FECHA: 2026-01-10
-- =============================================================
-- =====================
-- TABLA: devices
-- Dispositivos registrados por usuario
-- =====================
CREATE TABLE IF NOT EXISTS auth.devices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
tenant_id UUID REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Identificacion del dispositivo
device_uuid VARCHAR(100) NOT NULL,
device_name VARCHAR(100),
device_model VARCHAR(100),
device_brand VARCHAR(50),
-- Plataforma
platform VARCHAR(20) NOT NULL, -- ios, android, web, desktop
platform_version VARCHAR(20),
app_version VARCHAR(20),
-- Estado
is_active BOOLEAN DEFAULT TRUE,
is_trusted BOOLEAN DEFAULT FALSE,
trust_level INTEGER DEFAULT 0, -- 0=none, 1=low, 2=medium, 3=high
-- Biometricos habilitados
biometric_enabled BOOLEAN DEFAULT FALSE,
biometric_type VARCHAR(50), -- fingerprint, face_id, face_recognition
-- Push notifications
push_token TEXT,
push_token_updated_at TIMESTAMPTZ,
-- Ubicacion ultima conocida
last_latitude DECIMAL(10, 8),
last_longitude DECIMAL(11, 8),
last_location_at TIMESTAMPTZ,
-- Seguridad
last_ip_address INET,
last_user_agent TEXT,
-- Registro
first_seen_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
last_seen_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMPTZ,
UNIQUE(user_id, device_uuid)
);
-- Indices para devices
CREATE INDEX IF NOT EXISTS idx_devices_user ON auth.devices(user_id);
CREATE INDEX IF NOT EXISTS idx_devices_tenant ON auth.devices(tenant_id);
CREATE INDEX IF NOT EXISTS idx_devices_uuid ON auth.devices(device_uuid);
CREATE INDEX IF NOT EXISTS idx_devices_platform ON auth.devices(platform);
CREATE INDEX IF NOT EXISTS idx_devices_active ON auth.devices(is_active) WHERE is_active = TRUE;
-- =====================
-- TABLA: biometric_credentials
-- Credenciales biometricas por dispositivo
-- =====================
CREATE TABLE IF NOT EXISTS auth.biometric_credentials (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
device_id UUID NOT NULL REFERENCES auth.devices(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
-- Tipo de biometrico
biometric_type VARCHAR(50) NOT NULL, -- fingerprint, face_id, face_recognition, iris
-- Credencial (public key para WebAuthn/FIDO2)
credential_id TEXT NOT NULL,
public_key TEXT NOT NULL,
algorithm VARCHAR(20) DEFAULT 'ES256',
-- Metadata
credential_name VARCHAR(100), -- "Huella indice derecho", "Face ID iPhone"
is_primary BOOLEAN DEFAULT FALSE,
-- Estado
is_active BOOLEAN DEFAULT TRUE,
last_used_at TIMESTAMPTZ,
use_count INTEGER DEFAULT 0,
-- Seguridad
failed_attempts INTEGER DEFAULT 0,
locked_until TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMPTZ,
UNIQUE(device_id, credential_id)
);
-- Indices para biometric_credentials
CREATE INDEX IF NOT EXISTS idx_biometric_credentials_device ON auth.biometric_credentials(device_id);
CREATE INDEX IF NOT EXISTS idx_biometric_credentials_user ON auth.biometric_credentials(user_id);
CREATE INDEX IF NOT EXISTS idx_biometric_credentials_type ON auth.biometric_credentials(biometric_type);
-- =====================
-- TABLA: device_sessions
-- Sesiones activas por dispositivo
-- =====================
CREATE TABLE IF NOT EXISTS auth.device_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
device_id UUID NOT NULL REFERENCES auth.devices(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
tenant_id UUID REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Tokens
access_token_hash VARCHAR(255) NOT NULL,
refresh_token_hash VARCHAR(255),
-- Metodo de autenticacion
auth_method VARCHAR(50) NOT NULL, -- password, biometric, oauth, mfa
-- Validez
issued_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMPTZ NOT NULL,
refresh_expires_at TIMESTAMPTZ,
-- Estado
is_active BOOLEAN DEFAULT TRUE,
revoked_at TIMESTAMPTZ,
revoked_reason VARCHAR(100),
-- Ubicacion
ip_address INET,
user_agent TEXT,
latitude DECIMAL(10, 8),
longitude DECIMAL(11, 8),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Indices para device_sessions
CREATE INDEX IF NOT EXISTS idx_device_sessions_device ON auth.device_sessions(device_id);
CREATE INDEX IF NOT EXISTS idx_device_sessions_user ON auth.device_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_device_sessions_tenant ON auth.device_sessions(tenant_id);
CREATE INDEX IF NOT EXISTS idx_device_sessions_token ON auth.device_sessions(access_token_hash);
CREATE INDEX IF NOT EXISTS idx_device_sessions_active ON auth.device_sessions(is_active, expires_at) WHERE is_active = TRUE;
-- =====================
-- TABLA: device_activity_log
-- Log de actividad de dispositivos
-- =====================
CREATE TABLE IF NOT EXISTS auth.device_activity_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
device_id UUID NOT NULL REFERENCES auth.devices(id) ON DELETE CASCADE,
user_id UUID REFERENCES auth.users(id),
-- Actividad
activity_type VARCHAR(50) NOT NULL, -- login, logout, biometric_auth, location_update, app_open
activity_status VARCHAR(20) NOT NULL, -- success, failed, blocked
-- Detalles
details JSONB DEFAULT '{}',
-- Ubicacion
ip_address INET,
latitude DECIMAL(10, 8),
longitude DECIMAL(11, 8),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Indices para device_activity_log
CREATE INDEX IF NOT EXISTS idx_device_activity_device ON auth.device_activity_log(device_id);
CREATE INDEX IF NOT EXISTS idx_device_activity_user ON auth.device_activity_log(user_id);
CREATE INDEX IF NOT EXISTS idx_device_activity_type ON auth.device_activity_log(activity_type);
CREATE INDEX IF NOT EXISTS idx_device_activity_created ON auth.device_activity_log(created_at DESC);
-- Particionar por fecha para mejor rendimiento
-- CREATE TABLE auth.device_activity_log_y2026m01 PARTITION OF auth.device_activity_log
-- FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
-- =====================
-- RLS POLICIES
-- =====================
ALTER TABLE auth.devices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_devices ON auth.devices
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
ALTER TABLE auth.biometric_credentials ENABLE ROW LEVEL SECURITY;
CREATE POLICY user_own_biometrics ON auth.biometric_credentials
USING (user_id = current_setting('app.current_user_id', true)::uuid);
ALTER TABLE auth.device_sessions ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_sessions ON auth.device_sessions
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
ALTER TABLE auth.device_activity_log ENABLE ROW LEVEL SECURITY;
CREATE POLICY device_owner_activity ON auth.device_activity_log
USING (device_id IN (
SELECT id FROM auth.devices
WHERE tenant_id = current_setting('app.current_tenant_id', true)::uuid
));
-- =====================
-- FUNCIONES
-- =====================
-- Funcion para actualizar last_seen_at del dispositivo
CREATE OR REPLACE FUNCTION auth.update_device_last_seen()
RETURNS TRIGGER AS $$
BEGIN
UPDATE auth.devices
SET last_seen_at = CURRENT_TIMESTAMP
WHERE id = NEW.device_id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Trigger para actualizar last_seen_at cuando hay actividad
CREATE TRIGGER trg_update_device_last_seen
AFTER INSERT ON auth.device_activity_log
FOR EACH ROW
EXECUTE FUNCTION auth.update_device_last_seen();
-- Funcion para limpiar sesiones expiradas
CREATE OR REPLACE FUNCTION auth.cleanup_expired_sessions()
RETURNS INTEGER AS $$
DECLARE
deleted_count INTEGER;
BEGIN
DELETE FROM auth.device_sessions
WHERE expires_at < CURRENT_TIMESTAMP
AND is_active = FALSE;
GET DIAGNOSTICS deleted_count = ROW_COUNT;
RETURN deleted_count;
END;
$$ LANGUAGE plpgsql;
-- =====================
-- COMENTARIOS DE TABLAS
-- =====================
COMMENT ON TABLE auth.devices IS 'Dispositivos registrados por usuario (moviles, web, desktop)';
COMMENT ON TABLE auth.biometric_credentials IS 'Credenciales biometricas registradas por dispositivo (huella, face ID)';
COMMENT ON TABLE auth.device_sessions IS 'Sesiones activas por dispositivo con tokens';
COMMENT ON TABLE auth.device_activity_log IS 'Log de actividad de dispositivos para auditoria';

366
ddl/03-core-branches.sql Normal file
View File

@ -0,0 +1,366 @@
-- =============================================================
-- ARCHIVO: 03-core-branches.sql
-- DESCRIPCION: Sucursales, jerarquia y asignaciones de usuarios
-- VERSION: 1.0.0
-- PROYECTO: ERP-Core V2
-- FECHA: 2026-01-10
-- =============================================================
-- =====================
-- EXTENSIONES REQUERIDAS
-- =====================
CREATE EXTENSION IF NOT EXISTS cube;
CREATE EXTENSION IF NOT EXISTS earthdistance;
-- =====================
-- SCHEMA: core (si no existe)
-- =====================
CREATE SCHEMA IF NOT EXISTS core;
-- =====================
-- TABLA: branches
-- Sucursales/ubicaciones del negocio
-- =====================
CREATE TABLE IF NOT EXISTS core.branches (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
parent_id UUID REFERENCES core.branches(id) ON DELETE SET NULL,
-- Identificacion
code VARCHAR(20) NOT NULL,
name VARCHAR(100) NOT NULL,
short_name VARCHAR(50),
-- Tipo
branch_type VARCHAR(30) NOT NULL DEFAULT 'store', -- headquarters, regional, store, warehouse, office, factory
-- Contacto
phone VARCHAR(20),
email VARCHAR(255),
manager_id UUID REFERENCES auth.users(id),
-- Direccion
address_line1 VARCHAR(200),
address_line2 VARCHAR(200),
city VARCHAR(100),
state VARCHAR(100),
postal_code VARCHAR(20),
country VARCHAR(3) DEFAULT 'MEX',
-- Geolocalizacion
latitude DECIMAL(10, 8),
longitude DECIMAL(11, 8),
geofence_radius INTEGER DEFAULT 100, -- Radio en metros para validacion de ubicacion
geofence_enabled BOOLEAN DEFAULT TRUE,
-- Configuracion
timezone VARCHAR(50) DEFAULT 'America/Mexico_City',
currency VARCHAR(3) DEFAULT 'MXN',
is_active BOOLEAN DEFAULT TRUE,
is_main BOOLEAN DEFAULT FALSE, -- Sucursal principal/matriz
-- Horarios de operacion
operating_hours JSONB DEFAULT '{}',
-- Ejemplo: {"monday": {"open": "09:00", "close": "18:00"}, ...}
-- Configuraciones especificas
settings JSONB DEFAULT '{}',
-- Ejemplo: {"allow_pos": true, "allow_warehouse": true, ...}
-- Jerarquia (path materializado para consultas eficientes)
hierarchy_path TEXT, -- Ejemplo: /root/regional-norte/sucursal-01
hierarchy_level INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
UNIQUE(tenant_id, code)
);
-- Indices para branches
CREATE INDEX IF NOT EXISTS idx_branches_tenant ON core.branches(tenant_id);
CREATE INDEX IF NOT EXISTS idx_branches_parent ON core.branches(parent_id);
CREATE INDEX IF NOT EXISTS idx_branches_code ON core.branches(code);
CREATE INDEX IF NOT EXISTS idx_branches_type ON core.branches(branch_type);
CREATE INDEX IF NOT EXISTS idx_branches_active ON core.branches(is_active) WHERE is_active = TRUE;
CREATE INDEX IF NOT EXISTS idx_branches_hierarchy ON core.branches(hierarchy_path);
CREATE INDEX IF NOT EXISTS idx_branches_location ON core.branches USING gist (
ll_to_earth(latitude, longitude)
) WHERE latitude IS NOT NULL AND longitude IS NOT NULL;
-- =====================
-- TABLA: user_branch_assignments
-- Asignacion de usuarios a sucursales
-- =====================
CREATE TABLE IF NOT EXISTS core.user_branch_assignments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
branch_id UUID NOT NULL REFERENCES core.branches(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Tipo de asignacion
assignment_type VARCHAR(30) NOT NULL DEFAULT 'primary', -- primary, secondary, temporary, floating
-- Rol en la sucursal
branch_role VARCHAR(50), -- manager, supervisor, staff
-- Permisos especificos
permissions JSONB DEFAULT '[]',
-- Vigencia (para asignaciones temporales)
valid_from TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
valid_until TIMESTAMPTZ,
-- Estado
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, branch_id, assignment_type)
);
-- Indices para user_branch_assignments
CREATE INDEX IF NOT EXISTS idx_user_branch_user ON core.user_branch_assignments(user_id);
CREATE INDEX IF NOT EXISTS idx_user_branch_branch ON core.user_branch_assignments(branch_id);
CREATE INDEX IF NOT EXISTS idx_user_branch_tenant ON core.user_branch_assignments(tenant_id);
CREATE INDEX IF NOT EXISTS idx_user_branch_active ON core.user_branch_assignments(is_active) WHERE is_active = TRUE;
-- =====================
-- TABLA: branch_schedules
-- Horarios de trabajo por sucursal
-- =====================
CREATE TABLE IF NOT EXISTS core.branch_schedules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
branch_id UUID NOT NULL REFERENCES core.branches(id) ON DELETE CASCADE,
-- Identificacion
name VARCHAR(100) NOT NULL,
description TEXT,
-- Tipo
schedule_type VARCHAR(30) NOT NULL DEFAULT 'regular', -- regular, holiday, special
-- Dia de la semana (0=domingo, 1=lunes, ..., 6=sabado) o fecha especifica
day_of_week INTEGER, -- NULL para fechas especificas
specific_date DATE, -- Para dias festivos o especiales
-- Horarios
open_time TIME NOT NULL,
close_time TIME NOT NULL,
-- Turnos (si aplica)
shifts JSONB DEFAULT '[]',
-- Ejemplo: [{"name": "Matutino", "start": "08:00", "end": "14:00"}, ...]
-- Estado
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Indices para branch_schedules
CREATE INDEX IF NOT EXISTS idx_branch_schedules_branch ON core.branch_schedules(branch_id);
CREATE INDEX IF NOT EXISTS idx_branch_schedules_day ON core.branch_schedules(day_of_week);
CREATE INDEX IF NOT EXISTS idx_branch_schedules_date ON core.branch_schedules(specific_date);
-- =====================
-- TABLA: branch_inventory_settings
-- Configuracion de inventario por sucursal
-- =====================
CREATE TABLE IF NOT EXISTS core.branch_inventory_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
branch_id UUID NOT NULL REFERENCES core.branches(id) ON DELETE CASCADE,
-- Almacen asociado
warehouse_id UUID, -- Referencia a inventory.warehouses
-- Configuracion de stock
default_stock_min INTEGER DEFAULT 0,
default_stock_max INTEGER DEFAULT 1000,
auto_reorder_enabled BOOLEAN DEFAULT FALSE,
-- Configuracion de precios
price_list_id UUID, -- Referencia a sales.price_lists
allow_price_override BOOLEAN DEFAULT FALSE,
max_discount_percent DECIMAL(5,2) DEFAULT 0,
-- Configuracion de impuestos
tax_config JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE(branch_id)
);
-- Indices para branch_inventory_settings
CREATE INDEX IF NOT EXISTS idx_branch_inventory_branch ON core.branch_inventory_settings(branch_id);
-- =====================
-- TABLA: branch_payment_terminals
-- Terminales de pago asociadas a sucursal
-- =====================
CREATE TABLE IF NOT EXISTS core.branch_payment_terminals (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
branch_id UUID NOT NULL REFERENCES core.branches(id) ON DELETE CASCADE,
-- Terminal
terminal_provider VARCHAR(30) NOT NULL, -- clip, mercadopago, stripe
terminal_id VARCHAR(100) NOT NULL,
terminal_name VARCHAR(100),
-- Credenciales (encriptadas)
credentials JSONB NOT NULL DEFAULT '{}',
-- Configuracion
is_primary BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE,
-- Limites
daily_limit DECIMAL(12,2),
transaction_limit DECIMAL(12,2),
-- Ultima actividad
last_transaction_at TIMESTAMPTZ,
last_health_check_at TIMESTAMPTZ,
health_status VARCHAR(20) DEFAULT 'unknown', -- healthy, degraded, offline, unknown
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE(branch_id, terminal_provider, terminal_id)
);
-- Indices para branch_payment_terminals
CREATE INDEX IF NOT EXISTS idx_branch_terminals_branch ON core.branch_payment_terminals(branch_id);
CREATE INDEX IF NOT EXISTS idx_branch_terminals_provider ON core.branch_payment_terminals(terminal_provider);
CREATE INDEX IF NOT EXISTS idx_branch_terminals_active ON core.branch_payment_terminals(is_active) WHERE is_active = TRUE;
-- =====================
-- RLS POLICIES
-- =====================
ALTER TABLE core.branches ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_branches ON core.branches
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
ALTER TABLE core.user_branch_assignments ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_branch_assignments ON core.user_branch_assignments
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
ALTER TABLE core.branch_schedules ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_branch_schedules ON core.branch_schedules
USING (branch_id IN (
SELECT id FROM core.branches
WHERE tenant_id = current_setting('app.current_tenant_id', true)::uuid
));
ALTER TABLE core.branch_inventory_settings ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_branch_inventory ON core.branch_inventory_settings
USING (branch_id IN (
SELECT id FROM core.branches
WHERE tenant_id = current_setting('app.current_tenant_id', true)::uuid
));
ALTER TABLE core.branch_payment_terminals ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_branch_terminals ON core.branch_payment_terminals
USING (branch_id IN (
SELECT id FROM core.branches
WHERE tenant_id = current_setting('app.current_tenant_id', true)::uuid
));
-- =====================
-- FUNCIONES
-- =====================
-- Funcion para actualizar hierarchy_path
CREATE OR REPLACE FUNCTION core.update_branch_hierarchy()
RETURNS TRIGGER AS $$
DECLARE
parent_path TEXT;
BEGIN
IF NEW.parent_id IS NULL THEN
NEW.hierarchy_path := '/' || NEW.code;
NEW.hierarchy_level := 0;
ELSE
SELECT hierarchy_path, hierarchy_level + 1
INTO parent_path, NEW.hierarchy_level
FROM core.branches
WHERE id = NEW.parent_id;
NEW.hierarchy_path := parent_path || '/' || NEW.code;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Trigger para actualizar hierarchy_path automaticamente
CREATE TRIGGER trg_update_branch_hierarchy
BEFORE INSERT OR UPDATE OF parent_id, code ON core.branches
FOR EACH ROW
EXECUTE FUNCTION core.update_branch_hierarchy();
-- Funcion para obtener todas las sucursales hijas
CREATE OR REPLACE FUNCTION core.get_branch_children(parent_branch_id UUID)
RETURNS SETOF core.branches AS $$
BEGIN
RETURN QUERY
WITH RECURSIVE branch_tree AS (
SELECT * FROM core.branches WHERE id = parent_branch_id
UNION ALL
SELECT b.* FROM core.branches b
JOIN branch_tree bt ON b.parent_id = bt.id
)
SELECT * FROM branch_tree WHERE id != parent_branch_id;
END;
$$ LANGUAGE plpgsql;
-- Funcion para validar si usuario esta en rango de geofence
CREATE OR REPLACE FUNCTION core.is_within_geofence(
branch_id UUID,
user_lat DECIMAL(10, 8),
user_lon DECIMAL(11, 8)
)
RETURNS BOOLEAN AS $$
DECLARE
branch_record RECORD;
distance_meters FLOAT;
BEGIN
SELECT latitude, longitude, geofence_radius, geofence_enabled
INTO branch_record
FROM core.branches
WHERE id = branch_id;
IF NOT branch_record.geofence_enabled THEN
RETURN TRUE;
END IF;
IF branch_record.latitude IS NULL OR branch_record.longitude IS NULL THEN
RETURN TRUE;
END IF;
-- Calcular distancia usando formula Haversine (aproximada)
distance_meters := 6371000 * acos(
cos(radians(user_lat)) * cos(radians(branch_record.latitude)) *
cos(radians(branch_record.longitude) - radians(user_lon)) +
sin(radians(user_lat)) * sin(radians(branch_record.latitude))
);
RETURN distance_meters <= branch_record.geofence_radius;
END;
$$ LANGUAGE plpgsql;
-- =====================
-- COMENTARIOS DE TABLAS
-- =====================
COMMENT ON TABLE core.branches IS 'Sucursales/ubicaciones del negocio con soporte para jerarquia';
COMMENT ON TABLE core.user_branch_assignments IS 'Asignacion de usuarios a sucursales';
COMMENT ON TABLE core.branch_schedules IS 'Horarios de operacion por sucursal';
COMMENT ON TABLE core.branch_inventory_settings IS 'Configuracion de inventario especifica por sucursal';
COMMENT ON TABLE core.branch_payment_terminals IS 'Terminales de pago asociadas a cada sucursal';

393
ddl/04-mobile.sql Normal file
View File

@ -0,0 +1,393 @@
-- =============================================================
-- ARCHIVO: 04-mobile.sql
-- DESCRIPCION: Sesiones moviles, sincronizacion offline, push tokens
-- VERSION: 1.0.0
-- PROYECTO: ERP-Core V2
-- FECHA: 2026-01-10
-- =============================================================
-- =====================
-- SCHEMA: mobile
-- =====================
CREATE SCHEMA IF NOT EXISTS mobile;
-- =====================
-- TABLA: mobile_sessions
-- Sesiones activas de la aplicacion movil
-- =====================
CREATE TABLE IF NOT EXISTS mobile.mobile_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
device_id UUID NOT NULL REFERENCES auth.devices(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
branch_id UUID REFERENCES core.branches(id),
-- Estado de la sesion
status VARCHAR(20) NOT NULL DEFAULT 'active', -- active, paused, expired, terminated
-- Perfil activo
active_profile_id UUID REFERENCES auth.user_profiles(id),
active_profile_code VARCHAR(10),
-- Modo de operacion
is_offline_mode BOOLEAN DEFAULT FALSE,
offline_since TIMESTAMPTZ,
-- Sincronizacion
last_sync_at TIMESTAMPTZ,
pending_sync_count INTEGER DEFAULT 0,
-- Ubicacion
last_latitude DECIMAL(10, 8),
last_longitude DECIMAL(11, 8),
last_location_at TIMESTAMPTZ,
-- Metadata
app_version VARCHAR(20),
platform VARCHAR(20), -- ios, android
os_version VARCHAR(20),
-- Tiempos
started_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
last_activity_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMPTZ,
ended_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Indices para mobile_sessions
CREATE INDEX IF NOT EXISTS idx_mobile_sessions_user ON mobile.mobile_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_mobile_sessions_device ON mobile.mobile_sessions(device_id);
CREATE INDEX IF NOT EXISTS idx_mobile_sessions_tenant ON mobile.mobile_sessions(tenant_id);
CREATE INDEX IF NOT EXISTS idx_mobile_sessions_branch ON mobile.mobile_sessions(branch_id);
CREATE INDEX IF NOT EXISTS idx_mobile_sessions_active ON mobile.mobile_sessions(status) WHERE status = 'active';
-- =====================
-- TABLA: offline_sync_queue
-- Cola de operaciones pendientes de sincronizar
-- =====================
CREATE TABLE IF NOT EXISTS mobile.offline_sync_queue (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
device_id UUID NOT NULL REFERENCES auth.devices(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
session_id UUID REFERENCES mobile.mobile_sessions(id),
-- Operacion
entity_type VARCHAR(50) NOT NULL, -- sale, attendance, inventory_count, etc.
entity_id UUID, -- ID local del registro
operation VARCHAR(20) NOT NULL, -- create, update, delete
-- Datos
payload JSONB NOT NULL,
metadata JSONB DEFAULT '{}',
-- Orden y dependencias
sequence_number BIGINT NOT NULL,
depends_on UUID, -- ID de otra operacion que debe procesarse primero
-- Estado
status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending, processing, completed, failed, conflict
-- Procesamiento
retry_count INTEGER DEFAULT 0,
max_retries INTEGER DEFAULT 3,
last_error TEXT,
processed_at TIMESTAMPTZ,
-- Conflicto
conflict_data JSONB,
conflict_resolved_at TIMESTAMPTZ,
conflict_resolution VARCHAR(20), -- local_wins, server_wins, merged, manual
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Indices para offline_sync_queue
CREATE INDEX IF NOT EXISTS idx_offline_sync_user ON mobile.offline_sync_queue(user_id);
CREATE INDEX IF NOT EXISTS idx_offline_sync_device ON mobile.offline_sync_queue(device_id);
CREATE INDEX IF NOT EXISTS idx_offline_sync_tenant ON mobile.offline_sync_queue(tenant_id);
CREATE INDEX IF NOT EXISTS idx_offline_sync_status ON mobile.offline_sync_queue(status);
CREATE INDEX IF NOT EXISTS idx_offline_sync_sequence ON mobile.offline_sync_queue(device_id, sequence_number);
CREATE INDEX IF NOT EXISTS idx_offline_sync_pending ON mobile.offline_sync_queue(status, created_at) WHERE status = 'pending';
-- =====================
-- TABLA: sync_conflicts
-- Registro de conflictos de sincronizacion
-- =====================
CREATE TABLE IF NOT EXISTS mobile.sync_conflicts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
sync_queue_id UUID NOT NULL REFERENCES mobile.offline_sync_queue(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES auth.users(id),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id),
-- Tipo de conflicto
conflict_type VARCHAR(30) NOT NULL, -- version_mismatch, deleted_on_server, concurrent_edit
-- Datos en conflicto
local_data JSONB NOT NULL,
server_data JSONB NOT NULL,
-- Resolucion
resolution VARCHAR(20), -- local_wins, server_wins, merged, manual
merged_data JSONB,
resolved_by UUID REFERENCES auth.users(id),
resolved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Indices para sync_conflicts
CREATE INDEX IF NOT EXISTS idx_sync_conflicts_queue ON mobile.sync_conflicts(sync_queue_id);
CREATE INDEX IF NOT EXISTS idx_sync_conflicts_user ON mobile.sync_conflicts(user_id);
CREATE INDEX IF NOT EXISTS idx_sync_conflicts_unresolved ON mobile.sync_conflicts(resolved_at) WHERE resolved_at IS NULL;
-- =====================
-- TABLA: push_tokens
-- Tokens de notificaciones push
-- =====================
CREATE TABLE IF NOT EXISTS mobile.push_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
device_id UUID NOT NULL REFERENCES auth.devices(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Token
token TEXT NOT NULL,
platform VARCHAR(20) NOT NULL, -- ios, android
provider VARCHAR(30) NOT NULL DEFAULT 'firebase', -- firebase, apns, fcm
-- Estado
is_active BOOLEAN DEFAULT TRUE,
is_valid BOOLEAN DEFAULT TRUE,
invalid_reason TEXT,
-- Topics suscritos
subscribed_topics TEXT[] DEFAULT '{}',
-- Ultima actividad
last_used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE(device_id, platform)
);
-- Indices para push_tokens
CREATE INDEX IF NOT EXISTS idx_push_tokens_user ON mobile.push_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_push_tokens_device ON mobile.push_tokens(device_id);
CREATE INDEX IF NOT EXISTS idx_push_tokens_tenant ON mobile.push_tokens(tenant_id);
CREATE INDEX IF NOT EXISTS idx_push_tokens_active ON mobile.push_tokens(is_active, is_valid) WHERE is_active = TRUE AND is_valid = TRUE;
-- =====================
-- TABLA: push_notifications_log
-- Log de notificaciones enviadas
-- =====================
CREATE TABLE IF NOT EXISTS mobile.push_notifications_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Destino
user_id UUID REFERENCES auth.users(id),
device_id UUID REFERENCES auth.devices(id),
push_token_id UUID REFERENCES mobile.push_tokens(id),
-- Notificacion
title VARCHAR(200) NOT NULL,
body TEXT,
data JSONB DEFAULT '{}',
category VARCHAR(50), -- attendance, sale, inventory, alert, system
-- Envio
sent_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
provider_message_id VARCHAR(255),
-- Estado
status VARCHAR(20) NOT NULL DEFAULT 'sent', -- sent, delivered, failed, read
delivered_at TIMESTAMPTZ,
read_at TIMESTAMPTZ,
error_message TEXT,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Indices para push_notifications_log
CREATE INDEX IF NOT EXISTS idx_push_log_tenant ON mobile.push_notifications_log(tenant_id);
CREATE INDEX IF NOT EXISTS idx_push_log_user ON mobile.push_notifications_log(user_id);
CREATE INDEX IF NOT EXISTS idx_push_log_device ON mobile.push_notifications_log(device_id);
CREATE INDEX IF NOT EXISTS idx_push_log_created ON mobile.push_notifications_log(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_push_log_category ON mobile.push_notifications_log(category);
-- =====================
-- TABLA: payment_transactions
-- Transacciones de pago desde terminales moviles
-- =====================
CREATE TABLE IF NOT EXISTS mobile.payment_transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
branch_id UUID REFERENCES core.branches(id),
user_id UUID NOT NULL REFERENCES auth.users(id),
device_id UUID REFERENCES auth.devices(id),
-- Referencia al documento origen
source_type VARCHAR(30) NOT NULL, -- sale, invoice, subscription
source_id UUID NOT NULL,
-- Terminal de pago
terminal_provider VARCHAR(30) NOT NULL, -- clip, mercadopago, stripe
terminal_id VARCHAR(100),
-- Transaccion
external_transaction_id VARCHAR(255),
amount DECIMAL(12,2) NOT NULL,
currency VARCHAR(3) DEFAULT 'MXN',
tip_amount DECIMAL(12,2) DEFAULT 0,
total_amount DECIMAL(12,2) NOT NULL,
-- Metodo de pago
payment_method VARCHAR(30) NOT NULL, -- card, contactless, qr, link
card_brand VARCHAR(20), -- visa, mastercard, amex
card_last_four VARCHAR(4),
card_type VARCHAR(20), -- credit, debit
-- Estado
status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending, processing, completed, failed, refunded, cancelled
failure_reason TEXT,
-- Tiempos
initiated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMPTZ,
-- Metadata del proveedor
provider_response JSONB DEFAULT '{}',
-- Recibo
receipt_url TEXT,
receipt_sent BOOLEAN DEFAULT FALSE,
receipt_sent_to VARCHAR(255),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Indices para payment_transactions
CREATE INDEX IF NOT EXISTS idx_payment_tx_tenant ON mobile.payment_transactions(tenant_id);
CREATE INDEX IF NOT EXISTS idx_payment_tx_branch ON mobile.payment_transactions(branch_id);
CREATE INDEX IF NOT EXISTS idx_payment_tx_user ON mobile.payment_transactions(user_id);
CREATE INDEX IF NOT EXISTS idx_payment_tx_source ON mobile.payment_transactions(source_type, source_id);
CREATE INDEX IF NOT EXISTS idx_payment_tx_external ON mobile.payment_transactions(external_transaction_id);
CREATE INDEX IF NOT EXISTS idx_payment_tx_status ON mobile.payment_transactions(status);
CREATE INDEX IF NOT EXISTS idx_payment_tx_created ON mobile.payment_transactions(created_at DESC);
-- =====================
-- RLS POLICIES
-- =====================
ALTER TABLE mobile.mobile_sessions ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_mobile_sessions ON mobile.mobile_sessions
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
ALTER TABLE mobile.offline_sync_queue ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_sync_queue ON mobile.offline_sync_queue
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
ALTER TABLE mobile.sync_conflicts ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_sync_conflicts ON mobile.sync_conflicts
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
ALTER TABLE mobile.push_tokens ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_push_tokens ON mobile.push_tokens
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
ALTER TABLE mobile.push_notifications_log ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_push_log ON mobile.push_notifications_log
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
ALTER TABLE mobile.payment_transactions ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_payment_tx ON mobile.payment_transactions
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- =====================
-- FUNCIONES
-- =====================
-- Funcion para obtener siguiente numero de secuencia
CREATE OR REPLACE FUNCTION mobile.get_next_sync_sequence(p_device_id UUID)
RETURNS BIGINT AS $$
DECLARE
next_seq BIGINT;
BEGIN
SELECT COALESCE(MAX(sequence_number), 0) + 1
INTO next_seq
FROM mobile.offline_sync_queue
WHERE device_id = p_device_id;
RETURN next_seq;
END;
$$ LANGUAGE plpgsql;
-- Funcion para procesar cola de sincronizacion
CREATE OR REPLACE FUNCTION mobile.process_sync_queue(p_device_id UUID, p_batch_size INTEGER DEFAULT 100)
RETURNS TABLE (
processed_count INTEGER,
failed_count INTEGER,
conflict_count INTEGER
) AS $$
DECLARE
v_processed INTEGER := 0;
v_failed INTEGER := 0;
v_conflicts INTEGER := 0;
BEGIN
-- Marcar items como processing (usando subquery para ORDER BY y LIMIT en PostgreSQL)
UPDATE mobile.offline_sync_queue
SET status = 'processing', updated_at = CURRENT_TIMESTAMP
WHERE id IN (
SELECT osq.id FROM mobile.offline_sync_queue osq
WHERE osq.device_id = p_device_id
AND osq.status = 'pending'
AND (osq.depends_on IS NULL OR osq.depends_on IN (
SELECT id FROM mobile.offline_sync_queue WHERE status = 'completed'
))
ORDER BY osq.sequence_number
LIMIT p_batch_size
);
-- Aqui iria la logica de procesamiento real
-- Por ahora solo retornamos conteos
SELECT COUNT(*) INTO v_processed
FROM mobile.offline_sync_queue
WHERE device_id = p_device_id AND status = 'processing';
RETURN QUERY SELECT v_processed, v_failed, v_conflicts;
END;
$$ LANGUAGE plpgsql;
-- Funcion para limpiar sesiones inactivas
CREATE OR REPLACE FUNCTION mobile.cleanup_inactive_sessions(p_hours INTEGER DEFAULT 24)
RETURNS INTEGER AS $$
DECLARE
cleaned_count INTEGER;
BEGIN
UPDATE mobile.mobile_sessions
SET status = 'expired', ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE status = 'active'
AND last_activity_at < CURRENT_TIMESTAMP - (p_hours || ' hours')::INTERVAL;
GET DIAGNOSTICS cleaned_count = ROW_COUNT;
RETURN cleaned_count;
END;
$$ LANGUAGE plpgsql;
-- =====================
-- COMENTARIOS DE TABLAS
-- =====================
COMMENT ON TABLE mobile.mobile_sessions IS 'Sesiones activas de la aplicacion movil';
COMMENT ON TABLE mobile.offline_sync_queue IS 'Cola de operaciones pendientes de sincronizar desde modo offline';
COMMENT ON TABLE mobile.sync_conflicts IS 'Registro de conflictos de sincronizacion detectados';
COMMENT ON TABLE mobile.push_tokens IS 'Tokens de notificaciones push por dispositivo';
COMMENT ON TABLE mobile.push_notifications_log IS 'Log de notificaciones push enviadas';
COMMENT ON TABLE mobile.payment_transactions IS 'Transacciones de pago desde terminales moviles';

622
ddl/05-billing-usage.sql Normal file
View File

@ -0,0 +1,622 @@
-- =============================================================
-- ARCHIVO: 05-billing-usage.sql
-- DESCRIPCION: Facturacion por uso, tracking de consumo, suscripciones
-- VERSION: 1.0.0
-- PROYECTO: ERP-Core V2
-- FECHA: 2026-01-10
-- =============================================================
-- =====================
-- SCHEMA: billing
-- =====================
CREATE SCHEMA IF NOT EXISTS billing;
-- =====================
-- TABLA: subscription_plans
-- Planes de suscripcion disponibles
-- =====================
CREATE TABLE IF NOT EXISTS billing.subscription_plans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Identificacion
code VARCHAR(30) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
description TEXT,
-- Tipo
plan_type VARCHAR(20) NOT NULL DEFAULT 'saas', -- saas, on_premise, hybrid
-- Precios base
base_monthly_price DECIMAL(12,2) NOT NULL DEFAULT 0,
base_annual_price DECIMAL(12,2), -- Precio anual con descuento
setup_fee DECIMAL(12,2) DEFAULT 0,
-- Limites base
max_users INTEGER DEFAULT 5,
max_branches INTEGER DEFAULT 1,
storage_gb INTEGER DEFAULT 10,
api_calls_monthly INTEGER DEFAULT 10000,
-- Modulos incluidos
included_modules TEXT[] DEFAULT '{}',
-- Plataformas incluidas
included_platforms TEXT[] DEFAULT '{web}',
-- Features
features JSONB DEFAULT '{}',
-- Estado
is_active BOOLEAN DEFAULT TRUE,
is_public BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMPTZ
);
-- =====================
-- TABLA: tenant_subscriptions
-- Suscripciones activas de tenants
-- =====================
CREATE TABLE IF NOT EXISTS billing.tenant_subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
plan_id UUID NOT NULL REFERENCES billing.subscription_plans(id),
-- Periodo
billing_cycle VARCHAR(20) NOT NULL DEFAULT 'monthly', -- monthly, annual
current_period_start TIMESTAMPTZ NOT NULL,
current_period_end TIMESTAMPTZ NOT NULL,
-- Estado
status VARCHAR(20) NOT NULL DEFAULT 'active', -- trial, active, past_due, cancelled, suspended
-- Trial
trial_start TIMESTAMPTZ,
trial_end TIMESTAMPTZ,
-- Configuracion de facturacion
billing_email VARCHAR(255),
billing_name VARCHAR(200),
billing_address JSONB DEFAULT '{}',
tax_id VARCHAR(20), -- RFC para Mexico
-- Metodo de pago
payment_method_id UUID,
payment_provider VARCHAR(30), -- stripe, mercadopago, bank_transfer
-- Precios actuales (pueden diferir del plan por descuentos)
current_price DECIMAL(12,2) NOT NULL,
discount_percent DECIMAL(5,2) DEFAULT 0,
discount_reason VARCHAR(100),
-- Uso contratado
contracted_users INTEGER,
contracted_branches INTEGER,
-- Facturacion automatica
auto_renew BOOLEAN DEFAULT TRUE,
next_invoice_date DATE,
-- Cancelacion
cancel_at_period_end BOOLEAN DEFAULT FALSE,
cancelled_at TIMESTAMPTZ,
cancellation_reason TEXT,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE(tenant_id)
);
-- Indices para tenant_subscriptions
CREATE INDEX IF NOT EXISTS idx_subscriptions_tenant ON billing.tenant_subscriptions(tenant_id);
CREATE INDEX IF NOT EXISTS idx_subscriptions_plan ON billing.tenant_subscriptions(plan_id);
CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON billing.tenant_subscriptions(status);
CREATE INDEX IF NOT EXISTS idx_subscriptions_period_end ON billing.tenant_subscriptions(current_period_end);
-- =====================
-- TABLA: usage_tracking
-- Tracking de uso por tenant
-- =====================
CREATE TABLE IF NOT EXISTS billing.usage_tracking (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Periodo
period_start DATE NOT NULL,
period_end DATE NOT NULL,
-- Usuarios
active_users INTEGER DEFAULT 0,
peak_concurrent_users INTEGER DEFAULT 0,
-- Por perfil
users_by_profile JSONB DEFAULT '{}',
-- Ejemplo: {"ADM": 2, "VNT": 5, "ALM": 3}
-- Por plataforma
users_by_platform JSONB DEFAULT '{}',
-- Ejemplo: {"web": 8, "mobile": 5, "desktop": 0}
-- Sucursales
active_branches INTEGER DEFAULT 0,
-- Storage
storage_used_gb DECIMAL(10,2) DEFAULT 0,
documents_count INTEGER DEFAULT 0,
-- API
api_calls INTEGER DEFAULT 0,
api_errors INTEGER DEFAULT 0,
-- Transacciones
sales_count INTEGER DEFAULT 0,
sales_amount DECIMAL(14,2) DEFAULT 0,
invoices_generated INTEGER DEFAULT 0,
-- Mobile
mobile_sessions INTEGER DEFAULT 0,
offline_syncs INTEGER DEFAULT 0,
payment_transactions INTEGER DEFAULT 0,
-- Calculado
total_billable_amount DECIMAL(12,2) DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE(tenant_id, period_start)
);
-- Indices para usage_tracking
CREATE INDEX IF NOT EXISTS idx_usage_tenant ON billing.usage_tracking(tenant_id);
CREATE INDEX IF NOT EXISTS idx_usage_period ON billing.usage_tracking(period_start, period_end);
-- =====================
-- TABLA: usage_events
-- Eventos de uso en tiempo real (para calculo de billing)
-- =====================
CREATE TABLE IF NOT EXISTS billing.usage_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
user_id UUID REFERENCES auth.users(id),
device_id UUID REFERENCES auth.devices(id),
branch_id UUID REFERENCES core.branches(id),
-- Evento
event_type VARCHAR(50) NOT NULL, -- login, api_call, document_upload, sale, invoice, sync
event_category VARCHAR(30) NOT NULL, -- user, api, storage, transaction, mobile
-- Detalles
profile_code VARCHAR(10),
platform VARCHAR(20),
resource_id UUID,
resource_type VARCHAR(50),
-- Metricas
quantity INTEGER DEFAULT 1,
bytes_used BIGINT DEFAULT 0,
duration_ms INTEGER,
-- Metadata
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Indices para usage_events (particionado por fecha recomendado)
CREATE INDEX IF NOT EXISTS idx_usage_events_tenant ON billing.usage_events(tenant_id);
CREATE INDEX IF NOT EXISTS idx_usage_events_type ON billing.usage_events(event_type);
CREATE INDEX IF NOT EXISTS idx_usage_events_category ON billing.usage_events(event_category);
CREATE INDEX IF NOT EXISTS idx_usage_events_created ON billing.usage_events(created_at DESC);
-- =====================
-- TABLA: invoices
-- Facturas generadas
-- =====================
CREATE TABLE IF NOT EXISTS billing.invoices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
subscription_id UUID REFERENCES billing.tenant_subscriptions(id),
-- Numero de factura
invoice_number VARCHAR(30) NOT NULL UNIQUE,
invoice_date DATE NOT NULL,
-- Periodo facturado
period_start DATE NOT NULL,
period_end DATE NOT NULL,
-- Cliente
billing_name VARCHAR(200),
billing_email VARCHAR(255),
billing_address JSONB DEFAULT '{}',
tax_id VARCHAR(20),
-- Montos
subtotal DECIMAL(12,2) NOT NULL,
tax_amount DECIMAL(12,2) DEFAULT 0,
discount_amount DECIMAL(12,2) DEFAULT 0,
total DECIMAL(12,2) NOT NULL,
currency VARCHAR(3) DEFAULT 'MXN',
-- Estado
status VARCHAR(20) NOT NULL DEFAULT 'draft', -- draft, sent, paid, partial, overdue, void, refunded
-- Fechas de pago
due_date DATE NOT NULL,
paid_at TIMESTAMPTZ,
paid_amount DECIMAL(12,2) DEFAULT 0,
-- Detalles de pago
payment_method VARCHAR(30),
payment_reference VARCHAR(100),
-- CFDI (para Mexico)
cfdi_uuid VARCHAR(36),
cfdi_xml TEXT,
cfdi_pdf_url TEXT,
-- Metadata
notes TEXT,
internal_notes TEXT,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Indices para invoices
CREATE INDEX IF NOT EXISTS idx_invoices_tenant ON billing.invoices(tenant_id);
CREATE INDEX IF NOT EXISTS idx_invoices_subscription ON billing.invoices(subscription_id);
CREATE INDEX IF NOT EXISTS idx_invoices_number ON billing.invoices(invoice_number);
CREATE INDEX IF NOT EXISTS idx_invoices_status ON billing.invoices(status);
CREATE INDEX IF NOT EXISTS idx_invoices_date ON billing.invoices(invoice_date DESC);
CREATE INDEX IF NOT EXISTS idx_invoices_due ON billing.invoices(due_date) WHERE status IN ('sent', 'partial', 'overdue');
-- =====================
-- TABLA: invoice_items
-- Items de cada factura
-- =====================
CREATE TABLE IF NOT EXISTS billing.invoice_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
invoice_id UUID NOT NULL REFERENCES billing.invoices(id) ON DELETE CASCADE,
-- Descripcion
description VARCHAR(500) NOT NULL,
item_type VARCHAR(30) NOT NULL, -- subscription, user, profile, overage, addon
-- Cantidades
quantity INTEGER NOT NULL DEFAULT 1,
unit_price DECIMAL(12,2) NOT NULL,
subtotal DECIMAL(12,2) NOT NULL,
-- Detalles adicionales
profile_code VARCHAR(10), -- Si es cargo por perfil
platform VARCHAR(20), -- Si es cargo por plataforma
period_start DATE,
period_end DATE,
-- Metadata
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Indices para invoice_items
CREATE INDEX IF NOT EXISTS idx_invoice_items_invoice ON billing.invoice_items(invoice_id);
CREATE INDEX IF NOT EXISTS idx_invoice_items_type ON billing.invoice_items(item_type);
-- =====================
-- TABLA: payment_methods
-- Metodos de pago guardados
-- =====================
CREATE TABLE IF NOT EXISTS billing.payment_methods (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Proveedor
provider VARCHAR(30) NOT NULL, -- stripe, mercadopago, bank_transfer
-- Tipo
method_type VARCHAR(20) NOT NULL, -- card, bank_account, wallet
-- Datos (encriptados/tokenizados)
provider_customer_id VARCHAR(255),
provider_method_id VARCHAR(255),
-- Display info (no sensible)
display_name VARCHAR(100),
card_brand VARCHAR(20),
card_last_four VARCHAR(4),
card_exp_month INTEGER,
card_exp_year INTEGER,
bank_name VARCHAR(100),
bank_last_four VARCHAR(4),
-- Estado
is_default BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE,
is_verified BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMPTZ
);
-- Indices para payment_methods
CREATE INDEX IF NOT EXISTS idx_payment_methods_tenant ON billing.payment_methods(tenant_id);
CREATE INDEX IF NOT EXISTS idx_payment_methods_provider ON billing.payment_methods(provider);
CREATE INDEX IF NOT EXISTS idx_payment_methods_default ON billing.payment_methods(is_default) WHERE is_default = TRUE;
-- =====================
-- TABLA: billing_alerts
-- Alertas de facturacion
-- =====================
CREATE TABLE IF NOT EXISTS billing.billing_alerts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Tipo de alerta
alert_type VARCHAR(30) NOT NULL, -- usage_limit, payment_due, payment_failed, trial_ending, subscription_ending
-- Detalles
title VARCHAR(200) NOT NULL,
message TEXT,
severity VARCHAR(20) NOT NULL DEFAULT 'info', -- info, warning, critical
-- Estado
status VARCHAR(20) NOT NULL DEFAULT 'active', -- active, acknowledged, resolved
-- Notificacion
notified_at TIMESTAMPTZ,
acknowledged_at TIMESTAMPTZ,
acknowledged_by UUID REFERENCES auth.users(id),
-- Metadata
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Indices para billing_alerts
CREATE INDEX IF NOT EXISTS idx_billing_alerts_tenant ON billing.billing_alerts(tenant_id);
CREATE INDEX IF NOT EXISTS idx_billing_alerts_type ON billing.billing_alerts(alert_type);
CREATE INDEX IF NOT EXISTS idx_billing_alerts_status ON billing.billing_alerts(status) WHERE status = 'active';
-- =====================
-- RLS POLICIES
-- =====================
ALTER TABLE billing.tenant_subscriptions ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_subscriptions ON billing.tenant_subscriptions
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
ALTER TABLE billing.usage_tracking ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_usage ON billing.usage_tracking
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
ALTER TABLE billing.usage_events ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_usage_events ON billing.usage_events
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
ALTER TABLE billing.invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_invoices ON billing.invoices
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
ALTER TABLE billing.invoice_items ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_invoice_items ON billing.invoice_items
USING (invoice_id IN (
SELECT id FROM billing.invoices
WHERE tenant_id = current_setting('app.current_tenant_id', true)::uuid
));
ALTER TABLE billing.payment_methods ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_payment_methods ON billing.payment_methods
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
ALTER TABLE billing.billing_alerts ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_billing_alerts ON billing.billing_alerts
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- =====================
-- FUNCIONES
-- =====================
-- Funcion para calcular uso mensual de un tenant
CREATE OR REPLACE FUNCTION billing.calculate_monthly_usage(
p_tenant_id UUID,
p_period_start DATE,
p_period_end DATE
)
RETURNS TABLE (
active_users INTEGER,
users_by_profile JSONB,
users_by_platform JSONB,
active_branches INTEGER,
storage_used_gb DECIMAL,
api_calls INTEGER,
total_billable DECIMAL
) AS $$
BEGIN
RETURN QUERY
WITH user_stats AS (
SELECT
COUNT(DISTINCT ue.user_id) as active_users,
jsonb_object_agg(
COALESCE(ue.profile_code, 'unknown'),
COUNT(DISTINCT ue.user_id)
) as by_profile,
jsonb_object_agg(
COALESCE(ue.platform, 'unknown'),
COUNT(DISTINCT ue.user_id)
) as by_platform
FROM billing.usage_events ue
WHERE ue.tenant_id = p_tenant_id
AND ue.created_at >= p_period_start
AND ue.created_at < p_period_end
AND ue.event_category = 'user'
),
branch_stats AS (
SELECT COUNT(DISTINCT branch_id) as active_branches
FROM billing.usage_events
WHERE tenant_id = p_tenant_id
AND created_at >= p_period_start
AND created_at < p_period_end
AND branch_id IS NOT NULL
),
storage_stats AS (
SELECT COALESCE(SUM(bytes_used), 0)::DECIMAL / (1024*1024*1024) as storage_gb
FROM billing.usage_events
WHERE tenant_id = p_tenant_id
AND created_at >= p_period_start
AND created_at < p_period_end
AND event_category = 'storage'
),
api_stats AS (
SELECT COUNT(*) as api_calls
FROM billing.usage_events
WHERE tenant_id = p_tenant_id
AND created_at >= p_period_start
AND created_at < p_period_end
AND event_category = 'api'
)
SELECT
us.active_users::INTEGER,
us.by_profile,
us.by_platform,
bs.active_branches::INTEGER,
ss.storage_gb,
api.api_calls::INTEGER,
0::DECIMAL as total_billable -- Se calcula aparte basado en plan
FROM user_stats us, branch_stats bs, storage_stats ss, api_stats api;
END;
$$ LANGUAGE plpgsql;
-- Funcion para generar numero de factura
CREATE OR REPLACE FUNCTION billing.generate_invoice_number()
RETURNS VARCHAR(30) AS $$
DECLARE
v_year VARCHAR(4);
v_sequence INTEGER;
v_number VARCHAR(30);
BEGIN
v_year := to_char(CURRENT_DATE, 'YYYY');
SELECT COALESCE(MAX(
CAST(SUBSTRING(invoice_number FROM 6 FOR 6) AS INTEGER)
), 0) + 1
INTO v_sequence
FROM billing.invoices
WHERE invoice_number LIKE 'INV-' || v_year || '-%';
v_number := 'INV-' || v_year || '-' || LPAD(v_sequence::TEXT, 6, '0');
RETURN v_number;
END;
$$ LANGUAGE plpgsql;
-- Funcion para verificar limites de uso
CREATE OR REPLACE FUNCTION billing.check_usage_limits(p_tenant_id UUID)
RETURNS TABLE (
limit_type VARCHAR,
current_value INTEGER,
max_value INTEGER,
percentage DECIMAL,
is_exceeded BOOLEAN
) AS $$
BEGIN
RETURN QUERY
WITH subscription AS (
SELECT
ts.contracted_users,
ts.contracted_branches,
sp.storage_gb,
sp.api_calls_monthly
FROM billing.tenant_subscriptions ts
JOIN billing.subscription_plans sp ON sp.id = ts.plan_id
WHERE ts.tenant_id = p_tenant_id
AND ts.status = 'active'
),
current_usage AS (
SELECT
ut.active_users,
ut.active_branches,
ut.storage_used_gb::INTEGER,
ut.api_calls
FROM billing.usage_tracking ut
WHERE ut.tenant_id = p_tenant_id
AND ut.period_start <= CURRENT_DATE
AND ut.period_end >= CURRENT_DATE
)
SELECT
'users'::VARCHAR as limit_type,
COALESCE(cu.active_users, 0) as current_value,
s.contracted_users as max_value,
CASE WHEN s.contracted_users > 0
THEN (COALESCE(cu.active_users, 0)::DECIMAL / s.contracted_users * 100)
ELSE 0 END as percentage,
COALESCE(cu.active_users, 0) > s.contracted_users as is_exceeded
FROM subscription s, current_usage cu
UNION ALL
SELECT
'branches'::VARCHAR,
COALESCE(cu.active_branches, 0),
s.contracted_branches,
CASE WHEN s.contracted_branches > 0
THEN (COALESCE(cu.active_branches, 0)::DECIMAL / s.contracted_branches * 100)
ELSE 0 END,
COALESCE(cu.active_branches, 0) > s.contracted_branches
FROM subscription s, current_usage cu
UNION ALL
SELECT
'storage'::VARCHAR,
COALESCE(cu.storage_used_gb, 0),
s.storage_gb,
CASE WHEN s.storage_gb > 0
THEN (COALESCE(cu.storage_used_gb, 0)::DECIMAL / s.storage_gb * 100)
ELSE 0 END,
COALESCE(cu.storage_used_gb, 0) > s.storage_gb
FROM subscription s, current_usage cu
UNION ALL
SELECT
'api_calls'::VARCHAR,
COALESCE(cu.api_calls, 0),
s.api_calls_monthly,
CASE WHEN s.api_calls_monthly > 0
THEN (COALESCE(cu.api_calls, 0)::DECIMAL / s.api_calls_monthly * 100)
ELSE 0 END,
COALESCE(cu.api_calls, 0) > s.api_calls_monthly
FROM subscription s, current_usage cu;
END;
$$ LANGUAGE plpgsql;
-- =====================
-- SEED DATA: Planes de suscripcion
-- =====================
INSERT INTO billing.subscription_plans (code, name, description, plan_type, base_monthly_price, max_users, max_branches, storage_gb, api_calls_monthly, included_modules, included_platforms) VALUES
('starter', 'Starter', 'Plan basico para pequenos negocios', 'saas', 499, 3, 1, 5, 5000, '{core,sales,inventory}', '{web}'),
('professional', 'Professional', 'Plan profesional con app movil', 'saas', 999, 10, 3, 20, 25000, '{core,sales,inventory,purchases,financial,reports}', '{web,mobile}'),
('business', 'Business', 'Plan empresarial completo', 'saas', 2499, 25, 10, 100, 100000, '{all}', '{web,mobile,desktop}'),
('enterprise', 'Enterprise', 'Plan enterprise personalizado', 'saas', 0, 0, 0, 0, 0, '{all}', '{web,mobile,desktop}'),
('on_premise', 'On-Premise', 'Instalacion en servidor propio', 'on_premise', 0, 0, 0, 0, 0, '{all}', '{web,mobile,desktop}')
ON CONFLICT DO NOTHING;
-- =====================
-- COMENTARIOS DE TABLAS
-- =====================
COMMENT ON TABLE billing.subscription_plans IS 'Planes de suscripcion disponibles para tenants';
COMMENT ON TABLE billing.tenant_subscriptions IS 'Suscripciones activas de cada tenant';
COMMENT ON TABLE billing.usage_tracking IS 'Resumen de uso por periodo para calculo de facturacion';
COMMENT ON TABLE billing.usage_events IS 'Eventos de uso en tiempo real para tracking granular';
COMMENT ON TABLE billing.invoices IS 'Facturas generadas para cada tenant';
COMMENT ON TABLE billing.invoice_items IS 'Items detallados de cada factura';
COMMENT ON TABLE billing.payment_methods IS 'Metodos de pago guardados por tenant';
COMMENT ON TABLE billing.billing_alerts IS 'Alertas de facturacion y limites de uso';

337
ddl/06-auth-extended.sql Normal file
View File

@ -0,0 +1,337 @@
-- =============================================================
-- ARCHIVO: 06-auth-extended.sql
-- DESCRIPCION: Extensiones de autenticacion SaaS (JWT, OAuth, MFA)
-- VERSION: 1.0.0
-- PROYECTO: ERP-Core V2
-- FECHA: 2026-01-10
-- EPIC: SAAS-CORE-AUTH (EPIC-SAAS-001)
-- HISTORIAS: US-001, US-002, US-003
-- =============================================================
-- =====================
-- MODIFICACIONES A TABLAS EXISTENTES
-- =====================
-- Agregar columnas OAuth a auth.users (US-002)
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS oauth_provider VARCHAR(50);
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS oauth_provider_id VARCHAR(255);
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS avatar_url TEXT;
-- Agregar columnas MFA a auth.users (US-003)
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS mfa_enabled BOOLEAN DEFAULT FALSE;
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS mfa_secret_encrypted TEXT;
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS mfa_backup_codes TEXT[];
-- Agregar columna superadmin (EPIC-SAAS-006)
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS is_superadmin BOOLEAN DEFAULT FALSE;
-- Indices para nuevas columnas
CREATE INDEX IF NOT EXISTS idx_users_oauth_provider ON auth.users(oauth_provider, oauth_provider_id) WHERE oauth_provider IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_users_mfa_enabled ON auth.users(mfa_enabled) WHERE mfa_enabled = TRUE;
-- =====================
-- TABLA: auth.sessions
-- Sesiones de usuario con refresh tokens (US-001)
-- =====================
CREATE TABLE IF NOT EXISTS auth.sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Token info
refresh_token_hash VARCHAR(255) NOT NULL,
jti VARCHAR(255) UNIQUE NOT NULL, -- JWT ID para blacklist
-- Device info
device_info JSONB DEFAULT '{}',
device_fingerprint VARCHAR(255),
user_agent TEXT,
ip_address INET,
-- Geo info
country_code VARCHAR(2),
city VARCHAR(100),
-- Timestamps
last_activity_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
revoked_reason VARCHAR(100),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Indices para sessions
CREATE INDEX IF NOT EXISTS idx_sessions_user ON auth.sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_tenant ON auth.sessions(tenant_id);
CREATE INDEX IF NOT EXISTS idx_sessions_jti ON auth.sessions(jti);
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON auth.sessions(expires_at) WHERE revoked_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_sessions_active ON auth.sessions(user_id, revoked_at) WHERE revoked_at IS NULL;
-- =====================
-- TABLA: auth.token_blacklist
-- Tokens revocados/invalidados (US-001)
-- =====================
CREATE TABLE IF NOT EXISTS auth.token_blacklist (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
jti VARCHAR(255) UNIQUE NOT NULL,
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
token_type VARCHAR(20) NOT NULL CHECK (token_type IN ('access', 'refresh')),
-- Metadata
reason VARCHAR(100),
revoked_by UUID REFERENCES auth.users(id),
-- Timestamps
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Indice para limpieza de tokens expirados
CREATE INDEX IF NOT EXISTS idx_token_blacklist_expires ON auth.token_blacklist(expires_at);
CREATE INDEX IF NOT EXISTS idx_token_blacklist_jti ON auth.token_blacklist(jti);
-- =====================
-- TABLA: auth.oauth_providers
-- Proveedores OAuth vinculados a usuarios (US-002)
-- =====================
CREATE TABLE IF NOT EXISTS auth.oauth_providers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Provider info
provider VARCHAR(50) NOT NULL, -- google, github, microsoft, apple
provider_user_id VARCHAR(255) NOT NULL,
provider_email VARCHAR(255),
-- Tokens (encrypted)
access_token_encrypted TEXT,
refresh_token_encrypted TEXT,
token_expires_at TIMESTAMPTZ,
-- Profile data from provider
profile_data JSONB DEFAULT '{}',
-- Timestamps
linked_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
last_used_at TIMESTAMPTZ,
unlinked_at TIMESTAMPTZ,
UNIQUE(provider, provider_user_id)
);
-- Indices para oauth_providers
CREATE INDEX IF NOT EXISTS idx_oauth_providers_user ON auth.oauth_providers(user_id);
CREATE INDEX IF NOT EXISTS idx_oauth_providers_provider ON auth.oauth_providers(provider, provider_user_id);
CREATE INDEX IF NOT EXISTS idx_oauth_providers_email ON auth.oauth_providers(provider_email);
-- =====================
-- TABLA: auth.mfa_devices
-- Dispositivos MFA registrados (US-003)
-- =====================
CREATE TABLE IF NOT EXISTS auth.mfa_devices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
-- Device info
device_type VARCHAR(50) NOT NULL, -- totp, sms, email, hardware_key
device_name VARCHAR(255),
-- TOTP specific
secret_encrypted TEXT,
-- Status
is_primary BOOLEAN DEFAULT FALSE,
is_verified BOOLEAN DEFAULT FALSE,
verified_at TIMESTAMPTZ,
-- Usage tracking
last_used_at TIMESTAMPTZ,
use_count INTEGER DEFAULT 0,
-- Timestamps
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
disabled_at TIMESTAMPTZ
);
-- Indices para mfa_devices
CREATE INDEX IF NOT EXISTS idx_mfa_devices_user ON auth.mfa_devices(user_id);
CREATE INDEX IF NOT EXISTS idx_mfa_devices_primary ON auth.mfa_devices(user_id, is_primary) WHERE is_primary = TRUE;
-- =====================
-- TABLA: auth.mfa_backup_codes
-- Codigos de respaldo MFA (US-003)
-- =====================
CREATE TABLE IF NOT EXISTS auth.mfa_backup_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
-- Code (hashed)
code_hash VARCHAR(255) NOT NULL,
-- Status
used_at TIMESTAMPTZ,
-- Timestamps
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMPTZ
);
-- Indices para mfa_backup_codes
CREATE INDEX IF NOT EXISTS idx_mfa_backup_codes_user ON auth.mfa_backup_codes(user_id);
CREATE INDEX IF NOT EXISTS idx_mfa_backup_codes_unused ON auth.mfa_backup_codes(user_id, used_at) WHERE used_at IS NULL;
-- =====================
-- TABLA: auth.login_attempts
-- Intentos de login para rate limiting y seguridad
-- =====================
CREATE TABLE IF NOT EXISTS auth.login_attempts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Identificacion
email VARCHAR(255),
ip_address INET NOT NULL,
user_agent TEXT,
-- Resultado
success BOOLEAN NOT NULL,
failure_reason VARCHAR(100),
-- MFA
mfa_required BOOLEAN DEFAULT FALSE,
mfa_passed BOOLEAN,
-- Metadata
tenant_id UUID REFERENCES auth.tenants(id),
user_id UUID REFERENCES auth.users(id),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Indices para login_attempts
CREATE INDEX IF NOT EXISTS idx_login_attempts_email ON auth.login_attempts(email, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_login_attempts_ip ON auth.login_attempts(ip_address, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_login_attempts_cleanup ON auth.login_attempts(created_at);
-- =====================
-- RLS POLICIES
-- =====================
ALTER TABLE auth.sessions ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_sessions ON auth.sessions
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
ALTER TABLE auth.oauth_providers ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_oauth ON auth.oauth_providers
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
ALTER TABLE auth.mfa_devices ENABLE ROW LEVEL SECURITY;
CREATE POLICY user_isolation_mfa_devices ON auth.mfa_devices
USING (user_id = current_setting('app.current_user_id', true)::uuid);
ALTER TABLE auth.mfa_backup_codes ENABLE ROW LEVEL SECURITY;
CREATE POLICY user_isolation_mfa_codes ON auth.mfa_backup_codes
USING (user_id = current_setting('app.current_user_id', true)::uuid);
-- =====================
-- FUNCIONES
-- =====================
-- Funcion para limpiar sesiones expiradas
CREATE OR REPLACE FUNCTION auth.cleanup_expired_sessions()
RETURNS INTEGER AS $$
DECLARE
deleted_count INTEGER;
BEGIN
DELETE FROM auth.sessions
WHERE expires_at < CURRENT_TIMESTAMP
OR revoked_at IS NOT NULL;
GET DIAGNOSTICS deleted_count = ROW_COUNT;
RETURN deleted_count;
END;
$$ LANGUAGE plpgsql;
-- Funcion para limpiar tokens expirados del blacklist
CREATE OR REPLACE FUNCTION auth.cleanup_expired_tokens()
RETURNS INTEGER AS $$
DECLARE
deleted_count INTEGER;
BEGIN
DELETE FROM auth.token_blacklist
WHERE expires_at < CURRENT_TIMESTAMP;
GET DIAGNOSTICS deleted_count = ROW_COUNT;
RETURN deleted_count;
END;
$$ LANGUAGE plpgsql;
-- Funcion para limpiar intentos de login antiguos (mas de 30 dias)
CREATE OR REPLACE FUNCTION auth.cleanup_old_login_attempts()
RETURNS INTEGER AS $$
DECLARE
deleted_count INTEGER;
BEGIN
DELETE FROM auth.login_attempts
WHERE created_at < CURRENT_TIMESTAMP - INTERVAL '30 days';
GET DIAGNOSTICS deleted_count = ROW_COUNT;
RETURN deleted_count;
END;
$$ LANGUAGE plpgsql;
-- Funcion para verificar rate limit de login
CREATE OR REPLACE FUNCTION auth.check_login_rate_limit(
p_email VARCHAR(255),
p_ip_address INET,
p_max_attempts INTEGER DEFAULT 5,
p_window_minutes INTEGER DEFAULT 15
)
RETURNS BOOLEAN AS $$
DECLARE
attempt_count INTEGER;
BEGIN
SELECT COUNT(*) INTO attempt_count
FROM auth.login_attempts
WHERE (email = p_email OR ip_address = p_ip_address)
AND success = FALSE
AND created_at > CURRENT_TIMESTAMP - (p_window_minutes || ' minutes')::INTERVAL;
RETURN attempt_count < p_max_attempts;
END;
$$ LANGUAGE plpgsql;
-- Funcion para revocar todas las sesiones de un usuario
CREATE OR REPLACE FUNCTION auth.revoke_all_user_sessions(
p_user_id UUID,
p_reason VARCHAR(100) DEFAULT 'manual_revocation'
)
RETURNS INTEGER AS $$
DECLARE
revoked_count INTEGER;
BEGIN
UPDATE auth.sessions
SET revoked_at = CURRENT_TIMESTAMP,
revoked_reason = p_reason
WHERE user_id = p_user_id
AND revoked_at IS NULL;
GET DIAGNOSTICS revoked_count = ROW_COUNT;
RETURN revoked_count;
END;
$$ LANGUAGE plpgsql;
-- =====================
-- COMENTARIOS
-- =====================
COMMENT ON TABLE auth.sessions IS 'Sesiones de usuario con refresh tokens para JWT';
COMMENT ON TABLE auth.token_blacklist IS 'Tokens JWT revocados antes de su expiracion';
COMMENT ON TABLE auth.oauth_providers IS 'Proveedores OAuth vinculados a cuentas de usuario';
COMMENT ON TABLE auth.mfa_devices IS 'Dispositivos MFA registrados por usuario';
COMMENT ON TABLE auth.mfa_backup_codes IS 'Codigos de respaldo para MFA';
COMMENT ON TABLE auth.login_attempts IS 'Registro de intentos de login para seguridad';
COMMENT ON FUNCTION auth.cleanup_expired_sessions IS 'Limpia sesiones expiradas o revocadas';
COMMENT ON FUNCTION auth.cleanup_expired_tokens IS 'Limpia tokens del blacklist que ya expiraron';
COMMENT ON FUNCTION auth.check_login_rate_limit IS 'Verifica si un email/IP ha excedido intentos de login';
COMMENT ON FUNCTION auth.revoke_all_user_sessions IS 'Revoca todas las sesiones activas de un usuario';

565
ddl/07-users-rbac.sql Normal file
View File

@ -0,0 +1,565 @@
-- =============================================================
-- ARCHIVO: 07-users-rbac.sql
-- DESCRIPCION: Sistema RBAC (Roles, Permisos, Invitaciones)
-- VERSION: 1.0.0
-- PROYECTO: ERP-Core V2
-- FECHA: 2026-01-10
-- EPIC: SAAS-CORE-AUTH (EPIC-SAAS-001)
-- HISTORIAS: US-004, US-005, US-006, US-030
-- =============================================================
-- =====================
-- SCHEMA: users
-- =====================
CREATE SCHEMA IF NOT EXISTS users;
-- =====================
-- TABLA: users.roles
-- Roles del sistema con herencia (US-004)
-- =====================
CREATE TABLE IF NOT EXISTS users.roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Info basica
name VARCHAR(100) NOT NULL,
display_name VARCHAR(255),
description TEXT,
color VARCHAR(20),
icon VARCHAR(50),
-- Jerarquia
parent_role_id UUID REFERENCES users.roles(id) ON DELETE SET NULL,
hierarchy_level INTEGER DEFAULT 0,
-- Flags
is_system BOOLEAN DEFAULT FALSE, -- No editable por usuarios
is_default BOOLEAN DEFAULT FALSE, -- Asignado a nuevos usuarios
is_superadmin BOOLEAN DEFAULT FALSE, -- Acceso total
-- Metadata
metadata JSONB DEFAULT '{}',
-- Timestamps
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
-- Constraint: nombre unico por tenant (o global si tenant_id es NULL)
UNIQUE NULLS NOT DISTINCT (tenant_id, name)
);
-- Indices para roles
CREATE INDEX IF NOT EXISTS idx_roles_tenant ON users.roles(tenant_id);
CREATE INDEX IF NOT EXISTS idx_roles_parent ON users.roles(parent_role_id);
CREATE INDEX IF NOT EXISTS idx_roles_system ON users.roles(is_system) WHERE is_system = TRUE;
CREATE INDEX IF NOT EXISTS idx_roles_default ON users.roles(tenant_id, is_default) WHERE is_default = TRUE;
-- =====================
-- TABLA: users.permissions
-- Permisos granulares del sistema (US-004)
-- =====================
CREATE TABLE IF NOT EXISTS users.permissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Identificacion
resource VARCHAR(100) NOT NULL, -- users, tenants, branches, invoices, etc.
action VARCHAR(50) NOT NULL, -- create, read, update, delete, export, etc.
scope VARCHAR(50) DEFAULT 'own', -- own, tenant, global
-- Info
display_name VARCHAR(255),
description TEXT,
category VARCHAR(100), -- auth, billing, inventory, sales, etc.
-- Flags
is_dangerous BOOLEAN DEFAULT FALSE, -- Requiere confirmacion adicional
-- Timestamps
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE(resource, action, scope)
);
-- Indices para permissions
CREATE INDEX IF NOT EXISTS idx_permissions_resource ON users.permissions(resource);
CREATE INDEX IF NOT EXISTS idx_permissions_category ON users.permissions(category);
-- =====================
-- TABLA: users.role_permissions
-- Asignacion de permisos a roles (US-004)
-- =====================
CREATE TABLE IF NOT EXISTS users.role_permissions (
role_id UUID NOT NULL REFERENCES users.roles(id) ON DELETE CASCADE,
permission_id UUID NOT NULL REFERENCES users.permissions(id) ON DELETE CASCADE,
-- Condiciones opcionales
conditions JSONB DEFAULT '{}', -- Condiciones adicionales (ej: solo ciertos estados)
-- Metadata
granted_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
granted_by UUID REFERENCES auth.users(id),
PRIMARY KEY (role_id, permission_id)
);
-- Indices para role_permissions
CREATE INDEX IF NOT EXISTS idx_role_permissions_role ON users.role_permissions(role_id);
CREATE INDEX IF NOT EXISTS idx_role_permissions_permission ON users.role_permissions(permission_id);
-- =====================
-- TABLA: users.user_roles
-- Asignacion de roles a usuarios (US-004)
-- =====================
CREATE TABLE IF NOT EXISTS users.user_roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
role_id UUID NOT NULL REFERENCES users.roles(id) ON DELETE CASCADE,
-- Contexto
tenant_id UUID REFERENCES auth.tenants(id) ON DELETE CASCADE,
branch_id UUID REFERENCES core.branches(id) ON DELETE CASCADE, -- Opcional: rol por sucursal
-- Flags
is_primary BOOLEAN DEFAULT FALSE,
-- Vigencia
valid_from TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
valid_until TIMESTAMPTZ,
-- Metadata
assigned_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
assigned_by UUID REFERENCES auth.users(id),
revoked_at TIMESTAMPTZ,
revoked_by UUID REFERENCES auth.users(id),
-- Constraint: un usuario solo puede tener un rol una vez por branch (o global si branch es NULL)
UNIQUE (user_id, role_id, branch_id)
);
-- Indices para user_roles
CREATE INDEX IF NOT EXISTS idx_user_roles_user ON users.user_roles(user_id);
CREATE INDEX IF NOT EXISTS idx_user_roles_role ON users.user_roles(role_id);
CREATE INDEX IF NOT EXISTS idx_user_roles_tenant ON users.user_roles(tenant_id);
CREATE INDEX IF NOT EXISTS idx_user_roles_branch ON users.user_roles(branch_id) WHERE branch_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_user_roles_active ON users.user_roles(user_id, revoked_at) WHERE revoked_at IS NULL;
-- =====================
-- TABLA: users.invitations
-- Invitaciones de usuario (US-006)
-- =====================
CREATE TABLE IF NOT EXISTS users.invitations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Destinatario
email VARCHAR(255) NOT NULL,
first_name VARCHAR(100),
last_name VARCHAR(100),
-- Token de invitacion
token_hash VARCHAR(255) NOT NULL UNIQUE,
token_expires_at TIMESTAMPTZ NOT NULL,
-- Rol a asignar
role_id UUID REFERENCES users.roles(id) ON DELETE SET NULL,
branch_id UUID REFERENCES core.branches(id) ON DELETE SET NULL,
-- Estado
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'expired', 'revoked')),
-- Mensaje personalizado
message TEXT,
-- Metadata
metadata JSONB DEFAULT '{}',
-- Timestamps
invited_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
invited_by UUID NOT NULL REFERENCES auth.users(id),
accepted_at TIMESTAMPTZ,
accepted_user_id UUID REFERENCES auth.users(id),
resent_at TIMESTAMPTZ,
resent_count INTEGER DEFAULT 0,
-- Constraint: email unico por tenant mientras este pendiente
CONSTRAINT unique_pending_invitation UNIQUE (tenant_id, email, status)
);
-- Indices para invitations
CREATE INDEX IF NOT EXISTS idx_invitations_tenant ON users.invitations(tenant_id);
CREATE INDEX IF NOT EXISTS idx_invitations_email ON users.invitations(email);
CREATE INDEX IF NOT EXISTS idx_invitations_token ON users.invitations(token_hash);
CREATE INDEX IF NOT EXISTS idx_invitations_status ON users.invitations(status) WHERE status = 'pending';
CREATE INDEX IF NOT EXISTS idx_invitations_expires ON users.invitations(token_expires_at) WHERE status = 'pending';
-- =====================
-- TABLA: users.tenant_settings
-- Configuraciones por tenant (US-005)
-- =====================
CREATE TABLE IF NOT EXISTS users.tenant_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE UNIQUE,
-- Limites
max_users INTEGER DEFAULT 10,
max_branches INTEGER DEFAULT 5,
max_storage_mb INTEGER DEFAULT 1024,
-- Features habilitadas
features_enabled TEXT[] DEFAULT '{}',
-- Configuracion de branding
branding JSONB DEFAULT '{
"logo_url": null,
"primary_color": "#2563eb",
"secondary_color": "#64748b"
}',
-- Configuracion regional
locale VARCHAR(10) DEFAULT 'es-MX',
timezone VARCHAR(50) DEFAULT 'America/Mexico_City',
currency VARCHAR(3) DEFAULT 'MXN',
date_format VARCHAR(20) DEFAULT 'DD/MM/YYYY',
-- Configuracion de seguridad
security_settings JSONB DEFAULT '{
"require_mfa": false,
"session_timeout_minutes": 480,
"password_min_length": 8,
"password_require_special": true
}',
-- Configuracion de notificaciones
notification_settings JSONB DEFAULT '{
"email_enabled": true,
"push_enabled": true,
"sms_enabled": false
}',
-- Metadata
metadata JSONB DEFAULT '{}',
-- Timestamps
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- =====================
-- TABLA: users.profile_role_mapping
-- Mapeo de perfiles ERP a roles RBAC (US-030)
-- =====================
CREATE TABLE IF NOT EXISTS users.profile_role_mapping (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
profile_code VARCHAR(10) NOT NULL, -- ADM, CNT, VNT, ALM, etc.
role_id UUID NOT NULL REFERENCES users.roles(id) ON DELETE CASCADE,
-- Flags
is_default BOOLEAN DEFAULT TRUE,
-- Timestamps
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE(profile_code, role_id)
);
-- Indice para profile_role_mapping
CREATE INDEX IF NOT EXISTS idx_profile_role_mapping_profile ON users.profile_role_mapping(profile_code);
-- =====================
-- RLS POLICIES
-- =====================
ALTER TABLE users.roles ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_roles ON users.roles
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid OR tenant_id IS NULL);
ALTER TABLE users.role_permissions ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_role_permissions ON users.role_permissions
USING (role_id IN (
SELECT id FROM users.roles
WHERE tenant_id = current_setting('app.current_tenant_id', true)::uuid OR tenant_id IS NULL
));
ALTER TABLE users.user_roles ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_user_roles ON users.user_roles
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid OR tenant_id IS NULL);
ALTER TABLE users.invitations ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_invitations ON users.invitations
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
ALTER TABLE users.tenant_settings ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_settings ON users.tenant_settings
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- =====================
-- FUNCIONES
-- =====================
-- Funcion para verificar si un usuario tiene un permiso especifico
CREATE OR REPLACE FUNCTION users.has_permission(
p_user_id UUID,
p_resource VARCHAR(100),
p_action VARCHAR(50),
p_scope VARCHAR(50) DEFAULT 'own'
)
RETURNS BOOLEAN AS $$
DECLARE
has_perm BOOLEAN;
BEGIN
-- Verificar si es superadmin
IF EXISTS (
SELECT 1 FROM auth.users
WHERE id = p_user_id AND is_superadmin = TRUE
) THEN
RETURN TRUE;
END IF;
-- Verificar permisos via roles
SELECT EXISTS (
SELECT 1
FROM users.user_roles ur
JOIN users.role_permissions rp ON rp.role_id = ur.role_id
JOIN users.permissions p ON p.id = rp.permission_id
WHERE ur.user_id = p_user_id
AND ur.revoked_at IS NULL
AND (ur.valid_until IS NULL OR ur.valid_until > CURRENT_TIMESTAMP)
AND p.resource = p_resource
AND p.action = p_action
AND (p.scope = p_scope OR p.scope = 'global')
) INTO has_perm;
RETURN has_perm;
END;
$$ LANGUAGE plpgsql STABLE;
-- Funcion para obtener todos los permisos de un usuario
CREATE OR REPLACE FUNCTION users.get_user_permissions(p_user_id UUID)
RETURNS TABLE (
resource VARCHAR(100),
action VARCHAR(50),
scope VARCHAR(50),
conditions JSONB
) AS $$
BEGIN
-- Si es superadmin, devolver wildcard
IF EXISTS (
SELECT 1 FROM auth.users
WHERE id = p_user_id AND is_superadmin = TRUE
) THEN
RETURN QUERY
SELECT '*'::VARCHAR(100), '*'::VARCHAR(50), 'global'::VARCHAR(50), '{}'::JSONB;
RETURN;
END IF;
RETURN QUERY
SELECT DISTINCT
p.resource,
p.action,
p.scope,
rp.conditions
FROM users.user_roles ur
JOIN users.role_permissions rp ON rp.role_id = ur.role_id
JOIN users.permissions p ON p.id = rp.permission_id
WHERE ur.user_id = p_user_id
AND ur.revoked_at IS NULL
AND (ur.valid_until IS NULL OR ur.valid_until > CURRENT_TIMESTAMP);
END;
$$ LANGUAGE plpgsql STABLE;
-- Funcion para obtener permisos heredados de un rol
CREATE OR REPLACE FUNCTION users.get_role_permissions_with_inheritance(p_role_id UUID)
RETURNS TABLE (
permission_id UUID,
resource VARCHAR(100),
action VARCHAR(50),
scope VARCHAR(50),
inherited_from UUID
) AS $$
WITH RECURSIVE role_hierarchy AS (
-- Rol base
SELECT id, parent_role_id, 0 as level
FROM users.roles
WHERE id = p_role_id
UNION ALL
-- Roles padre (herencia)
SELECT r.id, r.parent_role_id, rh.level + 1
FROM users.roles r
JOIN role_hierarchy rh ON r.id = rh.parent_role_id
WHERE rh.level < 10 -- Limite de profundidad
)
SELECT
p.id as permission_id,
p.resource,
p.action,
p.scope,
rh.id as inherited_from
FROM role_hierarchy rh
JOIN users.role_permissions rp ON rp.role_id = rh.id
JOIN users.permissions p ON p.id = rp.permission_id;
$$ LANGUAGE sql STABLE;
-- Funcion para limpiar invitaciones expiradas
CREATE OR REPLACE FUNCTION users.cleanup_expired_invitations()
RETURNS INTEGER AS $$
DECLARE
updated_count INTEGER;
BEGIN
UPDATE users.invitations
SET status = 'expired'
WHERE status = 'pending'
AND token_expires_at < CURRENT_TIMESTAMP;
GET DIAGNOSTICS updated_count = ROW_COUNT;
RETURN updated_count;
END;
$$ LANGUAGE plpgsql;
-- =====================
-- TRIGGERS
-- =====================
-- Trigger para actualizar updated_at en roles
CREATE OR REPLACE FUNCTION users.update_role_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_roles_updated_at
BEFORE UPDATE ON users.roles
FOR EACH ROW
EXECUTE FUNCTION users.update_role_timestamp();
-- Trigger para actualizar updated_at en tenant_settings
CREATE TRIGGER trg_tenant_settings_updated_at
BEFORE UPDATE ON users.tenant_settings
FOR EACH ROW
EXECUTE FUNCTION users.update_role_timestamp();
-- =====================
-- SEED DATA: Permisos Base
-- =====================
INSERT INTO users.permissions (resource, action, scope, display_name, description, category) VALUES
-- Auth
('users', 'create', 'tenant', 'Crear usuarios', 'Crear nuevos usuarios en el tenant', 'auth'),
('users', 'read', 'tenant', 'Ver usuarios', 'Ver lista de usuarios del tenant', 'auth'),
('users', 'read', 'own', 'Ver perfil propio', 'Ver su propio perfil', 'auth'),
('users', 'update', 'tenant', 'Editar usuarios', 'Editar cualquier usuario del tenant', 'auth'),
('users', 'update', 'own', 'Editar perfil propio', 'Editar su propio perfil', 'auth'),
('users', 'delete', 'tenant', 'Eliminar usuarios', 'Eliminar usuarios del tenant', 'auth'),
('roles', 'create', 'tenant', 'Crear roles', 'Crear nuevos roles', 'auth'),
('roles', 'read', 'tenant', 'Ver roles', 'Ver roles del tenant', 'auth'),
('roles', 'update', 'tenant', 'Editar roles', 'Editar roles existentes', 'auth'),
('roles', 'delete', 'tenant', 'Eliminar roles', 'Eliminar roles', 'auth'),
('invitations', 'create', 'tenant', 'Invitar usuarios', 'Enviar invitaciones', 'auth'),
('invitations', 'read', 'tenant', 'Ver invitaciones', 'Ver invitaciones pendientes', 'auth'),
('invitations', 'delete', 'tenant', 'Cancelar invitaciones', 'Revocar invitaciones', 'auth'),
-- Tenants
('tenants', 'read', 'own', 'Ver configuracion', 'Ver configuracion del tenant', 'tenants'),
('tenants', 'update', 'own', 'Editar configuracion', 'Editar configuracion del tenant', 'tenants'),
('tenant_settings', 'read', 'own', 'Ver ajustes', 'Ver ajustes del tenant', 'tenants'),
('tenant_settings', 'update', 'own', 'Editar ajustes', 'Editar ajustes del tenant', 'tenants'),
-- Branches
('branches', 'create', 'tenant', 'Crear sucursales', 'Crear nuevas sucursales', 'branches'),
('branches', 'read', 'tenant', 'Ver sucursales', 'Ver todas las sucursales', 'branches'),
('branches', 'read', 'own', 'Ver sucursal asignada', 'Ver solo su sucursal', 'branches'),
('branches', 'update', 'tenant', 'Editar sucursales', 'Editar cualquier sucursal', 'branches'),
('branches', 'delete', 'tenant', 'Eliminar sucursales', 'Eliminar sucursales', 'branches'),
-- Billing
('billing', 'read', 'tenant', 'Ver facturacion', 'Ver informacion de facturacion', 'billing'),
('billing', 'update', 'tenant', 'Gestionar facturacion', 'Cambiar plan, metodo de pago', 'billing'),
('invoices', 'read', 'tenant', 'Ver facturas', 'Ver historial de facturas', 'billing'),
('invoices', 'export', 'tenant', 'Exportar facturas', 'Descargar facturas', 'billing'),
-- Audit
('audit_logs', 'read', 'tenant', 'Ver auditoria', 'Ver logs de auditoria', 'audit'),
('audit_logs', 'export', 'tenant', 'Exportar auditoria', 'Exportar logs', 'audit'),
('activity', 'read', 'own', 'Ver mi actividad', 'Ver actividad propia', 'audit'),
-- Notifications
('notifications', 'read', 'own', 'Ver notificaciones', 'Ver notificaciones propias', 'notifications'),
('notifications', 'update', 'own', 'Gestionar notificaciones', 'Marcar como leidas', 'notifications'),
('notification_settings', 'read', 'own', 'Ver preferencias', 'Ver preferencias de notificacion', 'notifications'),
('notification_settings', 'update', 'own', 'Editar preferencias', 'Editar preferencias', 'notifications')
ON CONFLICT DO NOTHING;
-- =====================
-- SEED DATA: Roles Base del Sistema
-- =====================
INSERT INTO users.roles (id, tenant_id, name, display_name, description, is_system, is_superadmin, hierarchy_level, icon, color) VALUES
('10000000-0000-0000-0000-000000000001', NULL, 'superadmin', 'Super Administrador', 'Acceso total a la plataforma', TRUE, TRUE, 0, 'shield-check', '#dc2626'),
('10000000-0000-0000-0000-000000000002', NULL, 'admin', 'Administrador', 'Administrador del tenant', TRUE, FALSE, 1, 'shield', '#ea580c'),
('10000000-0000-0000-0000-000000000003', NULL, 'manager', 'Gerente', 'Gerente con acceso a reportes', TRUE, FALSE, 2, 'briefcase', '#0891b2'),
('10000000-0000-0000-0000-000000000004', NULL, 'user', 'Usuario', 'Usuario estandar', TRUE, FALSE, 3, 'user', '#64748b'),
('10000000-0000-0000-0000-000000000005', NULL, 'viewer', 'Visor', 'Solo lectura', TRUE, FALSE, 4, 'eye', '#94a3b8')
ON CONFLICT DO NOTHING;
-- Asignar permisos al rol Admin
INSERT INTO users.role_permissions (role_id, permission_id)
SELECT '10000000-0000-0000-0000-000000000002', id
FROM users.permissions
WHERE resource NOT IN ('audit_logs') -- Admin no tiene acceso a audit
ON CONFLICT DO NOTHING;
-- Asignar permisos al rol Manager
INSERT INTO users.role_permissions (role_id, permission_id)
SELECT '10000000-0000-0000-0000-000000000003', id
FROM users.permissions
WHERE scope = 'own'
OR (resource IN ('branches', 'users', 'invoices') AND action = 'read')
ON CONFLICT DO NOTHING;
-- Asignar permisos al rol User
INSERT INTO users.role_permissions (role_id, permission_id)
SELECT '10000000-0000-0000-0000-000000000004', id
FROM users.permissions
WHERE scope = 'own'
ON CONFLICT DO NOTHING;
-- Asignar permisos al rol Viewer
INSERT INTO users.role_permissions (role_id, permission_id)
SELECT '10000000-0000-0000-0000-000000000005', id
FROM users.permissions
WHERE action = 'read' AND scope = 'own'
ON CONFLICT DO NOTHING;
-- =====================
-- SEED DATA: Mapeo de Perfiles ERP a Roles (US-030)
-- =====================
INSERT INTO users.profile_role_mapping (profile_code, role_id) VALUES
('ADM', '10000000-0000-0000-0000-000000000002'), -- Admin
('CNT', '10000000-0000-0000-0000-000000000003'), -- Manager
('VNT', '10000000-0000-0000-0000-000000000004'), -- User
('CMP', '10000000-0000-0000-0000-000000000004'), -- User
('ALM', '10000000-0000-0000-0000-000000000004'), -- User
('HRH', '10000000-0000-0000-0000-000000000003'), -- Manager
('PRD', '10000000-0000-0000-0000-000000000004'), -- User
('EMP', '10000000-0000-0000-0000-000000000005'), -- Viewer
('GER', '10000000-0000-0000-0000-000000000003'), -- Manager
('AUD', '10000000-0000-0000-0000-000000000005') -- Viewer (read-only)
ON CONFLICT DO NOTHING;
-- =====================
-- COMENTARIOS
-- =====================
COMMENT ON TABLE users.roles IS 'Roles del sistema con soporte para herencia';
COMMENT ON TABLE users.permissions IS 'Permisos granulares (resource.action.scope)';
COMMENT ON TABLE users.role_permissions IS 'Asignacion de permisos a roles';
COMMENT ON TABLE users.user_roles IS 'Asignacion de roles a usuarios';
COMMENT ON TABLE users.invitations IS 'Invitaciones para nuevos usuarios';
COMMENT ON TABLE users.tenant_settings IS 'Configuraciones personalizadas por tenant';
COMMENT ON TABLE users.profile_role_mapping IS 'Mapeo de perfiles ERP a roles RBAC';
COMMENT ON FUNCTION users.has_permission IS 'Verifica si un usuario tiene un permiso especifico';
COMMENT ON FUNCTION users.get_user_permissions IS 'Obtiene todos los permisos de un usuario';
COMMENT ON FUNCTION users.get_role_permissions_with_inheritance IS 'Obtiene permisos de un rol incluyendo herencia';

544
ddl/08-plans.sql Normal file
View File

@ -0,0 +1,544 @@
-- =============================================================
-- ARCHIVO: 08-plans.sql
-- DESCRIPCION: Extensiones de planes SaaS (features, limits, Stripe)
-- VERSION: 1.0.0
-- PROYECTO: ERP-Core V2
-- FECHA: 2026-01-10
-- EPIC: SAAS-BILLING (EPIC-SAAS-002)
-- HISTORIAS: US-007, US-010, US-011, US-012
-- =============================================================
-- =====================
-- MODIFICACIONES A TABLAS EXISTENTES
-- =====================
-- Agregar columnas Stripe a tenant_subscriptions (US-007, US-008)
ALTER TABLE billing.tenant_subscriptions
ADD COLUMN IF NOT EXISTS stripe_customer_id VARCHAR(255),
ADD COLUMN IF NOT EXISTS stripe_subscription_id VARCHAR(255),
ADD COLUMN IF NOT EXISTS stripe_payment_method_id VARCHAR(255),
ADD COLUMN IF NOT EXISTS stripe_price_id VARCHAR(255),
ADD COLUMN IF NOT EXISTS trial_ends_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS canceled_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS cancel_at TIMESTAMPTZ;
-- Agregar columnas Stripe a subscription_plans
ALTER TABLE billing.subscription_plans
ADD COLUMN IF NOT EXISTS stripe_product_id VARCHAR(255),
ADD COLUMN IF NOT EXISTS stripe_price_id_monthly VARCHAR(255),
ADD COLUMN IF NOT EXISTS stripe_price_id_annual VARCHAR(255);
-- Agregar columnas Stripe a payment_methods
ALTER TABLE billing.payment_methods
ADD COLUMN IF NOT EXISTS stripe_payment_method_id VARCHAR(255);
-- Indices para campos Stripe
CREATE INDEX IF NOT EXISTS idx_subscriptions_stripe_customer ON billing.tenant_subscriptions(stripe_customer_id) WHERE stripe_customer_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_subscriptions_stripe_sub ON billing.tenant_subscriptions(stripe_subscription_id) WHERE stripe_subscription_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_plans_stripe_product ON billing.subscription_plans(stripe_product_id) WHERE stripe_product_id IS NOT NULL;
-- =====================
-- TABLA: billing.plan_features
-- Features habilitadas por plan (US-010, US-011)
-- =====================
CREATE TABLE IF NOT EXISTS billing.plan_features (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
plan_id UUID NOT NULL REFERENCES billing.subscription_plans(id) ON DELETE CASCADE,
-- Identificacion
feature_key VARCHAR(100) NOT NULL,
feature_name VARCHAR(255) NOT NULL,
category VARCHAR(100),
-- Estado
enabled BOOLEAN DEFAULT TRUE,
-- Configuracion
configuration JSONB DEFAULT '{}',
-- Metadata
description TEXT,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE(plan_id, feature_key)
);
-- Indices para plan_features
CREATE INDEX IF NOT EXISTS idx_plan_features_plan ON billing.plan_features(plan_id);
CREATE INDEX IF NOT EXISTS idx_plan_features_key ON billing.plan_features(feature_key);
CREATE INDEX IF NOT EXISTS idx_plan_features_enabled ON billing.plan_features(plan_id, enabled) WHERE enabled = TRUE;
-- =====================
-- TABLA: billing.plan_limits
-- Limites cuantificables por plan (US-010, US-012)
-- =====================
CREATE TABLE IF NOT EXISTS billing.plan_limits (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
plan_id UUID NOT NULL REFERENCES billing.subscription_plans(id) ON DELETE CASCADE,
-- Identificacion
limit_key VARCHAR(100) NOT NULL,
limit_name VARCHAR(255) NOT NULL,
-- Valor
limit_value INTEGER NOT NULL,
limit_type VARCHAR(50) DEFAULT 'monthly', -- monthly, daily, total, per_user
-- Overage (si se permite exceder)
allow_overage BOOLEAN DEFAULT FALSE,
overage_unit_price DECIMAL(10,4) DEFAULT 0,
overage_currency VARCHAR(3) DEFAULT 'MXN',
-- Alertas
alert_threshold_percent INTEGER DEFAULT 80,
hard_limit BOOLEAN DEFAULT TRUE, -- Si true, bloquea; si false, solo alerta
-- Metadata
description TEXT,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE(plan_id, limit_key)
);
-- Indices para plan_limits
CREATE INDEX IF NOT EXISTS idx_plan_limits_plan ON billing.plan_limits(plan_id);
CREATE INDEX IF NOT EXISTS idx_plan_limits_key ON billing.plan_limits(limit_key);
-- =====================
-- TABLA: billing.coupons
-- Cupones de descuento
-- =====================
CREATE TABLE IF NOT EXISTS billing.coupons (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Identificacion
code VARCHAR(50) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
description TEXT,
-- Descuento
discount_type VARCHAR(20) NOT NULL CHECK (discount_type IN ('percentage', 'fixed')),
discount_value DECIMAL(10,2) NOT NULL,
currency VARCHAR(3) DEFAULT 'MXN',
-- Aplicabilidad
applicable_plans UUID[] DEFAULT '{}', -- Vacio = todos
min_amount DECIMAL(10,2) DEFAULT 0,
-- Limites
max_redemptions INTEGER,
times_redeemed INTEGER DEFAULT 0,
max_redemptions_per_customer INTEGER DEFAULT 1,
-- Vigencia
valid_from TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
valid_until TIMESTAMPTZ,
-- Duracion del descuento
duration VARCHAR(20) DEFAULT 'once', -- once, forever, repeating
duration_months INTEGER, -- Si duration = repeating
-- Stripe
stripe_coupon_id VARCHAR(255),
-- Estado
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Indices para coupons
CREATE INDEX IF NOT EXISTS idx_coupons_code ON billing.coupons(code);
CREATE INDEX IF NOT EXISTS idx_coupons_active ON billing.coupons(is_active, valid_until);
-- =====================
-- TABLA: billing.coupon_redemptions
-- Uso de cupones
-- =====================
CREATE TABLE IF NOT EXISTS billing.coupon_redemptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
coupon_id UUID NOT NULL REFERENCES billing.coupons(id),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id),
subscription_id UUID REFERENCES billing.tenant_subscriptions(id),
-- Descuento aplicado
discount_amount DECIMAL(10,2) NOT NULL,
-- Timestamps
redeemed_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMPTZ,
UNIQUE(coupon_id, tenant_id)
);
-- Indices para coupon_redemptions
CREATE INDEX IF NOT EXISTS idx_coupon_redemptions_coupon ON billing.coupon_redemptions(coupon_id);
CREATE INDEX IF NOT EXISTS idx_coupon_redemptions_tenant ON billing.coupon_redemptions(tenant_id);
-- =====================
-- TABLA: billing.stripe_events
-- Log de eventos de Stripe (US-008)
-- =====================
CREATE TABLE IF NOT EXISTS billing.stripe_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Evento de Stripe
stripe_event_id VARCHAR(255) NOT NULL UNIQUE,
event_type VARCHAR(100) NOT NULL,
api_version VARCHAR(20),
-- Datos
data JSONB NOT NULL,
-- Procesamiento
processed BOOLEAN DEFAULT FALSE,
processed_at TIMESTAMPTZ,
error_message TEXT,
retry_count INTEGER DEFAULT 0,
-- Tenant relacionado (si aplica)
tenant_id UUID REFERENCES auth.tenants(id),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Indices para stripe_events
CREATE INDEX IF NOT EXISTS idx_stripe_events_type ON billing.stripe_events(event_type);
CREATE INDEX IF NOT EXISTS idx_stripe_events_processed ON billing.stripe_events(processed) WHERE processed = FALSE;
CREATE INDEX IF NOT EXISTS idx_stripe_events_tenant ON billing.stripe_events(tenant_id);
-- =====================
-- RLS POLICIES
-- =====================
ALTER TABLE billing.plan_features ENABLE ROW LEVEL SECURITY;
-- Plan features son globales, no requieren isolation
CREATE POLICY public_read_plan_features ON billing.plan_features
FOR SELECT USING (true);
ALTER TABLE billing.plan_limits ENABLE ROW LEVEL SECURITY;
-- Plan limits son globales, no requieren isolation
CREATE POLICY public_read_plan_limits ON billing.plan_limits
FOR SELECT USING (true);
ALTER TABLE billing.coupons ENABLE ROW LEVEL SECURITY;
-- Cupones son globales pero solo admins pueden modificar
CREATE POLICY public_read_coupons ON billing.coupons
FOR SELECT USING (is_active = TRUE);
ALTER TABLE billing.coupon_redemptions ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_coupon_redemptions ON billing.coupon_redemptions
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
ALTER TABLE billing.stripe_events ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_stripe_events ON billing.stripe_events
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid OR tenant_id IS NULL);
-- =====================
-- FUNCIONES
-- =====================
-- Funcion para verificar si un tenant tiene una feature habilitada
CREATE OR REPLACE FUNCTION billing.has_feature(
p_tenant_id UUID,
p_feature_key VARCHAR(100)
)
RETURNS BOOLEAN AS $$
DECLARE
v_enabled BOOLEAN;
BEGIN
SELECT pf.enabled INTO v_enabled
FROM billing.tenant_subscriptions ts
JOIN billing.plan_features pf ON pf.plan_id = ts.plan_id
WHERE ts.tenant_id = p_tenant_id
AND ts.status = 'active'
AND pf.feature_key = p_feature_key;
RETURN COALESCE(v_enabled, FALSE);
END;
$$ LANGUAGE plpgsql STABLE;
-- Funcion para obtener limite de un tenant
CREATE OR REPLACE FUNCTION billing.get_limit(
p_tenant_id UUID,
p_limit_key VARCHAR(100)
)
RETURNS INTEGER AS $$
DECLARE
v_limit INTEGER;
BEGIN
SELECT pl.limit_value INTO v_limit
FROM billing.tenant_subscriptions ts
JOIN billing.plan_limits pl ON pl.plan_id = ts.plan_id
WHERE ts.tenant_id = p_tenant_id
AND ts.status = 'active'
AND pl.limit_key = p_limit_key;
RETURN COALESCE(v_limit, 0);
END;
$$ LANGUAGE plpgsql STABLE;
-- Funcion para verificar si se puede usar una feature (con limite)
CREATE OR REPLACE FUNCTION billing.can_use_feature(
p_tenant_id UUID,
p_limit_key VARCHAR(100),
p_current_usage INTEGER DEFAULT 0
)
RETURNS TABLE (
allowed BOOLEAN,
limit_value INTEGER,
current_usage INTEGER,
remaining INTEGER,
allow_overage BOOLEAN,
overage_price DECIMAL
) AS $$
BEGIN
RETURN QUERY
SELECT
CASE
WHEN pl.hard_limit THEN p_current_usage < pl.limit_value
ELSE TRUE
END as allowed,
pl.limit_value,
p_current_usage as current_usage,
GREATEST(0, pl.limit_value - p_current_usage) as remaining,
pl.allow_overage,
pl.overage_unit_price
FROM billing.tenant_subscriptions ts
JOIN billing.plan_limits pl ON pl.plan_id = ts.plan_id
WHERE ts.tenant_id = p_tenant_id
AND ts.status = 'active'
AND pl.limit_key = p_limit_key;
END;
$$ LANGUAGE plpgsql STABLE;
-- Funcion para obtener todas las features de un tenant
CREATE OR REPLACE FUNCTION billing.get_tenant_features(p_tenant_id UUID)
RETURNS TABLE (
feature_key VARCHAR(100),
feature_name VARCHAR(255),
enabled BOOLEAN,
configuration JSONB
) AS $$
BEGIN
RETURN QUERY
SELECT
pf.feature_key,
pf.feature_name,
pf.enabled,
pf.configuration
FROM billing.tenant_subscriptions ts
JOIN billing.plan_features pf ON pf.plan_id = ts.plan_id
WHERE ts.tenant_id = p_tenant_id
AND ts.status IN ('active', 'trial');
END;
$$ LANGUAGE plpgsql STABLE;
-- Funcion para obtener todos los limites de un tenant
CREATE OR REPLACE FUNCTION billing.get_tenant_limits(p_tenant_id UUID)
RETURNS TABLE (
limit_key VARCHAR(100),
limit_name VARCHAR(255),
limit_value INTEGER,
limit_type VARCHAR(50),
allow_overage BOOLEAN,
overage_unit_price DECIMAL
) AS $$
BEGIN
RETURN QUERY
SELECT
pl.limit_key,
pl.limit_name,
pl.limit_value,
pl.limit_type,
pl.allow_overage,
pl.overage_unit_price
FROM billing.tenant_subscriptions ts
JOIN billing.plan_limits pl ON pl.plan_id = ts.plan_id
WHERE ts.tenant_id = p_tenant_id
AND ts.status IN ('active', 'trial');
END;
$$ LANGUAGE plpgsql STABLE;
-- Funcion para aplicar cupon
CREATE OR REPLACE FUNCTION billing.apply_coupon(
p_tenant_id UUID,
p_coupon_code VARCHAR(50)
)
RETURNS TABLE (
success BOOLEAN,
message TEXT,
discount_amount DECIMAL
) AS $$
DECLARE
v_coupon RECORD;
v_subscription RECORD;
v_discount DECIMAL;
BEGIN
-- Obtener cupon
SELECT * INTO v_coupon
FROM billing.coupons
WHERE code = p_coupon_code
AND is_active = TRUE
AND (valid_until IS NULL OR valid_until > CURRENT_TIMESTAMP);
IF NOT FOUND THEN
RETURN QUERY SELECT FALSE, 'Cupon no valido o expirado'::TEXT, 0::DECIMAL;
RETURN;
END IF;
-- Verificar max redemptions
IF v_coupon.max_redemptions IS NOT NULL AND v_coupon.times_redeemed >= v_coupon.max_redemptions THEN
RETURN QUERY SELECT FALSE, 'Cupon agotado'::TEXT, 0::DECIMAL;
RETURN;
END IF;
-- Verificar si ya fue usado por este tenant
IF EXISTS (SELECT 1 FROM billing.coupon_redemptions WHERE coupon_id = v_coupon.id AND tenant_id = p_tenant_id) THEN
RETURN QUERY SELECT FALSE, 'Cupon ya utilizado'::TEXT, 0::DECIMAL;
RETURN;
END IF;
-- Obtener suscripcion
SELECT * INTO v_subscription
FROM billing.tenant_subscriptions
WHERE tenant_id = p_tenant_id
AND status = 'active';
IF NOT FOUND THEN
RETURN QUERY SELECT FALSE, 'No hay suscripcion activa'::TEXT, 0::DECIMAL;
RETURN;
END IF;
-- Calcular descuento
IF v_coupon.discount_type = 'percentage' THEN
v_discount := v_subscription.current_price * (v_coupon.discount_value / 100);
ELSE
v_discount := v_coupon.discount_value;
END IF;
-- Registrar uso
INSERT INTO billing.coupon_redemptions (coupon_id, tenant_id, subscription_id, discount_amount)
VALUES (v_coupon.id, p_tenant_id, v_subscription.id, v_discount);
-- Actualizar contador
UPDATE billing.coupons SET times_redeemed = times_redeemed + 1 WHERE id = v_coupon.id;
RETURN QUERY SELECT TRUE, 'Cupon aplicado correctamente'::TEXT, v_discount;
END;
$$ LANGUAGE plpgsql;
-- =====================
-- TRIGGERS
-- =====================
-- Trigger para updated_at en plan_features
CREATE OR REPLACE FUNCTION billing.update_plan_features_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_plan_features_updated_at
BEFORE UPDATE ON billing.plan_features
FOR EACH ROW
EXECUTE FUNCTION billing.update_plan_features_timestamp();
CREATE TRIGGER trg_plan_limits_updated_at
BEFORE UPDATE ON billing.plan_limits
FOR EACH ROW
EXECUTE FUNCTION billing.update_plan_features_timestamp();
-- =====================
-- SEED DATA: Features por Plan
-- =====================
-- Features para plan Starter
INSERT INTO billing.plan_features (plan_id, feature_key, feature_name, category, enabled) VALUES
((SELECT id FROM billing.subscription_plans WHERE code = 'starter'), 'pos', 'Punto de Venta', 'sales', TRUE),
((SELECT id FROM billing.subscription_plans WHERE code = 'starter'), 'inventory_basic', 'Inventario Basico', 'inventory', TRUE),
((SELECT id FROM billing.subscription_plans WHERE code = 'starter'), 'reports_basic', 'Reportes Basicos', 'reports', TRUE),
((SELECT id FROM billing.subscription_plans WHERE code = 'starter'), 'email_support', 'Soporte por Email', 'support', TRUE)
ON CONFLICT DO NOTHING;
-- Features para plan Professional
INSERT INTO billing.plan_features (plan_id, feature_key, feature_name, category, enabled) VALUES
((SELECT id FROM billing.subscription_plans WHERE code = 'professional'), 'pos', 'Punto de Venta', 'sales', TRUE),
((SELECT id FROM billing.subscription_plans WHERE code = 'professional'), 'mobile_app', 'App Movil', 'platform', TRUE),
((SELECT id FROM billing.subscription_plans WHERE code = 'professional'), 'inventory_advanced', 'Inventario Avanzado', 'inventory', TRUE),
((SELECT id FROM billing.subscription_plans WHERE code = 'professional'), 'reports_advanced', 'Reportes Avanzados', 'reports', TRUE),
((SELECT id FROM billing.subscription_plans WHERE code = 'professional'), 'multi_branch', 'Multi-Sucursal', 'core', TRUE),
((SELECT id FROM billing.subscription_plans WHERE code = 'professional'), 'api_access', 'Acceso a API', 'integration', TRUE),
((SELECT id FROM billing.subscription_plans WHERE code = 'professional'), 'chat_support', 'Soporte por Chat', 'support', TRUE)
ON CONFLICT DO NOTHING;
-- Features para plan Business
INSERT INTO billing.plan_features (plan_id, feature_key, feature_name, category, enabled) VALUES
((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'pos', 'Punto de Venta', 'sales', TRUE),
((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'mobile_app', 'App Movil', 'platform', TRUE),
((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'desktop_app', 'App Desktop', 'platform', TRUE),
((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'inventory_advanced', 'Inventario Avanzado', 'inventory', TRUE),
((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'manufacturing', 'Manufactura', 'production', TRUE),
((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'hr_module', 'Modulo RRHH', 'hr', TRUE),
((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'accounting', 'Contabilidad', 'financial', TRUE),
((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'ai_assistant', 'Asistente IA', 'ai', TRUE),
((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'webhooks', 'Webhooks', 'integration', TRUE),
((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'priority_support', 'Soporte Prioritario', 'support', TRUE),
((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'sla_99', 'SLA 99.9%', 'support', TRUE)
ON CONFLICT DO NOTHING;
-- =====================
-- SEED DATA: Limits por Plan
-- =====================
-- Limits para plan Starter
INSERT INTO billing.plan_limits (plan_id, limit_key, limit_name, limit_value, limit_type, allow_overage) VALUES
((SELECT id FROM billing.subscription_plans WHERE code = 'starter'), 'users', 'Usuarios', 3, 'total', FALSE),
((SELECT id FROM billing.subscription_plans WHERE code = 'starter'), 'branches', 'Sucursales', 1, 'total', FALSE),
((SELECT id FROM billing.subscription_plans WHERE code = 'starter'), 'storage_mb', 'Storage (MB)', 5120, 'total', TRUE),
((SELECT id FROM billing.subscription_plans WHERE code = 'starter'), 'api_calls', 'API Calls', 5000, 'monthly', FALSE),
((SELECT id FROM billing.subscription_plans WHERE code = 'starter'), 'invoices', 'Facturas', 100, 'monthly', TRUE)
ON CONFLICT DO NOTHING;
-- Limits para plan Professional
INSERT INTO billing.plan_limits (plan_id, limit_key, limit_name, limit_value, limit_type, allow_overage, overage_unit_price) VALUES
((SELECT id FROM billing.subscription_plans WHERE code = 'professional'), 'users', 'Usuarios', 10, 'total', TRUE, 99),
((SELECT id FROM billing.subscription_plans WHERE code = 'professional'), 'branches', 'Sucursales', 3, 'total', TRUE, 199),
((SELECT id FROM billing.subscription_plans WHERE code = 'professional'), 'storage_mb', 'Storage (MB)', 20480, 'total', TRUE, 0.10),
((SELECT id FROM billing.subscription_plans WHERE code = 'professional'), 'api_calls', 'API Calls', 25000, 'monthly', TRUE, 0.001),
((SELECT id FROM billing.subscription_plans WHERE code = 'professional'), 'invoices', 'Facturas', 500, 'monthly', TRUE, 2)
ON CONFLICT DO NOTHING;
-- Limits para plan Business
INSERT INTO billing.plan_limits (plan_id, limit_key, limit_name, limit_value, limit_type, allow_overage, overage_unit_price) VALUES
((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'users', 'Usuarios', 25, 'total', TRUE, 79),
((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'branches', 'Sucursales', 10, 'total', TRUE, 149),
((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'storage_mb', 'Storage (MB)', 102400, 'total', TRUE, 0.05),
((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'api_calls', 'API Calls', 100000, 'monthly', TRUE, 0.0005),
((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'invoices', 'Facturas', 0, 'monthly', FALSE, 0), -- Ilimitadas
((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'ai_tokens', 'Tokens IA', 100000, 'monthly', TRUE, 0.0001)
ON CONFLICT DO NOTHING;
-- =====================
-- COMENTARIOS
-- =====================
COMMENT ON TABLE billing.plan_features IS 'Features habilitadas por plan de suscripcion';
COMMENT ON TABLE billing.plan_limits IS 'Limites cuantificables por plan';
COMMENT ON TABLE billing.coupons IS 'Cupones de descuento';
COMMENT ON TABLE billing.coupon_redemptions IS 'Registro de uso de cupones';
COMMENT ON TABLE billing.stripe_events IS 'Log de eventos recibidos de Stripe';
COMMENT ON FUNCTION billing.has_feature IS 'Verifica si un tenant tiene una feature habilitada';
COMMENT ON FUNCTION billing.get_limit IS 'Obtiene el valor de un limite para un tenant';
COMMENT ON FUNCTION billing.can_use_feature IS 'Verifica si un tenant puede usar una feature con limite';
COMMENT ON FUNCTION billing.get_tenant_features IS 'Obtiene todas las features habilitadas para un tenant';
COMMENT ON FUNCTION billing.get_tenant_limits IS 'Obtiene todos los limites de un tenant';
COMMENT ON FUNCTION billing.apply_coupon IS 'Aplica un cupon de descuento a un tenant';

711
ddl/09-notifications.sql Normal file
View File

@ -0,0 +1,711 @@
-- =============================================================
-- ARCHIVO: 09-notifications.sql
-- DESCRIPCION: Sistema de notificaciones, templates, preferencias
-- VERSION: 1.0.0
-- PROYECTO: ERP-Core V2
-- FECHA: 2026-01-10
-- EPIC: SAAS-NOTIFICATIONS (EPIC-SAAS-003)
-- HISTORIAS: US-040, US-041, US-042
-- =============================================================
-- =====================
-- SCHEMA: notifications
-- =====================
CREATE SCHEMA IF NOT EXISTS notifications;
-- =====================
-- TABLA: notifications.channels
-- Canales de notificacion disponibles
-- =====================
CREATE TABLE IF NOT EXISTS notifications.channels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Identificacion
code VARCHAR(30) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
description TEXT,
-- Tipo
channel_type VARCHAR(30) NOT NULL, -- email, sms, push, whatsapp, in_app, webhook
-- Configuracion del proveedor
provider VARCHAR(50), -- sendgrid, twilio, firebase, meta, custom
provider_config JSONB DEFAULT '{}',
-- Limites
rate_limit_per_minute INTEGER DEFAULT 60,
rate_limit_per_hour INTEGER DEFAULT 1000,
rate_limit_per_day INTEGER DEFAULT 10000,
-- Estado
is_active BOOLEAN DEFAULT TRUE,
is_default BOOLEAN DEFAULT FALSE,
-- Metadata
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- =====================
-- TABLA: notifications.templates
-- Templates de notificaciones
-- =====================
CREATE TABLE IF NOT EXISTS notifications.templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES auth.tenants(id) ON DELETE CASCADE, -- NULL = global template
-- Identificacion
code VARCHAR(100) NOT NULL,
name VARCHAR(200) NOT NULL,
description TEXT,
category VARCHAR(50), -- system, marketing, transactional, alert
-- Canal objetivo
channel_type VARCHAR(30) NOT NULL, -- email, sms, push, whatsapp, in_app
-- Contenido
subject VARCHAR(500), -- Para email
body_template TEXT NOT NULL,
body_html TEXT, -- Para email HTML
-- Variables disponibles
available_variables JSONB DEFAULT '[]',
-- Ejemplo: ["user_name", "company_name", "action_url"]
-- Configuracion
default_locale VARCHAR(10) DEFAULT 'es-MX',
-- Estado
is_active BOOLEAN DEFAULT TRUE,
is_system BOOLEAN DEFAULT FALSE, -- Templates del sistema no editables
-- Versionamiento
version INTEGER DEFAULT 1,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
created_by UUID REFERENCES auth.users(id),
updated_by UUID REFERENCES auth.users(id),
UNIQUE(tenant_id, code, channel_type)
);
-- =====================
-- TABLA: notifications.template_translations
-- Traducciones de templates
-- =====================
CREATE TABLE IF NOT EXISTS notifications.template_translations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
template_id UUID NOT NULL REFERENCES notifications.templates(id) ON DELETE CASCADE,
-- Idioma
locale VARCHAR(10) NOT NULL, -- es-MX, en-US, etc.
-- Contenido traducido
subject VARCHAR(500),
body_template TEXT NOT NULL,
body_html TEXT,
-- Estado
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE(template_id, locale)
);
-- =====================
-- TABLA: notifications.preferences
-- Preferencias de notificacion por usuario
-- =====================
CREATE TABLE IF NOT EXISTS notifications.preferences (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Preferencias globales
global_enabled BOOLEAN DEFAULT TRUE,
quiet_hours_start TIME,
quiet_hours_end TIME,
timezone VARCHAR(50) DEFAULT 'America/Mexico_City',
-- Preferencias por canal
email_enabled BOOLEAN DEFAULT TRUE,
sms_enabled BOOLEAN DEFAULT TRUE,
push_enabled BOOLEAN DEFAULT TRUE,
whatsapp_enabled BOOLEAN DEFAULT FALSE,
in_app_enabled BOOLEAN DEFAULT TRUE,
-- Preferencias por categoria
category_preferences JSONB DEFAULT '{}',
-- Ejemplo: {"marketing": false, "alerts": true, "reports": {"email": true, "push": false}}
-- Frecuencia de digest
digest_frequency VARCHAR(20) DEFAULT 'instant', -- instant, hourly, daily, weekly
digest_day INTEGER, -- 0-6 para weekly
digest_hour INTEGER DEFAULT 9, -- Hora del dia para daily/weekly
-- Metadata
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, tenant_id)
);
-- =====================
-- TABLA: notifications.notifications
-- Notificaciones enviadas/pendientes
-- =====================
CREATE TABLE IF NOT EXISTS notifications.notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Destinatario
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
recipient_email VARCHAR(255),
recipient_phone VARCHAR(20),
recipient_device_id UUID REFERENCES auth.devices(id),
-- Template usado
template_id UUID REFERENCES notifications.templates(id),
template_code VARCHAR(100),
-- Canal
channel_type VARCHAR(30) NOT NULL,
channel_id UUID REFERENCES notifications.channels(id),
-- Contenido renderizado
subject VARCHAR(500),
body TEXT NOT NULL,
body_html TEXT,
-- Variables usadas
variables JSONB DEFAULT '{}',
-- Contexto
context_type VARCHAR(50), -- sale, attendance, inventory, system
context_id UUID,
-- Prioridad
priority VARCHAR(20) DEFAULT 'normal', -- low, normal, high, urgent
-- Estado
status VARCHAR(20) NOT NULL DEFAULT 'pending',
-- pending, queued, sending, sent, delivered, read, failed, cancelled
-- Tracking
queued_at TIMESTAMPTZ,
sent_at TIMESTAMPTZ,
delivered_at TIMESTAMPTZ,
read_at TIMESTAMPTZ,
failed_at TIMESTAMPTZ,
-- Errores
error_message TEXT,
retry_count INTEGER DEFAULT 0,
max_retries INTEGER DEFAULT 3,
next_retry_at TIMESTAMPTZ,
-- Proveedor
provider_message_id VARCHAR(255),
provider_response JSONB DEFAULT '{}',
-- Metadata
metadata JSONB DEFAULT '{}',
-- Expiracion
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- =====================
-- TABLA: notifications.notification_batches
-- Lotes de notificaciones masivas
-- =====================
CREATE TABLE IF NOT EXISTS notifications.notification_batches (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Identificacion
name VARCHAR(200) NOT NULL,
description TEXT,
-- Template
template_id UUID REFERENCES notifications.templates(id),
channel_type VARCHAR(30) NOT NULL,
-- Audiencia
audience_type VARCHAR(30) NOT NULL, -- all_users, segment, custom
audience_filter JSONB DEFAULT '{}',
-- Contenido
variables JSONB DEFAULT '{}',
-- Programacion
scheduled_at TIMESTAMPTZ,
-- Estado
status VARCHAR(20) NOT NULL DEFAULT 'draft',
-- draft, scheduled, processing, completed, failed, cancelled
-- Estadisticas
total_recipients INTEGER DEFAULT 0,
sent_count INTEGER DEFAULT 0,
delivered_count INTEGER DEFAULT 0,
failed_count INTEGER DEFAULT 0,
read_count INTEGER DEFAULT 0,
-- Tiempos
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
created_by UUID REFERENCES auth.users(id)
);
-- =====================
-- TABLA: notifications.in_app_notifications
-- Notificaciones in-app (centro de notificaciones)
-- =====================
CREATE TABLE IF NOT EXISTS notifications.in_app_notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
-- Contenido
title VARCHAR(200) NOT NULL,
message TEXT NOT NULL,
icon VARCHAR(50),
color VARCHAR(20),
-- Accion
action_type VARCHAR(30), -- link, modal, function
action_url TEXT,
action_data JSONB DEFAULT '{}',
-- Categoria
category VARCHAR(50), -- info, success, warning, error, task
-- Contexto
context_type VARCHAR(50),
context_id UUID,
-- Estado
is_read BOOLEAN DEFAULT FALSE,
read_at TIMESTAMPTZ,
is_archived BOOLEAN DEFAULT FALSE,
archived_at TIMESTAMPTZ,
-- Prioridad y expiracion
priority VARCHAR(20) DEFAULT 'normal',
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- =====================
-- INDICES
-- =====================
-- Indices para channels
CREATE INDEX IF NOT EXISTS idx_channels_type ON notifications.channels(channel_type);
CREATE INDEX IF NOT EXISTS idx_channels_active ON notifications.channels(is_active) WHERE is_active = TRUE;
-- Indices para templates
CREATE INDEX IF NOT EXISTS idx_templates_tenant ON notifications.templates(tenant_id);
CREATE INDEX IF NOT EXISTS idx_templates_code ON notifications.templates(code);
CREATE INDEX IF NOT EXISTS idx_templates_channel ON notifications.templates(channel_type);
CREATE INDEX IF NOT EXISTS idx_templates_active ON notifications.templates(is_active) WHERE is_active = TRUE;
-- Indices para template_translations
CREATE INDEX IF NOT EXISTS idx_template_trans_template ON notifications.template_translations(template_id);
CREATE INDEX IF NOT EXISTS idx_template_trans_locale ON notifications.template_translations(locale);
-- Indices para preferences
CREATE INDEX IF NOT EXISTS idx_preferences_user ON notifications.preferences(user_id);
CREATE INDEX IF NOT EXISTS idx_preferences_tenant ON notifications.preferences(tenant_id);
-- Indices para notifications
CREATE INDEX IF NOT EXISTS idx_notifications_tenant ON notifications.notifications(tenant_id);
CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications.notifications(user_id);
CREATE INDEX IF NOT EXISTS idx_notifications_status ON notifications.notifications(status);
CREATE INDEX IF NOT EXISTS idx_notifications_channel ON notifications.notifications(channel_type);
CREATE INDEX IF NOT EXISTS idx_notifications_context ON notifications.notifications(context_type, context_id);
CREATE INDEX IF NOT EXISTS idx_notifications_pending ON notifications.notifications(status, next_retry_at)
WHERE status IN ('pending', 'queued');
CREATE INDEX IF NOT EXISTS idx_notifications_created ON notifications.notifications(created_at DESC);
-- Indices para notification_batches
CREATE INDEX IF NOT EXISTS idx_batches_tenant ON notifications.notification_batches(tenant_id);
CREATE INDEX IF NOT EXISTS idx_batches_status ON notifications.notification_batches(status);
CREATE INDEX IF NOT EXISTS idx_batches_scheduled ON notifications.notification_batches(scheduled_at)
WHERE status = 'scheduled';
-- Indices para in_app_notifications
CREATE INDEX IF NOT EXISTS idx_in_app_user ON notifications.in_app_notifications(user_id);
CREATE INDEX IF NOT EXISTS idx_in_app_tenant ON notifications.in_app_notifications(tenant_id);
CREATE INDEX IF NOT EXISTS idx_in_app_unread ON notifications.in_app_notifications(user_id, is_read)
WHERE is_read = FALSE;
CREATE INDEX IF NOT EXISTS idx_in_app_created ON notifications.in_app_notifications(created_at DESC);
-- =====================
-- RLS POLICIES
-- =====================
-- Channels son globales (lectura publica, escritura admin)
ALTER TABLE notifications.channels ENABLE ROW LEVEL SECURITY;
CREATE POLICY public_read_channels ON notifications.channels
FOR SELECT USING (true);
-- Templates: globales (tenant_id NULL) o por tenant
ALTER TABLE notifications.templates ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_or_global_templates ON notifications.templates
FOR SELECT USING (
tenant_id IS NULL
OR tenant_id = current_setting('app.current_tenant_id', true)::uuid
);
ALTER TABLE notifications.template_translations ENABLE ROW LEVEL SECURITY;
CREATE POLICY template_trans_access ON notifications.template_translations
FOR SELECT USING (
template_id IN (
SELECT id FROM notifications.templates
WHERE tenant_id IS NULL
OR tenant_id = current_setting('app.current_tenant_id', true)::uuid
)
);
-- Preferences por tenant
ALTER TABLE notifications.preferences ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_preferences ON notifications.preferences
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- Notifications por tenant
ALTER TABLE notifications.notifications ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_notifications ON notifications.notifications
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- Batches por tenant
ALTER TABLE notifications.notification_batches ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_batches ON notifications.notification_batches
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- In-app notifications por tenant
ALTER TABLE notifications.in_app_notifications ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_in_app ON notifications.in_app_notifications
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- =====================
-- FUNCIONES
-- =====================
-- Funcion para obtener template con fallback a global
CREATE OR REPLACE FUNCTION notifications.get_template(
p_tenant_id UUID,
p_code VARCHAR(100),
p_channel_type VARCHAR(30),
p_locale VARCHAR(10) DEFAULT 'es-MX'
)
RETURNS TABLE (
template_id UUID,
subject VARCHAR(500),
body_template TEXT,
body_html TEXT,
available_variables JSONB
) AS $$
BEGIN
RETURN QUERY
SELECT
t.id as template_id,
COALESCE(tt.subject, t.subject) as subject,
COALESCE(tt.body_template, t.body_template) as body_template,
COALESCE(tt.body_html, t.body_html) as body_html,
t.available_variables
FROM notifications.templates t
LEFT JOIN notifications.template_translations tt
ON tt.template_id = t.id AND tt.locale = p_locale AND tt.is_active = TRUE
WHERE t.code = p_code
AND t.channel_type = p_channel_type
AND t.is_active = TRUE
AND (t.tenant_id = p_tenant_id OR t.tenant_id IS NULL)
ORDER BY t.tenant_id NULLS LAST -- Priorizar template del tenant
LIMIT 1;
END;
$$ LANGUAGE plpgsql STABLE;
-- Funcion para verificar preferencias de usuario
CREATE OR REPLACE FUNCTION notifications.should_send(
p_user_id UUID,
p_tenant_id UUID,
p_channel_type VARCHAR(30),
p_category VARCHAR(50) DEFAULT NULL
)
RETURNS BOOLEAN AS $$
DECLARE
v_prefs RECORD;
v_channel_enabled BOOLEAN;
v_category_enabled BOOLEAN;
v_in_quiet_hours BOOLEAN;
BEGIN
-- Obtener preferencias
SELECT * INTO v_prefs
FROM notifications.preferences
WHERE user_id = p_user_id AND tenant_id = p_tenant_id;
-- Si no hay preferencias, permitir por defecto
IF NOT FOUND THEN
RETURN TRUE;
END IF;
-- Verificar si las notificaciones estan habilitadas globalmente
IF NOT v_prefs.global_enabled THEN
RETURN FALSE;
END IF;
-- Verificar canal especifico
v_channel_enabled := CASE p_channel_type
WHEN 'email' THEN v_prefs.email_enabled
WHEN 'sms' THEN v_prefs.sms_enabled
WHEN 'push' THEN v_prefs.push_enabled
WHEN 'whatsapp' THEN v_prefs.whatsapp_enabled
WHEN 'in_app' THEN v_prefs.in_app_enabled
ELSE TRUE
END;
IF NOT v_channel_enabled THEN
RETURN FALSE;
END IF;
-- Verificar categoria si se proporciona
IF p_category IS NOT NULL AND v_prefs.category_preferences ? p_category THEN
v_category_enabled := (v_prefs.category_preferences->>p_category)::boolean;
IF NOT v_category_enabled THEN
RETURN FALSE;
END IF;
END IF;
-- Verificar horas de silencio
IF v_prefs.quiet_hours_start IS NOT NULL AND v_prefs.quiet_hours_end IS NOT NULL THEN
v_in_quiet_hours := CURRENT_TIME BETWEEN v_prefs.quiet_hours_start AND v_prefs.quiet_hours_end;
IF v_in_quiet_hours AND p_channel_type IN ('push', 'sms') THEN
RETURN FALSE;
END IF;
END IF;
RETURN TRUE;
END;
$$ LANGUAGE plpgsql STABLE;
-- Funcion para encolar notificacion
CREATE OR REPLACE FUNCTION notifications.enqueue_notification(
p_tenant_id UUID,
p_user_id UUID,
p_template_code VARCHAR(100),
p_channel_type VARCHAR(30),
p_variables JSONB DEFAULT '{}',
p_context_type VARCHAR(50) DEFAULT NULL,
p_context_id UUID DEFAULT NULL,
p_priority VARCHAR(20) DEFAULT 'normal'
)
RETURNS UUID AS $$
DECLARE
v_template RECORD;
v_notification_id UUID;
v_subject VARCHAR(500);
v_body TEXT;
v_body_html TEXT;
BEGIN
-- Verificar preferencias
IF NOT notifications.should_send(p_user_id, p_tenant_id, p_channel_type) THEN
RETURN NULL;
END IF;
-- Obtener template
SELECT * INTO v_template
FROM notifications.get_template(p_tenant_id, p_template_code, p_channel_type);
IF v_template.template_id IS NULL THEN
RAISE EXCEPTION 'Template not found: %', p_template_code;
END IF;
-- TODO: Renderizar template con variables (se hara en el backend)
v_subject := v_template.subject;
v_body := v_template.body_template;
v_body_html := v_template.body_html;
-- Crear notificacion
INSERT INTO notifications.notifications (
tenant_id, user_id, template_id, template_code,
channel_type, subject, body, body_html,
variables, context_type, context_id, priority,
status, queued_at
) VALUES (
p_tenant_id, p_user_id, v_template.template_id, p_template_code,
p_channel_type, v_subject, v_body, v_body_html,
p_variables, p_context_type, p_context_id, p_priority,
'queued', CURRENT_TIMESTAMP
) RETURNING id INTO v_notification_id;
RETURN v_notification_id;
END;
$$ LANGUAGE plpgsql;
-- Funcion para marcar notificacion como leida
CREATE OR REPLACE FUNCTION notifications.mark_as_read(p_notification_id UUID)
RETURNS BOOLEAN AS $$
BEGIN
UPDATE notifications.in_app_notifications
SET is_read = TRUE, read_at = CURRENT_TIMESTAMP
WHERE id = p_notification_id AND is_read = FALSE;
RETURN FOUND;
END;
$$ LANGUAGE plpgsql;
-- Funcion para obtener conteo de no leidas
CREATE OR REPLACE FUNCTION notifications.get_unread_count(p_user_id UUID, p_tenant_id UUID)
RETURNS INTEGER AS $$
BEGIN
RETURN (
SELECT COUNT(*)::INTEGER
FROM notifications.in_app_notifications
WHERE user_id = p_user_id
AND tenant_id = p_tenant_id
AND is_read = FALSE
AND is_archived = FALSE
AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP)
);
END;
$$ LANGUAGE plpgsql STABLE;
-- Funcion para limpiar notificaciones antiguas
CREATE OR REPLACE FUNCTION notifications.cleanup_old_notifications(p_days INTEGER DEFAULT 90)
RETURNS INTEGER AS $$
DECLARE
deleted_count INTEGER;
BEGIN
-- Eliminar notificaciones enviadas antiguas
DELETE FROM notifications.notifications
WHERE created_at < CURRENT_TIMESTAMP - (p_days || ' days')::INTERVAL
AND status IN ('sent', 'delivered', 'read', 'failed', 'cancelled');
GET DIAGNOSTICS deleted_count = ROW_COUNT;
-- Eliminar in-app archivadas antiguas
DELETE FROM notifications.in_app_notifications
WHERE archived_at IS NOT NULL
AND archived_at < CURRENT_TIMESTAMP - (p_days || ' days')::INTERVAL;
RETURN deleted_count;
END;
$$ LANGUAGE plpgsql;
-- =====================
-- TRIGGERS
-- =====================
-- Trigger para updated_at
CREATE OR REPLACE FUNCTION notifications.update_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_channels_updated_at
BEFORE UPDATE ON notifications.channels
FOR EACH ROW EXECUTE FUNCTION notifications.update_timestamp();
CREATE TRIGGER trg_templates_updated_at
BEFORE UPDATE ON notifications.templates
FOR EACH ROW EXECUTE FUNCTION notifications.update_timestamp();
CREATE TRIGGER trg_preferences_updated_at
BEFORE UPDATE ON notifications.preferences
FOR EACH ROW EXECUTE FUNCTION notifications.update_timestamp();
CREATE TRIGGER trg_notifications_updated_at
BEFORE UPDATE ON notifications.notifications
FOR EACH ROW EXECUTE FUNCTION notifications.update_timestamp();
CREATE TRIGGER trg_batches_updated_at
BEFORE UPDATE ON notifications.notification_batches
FOR EACH ROW EXECUTE FUNCTION notifications.update_timestamp();
-- =====================
-- SEED DATA: Canales
-- =====================
INSERT INTO notifications.channels (code, name, channel_type, provider, is_active, is_default) VALUES
('email_sendgrid', 'Email (SendGrid)', 'email', 'sendgrid', TRUE, TRUE),
('email_smtp', 'Email (SMTP)', 'email', 'smtp', TRUE, FALSE),
('sms_twilio', 'SMS (Twilio)', 'sms', 'twilio', TRUE, TRUE),
('push_firebase', 'Push (Firebase)', 'push', 'firebase', TRUE, TRUE),
('whatsapp_meta', 'WhatsApp (Meta)', 'whatsapp', 'meta', FALSE, FALSE),
('in_app', 'In-App', 'in_app', 'internal', TRUE, TRUE)
ON CONFLICT (code) DO NOTHING;
-- =====================
-- SEED DATA: Templates del Sistema
-- =====================
INSERT INTO notifications.templates (code, name, channel_type, subject, body_template, category, is_system, available_variables) VALUES
-- Email templates
('welcome', 'Bienvenida', 'email', 'Bienvenido a {{company_name}}',
'Hola {{user_name}},\n\nBienvenido a {{company_name}}. Tu cuenta ha sido creada exitosamente.\n\nPuedes acceder desde: {{login_url}}\n\nSaludos,\nEl equipo de {{company_name}}',
'system', TRUE, '["user_name", "company_name", "login_url"]'),
('password_reset', 'Recuperar Contraseña', 'email', 'Recupera tu contraseña - {{company_name}}',
'Hola {{user_name}},\n\nHemos recibido una solicitud para recuperar tu contraseña.\n\nHaz clic aquí para restablecerla: {{reset_url}}\n\nEste enlace expira en {{expiry_hours}} horas.\n\nSi no solicitaste esto, ignora este correo.',
'system', TRUE, '["user_name", "reset_url", "expiry_hours", "company_name"]'),
('invitation', 'Invitación', 'email', 'Has sido invitado a {{company_name}}',
'Hola,\n\n{{inviter_name}} te ha invitado a unirte a {{company_name}} con el rol de {{role_name}}.\n\nAcepta la invitación aquí: {{invitation_url}}\n\nEsta invitación expira el {{expiry_date}}.',
'system', TRUE, '["inviter_name", "company_name", "role_name", "invitation_url", "expiry_date"]'),
('mfa_code', 'Código de Verificación', 'email', 'Tu código de verificación: {{code}}',
'Tu código de verificación es: {{code}}\n\nEste código expira en {{expiry_minutes}} minutos.\n\nSi no solicitaste esto, cambia tu contraseña inmediatamente.',
'system', TRUE, '["code", "expiry_minutes"]'),
-- Push templates
('attendance_reminder', 'Recordatorio de Asistencia', 'push', NULL,
'{{user_name}}, no olvides registrar tu {{attendance_type}} de hoy.',
'transactional', TRUE, '["user_name", "attendance_type"]'),
('low_stock_alert', 'Alerta de Stock Bajo', 'push', NULL,
'Stock bajo: {{product_name}} tiene solo {{quantity}} unidades en {{branch_name}}.',
'alert', TRUE, '["product_name", "quantity", "branch_name"]'),
-- In-app templates
('task_assigned', 'Tarea Asignada', 'in_app', NULL,
'{{assigner_name}} te ha asignado una nueva tarea: {{task_title}}',
'transactional', TRUE, '["assigner_name", "task_title"]'),
('payment_received', 'Pago Recibido', 'in_app', NULL,
'Se ha recibido un pago de ${{amount}} {{currency}} de {{customer_name}}.',
'transactional', TRUE, '["amount", "currency", "customer_name"]')
ON CONFLICT (tenant_id, code, channel_type) DO NOTHING;
-- =====================
-- COMENTARIOS
-- =====================
COMMENT ON TABLE notifications.channels IS 'Canales de notificacion disponibles (email, sms, push, etc.)';
COMMENT ON TABLE notifications.templates IS 'Templates de notificaciones con soporte multi-idioma';
COMMENT ON TABLE notifications.template_translations IS 'Traducciones de templates de notificaciones';
COMMENT ON TABLE notifications.preferences IS 'Preferencias de notificacion por usuario';
COMMENT ON TABLE notifications.notifications IS 'Cola y log de notificaciones';
COMMENT ON TABLE notifications.notification_batches IS 'Lotes de notificaciones masivas';
COMMENT ON TABLE notifications.in_app_notifications IS 'Notificaciones in-app para centro de notificaciones';
COMMENT ON FUNCTION notifications.get_template IS 'Obtiene template con fallback a template global';
COMMENT ON FUNCTION notifications.should_send IS 'Verifica si se debe enviar notificacion segun preferencias';
COMMENT ON FUNCTION notifications.enqueue_notification IS 'Encola una notificacion para envio';
COMMENT ON FUNCTION notifications.get_unread_count IS 'Obtiene conteo de notificaciones no leidas';

793
ddl/10-audit.sql Normal file
View File

@ -0,0 +1,793 @@
-- =============================================================
-- ARCHIVO: 10-audit.sql
-- DESCRIPCION: Sistema de audit trail, cambios de entidades, logs
-- VERSION: 1.0.0
-- PROYECTO: ERP-Core V2
-- FECHA: 2026-01-10
-- EPIC: SAAS-AUDIT (EPIC-SAAS-004)
-- HISTORIAS: US-050, US-051, US-052
-- =============================================================
-- =====================
-- SCHEMA: audit
-- =====================
CREATE SCHEMA IF NOT EXISTS audit;
-- =====================
-- TABLA: audit.audit_logs
-- Log de auditoría general
-- =====================
CREATE TABLE IF NOT EXISTS audit.audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Actor
user_id UUID REFERENCES auth.users(id),
user_email VARCHAR(255),
user_name VARCHAR(200),
session_id UUID,
impersonator_id UUID REFERENCES auth.users(id), -- Si está siendo impersonado
-- Acción
action VARCHAR(50) NOT NULL, -- create, read, update, delete, login, logout, export, etc.
action_category VARCHAR(50), -- data, auth, system, config, billing
-- Recurso
resource_type VARCHAR(100) NOT NULL, -- user, product, sale, branch, etc.
resource_id UUID,
resource_name VARCHAR(255),
-- Cambios
old_values JSONB,
new_values JSONB,
changed_fields TEXT[],
-- Contexto
ip_address INET,
user_agent TEXT,
device_info JSONB DEFAULT '{}',
location JSONB DEFAULT '{}', -- {country, city, lat, lng}
-- Request info
request_id VARCHAR(100),
request_method VARCHAR(10),
request_path TEXT,
request_params JSONB DEFAULT '{}',
-- Resultado
status VARCHAR(20) DEFAULT 'success', -- success, failure, partial
error_message TEXT,
duration_ms INTEGER,
-- Metadata
metadata JSONB DEFAULT '{}',
tags TEXT[] DEFAULT '{}',
-- Timestamp
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Particionar por fecha para mejor rendimiento (recomendado en producción)
-- CREATE TABLE audit.audit_logs_y2026m01 PARTITION OF audit.audit_logs
-- FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
-- =====================
-- TABLA: audit.entity_changes
-- Historial detallado de cambios por entidad
-- =====================
CREATE TABLE IF NOT EXISTS audit.entity_changes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Entidad
entity_type VARCHAR(100) NOT NULL,
entity_id UUID NOT NULL,
entity_name VARCHAR(255),
-- Versión
version INTEGER NOT NULL DEFAULT 1,
previous_version INTEGER,
-- Snapshot completo de la entidad
data_snapshot JSONB NOT NULL,
-- Cambios específicos
changes JSONB DEFAULT '[]',
-- Ejemplo: [{"field": "price", "old": 100, "new": 150, "type": "update"}]
-- Actor
changed_by UUID REFERENCES auth.users(id),
change_reason TEXT,
-- Tipo de cambio
change_type VARCHAR(20) NOT NULL, -- create, update, delete, restore
-- Timestamp
changed_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Índice único para versiones
CREATE UNIQUE INDEX IF NOT EXISTS idx_entity_version ON audit.entity_changes(entity_type, entity_id, version);
-- =====================
-- TABLA: audit.sensitive_data_access
-- Acceso a datos sensibles
-- =====================
CREATE TABLE IF NOT EXISTS audit.sensitive_data_access (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Actor
user_id UUID NOT NULL REFERENCES auth.users(id),
session_id UUID,
-- Datos accedidos
data_type VARCHAR(100) NOT NULL, -- pii, financial, medical, credentials
data_category VARCHAR(100), -- customer_data, employee_data, payment_info
entity_type VARCHAR(100),
entity_id UUID,
-- Acción
access_type VARCHAR(30) NOT NULL, -- view, export, modify, decrypt
access_reason TEXT,
-- Contexto
ip_address INET,
user_agent TEXT,
-- Resultado
was_authorized BOOLEAN DEFAULT TRUE,
denial_reason TEXT,
-- Timestamp
accessed_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- =====================
-- TABLA: audit.data_exports
-- Log de exportaciones de datos
-- =====================
CREATE TABLE IF NOT EXISTS audit.data_exports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Actor
user_id UUID NOT NULL REFERENCES auth.users(id),
-- Exportación
export_type VARCHAR(50) NOT NULL, -- report, backup, gdpr_request, bulk_export
export_format VARCHAR(20), -- csv, xlsx, pdf, json
entity_types TEXT[] NOT NULL,
-- Filtros aplicados
filters JSONB DEFAULT '{}',
date_range_start TIMESTAMPTZ,
date_range_end TIMESTAMPTZ,
-- Resultado
record_count INTEGER,
file_size_bytes BIGINT,
file_hash VARCHAR(64), -- SHA-256 del archivo
-- Estado
status VARCHAR(20) DEFAULT 'pending', -- pending, processing, completed, failed, expired
-- Archivos
download_url TEXT,
download_expires_at TIMESTAMPTZ,
download_count INTEGER DEFAULT 0,
-- Contexto
ip_address INET,
user_agent TEXT,
-- Timestamps
requested_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ
);
-- =====================
-- TABLA: audit.login_history
-- Historial de inicios de sesión
-- =====================
CREATE TABLE IF NOT EXISTS audit.login_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES auth.tenants(id) ON DELETE CASCADE,
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
-- Identificación del usuario
email VARCHAR(255),
username VARCHAR(100),
-- Resultado
status VARCHAR(20) NOT NULL, -- success, failed, blocked, mfa_required, mfa_failed
-- Método de autenticación
auth_method VARCHAR(30), -- password, sso, oauth, mfa, magic_link, biometric
oauth_provider VARCHAR(30),
-- MFA
mfa_method VARCHAR(20), -- totp, sms, email, push
mfa_verified BOOLEAN,
-- Dispositivo
device_id UUID REFERENCES auth.devices(id),
device_fingerprint VARCHAR(255),
device_type VARCHAR(30), -- desktop, mobile, tablet
device_os VARCHAR(50),
device_browser VARCHAR(50),
-- Ubicación
ip_address INET,
user_agent TEXT,
country_code VARCHAR(2),
city VARCHAR(100),
latitude DECIMAL(10, 8),
longitude DECIMAL(11, 8),
-- Riesgo
risk_score INTEGER, -- 0-100
risk_factors JSONB DEFAULT '[]',
is_suspicious BOOLEAN DEFAULT FALSE,
is_new_device BOOLEAN DEFAULT FALSE,
is_new_location BOOLEAN DEFAULT FALSE,
-- Error info
failure_reason VARCHAR(100),
failure_count INTEGER,
-- Timestamp
attempted_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- =====================
-- TABLA: audit.permission_changes
-- Cambios en permisos y roles
-- =====================
CREATE TABLE IF NOT EXISTS audit.permission_changes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Actor
changed_by UUID NOT NULL REFERENCES auth.users(id),
-- Usuario afectado
target_user_id UUID NOT NULL REFERENCES auth.users(id),
target_user_email VARCHAR(255),
-- Tipo de cambio
change_type VARCHAR(30) NOT NULL, -- role_assigned, role_revoked, permission_granted, permission_revoked
-- Rol/Permiso
role_id UUID,
role_code VARCHAR(50),
permission_id UUID,
permission_code VARCHAR(100),
-- Contexto
branch_id UUID REFERENCES core.branches(id),
scope VARCHAR(30), -- global, tenant, branch
-- Valores anteriores
previous_roles TEXT[],
previous_permissions TEXT[],
-- Razón
reason TEXT,
-- Timestamp
changed_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- =====================
-- TABLA: audit.config_changes
-- Cambios en configuración del sistema
-- =====================
CREATE TABLE IF NOT EXISTS audit.config_changes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES auth.tenants(id) ON DELETE CASCADE, -- NULL = config global
-- Actor
changed_by UUID NOT NULL REFERENCES auth.users(id),
-- Configuración
config_type VARCHAR(50) NOT NULL, -- tenant_settings, user_settings, system_settings, feature_flags
config_key VARCHAR(100) NOT NULL,
config_path TEXT, -- Path jerárquico: billing.invoicing.prefix
-- Valores
old_value JSONB,
new_value JSONB,
-- Contexto
reason TEXT,
ticket_id VARCHAR(50), -- Referencia a ticket de soporte
-- Timestamp
changed_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- =====================
-- INDICES
-- =====================
-- Indices para audit_logs
CREATE INDEX IF NOT EXISTS idx_audit_logs_tenant ON audit.audit_logs(tenant_id);
CREATE INDEX IF NOT EXISTS idx_audit_logs_user ON audit.audit_logs(user_id);
CREATE INDEX IF NOT EXISTS idx_audit_logs_action ON audit.audit_logs(action);
CREATE INDEX IF NOT EXISTS idx_audit_logs_resource ON audit.audit_logs(resource_type, resource_id);
CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit.audit_logs(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_logs_category ON audit.audit_logs(action_category);
CREATE INDEX IF NOT EXISTS idx_audit_logs_status ON audit.audit_logs(status) WHERE status = 'failure';
CREATE INDEX IF NOT EXISTS idx_audit_logs_tags ON audit.audit_logs USING GIN(tags);
-- Indices para entity_changes
CREATE INDEX IF NOT EXISTS idx_entity_changes_tenant ON audit.entity_changes(tenant_id);
CREATE INDEX IF NOT EXISTS idx_entity_changes_entity ON audit.entity_changes(entity_type, entity_id);
CREATE INDEX IF NOT EXISTS idx_entity_changes_changed_by ON audit.entity_changes(changed_by);
CREATE INDEX IF NOT EXISTS idx_entity_changes_date ON audit.entity_changes(changed_at DESC);
-- Indices para sensitive_data_access
CREATE INDEX IF NOT EXISTS idx_sensitive_access_tenant ON audit.sensitive_data_access(tenant_id);
CREATE INDEX IF NOT EXISTS idx_sensitive_access_user ON audit.sensitive_data_access(user_id);
CREATE INDEX IF NOT EXISTS idx_sensitive_access_type ON audit.sensitive_data_access(data_type);
CREATE INDEX IF NOT EXISTS idx_sensitive_access_date ON audit.sensitive_data_access(accessed_at DESC);
CREATE INDEX IF NOT EXISTS idx_sensitive_unauthorized ON audit.sensitive_data_access(was_authorized)
WHERE was_authorized = FALSE;
-- Indices para data_exports
CREATE INDEX IF NOT EXISTS idx_exports_tenant ON audit.data_exports(tenant_id);
CREATE INDEX IF NOT EXISTS idx_exports_user ON audit.data_exports(user_id);
CREATE INDEX IF NOT EXISTS idx_exports_status ON audit.data_exports(status);
CREATE INDEX IF NOT EXISTS idx_exports_date ON audit.data_exports(requested_at DESC);
-- Indices para login_history
CREATE INDEX IF NOT EXISTS idx_login_tenant ON audit.login_history(tenant_id);
CREATE INDEX IF NOT EXISTS idx_login_user ON audit.login_history(user_id);
CREATE INDEX IF NOT EXISTS idx_login_status ON audit.login_history(status);
CREATE INDEX IF NOT EXISTS idx_login_date ON audit.login_history(attempted_at DESC);
CREATE INDEX IF NOT EXISTS idx_login_ip ON audit.login_history(ip_address);
CREATE INDEX IF NOT EXISTS idx_login_suspicious ON audit.login_history(is_suspicious) WHERE is_suspicious = TRUE;
CREATE INDEX IF NOT EXISTS idx_login_failed ON audit.login_history(status, email) WHERE status = 'failed';
-- Indices para permission_changes
CREATE INDEX IF NOT EXISTS idx_perm_changes_tenant ON audit.permission_changes(tenant_id);
CREATE INDEX IF NOT EXISTS idx_perm_changes_target ON audit.permission_changes(target_user_id);
CREATE INDEX IF NOT EXISTS idx_perm_changes_date ON audit.permission_changes(changed_at DESC);
-- Indices para config_changes
CREATE INDEX IF NOT EXISTS idx_config_changes_tenant ON audit.config_changes(tenant_id);
CREATE INDEX IF NOT EXISTS idx_config_changes_type ON audit.config_changes(config_type);
CREATE INDEX IF NOT EXISTS idx_config_changes_date ON audit.config_changes(changed_at DESC);
-- =====================
-- RLS POLICIES
-- =====================
ALTER TABLE audit.audit_logs ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_audit_logs ON audit.audit_logs
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
ALTER TABLE audit.entity_changes ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_entity_changes ON audit.entity_changes
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
ALTER TABLE audit.sensitive_data_access ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_sensitive ON audit.sensitive_data_access
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
ALTER TABLE audit.data_exports ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_exports ON audit.data_exports
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
ALTER TABLE audit.login_history ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_login ON audit.login_history
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
ALTER TABLE audit.permission_changes ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_perm_changes ON audit.permission_changes
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
ALTER TABLE audit.config_changes ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_config_changes ON audit.config_changes
USING (
tenant_id IS NULL
OR tenant_id = current_setting('app.current_tenant_id', true)::uuid
);
-- =====================
-- FUNCIONES
-- =====================
-- Función para registrar log de auditoría
CREATE OR REPLACE FUNCTION audit.log(
p_tenant_id UUID,
p_user_id UUID,
p_action VARCHAR(50),
p_resource_type VARCHAR(100),
p_resource_id UUID DEFAULT NULL,
p_old_values JSONB DEFAULT NULL,
p_new_values JSONB DEFAULT NULL,
p_metadata JSONB DEFAULT '{}'
)
RETURNS UUID AS $$
DECLARE
v_log_id UUID;
v_changed_fields TEXT[];
BEGIN
-- Calcular campos cambiados
IF p_old_values IS NOT NULL AND p_new_values IS NOT NULL THEN
SELECT ARRAY_AGG(key)
INTO v_changed_fields
FROM (
SELECT key FROM jsonb_object_keys(p_old_values) AS key
WHERE p_old_values->key IS DISTINCT FROM p_new_values->key
UNION
SELECT key FROM jsonb_object_keys(p_new_values) AS key
WHERE NOT p_old_values ? key
) AS changed;
END IF;
INSERT INTO audit.audit_logs (
tenant_id, user_id, action, resource_type, resource_id,
old_values, new_values, changed_fields, metadata
) VALUES (
p_tenant_id, p_user_id, p_action, p_resource_type, p_resource_id,
p_old_values, p_new_values, v_changed_fields, p_metadata
) RETURNING id INTO v_log_id;
RETURN v_log_id;
END;
$$ LANGUAGE plpgsql;
-- Función para registrar cambio de entidad con versionamiento
CREATE OR REPLACE FUNCTION audit.log_entity_change(
p_tenant_id UUID,
p_entity_type VARCHAR(100),
p_entity_id UUID,
p_data_snapshot JSONB,
p_changes JSONB DEFAULT '[]',
p_changed_by UUID DEFAULT NULL,
p_change_type VARCHAR(20) DEFAULT 'update',
p_change_reason TEXT DEFAULT NULL
)
RETURNS INTEGER AS $$
DECLARE
v_version INTEGER;
v_prev_version INTEGER;
BEGIN
-- Obtener versión actual
SELECT COALESCE(MAX(version), 0) INTO v_prev_version
FROM audit.entity_changes
WHERE entity_type = p_entity_type AND entity_id = p_entity_id;
v_version := v_prev_version + 1;
INSERT INTO audit.entity_changes (
tenant_id, entity_type, entity_id, version, previous_version,
data_snapshot, changes, changed_by, change_type, change_reason
) VALUES (
p_tenant_id, p_entity_type, p_entity_id, v_version, v_prev_version,
p_data_snapshot, p_changes, p_changed_by, p_change_type, p_change_reason
);
RETURN v_version;
END;
$$ LANGUAGE plpgsql;
-- Función para obtener historial de una entidad
CREATE OR REPLACE FUNCTION audit.get_entity_history(
p_entity_type VARCHAR(100),
p_entity_id UUID,
p_limit INTEGER DEFAULT 50
)
RETURNS TABLE (
version INTEGER,
change_type VARCHAR(20),
data_snapshot JSONB,
changes JSONB,
changed_by UUID,
change_reason TEXT,
changed_at TIMESTAMPTZ
) AS $$
BEGIN
RETURN QUERY
SELECT
ec.version,
ec.change_type,
ec.data_snapshot,
ec.changes,
ec.changed_by,
ec.change_reason,
ec.changed_at
FROM audit.entity_changes ec
WHERE ec.entity_type = p_entity_type
AND ec.entity_id = p_entity_id
ORDER BY ec.version DESC
LIMIT p_limit;
END;
$$ LANGUAGE plpgsql STABLE;
-- Función para obtener snapshot de una entidad en un momento dado
CREATE OR REPLACE FUNCTION audit.get_entity_at_time(
p_entity_type VARCHAR(100),
p_entity_id UUID,
p_at_time TIMESTAMPTZ
)
RETURNS JSONB AS $$
BEGIN
RETURN (
SELECT data_snapshot
FROM audit.entity_changes
WHERE entity_type = p_entity_type
AND entity_id = p_entity_id
AND changed_at <= p_at_time
ORDER BY changed_at DESC
LIMIT 1
);
END;
$$ LANGUAGE plpgsql STABLE;
-- Función para registrar acceso a datos sensibles
CREATE OR REPLACE FUNCTION audit.log_sensitive_access(
p_tenant_id UUID,
p_user_id UUID,
p_data_type VARCHAR(100),
p_access_type VARCHAR(30),
p_entity_type VARCHAR(100) DEFAULT NULL,
p_entity_id UUID DEFAULT NULL,
p_was_authorized BOOLEAN DEFAULT TRUE,
p_access_reason TEXT DEFAULT NULL
)
RETURNS UUID AS $$
DECLARE
v_access_id UUID;
BEGIN
INSERT INTO audit.sensitive_data_access (
tenant_id, user_id, data_type, access_type,
entity_type, entity_id, was_authorized, access_reason
) VALUES (
p_tenant_id, p_user_id, p_data_type, p_access_type,
p_entity_type, p_entity_id, p_was_authorized, p_access_reason
) RETURNING id INTO v_access_id;
RETURN v_access_id;
END;
$$ LANGUAGE plpgsql;
-- Función para registrar login
CREATE OR REPLACE FUNCTION audit.log_login(
p_user_id UUID,
p_tenant_id UUID,
p_email VARCHAR(255),
p_status VARCHAR(20),
p_auth_method VARCHAR(30) DEFAULT 'password',
p_ip_address INET DEFAULT NULL,
p_user_agent TEXT DEFAULT NULL,
p_device_info JSONB DEFAULT '{}'
)
RETURNS UUID AS $$
DECLARE
v_login_id UUID;
v_is_new_device BOOLEAN := FALSE;
v_is_new_location BOOLEAN := FALSE;
v_failure_count INTEGER := 0;
BEGIN
-- Verificar si es dispositivo nuevo
IF p_device_info->>'fingerprint' IS NOT NULL THEN
SELECT NOT EXISTS (
SELECT 1 FROM audit.login_history
WHERE user_id = p_user_id
AND device_fingerprint = p_device_info->>'fingerprint'
AND status = 'success'
) INTO v_is_new_device;
END IF;
-- Contar intentos fallidos recientes
IF p_status = 'failed' THEN
SELECT COUNT(*) INTO v_failure_count
FROM audit.login_history
WHERE email = p_email
AND status = 'failed'
AND attempted_at > CURRENT_TIMESTAMP - INTERVAL '1 hour';
END IF;
INSERT INTO audit.login_history (
user_id, tenant_id, email, status, auth_method,
ip_address, user_agent,
device_fingerprint, device_type, device_os, device_browser,
is_new_device, failure_count
) VALUES (
p_user_id, p_tenant_id, p_email, p_status, p_auth_method,
p_ip_address, p_user_agent,
p_device_info->>'fingerprint',
p_device_info->>'type',
p_device_info->>'os',
p_device_info->>'browser',
v_is_new_device, v_failure_count
) RETURNING id INTO v_login_id;
RETURN v_login_id;
END;
$$ LANGUAGE plpgsql;
-- Función para obtener estadísticas de auditoría
CREATE OR REPLACE FUNCTION audit.get_stats(
p_tenant_id UUID,
p_days INTEGER DEFAULT 30
)
RETURNS TABLE (
total_actions BIGINT,
unique_users BIGINT,
actions_by_category JSONB,
actions_by_day JSONB,
top_resources JSONB,
failed_actions BIGINT
) AS $$
BEGIN
RETURN QUERY
SELECT
COUNT(*) as total_actions,
COUNT(DISTINCT al.user_id) as unique_users,
jsonb_object_agg(COALESCE(al.action_category, 'other'), cat_count) as actions_by_category,
jsonb_object_agg(day_date, day_count) as actions_by_day,
jsonb_agg(DISTINCT jsonb_build_object('type', al.resource_type, 'count', res_count)) as top_resources,
COUNT(*) FILTER (WHERE al.status = 'failure') as failed_actions
FROM audit.audit_logs al
LEFT JOIN (
SELECT action_category, COUNT(*) as cat_count
FROM audit.audit_logs
WHERE tenant_id = p_tenant_id
AND created_at > CURRENT_TIMESTAMP - (p_days || ' days')::INTERVAL
GROUP BY action_category
) cat ON cat.action_category = al.action_category
LEFT JOIN (
SELECT DATE(created_at) as day_date, COUNT(*) as day_count
FROM audit.audit_logs
WHERE tenant_id = p_tenant_id
AND created_at > CURRENT_TIMESTAMP - (p_days || ' days')::INTERVAL
GROUP BY DATE(created_at)
) days ON TRUE
LEFT JOIN (
SELECT resource_type, COUNT(*) as res_count
FROM audit.audit_logs
WHERE tenant_id = p_tenant_id
AND created_at > CURRENT_TIMESTAMP - (p_days || ' days')::INTERVAL
GROUP BY resource_type
ORDER BY res_count DESC
LIMIT 10
) res ON res.resource_type = al.resource_type
WHERE al.tenant_id = p_tenant_id
AND al.created_at > CURRENT_TIMESTAMP - (p_days || ' days')::INTERVAL
LIMIT 1;
END;
$$ LANGUAGE plpgsql STABLE;
-- Función para limpiar logs antiguos
CREATE OR REPLACE FUNCTION audit.cleanup_old_logs(
p_audit_days INTEGER DEFAULT 365,
p_login_days INTEGER DEFAULT 90,
p_export_days INTEGER DEFAULT 30
)
RETURNS TABLE (
audit_deleted INTEGER,
login_deleted INTEGER,
export_deleted INTEGER
) AS $$
DECLARE
v_audit INTEGER;
v_login INTEGER;
v_export INTEGER;
BEGIN
-- Limpiar audit_logs
DELETE FROM audit.audit_logs
WHERE created_at < CURRENT_TIMESTAMP - (p_audit_days || ' days')::INTERVAL;
GET DIAGNOSTICS v_audit = ROW_COUNT;
-- Limpiar login_history
DELETE FROM audit.login_history
WHERE attempted_at < CURRENT_TIMESTAMP - (p_login_days || ' days')::INTERVAL;
GET DIAGNOSTICS v_login = ROW_COUNT;
-- Limpiar data_exports completados/expirados
DELETE FROM audit.data_exports
WHERE (status IN ('completed', 'expired', 'failed'))
AND requested_at < CURRENT_TIMESTAMP - (p_export_days || ' days')::INTERVAL;
GET DIAGNOSTICS v_export = ROW_COUNT;
RETURN QUERY SELECT v_audit, v_login, v_export;
END;
$$ LANGUAGE plpgsql;
-- =====================
-- TRIGGER GENÉRICO PARA AUDITORÍA
-- =====================
-- Función trigger para auditoría automática
CREATE OR REPLACE FUNCTION audit.audit_trigger_func()
RETURNS TRIGGER AS $$
DECLARE
v_old_data JSONB;
v_new_data JSONB;
v_tenant_id UUID;
v_user_id UUID;
BEGIN
-- Obtener tenant_id y user_id del contexto
v_tenant_id := current_setting('app.current_tenant_id', true)::uuid;
v_user_id := current_setting('app.current_user_id', true)::uuid;
IF TG_OP = 'INSERT' THEN
v_new_data := to_jsonb(NEW);
PERFORM audit.log_entity_change(
v_tenant_id,
TG_TABLE_SCHEMA || '.' || TG_TABLE_NAME,
(v_new_data->>'id')::uuid,
v_new_data,
'[]'::jsonb,
v_user_id,
'create'
);
RETURN NEW;
ELSIF TG_OP = 'UPDATE' THEN
v_old_data := to_jsonb(OLD);
v_new_data := to_jsonb(NEW);
-- Solo registrar si hay cambios reales
IF v_old_data IS DISTINCT FROM v_new_data THEN
PERFORM audit.log_entity_change(
v_tenant_id,
TG_TABLE_SCHEMA || '.' || TG_TABLE_NAME,
(v_new_data->>'id')::uuid,
v_new_data,
jsonb_build_array(jsonb_build_object(
'old', v_old_data,
'new', v_new_data
)),
v_user_id,
'update'
);
END IF;
RETURN NEW;
ELSIF TG_OP = 'DELETE' THEN
v_old_data := to_jsonb(OLD);
PERFORM audit.log_entity_change(
v_tenant_id,
TG_TABLE_SCHEMA || '.' || TG_TABLE_NAME,
(v_old_data->>'id')::uuid,
v_old_data,
'[]'::jsonb,
v_user_id,
'delete'
);
RETURN OLD;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
-- =====================
-- COMENTARIOS
-- =====================
COMMENT ON TABLE audit.audit_logs IS 'Log de auditoría general para todas las acciones';
COMMENT ON TABLE audit.entity_changes IS 'Historial de cambios con versionamiento por entidad';
COMMENT ON TABLE audit.sensitive_data_access IS 'Log de acceso a datos sensibles';
COMMENT ON TABLE audit.data_exports IS 'Registro de exportaciones de datos';
COMMENT ON TABLE audit.login_history IS 'Historial de intentos de inicio de sesión';
COMMENT ON TABLE audit.permission_changes IS 'Log de cambios en permisos y roles';
COMMENT ON TABLE audit.config_changes IS 'Log de cambios en configuración del sistema';
COMMENT ON FUNCTION audit.log IS 'Registra una acción en el log de auditoría';
COMMENT ON FUNCTION audit.log_entity_change IS 'Registra un cambio versionado de una entidad';
COMMENT ON FUNCTION audit.get_entity_history IS 'Obtiene el historial de cambios de una entidad';
COMMENT ON FUNCTION audit.get_entity_at_time IS 'Obtiene el snapshot de una entidad en un momento específico';
COMMENT ON FUNCTION audit.log_login IS 'Registra un intento de inicio de sesión';
COMMENT ON FUNCTION audit.audit_trigger_func IS 'Función trigger para auditoría automática de tablas';

424
ddl/11-feature-flags.sql Normal file
View File

@ -0,0 +1,424 @@
-- =============================================================
-- ARCHIVO: 11-feature-flags.sql
-- DESCRIPCION: Sistema de Feature Flags para rollout gradual
-- VERSION: 1.0.0
-- PROYECTO: ERP-Core V2
-- FECHA: 2026-01-10
-- EPIC: SAAS-BILLING (EPIC-SAAS-002)
-- HISTORIAS: US-022
-- =============================================================
-- =====================
-- SCHEMA: flags
-- =====================
CREATE SCHEMA IF NOT EXISTS flags;
-- =====================
-- TABLA: flags.flags
-- Definicion de feature flags globales (US-022)
-- =====================
CREATE TABLE IF NOT EXISTS flags.flags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Identificacion
key VARCHAR(100) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
category VARCHAR(100),
-- Estado global
enabled BOOLEAN DEFAULT FALSE,
-- Rollout gradual
rollout_percentage INTEGER DEFAULT 0 CHECK (rollout_percentage BETWEEN 0 AND 100),
-- Targeting
targeting_rules JSONB DEFAULT '[]',
-- Ejemplo: [{"type": "tenant", "operator": "in", "values": ["uuid1", "uuid2"]}]
-- Variantes (para A/B testing)
variants JSONB DEFAULT '[]',
-- Ejemplo: [{"key": "control", "weight": 50}, {"key": "variant_a", "weight": 50}]
default_variant VARCHAR(100) DEFAULT 'control',
-- Metadata
metadata JSONB DEFAULT '{}',
tags TEXT[] DEFAULT '{}',
-- Lifecycle
starts_at TIMESTAMPTZ,
ends_at TIMESTAMPTZ,
-- Audit
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_by UUID REFERENCES auth.users(id),
archived_at TIMESTAMPTZ
);
-- Indices para flags
CREATE INDEX IF NOT EXISTS idx_flags_key ON flags.flags(key);
CREATE INDEX IF NOT EXISTS idx_flags_enabled ON flags.flags(enabled) WHERE enabled = TRUE;
CREATE INDEX IF NOT EXISTS idx_flags_category ON flags.flags(category);
CREATE INDEX IF NOT EXISTS idx_flags_tags ON flags.flags USING GIN(tags);
CREATE INDEX IF NOT EXISTS idx_flags_active ON flags.flags(starts_at, ends_at)
WHERE archived_at IS NULL;
-- =====================
-- TABLA: flags.flag_overrides
-- Overrides por tenant para feature flags (US-022)
-- =====================
CREATE TABLE IF NOT EXISTS flags.flag_overrides (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
flag_id UUID NOT NULL REFERENCES flags.flags(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Override
enabled BOOLEAN NOT NULL,
variant VARCHAR(100), -- Variante especifica para este tenant
-- Razon
reason TEXT,
expires_at TIMESTAMPTZ,
-- Audit
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE(flag_id, tenant_id)
);
-- Indices para flag_overrides
CREATE INDEX IF NOT EXISTS idx_flag_overrides_flag ON flags.flag_overrides(flag_id);
CREATE INDEX IF NOT EXISTS idx_flag_overrides_tenant ON flags.flag_overrides(tenant_id);
CREATE INDEX IF NOT EXISTS idx_flag_overrides_active ON flags.flag_overrides(expires_at)
WHERE expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP;
-- =====================
-- TABLA: flags.flag_evaluations
-- Log de evaluaciones de flags (para analytics)
-- =====================
CREATE TABLE IF NOT EXISTS flags.flag_evaluations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
flag_id UUID NOT NULL REFERENCES flags.flags(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
user_id UUID REFERENCES auth.users(id),
-- Resultado
result BOOLEAN NOT NULL,
variant VARCHAR(100),
-- Contexto de evaluacion
evaluation_context JSONB DEFAULT '{}',
evaluation_reason VARCHAR(100), -- 'override', 'targeting', 'rollout', 'default'
evaluated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Indices para flag_evaluations (particionado por fecha recomendado en produccion)
CREATE INDEX IF NOT EXISTS idx_flag_evaluations_flag ON flags.flag_evaluations(flag_id);
CREATE INDEX IF NOT EXISTS idx_flag_evaluations_tenant ON flags.flag_evaluations(tenant_id);
CREATE INDEX IF NOT EXISTS idx_flag_evaluations_date ON flags.flag_evaluations(evaluated_at DESC);
-- =====================
-- TABLA: flags.flag_segments
-- Segmentos de usuarios para targeting avanzado
-- =====================
CREATE TABLE IF NOT EXISTS flags.flag_segments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Identificacion
key VARCHAR(100) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
-- Reglas del segmento
rules JSONB NOT NULL DEFAULT '[]',
-- Ejemplo: [{"attribute": "plan", "operator": "eq", "value": "business"}]
-- Estado
is_active BOOLEAN DEFAULT TRUE,
-- Audit
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Indices para flag_segments
CREATE INDEX IF NOT EXISTS idx_flag_segments_key ON flags.flag_segments(key);
CREATE INDEX IF NOT EXISTS idx_flag_segments_active ON flags.flag_segments(is_active) WHERE is_active = TRUE;
-- =====================
-- RLS POLICIES
-- =====================
-- Flags son globales, lectura publica
ALTER TABLE flags.flags ENABLE ROW LEVEL SECURITY;
CREATE POLICY public_read_flags ON flags.flags
FOR SELECT USING (true);
-- Overrides son por tenant
ALTER TABLE flags.flag_overrides ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_overrides ON flags.flag_overrides
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- Evaluations son por tenant
ALTER TABLE flags.flag_evaluations ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_evaluations ON flags.flag_evaluations
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- Segments son globales
ALTER TABLE flags.flag_segments ENABLE ROW LEVEL SECURITY;
CREATE POLICY public_read_segments ON flags.flag_segments
FOR SELECT USING (true);
-- =====================
-- FUNCIONES
-- =====================
-- Funcion principal para evaluar un flag
CREATE OR REPLACE FUNCTION flags.evaluate_flag(
p_flag_key VARCHAR(100),
p_tenant_id UUID,
p_user_id UUID DEFAULT NULL,
p_context JSONB DEFAULT '{}'
)
RETURNS TABLE (
enabled BOOLEAN,
variant VARCHAR(100),
reason VARCHAR(100)
) AS $$
DECLARE
v_flag RECORD;
v_override RECORD;
v_result BOOLEAN;
v_variant VARCHAR(100);
v_reason VARCHAR(100);
BEGIN
-- Obtener flag
SELECT * INTO v_flag
FROM flags.flags
WHERE key = p_flag_key
AND archived_at IS NULL
AND (starts_at IS NULL OR starts_at <= CURRENT_TIMESTAMP)
AND (ends_at IS NULL OR ends_at > CURRENT_TIMESTAMP);
IF NOT FOUND THEN
RETURN QUERY SELECT FALSE, 'control'::VARCHAR(100), 'flag_not_found'::VARCHAR(100);
RETURN;
END IF;
-- Verificar override para el tenant
SELECT * INTO v_override
FROM flags.flag_overrides
WHERE flag_id = v_flag.id
AND tenant_id = p_tenant_id
AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP);
IF FOUND THEN
v_result := v_override.enabled;
v_variant := COALESCE(v_override.variant, v_flag.default_variant);
v_reason := 'override';
ELSE
-- Evaluar targeting rules (simplificado)
IF v_flag.targeting_rules IS NOT NULL AND jsonb_array_length(v_flag.targeting_rules) > 0 THEN
-- Por ahora, si hay targeting rules y el tenant esta en la lista, habilitar
IF EXISTS (
SELECT 1 FROM jsonb_array_elements(v_flag.targeting_rules) AS rule
WHERE rule->>'type' = 'tenant'
AND p_tenant_id::text = ANY(
SELECT jsonb_array_elements_text(rule->'values')
)
) THEN
v_result := TRUE;
v_reason := 'targeting';
END IF;
END IF;
-- Si no paso targeting, evaluar rollout
IF v_reason IS NULL THEN
IF v_flag.rollout_percentage > 0 THEN
-- Usar hash del tenant_id para consistencia
IF (abs(hashtext(p_tenant_id::text)) % 100) < v_flag.rollout_percentage THEN
v_result := TRUE;
v_reason := 'rollout';
ELSE
v_result := v_flag.enabled;
v_reason := 'default';
END IF;
ELSE
v_result := v_flag.enabled;
v_reason := 'default';
END IF;
END IF;
v_variant := v_flag.default_variant;
END IF;
-- Registrar evaluacion (async en produccion)
INSERT INTO flags.flag_evaluations (flag_id, tenant_id, user_id, result, variant, evaluation_context, evaluation_reason)
VALUES (v_flag.id, p_tenant_id, p_user_id, v_result, v_variant, p_context, v_reason);
RETURN QUERY SELECT v_result, v_variant, v_reason;
END;
$$ LANGUAGE plpgsql;
-- Funcion simplificada para solo verificar si esta habilitado
CREATE OR REPLACE FUNCTION flags.is_enabled(
p_flag_key VARCHAR(100),
p_tenant_id UUID
)
RETURNS BOOLEAN AS $$
DECLARE
v_enabled BOOLEAN;
BEGIN
SELECT enabled INTO v_enabled
FROM flags.evaluate_flag(p_flag_key, p_tenant_id);
RETURN COALESCE(v_enabled, FALSE);
END;
$$ LANGUAGE plpgsql;
-- Funcion para obtener todos los flags de un tenant
CREATE OR REPLACE FUNCTION flags.get_all_flags_for_tenant(p_tenant_id UUID)
RETURNS TABLE (
flag_key VARCHAR(100),
flag_name VARCHAR(255),
enabled BOOLEAN,
variant VARCHAR(100)
) AS $$
BEGIN
RETURN QUERY
SELECT
f.key as flag_key,
f.name as flag_name,
COALESCE(fo.enabled, f.enabled) as enabled,
COALESCE(fo.variant, f.default_variant) as variant
FROM flags.flags f
LEFT JOIN flags.flag_overrides fo ON fo.flag_id = f.id AND fo.tenant_id = p_tenant_id
WHERE f.archived_at IS NULL
AND (f.starts_at IS NULL OR f.starts_at <= CURRENT_TIMESTAMP)
AND (f.ends_at IS NULL OR f.ends_at > CURRENT_TIMESTAMP);
END;
$$ LANGUAGE plpgsql STABLE;
-- Funcion para obtener estadisticas de un flag
CREATE OR REPLACE FUNCTION flags.get_flag_stats(
p_flag_key VARCHAR(100),
p_days INTEGER DEFAULT 7
)
RETURNS TABLE (
total_evaluations BIGINT,
enabled_count BIGINT,
disabled_count BIGINT,
enabled_percentage DECIMAL,
unique_tenants BIGINT
) AS $$
BEGIN
RETURN QUERY
SELECT
COUNT(*) as total_evaluations,
COUNT(*) FILTER (WHERE fe.result = TRUE) as enabled_count,
COUNT(*) FILTER (WHERE fe.result = FALSE) as disabled_count,
ROUND(COUNT(*) FILTER (WHERE fe.result = TRUE)::DECIMAL / NULLIF(COUNT(*), 0) * 100, 2) as enabled_percentage,
COUNT(DISTINCT fe.tenant_id) as unique_tenants
FROM flags.flag_evaluations fe
JOIN flags.flags f ON f.id = fe.flag_id
WHERE f.key = p_flag_key
AND fe.evaluated_at >= CURRENT_TIMESTAMP - (p_days || ' days')::INTERVAL;
END;
$$ LANGUAGE plpgsql STABLE;
-- Funcion para limpiar evaluaciones antiguas
CREATE OR REPLACE FUNCTION flags.cleanup_old_evaluations(p_days INTEGER DEFAULT 30)
RETURNS INTEGER AS $$
DECLARE
deleted_count INTEGER;
BEGIN
DELETE FROM flags.flag_evaluations
WHERE evaluated_at < CURRENT_TIMESTAMP - (p_days || ' days')::INTERVAL;
GET DIAGNOSTICS deleted_count = ROW_COUNT;
RETURN deleted_count;
END;
$$ LANGUAGE plpgsql;
-- =====================
-- TRIGGERS
-- =====================
-- Trigger para updated_at en flags
CREATE OR REPLACE FUNCTION flags.update_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_flags_updated_at
BEFORE UPDATE ON flags.flags
FOR EACH ROW
EXECUTE FUNCTION flags.update_timestamp();
CREATE TRIGGER trg_flag_overrides_updated_at
BEFORE UPDATE ON flags.flag_overrides
FOR EACH ROW
EXECUTE FUNCTION flags.update_timestamp();
CREATE TRIGGER trg_flag_segments_updated_at
BEFORE UPDATE ON flags.flag_segments
FOR EACH ROW
EXECUTE FUNCTION flags.update_timestamp();
-- =====================
-- SEED DATA: Feature Flags Base
-- =====================
INSERT INTO flags.flags (key, name, description, category, enabled, rollout_percentage, tags) VALUES
-- Features nuevas (deshabilitadas por default)
('new_dashboard', 'Nuevo Dashboard', 'Dashboard rediseñado con metricas en tiempo real', 'ui', FALSE, 0, '{ui,beta}'),
('ai_assistant', 'Asistente IA', 'Chat con asistente de inteligencia artificial', 'ai', FALSE, 0, '{ai,premium}'),
('whatsapp_notifications', 'Notificaciones WhatsApp', 'Enviar notificaciones via WhatsApp', 'notifications', FALSE, 0, '{notifications,beta}'),
('offline_mode', 'Modo Offline Mejorado', 'Sincronizacion offline avanzada', 'mobile', FALSE, 0, '{mobile,beta}'),
('multi_currency', 'Multi-Moneda', 'Soporte para multiples monedas', 'billing', FALSE, 0, '{billing,premium}'),
-- Features de rollout gradual
('new_checkout', 'Nuevo Flujo de Checkout', 'Checkout optimizado con menos pasos', 'billing', FALSE, 25, '{billing,ab_test}'),
('smart_inventory', 'Inventario Inteligente', 'Predicciones de stock con ML', 'inventory', FALSE, 10, '{inventory,ai,beta}'),
-- Features habilitadas globalmente
('dark_mode', 'Modo Oscuro', 'Tema oscuro para la interfaz', 'ui', TRUE, 100, '{ui}'),
('export_csv', 'Exportar a CSV', 'Exportar datos a formato CSV', 'reports', TRUE, 100, '{reports}'),
('notifications_center', 'Centro de Notificaciones', 'Panel centralizado de notificaciones', 'notifications', TRUE, 100, '{notifications}'),
-- Kill switches (para emergencias)
('maintenance_mode', 'Modo Mantenimiento', 'Mostrar pagina de mantenimiento', 'system', FALSE, 0, '{system,kill_switch}'),
('disable_signups', 'Deshabilitar Registros', 'Pausar nuevos registros', 'system', FALSE, 0, '{system,kill_switch}'),
('read_only_mode', 'Modo Solo Lectura', 'Deshabilitar escrituras en DB', 'system', FALSE, 0, '{system,kill_switch}')
ON CONFLICT (key) DO NOTHING;
-- =====================
-- SEED DATA: Segmentos
-- =====================
INSERT INTO flags.flag_segments (key, name, description, rules) VALUES
('beta_testers', 'Beta Testers', 'Usuarios en programa beta', '[{"attribute": "tags", "operator": "contains", "value": "beta"}]'),
('enterprise_customers', 'Clientes Enterprise', 'Tenants con plan enterprise', '[{"attribute": "plan", "operator": "eq", "value": "enterprise"}]'),
('high_usage', 'Alto Uso', 'Tenants con alto volumen de uso', '[{"attribute": "monthly_transactions", "operator": "gt", "value": 1000}]'),
('mexico_only', 'Solo Mexico', 'Tenants en Mexico', '[{"attribute": "country", "operator": "eq", "value": "MX"}]')
ON CONFLICT (key) DO NOTHING;
-- =====================
-- COMENTARIOS
-- =====================
COMMENT ON TABLE flags.flags IS 'Feature flags globales para control de funcionalidades';
COMMENT ON TABLE flags.flag_overrides IS 'Overrides de flags por tenant';
COMMENT ON TABLE flags.flag_evaluations IS 'Log de evaluaciones de flags para analytics';
COMMENT ON TABLE flags.flag_segments IS 'Segmentos de usuarios para targeting';
COMMENT ON FUNCTION flags.evaluate_flag IS 'Evalua un flag para un tenant, retorna enabled, variant y reason';
COMMENT ON FUNCTION flags.is_enabled IS 'Verifica si un flag esta habilitado para un tenant';
COMMENT ON FUNCTION flags.get_all_flags_for_tenant IS 'Obtiene todos los flags con su estado para un tenant';
COMMENT ON FUNCTION flags.get_flag_stats IS 'Obtiene estadisticas de uso de un flag';

724
ddl/12-webhooks.sql Normal file
View File

@ -0,0 +1,724 @@
-- =============================================================
-- ARCHIVO: 12-webhooks.sql
-- DESCRIPCION: Sistema de webhooks, endpoints, entregas
-- VERSION: 1.0.0
-- PROYECTO: ERP-Core V2
-- FECHA: 2026-01-10
-- EPIC: SAAS-INTEGRATIONS (EPIC-SAAS-005)
-- HISTORIAS: US-060, US-061
-- =============================================================
-- =====================
-- SCHEMA: webhooks
-- =====================
CREATE SCHEMA IF NOT EXISTS webhooks;
-- =====================
-- TABLA: webhooks.event_types
-- Tipos de eventos disponibles para webhooks
-- =====================
CREATE TABLE IF NOT EXISTS webhooks.event_types (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Identificación
code VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(200) NOT NULL,
description TEXT,
category VARCHAR(50), -- sales, inventory, auth, billing, system
-- Schema del payload
payload_schema JSONB DEFAULT '{}',
-- Estado
is_active BOOLEAN DEFAULT TRUE,
is_internal BOOLEAN DEFAULT FALSE, -- Eventos internos no expuestos
-- Metadata
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- =====================
-- TABLA: webhooks.endpoints
-- Endpoints configurados por tenant
-- =====================
CREATE TABLE IF NOT EXISTS webhooks.endpoints (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Identificación
name VARCHAR(200) NOT NULL,
description TEXT,
-- URL destino
url TEXT NOT NULL,
http_method VARCHAR(10) DEFAULT 'POST',
-- Autenticación
auth_type VARCHAR(30) DEFAULT 'none', -- none, basic, bearer, hmac, oauth2
auth_config JSONB DEFAULT '{}',
-- basic: {username, password}
-- bearer: {token}
-- hmac: {secret, header_name, algorithm}
-- oauth2: {client_id, client_secret, token_url}
-- Headers personalizados
custom_headers JSONB DEFAULT '{}',
-- Eventos suscritos
subscribed_events TEXT[] NOT NULL DEFAULT '{}',
-- Filtros
filters JSONB DEFAULT '{}',
-- Ejemplo: {"branch_id": ["uuid1", "uuid2"], "amount_gte": 1000}
-- Configuración de reintentos
retry_enabled BOOLEAN DEFAULT TRUE,
max_retries INTEGER DEFAULT 5,
retry_delay_seconds INTEGER DEFAULT 60,
retry_backoff_multiplier DECIMAL(3,1) DEFAULT 2.0,
-- Timeouts
timeout_seconds INTEGER DEFAULT 30,
-- Estado
is_active BOOLEAN DEFAULT TRUE,
is_verified BOOLEAN DEFAULT FALSE,
verified_at TIMESTAMPTZ,
-- Secreto para firma
signing_secret VARCHAR(255),
-- Estadísticas
total_deliveries INTEGER DEFAULT 0,
successful_deliveries INTEGER DEFAULT 0,
failed_deliveries INTEGER DEFAULT 0,
last_delivery_at TIMESTAMPTZ,
last_success_at TIMESTAMPTZ,
last_failure_at TIMESTAMPTZ,
-- Rate limiting
rate_limit_per_minute INTEGER DEFAULT 60,
rate_limit_per_hour INTEGER DEFAULT 1000,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
created_by UUID REFERENCES auth.users(id),
UNIQUE(tenant_id, url)
);
-- =====================
-- TABLA: webhooks.deliveries
-- Log de entregas de webhooks
-- =====================
CREATE TABLE IF NOT EXISTS webhooks.deliveries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
endpoint_id UUID NOT NULL REFERENCES webhooks.endpoints(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Evento
event_type VARCHAR(100) NOT NULL,
event_id UUID NOT NULL,
-- Payload enviado
payload JSONB NOT NULL,
payload_hash VARCHAR(64), -- SHA-256 para deduplicación
-- Request
request_url TEXT NOT NULL,
request_method VARCHAR(10) NOT NULL,
request_headers JSONB DEFAULT '{}',
-- Response
response_status INTEGER,
response_headers JSONB DEFAULT '{}',
response_body TEXT,
response_time_ms INTEGER,
-- Estado
status VARCHAR(20) NOT NULL DEFAULT 'pending',
-- pending, sending, delivered, failed, retrying, cancelled
-- Reintentos
attempt_number INTEGER DEFAULT 1,
max_attempts INTEGER DEFAULT 5,
next_retry_at TIMESTAMPTZ,
-- Error info
error_message TEXT,
error_code VARCHAR(50),
-- Timestamps
scheduled_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- =====================
-- TABLA: webhooks.events
-- Cola de eventos pendientes de envío
-- =====================
CREATE TABLE IF NOT EXISTS webhooks.events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Tipo de evento
event_type VARCHAR(100) NOT NULL,
-- Payload del evento
payload JSONB NOT NULL,
-- Contexto
resource_type VARCHAR(100),
resource_id UUID,
triggered_by UUID REFERENCES auth.users(id),
-- Estado
status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending, processing, dispatched, failed
-- Procesamiento
processed_at TIMESTAMPTZ,
dispatched_endpoints INTEGER DEFAULT 0,
failed_endpoints INTEGER DEFAULT 0,
-- Deduplicación
idempotency_key VARCHAR(255),
-- Metadata
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMPTZ
);
-- =====================
-- TABLA: webhooks.subscriptions
-- Suscripciones individuales evento-endpoint
-- =====================
CREATE TABLE IF NOT EXISTS webhooks.subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
endpoint_id UUID NOT NULL REFERENCES webhooks.endpoints(id) ON DELETE CASCADE,
event_type_id UUID NOT NULL REFERENCES webhooks.event_types(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Filtros específicos para esta suscripción
filters JSONB DEFAULT '{}',
-- Transformación del payload
payload_template JSONB, -- Template para transformar el payload
-- Estado
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE(endpoint_id, event_type_id)
);
-- =====================
-- TABLA: webhooks.endpoint_logs
-- Logs de actividad de endpoints
-- =====================
CREATE TABLE IF NOT EXISTS webhooks.endpoint_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
endpoint_id UUID NOT NULL REFERENCES webhooks.endpoints(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Tipo de log
log_type VARCHAR(30) NOT NULL, -- config_changed, activated, deactivated, verified, error, rate_limited
-- Detalles
message TEXT,
details JSONB DEFAULT '{}',
-- Actor
actor_id UUID REFERENCES auth.users(id),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- =====================
-- INDICES
-- =====================
-- Indices para event_types
CREATE INDEX IF NOT EXISTS idx_event_types_code ON webhooks.event_types(code);
CREATE INDEX IF NOT EXISTS idx_event_types_category ON webhooks.event_types(category);
CREATE INDEX IF NOT EXISTS idx_event_types_active ON webhooks.event_types(is_active) WHERE is_active = TRUE;
-- Indices para endpoints
CREATE INDEX IF NOT EXISTS idx_endpoints_tenant ON webhooks.endpoints(tenant_id);
CREATE INDEX IF NOT EXISTS idx_endpoints_active ON webhooks.endpoints(is_active) WHERE is_active = TRUE;
CREATE INDEX IF NOT EXISTS idx_endpoints_events ON webhooks.endpoints USING GIN(subscribed_events);
-- Indices para deliveries
CREATE INDEX IF NOT EXISTS idx_deliveries_endpoint ON webhooks.deliveries(endpoint_id);
CREATE INDEX IF NOT EXISTS idx_deliveries_tenant ON webhooks.deliveries(tenant_id);
CREATE INDEX IF NOT EXISTS idx_deliveries_event ON webhooks.deliveries(event_type, event_id);
CREATE INDEX IF NOT EXISTS idx_deliveries_status ON webhooks.deliveries(status);
CREATE INDEX IF NOT EXISTS idx_deliveries_pending ON webhooks.deliveries(status, next_retry_at)
WHERE status IN ('pending', 'retrying');
CREATE INDEX IF NOT EXISTS idx_deliveries_created ON webhooks.deliveries(created_at DESC);
-- Indices para events
CREATE INDEX IF NOT EXISTS idx_events_tenant ON webhooks.events(tenant_id);
CREATE INDEX IF NOT EXISTS idx_events_type ON webhooks.events(event_type);
CREATE INDEX IF NOT EXISTS idx_events_status ON webhooks.events(status);
CREATE INDEX IF NOT EXISTS idx_events_pending ON webhooks.events(status, created_at)
WHERE status = 'pending';
CREATE INDEX IF NOT EXISTS idx_events_idempotency ON webhooks.events(idempotency_key)
WHERE idempotency_key IS NOT NULL;
-- Indices para subscriptions
CREATE INDEX IF NOT EXISTS idx_subs_endpoint ON webhooks.subscriptions(endpoint_id);
CREATE INDEX IF NOT EXISTS idx_subs_event_type ON webhooks.subscriptions(event_type_id);
CREATE INDEX IF NOT EXISTS idx_subs_tenant ON webhooks.subscriptions(tenant_id);
-- Indices para endpoint_logs
CREATE INDEX IF NOT EXISTS idx_endpoint_logs_endpoint ON webhooks.endpoint_logs(endpoint_id);
CREATE INDEX IF NOT EXISTS idx_endpoint_logs_created ON webhooks.endpoint_logs(created_at DESC);
-- =====================
-- RLS POLICIES
-- =====================
-- Event types son globales (lectura pública)
ALTER TABLE webhooks.event_types ENABLE ROW LEVEL SECURITY;
CREATE POLICY public_read_event_types ON webhooks.event_types
FOR SELECT USING (is_active = TRUE AND is_internal = FALSE);
-- Endpoints por tenant
ALTER TABLE webhooks.endpoints ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_endpoints ON webhooks.endpoints
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- Deliveries por tenant
ALTER TABLE webhooks.deliveries ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_deliveries ON webhooks.deliveries
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- Events por tenant
ALTER TABLE webhooks.events ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_events ON webhooks.events
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- Subscriptions por tenant
ALTER TABLE webhooks.subscriptions ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_subscriptions ON webhooks.subscriptions
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- Endpoint logs por tenant
ALTER TABLE webhooks.endpoint_logs ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_endpoint_logs ON webhooks.endpoint_logs
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- =====================
-- FUNCIONES
-- =====================
-- Función para generar signing secret
CREATE OR REPLACE FUNCTION webhooks.generate_signing_secret()
RETURNS VARCHAR(255) AS $$
BEGIN
RETURN 'whsec_' || encode(gen_random_bytes(32), 'hex');
END;
$$ LANGUAGE plpgsql;
-- Función para crear un endpoint con secreto
CREATE OR REPLACE FUNCTION webhooks.create_endpoint(
p_tenant_id UUID,
p_name VARCHAR(200),
p_url TEXT,
p_subscribed_events TEXT[],
p_auth_type VARCHAR(30) DEFAULT 'none',
p_auth_config JSONB DEFAULT '{}',
p_created_by UUID DEFAULT NULL
)
RETURNS UUID AS $$
DECLARE
v_endpoint_id UUID;
BEGIN
INSERT INTO webhooks.endpoints (
tenant_id, name, url, subscribed_events,
auth_type, auth_config, signing_secret, created_by
) VALUES (
p_tenant_id, p_name, p_url, p_subscribed_events,
p_auth_type, p_auth_config, webhooks.generate_signing_secret(), p_created_by
) RETURNING id INTO v_endpoint_id;
-- Log de creación
INSERT INTO webhooks.endpoint_logs (endpoint_id, tenant_id, log_type, message, actor_id)
VALUES (v_endpoint_id, p_tenant_id, 'created', 'Endpoint created', p_created_by);
RETURN v_endpoint_id;
END;
$$ LANGUAGE plpgsql;
-- Función para emitir un evento
CREATE OR REPLACE FUNCTION webhooks.emit_event(
p_tenant_id UUID,
p_event_type VARCHAR(100),
p_payload JSONB,
p_resource_type VARCHAR(100) DEFAULT NULL,
p_resource_id UUID DEFAULT NULL,
p_triggered_by UUID DEFAULT NULL,
p_idempotency_key VARCHAR(255) DEFAULT NULL
)
RETURNS UUID AS $$
DECLARE
v_event_id UUID;
BEGIN
-- Verificar deduplicación
IF p_idempotency_key IS NOT NULL THEN
SELECT id INTO v_event_id
FROM webhooks.events
WHERE tenant_id = p_tenant_id
AND idempotency_key = p_idempotency_key
AND created_at > CURRENT_TIMESTAMP - INTERVAL '24 hours';
IF FOUND THEN
RETURN v_event_id;
END IF;
END IF;
-- Crear evento
INSERT INTO webhooks.events (
tenant_id, event_type, payload,
resource_type, resource_id, triggered_by,
idempotency_key, expires_at
) VALUES (
p_tenant_id, p_event_type, p_payload,
p_resource_type, p_resource_id, p_triggered_by,
p_idempotency_key, CURRENT_TIMESTAMP + INTERVAL '7 days'
) RETURNING id INTO v_event_id;
RETURN v_event_id;
END;
$$ LANGUAGE plpgsql;
-- Función para obtener endpoints suscritos a un evento
CREATE OR REPLACE FUNCTION webhooks.get_subscribed_endpoints(
p_tenant_id UUID,
p_event_type VARCHAR(100)
)
RETURNS TABLE (
endpoint_id UUID,
url TEXT,
auth_type VARCHAR(30),
auth_config JSONB,
custom_headers JSONB,
signing_secret VARCHAR(255),
timeout_seconds INTEGER,
filters JSONB
) AS $$
BEGIN
RETURN QUERY
SELECT
e.id as endpoint_id,
e.url,
e.auth_type,
e.auth_config,
e.custom_headers,
e.signing_secret,
e.timeout_seconds,
e.filters
FROM webhooks.endpoints e
WHERE e.tenant_id = p_tenant_id
AND e.is_active = TRUE
AND p_event_type = ANY(e.subscribed_events);
END;
$$ LANGUAGE plpgsql STABLE;
-- Función para encolar entrega
CREATE OR REPLACE FUNCTION webhooks.queue_delivery(
p_endpoint_id UUID,
p_event_type VARCHAR(100),
p_event_id UUID,
p_payload JSONB
)
RETURNS UUID AS $$
DECLARE
v_endpoint RECORD;
v_delivery_id UUID;
BEGIN
-- Obtener endpoint
SELECT * INTO v_endpoint
FROM webhooks.endpoints
WHERE id = p_endpoint_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'Endpoint not found: %', p_endpoint_id;
END IF;
-- Crear delivery
INSERT INTO webhooks.deliveries (
endpoint_id, tenant_id, event_type, event_id,
payload, payload_hash,
request_url, request_method, request_headers,
max_attempts, status, scheduled_at
) VALUES (
p_endpoint_id, v_endpoint.tenant_id, p_event_type, p_event_id,
p_payload, encode(sha256(p_payload::text::bytea), 'hex'),
v_endpoint.url, v_endpoint.http_method, v_endpoint.custom_headers,
v_endpoint.max_retries + 1, 'pending', CURRENT_TIMESTAMP
) RETURNING id INTO v_delivery_id;
RETURN v_delivery_id;
END;
$$ LANGUAGE plpgsql;
-- Función para marcar entrega como completada
CREATE OR REPLACE FUNCTION webhooks.mark_delivery_completed(
p_delivery_id UUID,
p_response_status INTEGER,
p_response_headers JSONB,
p_response_body TEXT,
p_response_time_ms INTEGER
)
RETURNS BOOLEAN AS $$
DECLARE
v_delivery RECORD;
v_is_success BOOLEAN;
BEGIN
SELECT * INTO v_delivery
FROM webhooks.deliveries
WHERE id = p_delivery_id;
IF NOT FOUND THEN
RETURN FALSE;
END IF;
v_is_success := p_response_status >= 200 AND p_response_status < 300;
UPDATE webhooks.deliveries
SET
status = CASE WHEN v_is_success THEN 'delivered' ELSE 'failed' END,
response_status = p_response_status,
response_headers = p_response_headers,
response_body = LEFT(p_response_body, 10000), -- Truncar respuesta larga
response_time_ms = p_response_time_ms,
completed_at = CURRENT_TIMESTAMP
WHERE id = p_delivery_id;
-- Actualizar estadísticas del endpoint
UPDATE webhooks.endpoints
SET
total_deliveries = total_deliveries + 1,
successful_deliveries = successful_deliveries + CASE WHEN v_is_success THEN 1 ELSE 0 END,
failed_deliveries = failed_deliveries + CASE WHEN v_is_success THEN 0 ELSE 1 END,
last_delivery_at = CURRENT_TIMESTAMP,
last_success_at = CASE WHEN v_is_success THEN CURRENT_TIMESTAMP ELSE last_success_at END,
last_failure_at = CASE WHEN v_is_success THEN last_failure_at ELSE CURRENT_TIMESTAMP END,
updated_at = CURRENT_TIMESTAMP
WHERE id = v_delivery.endpoint_id;
RETURN v_is_success;
END;
$$ LANGUAGE plpgsql;
-- Función para programar reintento
CREATE OR REPLACE FUNCTION webhooks.schedule_retry(
p_delivery_id UUID,
p_error_message TEXT DEFAULT NULL
)
RETURNS BOOLEAN AS $$
DECLARE
v_delivery RECORD;
v_endpoint RECORD;
v_delay_seconds INTEGER;
BEGIN
SELECT d.*, e.retry_delay_seconds, e.retry_backoff_multiplier
INTO v_delivery
FROM webhooks.deliveries d
JOIN webhooks.endpoints e ON e.id = d.endpoint_id
WHERE d.id = p_delivery_id;
IF NOT FOUND THEN
RETURN FALSE;
END IF;
-- Verificar si quedan reintentos
IF v_delivery.attempt_number >= v_delivery.max_attempts THEN
UPDATE webhooks.deliveries
SET status = 'failed', error_message = p_error_message, completed_at = CURRENT_TIMESTAMP
WHERE id = p_delivery_id;
RETURN FALSE;
END IF;
-- Calcular delay con backoff exponencial
v_delay_seconds := v_delivery.retry_delay_seconds *
POWER(v_delivery.retry_backoff_multiplier, v_delivery.attempt_number - 1);
-- Programar reintento
UPDATE webhooks.deliveries
SET
status = 'retrying',
attempt_number = attempt_number + 1,
next_retry_at = CURRENT_TIMESTAMP + (v_delay_seconds || ' seconds')::INTERVAL,
error_message = p_error_message
WHERE id = p_delivery_id;
RETURN TRUE;
END;
$$ LANGUAGE plpgsql;
-- Función para obtener estadísticas de un endpoint
CREATE OR REPLACE FUNCTION webhooks.get_endpoint_stats(
p_endpoint_id UUID,
p_days INTEGER DEFAULT 7
)
RETURNS TABLE (
total_deliveries BIGINT,
successful BIGINT,
failed BIGINT,
success_rate DECIMAL,
avg_response_time_ms DECIMAL,
deliveries_by_day JSONB,
errors_by_type JSONB
) AS $$
BEGIN
RETURN QUERY
SELECT
COUNT(*) as total_deliveries,
COUNT(*) FILTER (WHERE d.status = 'delivered') as successful,
COUNT(*) FILTER (WHERE d.status = 'failed') as failed,
ROUND(
COUNT(*) FILTER (WHERE d.status = 'delivered')::DECIMAL /
NULLIF(COUNT(*), 0) * 100, 2
) as success_rate,
ROUND(AVG(d.response_time_ms)::DECIMAL, 2) as avg_response_time_ms,
jsonb_object_agg(
COALESCE(DATE(d.created_at)::TEXT, 'unknown'),
day_count
) as deliveries_by_day,
jsonb_object_agg(
COALESCE(d.error_code, 'unknown'),
error_count
) FILTER (WHERE d.error_code IS NOT NULL) as errors_by_type
FROM webhooks.deliveries d
LEFT JOIN (
SELECT DATE(created_at) as day, COUNT(*) as day_count
FROM webhooks.deliveries
WHERE endpoint_id = p_endpoint_id
AND created_at > CURRENT_TIMESTAMP - (p_days || ' days')::INTERVAL
GROUP BY DATE(created_at)
) days ON TRUE
LEFT JOIN (
SELECT error_code, COUNT(*) as error_count
FROM webhooks.deliveries
WHERE endpoint_id = p_endpoint_id
AND error_code IS NOT NULL
AND created_at > CURRENT_TIMESTAMP - (p_days || ' days')::INTERVAL
GROUP BY error_code
) errors ON TRUE
WHERE d.endpoint_id = p_endpoint_id
AND d.created_at > CURRENT_TIMESTAMP - (p_days || ' days')::INTERVAL;
END;
$$ LANGUAGE plpgsql STABLE;
-- Función para limpiar entregas antiguas
CREATE OR REPLACE FUNCTION webhooks.cleanup_old_deliveries(p_days INTEGER DEFAULT 30)
RETURNS INTEGER AS $$
DECLARE
deleted_count INTEGER;
BEGIN
DELETE FROM webhooks.deliveries
WHERE created_at < CURRENT_TIMESTAMP - (p_days || ' days')::INTERVAL
AND status IN ('delivered', 'failed', 'cancelled');
GET DIAGNOSTICS deleted_count = ROW_COUNT;
-- También limpiar eventos procesados
DELETE FROM webhooks.events
WHERE created_at < CURRENT_TIMESTAMP - (p_days || ' days')::INTERVAL
AND status = 'dispatched';
RETURN deleted_count;
END;
$$ LANGUAGE plpgsql;
-- =====================
-- TRIGGERS
-- =====================
CREATE OR REPLACE FUNCTION webhooks.update_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_event_types_updated_at
BEFORE UPDATE ON webhooks.event_types
FOR EACH ROW EXECUTE FUNCTION webhooks.update_timestamp();
CREATE TRIGGER trg_endpoints_updated_at
BEFORE UPDATE ON webhooks.endpoints
FOR EACH ROW EXECUTE FUNCTION webhooks.update_timestamp();
CREATE TRIGGER trg_subscriptions_updated_at
BEFORE UPDATE ON webhooks.subscriptions
FOR EACH ROW EXECUTE FUNCTION webhooks.update_timestamp();
-- =====================
-- SEED DATA: Event Types
-- =====================
INSERT INTO webhooks.event_types (code, name, category, description, payload_schema) VALUES
-- Sales events
('sale.created', 'Venta Creada', 'sales', 'Se creó una nueva venta', '{"type": "object", "properties": {"sale_id": {"type": "string"}, "total": {"type": "number"}}}'),
('sale.completed', 'Venta Completada', 'sales', 'Una venta fue completada y pagada', '{}'),
('sale.cancelled', 'Venta Cancelada', 'sales', 'Una venta fue cancelada', '{}'),
('sale.refunded', 'Venta Reembolsada', 'sales', 'Se procesó un reembolso', '{}'),
-- Inventory events
('inventory.low_stock', 'Stock Bajo', 'inventory', 'Un producto alcanzó el nivel mínimo de stock', '{}'),
('inventory.out_of_stock', 'Sin Stock', 'inventory', 'Un producto se quedó sin stock', '{}'),
('inventory.adjusted', 'Inventario Ajustado', 'inventory', 'Se realizó un ajuste de inventario', '{}'),
('inventory.received', 'Mercancía Recibida', 'inventory', 'Se recibió mercancía en el almacén', '{}'),
-- Customer events
('customer.created', 'Cliente Creado', 'customers', 'Se registró un nuevo cliente', '{}'),
('customer.updated', 'Cliente Actualizado', 'customers', 'Se actualizó información del cliente', '{}'),
-- Auth events
('user.created', 'Usuario Creado', 'auth', 'Se creó un nuevo usuario', '{}'),
('user.login', 'Inicio de Sesión', 'auth', 'Un usuario inició sesión', '{}'),
('user.password_reset', 'Contraseña Restablecida', 'auth', 'Un usuario restableció su contraseña', '{}'),
-- Billing events
('subscription.created', 'Suscripción Creada', 'billing', 'Se creó una nueva suscripción', '{}'),
('subscription.renewed', 'Suscripción Renovada', 'billing', 'Se renovó una suscripción', '{}'),
('subscription.cancelled', 'Suscripción Cancelada', 'billing', 'Se canceló una suscripción', '{}'),
('invoice.created', 'Factura Creada', 'billing', 'Se generó una nueva factura', '{}'),
('invoice.paid', 'Factura Pagada', 'billing', 'Se pagó una factura', '{}'),
('payment.received', 'Pago Recibido', 'billing', 'Se recibió un pago', '{}'),
('payment.failed', 'Pago Fallido', 'billing', 'Un pago falló', '{}'),
-- System events
('system.maintenance', 'Mantenimiento Programado', 'system', 'Se programó mantenimiento del sistema', '{}'),
('system.alert', 'Alerta del Sistema', 'system', 'Se generó una alerta del sistema', '{}')
ON CONFLICT (code) DO NOTHING;
-- =====================
-- COMENTARIOS
-- =====================
COMMENT ON TABLE webhooks.event_types IS 'Tipos de eventos disponibles para webhooks';
COMMENT ON TABLE webhooks.endpoints IS 'Endpoints configurados por tenant para recibir webhooks';
COMMENT ON TABLE webhooks.deliveries IS 'Log de entregas de webhooks con estado y reintentos';
COMMENT ON TABLE webhooks.events IS 'Cola de eventos pendientes de despacho';
COMMENT ON TABLE webhooks.subscriptions IS 'Suscripciones individuales evento-endpoint';
COMMENT ON TABLE webhooks.endpoint_logs IS 'Logs de actividad de endpoints';
COMMENT ON FUNCTION webhooks.emit_event IS 'Emite un evento a la cola de webhooks';
COMMENT ON FUNCTION webhooks.queue_delivery IS 'Encola una entrega de webhook';
COMMENT ON FUNCTION webhooks.mark_delivery_completed IS 'Marca una entrega como completada';
COMMENT ON FUNCTION webhooks.schedule_retry IS 'Programa un reintento de entrega';
COMMENT ON FUNCTION webhooks.get_endpoint_stats IS 'Obtiene estadísticas de un endpoint';

736
ddl/13-storage.sql Normal file
View File

@ -0,0 +1,736 @@
-- =============================================================
-- ARCHIVO: 13-storage.sql
-- DESCRIPCION: Sistema de almacenamiento de archivos, carpetas, uploads
-- VERSION: 1.0.0
-- PROYECTO: ERP-Core V2
-- FECHA: 2026-01-10
-- EPIC: SAAS-STORAGE (EPIC-SAAS-006)
-- HISTORIAS: US-070, US-071, US-072
-- =============================================================
-- =====================
-- SCHEMA: storage
-- =====================
CREATE SCHEMA IF NOT EXISTS storage;
-- =====================
-- TABLA: storage.buckets
-- Contenedores de almacenamiento
-- =====================
CREATE TABLE IF NOT EXISTS storage.buckets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Identificación
name VARCHAR(100) NOT NULL UNIQUE,
description TEXT,
-- Tipo
bucket_type VARCHAR(30) NOT NULL DEFAULT 'private',
-- public: acceso público sin autenticación
-- private: requiere autenticación
-- protected: requiere token temporal
-- Configuración
max_file_size_mb INTEGER DEFAULT 50,
allowed_mime_types TEXT[] DEFAULT '{}', -- Vacío = todos permitidos
allowed_extensions TEXT[] DEFAULT '{}',
-- Políticas
auto_delete_days INTEGER, -- NULL = no auto-eliminar
versioning_enabled BOOLEAN DEFAULT FALSE,
max_versions INTEGER DEFAULT 5,
-- Storage backend
storage_provider VARCHAR(30) DEFAULT 'local', -- local, s3, gcs, azure
storage_config JSONB DEFAULT '{}',
-- Límites por tenant
quota_per_tenant_gb INTEGER,
-- Estado
is_active BOOLEAN DEFAULT TRUE,
is_system BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- =====================
-- TABLA: storage.folders
-- Estructura de carpetas virtuales
-- =====================
CREATE TABLE IF NOT EXISTS storage.folders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
bucket_id UUID NOT NULL REFERENCES storage.buckets(id) ON DELETE CASCADE,
-- Jerarquía
parent_id UUID REFERENCES storage.folders(id) ON DELETE CASCADE,
path TEXT NOT NULL, -- /documents/invoices/2026/
name VARCHAR(255) NOT NULL,
depth INTEGER DEFAULT 0,
-- Metadata
description TEXT,
color VARCHAR(7), -- Color hex para UI
icon VARCHAR(50),
-- Permisos
is_private BOOLEAN DEFAULT FALSE,
owner_id UUID REFERENCES auth.users(id),
-- Estadísticas (actualizadas async)
file_count INTEGER DEFAULT 0,
total_size_bytes BIGINT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
created_by UUID REFERENCES auth.users(id),
UNIQUE(tenant_id, bucket_id, path)
);
-- =====================
-- TABLA: storage.files
-- Archivos almacenados
-- =====================
CREATE TABLE IF NOT EXISTS storage.files (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
bucket_id UUID NOT NULL REFERENCES storage.buckets(id) ON DELETE CASCADE,
folder_id UUID REFERENCES storage.folders(id) ON DELETE SET NULL,
-- Identificación
name VARCHAR(255) NOT NULL,
original_name VARCHAR(255) NOT NULL,
path TEXT NOT NULL, -- Ruta completa en storage
-- Tipo de archivo
mime_type VARCHAR(100) NOT NULL,
extension VARCHAR(20),
category VARCHAR(30), -- image, document, video, audio, archive, other
-- Tamaño
size_bytes BIGINT NOT NULL,
-- Hashes para integridad y deduplicación
checksum_md5 VARCHAR(32),
checksum_sha256 VARCHAR(64),
-- Almacenamiento
storage_key TEXT NOT NULL, -- Key en el backend de storage
storage_url TEXT, -- URL directa (si aplica)
cdn_url TEXT, -- URL de CDN (si aplica)
-- Imagen (si aplica)
width INTEGER,
height INTEGER,
thumbnail_url TEXT,
thumbnails JSONB DEFAULT '{}', -- {small: url, medium: url, large: url}
-- Metadata
metadata JSONB DEFAULT '{}',
tags TEXT[] DEFAULT '{}',
alt_text TEXT,
-- Versionamiento
version INTEGER DEFAULT 1,
parent_version_id UUID REFERENCES storage.files(id),
is_latest BOOLEAN DEFAULT TRUE,
-- Asociación con entidades
entity_type VARCHAR(100), -- product, user, invoice, etc.
entity_id UUID,
-- Acceso
is_public BOOLEAN DEFAULT FALSE,
access_count INTEGER DEFAULT 0,
last_accessed_at TIMESTAMPTZ,
-- Estado
status VARCHAR(20) DEFAULT 'active', -- active, processing, archived, deleted
archived_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ,
-- Procesamiento
processing_status VARCHAR(20), -- pending, processing, completed, failed
processing_error TEXT,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
uploaded_by UUID REFERENCES auth.users(id),
UNIQUE(tenant_id, bucket_id, path, version)
);
-- =====================
-- TABLA: storage.file_access_tokens
-- Tokens de acceso temporal a archivos
-- =====================
CREATE TABLE IF NOT EXISTS storage.file_access_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
file_id UUID NOT NULL REFERENCES storage.files(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Token
token VARCHAR(255) NOT NULL UNIQUE,
-- Permisos
permissions TEXT[] DEFAULT '{read}', -- read, download, write
-- Restricciones
allowed_ips INET[],
max_downloads INTEGER,
download_count INTEGER DEFAULT 0,
-- Validez
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
-- Metadata
created_for VARCHAR(255), -- Email o nombre para quien se creó
purpose TEXT,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
created_by UUID REFERENCES auth.users(id)
);
-- =====================
-- TABLA: storage.uploads
-- Uploads en progreso (multipart, resumable)
-- =====================
CREATE TABLE IF NOT EXISTS storage.uploads (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
bucket_id UUID NOT NULL REFERENCES storage.buckets(id) ON DELETE CASCADE,
folder_id UUID REFERENCES storage.folders(id),
-- Archivo destino
file_name VARCHAR(255) NOT NULL,
mime_type VARCHAR(100),
total_size_bytes BIGINT,
-- Estado del upload
status VARCHAR(20) NOT NULL DEFAULT 'pending',
-- pending, uploading, processing, completed, failed, cancelled
-- Progreso
uploaded_bytes BIGINT DEFAULT 0,
upload_progress DECIMAL(5,2) DEFAULT 0,
-- Chunks (para multipart)
total_chunks INTEGER,
completed_chunks INTEGER DEFAULT 0,
chunk_size_bytes INTEGER,
chunks_status JSONB DEFAULT '{}', -- {0: 'completed', 1: 'pending', ...}
-- Metadata
metadata JSONB DEFAULT '{}',
-- Resultado
file_id UUID REFERENCES storage.files(id),
error_message TEXT,
-- Tiempos
started_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
last_chunk_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
created_by UUID REFERENCES auth.users(id)
);
-- =====================
-- TABLA: storage.file_shares
-- Archivos compartidos
-- =====================
CREATE TABLE IF NOT EXISTS storage.file_shares (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
file_id UUID NOT NULL REFERENCES storage.files(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Compartido con
shared_with_user_id UUID REFERENCES auth.users(id),
shared_with_email VARCHAR(255),
shared_with_role VARCHAR(50),
-- Permisos
can_view BOOLEAN DEFAULT TRUE,
can_download BOOLEAN DEFAULT TRUE,
can_edit BOOLEAN DEFAULT FALSE,
can_delete BOOLEAN DEFAULT FALSE,
can_share BOOLEAN DEFAULT FALSE,
-- Link público
public_link VARCHAR(255) UNIQUE,
public_link_password VARCHAR(255),
-- Validez
expires_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
-- Estadísticas
view_count INTEGER DEFAULT 0,
download_count INTEGER DEFAULT 0,
last_accessed_at TIMESTAMPTZ,
-- Notificaciones
notify_on_access BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- =====================
-- TABLA: storage.tenant_usage
-- Uso de storage por tenant
-- =====================
CREATE TABLE IF NOT EXISTS storage.tenant_usage (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
bucket_id UUID NOT NULL REFERENCES storage.buckets(id) ON DELETE CASCADE,
-- Uso actual
file_count INTEGER DEFAULT 0,
total_size_bytes BIGINT DEFAULT 0,
-- Límites
quota_bytes BIGINT,
quota_file_count INTEGER,
-- Uso por categoría
usage_by_category JSONB DEFAULT '{}',
-- {image: 1024000, document: 2048000, ...}
-- Histórico mensual
monthly_upload_bytes BIGINT DEFAULT 0,
monthly_download_bytes BIGINT DEFAULT 0,
month_year VARCHAR(7), -- 2026-01
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE(tenant_id, bucket_id, month_year)
);
-- =====================
-- INDICES
-- =====================
-- Indices para buckets
CREATE INDEX IF NOT EXISTS idx_buckets_name ON storage.buckets(name);
CREATE INDEX IF NOT EXISTS idx_buckets_active ON storage.buckets(is_active) WHERE is_active = TRUE;
-- Indices para folders
CREATE INDEX IF NOT EXISTS idx_folders_tenant ON storage.folders(tenant_id);
CREATE INDEX IF NOT EXISTS idx_folders_bucket ON storage.folders(bucket_id);
CREATE INDEX IF NOT EXISTS idx_folders_parent ON storage.folders(parent_id);
CREATE INDEX IF NOT EXISTS idx_folders_path ON storage.folders(path);
-- Indices para files
CREATE INDEX IF NOT EXISTS idx_files_tenant ON storage.files(tenant_id);
CREATE INDEX IF NOT EXISTS idx_files_bucket ON storage.files(bucket_id);
CREATE INDEX IF NOT EXISTS idx_files_folder ON storage.files(folder_id);
CREATE INDEX IF NOT EXISTS idx_files_entity ON storage.files(entity_type, entity_id);
CREATE INDEX IF NOT EXISTS idx_files_mime ON storage.files(mime_type);
CREATE INDEX IF NOT EXISTS idx_files_category ON storage.files(category);
CREATE INDEX IF NOT EXISTS idx_files_status ON storage.files(status);
CREATE INDEX IF NOT EXISTS idx_files_checksum ON storage.files(checksum_sha256);
CREATE INDEX IF NOT EXISTS idx_files_created ON storage.files(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_files_tags ON storage.files USING GIN(tags);
CREATE INDEX IF NOT EXISTS idx_files_latest ON storage.files(parent_version_id) WHERE is_latest = TRUE;
-- Indices para file_access_tokens
CREATE INDEX IF NOT EXISTS idx_access_tokens_file ON storage.file_access_tokens(file_id);
CREATE INDEX IF NOT EXISTS idx_access_tokens_token ON storage.file_access_tokens(token);
CREATE INDEX IF NOT EXISTS idx_access_tokens_valid ON storage.file_access_tokens(expires_at)
WHERE revoked_at IS NULL;
-- Indices para uploads
CREATE INDEX IF NOT EXISTS idx_uploads_tenant ON storage.uploads(tenant_id);
CREATE INDEX IF NOT EXISTS idx_uploads_status ON storage.uploads(status);
CREATE INDEX IF NOT EXISTS idx_uploads_expires ON storage.uploads(expires_at) WHERE status = 'uploading';
-- Indices para file_shares
CREATE INDEX IF NOT EXISTS idx_shares_file ON storage.file_shares(file_id);
CREATE INDEX IF NOT EXISTS idx_shares_user ON storage.file_shares(shared_with_user_id);
CREATE INDEX IF NOT EXISTS idx_shares_link ON storage.file_shares(public_link);
-- Indices para tenant_usage
CREATE INDEX IF NOT EXISTS idx_usage_tenant ON storage.tenant_usage(tenant_id);
CREATE INDEX IF NOT EXISTS idx_usage_bucket ON storage.tenant_usage(bucket_id);
-- =====================
-- RLS POLICIES
-- =====================
-- Buckets son globales (lectura pública)
ALTER TABLE storage.buckets ENABLE ROW LEVEL SECURITY;
CREATE POLICY public_read_buckets ON storage.buckets
FOR SELECT USING (is_active = TRUE);
-- Folders por tenant
ALTER TABLE storage.folders ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_folders ON storage.folders
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- Files por tenant
ALTER TABLE storage.files ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_files ON storage.files
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- Access tokens por tenant
ALTER TABLE storage.file_access_tokens ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_tokens ON storage.file_access_tokens
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- Uploads por tenant
ALTER TABLE storage.uploads ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_uploads ON storage.uploads
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- File shares por tenant
ALTER TABLE storage.file_shares ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_shares ON storage.file_shares
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- Tenant usage por tenant
ALTER TABLE storage.tenant_usage ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_usage ON storage.tenant_usage
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- =====================
-- FUNCIONES
-- =====================
-- Función para generar storage key único
CREATE OR REPLACE FUNCTION storage.generate_storage_key(
p_tenant_id UUID,
p_bucket_name VARCHAR(100),
p_file_name VARCHAR(255)
)
RETURNS TEXT AS $$
BEGIN
RETURN p_bucket_name || '/' ||
p_tenant_id::TEXT || '/' ||
TO_CHAR(CURRENT_DATE, 'YYYY/MM/DD') || '/' ||
gen_random_uuid()::TEXT || '/' ||
p_file_name;
END;
$$ LANGUAGE plpgsql;
-- Función para determinar categoría por mime type
CREATE OR REPLACE FUNCTION storage.get_file_category(p_mime_type VARCHAR(100))
RETURNS VARCHAR(30) AS $$
BEGIN
RETURN CASE
WHEN p_mime_type LIKE 'image/%' THEN 'image'
WHEN p_mime_type LIKE 'video/%' THEN 'video'
WHEN p_mime_type LIKE 'audio/%' THEN 'audio'
WHEN p_mime_type IN ('application/pdf', 'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/plain', 'text/csv') THEN 'document'
WHEN p_mime_type IN ('application/zip', 'application/x-rar-compressed',
'application/x-7z-compressed', 'application/gzip') THEN 'archive'
ELSE 'other'
END;
END;
$$ LANGUAGE plpgsql IMMUTABLE;
-- Función para crear archivo
CREATE OR REPLACE FUNCTION storage.create_file(
p_tenant_id UUID,
p_bucket_id UUID,
p_folder_id UUID,
p_name VARCHAR(255),
p_original_name VARCHAR(255),
p_mime_type VARCHAR(100),
p_size_bytes BIGINT,
p_storage_key TEXT,
p_uploaded_by UUID DEFAULT NULL,
p_metadata JSONB DEFAULT '{}'
)
RETURNS UUID AS $$
DECLARE
v_file_id UUID;
v_bucket RECORD;
v_path TEXT;
v_category VARCHAR(30);
BEGIN
-- Verificar bucket
SELECT * INTO v_bucket FROM storage.buckets WHERE id = p_bucket_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'Bucket not found';
END IF;
-- Verificar tamaño
IF v_bucket.max_file_size_mb IS NOT NULL AND
p_size_bytes > v_bucket.max_file_size_mb * 1024 * 1024 THEN
RAISE EXCEPTION 'File size exceeds bucket limit';
END IF;
-- Obtener path de la carpeta
IF p_folder_id IS NOT NULL THEN
SELECT path INTO v_path FROM storage.folders WHERE id = p_folder_id;
v_path := v_path || p_name;
ELSE
v_path := '/' || p_name;
END IF;
-- Determinar categoría
v_category := storage.get_file_category(p_mime_type);
-- Crear archivo
INSERT INTO storage.files (
tenant_id, bucket_id, folder_id,
name, original_name, path,
mime_type, extension, category,
size_bytes, storage_key,
metadata, uploaded_by
) VALUES (
p_tenant_id, p_bucket_id, p_folder_id,
p_name, p_original_name, v_path,
p_mime_type, LOWER(SPLIT_PART(p_name, '.', -1)), v_category,
p_size_bytes, p_storage_key,
p_metadata, p_uploaded_by
) RETURNING id INTO v_file_id;
-- Actualizar estadísticas de carpeta
IF p_folder_id IS NOT NULL THEN
UPDATE storage.folders
SET file_count = file_count + 1,
total_size_bytes = total_size_bytes + p_size_bytes,
updated_at = CURRENT_TIMESTAMP
WHERE id = p_folder_id;
END IF;
-- Actualizar uso del tenant
INSERT INTO storage.tenant_usage (tenant_id, bucket_id, file_count, total_size_bytes, month_year)
VALUES (p_tenant_id, p_bucket_id, 1, p_size_bytes, TO_CHAR(CURRENT_DATE, 'YYYY-MM'))
ON CONFLICT (tenant_id, bucket_id, month_year)
DO UPDATE SET
file_count = storage.tenant_usage.file_count + 1,
total_size_bytes = storage.tenant_usage.total_size_bytes + p_size_bytes,
updated_at = CURRENT_TIMESTAMP;
RETURN v_file_id;
END;
$$ LANGUAGE plpgsql;
-- Función para crear token de acceso
CREATE OR REPLACE FUNCTION storage.create_access_token(
p_file_id UUID,
p_expires_in_hours INTEGER DEFAULT 24,
p_permissions TEXT[] DEFAULT '{read}',
p_max_downloads INTEGER DEFAULT NULL,
p_created_by UUID DEFAULT NULL
)
RETURNS TEXT AS $$
DECLARE
v_token TEXT;
v_tenant_id UUID;
BEGIN
-- Obtener tenant del archivo
SELECT tenant_id INTO v_tenant_id FROM storage.files WHERE id = p_file_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'File not found';
END IF;
-- Generar token
v_token := 'sat_' || encode(gen_random_bytes(32), 'hex');
-- Crear registro
INSERT INTO storage.file_access_tokens (
file_id, tenant_id, token, permissions,
max_downloads, expires_at, created_by
) VALUES (
p_file_id, v_tenant_id, v_token, p_permissions,
p_max_downloads,
CURRENT_TIMESTAMP + (p_expires_in_hours || ' hours')::INTERVAL,
p_created_by
);
RETURN v_token;
END;
$$ LANGUAGE plpgsql;
-- Función para validar token de acceso
CREATE OR REPLACE FUNCTION storage.validate_access_token(
p_token VARCHAR(255),
p_permission VARCHAR(20) DEFAULT 'read'
)
RETURNS TABLE (
is_valid BOOLEAN,
file_id UUID,
tenant_id UUID,
error_message TEXT
) AS $$
DECLARE
v_token RECORD;
BEGIN
SELECT * INTO v_token
FROM storage.file_access_tokens
WHERE token = p_token;
IF NOT FOUND THEN
RETURN QUERY SELECT FALSE, NULL::UUID, NULL::UUID, 'Token not found'::TEXT;
RETURN;
END IF;
IF v_token.revoked_at IS NOT NULL THEN
RETURN QUERY SELECT FALSE, NULL::UUID, NULL::UUID, 'Token revoked'::TEXT;
RETURN;
END IF;
IF v_token.expires_at < CURRENT_TIMESTAMP THEN
RETURN QUERY SELECT FALSE, NULL::UUID, NULL::UUID, 'Token expired'::TEXT;
RETURN;
END IF;
IF NOT (p_permission = ANY(v_token.permissions)) THEN
RETURN QUERY SELECT FALSE, NULL::UUID, NULL::UUID, 'Permission denied'::TEXT;
RETURN;
END IF;
IF v_token.max_downloads IS NOT NULL AND
p_permission = 'download' AND
v_token.download_count >= v_token.max_downloads THEN
RETURN QUERY SELECT FALSE, NULL::UUID, NULL::UUID, 'Download limit reached'::TEXT;
RETURN;
END IF;
-- Incrementar contador si es download
IF p_permission = 'download' THEN
UPDATE storage.file_access_tokens
SET download_count = download_count + 1
WHERE id = v_token.id;
END IF;
RETURN QUERY SELECT TRUE, v_token.file_id, v_token.tenant_id, NULL::TEXT;
END;
$$ LANGUAGE plpgsql;
-- Función para obtener uso del tenant
CREATE OR REPLACE FUNCTION storage.get_tenant_usage(p_tenant_id UUID)
RETURNS TABLE (
total_files BIGINT,
total_size_bytes BIGINT,
total_size_mb DECIMAL,
usage_by_bucket JSONB,
usage_by_category JSONB
) AS $$
BEGIN
RETURN QUERY
SELECT
COALESCE(SUM(tu.file_count), 0)::BIGINT as total_files,
COALESCE(SUM(tu.total_size_bytes), 0)::BIGINT as total_size_bytes,
ROUND(COALESCE(SUM(tu.total_size_bytes), 0)::DECIMAL / 1024 / 1024, 2) as total_size_mb,
jsonb_object_agg(b.name, tu.total_size_bytes) as usage_by_bucket,
COALESCE(
(SELECT jsonb_object_agg(category, cat_size)
FROM (
SELECT f.category, SUM(f.size_bytes) as cat_size
FROM storage.files f
WHERE f.tenant_id = p_tenant_id AND f.status = 'active'
GROUP BY f.category
) cats),
'{}'::JSONB
) as usage_by_category
FROM storage.tenant_usage tu
JOIN storage.buckets b ON b.id = tu.bucket_id
WHERE tu.tenant_id = p_tenant_id
AND tu.month_year = TO_CHAR(CURRENT_DATE, 'YYYY-MM');
END;
$$ LANGUAGE plpgsql STABLE;
-- Función para limpiar archivos expirados
CREATE OR REPLACE FUNCTION storage.cleanup_expired_files()
RETURNS INTEGER AS $$
DECLARE
deleted_count INTEGER := 0;
v_bucket RECORD;
BEGIN
-- Procesar cada bucket con auto_delete_days
FOR v_bucket IN SELECT * FROM storage.buckets WHERE auto_delete_days IS NOT NULL LOOP
UPDATE storage.files
SET status = 'deleted', deleted_at = CURRENT_TIMESTAMP
WHERE bucket_id = v_bucket.id
AND status = 'active'
AND created_at < CURRENT_TIMESTAMP - (v_bucket.auto_delete_days || ' days')::INTERVAL;
deleted_count := deleted_count + ROW_COUNT;
END LOOP;
-- Limpiar tokens expirados
DELETE FROM storage.file_access_tokens
WHERE expires_at < CURRENT_TIMESTAMP - INTERVAL '7 days';
-- Limpiar uploads abandonados
DELETE FROM storage.uploads
WHERE status IN ('pending', 'uploading')
AND expires_at < CURRENT_TIMESTAMP;
RETURN deleted_count;
END;
$$ LANGUAGE plpgsql;
-- =====================
-- TRIGGERS
-- =====================
CREATE OR REPLACE FUNCTION storage.update_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_buckets_updated_at
BEFORE UPDATE ON storage.buckets
FOR EACH ROW EXECUTE FUNCTION storage.update_timestamp();
CREATE TRIGGER trg_folders_updated_at
BEFORE UPDATE ON storage.folders
FOR EACH ROW EXECUTE FUNCTION storage.update_timestamp();
CREATE TRIGGER trg_files_updated_at
BEFORE UPDATE ON storage.files
FOR EACH ROW EXECUTE FUNCTION storage.update_timestamp();
CREATE TRIGGER trg_shares_updated_at
BEFORE UPDATE ON storage.file_shares
FOR EACH ROW EXECUTE FUNCTION storage.update_timestamp();
-- =====================
-- SEED DATA: Buckets del Sistema
-- =====================
INSERT INTO storage.buckets (name, description, bucket_type, max_file_size_mb, allowed_mime_types, is_system) VALUES
('avatars', 'Avatares de usuarios', 'public', 5, '{image/jpeg,image/png,image/gif,image/webp}', TRUE),
('logos', 'Logos de empresas', 'public', 10, '{image/jpeg,image/png,image/svg+xml,image/webp}', TRUE),
('documents', 'Documentos generales', 'private', 50, '{}', TRUE),
('invoices', 'Facturas y comprobantes', 'private', 20, '{application/pdf,image/jpeg,image/png}', TRUE),
('products', 'Imágenes de productos', 'public', 10, '{image/jpeg,image/png,image/webp}', TRUE),
('attachments', 'Archivos adjuntos', 'private', 25, '{}', TRUE),
('exports', 'Exportaciones de datos', 'protected', 500, '{application/zip,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet}', TRUE),
('backups', 'Respaldos', 'private', 1000, '{application/zip,application/gzip}', TRUE)
ON CONFLICT (name) DO NOTHING;
-- =====================
-- COMENTARIOS
-- =====================
COMMENT ON TABLE storage.buckets IS 'Contenedores de almacenamiento configurables';
COMMENT ON TABLE storage.folders IS 'Estructura de carpetas virtuales por tenant';
COMMENT ON TABLE storage.files IS 'Archivos almacenados con metadata y versionamiento';
COMMENT ON TABLE storage.file_access_tokens IS 'Tokens de acceso temporal a archivos';
COMMENT ON TABLE storage.uploads IS 'Uploads en progreso (multipart/resumable)';
COMMENT ON TABLE storage.file_shares IS 'Configuración de archivos compartidos';
COMMENT ON TABLE storage.tenant_usage IS 'Uso de storage por tenant y bucket';
COMMENT ON FUNCTION storage.create_file IS 'Crea un registro de archivo con validaciones';
COMMENT ON FUNCTION storage.create_access_token IS 'Genera un token de acceso temporal';
COMMENT ON FUNCTION storage.validate_access_token IS 'Valida un token de acceso';
COMMENT ON FUNCTION storage.get_tenant_usage IS 'Obtiene estadísticas de uso de storage';

852
ddl/14-ai.sql Normal file
View File

@ -0,0 +1,852 @@
-- =============================================================
-- ARCHIVO: 14-ai.sql
-- DESCRIPCION: Sistema de AI/ML, prompts, completions, embeddings
-- VERSION: 1.0.0
-- PROYECTO: ERP-Core V2
-- FECHA: 2026-01-10
-- EPIC: SAAS-AI (EPIC-SAAS-007)
-- HISTORIAS: US-080, US-081, US-082
-- =============================================================
-- =====================
-- SCHEMA: ai
-- =====================
CREATE SCHEMA IF NOT EXISTS ai;
-- =====================
-- EXTENSIÓN: pgvector para embeddings
-- =====================
-- CREATE EXTENSION IF NOT EXISTS vector;
-- =====================
-- TABLA: ai.models
-- Modelos de AI disponibles
-- =====================
CREATE TABLE IF NOT EXISTS ai.models (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Identificación
code VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(200) NOT NULL,
description TEXT,
-- Proveedor
provider VARCHAR(50) NOT NULL, -- openai, anthropic, google, azure, local
model_id VARCHAR(100) NOT NULL, -- gpt-4, claude-3, etc.
-- Tipo
model_type VARCHAR(30) NOT NULL, -- chat, completion, embedding, image, audio
-- Capacidades
max_tokens INTEGER,
supports_functions BOOLEAN DEFAULT FALSE,
supports_vision BOOLEAN DEFAULT FALSE,
supports_streaming BOOLEAN DEFAULT TRUE,
-- Costos (por 1K tokens)
input_cost_per_1k DECIMAL(10,6),
output_cost_per_1k DECIMAL(10,6),
-- Límites
rate_limit_rpm INTEGER, -- Requests per minute
rate_limit_tpm INTEGER, -- Tokens per minute
-- Estado
is_active BOOLEAN DEFAULT TRUE,
is_default BOOLEAN DEFAULT FALSE,
-- Metadata
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- =====================
-- TABLA: ai.prompts
-- Biblioteca de prompts del sistema
-- =====================
CREATE TABLE IF NOT EXISTS ai.prompts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES auth.tenants(id) ON DELETE CASCADE, -- NULL = global
-- Identificación
code VARCHAR(100) NOT NULL,
name VARCHAR(200) NOT NULL,
description TEXT,
category VARCHAR(50), -- assistant, analysis, generation, extraction
-- Contenido
system_prompt TEXT,
user_prompt_template TEXT NOT NULL,
-- Variables: {{variable_name}}
-- Configuración del modelo
model_id UUID REFERENCES ai.models(id),
temperature DECIMAL(3,2) DEFAULT 0.7,
max_tokens INTEGER,
top_p DECIMAL(3,2),
frequency_penalty DECIMAL(3,2),
presence_penalty DECIMAL(3,2),
-- Variables requeridas
required_variables TEXT[] DEFAULT '{}',
variable_schema JSONB DEFAULT '{}',
-- Funciones (para function calling)
functions JSONB DEFAULT '[]',
-- Versionamiento
version INTEGER DEFAULT 1,
is_latest BOOLEAN DEFAULT TRUE,
parent_version_id UUID REFERENCES ai.prompts(id),
-- Estado
is_active BOOLEAN DEFAULT TRUE,
is_system BOOLEAN DEFAULT FALSE,
-- Estadísticas
usage_count INTEGER DEFAULT 0,
avg_tokens_used INTEGER,
avg_latency_ms INTEGER,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
created_by UUID REFERENCES auth.users(id),
UNIQUE(tenant_id, code, version)
);
-- =====================
-- TABLA: ai.conversations
-- Conversaciones con el asistente AI
-- =====================
CREATE TABLE IF NOT EXISTS ai.conversations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
-- Identificación
title VARCHAR(255),
summary TEXT,
-- Contexto
context_type VARCHAR(50), -- general, sales, inventory, support
context_data JSONB DEFAULT '{}',
-- Modelo usado
model_id UUID REFERENCES ai.models(id),
prompt_id UUID REFERENCES ai.prompts(id),
-- Estado
status VARCHAR(20) DEFAULT 'active', -- active, archived, deleted
is_pinned BOOLEAN DEFAULT FALSE,
-- Estadísticas
message_count INTEGER DEFAULT 0,
total_tokens INTEGER DEFAULT 0,
total_cost DECIMAL(10,4) DEFAULT 0,
-- Metadata
metadata JSONB DEFAULT '{}',
tags TEXT[] DEFAULT '{}',
-- Tiempos
last_message_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- =====================
-- TABLA: ai.messages
-- Mensajes en conversaciones
-- =====================
CREATE TABLE IF NOT EXISTS ai.messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
conversation_id UUID NOT NULL REFERENCES ai.conversations(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Mensaje
role VARCHAR(20) NOT NULL, -- system, user, assistant, function
content TEXT NOT NULL,
-- Función (si aplica)
function_name VARCHAR(100),
function_arguments JSONB,
function_result JSONB,
-- Modelo usado
model_id UUID REFERENCES ai.models(id),
model_response_id VARCHAR(255), -- ID de respuesta del proveedor
-- Tokens y costos
prompt_tokens INTEGER,
completion_tokens INTEGER,
total_tokens INTEGER,
cost DECIMAL(10,6),
-- Performance
latency_ms INTEGER,
finish_reason VARCHAR(30), -- stop, length, function_call, content_filter
-- Metadata
metadata JSONB DEFAULT '{}',
-- Feedback
feedback_rating INTEGER, -- 1-5
feedback_text TEXT,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- =====================
-- TABLA: ai.completions
-- Completaciones individuales (no conversacionales)
-- =====================
CREATE TABLE IF NOT EXISTS ai.completions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
user_id UUID REFERENCES auth.users(id),
-- Prompt usado
prompt_id UUID REFERENCES ai.prompts(id),
prompt_code VARCHAR(100),
-- Modelo
model_id UUID REFERENCES ai.models(id),
-- Input/Output
input_text TEXT NOT NULL,
input_variables JSONB DEFAULT '{}',
output_text TEXT,
-- Tokens y costos
prompt_tokens INTEGER,
completion_tokens INTEGER,
total_tokens INTEGER,
cost DECIMAL(10,6),
-- Performance
latency_ms INTEGER,
finish_reason VARCHAR(30),
-- Estado
status VARCHAR(20) DEFAULT 'pending', -- pending, processing, completed, failed
error_message TEXT,
-- Contexto
context_type VARCHAR(50),
context_id UUID,
-- Metadata
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- =====================
-- TABLA: ai.embeddings
-- Embeddings vectoriales
-- =====================
CREATE TABLE IF NOT EXISTS ai.embeddings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Contenido original
content TEXT NOT NULL,
content_hash VARCHAR(64), -- SHA-256 para deduplicación
-- Vector
-- embedding vector(1536), -- Para OpenAI ada-002
embedding_json JSONB, -- Alternativa si no hay pgvector
-- Modelo usado
model_id UUID REFERENCES ai.models(id),
model_name VARCHAR(100),
dimensions INTEGER,
-- Asociación
entity_type VARCHAR(100), -- product, document, faq
entity_id UUID,
-- Metadata
metadata JSONB DEFAULT '{}',
tags TEXT[] DEFAULT '{}',
-- Chunks (si es parte de un documento grande)
chunk_index INTEGER,
chunk_total INTEGER,
parent_embedding_id UUID REFERENCES ai.embeddings(id),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- =====================
-- TABLA: ai.usage_logs
-- Log de uso de AI para billing
-- =====================
CREATE TABLE IF NOT EXISTS ai.usage_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
user_id UUID REFERENCES auth.users(id),
-- Modelo
model_id UUID REFERENCES ai.models(id),
model_name VARCHAR(100),
provider VARCHAR(50),
-- Tipo de uso
usage_type VARCHAR(30) NOT NULL, -- chat, completion, embedding, image
-- Tokens
prompt_tokens INTEGER DEFAULT 0,
completion_tokens INTEGER DEFAULT 0,
total_tokens INTEGER DEFAULT 0,
-- Costos
cost DECIMAL(10,6) DEFAULT 0,
-- Contexto
conversation_id UUID,
completion_id UUID,
request_id VARCHAR(255),
-- Periodo
usage_date DATE DEFAULT CURRENT_DATE,
usage_month VARCHAR(7), -- 2026-01
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- =====================
-- TABLA: ai.tenant_quotas
-- Cuotas de AI por tenant
-- =====================
CREATE TABLE IF NOT EXISTS ai.tenant_quotas (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Límites mensuales
monthly_token_limit INTEGER,
monthly_request_limit INTEGER,
monthly_cost_limit DECIMAL(10,2),
-- Uso actual del mes
current_tokens INTEGER DEFAULT 0,
current_requests INTEGER DEFAULT 0,
current_cost DECIMAL(10,4) DEFAULT 0,
-- Periodo
quota_month VARCHAR(7) NOT NULL, -- 2026-01
-- Estado
is_exceeded BOOLEAN DEFAULT FALSE,
exceeded_at TIMESTAMPTZ,
-- Alertas
alert_threshold_percent INTEGER DEFAULT 80,
alert_sent_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE(tenant_id, quota_month)
);
-- =====================
-- TABLA: ai.knowledge_base
-- Base de conocimiento para RAG
-- =====================
CREATE TABLE IF NOT EXISTS ai.knowledge_base (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES auth.tenants(id) ON DELETE CASCADE, -- NULL = global
-- Identificación
code VARCHAR(100) NOT NULL,
name VARCHAR(200) NOT NULL,
description TEXT,
-- Fuente
source_type VARCHAR(30), -- manual, document, website, api
source_url TEXT,
source_file_id UUID,
-- Contenido
content TEXT NOT NULL,
content_type VARCHAR(50), -- faq, documentation, policy, procedure
-- Categorización
category VARCHAR(100),
subcategory VARCHAR(100),
tags TEXT[] DEFAULT '{}',
-- Embedding
embedding_id UUID REFERENCES ai.embeddings(id),
-- Relevancia
priority INTEGER DEFAULT 0,
relevance_score DECIMAL(5,4),
-- Estado
is_active BOOLEAN DEFAULT TRUE,
is_verified BOOLEAN DEFAULT FALSE,
verified_by UUID REFERENCES auth.users(id),
verified_at TIMESTAMPTZ,
-- Metadata
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
created_by UUID REFERENCES auth.users(id),
UNIQUE(tenant_id, code)
);
-- =====================
-- INDICES
-- =====================
-- Indices para models
CREATE INDEX IF NOT EXISTS idx_models_provider ON ai.models(provider);
CREATE INDEX IF NOT EXISTS idx_models_type ON ai.models(model_type);
CREATE INDEX IF NOT EXISTS idx_models_active ON ai.models(is_active) WHERE is_active = TRUE;
-- Indices para prompts
CREATE INDEX IF NOT EXISTS idx_prompts_tenant ON ai.prompts(tenant_id);
CREATE INDEX IF NOT EXISTS idx_prompts_code ON ai.prompts(code);
CREATE INDEX IF NOT EXISTS idx_prompts_category ON ai.prompts(category);
CREATE INDEX IF NOT EXISTS idx_prompts_active ON ai.prompts(is_active) WHERE is_active = TRUE;
-- Indices para conversations
CREATE INDEX IF NOT EXISTS idx_conversations_tenant ON ai.conversations(tenant_id);
CREATE INDEX IF NOT EXISTS idx_conversations_user ON ai.conversations(user_id);
CREATE INDEX IF NOT EXISTS idx_conversations_status ON ai.conversations(status);
CREATE INDEX IF NOT EXISTS idx_conversations_created ON ai.conversations(created_at DESC);
-- Indices para messages
CREATE INDEX IF NOT EXISTS idx_messages_conversation ON ai.messages(conversation_id);
CREATE INDEX IF NOT EXISTS idx_messages_tenant ON ai.messages(tenant_id);
CREATE INDEX IF NOT EXISTS idx_messages_created ON ai.messages(created_at);
-- Indices para completions
CREATE INDEX IF NOT EXISTS idx_completions_tenant ON ai.completions(tenant_id);
CREATE INDEX IF NOT EXISTS idx_completions_user ON ai.completions(user_id);
CREATE INDEX IF NOT EXISTS idx_completions_prompt ON ai.completions(prompt_id);
CREATE INDEX IF NOT EXISTS idx_completions_context ON ai.completions(context_type, context_id);
CREATE INDEX IF NOT EXISTS idx_completions_created ON ai.completions(created_at DESC);
-- Indices para embeddings
CREATE INDEX IF NOT EXISTS idx_embeddings_tenant ON ai.embeddings(tenant_id);
CREATE INDEX IF NOT EXISTS idx_embeddings_entity ON ai.embeddings(entity_type, entity_id);
CREATE INDEX IF NOT EXISTS idx_embeddings_hash ON ai.embeddings(content_hash);
CREATE INDEX IF NOT EXISTS idx_embeddings_tags ON ai.embeddings USING GIN(tags);
-- Indices para usage_logs
CREATE INDEX IF NOT EXISTS idx_usage_tenant ON ai.usage_logs(tenant_id);
CREATE INDEX IF NOT EXISTS idx_usage_date ON ai.usage_logs(usage_date);
CREATE INDEX IF NOT EXISTS idx_usage_month ON ai.usage_logs(usage_month);
CREATE INDEX IF NOT EXISTS idx_usage_model ON ai.usage_logs(model_id);
-- Indices para tenant_quotas
CREATE INDEX IF NOT EXISTS idx_quotas_tenant ON ai.tenant_quotas(tenant_id);
CREATE INDEX IF NOT EXISTS idx_quotas_month ON ai.tenant_quotas(quota_month);
-- Indices para knowledge_base
CREATE INDEX IF NOT EXISTS idx_kb_tenant ON ai.knowledge_base(tenant_id);
CREATE INDEX IF NOT EXISTS idx_kb_category ON ai.knowledge_base(category);
CREATE INDEX IF NOT EXISTS idx_kb_tags ON ai.knowledge_base USING GIN(tags);
CREATE INDEX IF NOT EXISTS idx_kb_active ON ai.knowledge_base(is_active) WHERE is_active = TRUE;
-- =====================
-- RLS POLICIES
-- =====================
-- Models son globales
ALTER TABLE ai.models ENABLE ROW LEVEL SECURITY;
CREATE POLICY public_read_models ON ai.models
FOR SELECT USING (is_active = TRUE);
-- Prompts: globales o por tenant
ALTER TABLE ai.prompts ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_or_global_prompts ON ai.prompts
FOR SELECT USING (
tenant_id IS NULL
OR tenant_id = current_setting('app.current_tenant_id', true)::uuid
);
-- Conversations por tenant
ALTER TABLE ai.conversations ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_conversations ON ai.conversations
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- Messages por tenant
ALTER TABLE ai.messages ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_messages ON ai.messages
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- Completions por tenant
ALTER TABLE ai.completions ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_completions ON ai.completions
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- Embeddings por tenant
ALTER TABLE ai.embeddings ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_embeddings ON ai.embeddings
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- Usage logs por tenant
ALTER TABLE ai.usage_logs ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_usage ON ai.usage_logs
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- Quotas por tenant
ALTER TABLE ai.tenant_quotas ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_quotas ON ai.tenant_quotas
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- Knowledge base: global o por tenant
ALTER TABLE ai.knowledge_base ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_or_global_kb ON ai.knowledge_base
FOR SELECT USING (
tenant_id IS NULL
OR tenant_id = current_setting('app.current_tenant_id', true)::uuid
);
-- =====================
-- FUNCIONES
-- =====================
-- Función para crear conversación
CREATE OR REPLACE FUNCTION ai.create_conversation(
p_tenant_id UUID,
p_user_id UUID,
p_title VARCHAR(255) DEFAULT NULL,
p_context_type VARCHAR(50) DEFAULT 'general',
p_model_id UUID DEFAULT NULL
)
RETURNS UUID AS $$
DECLARE
v_conversation_id UUID;
BEGIN
INSERT INTO ai.conversations (
tenant_id, user_id, title, context_type, model_id
) VALUES (
p_tenant_id, p_user_id, p_title, p_context_type, p_model_id
) RETURNING id INTO v_conversation_id;
RETURN v_conversation_id;
END;
$$ LANGUAGE plpgsql;
-- Función para agregar mensaje a conversación
CREATE OR REPLACE FUNCTION ai.add_message(
p_conversation_id UUID,
p_role VARCHAR(20),
p_content TEXT,
p_model_id UUID DEFAULT NULL,
p_tokens JSONB DEFAULT NULL,
p_latency_ms INTEGER DEFAULT NULL
)
RETURNS UUID AS $$
DECLARE
v_message_id UUID;
v_tenant_id UUID;
v_prompt_tokens INTEGER;
v_completion_tokens INTEGER;
v_total_tokens INTEGER;
v_cost DECIMAL(10,6);
BEGIN
-- Obtener tenant de la conversación
SELECT tenant_id INTO v_tenant_id
FROM ai.conversations WHERE id = p_conversation_id;
-- Extraer tokens
v_prompt_tokens := (p_tokens->>'prompt_tokens')::INTEGER;
v_completion_tokens := (p_tokens->>'completion_tokens')::INTEGER;
v_total_tokens := COALESCE(v_prompt_tokens, 0) + COALESCE(v_completion_tokens, 0);
-- Calcular costo (si hay modelo)
IF p_model_id IS NOT NULL THEN
SELECT
(COALESCE(v_prompt_tokens, 0) * m.input_cost_per_1k / 1000) +
(COALESCE(v_completion_tokens, 0) * m.output_cost_per_1k / 1000)
INTO v_cost
FROM ai.models m WHERE m.id = p_model_id;
END IF;
-- Crear mensaje
INSERT INTO ai.messages (
conversation_id, tenant_id, role, content, model_id,
prompt_tokens, completion_tokens, total_tokens, cost, latency_ms
) VALUES (
p_conversation_id, v_tenant_id, p_role, p_content, p_model_id,
v_prompt_tokens, v_completion_tokens, v_total_tokens, v_cost, p_latency_ms
) RETURNING id INTO v_message_id;
-- Actualizar estadísticas de conversación
UPDATE ai.conversations
SET
message_count = message_count + 1,
total_tokens = total_tokens + COALESCE(v_total_tokens, 0),
total_cost = total_cost + COALESCE(v_cost, 0),
last_message_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = p_conversation_id;
-- Registrar uso
IF v_total_tokens > 0 THEN
PERFORM ai.log_usage(
v_tenant_id, NULL, p_model_id, 'chat',
v_prompt_tokens, v_completion_tokens, v_cost
);
END IF;
RETURN v_message_id;
END;
$$ LANGUAGE plpgsql;
-- Función para registrar uso
CREATE OR REPLACE FUNCTION ai.log_usage(
p_tenant_id UUID,
p_user_id UUID,
p_model_id UUID,
p_usage_type VARCHAR(30),
p_prompt_tokens INTEGER DEFAULT 0,
p_completion_tokens INTEGER DEFAULT 0,
p_cost DECIMAL(10,6) DEFAULT 0
)
RETURNS UUID AS $$
DECLARE
v_log_id UUID;
v_model_name VARCHAR(100);
v_provider VARCHAR(50);
v_current_month VARCHAR(7);
BEGIN
-- Obtener info del modelo
SELECT name, provider INTO v_model_name, v_provider
FROM ai.models WHERE id = p_model_id;
v_current_month := TO_CHAR(CURRENT_DATE, 'YYYY-MM');
-- Registrar uso
INSERT INTO ai.usage_logs (
tenant_id, user_id, model_id, model_name, provider,
usage_type, prompt_tokens, completion_tokens,
total_tokens, cost, usage_month
) VALUES (
p_tenant_id, p_user_id, p_model_id, v_model_name, v_provider,
p_usage_type, p_prompt_tokens, p_completion_tokens,
p_prompt_tokens + p_completion_tokens, p_cost, v_current_month
) RETURNING id INTO v_log_id;
-- Actualizar cuota del tenant
INSERT INTO ai.tenant_quotas (tenant_id, quota_month, current_tokens, current_requests, current_cost)
VALUES (p_tenant_id, v_current_month, p_prompt_tokens + p_completion_tokens, 1, p_cost)
ON CONFLICT (tenant_id, quota_month)
DO UPDATE SET
current_tokens = ai.tenant_quotas.current_tokens + p_prompt_tokens + p_completion_tokens,
current_requests = ai.tenant_quotas.current_requests + 1,
current_cost = ai.tenant_quotas.current_cost + p_cost,
updated_at = CURRENT_TIMESTAMP;
RETURN v_log_id;
END;
$$ LANGUAGE plpgsql;
-- Función para verificar cuota
CREATE OR REPLACE FUNCTION ai.check_quota(p_tenant_id UUID)
RETURNS TABLE (
has_quota BOOLEAN,
tokens_remaining INTEGER,
requests_remaining INTEGER,
cost_remaining DECIMAL,
percent_used INTEGER
) AS $$
DECLARE
v_quota RECORD;
BEGIN
SELECT * INTO v_quota
FROM ai.tenant_quotas
WHERE tenant_id = p_tenant_id
AND quota_month = TO_CHAR(CURRENT_DATE, 'YYYY-MM');
IF NOT FOUND THEN
-- Sin límites configurados
RETURN QUERY SELECT TRUE, NULL::INTEGER, NULL::INTEGER, NULL::DECIMAL, 0;
RETURN;
END IF;
RETURN QUERY SELECT
NOT v_quota.is_exceeded AND
(v_quota.monthly_token_limit IS NULL OR v_quota.current_tokens < v_quota.monthly_token_limit) AND
(v_quota.monthly_request_limit IS NULL OR v_quota.current_requests < v_quota.monthly_request_limit) AND
(v_quota.monthly_cost_limit IS NULL OR v_quota.current_cost < v_quota.monthly_cost_limit),
CASE WHEN v_quota.monthly_token_limit IS NOT NULL
THEN v_quota.monthly_token_limit - v_quota.current_tokens
ELSE NULL END,
CASE WHEN v_quota.monthly_request_limit IS NOT NULL
THEN v_quota.monthly_request_limit - v_quota.current_requests
ELSE NULL END,
CASE WHEN v_quota.monthly_cost_limit IS NOT NULL
THEN v_quota.monthly_cost_limit - v_quota.current_cost
ELSE NULL END,
CASE WHEN v_quota.monthly_token_limit IS NOT NULL
THEN (v_quota.current_tokens * 100 / v_quota.monthly_token_limit)::INTEGER
ELSE 0 END;
END;
$$ LANGUAGE plpgsql STABLE;
-- Función para obtener uso del tenant
CREATE OR REPLACE FUNCTION ai.get_tenant_usage(
p_tenant_id UUID,
p_month VARCHAR(7) DEFAULT NULL
)
RETURNS TABLE (
total_tokens BIGINT,
total_requests BIGINT,
total_cost DECIMAL,
usage_by_model JSONB,
usage_by_type JSONB,
daily_usage JSONB
) AS $$
DECLARE
v_month VARCHAR(7);
BEGIN
v_month := COALESCE(p_month, TO_CHAR(CURRENT_DATE, 'YYYY-MM'));
RETURN QUERY
SELECT
COALESCE(SUM(ul.total_tokens), 0)::BIGINT as total_tokens,
COUNT(*)::BIGINT as total_requests,
COALESCE(SUM(ul.cost), 0)::DECIMAL as total_cost,
jsonb_object_agg(
COALESCE(ul.model_name, 'unknown'),
model_tokens
) as usage_by_model,
jsonb_object_agg(ul.usage_type, type_tokens) as usage_by_type,
jsonb_object_agg(ul.usage_date::TEXT, day_tokens) as daily_usage
FROM ai.usage_logs ul
LEFT JOIN (
SELECT model_name, SUM(total_tokens) as model_tokens
FROM ai.usage_logs
WHERE tenant_id = p_tenant_id AND usage_month = v_month
GROUP BY model_name
) models ON models.model_name = ul.model_name
LEFT JOIN (
SELECT usage_type, SUM(total_tokens) as type_tokens
FROM ai.usage_logs
WHERE tenant_id = p_tenant_id AND usage_month = v_month
GROUP BY usage_type
) types ON types.usage_type = ul.usage_type
LEFT JOIN (
SELECT usage_date, SUM(total_tokens) as day_tokens
FROM ai.usage_logs
WHERE tenant_id = p_tenant_id AND usage_month = v_month
GROUP BY usage_date
) days ON days.usage_date = ul.usage_date
WHERE ul.tenant_id = p_tenant_id
AND ul.usage_month = v_month;
END;
$$ LANGUAGE plpgsql STABLE;
-- =====================
-- TRIGGERS
-- =====================
CREATE OR REPLACE FUNCTION ai.update_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_models_updated_at
BEFORE UPDATE ON ai.models
FOR EACH ROW EXECUTE FUNCTION ai.update_timestamp();
CREATE TRIGGER trg_prompts_updated_at
BEFORE UPDATE ON ai.prompts
FOR EACH ROW EXECUTE FUNCTION ai.update_timestamp();
CREATE TRIGGER trg_conversations_updated_at
BEFORE UPDATE ON ai.conversations
FOR EACH ROW EXECUTE FUNCTION ai.update_timestamp();
CREATE TRIGGER trg_embeddings_updated_at
BEFORE UPDATE ON ai.embeddings
FOR EACH ROW EXECUTE FUNCTION ai.update_timestamp();
CREATE TRIGGER trg_kb_updated_at
BEFORE UPDATE ON ai.knowledge_base
FOR EACH ROW EXECUTE FUNCTION ai.update_timestamp();
-- =====================
-- SEED DATA: Modelos
-- =====================
INSERT INTO ai.models (code, name, provider, model_id, model_type, max_tokens, supports_functions, supports_vision, input_cost_per_1k, output_cost_per_1k) VALUES
('gpt-4o', 'GPT-4o', 'openai', 'gpt-4o', 'chat', 128000, TRUE, TRUE, 0.005, 0.015),
('gpt-4o-mini', 'GPT-4o Mini', 'openai', 'gpt-4o-mini', 'chat', 128000, TRUE, TRUE, 0.00015, 0.0006),
('gpt-4-turbo', 'GPT-4 Turbo', 'openai', 'gpt-4-turbo', 'chat', 128000, TRUE, TRUE, 0.01, 0.03),
('claude-3-opus', 'Claude 3 Opus', 'anthropic', 'claude-3-opus-20240229', 'chat', 200000, TRUE, TRUE, 0.015, 0.075),
('claude-3-sonnet', 'Claude 3 Sonnet', 'anthropic', 'claude-3-sonnet-20240229', 'chat', 200000, TRUE, TRUE, 0.003, 0.015),
('claude-3-haiku', 'Claude 3 Haiku', 'anthropic', 'claude-3-haiku-20240307', 'chat', 200000, TRUE, TRUE, 0.00025, 0.00125),
('text-embedding-3-small', 'Text Embedding 3 Small', 'openai', 'text-embedding-3-small', 'embedding', 8191, FALSE, FALSE, 0.00002, 0),
('text-embedding-3-large', 'Text Embedding 3 Large', 'openai', 'text-embedding-3-large', 'embedding', 8191, FALSE, FALSE, 0.00013, 0)
ON CONFLICT (code) DO NOTHING;
-- =====================
-- SEED DATA: Prompts del Sistema
-- =====================
INSERT INTO ai.prompts (code, name, category, system_prompt, user_prompt_template, required_variables, is_system) VALUES
('assistant_general', 'Asistente General', 'assistant',
'Eres un asistente virtual para un sistema ERP. Ayudas a los usuarios con consultas sobre ventas, inventario, facturación y gestión empresarial. Responde de forma clara y concisa en español.',
'{{user_message}}',
'{user_message}', TRUE),
('sales_analysis', 'Análisis de Ventas', 'analysis',
'Eres un analista de ventas experto. Analiza los datos proporcionados y genera insights accionables.',
'Analiza los siguientes datos de ventas:\n\n{{sales_data}}\n\nGenera un resumen ejecutivo con los principales hallazgos.',
'{sales_data}', TRUE),
('product_description', 'Generador de Descripción', 'generation',
'Eres un copywriter experto en productos. Genera descripciones atractivas y persuasivas.',
'Genera una descripción de producto para:\n\nNombre: {{product_name}}\nCategoría: {{category}}\nCaracterísticas: {{features}}\n\nLa descripción debe ser de {{word_count}} palabras aproximadamente.',
'{product_name,category,features,word_count}', TRUE),
('invoice_data_extraction', 'Extracción de Facturas', 'extraction',
'Eres un experto en extracción de datos de documentos fiscales mexicanos. Extrae la información estructurada de facturas.',
'Extrae los datos de la siguiente factura:\n\n{{invoice_text}}\n\nDevuelve los datos en formato JSON con los campos: rfc_emisor, rfc_receptor, fecha, total, conceptos.',
'{invoice_text}', TRUE),
('support_response', 'Respuesta de Soporte', 'assistant',
'Eres un agente de soporte técnico. Responde de forma amable y profesional, proporcionando soluciones claras.',
'El cliente tiene el siguiente problema:\n\n{{issue_description}}\n\nContexto adicional:\n{{context}}\n\nGenera una respuesta de soporte apropiada.',
'{issue_description,context}', TRUE)
ON CONFLICT (tenant_id, code, version) DO NOTHING;
-- =====================
-- COMENTARIOS
-- =====================
COMMENT ON TABLE ai.models IS 'Modelos de AI disponibles (OpenAI, Anthropic, etc.)';
COMMENT ON TABLE ai.prompts IS 'Biblioteca de prompts del sistema y personalizados';
COMMENT ON TABLE ai.conversations IS 'Conversaciones con el asistente AI';
COMMENT ON TABLE ai.messages IS 'Mensajes individuales en conversaciones';
COMMENT ON TABLE ai.completions IS 'Completaciones individuales (no conversacionales)';
COMMENT ON TABLE ai.embeddings IS 'Embeddings vectoriales para búsqueda semántica';
COMMENT ON TABLE ai.usage_logs IS 'Log de uso de AI para billing y analytics';
COMMENT ON TABLE ai.tenant_quotas IS 'Cuotas de uso de AI por tenant';
COMMENT ON TABLE ai.knowledge_base IS 'Base de conocimiento para RAG';
COMMENT ON FUNCTION ai.create_conversation IS 'Crea una nueva conversación con el asistente';
COMMENT ON FUNCTION ai.add_message IS 'Agrega un mensaje a una conversación';
COMMENT ON FUNCTION ai.log_usage IS 'Registra uso de AI para billing';
COMMENT ON FUNCTION ai.check_quota IS 'Verifica si el tenant tiene cuota disponible';

1018
ddl/15-whatsapp.sql Normal file

File diff suppressed because it is too large Load Diff

215
ddl/16-partners.sql Normal file
View File

@ -0,0 +1,215 @@
-- =============================================================
-- ARCHIVO: 16-partners.sql
-- DESCRIPCION: Partners (clientes, proveedores, contactos)
-- VERSION: 1.0.0
-- PROYECTO: ERP-Core V2
-- FECHA: 2026-01-13
-- =============================================================
-- =====================
-- SCHEMA: partners
-- =====================
CREATE SCHEMA IF NOT EXISTS partners;
-- =====================
-- TABLA: partners
-- Clientes, proveedores, y otros socios comerciales
-- =====================
CREATE TABLE IF NOT EXISTS partners.partners (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Identificacion
code VARCHAR(30) NOT NULL,
name VARCHAR(200) NOT NULL,
display_name VARCHAR(200),
-- Tipo de partner
partner_type VARCHAR(20) NOT NULL DEFAULT 'customer', -- customer, supplier, both, contact
-- Datos fiscales
tax_id VARCHAR(50), -- RFC en Mexico
tax_id_type VARCHAR(20), -- rfc_moral, rfc_fisica, extranjero
legal_name VARCHAR(200),
-- Contacto principal
email VARCHAR(255),
phone VARCHAR(30),
mobile VARCHAR(30),
website VARCHAR(255),
-- Credito y pagos
credit_limit DECIMAL(15, 2) DEFAULT 0,
payment_term_days INTEGER DEFAULT 0,
payment_method VARCHAR(50), -- cash, transfer, credit_card, check
-- Clasificacion
category VARCHAR(50), -- retail, wholesale, government, etc.
tags TEXT[] DEFAULT '{}',
-- Estado
is_active BOOLEAN DEFAULT TRUE,
is_verified BOOLEAN DEFAULT FALSE,
verified_at TIMESTAMPTZ,
-- Configuracion
settings JSONB DEFAULT '{}',
-- Ejemplo: {"send_reminders": true, "preferred_contact": "email"}
-- Notas
notes TEXT,
-- Metadata
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
UNIQUE(tenant_id, code)
);
-- Indices para partners
CREATE INDEX IF NOT EXISTS idx_partners_tenant ON partners.partners(tenant_id);
CREATE INDEX IF NOT EXISTS idx_partners_code ON partners.partners(code);
CREATE INDEX IF NOT EXISTS idx_partners_type ON partners.partners(partner_type);
CREATE INDEX IF NOT EXISTS idx_partners_tax_id ON partners.partners(tax_id);
CREATE INDEX IF NOT EXISTS idx_partners_active ON partners.partners(is_active) WHERE is_active = TRUE;
CREATE INDEX IF NOT EXISTS idx_partners_email ON partners.partners(email);
CREATE INDEX IF NOT EXISTS idx_partners_name ON partners.partners USING gin(to_tsvector('spanish', name));
-- =====================
-- TABLA: partner_addresses
-- Direcciones de partners (facturacion, envio, etc.)
-- =====================
CREATE TABLE IF NOT EXISTS partners.partner_addresses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
partner_id UUID NOT NULL REFERENCES partners.partners(id) ON DELETE CASCADE,
-- Tipo de direccion
address_type VARCHAR(20) NOT NULL DEFAULT 'billing', -- billing, shipping, main, other
-- Direccion
address_line1 VARCHAR(200) NOT NULL,
address_line2 VARCHAR(200),
city VARCHAR(100) NOT NULL,
state VARCHAR(100),
postal_code VARCHAR(20),
country VARCHAR(3) DEFAULT 'MEX',
-- Contacto en esta direccion
contact_name VARCHAR(100),
contact_phone VARCHAR(30),
contact_email VARCHAR(255),
-- Referencia
reference TEXT,
-- Geolocalizacion
latitude DECIMAL(10, 8),
longitude DECIMAL(11, 8),
-- Estado
is_default BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE,
-- Metadata
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMPTZ
);
-- Indices para partner_addresses
CREATE INDEX IF NOT EXISTS idx_partner_addresses_partner ON partners.partner_addresses(partner_id);
CREATE INDEX IF NOT EXISTS idx_partner_addresses_type ON partners.partner_addresses(address_type);
CREATE INDEX IF NOT EXISTS idx_partner_addresses_default ON partners.partner_addresses(partner_id, is_default) WHERE is_default = TRUE;
-- =====================
-- TABLA: partner_contacts
-- Contactos individuales de un partner
-- =====================
CREATE TABLE IF NOT EXISTS partners.partner_contacts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
partner_id UUID NOT NULL REFERENCES partners.partners(id) ON DELETE CASCADE,
-- Datos personales
name VARCHAR(200) NOT NULL,
job_title VARCHAR(100),
department VARCHAR(100),
-- Contacto
email VARCHAR(255),
phone VARCHAR(30),
mobile VARCHAR(30),
-- Rol
contact_type VARCHAR(20) DEFAULT 'general', -- general, billing, purchasing, sales, technical
is_primary BOOLEAN DEFAULT FALSE,
-- Estado
is_active BOOLEAN DEFAULT TRUE,
-- Notas
notes TEXT,
-- Metadata
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMPTZ
);
-- Indices para partner_contacts
CREATE INDEX IF NOT EXISTS idx_partner_contacts_partner ON partners.partner_contacts(partner_id);
CREATE INDEX IF NOT EXISTS idx_partner_contacts_type ON partners.partner_contacts(contact_type);
CREATE INDEX IF NOT EXISTS idx_partner_contacts_primary ON partners.partner_contacts(partner_id, is_primary) WHERE is_primary = TRUE;
CREATE INDEX IF NOT EXISTS idx_partner_contacts_email ON partners.partner_contacts(email);
-- =====================
-- TABLA: partner_bank_accounts
-- Cuentas bancarias de partners
-- =====================
CREATE TABLE IF NOT EXISTS partners.partner_bank_accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
partner_id UUID NOT NULL REFERENCES partners.partners(id) ON DELETE CASCADE,
-- Datos bancarios
bank_name VARCHAR(100) NOT NULL,
account_number VARCHAR(50),
clabe VARCHAR(20), -- CLABE para Mexico
swift_code VARCHAR(20),
iban VARCHAR(50),
-- Titular
account_holder VARCHAR(200),
-- Estado
is_default BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE,
is_verified BOOLEAN DEFAULT FALSE,
-- Metadata
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMPTZ
);
-- Indices para partner_bank_accounts
CREATE INDEX IF NOT EXISTS idx_partner_bank_accounts_partner ON partners.partner_bank_accounts(partner_id);
CREATE INDEX IF NOT EXISTS idx_partner_bank_accounts_default ON partners.partner_bank_accounts(partner_id, is_default) WHERE is_default = TRUE;
-- =====================
-- COMENTARIOS
-- =====================
COMMENT ON TABLE partners.partners IS 'Clientes, proveedores y otros socios comerciales del negocio';
COMMENT ON COLUMN partners.partners.partner_type IS 'Tipo: customer (cliente), supplier (proveedor), both (ambos), contact (contacto)';
COMMENT ON COLUMN partners.partners.tax_id IS 'Identificacion fiscal (RFC en Mexico)';
COMMENT ON COLUMN partners.partners.credit_limit IS 'Limite de credito en moneda local';
COMMENT ON COLUMN partners.partners.payment_term_days IS 'Dias de plazo para pago';
COMMENT ON TABLE partners.partner_addresses IS 'Direcciones asociadas a un partner (facturacion, envio, etc.)';
COMMENT ON COLUMN partners.partner_addresses.address_type IS 'Tipo: billing, shipping, main, other';
COMMENT ON TABLE partners.partner_contacts IS 'Personas de contacto individuales de un partner';
COMMENT ON COLUMN partners.partner_contacts.contact_type IS 'Rol: general, billing, purchasing, sales, technical';
COMMENT ON TABLE partners.partner_bank_accounts IS 'Cuentas bancarias de partners para pagos/cobros';

230
ddl/17-products.sql Normal file
View File

@ -0,0 +1,230 @@
-- =============================================================
-- ARCHIVO: 17-products.sql
-- DESCRIPCION: Productos, categorias y precios
-- VERSION: 1.0.0
-- PROYECTO: ERP-Core V2
-- FECHA: 2026-01-13
-- =============================================================
-- =====================
-- SCHEMA: products
-- =====================
CREATE SCHEMA IF NOT EXISTS products;
-- =====================
-- TABLA: product_categories
-- Categorias jerarquicas de productos
-- =====================
CREATE TABLE IF NOT EXISTS products.product_categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
parent_id UUID REFERENCES products.product_categories(id) ON DELETE SET NULL,
-- Identificacion
code VARCHAR(30) NOT NULL,
name VARCHAR(100) NOT NULL,
description TEXT,
-- Jerarquia
hierarchy_path TEXT, -- /root/electronics/phones
hierarchy_level INTEGER DEFAULT 0,
-- Imagen/icono
image_url VARCHAR(500),
icon VARCHAR(50),
color VARCHAR(20),
-- Estado
is_active BOOLEAN DEFAULT TRUE,
display_order INTEGER DEFAULT 0,
-- Metadata
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
UNIQUE(tenant_id, code)
);
-- Indices para product_categories
CREATE INDEX IF NOT EXISTS idx_product_categories_tenant ON products.product_categories(tenant_id);
CREATE INDEX IF NOT EXISTS idx_product_categories_parent ON products.product_categories(parent_id);
CREATE INDEX IF NOT EXISTS idx_product_categories_code ON products.product_categories(code);
CREATE INDEX IF NOT EXISTS idx_product_categories_hierarchy ON products.product_categories(hierarchy_path);
CREATE INDEX IF NOT EXISTS idx_product_categories_active ON products.product_categories(is_active) WHERE is_active = TRUE;
-- =====================
-- TABLA: products
-- Productos y servicios
-- =====================
CREATE TABLE IF NOT EXISTS products.products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
category_id UUID REFERENCES products.product_categories(id) ON DELETE SET NULL,
-- Identificacion
sku VARCHAR(50) NOT NULL, -- Stock Keeping Unit
barcode VARCHAR(50), -- EAN, UPC, etc.
name VARCHAR(200) NOT NULL,
description TEXT,
short_description VARCHAR(500),
-- Tipo
product_type VARCHAR(20) NOT NULL DEFAULT 'product', -- product, service, consumable, kit
-- Precios
price DECIMAL(15, 4) NOT NULL DEFAULT 0,
cost DECIMAL(15, 4) DEFAULT 0,
currency VARCHAR(3) DEFAULT 'MXN',
tax_included BOOLEAN DEFAULT TRUE,
-- Impuestos
tax_rate DECIMAL(5, 2) DEFAULT 16.00, -- IVA en Mexico
tax_code VARCHAR(20), -- Codigo SAT para facturacion
-- Unidad de medida
uom VARCHAR(20) DEFAULT 'PZA', -- Unidad de medida principal
uom_purchase VARCHAR(20), -- Unidad de medida para compras
uom_conversion DECIMAL(10, 4) DEFAULT 1, -- Factor de conversion
-- Inventario
track_inventory BOOLEAN DEFAULT TRUE,
min_stock DECIMAL(15, 4) DEFAULT 0,
max_stock DECIMAL(15, 4),
reorder_point DECIMAL(15, 4),
lead_time_days INTEGER DEFAULT 0,
-- Caracteristicas fisicas
weight DECIMAL(10, 4), -- Peso en kg
length DECIMAL(10, 4), -- Dimensiones en cm
width DECIMAL(10, 4),
height DECIMAL(10, 4),
volume DECIMAL(10, 4), -- Volumen en m3
-- Imagenes
image_url VARCHAR(500),
images JSONB DEFAULT '[]', -- Array de URLs de imagenes
-- Atributos
attributes JSONB DEFAULT '{}',
-- Ejemplo: {"color": "red", "size": "XL", "material": "cotton"}
-- Estado
is_active BOOLEAN DEFAULT TRUE,
is_sellable BOOLEAN DEFAULT TRUE,
is_purchasable BOOLEAN DEFAULT TRUE,
-- Metadata
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
UNIQUE(tenant_id, sku)
);
-- Indices para products
CREATE INDEX IF NOT EXISTS idx_products_tenant ON products.products(tenant_id);
CREATE INDEX IF NOT EXISTS idx_products_category ON products.products(category_id);
CREATE INDEX IF NOT EXISTS idx_products_sku ON products.products(sku);
CREATE INDEX IF NOT EXISTS idx_products_barcode ON products.products(barcode);
CREATE INDEX IF NOT EXISTS idx_products_type ON products.products(product_type);
CREATE INDEX IF NOT EXISTS idx_products_active ON products.products(is_active) WHERE is_active = TRUE;
CREATE INDEX IF NOT EXISTS idx_products_name ON products.products USING gin(to_tsvector('spanish', name));
CREATE INDEX IF NOT EXISTS idx_products_sellable ON products.products(is_sellable) WHERE is_sellable = TRUE;
CREATE INDEX IF NOT EXISTS idx_products_purchasable ON products.products(is_purchasable) WHERE is_purchasable = TRUE;
-- =====================
-- TABLA: product_prices
-- Listas de precios y precios especiales
-- =====================
CREATE TABLE IF NOT EXISTS products.product_prices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
product_id UUID NOT NULL REFERENCES products.products(id) ON DELETE CASCADE,
-- Tipo de precio
price_type VARCHAR(30) NOT NULL DEFAULT 'standard', -- standard, wholesale, retail, promo
price_list_name VARCHAR(100),
-- Precio
price DECIMAL(15, 4) NOT NULL,
currency VARCHAR(3) DEFAULT 'MXN',
-- Cantidad minima para este precio
min_quantity DECIMAL(15, 4) DEFAULT 1,
-- Vigencia
valid_from TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
valid_to TIMESTAMPTZ,
-- Estado
is_active BOOLEAN DEFAULT TRUE,
-- Metadata
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Indices para product_prices
CREATE INDEX IF NOT EXISTS idx_product_prices_product ON products.product_prices(product_id);
CREATE INDEX IF NOT EXISTS idx_product_prices_type ON products.product_prices(price_type);
CREATE INDEX IF NOT EXISTS idx_product_prices_active ON products.product_prices(is_active) WHERE is_active = TRUE;
CREATE INDEX IF NOT EXISTS idx_product_prices_validity ON products.product_prices(valid_from, valid_to);
-- =====================
-- TABLA: product_suppliers
-- Proveedores de productos
-- =====================
CREATE TABLE IF NOT EXISTS products.product_suppliers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
product_id UUID NOT NULL REFERENCES products.products(id) ON DELETE CASCADE,
supplier_id UUID NOT NULL REFERENCES partners.partners(id) ON DELETE CASCADE,
-- Datos del proveedor
supplier_sku VARCHAR(50), -- SKU del proveedor
supplier_name VARCHAR(200), -- Nombre del producto del proveedor
-- Precios de compra
purchase_price DECIMAL(15, 4),
currency VARCHAR(3) DEFAULT 'MXN',
min_order_qty DECIMAL(15, 4) DEFAULT 1,
-- Tiempos
lead_time_days INTEGER DEFAULT 0,
-- Estado
is_preferred BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE,
-- Metadata
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE(product_id, supplier_id)
);
-- Indices para product_suppliers
CREATE INDEX IF NOT EXISTS idx_product_suppliers_product ON products.product_suppliers(product_id);
CREATE INDEX IF NOT EXISTS idx_product_suppliers_supplier ON products.product_suppliers(supplier_id);
CREATE INDEX IF NOT EXISTS idx_product_suppliers_preferred ON products.product_suppliers(product_id, is_preferred) WHERE is_preferred = TRUE;
-- =====================
-- COMENTARIOS
-- =====================
COMMENT ON TABLE products.product_categories IS 'Categorias jerarquicas para organizar productos';
COMMENT ON COLUMN products.product_categories.hierarchy_path IS 'Path materializado para consultas eficientes de jerarquia';
COMMENT ON TABLE products.products IS 'Catalogo de productos y servicios';
COMMENT ON COLUMN products.products.product_type IS 'Tipo: product (fisico), service (servicio), consumable (consumible), kit (combo)';
COMMENT ON COLUMN products.products.sku IS 'Stock Keeping Unit - identificador unico del producto';
COMMENT ON COLUMN products.products.tax_code IS 'Codigo SAT para facturacion electronica en Mexico';
COMMENT ON COLUMN products.products.track_inventory IS 'Si se debe llevar control de inventario';
COMMENT ON TABLE products.product_prices IS 'Listas de precios y precios especiales por cantidad';
COMMENT ON COLUMN products.product_prices.price_type IS 'Tipo: standard, wholesale (mayoreo), retail (menudeo), promo (promocional)';
COMMENT ON TABLE products.product_suppliers IS 'Relacion de productos con sus proveedores';
COMMENT ON COLUMN products.product_suppliers.is_preferred IS 'Proveedor preferido para este producto';

182
ddl/18-warehouses.sql Normal file
View File

@ -0,0 +1,182 @@
-- =============================================================
-- ARCHIVO: 18-warehouses.sql
-- DESCRIPCION: Almacenes y ubicaciones
-- VERSION: 1.0.0
-- PROYECTO: ERP-Core V2
-- FECHA: 2026-01-13
-- =============================================================
-- =====================
-- SCHEMA: inventory (compartido con 19-inventory.sql)
-- =====================
CREATE SCHEMA IF NOT EXISTS inventory;
-- =====================
-- TABLA: warehouses
-- Almacenes/bodegas para inventario
-- =====================
CREATE TABLE IF NOT EXISTS inventory.warehouses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
branch_id UUID REFERENCES core.branches(id) ON DELETE SET NULL,
-- Identificacion
code VARCHAR(20) NOT NULL,
name VARCHAR(100) NOT NULL,
description TEXT,
-- Tipo
warehouse_type VARCHAR(20) DEFAULT 'standard', -- standard, transit, returns, quarantine, virtual
-- Direccion
address_line1 VARCHAR(200),
address_line2 VARCHAR(200),
city VARCHAR(100),
state VARCHAR(100),
postal_code VARCHAR(20),
country VARCHAR(3) DEFAULT 'MEX',
-- Contacto
manager_name VARCHAR(100),
phone VARCHAR(30),
email VARCHAR(255),
-- Geolocalizacion
latitude DECIMAL(10, 8),
longitude DECIMAL(11, 8),
-- Capacidad
capacity_units INTEGER, -- Capacidad en unidades
capacity_volume DECIMAL(10, 4), -- Capacidad en m3
capacity_weight DECIMAL(10, 4), -- Capacidad en kg
-- Configuracion
settings JSONB DEFAULT '{}',
-- Ejemplo: {"allow_negative": false, "auto_reorder": true}
-- Estado
is_active BOOLEAN DEFAULT TRUE,
is_default BOOLEAN DEFAULT FALSE,
-- Metadata
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
UNIQUE(tenant_id, code)
);
-- Indices para warehouses
CREATE INDEX IF NOT EXISTS idx_warehouses_tenant ON inventory.warehouses(tenant_id);
CREATE INDEX IF NOT EXISTS idx_warehouses_branch ON inventory.warehouses(branch_id);
CREATE INDEX IF NOT EXISTS idx_warehouses_code ON inventory.warehouses(code);
CREATE INDEX IF NOT EXISTS idx_warehouses_type ON inventory.warehouses(warehouse_type);
CREATE INDEX IF NOT EXISTS idx_warehouses_active ON inventory.warehouses(is_active) WHERE is_active = TRUE;
CREATE INDEX IF NOT EXISTS idx_warehouses_default ON inventory.warehouses(tenant_id, is_default) WHERE is_default = TRUE;
-- =====================
-- TABLA: warehouse_locations
-- Ubicaciones dentro de almacenes (pasillos, racks, estantes)
-- =====================
CREATE TABLE IF NOT EXISTS inventory.warehouse_locations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
warehouse_id UUID NOT NULL REFERENCES inventory.warehouses(id) ON DELETE CASCADE,
parent_id UUID REFERENCES inventory.warehouse_locations(id) ON DELETE SET NULL,
-- Identificacion
code VARCHAR(30) NOT NULL,
name VARCHAR(100) NOT NULL,
barcode VARCHAR(50),
-- Tipo de ubicacion
location_type VARCHAR(20) DEFAULT 'shelf', -- zone, aisle, rack, shelf, bin
-- Jerarquia
hierarchy_path TEXT, -- /warehouse-01/zone-a/rack-1/shelf-2
hierarchy_level INTEGER DEFAULT 0,
-- Coordenadas dentro del almacen
aisle VARCHAR(10),
rack VARCHAR(10),
shelf VARCHAR(10),
bin VARCHAR(10),
-- Capacidad
capacity_units INTEGER,
capacity_volume DECIMAL(10, 4),
capacity_weight DECIMAL(10, 4),
-- Restricciones
allowed_product_types TEXT[] DEFAULT '{}', -- Tipos de producto permitidos
temperature_range JSONB, -- {"min": -20, "max": 4} para productos refrigerados
humidity_range JSONB, -- {"min": 30, "max": 50}
-- Estado
is_active BOOLEAN DEFAULT TRUE,
is_pickable BOOLEAN DEFAULT TRUE, -- Se puede tomar inventario
is_receivable BOOLEAN DEFAULT TRUE, -- Se puede recibir inventario
-- Metadata
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMPTZ,
UNIQUE(warehouse_id, code)
);
-- Indices para warehouse_locations
CREATE INDEX IF NOT EXISTS idx_warehouse_locations_warehouse ON inventory.warehouse_locations(warehouse_id);
CREATE INDEX IF NOT EXISTS idx_warehouse_locations_parent ON inventory.warehouse_locations(parent_id);
CREATE INDEX IF NOT EXISTS idx_warehouse_locations_code ON inventory.warehouse_locations(code);
CREATE INDEX IF NOT EXISTS idx_warehouse_locations_type ON inventory.warehouse_locations(location_type);
CREATE INDEX IF NOT EXISTS idx_warehouse_locations_hierarchy ON inventory.warehouse_locations(hierarchy_path);
CREATE INDEX IF NOT EXISTS idx_warehouse_locations_active ON inventory.warehouse_locations(is_active) WHERE is_active = TRUE;
CREATE INDEX IF NOT EXISTS idx_warehouse_locations_barcode ON inventory.warehouse_locations(barcode);
-- =====================
-- TABLA: warehouse_zones
-- Zonas logicas de almacen (para organizacion)
-- =====================
CREATE TABLE IF NOT EXISTS inventory.warehouse_zones (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
warehouse_id UUID NOT NULL REFERENCES inventory.warehouses(id) ON DELETE CASCADE,
-- Identificacion
code VARCHAR(20) NOT NULL,
name VARCHAR(100) NOT NULL,
description TEXT,
color VARCHAR(20),
-- Tipo de zona
zone_type VARCHAR(20) DEFAULT 'storage', -- storage, picking, packing, shipping, receiving, quarantine
-- Estado
is_active BOOLEAN DEFAULT TRUE,
-- Metadata
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE(warehouse_id, code)
);
-- Indices para warehouse_zones
CREATE INDEX IF NOT EXISTS idx_warehouse_zones_warehouse ON inventory.warehouse_zones(warehouse_id);
CREATE INDEX IF NOT EXISTS idx_warehouse_zones_type ON inventory.warehouse_zones(zone_type);
-- =====================
-- COMENTARIOS
-- =====================
COMMENT ON TABLE inventory.warehouses IS 'Almacenes y bodegas para gestion de inventario';
COMMENT ON COLUMN inventory.warehouses.warehouse_type IS 'Tipo: standard, transit (en transito), returns (devoluciones), quarantine (cuarentena), virtual';
COMMENT ON COLUMN inventory.warehouses.is_default IS 'Almacen por defecto para operaciones';
COMMENT ON TABLE inventory.warehouse_locations IS 'Ubicaciones fisicas dentro de almacenes (racks, estantes, bins)';
COMMENT ON COLUMN inventory.warehouse_locations.location_type IS 'Tipo: zone, aisle, rack, shelf, bin';
COMMENT ON COLUMN inventory.warehouse_locations.is_pickable IS 'Se puede hacer picking desde esta ubicacion';
COMMENT ON COLUMN inventory.warehouse_locations.is_receivable IS 'Se puede recibir inventario en esta ubicacion';
COMMENT ON TABLE inventory.warehouse_zones IS 'Zonas logicas para organizar el almacen';
COMMENT ON COLUMN inventory.warehouse_zones.zone_type IS 'Tipo: storage, picking, packing, shipping, receiving, quarantine';

303
ddl/21-inventory.sql Normal file
View File

@ -0,0 +1,303 @@
-- =============================================================
-- ARCHIVO: 21-inventory.sql
-- DESCRIPCION: Niveles de stock y movimientos de inventario
-- VERSION: 1.0.0
-- PROYECTO: ERP-Core V2
-- FECHA: 2026-01-13
-- DEPENDE DE: 17-products.sql, 18-warehouses.sql
-- =============================================================
-- =====================
-- SCHEMA: inventory (ya creado en 18-warehouses.sql)
-- =====================
-- =====================
-- TABLA: stock_levels
-- Niveles de inventario por producto/almacen/ubicacion
-- =====================
CREATE TABLE IF NOT EXISTS inventory.stock_levels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
product_id UUID NOT NULL REFERENCES products.products(id) ON DELETE CASCADE,
warehouse_id UUID NOT NULL REFERENCES inventory.warehouses(id) ON DELETE CASCADE,
location_id UUID REFERENCES inventory.warehouse_locations(id) ON DELETE SET NULL,
-- Cantidades
quantity_on_hand DECIMAL(15, 4) NOT NULL DEFAULT 0, -- Cantidad fisica disponible
quantity_reserved DECIMAL(15, 4) NOT NULL DEFAULT 0, -- Reservada para ordenes
quantity_available DECIMAL(15, 4) GENERATED ALWAYS AS (quantity_on_hand - quantity_reserved) STORED,
quantity_incoming DECIMAL(15, 4) NOT NULL DEFAULT 0, -- En transito/por recibir
quantity_outgoing DECIMAL(15, 4) NOT NULL DEFAULT 0, -- Por enviar
-- Lote y serie
lot_number VARCHAR(50),
serial_number VARCHAR(50),
expiry_date DATE,
-- Costo
unit_cost DECIMAL(15, 4),
total_cost DECIMAL(15, 4),
-- Ultima actividad
last_movement_at TIMESTAMPTZ,
last_count_at TIMESTAMPTZ,
-- Metadata
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE(product_id, warehouse_id, COALESCE(location_id, '00000000-0000-0000-0000-000000000000'::UUID), COALESCE(lot_number, ''), COALESCE(serial_number, ''))
);
-- Indices para stock_levels
CREATE INDEX IF NOT EXISTS idx_stock_levels_tenant ON inventory.stock_levels(tenant_id);
CREATE INDEX IF NOT EXISTS idx_stock_levels_product ON inventory.stock_levels(product_id);
CREATE INDEX IF NOT EXISTS idx_stock_levels_warehouse ON inventory.stock_levels(warehouse_id);
CREATE INDEX IF NOT EXISTS idx_stock_levels_location ON inventory.stock_levels(location_id);
CREATE INDEX IF NOT EXISTS idx_stock_levels_lot ON inventory.stock_levels(lot_number);
CREATE INDEX IF NOT EXISTS idx_stock_levels_serial ON inventory.stock_levels(serial_number);
CREATE INDEX IF NOT EXISTS idx_stock_levels_expiry ON inventory.stock_levels(expiry_date) WHERE expiry_date IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_stock_levels_low_stock ON inventory.stock_levels(quantity_on_hand) WHERE quantity_on_hand <= 0;
CREATE INDEX IF NOT EXISTS idx_stock_levels_available ON inventory.stock_levels(quantity_available);
-- =====================
-- TABLA: stock_movements
-- Movimientos de inventario (entradas, salidas, transferencias, ajustes)
-- =====================
CREATE TABLE IF NOT EXISTS inventory.stock_movements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Tipo de movimiento
movement_type VARCHAR(20) NOT NULL, -- receipt, shipment, transfer, adjustment, return, production, consumption
movement_number VARCHAR(30) NOT NULL, -- Numero secuencial
-- Producto
product_id UUID NOT NULL REFERENCES products.products(id) ON DELETE RESTRICT,
-- Origen y destino
source_warehouse_id UUID REFERENCES inventory.warehouses(id),
source_location_id UUID REFERENCES inventory.warehouse_locations(id),
dest_warehouse_id UUID REFERENCES inventory.warehouses(id),
dest_location_id UUID REFERENCES inventory.warehouse_locations(id),
-- Cantidad
quantity DECIMAL(15, 4) NOT NULL,
uom VARCHAR(20) DEFAULT 'PZA',
-- Lote y serie
lot_number VARCHAR(50),
serial_number VARCHAR(50),
expiry_date DATE,
-- Costo
unit_cost DECIMAL(15, 4),
total_cost DECIMAL(15, 4),
-- Referencia
reference_type VARCHAR(30), -- sales_order, purchase_order, transfer_order, adjustment, return
reference_id UUID,
reference_number VARCHAR(50),
-- Razon (para ajustes)
reason VARCHAR(100),
notes TEXT,
-- Estado
status VARCHAR(20) NOT NULL DEFAULT 'draft', -- draft, confirmed, cancelled
confirmed_at TIMESTAMPTZ,
confirmed_by UUID REFERENCES auth.users(id),
-- Metadata
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMPTZ
);
-- Indices para stock_movements
CREATE INDEX IF NOT EXISTS idx_stock_movements_tenant ON inventory.stock_movements(tenant_id);
CREATE INDEX IF NOT EXISTS idx_stock_movements_type ON inventory.stock_movements(movement_type);
CREATE INDEX IF NOT EXISTS idx_stock_movements_number ON inventory.stock_movements(movement_number);
CREATE INDEX IF NOT EXISTS idx_stock_movements_product ON inventory.stock_movements(product_id);
CREATE INDEX IF NOT EXISTS idx_stock_movements_source ON inventory.stock_movements(source_warehouse_id);
CREATE INDEX IF NOT EXISTS idx_stock_movements_dest ON inventory.stock_movements(dest_warehouse_id);
CREATE INDEX IF NOT EXISTS idx_stock_movements_status ON inventory.stock_movements(status);
CREATE INDEX IF NOT EXISTS idx_stock_movements_reference ON inventory.stock_movements(reference_type, reference_id);
CREATE INDEX IF NOT EXISTS idx_stock_movements_date ON inventory.stock_movements(created_at);
CREATE INDEX IF NOT EXISTS idx_stock_movements_lot ON inventory.stock_movements(lot_number) WHERE lot_number IS NOT NULL;
-- =====================
-- TABLA: inventory_counts
-- Conteos fisicos de inventario
-- =====================
CREATE TABLE IF NOT EXISTS inventory.inventory_counts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
warehouse_id UUID NOT NULL REFERENCES inventory.warehouses(id) ON DELETE CASCADE,
-- Identificacion
count_number VARCHAR(30) NOT NULL,
name VARCHAR(100),
-- Tipo de conteo
count_type VARCHAR(20) DEFAULT 'full', -- full, partial, cycle, spot
-- Fecha programada
scheduled_date DATE,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
-- Estado
status VARCHAR(20) NOT NULL DEFAULT 'draft', -- draft, in_progress, completed, cancelled
-- Responsable
assigned_to UUID REFERENCES auth.users(id),
-- Notas
notes TEXT,
-- Metadata
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Indices para inventory_counts
CREATE INDEX IF NOT EXISTS idx_inventory_counts_tenant ON inventory.inventory_counts(tenant_id);
CREATE INDEX IF NOT EXISTS idx_inventory_counts_warehouse ON inventory.inventory_counts(warehouse_id);
CREATE INDEX IF NOT EXISTS idx_inventory_counts_status ON inventory.inventory_counts(status);
CREATE INDEX IF NOT EXISTS idx_inventory_counts_date ON inventory.inventory_counts(scheduled_date);
-- =====================
-- TABLA: inventory_count_lines
-- Lineas de conteo de inventario
-- =====================
CREATE TABLE IF NOT EXISTS inventory.inventory_count_lines (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
count_id UUID NOT NULL REFERENCES inventory.inventory_counts(id) ON DELETE CASCADE,
product_id UUID NOT NULL REFERENCES products.products(id) ON DELETE RESTRICT,
location_id UUID REFERENCES inventory.warehouse_locations(id),
-- Cantidades
system_quantity DECIMAL(15, 4), -- Cantidad segun sistema
counted_quantity DECIMAL(15, 4), -- Cantidad contada
difference DECIMAL(15, 4) GENERATED ALWAYS AS (COALESCE(counted_quantity, 0) - COALESCE(system_quantity, 0)) STORED,
-- Lote y serie
lot_number VARCHAR(50),
serial_number VARCHAR(50),
-- Estado
is_counted BOOLEAN DEFAULT FALSE,
counted_at TIMESTAMPTZ,
counted_by UUID REFERENCES auth.users(id),
-- Notas
notes TEXT,
-- Metadata
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Indices para inventory_count_lines
CREATE INDEX IF NOT EXISTS idx_inventory_count_lines_count ON inventory.inventory_count_lines(count_id);
CREATE INDEX IF NOT EXISTS idx_inventory_count_lines_product ON inventory.inventory_count_lines(product_id);
CREATE INDEX IF NOT EXISTS idx_inventory_count_lines_location ON inventory.inventory_count_lines(location_id);
CREATE INDEX IF NOT EXISTS idx_inventory_count_lines_counted ON inventory.inventory_count_lines(is_counted);
-- =====================
-- TABLA: transfer_orders
-- Ordenes de transferencia entre almacenes
-- =====================
CREATE TABLE IF NOT EXISTS inventory.transfer_orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Identificacion
transfer_number VARCHAR(30) NOT NULL,
-- Origen y destino
source_warehouse_id UUID NOT NULL REFERENCES inventory.warehouses(id),
dest_warehouse_id UUID NOT NULL REFERENCES inventory.warehouses(id),
-- Fechas
scheduled_date DATE,
shipped_at TIMESTAMPTZ,
received_at TIMESTAMPTZ,
-- Estado
status VARCHAR(20) NOT NULL DEFAULT 'draft', -- draft, confirmed, shipped, in_transit, received, cancelled
-- Notas
notes TEXT,
-- Metadata
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMPTZ,
UNIQUE(tenant_id, transfer_number)
);
-- Indices para transfer_orders
CREATE INDEX IF NOT EXISTS idx_transfer_orders_tenant ON inventory.transfer_orders(tenant_id);
CREATE INDEX IF NOT EXISTS idx_transfer_orders_source ON inventory.transfer_orders(source_warehouse_id);
CREATE INDEX IF NOT EXISTS idx_transfer_orders_dest ON inventory.transfer_orders(dest_warehouse_id);
CREATE INDEX IF NOT EXISTS idx_transfer_orders_status ON inventory.transfer_orders(status);
CREATE INDEX IF NOT EXISTS idx_transfer_orders_date ON inventory.transfer_orders(scheduled_date);
-- =====================
-- TABLA: transfer_order_lines
-- Lineas de orden de transferencia
-- =====================
CREATE TABLE IF NOT EXISTS inventory.transfer_order_lines (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
transfer_id UUID NOT NULL REFERENCES inventory.transfer_orders(id) ON DELETE CASCADE,
product_id UUID NOT NULL REFERENCES products.products(id) ON DELETE RESTRICT,
-- Ubicaciones especificas
source_location_id UUID REFERENCES inventory.warehouse_locations(id),
dest_location_id UUID REFERENCES inventory.warehouse_locations(id),
-- Cantidades
quantity_requested DECIMAL(15, 4) NOT NULL,
quantity_shipped DECIMAL(15, 4) DEFAULT 0,
quantity_received DECIMAL(15, 4) DEFAULT 0,
-- Lote y serie
lot_number VARCHAR(50),
serial_number VARCHAR(50),
-- Notas
notes TEXT,
-- Metadata
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Indices para transfer_order_lines
CREATE INDEX IF NOT EXISTS idx_transfer_order_lines_transfer ON inventory.transfer_order_lines(transfer_id);
CREATE INDEX IF NOT EXISTS idx_transfer_order_lines_product ON inventory.transfer_order_lines(product_id);
-- =====================
-- COMENTARIOS
-- =====================
COMMENT ON TABLE inventory.stock_levels IS 'Niveles actuales de inventario por producto/almacen/ubicacion';
COMMENT ON COLUMN inventory.stock_levels.quantity_on_hand IS 'Cantidad fisica disponible en el almacen';
COMMENT ON COLUMN inventory.stock_levels.quantity_reserved IS 'Cantidad reservada para ordenes pendientes';
COMMENT ON COLUMN inventory.stock_levels.quantity_available IS 'Cantidad disponible para venta (on_hand - reserved)';
COMMENT ON COLUMN inventory.stock_levels.quantity_incoming IS 'Cantidad en transito o por recibir';
COMMENT ON TABLE inventory.stock_movements IS 'Historial de movimientos de inventario';
COMMENT ON COLUMN inventory.stock_movements.movement_type IS 'Tipo: receipt (entrada), shipment (salida), transfer, adjustment, return, production, consumption';
COMMENT ON COLUMN inventory.stock_movements.status IS 'Estado: draft, confirmed, cancelled';
COMMENT ON TABLE inventory.inventory_counts IS 'Conteos fisicos de inventario para reconciliacion';
COMMENT ON COLUMN inventory.inventory_counts.count_type IS 'Tipo: full (completo), partial, cycle (ciclico), spot (aleatorio)';
COMMENT ON TABLE inventory.transfer_orders IS 'Ordenes de transferencia entre almacenes';
COMMENT ON COLUMN inventory.transfer_orders.status IS 'Estado: draft, confirmed, shipped, in_transit, received, cancelled';

285
ddl/22-sales.sql Normal file
View File

@ -0,0 +1,285 @@
-- =============================================================
-- ARCHIVO: 22-sales.sql
-- DESCRIPCION: Cotizaciones y ordenes de venta
-- VERSION: 1.0.0
-- PROYECTO: ERP-Core V2
-- FECHA: 2026-01-13
-- DEPENDE DE: 16-partners.sql, 17-products.sql
-- =============================================================
-- =====================
-- SCHEMA: sales
-- =====================
CREATE SCHEMA IF NOT EXISTS sales;
-- =====================
-- TABLA: quotations
-- Cotizaciones de venta
-- =====================
CREATE TABLE IF NOT EXISTS sales.quotations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Identificacion
quotation_number VARCHAR(30) NOT NULL,
-- Cliente
partner_id UUID NOT NULL REFERENCES partners.partners(id) ON DELETE RESTRICT,
partner_name VARCHAR(200), -- Snapshot del nombre
partner_email VARCHAR(255),
-- Direcciones
billing_address JSONB,
shipping_address JSONB,
-- Fechas
quotation_date DATE NOT NULL DEFAULT CURRENT_DATE,
valid_until DATE,
expected_close_date DATE,
-- Vendedor
sales_rep_id UUID REFERENCES auth.users(id),
-- Totales
currency VARCHAR(3) DEFAULT 'MXN',
subtotal DECIMAL(15, 2) NOT NULL DEFAULT 0,
tax_amount DECIMAL(15, 2) NOT NULL DEFAULT 0,
discount_amount DECIMAL(15, 2) NOT NULL DEFAULT 0,
total DECIMAL(15, 2) NOT NULL DEFAULT 0,
-- Terminos
payment_term_days INTEGER DEFAULT 0,
payment_method VARCHAR(50),
-- Estado
status VARCHAR(20) NOT NULL DEFAULT 'draft', -- draft, sent, accepted, rejected, expired, converted
-- Conversion
converted_to_order BOOLEAN DEFAULT FALSE,
order_id UUID,
converted_at TIMESTAMPTZ,
-- Notas
notes TEXT,
internal_notes TEXT,
terms_and_conditions TEXT,
-- Metadata
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
UNIQUE(tenant_id, quotation_number)
);
-- Indices para quotations
CREATE INDEX IF NOT EXISTS idx_quotations_tenant ON sales.quotations(tenant_id);
CREATE INDEX IF NOT EXISTS idx_quotations_number ON sales.quotations(quotation_number);
CREATE INDEX IF NOT EXISTS idx_quotations_partner ON sales.quotations(partner_id);
CREATE INDEX IF NOT EXISTS idx_quotations_status ON sales.quotations(status);
CREATE INDEX IF NOT EXISTS idx_quotations_date ON sales.quotations(quotation_date);
CREATE INDEX IF NOT EXISTS idx_quotations_valid_until ON sales.quotations(valid_until);
CREATE INDEX IF NOT EXISTS idx_quotations_sales_rep ON sales.quotations(sales_rep_id);
-- =====================
-- TABLA: quotation_items
-- Lineas de cotizacion
-- =====================
CREATE TABLE IF NOT EXISTS sales.quotation_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
quotation_id UUID NOT NULL REFERENCES sales.quotations(id) ON DELETE CASCADE,
product_id UUID REFERENCES products.products(id) ON DELETE SET NULL,
-- Linea
line_number INTEGER NOT NULL DEFAULT 1,
-- Producto
product_sku VARCHAR(50),
product_name VARCHAR(200) NOT NULL,
description TEXT,
-- Cantidad y precio
quantity DECIMAL(15, 4) NOT NULL DEFAULT 1,
uom VARCHAR(20) DEFAULT 'PZA',
unit_price DECIMAL(15, 4) NOT NULL DEFAULT 0,
-- Descuentos
discount_percent DECIMAL(5, 2) DEFAULT 0,
discount_amount DECIMAL(15, 2) DEFAULT 0,
-- Impuestos
tax_rate DECIMAL(5, 2) DEFAULT 16.00,
tax_amount DECIMAL(15, 2) DEFAULT 0,
-- Totales
subtotal DECIMAL(15, 2) NOT NULL DEFAULT 0,
total DECIMAL(15, 2) NOT NULL DEFAULT 0,
-- Metadata
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Indices para quotation_items
CREATE INDEX IF NOT EXISTS idx_quotation_items_quotation ON sales.quotation_items(quotation_id);
CREATE INDEX IF NOT EXISTS idx_quotation_items_product ON sales.quotation_items(product_id);
CREATE INDEX IF NOT EXISTS idx_quotation_items_line ON sales.quotation_items(quotation_id, line_number);
-- =====================
-- TABLA: sales_orders
-- Ordenes de venta
-- =====================
CREATE TABLE IF NOT EXISTS sales.sales_orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Identificacion
order_number VARCHAR(30) NOT NULL,
-- Origen
quotation_id UUID REFERENCES sales.quotations(id),
-- Cliente
partner_id UUID NOT NULL REFERENCES partners.partners(id) ON DELETE RESTRICT,
partner_name VARCHAR(200),
partner_email VARCHAR(255),
-- Direcciones
billing_address JSONB,
shipping_address JSONB,
-- Fechas
order_date DATE NOT NULL DEFAULT CURRENT_DATE,
requested_date DATE, -- Fecha solicitada por cliente
promised_date DATE, -- Fecha prometida
shipped_date DATE,
delivered_date DATE,
-- Vendedor
sales_rep_id UUID REFERENCES auth.users(id),
-- Almacen
warehouse_id UUID REFERENCES inventory.warehouses(id),
-- Totales
currency VARCHAR(3) DEFAULT 'MXN',
subtotal DECIMAL(15, 2) NOT NULL DEFAULT 0,
tax_amount DECIMAL(15, 2) NOT NULL DEFAULT 0,
discount_amount DECIMAL(15, 2) NOT NULL DEFAULT 0,
shipping_amount DECIMAL(15, 2) DEFAULT 0,
total DECIMAL(15, 2) NOT NULL DEFAULT 0,
-- Terminos
payment_term_days INTEGER DEFAULT 0,
payment_method VARCHAR(50),
-- Estado
status VARCHAR(20) NOT NULL DEFAULT 'draft', -- draft, confirmed, processing, shipped, delivered, cancelled
-- Envio
shipping_method VARCHAR(50),
tracking_number VARCHAR(100),
carrier VARCHAR(100),
-- Notas
notes TEXT,
internal_notes TEXT,
-- Metadata
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
UNIQUE(tenant_id, order_number)
);
-- Indices para sales_orders
CREATE INDEX IF NOT EXISTS idx_sales_orders_tenant ON sales.sales_orders(tenant_id);
CREATE INDEX IF NOT EXISTS idx_sales_orders_number ON sales.sales_orders(order_number);
CREATE INDEX IF NOT EXISTS idx_sales_orders_quotation ON sales.sales_orders(quotation_id);
CREATE INDEX IF NOT EXISTS idx_sales_orders_partner ON sales.sales_orders(partner_id);
CREATE INDEX IF NOT EXISTS idx_sales_orders_status ON sales.sales_orders(status);
CREATE INDEX IF NOT EXISTS idx_sales_orders_date ON sales.sales_orders(order_date);
CREATE INDEX IF NOT EXISTS idx_sales_orders_warehouse ON sales.sales_orders(warehouse_id);
CREATE INDEX IF NOT EXISTS idx_sales_orders_sales_rep ON sales.sales_orders(sales_rep_id);
-- =====================
-- TABLA: sales_order_items
-- Lineas de orden de venta
-- =====================
CREATE TABLE IF NOT EXISTS sales.sales_order_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_id UUID NOT NULL REFERENCES sales.sales_orders(id) ON DELETE CASCADE,
product_id UUID REFERENCES products.products(id) ON DELETE SET NULL,
-- Linea
line_number INTEGER NOT NULL DEFAULT 1,
-- Producto
product_sku VARCHAR(50),
product_name VARCHAR(200) NOT NULL,
description TEXT,
-- Cantidad
quantity DECIMAL(15, 4) NOT NULL DEFAULT 1,
quantity_reserved DECIMAL(15, 4) DEFAULT 0,
quantity_shipped DECIMAL(15, 4) DEFAULT 0,
quantity_delivered DECIMAL(15, 4) DEFAULT 0,
quantity_returned DECIMAL(15, 4) DEFAULT 0,
uom VARCHAR(20) DEFAULT 'PZA',
-- Precio
unit_price DECIMAL(15, 4) NOT NULL DEFAULT 0,
unit_cost DECIMAL(15, 4) DEFAULT 0,
-- Descuentos
discount_percent DECIMAL(5, 2) DEFAULT 0,
discount_amount DECIMAL(15, 2) DEFAULT 0,
-- Impuestos
tax_rate DECIMAL(5, 2) DEFAULT 16.00,
tax_amount DECIMAL(15, 2) DEFAULT 0,
-- Totales
subtotal DECIMAL(15, 2) NOT NULL DEFAULT 0,
total DECIMAL(15, 2) NOT NULL DEFAULT 0,
-- Lote/Serie
lot_number VARCHAR(50),
serial_number VARCHAR(50),
-- Estado
status VARCHAR(20) DEFAULT 'pending', -- pending, reserved, shipped, delivered, cancelled
-- Metadata
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Indices para sales_order_items
CREATE INDEX IF NOT EXISTS idx_sales_order_items_order ON sales.sales_order_items(order_id);
CREATE INDEX IF NOT EXISTS idx_sales_order_items_product ON sales.sales_order_items(product_id);
CREATE INDEX IF NOT EXISTS idx_sales_order_items_line ON sales.sales_order_items(order_id, line_number);
CREATE INDEX IF NOT EXISTS idx_sales_order_items_status ON sales.sales_order_items(status);
-- =====================
-- COMENTARIOS
-- =====================
COMMENT ON TABLE sales.quotations IS 'Cotizaciones de venta a clientes';
COMMENT ON COLUMN sales.quotations.status IS 'Estado: draft, sent, accepted, rejected, expired, converted';
COMMENT ON COLUMN sales.quotations.converted_to_order IS 'Indica si la cotizacion fue convertida a orden de venta';
COMMENT ON TABLE sales.quotation_items IS 'Lineas de detalle de cotizaciones';
COMMENT ON TABLE sales.sales_orders IS 'Ordenes de venta confirmadas';
COMMENT ON COLUMN sales.sales_orders.status IS 'Estado: draft, confirmed, processing, shipped, delivered, cancelled';
COMMENT ON COLUMN sales.sales_orders.quotation_id IS 'Referencia a la cotizacion origen (si aplica)';
COMMENT ON TABLE sales.sales_order_items IS 'Lineas de detalle de ordenes de venta';
COMMENT ON COLUMN sales.sales_order_items.quantity_reserved IS 'Cantidad reservada en inventario';
COMMENT ON COLUMN sales.sales_order_items.quantity_shipped IS 'Cantidad enviada';
COMMENT ON COLUMN sales.sales_order_items.quantity_delivered IS 'Cantidad entregada al cliente';

243
ddl/23-purchases.sql Normal file
View File

@ -0,0 +1,243 @@
-- =============================================================
-- ARCHIVO: 23-purchases.sql
-- DESCRIPCION: Ordenes de compra
-- VERSION: 1.0.0
-- PROYECTO: ERP-Core V2
-- FECHA: 2026-01-13
-- DEPENDE DE: 16-partners.sql, 17-products.sql, 18-warehouses.sql
-- =============================================================
-- =====================
-- SCHEMA: purchases
-- =====================
CREATE SCHEMA IF NOT EXISTS purchases;
-- =====================
-- TABLA: purchase_orders
-- Ordenes de compra a proveedores
-- =====================
CREATE TABLE IF NOT EXISTS purchases.purchase_orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Identificacion
order_number VARCHAR(30) NOT NULL,
-- Proveedor
supplier_id UUID NOT NULL REFERENCES partners.partners(id) ON DELETE RESTRICT,
supplier_name VARCHAR(200),
supplier_email VARCHAR(255),
-- Direcciones
shipping_address JSONB, -- Direccion de recepcion
-- Fechas
order_date DATE NOT NULL DEFAULT CURRENT_DATE,
expected_date DATE, -- Fecha esperada de recepcion
received_date DATE,
-- Comprador
buyer_id UUID REFERENCES auth.users(id),
-- Almacen destino
warehouse_id UUID REFERENCES inventory.warehouses(id),
-- Totales
currency VARCHAR(3) DEFAULT 'MXN',
subtotal DECIMAL(15, 2) NOT NULL DEFAULT 0,
tax_amount DECIMAL(15, 2) NOT NULL DEFAULT 0,
discount_amount DECIMAL(15, 2) NOT NULL DEFAULT 0,
shipping_amount DECIMAL(15, 2) DEFAULT 0,
total DECIMAL(15, 2) NOT NULL DEFAULT 0,
-- Terminos
payment_term_days INTEGER DEFAULT 0,
payment_method VARCHAR(50),
incoterm VARCHAR(10), -- FOB, CIF, EXW, etc.
-- Estado
status VARCHAR(20) NOT NULL DEFAULT 'draft', -- draft, sent, confirmed, partial, received, cancelled
-- Referencia del proveedor
supplier_reference VARCHAR(100),
-- Notas
notes TEXT,
internal_notes TEXT,
-- Metadata
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
UNIQUE(tenant_id, order_number)
);
-- Indices para purchase_orders
CREATE INDEX IF NOT EXISTS idx_purchase_orders_tenant ON purchases.purchase_orders(tenant_id);
CREATE INDEX IF NOT EXISTS idx_purchase_orders_number ON purchases.purchase_orders(order_number);
CREATE INDEX IF NOT EXISTS idx_purchase_orders_supplier ON purchases.purchase_orders(supplier_id);
CREATE INDEX IF NOT EXISTS idx_purchase_orders_status ON purchases.purchase_orders(status);
CREATE INDEX IF NOT EXISTS idx_purchase_orders_date ON purchases.purchase_orders(order_date);
CREATE INDEX IF NOT EXISTS idx_purchase_orders_expected ON purchases.purchase_orders(expected_date);
CREATE INDEX IF NOT EXISTS idx_purchase_orders_warehouse ON purchases.purchase_orders(warehouse_id);
CREATE INDEX IF NOT EXISTS idx_purchase_orders_buyer ON purchases.purchase_orders(buyer_id);
-- =====================
-- TABLA: purchase_order_items
-- Lineas de orden de compra
-- =====================
CREATE TABLE IF NOT EXISTS purchases.purchase_order_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_id UUID NOT NULL REFERENCES purchases.purchase_orders(id) ON DELETE CASCADE,
product_id UUID REFERENCES products.products(id) ON DELETE SET NULL,
-- Linea
line_number INTEGER NOT NULL DEFAULT 1,
-- Producto
product_sku VARCHAR(50),
product_name VARCHAR(200) NOT NULL,
supplier_sku VARCHAR(50), -- SKU del proveedor
description TEXT,
-- Cantidad
quantity DECIMAL(15, 4) NOT NULL DEFAULT 1,
quantity_received DECIMAL(15, 4) DEFAULT 0,
quantity_returned DECIMAL(15, 4) DEFAULT 0,
uom VARCHAR(20) DEFAULT 'PZA',
-- Precio
unit_price DECIMAL(15, 4) NOT NULL DEFAULT 0,
-- Descuentos
discount_percent DECIMAL(5, 2) DEFAULT 0,
discount_amount DECIMAL(15, 2) DEFAULT 0,
-- Impuestos
tax_rate DECIMAL(5, 2) DEFAULT 16.00,
tax_amount DECIMAL(15, 2) DEFAULT 0,
-- Totales
subtotal DECIMAL(15, 2) NOT NULL DEFAULT 0,
total DECIMAL(15, 2) NOT NULL DEFAULT 0,
-- Lote/Serie
lot_number VARCHAR(50),
expiry_date DATE,
-- Estado
status VARCHAR(20) DEFAULT 'pending', -- pending, partial, received, cancelled
-- Metadata
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Indices para purchase_order_items
CREATE INDEX IF NOT EXISTS idx_purchase_order_items_order ON purchases.purchase_order_items(order_id);
CREATE INDEX IF NOT EXISTS idx_purchase_order_items_product ON purchases.purchase_order_items(product_id);
CREATE INDEX IF NOT EXISTS idx_purchase_order_items_line ON purchases.purchase_order_items(order_id, line_number);
CREATE INDEX IF NOT EXISTS idx_purchase_order_items_status ON purchases.purchase_order_items(status);
-- =====================
-- TABLA: purchase_receipts
-- Recepciones de mercancia
-- =====================
CREATE TABLE IF NOT EXISTS purchases.purchase_receipts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
order_id UUID NOT NULL REFERENCES purchases.purchase_orders(id) ON DELETE RESTRICT,
-- Identificacion
receipt_number VARCHAR(30) NOT NULL,
-- Recepcion
receipt_date DATE NOT NULL DEFAULT CURRENT_DATE,
received_by UUID REFERENCES auth.users(id),
-- Almacen
warehouse_id UUID REFERENCES inventory.warehouses(id),
location_id UUID REFERENCES inventory.warehouse_locations(id),
-- Documentos del proveedor
supplier_delivery_note VARCHAR(100),
supplier_invoice_number VARCHAR(100),
-- Estado
status VARCHAR(20) NOT NULL DEFAULT 'draft', -- draft, confirmed, cancelled
-- Notas
notes TEXT,
-- Metadata
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE(tenant_id, receipt_number)
);
-- Indices para purchase_receipts
CREATE INDEX IF NOT EXISTS idx_purchase_receipts_tenant ON purchases.purchase_receipts(tenant_id);
CREATE INDEX IF NOT EXISTS idx_purchase_receipts_order ON purchases.purchase_receipts(order_id);
CREATE INDEX IF NOT EXISTS idx_purchase_receipts_number ON purchases.purchase_receipts(receipt_number);
CREATE INDEX IF NOT EXISTS idx_purchase_receipts_date ON purchases.purchase_receipts(receipt_date);
CREATE INDEX IF NOT EXISTS idx_purchase_receipts_status ON purchases.purchase_receipts(status);
-- =====================
-- TABLA: purchase_receipt_items
-- Lineas de recepcion
-- =====================
CREATE TABLE IF NOT EXISTS purchases.purchase_receipt_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
receipt_id UUID NOT NULL REFERENCES purchases.purchase_receipts(id) ON DELETE CASCADE,
order_item_id UUID REFERENCES purchases.purchase_order_items(id),
product_id UUID REFERENCES products.products(id) ON DELETE SET NULL,
-- Cantidad
quantity_expected DECIMAL(15, 4),
quantity_received DECIMAL(15, 4) NOT NULL,
quantity_rejected DECIMAL(15, 4) DEFAULT 0,
-- Lote/Serie
lot_number VARCHAR(50),
serial_number VARCHAR(50),
expiry_date DATE,
-- Ubicacion de almacenamiento
location_id UUID REFERENCES inventory.warehouse_locations(id),
-- Control de calidad
quality_status VARCHAR(20) DEFAULT 'pending', -- pending, approved, rejected, quarantine
quality_notes TEXT,
-- Metadata
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Indices para purchase_receipt_items
CREATE INDEX IF NOT EXISTS idx_purchase_receipt_items_receipt ON purchases.purchase_receipt_items(receipt_id);
CREATE INDEX IF NOT EXISTS idx_purchase_receipt_items_order_item ON purchases.purchase_receipt_items(order_item_id);
CREATE INDEX IF NOT EXISTS idx_purchase_receipt_items_product ON purchases.purchase_receipt_items(product_id);
CREATE INDEX IF NOT EXISTS idx_purchase_receipt_items_lot ON purchases.purchase_receipt_items(lot_number);
-- =====================
-- COMENTARIOS
-- =====================
COMMENT ON TABLE purchases.purchase_orders IS 'Ordenes de compra a proveedores';
COMMENT ON COLUMN purchases.purchase_orders.status IS 'Estado: draft, sent, confirmed, partial (parcialmente recibido), received, cancelled';
COMMENT ON COLUMN purchases.purchase_orders.incoterm IS 'Termino de comercio internacional: FOB, CIF, EXW, etc.';
COMMENT ON TABLE purchases.purchase_order_items IS 'Lineas de detalle de ordenes de compra';
COMMENT ON COLUMN purchases.purchase_order_items.supplier_sku IS 'Codigo del producto segun el proveedor';
COMMENT ON COLUMN purchases.purchase_order_items.quantity_received IS 'Cantidad ya recibida de esta linea';
COMMENT ON TABLE purchases.purchase_receipts IS 'Documentos de recepcion de mercancia';
COMMENT ON COLUMN purchases.purchase_receipts.status IS 'Estado: draft, confirmed, cancelled';
COMMENT ON TABLE purchases.purchase_receipt_items IS 'Lineas de detalle de recepciones';
COMMENT ON COLUMN purchases.purchase_receipt_items.quality_status IS 'Estado QC: pending, approved, rejected, quarantine';

250
ddl/24-invoices.sql Normal file
View File

@ -0,0 +1,250 @@
-- =============================================================
-- ARCHIVO: 24-invoices.sql
-- DESCRIPCION: Facturas de venta/compra y pagos
-- VERSION: 1.0.0
-- PROYECTO: ERP-Core V2
-- FECHA: 2026-01-13
-- DEPENDE DE: 16-partners.sql, 22-sales.sql, 23-purchases.sql
-- =============================================================
-- =====================
-- SCHEMA: billing
-- =====================
CREATE SCHEMA IF NOT EXISTS billing;
-- =====================
-- TABLA: invoices
-- Facturas de venta y compra
-- =====================
CREATE TABLE IF NOT EXISTS billing.invoices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Identificacion
invoice_number VARCHAR(30) NOT NULL,
invoice_type VARCHAR(20) NOT NULL DEFAULT 'sale', -- sale (venta), purchase (compra), credit_note, debit_note
-- Referencia a origen
sales_order_id UUID REFERENCES sales.sales_orders(id),
purchase_order_id UUID REFERENCES purchases.purchase_orders(id),
-- Partner
partner_id UUID NOT NULL REFERENCES partners.partners(id) ON DELETE RESTRICT,
partner_name VARCHAR(200),
partner_tax_id VARCHAR(50),
-- Direcciones
billing_address JSONB,
-- Fechas
invoice_date DATE NOT NULL DEFAULT CURRENT_DATE,
due_date DATE,
payment_date DATE, -- Fecha real de pago
-- Totales
currency VARCHAR(3) DEFAULT 'MXN',
exchange_rate DECIMAL(10, 6) DEFAULT 1,
subtotal DECIMAL(15, 2) NOT NULL DEFAULT 0,
tax_amount DECIMAL(15, 2) NOT NULL DEFAULT 0,
withholding_tax DECIMAL(15, 2) DEFAULT 0, -- Retenciones
discount_amount DECIMAL(15, 2) NOT NULL DEFAULT 0,
total DECIMAL(15, 2) NOT NULL DEFAULT 0,
-- Pagos
amount_paid DECIMAL(15, 2) DEFAULT 0,
amount_due DECIMAL(15, 2) GENERATED ALWAYS AS (total - COALESCE(amount_paid, 0)) STORED,
-- Terminos
payment_term_days INTEGER DEFAULT 0,
payment_method VARCHAR(50),
-- Estado
status VARCHAR(20) NOT NULL DEFAULT 'draft', -- draft, validated, sent, partial, paid, cancelled, voided
-- CFDI (Facturacion electronica Mexico)
cfdi_uuid VARCHAR(40), -- UUID del CFDI
cfdi_status VARCHAR(20), -- pending, stamped, cancelled
cfdi_xml TEXT, -- XML del CFDI
cfdi_pdf_url VARCHAR(500),
-- Notas
notes TEXT,
internal_notes TEXT,
-- Metadata
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMPTZ,
UNIQUE(tenant_id, invoice_number)
);
-- Indices para invoices
CREATE INDEX IF NOT EXISTS idx_invoices_tenant ON billing.invoices(tenant_id);
CREATE INDEX IF NOT EXISTS idx_invoices_number ON billing.invoices(invoice_number);
CREATE INDEX IF NOT EXISTS idx_invoices_type ON billing.invoices(invoice_type);
CREATE INDEX IF NOT EXISTS idx_invoices_partner ON billing.invoices(partner_id);
CREATE INDEX IF NOT EXISTS idx_invoices_sales_order ON billing.invoices(sales_order_id);
CREATE INDEX IF NOT EXISTS idx_invoices_purchase_order ON billing.invoices(purchase_order_id);
CREATE INDEX IF NOT EXISTS idx_invoices_status ON billing.invoices(status);
CREATE INDEX IF NOT EXISTS idx_invoices_date ON billing.invoices(invoice_date);
CREATE INDEX IF NOT EXISTS idx_invoices_due_date ON billing.invoices(due_date);
CREATE INDEX IF NOT EXISTS idx_invoices_cfdi ON billing.invoices(cfdi_uuid);
CREATE INDEX IF NOT EXISTS idx_invoices_unpaid ON billing.invoices(status) WHERE status IN ('validated', 'sent', 'partial');
-- =====================
-- TABLA: invoice_items
-- Lineas de factura
-- =====================
CREATE TABLE IF NOT EXISTS billing.invoice_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
invoice_id UUID NOT NULL REFERENCES billing.invoices(id) ON DELETE CASCADE,
product_id UUID REFERENCES products.products(id) ON DELETE SET NULL,
-- Linea
line_number INTEGER NOT NULL DEFAULT 1,
-- Producto
product_sku VARCHAR(50),
product_name VARCHAR(200) NOT NULL,
description TEXT,
-- SAT (Mexico)
sat_product_code VARCHAR(20), -- Clave de producto SAT
sat_unit_code VARCHAR(10), -- Clave de unidad SAT
-- Cantidad y precio
quantity DECIMAL(15, 4) NOT NULL DEFAULT 1,
uom VARCHAR(20) DEFAULT 'PZA',
unit_price DECIMAL(15, 4) NOT NULL DEFAULT 0,
-- Descuentos
discount_percent DECIMAL(5, 2) DEFAULT 0,
discount_amount DECIMAL(15, 2) DEFAULT 0,
-- Impuestos
tax_rate DECIMAL(5, 2) DEFAULT 16.00,
tax_amount DECIMAL(15, 2) DEFAULT 0,
withholding_rate DECIMAL(5, 2) DEFAULT 0,
withholding_amount DECIMAL(15, 2) DEFAULT 0,
-- Totales
subtotal DECIMAL(15, 2) NOT NULL DEFAULT 0,
total DECIMAL(15, 2) NOT NULL DEFAULT 0,
-- Metadata
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Indices para invoice_items
CREATE INDEX IF NOT EXISTS idx_invoice_items_invoice ON billing.invoice_items(invoice_id);
CREATE INDEX IF NOT EXISTS idx_invoice_items_product ON billing.invoice_items(product_id);
CREATE INDEX IF NOT EXISTS idx_invoice_items_line ON billing.invoice_items(invoice_id, line_number);
-- =====================
-- TABLA: payments
-- Pagos recibidos y realizados
-- =====================
CREATE TABLE IF NOT EXISTS billing.payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Identificacion
payment_number VARCHAR(30) NOT NULL,
payment_type VARCHAR(20) NOT NULL DEFAULT 'received', -- received (cobro), made (pago)
-- Partner
partner_id UUID NOT NULL REFERENCES partners.partners(id) ON DELETE RESTRICT,
partner_name VARCHAR(200),
-- Monto
currency VARCHAR(3) DEFAULT 'MXN',
amount DECIMAL(15, 2) NOT NULL,
exchange_rate DECIMAL(10, 6) DEFAULT 1,
-- Fecha
payment_date DATE NOT NULL DEFAULT CURRENT_DATE,
-- Metodo de pago
payment_method VARCHAR(50) NOT NULL, -- cash, transfer, check, credit_card, debit_card
reference VARCHAR(100), -- Numero de referencia, cheque, etc.
-- Cuenta bancaria
bank_account_id UUID REFERENCES partners.partner_bank_accounts(id),
-- Estado
status VARCHAR(20) NOT NULL DEFAULT 'draft', -- draft, confirmed, reconciled, cancelled
-- Notas
notes TEXT,
-- CFDI de pago (Mexico)
cfdi_uuid VARCHAR(40),
cfdi_status VARCHAR(20),
-- Metadata
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMPTZ,
UNIQUE(tenant_id, payment_number)
);
-- Indices para payments
CREATE INDEX IF NOT EXISTS idx_payments_tenant ON billing.payments(tenant_id);
CREATE INDEX IF NOT EXISTS idx_payments_number ON billing.payments(payment_number);
CREATE INDEX IF NOT EXISTS idx_payments_type ON billing.payments(payment_type);
CREATE INDEX IF NOT EXISTS idx_payments_partner ON billing.payments(partner_id);
CREATE INDEX IF NOT EXISTS idx_payments_status ON billing.payments(status);
CREATE INDEX IF NOT EXISTS idx_payments_date ON billing.payments(payment_date);
CREATE INDEX IF NOT EXISTS idx_payments_method ON billing.payments(payment_method);
-- =====================
-- TABLA: payment_allocations
-- Aplicacion de pagos a facturas
-- =====================
CREATE TABLE IF NOT EXISTS billing.payment_allocations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
payment_id UUID NOT NULL REFERENCES billing.payments(id) ON DELETE CASCADE,
invoice_id UUID NOT NULL REFERENCES billing.invoices(id) ON DELETE CASCADE,
-- Monto aplicado
amount DECIMAL(15, 2) NOT NULL,
-- Fecha de aplicacion
allocation_date DATE NOT NULL DEFAULT CURRENT_DATE,
-- Metadata
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
created_by UUID REFERENCES auth.users(id),
UNIQUE(payment_id, invoice_id)
);
-- Indices para payment_allocations
CREATE INDEX IF NOT EXISTS idx_payment_allocations_payment ON billing.payment_allocations(payment_id);
CREATE INDEX IF NOT EXISTS idx_payment_allocations_invoice ON billing.payment_allocations(invoice_id);
-- =====================
-- COMENTARIOS
-- =====================
COMMENT ON TABLE billing.invoices IS 'Facturas de venta y compra';
COMMENT ON COLUMN billing.invoices.invoice_type IS 'Tipo: sale (venta), purchase (compra), credit_note (nota credito), debit_note (nota debito)';
COMMENT ON COLUMN billing.invoices.status IS 'Estado: draft, validated, sent, partial (pago parcial), paid, cancelled, voided';
COMMENT ON COLUMN billing.invoices.cfdi_uuid IS 'UUID del CFDI para facturacion electronica en Mexico';
COMMENT ON COLUMN billing.invoices.amount_due IS 'Saldo pendiente de pago (calculado)';
COMMENT ON TABLE billing.invoice_items IS 'Lineas de detalle de facturas';
COMMENT ON COLUMN billing.invoice_items.sat_product_code IS 'Clave de producto del catalogo SAT (Mexico)';
COMMENT ON COLUMN billing.invoice_items.sat_unit_code IS 'Clave de unidad del catalogo SAT (Mexico)';
COMMENT ON TABLE billing.payments IS 'Registro de pagos recibidos y realizados';
COMMENT ON COLUMN billing.payments.payment_type IS 'Tipo: received (cobro a cliente), made (pago a proveedor)';
COMMENT ON COLUMN billing.payments.status IS 'Estado: draft, confirmed, reconciled, cancelled';
COMMENT ON TABLE billing.payment_allocations IS 'Aplicacion de pagos a facturas especificas';
COMMENT ON COLUMN billing.payment_allocations.amount IS 'Monto del pago aplicado a esta factura';

View File

@ -0,0 +1,238 @@
-- =============================================================
-- MIGRACION: 20260110_001_add_tenant_user_fields.sql
-- DESCRIPCION: Agregar campos nuevos a tenants y users para soportar
-- persona moral/fisica, sucursales, perfiles y mobile
-- VERSION: 1.0.0
-- PROYECTO: ERP-Core V2
-- FECHA: 2026-01-10
-- =============================================================
-- =====================
-- UP MIGRATION
-- =====================
BEGIN;
-- =====================
-- MODIFICACIONES A auth.tenants
-- =====================
-- Agregar columna client_type (Persona Moral o Fisica)
ALTER TABLE auth.tenants
ADD COLUMN IF NOT EXISTS client_type VARCHAR(20) DEFAULT 'persona_moral';
-- Agregar restriccion para client_type
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_tenant_client_type'
) THEN
ALTER TABLE auth.tenants
ADD CONSTRAINT chk_tenant_client_type
CHECK (client_type IN ('persona_fisica', 'persona_moral'));
END IF;
END $$;
-- Agregar persona responsable (representante legal o titular)
ALTER TABLE auth.tenants
ADD COLUMN IF NOT EXISTS responsible_person_id UUID REFERENCES auth.persons(id);
-- Agregar tipo de despliegue
ALTER TABLE auth.tenants
ADD COLUMN IF NOT EXISTS deployment_type VARCHAR(20) DEFAULT 'saas';
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_tenant_deployment_type'
) THEN
ALTER TABLE auth.tenants
ADD CONSTRAINT chk_tenant_deployment_type
CHECK (deployment_type IN ('saas', 'on_premise', 'hybrid'));
END IF;
END $$;
-- Agregar configuracion de facturacion
ALTER TABLE auth.tenants
ADD COLUMN IF NOT EXISTS billing_config JSONB DEFAULT '{}';
-- Agregar sucursal matriz
ALTER TABLE auth.tenants
ADD COLUMN IF NOT EXISTS main_branch_id UUID REFERENCES core.branches(id);
-- Agregar configuracion de perfiles permitidos
ALTER TABLE auth.tenants
ADD COLUMN IF NOT EXISTS allowed_profiles TEXT[] DEFAULT '{}';
-- Agregar configuracion de plataformas permitidas
ALTER TABLE auth.tenants
ADD COLUMN IF NOT EXISTS allowed_platforms TEXT[] DEFAULT '{web}';
-- Agregar limite de usuarios
ALTER TABLE auth.tenants
ADD COLUMN IF NOT EXISTS max_users INTEGER DEFAULT 5;
-- Agregar limite de sucursales
ALTER TABLE auth.tenants
ADD COLUMN IF NOT EXISTS max_branches INTEGER DEFAULT 1;
-- Agregar datos fiscales
ALTER TABLE auth.tenants
ADD COLUMN IF NOT EXISTS fiscal_data JSONB DEFAULT '{}';
-- Ejemplo: {"rfc": "ABC123456XYZ", "regimen_fiscal": "601", "uso_cfdi": "G03"}
-- Agregar logo
ALTER TABLE auth.tenants
ADD COLUMN IF NOT EXISTS logo_url TEXT;
-- Agregar configuracion general
ALTER TABLE auth.tenants
ADD COLUMN IF NOT EXISTS settings JSONB DEFAULT '{}';
-- Indices nuevos para tenants
CREATE INDEX IF NOT EXISTS idx_tenants_client_type ON auth.tenants(client_type);
CREATE INDEX IF NOT EXISTS idx_tenants_deployment ON auth.tenants(deployment_type);
CREATE INDEX IF NOT EXISTS idx_tenants_responsible ON auth.tenants(responsible_person_id);
-- =====================
-- MODIFICACIONES A auth.users
-- =====================
-- Agregar perfil principal
ALTER TABLE auth.users
ADD COLUMN IF NOT EXISTS primary_profile_id UUID REFERENCES auth.user_profiles(id);
-- Agregar perfiles adicionales (array de UUIDs)
ALTER TABLE auth.users
ADD COLUMN IF NOT EXISTS additional_profile_ids UUID[] DEFAULT '{}';
-- Agregar sucursal principal
ALTER TABLE auth.users
ADD COLUMN IF NOT EXISTS primary_branch_id UUID REFERENCES core.branches(id);
-- Agregar sucursales adicionales (array de UUIDs)
ALTER TABLE auth.users
ADD COLUMN IF NOT EXISTS additional_branch_ids UUID[] DEFAULT '{}';
-- Agregar plataformas permitidas
ALTER TABLE auth.users
ADD COLUMN IF NOT EXISTS allowed_platforms TEXT[] DEFAULT '{web}';
-- Agregar configuracion de biometricos
ALTER TABLE auth.users
ADD COLUMN IF NOT EXISTS biometric_enabled BOOLEAN DEFAULT FALSE;
-- Agregar persona asociada (para empleados)
ALTER TABLE auth.users
ADD COLUMN IF NOT EXISTS person_id UUID REFERENCES auth.persons(id);
-- Agregar preferencias de usuario
ALTER TABLE auth.users
ADD COLUMN IF NOT EXISTS preferences JSONB DEFAULT '{}';
-- Ejemplo: {"theme": "light", "language": "es", "notifications": true}
-- Agregar configuracion de notificaciones
ALTER TABLE auth.users
ADD COLUMN IF NOT EXISTS notification_settings JSONB DEFAULT '{}';
-- Ejemplo: {"push": true, "email": true, "sms": false, "categories": ["sales", "inventory"]}
-- Agregar ultimo dispositivo usado
ALTER TABLE auth.users
ADD COLUMN IF NOT EXISTS last_device_id UUID REFERENCES auth.devices(id);
-- Agregar ultima ubicacion conocida
ALTER TABLE auth.users
ADD COLUMN IF NOT EXISTS last_latitude DECIMAL(10, 8);
ALTER TABLE auth.users
ADD COLUMN IF NOT EXISTS last_longitude DECIMAL(11, 8);
ALTER TABLE auth.users
ADD COLUMN IF NOT EXISTS last_location_at TIMESTAMPTZ;
-- Agregar contador de dispositivos
ALTER TABLE auth.users
ADD COLUMN IF NOT EXISTS device_count INTEGER DEFAULT 0;
-- Agregar flag de usuario movil
ALTER TABLE auth.users
ADD COLUMN IF NOT EXISTS is_mobile_user BOOLEAN DEFAULT FALSE;
-- Indices nuevos para users
CREATE INDEX IF NOT EXISTS idx_users_primary_profile ON auth.users(primary_profile_id);
CREATE INDEX IF NOT EXISTS idx_users_primary_branch ON auth.users(primary_branch_id);
CREATE INDEX IF NOT EXISTS idx_users_person ON auth.users(person_id);
CREATE INDEX IF NOT EXISTS idx_users_mobile ON auth.users(is_mobile_user) WHERE is_mobile_user = TRUE;
-- =====================
-- COMENTARIOS
-- =====================
COMMENT ON COLUMN auth.tenants.client_type IS 'Tipo de cliente: persona_fisica o persona_moral';
COMMENT ON COLUMN auth.tenants.responsible_person_id IS 'Persona fisica responsable de la cuenta (representante legal o titular)';
COMMENT ON COLUMN auth.tenants.deployment_type IS 'Tipo de despliegue: saas, on_premise, hybrid';
COMMENT ON COLUMN auth.tenants.billing_config IS 'Configuracion de facturacion en formato JSON';
COMMENT ON COLUMN auth.tenants.fiscal_data IS 'Datos fiscales: RFC, regimen fiscal, uso CFDI';
COMMENT ON COLUMN auth.users.primary_profile_id IS 'Perfil principal del usuario';
COMMENT ON COLUMN auth.users.primary_branch_id IS 'Sucursal principal asignada';
COMMENT ON COLUMN auth.users.biometric_enabled IS 'Indica si el usuario tiene biometricos habilitados';
COMMENT ON COLUMN auth.users.is_mobile_user IS 'Indica si el usuario usa la app movil';
COMMIT;
-- =====================
-- DOWN MIGRATION (para rollback)
-- =====================
-- Para ejecutar rollback, descomenta y ejecuta lo siguiente:
/*
BEGIN;
-- Quitar columnas de tenants
ALTER TABLE auth.tenants
DROP COLUMN IF EXISTS client_type,
DROP COLUMN IF EXISTS responsible_person_id,
DROP COLUMN IF EXISTS deployment_type,
DROP COLUMN IF EXISTS billing_config,
DROP COLUMN IF EXISTS main_branch_id,
DROP COLUMN IF EXISTS allowed_profiles,
DROP COLUMN IF EXISTS allowed_platforms,
DROP COLUMN IF EXISTS max_users,
DROP COLUMN IF EXISTS max_branches,
DROP COLUMN IF EXISTS fiscal_data,
DROP COLUMN IF EXISTS logo_url,
DROP COLUMN IF EXISTS settings;
-- Quitar columnas de users
ALTER TABLE auth.users
DROP COLUMN IF EXISTS primary_profile_id,
DROP COLUMN IF EXISTS additional_profile_ids,
DROP COLUMN IF EXISTS primary_branch_id,
DROP COLUMN IF EXISTS additional_branch_ids,
DROP COLUMN IF EXISTS allowed_platforms,
DROP COLUMN IF EXISTS biometric_enabled,
DROP COLUMN IF EXISTS person_id,
DROP COLUMN IF EXISTS preferences,
DROP COLUMN IF EXISTS notification_settings,
DROP COLUMN IF EXISTS last_device_id,
DROP COLUMN IF EXISTS last_latitude,
DROP COLUMN IF EXISTS last_longitude,
DROP COLUMN IF EXISTS last_location_at,
DROP COLUMN IF EXISTS device_count,
DROP COLUMN IF EXISTS is_mobile_user;
-- Quitar constraints
ALTER TABLE auth.tenants DROP CONSTRAINT IF EXISTS chk_tenant_client_type;
ALTER TABLE auth.tenants DROP CONSTRAINT IF EXISTS chk_tenant_deployment_type;
-- Quitar indices
DROP INDEX IF EXISTS auth.idx_tenants_client_type;
DROP INDEX IF EXISTS auth.idx_tenants_deployment;
DROP INDEX IF EXISTS auth.idx_tenants_responsible;
DROP INDEX IF EXISTS auth.idx_users_primary_profile;
DROP INDEX IF EXISTS auth.idx_users_primary_branch;
DROP INDEX IF EXISTS auth.idx_users_person;
DROP INDEX IF EXISTS auth.idx_users_mobile;
COMMIT;
*/

154
scripts/create-database.sh Executable file
View File

@ -0,0 +1,154 @@
#!/bin/bash
# ============================================================================
# ERP GENERIC - CREATE DATABASE SCRIPT
# ============================================================================
# Description: Creates the database and executes all DDL files in order
# Usage: ./scripts/create-database.sh [--skip-seeds]
# ============================================================================
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Script directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DATABASE_DIR="$(dirname "$SCRIPT_DIR")"
DDL_DIR="$DATABASE_DIR/ddl"
# Load environment variables
if [ -f "$DATABASE_DIR/.env" ]; then
source "$DATABASE_DIR/.env"
elif [ -f "$DATABASE_DIR/.env.example" ]; then
echo -e "${YELLOW}Warning: Using .env.example as .env not found${NC}"
source "$DATABASE_DIR/.env.example"
fi
# Default values
POSTGRES_HOST="${POSTGRES_HOST:-localhost}"
POSTGRES_PORT="${POSTGRES_PORT:-5432}"
POSTGRES_DB="${POSTGRES_DB:-erp_generic}"
POSTGRES_USER="${POSTGRES_USER:-erp_admin}"
POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-erp_secret_2024}"
# Connection string
export PGPASSWORD="$POSTGRES_PASSWORD"
PSQL_CMD="psql -h $POSTGRES_HOST -p $POSTGRES_PORT -U $POSTGRES_USER"
echo -e "${BLUE}============================================${NC}"
echo -e "${BLUE} ERP GENERIC - DATABASE CREATION${NC}"
echo -e "${BLUE}============================================${NC}"
echo ""
echo -e "Host: ${GREEN}$POSTGRES_HOST:$POSTGRES_PORT${NC}"
echo -e "Database: ${GREEN}$POSTGRES_DB${NC}"
echo -e "User: ${GREEN}$POSTGRES_USER${NC}"
echo ""
# Check if PostgreSQL is reachable
echo -e "${BLUE}[1/4] Checking PostgreSQL connection...${NC}"
if ! $PSQL_CMD -d postgres -c "SELECT 1" > /dev/null 2>&1; then
echo -e "${RED}Error: Cannot connect to PostgreSQL${NC}"
echo "Make sure PostgreSQL is running and credentials are correct."
echo "You can start PostgreSQL with: docker-compose up -d"
exit 1
fi
echo -e "${GREEN}PostgreSQL is reachable!${NC}"
# Drop database if exists
echo -e "${BLUE}[2/4] Dropping existing database if exists...${NC}"
$PSQL_CMD -d postgres -c "DROP DATABASE IF EXISTS $POSTGRES_DB;" 2>/dev/null || true
echo -e "${GREEN}Old database dropped (if existed)${NC}"
# Create database
echo -e "${BLUE}[3/4] Creating database...${NC}"
$PSQL_CMD -d postgres -c "CREATE DATABASE $POSTGRES_DB WITH ENCODING='UTF8' LC_COLLATE='en_US.UTF-8' LC_CTYPE='en_US.UTF-8' TEMPLATE=template0;" 2>/dev/null || \
$PSQL_CMD -d postgres -c "CREATE DATABASE $POSTGRES_DB;"
echo -e "${GREEN}Database '$POSTGRES_DB' created!${NC}"
# Execute DDL files in order
echo -e "${BLUE}[4/4] Executing DDL files...${NC}"
echo ""
DDL_FILES=(
"00-prerequisites.sql"
"01-auth.sql"
"01-auth-extensions.sql"
"01-auth-mfa-email-verification.sql"
"02-core.sql"
"02-core-extensions.sql"
"03-analytics.sql"
"04-financial.sql"
"05-inventory.sql"
"05-inventory-extensions.sql"
"06-purchase.sql"
"07-sales.sql"
"08-projects.sql"
"09-system.sql"
"09-system-extensions.sql"
"10-billing.sql"
"11-crm.sql"
"12-hr.sql"
"13-audit.sql"
"14-reports.sql"
# MGN-020, MGN-021, MGN-022 - AI Agents, Messaging, Integrations
"15-ai-agents.sql"
"16-messaging.sql"
"17-integrations.sql"
# MGN-018, MGN-019 - Webhooks, Feature Flags (2026-01-13)
"19-webhooks.sql"
"20-feature-flags.sql"
)
TOTAL=${#DDL_FILES[@]}
CURRENT=0
for ddl_file in "${DDL_FILES[@]}"; do
CURRENT=$((CURRENT + 1))
filepath="$DDL_DIR/$ddl_file"
if [ -f "$filepath" ]; then
echo -e " [${CURRENT}/${TOTAL}] Executing ${YELLOW}$ddl_file${NC}..."
if $PSQL_CMD -d $POSTGRES_DB -f "$filepath" > /dev/null 2>&1; then
echo -e " [${CURRENT}/${TOTAL}] ${GREEN}$ddl_file executed successfully${NC}"
else
echo -e " [${CURRENT}/${TOTAL}] ${RED}Error executing $ddl_file${NC}"
echo "Attempting with verbose output..."
$PSQL_CMD -d $POSTGRES_DB -f "$filepath"
exit 1
fi
else
echo -e " [${CURRENT}/${TOTAL}] ${RED}File not found: $ddl_file${NC}"
exit 1
fi
done
echo ""
echo -e "${GREEN}============================================${NC}"
echo -e "${GREEN} DATABASE CREATED SUCCESSFULLY!${NC}"
echo -e "${GREEN}============================================${NC}"
echo ""
echo -e "Connection string:"
echo -e "${BLUE}postgresql://$POSTGRES_USER:****@$POSTGRES_HOST:$POSTGRES_PORT/$POSTGRES_DB${NC}"
echo ""
# Show statistics
echo -e "${BLUE}Database Statistics:${NC}"
$PSQL_CMD -d $POSTGRES_DB -c "
SELECT
schemaname AS schema,
COUNT(*) AS tables
FROM pg_tables
WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
GROUP BY schemaname
ORDER BY schemaname;
"
echo ""
echo -e "${YELLOW}Next steps:${NC}"
echo " 1. Load seed data: ./scripts/load-seeds.sh dev"
echo " 2. Start backend: cd ../backend && npm run dev"
echo ""

161
scripts/create-test-database.sh Executable file
View File

@ -0,0 +1,161 @@
#!/bin/bash
# ============================================================================
# ERP GENERIC - CREATE TEST DATABASE SCRIPT
# ============================================================================
# Description: Creates a test database for integration tests
# Usage: ./scripts/create-test-database.sh
# ============================================================================
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Script directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DATABASE_DIR="$(dirname "$SCRIPT_DIR")"
DDL_DIR="$DATABASE_DIR/ddl"
# Load environment variables
if [ -f "$DATABASE_DIR/.env" ]; then
source "$DATABASE_DIR/.env"
fi
# Test database configuration (separate from main DB)
POSTGRES_HOST="${POSTGRES_HOST:-localhost}"
POSTGRES_PORT="${POSTGRES_PORT:-5432}"
POSTGRES_DB="${TEST_DB_NAME:-erp_generic_test}"
POSTGRES_USER="${POSTGRES_USER:-erp_admin}"
POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-erp_secret_2024}"
# Connection string
export PGPASSWORD="$POSTGRES_PASSWORD"
PSQL_CMD="psql -h $POSTGRES_HOST -p $POSTGRES_PORT -U $POSTGRES_USER"
echo -e "${BLUE}============================================${NC}"
echo -e "${BLUE} ERP GENERIC - TEST DATABASE CREATION${NC}"
echo -e "${BLUE}============================================${NC}"
echo ""
echo -e "Host: ${GREEN}$POSTGRES_HOST:$POSTGRES_PORT${NC}"
echo -e "Database: ${GREEN}$POSTGRES_DB${NC}"
echo -e "User: ${GREEN}$POSTGRES_USER${NC}"
echo ""
# Check if PostgreSQL is reachable
echo -e "${BLUE}[1/5] Checking PostgreSQL connection...${NC}"
if ! $PSQL_CMD -d postgres -c "SELECT 1" > /dev/null 2>&1; then
echo -e "${RED}Error: Cannot connect to PostgreSQL${NC}"
exit 1
fi
echo -e "${GREEN}PostgreSQL is reachable!${NC}"
# Drop test database if exists
echo -e "${BLUE}[2/5] Dropping existing test database if exists...${NC}"
$PSQL_CMD -d postgres -c "DROP DATABASE IF EXISTS $POSTGRES_DB;" 2>/dev/null || true
echo -e "${GREEN}Old test database dropped (if existed)${NC}"
# Create test database
echo -e "${BLUE}[3/5] Creating test database...${NC}"
$PSQL_CMD -d postgres -c "CREATE DATABASE $POSTGRES_DB WITH ENCODING='UTF8' LC_COLLATE='en_US.UTF-8' LC_CTYPE='en_US.UTF-8' TEMPLATE=template0;" 2>/dev/null || \
$PSQL_CMD -d postgres -c "CREATE DATABASE $POSTGRES_DB;"
echo -e "${GREEN}Test database '$POSTGRES_DB' created!${NC}"
# Execute DDL files in order
echo -e "${BLUE}[4/5] Executing DDL files...${NC}"
DDL_FILES=(
"00-prerequisites.sql"
"01-auth.sql"
"01-auth-extensions.sql"
"01-auth-mfa-email-verification.sql"
"02-core.sql"
"02-core-extensions.sql"
"03-analytics.sql"
"04-financial.sql"
"05-inventory.sql"
"05-inventory-extensions.sql"
"06-purchase.sql"
"07-sales.sql"
"08-projects.sql"
"09-system.sql"
"09-system-extensions.sql"
"10-billing.sql"
"11-crm.sql"
"12-hr.sql"
"13-audit.sql"
"14-reports.sql"
# MGN-020, MGN-021, MGN-022 - AI Agents, Messaging, Integrations
"15-ai-agents.sql"
"16-messaging.sql"
"17-integrations.sql"
# MGN-018, MGN-019 - Webhooks, Feature Flags (2026-01-13)
"19-webhooks.sql"
"20-feature-flags.sql"
)
TOTAL=${#DDL_FILES[@]}
CURRENT=0
for ddl_file in "${DDL_FILES[@]}"; do
CURRENT=$((CURRENT + 1))
filepath="$DDL_DIR/$ddl_file"
if [ -f "$filepath" ]; then
if $PSQL_CMD -d $POSTGRES_DB -f "$filepath" > /dev/null 2>&1; then
echo -e " [${CURRENT}/${TOTAL}] ${GREEN}${NC} $ddl_file"
else
echo -e " [${CURRENT}/${TOTAL}] ${RED}${NC} $ddl_file"
exit 1
fi
else
echo -e " [${CURRENT}/${TOTAL}] ${YELLOW}${NC} $ddl_file (not found, skipping)"
fi
done
# Load test fixtures
echo -e "${BLUE}[5/5] Loading test fixtures...${NC}"
FIXTURES_FILE="$DATABASE_DIR/seeds/test/fixtures.sql"
if [ -f "$FIXTURES_FILE" ]; then
if $PSQL_CMD -d $POSTGRES_DB -f "$FIXTURES_FILE" > /dev/null 2>&1; then
echo -e " ${GREEN}${NC} Test fixtures loaded"
else
echo -e " ${RED}${NC} Error loading fixtures"
exit 1
fi
else
echo -e " ${YELLOW}${NC} No fixtures file found (seeds/test/fixtures.sql)"
fi
echo ""
echo -e "${GREEN}============================================${NC}"
echo -e "${GREEN} TEST DATABASE CREATED SUCCESSFULLY!${NC}"
echo -e "${GREEN}============================================${NC}"
echo ""
echo -e "Connection string:"
echo -e "${BLUE}postgresql://$POSTGRES_USER:****@$POSTGRES_HOST:$POSTGRES_PORT/$POSTGRES_DB${NC}"
echo ""
# Show statistics
echo -e "${BLUE}Database Statistics:${NC}"
$PSQL_CMD -d $POSTGRES_DB -c "
SELECT
schemaname AS schema,
COUNT(*) AS tables
FROM pg_tables
WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
GROUP BY schemaname
ORDER BY schemaname;
"
echo ""
echo -e "${YELLOW}Environment variables for tests:${NC}"
echo " export TEST_DB_HOST=$POSTGRES_HOST"
echo " export TEST_DB_PORT=$POSTGRES_PORT"
echo " export TEST_DB_NAME=$POSTGRES_DB"
echo " export TEST_DB_USER=$POSTGRES_USER"
echo " export TEST_DB_PASSWORD=<your_password>"
echo ""

481
scripts/recreate-database.sh Executable file
View File

@ -0,0 +1,481 @@
#!/bin/bash
# =============================================================
# SCRIPT: recreate-database.sh
# DESCRIPCION: Script de recreacion completa de base de datos ERP-Core
# VERSION: 1.0.0
# PROYECTO: ERP-Core V2
# FECHA: 2026-01-10
# =============================================================
set -e
# Colores para output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Configuracion por defecto
DB_HOST="${DB_HOST:-localhost}"
DB_PORT="${DB_PORT:-5432}"
DB_NAME="${DB_NAME:-erp_core}"
DB_USER="${DB_USER:-postgres}"
DB_PASSWORD="${DB_PASSWORD:-}"
# Directorios
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DATABASE_DIR="$(dirname "$SCRIPT_DIR")"
DDL_DIR="$DATABASE_DIR/ddl"
MIGRATIONS_DIR="$DATABASE_DIR/migrations"
SEEDS_DIR="$DATABASE_DIR/seeds"
# Flags
DROP_DB=false
LOAD_SEEDS=false
VERBOSE=false
DRY_RUN=false
# Funciones de utilidad
log_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
log_success() {
echo -e "${GREEN}[OK]${NC} $1"
}
log_warning() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
show_help() {
echo "Uso: $0 [opciones]"
echo ""
echo "Script de recreacion de base de datos ERP-Core"
echo ""
echo "Opciones:"
echo " -h, --help Mostrar esta ayuda"
echo " -d, --drop Eliminar y recrear la base de datos completa"
echo " -s, --seeds Cargar seeds de desarrollo"
echo " -v, --verbose Modo verbose"
echo " --dry-run Mostrar comandos sin ejecutar"
echo ""
echo "Variables de entorno:"
echo " DB_HOST Host de la base de datos (default: localhost)"
echo " DB_PORT Puerto de la base de datos (default: 5432)"
echo " DB_NAME Nombre de la base de datos (default: erp_core)"
echo " DB_USER Usuario de la base de datos (default: postgres)"
echo " DB_PASSWORD Password de la base de datos"
echo ""
echo "Ejemplos:"
echo " $0 Ejecutar DDL y migraciones sin eliminar DB"
echo " $0 -d Eliminar y recrear DB completa"
echo " $0 -d -s Eliminar, recrear DB y cargar seeds"
echo " DB_HOST=db.example.com $0 -d Usar host remoto"
}
parse_args() {
while [[ $# -gt 0 ]]; do
case $1 in
-h|--help)
show_help
exit 0
;;
-d|--drop)
DROP_DB=true
shift
;;
-s|--seeds)
LOAD_SEEDS=true
shift
;;
-v|--verbose)
VERBOSE=true
shift
;;
--dry-run)
DRY_RUN=true
shift
;;
*)
log_error "Opcion desconocida: $1"
show_help
exit 1
;;
esac
done
}
# Construir connection string
get_psql_cmd() {
local cmd="psql -h $DB_HOST -p $DB_PORT -U $DB_USER"
if [ -n "$DB_PASSWORD" ]; then
cmd="PGPASSWORD=$DB_PASSWORD $cmd"
fi
echo "$cmd"
}
run_sql() {
local db="$1"
local sql="$2"
local psql_cmd=$(get_psql_cmd)
if [ "$DRY_RUN" = true ]; then
log_info "[DRY-RUN] Ejecutaria en $db: $sql"
return 0
fi
if [ "$VERBOSE" = true ]; then
log_info "Ejecutando: $sql"
fi
eval "$psql_cmd -d $db -c \"$sql\""
}
run_sql_file() {
local db="$1"
local file="$2"
local psql_cmd=$(get_psql_cmd)
if [ ! -f "$file" ]; then
log_error "Archivo no encontrado: $file"
return 1
fi
if [ "$DRY_RUN" = true ]; then
log_info "[DRY-RUN] Ejecutaria archivo: $file"
return 0
fi
if [ "$VERBOSE" = true ]; then
log_info "Ejecutando archivo: $file"
fi
eval "$psql_cmd -d $db -f \"$file\""
}
# Drop y recrear base de datos
drop_and_create_db() {
local psql_cmd=$(get_psql_cmd)
log_info "Eliminando base de datos existente: $DB_NAME"
if [ "$DRY_RUN" = true ]; then
log_info "[DRY-RUN] DROP DATABASE IF EXISTS $DB_NAME"
log_info "[DRY-RUN] CREATE DATABASE $DB_NAME"
return 0
fi
# Terminar conexiones activas
eval "$psql_cmd -d postgres -c \"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$DB_NAME' AND pid <> pg_backend_pid();\"" 2>/dev/null || true
# Eliminar y crear base de datos
eval "$psql_cmd -d postgres -c \"DROP DATABASE IF EXISTS $DB_NAME;\""
eval "$psql_cmd -d postgres -c \"CREATE DATABASE $DB_NAME WITH ENCODING 'UTF8' LC_COLLATE 'en_US.UTF-8' LC_CTYPE 'en_US.UTF-8' TEMPLATE template0;\""
log_success "Base de datos $DB_NAME creada"
}
# Crear schemas base
create_base_schemas() {
log_info "Creando schemas base..."
run_sql "$DB_NAME" "CREATE SCHEMA IF NOT EXISTS auth;"
run_sql "$DB_NAME" "CREATE SCHEMA IF NOT EXISTS core;"
run_sql "$DB_NAME" "CREATE SCHEMA IF NOT EXISTS mobile;"
run_sql "$DB_NAME" "CREATE SCHEMA IF NOT EXISTS billing;"
run_sql "$DB_NAME" "CREATE SCHEMA IF NOT EXISTS users;"
run_sql "$DB_NAME" "CREATE SCHEMA IF NOT EXISTS flags;"
# Sprint 3+ schemas
run_sql "$DB_NAME" "CREATE SCHEMA IF NOT EXISTS notifications;"
run_sql "$DB_NAME" "CREATE SCHEMA IF NOT EXISTS audit;"
run_sql "$DB_NAME" "CREATE SCHEMA IF NOT EXISTS webhooks;"
run_sql "$DB_NAME" "CREATE SCHEMA IF NOT EXISTS storage;"
run_sql "$DB_NAME" "CREATE SCHEMA IF NOT EXISTS ai;"
run_sql "$DB_NAME" "CREATE SCHEMA IF NOT EXISTS whatsapp;"
# Business modules schemas
run_sql "$DB_NAME" "CREATE SCHEMA IF NOT EXISTS partners;"
run_sql "$DB_NAME" "CREATE SCHEMA IF NOT EXISTS products;"
run_sql "$DB_NAME" "CREATE SCHEMA IF NOT EXISTS inventory;"
run_sql "$DB_NAME" "CREATE SCHEMA IF NOT EXISTS sales;"
run_sql "$DB_NAME" "CREATE SCHEMA IF NOT EXISTS purchases;"
log_success "Schemas base creados"
}
# Crear extensiones requeridas
create_extensions() {
log_info "Creando extensiones requeridas..."
run_sql "$DB_NAME" "CREATE EXTENSION IF NOT EXISTS cube;"
run_sql "$DB_NAME" "CREATE EXTENSION IF NOT EXISTS earthdistance;"
log_success "Extensiones creadas"
}
# Verificar si existen tablas base
check_base_tables() {
local psql_cmd=$(get_psql_cmd)
local result
result=$(eval "$psql_cmd -d $DB_NAME -t -c \"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'auth' AND table_name IN ('tenants', 'users');\"" 2>/dev/null | tr -d ' ')
if [ "$result" -eq "2" ]; then
return 0 # Tablas existen
else
return 1 # Tablas no existen
fi
}
# Crear tablas base (si no existen y es una recreacion)
create_base_tables() {
log_info "Creando tablas base (auth.tenants, auth.users)..."
# Solo crear si estamos en modo drop o si no existen
run_sql "$DB_NAME" "
CREATE TABLE IF NOT EXISTS auth.tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(200) NOT NULL,
slug VARCHAR(100) UNIQUE NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS auth.users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES auth.tenants(id) ON DELETE CASCADE,
email VARCHAR(255) NOT NULL,
password_hash TEXT,
first_name VARCHAR(100),
last_name VARCHAR(100),
is_active BOOLEAN DEFAULT TRUE,
email_verified BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMPTZ,
UNIQUE(tenant_id, email)
);
CREATE INDEX IF NOT EXISTS idx_users_tenant ON auth.users(tenant_id);
CREATE INDEX IF NOT EXISTS idx_users_email ON auth.users(email);
"
log_success "Tablas base creadas"
}
# Ejecutar archivos DDL en orden
run_ddl_files() {
log_info "Ejecutando archivos DDL..."
# Orden especifico de ejecucion
# Nota: El orden es importante por las dependencias entre tablas
local ddl_files=(
# Core existente
"01-auth-profiles.sql"
"02-auth-devices.sql"
"03-core-branches.sql"
"04-mobile.sql"
"05-billing-usage.sql"
# SaaS Extensions - Sprint 1-2 (EPIC-SAAS-001, EPIC-SAAS-002)
"06-auth-extended.sql"
"07-users-rbac.sql"
"08-plans.sql"
"11-feature-flags.sql"
# SaaS Extensions - Sprint 3+ (EPIC-SAAS-003 - EPIC-SAAS-008)
"09-notifications.sql"
"10-audit.sql"
"12-webhooks.sql"
"13-storage.sql"
"14-ai.sql"
"15-whatsapp.sql"
# Business Modules - ERP Core
"16-partners.sql"
"17-products.sql"
"18-warehouses.sql"
"21-inventory.sql"
"22-sales.sql"
"23-purchases.sql"
"24-invoices.sql"
)
for ddl_file in "${ddl_files[@]}"; do
local file_path="$DDL_DIR/$ddl_file"
if [ -f "$file_path" ]; then
log_info "Ejecutando: $ddl_file"
run_sql_file "$DB_NAME" "$file_path"
log_success "Completado: $ddl_file"
else
log_warning "Archivo no encontrado: $ddl_file"
fi
done
}
# Ejecutar migraciones
run_migrations() {
log_info "Ejecutando migraciones..."
if [ ! -d "$MIGRATIONS_DIR" ]; then
log_warning "Directorio de migraciones no encontrado: $MIGRATIONS_DIR"
return 0
fi
# Ordenar migraciones por nombre (fecha)
local migration_files=$(ls -1 "$MIGRATIONS_DIR"/*.sql 2>/dev/null | sort)
if [ -z "$migration_files" ]; then
log_info "No hay migraciones pendientes"
return 0
fi
for migration_file in $migration_files; do
local filename=$(basename "$migration_file")
log_info "Ejecutando migracion: $filename"
run_sql_file "$DB_NAME" "$migration_file"
log_success "Migracion completada: $filename"
done
}
# Cargar seeds de desarrollo
load_seeds() {
log_info "Cargando seeds de desarrollo..."
local seeds_dev_dir="$SEEDS_DIR/dev"
if [ ! -d "$seeds_dev_dir" ]; then
log_warning "Directorio de seeds no encontrado: $seeds_dev_dir"
return 0
fi
# Ordenar seeds por nombre
local seed_files=$(ls -1 "$seeds_dev_dir"/*.sql 2>/dev/null | sort)
if [ -z "$seed_files" ]; then
log_info "No hay seeds para cargar"
return 0
fi
for seed_file in $seed_files; do
local filename=$(basename "$seed_file")
log_info "Cargando seed: $filename"
run_sql_file "$DB_NAME" "$seed_file"
log_success "Seed cargado: $filename"
done
}
# Validar creacion de tablas
validate_database() {
log_info "Validando base de datos..."
local psql_cmd=$(get_psql_cmd)
if [ "$DRY_RUN" = true ]; then
log_info "[DRY-RUN] Validaria tablas creadas"
return 0
fi
# Contar tablas por schema
echo ""
echo "=== Resumen de tablas por schema ==="
echo ""
local schemas=("auth" "core" "mobile" "billing" "users" "flags" "notifications" "audit" "webhooks" "storage" "ai" "whatsapp" "partners" "products" "inventory" "sales" "purchases")
local total_tables=0
for schema in "${schemas[@]}"; do
local count=$(eval "$psql_cmd -d $DB_NAME -t -c \"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = '$schema';\"" | tr -d ' ')
echo " $schema: $count tablas"
total_tables=$((total_tables + count))
done
echo ""
echo " Total: $total_tables tablas"
echo ""
# Listar tablas principales
echo "=== Tablas principales creadas ==="
echo ""
eval "$psql_cmd -d $DB_NAME -c \"
SELECT table_schema, table_name
FROM information_schema.tables
WHERE table_schema IN ('auth', 'core', 'mobile', 'billing', 'users', 'flags', 'notifications', 'audit', 'webhooks', 'storage', 'ai', 'whatsapp', 'partners', 'products', 'inventory', 'sales', 'purchases')
AND table_type = 'BASE TABLE'
ORDER BY table_schema, table_name;
\""
log_success "Validacion completada"
}
# Mostrar configuracion actual
show_config() {
echo ""
echo "=== Configuracion ==="
echo " Host: $DB_HOST"
echo " Puerto: $DB_PORT"
echo " Base de datos: $DB_NAME"
echo " Usuario: $DB_USER"
echo " Drop DB: $DROP_DB"
echo " Cargar seeds: $LOAD_SEEDS"
echo " Dry run: $DRY_RUN"
echo ""
}
# Main
main() {
parse_args "$@"
echo "=================================================="
echo " ERP-Core Database Recreation Script v1.0.0"
echo "=================================================="
show_config
# Verificar que psql esta disponible
if ! command -v psql &> /dev/null; then
log_error "psql no encontrado. Instalar PostgreSQL client."
exit 1
fi
# Verificar conexion
log_info "Verificando conexion a PostgreSQL..."
local psql_cmd=$(get_psql_cmd)
if ! eval "$psql_cmd -d postgres -c 'SELECT 1;'" &> /dev/null; then
log_error "No se puede conectar a PostgreSQL. Verificar credenciales."
exit 1
fi
log_success "Conexion exitosa"
# Ejecutar pasos
if [ "$DROP_DB" = true ]; then
drop_and_create_db
create_base_schemas
create_extensions
create_base_tables
fi
# Ejecutar DDL
run_ddl_files
# Ejecutar migraciones
run_migrations
# Cargar seeds si se solicito
if [ "$LOAD_SEEDS" = true ]; then
load_seeds
fi
# Validar
validate_database
echo ""
log_success "Recreacion de base de datos completada exitosamente"
echo ""
}
main "$@"

View File

@ -0,0 +1,38 @@
-- =============================================================
-- SEED: 01-seed-tenants.sql
-- DESCRIPCION: Datos de desarrollo para tenants y usuarios
-- VERSION: 1.0.0
-- PROYECTO: ERP-Core V2
-- FECHA: 2026-01-10
-- =============================================================
-- Insertar tenant de desarrollo
INSERT INTO auth.tenants (id, name, slug, is_active)
VALUES
('11111111-1111-1111-1111-111111111111', 'Empresa Demo SA de CV', 'demo', TRUE),
('22222222-2222-2222-2222-222222222222', 'Tienda Pruebas', 'test-store', TRUE),
('33333333-3333-3333-3333-333333333333', 'Sucursal Norte SA', 'norte', TRUE)
ON CONFLICT (slug) DO NOTHING;
-- Insertar usuarios de desarrollo
-- Password: password123 (hash bcrypt)
INSERT INTO auth.users (id, tenant_id, email, password_hash, first_name, last_name, is_active, email_verified)
VALUES
-- Usuarios tenant Demo
('aaaa1111-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111', 'admin@demo.com', '$2b$10$EIX2KxQcTcDhxXQ8EfYWzuZs8KxMqrq7y8E8fLJ3VDLqvAF3WDK5K', 'Admin', 'Demo', TRUE, TRUE),
('aaaa2222-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111', 'vendedor@demo.com', '$2b$10$EIX2KxQcTcDhxXQ8EfYWzuZs8KxMqrq7y8E8fLJ3VDLqvAF3WDK5K', 'Juan', 'Vendedor', TRUE, TRUE),
('aaaa3333-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111', 'cajero@demo.com', '$2b$10$EIX2KxQcTcDhxXQ8EfYWzuZs8KxMqrq7y8E8fLJ3VDLqvAF3WDK5K', 'Maria', 'Cajera', TRUE, TRUE),
('aaaa4444-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111', 'almacenista@demo.com', '$2b$10$EIX2KxQcTcDhxXQ8EfYWzuZs8KxMqrq7y8E8fLJ3VDLqvAF3WDK5K', 'Pedro', 'Almacen', TRUE, TRUE),
-- Usuarios tenant Test Store
('bbbb1111-2222-2222-2222-222222222222', '22222222-2222-2222-2222-222222222222', 'admin@test.com', '$2b$10$EIX2KxQcTcDhxXQ8EfYWzuZs8KxMqrq7y8E8fLJ3VDLqvAF3WDK5K', 'Admin', 'Test', TRUE, TRUE)
ON CONFLICT DO NOTHING;
-- Insertar personas responsables
INSERT INTO auth.persons (id, full_name, first_name, last_name, email, phone, identification_type, identification_number, is_verified, is_responsible_for_tenant)
VALUES
('eeee1111-1111-1111-1111-111111111111', 'Carlos Rodriguez Martinez', 'Carlos', 'Rodriguez', 'carlos@demo.com', '+52 55 1234 5678', 'INE', 'ROMC850101HDFRRL09', TRUE, TRUE),
('eeee2222-2222-2222-2222-222222222222', 'Ana Garcia Lopez', 'Ana', 'Garcia', 'ana@test.com', '+52 55 8765 4321', 'INE', 'GALA900515MDFRRN02', TRUE, TRUE)
ON CONFLICT DO NOTHING;
-- Comentario
COMMENT ON TABLE auth.tenants IS 'Seeds de desarrollo cargados';

View File

@ -0,0 +1,78 @@
-- =============================================================
-- SEED: 02-seed-branches.sql
-- DESCRIPCION: Datos de desarrollo para sucursales
-- VERSION: 1.0.1
-- PROYECTO: ERP-Core V2
-- FECHA: 2026-01-10
-- =============================================================
-- Sucursales para tenant Demo
INSERT INTO core.branches (id, tenant_id, code, name, branch_type, is_main, phone, email,
address_line1, city, state, postal_code, country, latitude, longitude, geofence_radius, is_active)
VALUES
-- Sucursales Demo
('bbbb1111-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111',
'MTZ-001', 'Matriz Centro', 'matriz', TRUE, '+52 55 1234 0001', 'matriz@demo.com',
'Av. Reforma 123', 'Ciudad de Mexico', 'CDMX', '06600', 'MEX', 19.4284, -99.1677, 100, TRUE),
('bbbb2222-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111',
'SUC-001', 'Sucursal Polanco', 'store', FALSE, '+52 55 1234 0002', 'polanco@demo.com',
'Av. Presidente Masaryk 456', 'Ciudad de Mexico', 'CDMX', '11560', 'MEX', 19.4341, -99.1918, 100, TRUE),
('bbbb3333-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111',
'SUC-002', 'Sucursal Santa Fe', 'store', FALSE, '+52 55 1234 0003', 'santafe@demo.com',
'Centro Comercial Santa Fe', 'Ciudad de Mexico', 'CDMX', '01210', 'MEX', 19.3573, -99.2611, 150, TRUE),
('bbbb4444-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111',
'ALM-001', 'Almacen Central', 'warehouse', FALSE, '+52 55 1234 0004', 'almacen@demo.com',
'Parque Industrial Vallejo', 'Ciudad de Mexico', 'CDMX', '02300', 'MEX', 19.4895, -99.1456, 200, TRUE),
-- Sucursales Test Store
('cccc1111-2222-2222-2222-222222222222', '22222222-2222-2222-2222-222222222222',
'MTZ-001', 'Tienda Principal', 'matriz', TRUE, '+52 33 1234 0001', 'principal@test.com',
'Av. Vallarta 1234', 'Guadalajara', 'Jalisco', '44100', 'MEX', 20.6769, -103.3653, 100, TRUE)
ON CONFLICT DO NOTHING;
-- Horarios de sucursales
INSERT INTO core.branch_schedules (id, branch_id, name, schedule_type, day_of_week, open_time, close_time, is_active)
VALUES
-- Matriz Centro - Lunes a Viernes 9:00-19:00, Sabado 9:00-14:00
(gen_random_uuid(), 'bbbb1111-1111-1111-1111-111111111111', 'Lunes', 'regular', 0, '09:00', '19:00', TRUE),
(gen_random_uuid(), 'bbbb1111-1111-1111-1111-111111111111', 'Martes', 'regular', 1, '09:00', '19:00', TRUE),
(gen_random_uuid(), 'bbbb1111-1111-1111-1111-111111111111', 'Miercoles', 'regular', 2, '09:00', '19:00', TRUE),
(gen_random_uuid(), 'bbbb1111-1111-1111-1111-111111111111', 'Jueves', 'regular', 3, '09:00', '19:00', TRUE),
(gen_random_uuid(), 'bbbb1111-1111-1111-1111-111111111111', 'Viernes', 'regular', 4, '09:00', '19:00', TRUE),
(gen_random_uuid(), 'bbbb1111-1111-1111-1111-111111111111', 'Sabado', 'regular', 5, '09:00', '14:00', TRUE),
(gen_random_uuid(), 'bbbb1111-1111-1111-1111-111111111111', 'Domingo', 'regular', 6, '00:00', '00:00', FALSE),
-- Sucursal Polanco - Lunes a Sabado 10:00-20:00
(gen_random_uuid(), 'bbbb2222-1111-1111-1111-111111111111', 'Lunes', 'regular', 0, '10:00', '20:00', TRUE),
(gen_random_uuid(), 'bbbb2222-1111-1111-1111-111111111111', 'Martes', 'regular', 1, '10:00', '20:00', TRUE),
(gen_random_uuid(), 'bbbb2222-1111-1111-1111-111111111111', 'Miercoles', 'regular', 2, '10:00', '20:00', TRUE),
(gen_random_uuid(), 'bbbb2222-1111-1111-1111-111111111111', 'Jueves', 'regular', 3, '10:00', '20:00', TRUE),
(gen_random_uuid(), 'bbbb2222-1111-1111-1111-111111111111', 'Viernes', 'regular', 4, '10:00', '20:00', TRUE),
(gen_random_uuid(), 'bbbb2222-1111-1111-1111-111111111111', 'Sabado', 'regular', 5, '10:00', '20:00', TRUE),
(gen_random_uuid(), 'bbbb2222-1111-1111-1111-111111111111', 'Domingo', 'regular', 6, '11:00', '18:00', TRUE)
ON CONFLICT DO NOTHING;
-- Asignaciones de usuarios a sucursales
INSERT INTO core.user_branch_assignments (id, user_id, branch_id, tenant_id, assignment_type, branch_role, is_active)
VALUES
-- Admin puede acceder a todas las sucursales
(gen_random_uuid(), 'aaaa1111-1111-1111-1111-111111111111', 'bbbb1111-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111', 'primary', 'admin', TRUE),
(gen_random_uuid(), 'aaaa1111-1111-1111-1111-111111111111', 'bbbb2222-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111', 'secondary', 'admin', TRUE),
(gen_random_uuid(), 'aaaa1111-1111-1111-1111-111111111111', 'bbbb3333-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111', 'secondary', 'admin', TRUE),
(gen_random_uuid(), 'aaaa1111-1111-1111-1111-111111111111', 'bbbb4444-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111', 'secondary', 'admin', TRUE),
-- Vendedor solo Polanco
(gen_random_uuid(), 'aaaa2222-1111-1111-1111-111111111111', 'bbbb2222-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111', 'primary', 'sales', TRUE),
-- Cajero en Matriz y Polanco
(gen_random_uuid(), 'aaaa3333-1111-1111-1111-111111111111', 'bbbb1111-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111', 'primary', 'cashier', TRUE),
(gen_random_uuid(), 'aaaa3333-1111-1111-1111-111111111111', 'bbbb2222-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111', 'secondary', 'cashier', TRUE),
-- Almacenista en Almacen Central
(gen_random_uuid(), 'aaaa4444-1111-1111-1111-111111111111', 'bbbb4444-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111', 'primary', 'warehouse', TRUE)
ON CONFLICT DO NOTHING;
COMMENT ON TABLE core.branches IS 'Seeds de desarrollo cargados';

View File

@ -0,0 +1,131 @@
-- =============================================================
-- SEED: 03-seed-subscriptions.sql
-- DESCRIPCION: Datos de desarrollo para suscripciones
-- VERSION: 1.0.0
-- PROYECTO: ERP-Core V2
-- FECHA: 2026-01-10
-- =============================================================
-- Obtener IDs de planes (los planes ya deben existir desde el DDL)
-- Los planes fueron insertados en 05-billing-usage.sql
-- Suscripciones para tenants de desarrollo
INSERT INTO billing.tenant_subscriptions (
id, tenant_id, plan_id, billing_cycle,
current_period_start, current_period_end, status,
billing_email, billing_name, tax_id,
current_price, contracted_users, contracted_branches,
auto_renew
)
SELECT
'dddd1111-1111-1111-1111-111111111111'::uuid,
'11111111-1111-1111-1111-111111111111'::uuid,
sp.id,
'monthly',
CURRENT_DATE - INTERVAL '15 days',
CURRENT_DATE + INTERVAL '15 days',
'active',
'billing@demo.com',
'Empresa Demo SA de CV',
'ABC123456XYZ',
sp.base_monthly_price,
10,
5,
TRUE
FROM billing.subscription_plans sp
WHERE sp.code = 'professional'
ON CONFLICT (tenant_id) DO NOTHING;
INSERT INTO billing.tenant_subscriptions (
id, tenant_id, plan_id, billing_cycle,
current_period_start, current_period_end, status,
trial_start, trial_end,
billing_email, billing_name, tax_id,
current_price, contracted_users, contracted_branches,
auto_renew
)
SELECT
'dddd2222-2222-2222-2222-222222222222'::uuid,
'22222222-2222-2222-2222-222222222222'::uuid,
sp.id,
'monthly',
CURRENT_DATE - INTERVAL '10 days',
CURRENT_DATE + INTERVAL '20 days',
'trial',
CURRENT_DATE - INTERVAL '10 days',
CURRENT_DATE + INTERVAL '4 days',
'billing@test.com',
'Tienda Pruebas',
'XYZ987654ABC',
0, -- Gratis durante trial
5,
1,
TRUE
FROM billing.subscription_plans sp
WHERE sp.code = 'starter'
ON CONFLICT (tenant_id) DO NOTHING;
-- Usage Tracking para el periodo actual
INSERT INTO billing.usage_tracking (
id, tenant_id, period_start, period_end,
active_users, peak_concurrent_users, active_branches,
storage_used_gb, api_calls, sales_count, sales_amount,
mobile_sessions, payment_transactions
)
VALUES
-- Uso de Demo (tenant activo)
('eeee3333-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111',
date_trunc('month', CURRENT_DATE), date_trunc('month', CURRENT_DATE) + INTERVAL '1 month' - INTERVAL '1 day',
4, 3, 4, 2.5, 15000, 250, 125000.00, 150, 75),
-- Uso de Test Store (trial)
('eeee4444-2222-2222-2222-222222222222', '22222222-2222-2222-2222-222222222222',
date_trunc('month', CURRENT_DATE), date_trunc('month', CURRENT_DATE) + INTERVAL '1 month' - INTERVAL '1 day',
1, 1, 1, 0.1, 500, 15, 3500.00, 10, 5)
ON CONFLICT (tenant_id, period_start) DO NOTHING;
-- Factura de ejemplo (pagada)
INSERT INTO billing.invoices (
id, tenant_id, subscription_id, invoice_number, invoice_date,
period_start, period_end,
billing_name, billing_email, tax_id,
subtotal, tax_amount, total, currency, status,
due_date, paid_at, paid_amount, payment_method, payment_reference
)
VALUES
('ffff1111-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111',
'dddd1111-1111-1111-1111-111111111111',
'INV-2026-000001', CURRENT_DATE - INTERVAL '1 month',
CURRENT_DATE - INTERVAL '2 months', CURRENT_DATE - INTERVAL '1 month',
'Empresa Demo SA de CV', 'billing@demo.com', 'ABC123456XYZ',
999.00, 159.84, 1158.84, 'MXN', 'paid',
CURRENT_DATE - INTERVAL '25 days', CURRENT_DATE - INTERVAL '26 days', 1158.84,
'card', 'ch_1234567890')
ON CONFLICT DO NOTHING;
-- Items de factura
INSERT INTO billing.invoice_items (
id, invoice_id, description, item_type, quantity, unit_price, subtotal
)
VALUES
(gen_random_uuid(), 'ffff1111-1111-1111-1111-111111111111',
'Suscripcion Professional - Diciembre 2025', 'subscription', 1, 999.00, 999.00)
ON CONFLICT DO NOTHING;
-- Alertas de billing de ejemplo
INSERT INTO billing.billing_alerts (
id, tenant_id, alert_type, title, message, severity, status
)
VALUES
-- Alerta resuelta
(gen_random_uuid(), '11111111-1111-1111-1111-111111111111',
'payment_due', 'Pago pendiente procesado',
'Su pago del periodo anterior ha sido procesado exitosamente.',
'info', 'resolved'),
-- Alerta de trial ending para tenant test
(gen_random_uuid(), '22222222-2222-2222-2222-222222222222',
'trial_ending', 'Su periodo de prueba termina pronto',
'Su periodo de prueba terminara en 4 dias. Seleccione un plan para continuar usando el servicio.',
'warning', 'active')
ON CONFLICT DO NOTHING;