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:
parent
7e8b72d47f
commit
ad24cc2f10
271
ddl/01-auth-profiles.sql
Normal file
271
ddl/01-auth-profiles.sql
Normal 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
252
ddl/02-auth-devices.sql
Normal 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
366
ddl/03-core-branches.sql
Normal 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
393
ddl/04-mobile.sql
Normal 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
622
ddl/05-billing-usage.sql
Normal 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
337
ddl/06-auth-extended.sql
Normal 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
565
ddl/07-users-rbac.sql
Normal 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
544
ddl/08-plans.sql
Normal 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
711
ddl/09-notifications.sql
Normal 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
793
ddl/10-audit.sql
Normal 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
424
ddl/11-feature-flags.sql
Normal 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
724
ddl/12-webhooks.sql
Normal 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
736
ddl/13-storage.sql
Normal 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
852
ddl/14-ai.sql
Normal 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
1018
ddl/15-whatsapp.sql
Normal file
File diff suppressed because it is too large
Load Diff
215
ddl/16-partners.sql
Normal file
215
ddl/16-partners.sql
Normal 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
230
ddl/17-products.sql
Normal 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
182
ddl/18-warehouses.sql
Normal 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
303
ddl/21-inventory.sql
Normal 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
285
ddl/22-sales.sql
Normal 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
243
ddl/23-purchases.sql
Normal 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
250
ddl/24-invoices.sql
Normal 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';
|
||||
238
migrations/20260110_001_add_tenant_user_fields.sql
Normal file
238
migrations/20260110_001_add_tenant_user_fields.sql
Normal 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
154
scripts/create-database.sh
Executable 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
161
scripts/create-test-database.sh
Executable 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
481
scripts/recreate-database.sh
Executable 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 "$@"
|
||||
38
seeds/dev/01-seed-tenants.sql
Normal file
38
seeds/dev/01-seed-tenants.sql
Normal 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';
|
||||
78
seeds/dev/02-seed-branches.sql
Normal file
78
seeds/dev/02-seed-branches.sql
Normal 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';
|
||||
131
seeds/dev/03-seed-subscriptions.sql
Normal file
131
seeds/dev/03-seed-subscriptions.sql
Normal 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;
|
||||
Loading…
Reference in New Issue
Block a user