From e964ff48123e11159af9c5b01fb97e05c6911737 Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Mon, 26 Jan 2026 16:11:51 -0600 Subject: [PATCH] feat(ddl): Add DDL for Settings, Reports and HR modules - 30-settings.sql: system_settings, plan_settings, tenant_settings, user_preferences (4 tables) - 31-reports.sql: report_definitions, executions, schedules, dashboards, widgets (12 tables, 7 enums) - 45-hr.sql: employees, departments, job_positions, contracts, leave_types, leaves (7 tables, 6 enums) Includes RLS policies, triggers, and utility functions for each module. Co-Authored-By: Claude Opus 4.5 --- ddl/30-settings.sql | 319 +++++++++++++++++++++ ddl/31-reports.sql | 679 ++++++++++++++++++++++++++++++++++++++++++++ ddl/45-hr.sql | 666 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 1664 insertions(+) create mode 100644 ddl/30-settings.sql create mode 100644 ddl/31-reports.sql create mode 100644 ddl/45-hr.sql diff --git a/ddl/30-settings.sql b/ddl/30-settings.sql new file mode 100644 index 0000000..f485ec3 --- /dev/null +++ b/ddl/30-settings.sql @@ -0,0 +1,319 @@ +-- ============================================================= +-- ARCHIVO: 30-settings.sql +-- DESCRIPCION: Sistema de Settings para configuraciones multi-nivel +-- VERSION: 1.0.0 +-- PROYECTO: ERP-Core +-- FECHA: 2026-01-26 +-- MODULO: MGN-006 (Settings) +-- RF: RF-SETTINGS-001, RF-SETTINGS-002, RF-SETTINGS-003 +-- NOTA: Feature Flags ya existen en 11-feature-flags.sql (schema flags) +-- ============================================================= + +-- ===================== +-- SCHEMA: core_settings +-- ===================== +CREATE SCHEMA IF NOT EXISTS core_settings; + +-- ===================== +-- TABLA: core_settings.system_settings +-- Configuraciones globales del sistema (RF-SETTINGS-001) +-- ===================== +CREATE TABLE IF NOT EXISTS core_settings.system_settings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Identificacion + key VARCHAR(100) UNIQUE NOT NULL, + value JSONB NOT NULL, + + -- Metadata + data_type VARCHAR(20) NOT NULL DEFAULT 'string' + CHECK (data_type IN ('string', 'number', 'boolean', 'json', 'array', 'secret')), + category VARCHAR(50) NOT NULL, + description TEXT, + + -- Control + is_public BOOLEAN NOT NULL DEFAULT false, + is_editable BOOLEAN NOT NULL DEFAULT true, + default_value JSONB, + validation_rules JSONB DEFAULT '{}', + + -- Audit + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by UUID REFERENCES auth.users(id) +); + +-- Indices para system_settings +CREATE INDEX IF NOT EXISTS idx_system_settings_category + ON core_settings.system_settings(category); +CREATE INDEX IF NOT EXISTS idx_system_settings_public + ON core_settings.system_settings(is_public) WHERE is_public = true; +CREATE INDEX IF NOT EXISTS idx_system_settings_editable + ON core_settings.system_settings(is_editable) WHERE is_editable = true; + +-- ===================== +-- TABLA: core_settings.plan_settings +-- Configuraciones por defecto por plan de suscripcion (RF-SETTINGS-002) +-- ===================== +CREATE TABLE IF NOT EXISTS core_settings.plan_settings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Relaciones + plan_id UUID NOT NULL REFERENCES billing.subscription_plans(id) ON DELETE CASCADE, + key VARCHAR(100) NOT NULL, + value JSONB NOT NULL, + + -- Audit + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + UNIQUE(plan_id, key) +); + +-- Indices para plan_settings +CREATE INDEX IF NOT EXISTS idx_plan_settings_plan + ON core_settings.plan_settings(plan_id); + +-- ===================== +-- TABLA: core_settings.tenant_settings +-- Configuraciones personalizadas por tenant (RF-SETTINGS-002) +-- ===================== +CREATE TABLE IF NOT EXISTS core_settings.tenant_settings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Relaciones + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + key VARCHAR(100) NOT NULL, + value JSONB NOT NULL, + + -- Herencia + inherited_from VARCHAR(20) NOT NULL DEFAULT 'custom' + CHECK (inherited_from IN ('system', 'plan', 'custom')), + is_overridden BOOLEAN NOT NULL DEFAULT true, + + -- Audit + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + UNIQUE(tenant_id, key) +); + +-- Indices para tenant_settings +CREATE INDEX IF NOT EXISTS idx_tenant_settings_tenant + ON core_settings.tenant_settings(tenant_id); +CREATE INDEX IF NOT EXISTS idx_tenant_settings_key + ON core_settings.tenant_settings(key); + +-- RLS para tenant_settings +ALTER TABLE core_settings.tenant_settings ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS tenant_isolation_settings ON core_settings.tenant_settings; +CREATE POLICY tenant_isolation_settings ON core_settings.tenant_settings + FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); + +-- ===================== +-- TABLA: core_settings.user_preferences +-- Preferencias personales de usuario (RF-SETTINGS-003) +-- ===================== +CREATE TABLE IF NOT EXISTS core_settings.user_preferences ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Relaciones + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + key VARCHAR(100) NOT NULL, + value JSONB NOT NULL, + + -- Sync + synced_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + -- Audit + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + UNIQUE(user_id, key) +); + +-- Indices para user_preferences +CREATE INDEX IF NOT EXISTS idx_user_preferences_user + ON core_settings.user_preferences(user_id); +CREATE INDEX IF NOT EXISTS idx_user_preferences_key + ON core_settings.user_preferences(key); + +-- ===================== +-- FUNCIONES +-- ===================== + +-- Funcion para obtener setting efectivo del tenant (herencia system -> plan -> tenant) +CREATE OR REPLACE FUNCTION core_settings.get_tenant_setting( + p_tenant_id UUID, + p_key VARCHAR +) RETURNS JSONB AS $$ +DECLARE + v_value JSONB; + v_plan_id UUID; +BEGIN + -- 1. Buscar en tenant_settings (override) + SELECT value INTO v_value + FROM core_settings.tenant_settings + WHERE tenant_id = p_tenant_id + AND key = p_key + AND is_overridden = true; + + IF v_value IS NOT NULL THEN + RETURN v_value; + END IF; + + -- 2. Buscar en plan_settings + SELECT t.plan_id INTO v_plan_id + FROM auth.tenants t WHERE t.id = p_tenant_id; + + IF v_plan_id IS NOT NULL THEN + SELECT value INTO v_value + FROM core_settings.plan_settings + WHERE plan_id = v_plan_id AND key = p_key; + + IF v_value IS NOT NULL THEN + RETURN v_value; + END IF; + END IF; + + -- 3. Retornar system default + SELECT COALESCE(value, default_value) INTO v_value + FROM core_settings.system_settings + WHERE key = p_key; + + RETURN v_value; +END; +$$ LANGUAGE plpgsql STABLE; + +-- Funcion para obtener todas las preferencias de un usuario +CREATE OR REPLACE FUNCTION core_settings.get_user_preferences(p_user_id UUID) +RETURNS TABLE ( + key VARCHAR(100), + value JSONB, + synced_at TIMESTAMPTZ +) AS $$ +BEGIN + RETURN QUERY + SELECT up.key, up.value, up.synced_at + FROM core_settings.user_preferences up + WHERE up.user_id = p_user_id + ORDER BY up.key; +END; +$$ LANGUAGE plpgsql STABLE; + +-- Funcion para obtener todos los settings de un tenant (merged) +CREATE OR REPLACE FUNCTION core_settings.get_all_tenant_settings(p_tenant_id UUID) +RETURNS TABLE ( + key VARCHAR(100), + value JSONB, + source VARCHAR(20) +) AS $$ +BEGIN + RETURN QUERY + -- System settings publicos + SELECT ss.key, COALESCE(ss.value, ss.default_value), 'system'::VARCHAR(20) + FROM core_settings.system_settings ss + WHERE ss.is_public = true + AND NOT EXISTS ( + SELECT 1 FROM core_settings.tenant_settings ts + WHERE ts.tenant_id = p_tenant_id AND ts.key = ss.key AND ts.is_overridden = true + ) + UNION ALL + -- Tenant overrides + SELECT ts.key, ts.value, ts.inherited_from + FROM core_settings.tenant_settings ts + WHERE ts.tenant_id = p_tenant_id AND ts.is_overridden = true + ORDER BY key; +END; +$$ LANGUAGE plpgsql STABLE; + +-- ===================== +-- TRIGGERS +-- ===================== + +-- Trigger para updated_at +CREATE OR REPLACE FUNCTION core_settings.update_timestamp() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_system_settings_updated_at + BEFORE UPDATE ON core_settings.system_settings + FOR EACH ROW + EXECUTE FUNCTION core_settings.update_timestamp(); + +CREATE TRIGGER trg_plan_settings_updated_at + BEFORE UPDATE ON core_settings.plan_settings + FOR EACH ROW + EXECUTE FUNCTION core_settings.update_timestamp(); + +CREATE TRIGGER trg_tenant_settings_updated_at + BEFORE UPDATE ON core_settings.tenant_settings + FOR EACH ROW + EXECUTE FUNCTION core_settings.update_timestamp(); + +CREATE TRIGGER trg_user_preferences_updated_at + BEFORE UPDATE ON core_settings.user_preferences + FOR EACH ROW + EXECUTE FUNCTION core_settings.update_timestamp(); + +-- ===================== +-- SEED DATA: System Settings Base +-- ===================== +INSERT INTO core_settings.system_settings (key, value, data_type, category, description, is_public, is_editable, default_value) VALUES +-- Security +('security.max_login_attempts', '5', 'number', 'security', 'Intentos maximos de login antes de bloqueo', true, true, '5'), +('security.lockout_duration_minutes', '15', 'number', 'security', 'Duracion de bloqueo en minutos', true, true, '15'), +('security.session_timeout_hours', '24', 'number', 'security', 'Timeout de sesion en horas', true, true, '24'), +('security.password_min_length', '8', 'number', 'security', 'Longitud minima de password', true, true, '8'), +('security.require_mfa', 'false', 'boolean', 'security', 'Requerir MFA para todos los usuarios', true, true, 'false'), +('security.allowed_ip_ranges', '[]', 'array', 'security', 'Rangos IP permitidos (vacio = todos)', false, true, '[]'), + +-- Email +('email.smtp_host', '""', 'string', 'email', 'Host SMTP para envio de emails', false, true, '""'), +('email.smtp_port', '587', 'number', 'email', 'Puerto SMTP', false, true, '587'), +('email.smtp_secure', 'true', 'boolean', 'email', 'Usar TLS para conexion SMTP', false, true, 'true'), +('email.from_address', '"noreply@erp-core.local"', 'string', 'email', 'Email de origen por defecto', true, true, '"noreply@erp-core.local"'), +('email.from_name', '"ERP Core"', 'string', 'email', 'Nombre de remitente por defecto', true, true, '"ERP Core"'), + +-- Storage +('storage.max_file_size_mb', '10', 'number', 'storage', 'Tamano maximo de archivo en MB', true, true, '10'), +('storage.allowed_extensions', '["pdf","jpg","jpeg","png","xlsx","docx","csv"]', 'array', 'storage', 'Extensiones de archivo permitidas', true, true, '["pdf","jpg","jpeg","png","xlsx","docx","csv"]'), +('storage.provider', '"local"', 'string', 'storage', 'Proveedor de almacenamiento (local, s3, r2)', false, true, '"local"'), + +-- Performance +('performance.cache_ttl_seconds', '300', 'number', 'performance', 'TTL de cache en segundos', false, true, '300'), +('performance.pagination_default_limit', '20', 'number', 'performance', 'Items por pagina por defecto', true, true, '20'), +('performance.pagination_max_limit', '100', 'number', 'performance', 'Maximo items por pagina', true, false, '100'), + +-- Localization +('localization.default_timezone', '"America/Mexico_City"', 'string', 'localization', 'Timezone por defecto', true, true, '"America/Mexico_City"'), +('localization.default_locale', '"es-MX"', 'string', 'localization', 'Locale por defecto', true, true, '"es-MX"'), +('localization.default_currency', '"MXN"', 'string', 'localization', 'Moneda por defecto', true, true, '"MXN"'), +('localization.date_format', '"DD/MM/YYYY"', 'string', 'localization', 'Formato de fecha', true, true, '"DD/MM/YYYY"'), + +-- Business +('business.fiscal_year_start_month', '1', 'number', 'business', 'Mes de inicio del ano fiscal', true, true, '1'), +('business.default_tax_rate', '16', 'number', 'business', 'Tasa de IVA por defecto', true, true, '16'), +('business.invoice_prefix', '"INV-"', 'string', 'business', 'Prefijo de facturas', true, true, '"INV-"'), +('business.require_approval_amount', '10000', 'number', 'business', 'Monto que requiere aprobacion', true, true, '10000') + +ON CONFLICT (key) DO UPDATE SET + value = EXCLUDED.value, + updated_at = CURRENT_TIMESTAMP; + +-- ===================== +-- COMENTARIOS +-- ===================== +COMMENT ON SCHEMA core_settings IS 'Schema para configuraciones del sistema multi-nivel'; +COMMENT ON TABLE core_settings.system_settings IS 'Configuraciones globales del sistema'; +COMMENT ON TABLE core_settings.plan_settings IS 'Configuraciones por defecto por plan de suscripcion'; +COMMENT ON TABLE core_settings.tenant_settings IS 'Configuraciones personalizadas por tenant'; +COMMENT ON TABLE core_settings.user_preferences IS 'Preferencias personales de usuario'; +COMMENT ON FUNCTION core_settings.get_tenant_setting IS 'Obtiene setting efectivo con herencia system->plan->tenant'; +COMMENT ON FUNCTION core_settings.get_user_preferences IS 'Obtiene todas las preferencias de un usuario'; +COMMENT ON FUNCTION core_settings.get_all_tenant_settings IS 'Obtiene todos los settings de un tenant merged con sistema'; diff --git a/ddl/31-reports.sql b/ddl/31-reports.sql new file mode 100644 index 0000000..797ad70 --- /dev/null +++ b/ddl/31-reports.sql @@ -0,0 +1,679 @@ +-- ============================================================= +-- ARCHIVO: 31-reports.sql +-- DESCRIPCION: Sistema de Reportes y Analytics +-- VERSION: 1.0.0 +-- PROYECTO: ERP-Core +-- FECHA: 2026-01-26 +-- MODULO: MGN-009 (Reports) +-- EPIC: EPIC-MGN-009-reports +-- ============================================================= + +-- ===================== +-- SCHEMA: reports +-- ===================== +CREATE SCHEMA IF NOT EXISTS reports; + +-- ===================== +-- ENUMS +-- ===================== + +-- Tipo de reporte +DO $$ BEGIN + CREATE TYPE reports.report_type AS ENUM ( + 'financial', + 'accounting', + 'tax', + 'management', + 'operational', + 'custom' + ); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +-- Estado de ejecucion +DO $$ BEGIN + CREATE TYPE reports.execution_status AS ENUM ( + 'pending', + 'running', + 'completed', + 'failed', + 'cancelled' + ); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +-- Formato de exportacion +DO $$ BEGIN + CREATE TYPE reports.export_format AS ENUM ( + 'pdf', + 'excel', + 'csv', + 'json', + 'html' + ); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +-- Metodo de entrega +DO $$ BEGIN + CREATE TYPE reports.delivery_method AS ENUM ( + 'none', + 'email', + 'storage', + 'webhook' + ); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +-- Tipo de widget +DO $$ BEGIN + CREATE TYPE reports.widget_type AS ENUM ( + 'kpi', + 'bar_chart', + 'line_chart', + 'pie_chart', + 'donut_chart', + 'gauge', + 'table', + 'map', + 'text' + ); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +-- Tipo de parametro +DO $$ BEGIN + CREATE TYPE reports.param_type AS ENUM ( + 'string', + 'number', + 'date', + 'daterange', + 'boolean', + 'select', + 'multiselect', + 'entity' + ); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +-- Operador de filtro +DO $$ BEGIN + CREATE TYPE reports.filter_operator AS ENUM ( + 'eq', + 'ne', + 'gt', + 'gte', + 'lt', + 'lte', + 'like', + 'ilike', + 'in', + 'not_in', + 'between', + 'is_null', + 'is_not_null' + ); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +-- ===================== +-- TABLA: reports.report_definitions +-- Definiciones de reportes +-- ===================== +CREATE TABLE IF NOT EXISTS reports.report_definitions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + + -- Identificacion + code VARCHAR(50) NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT, + category VARCHAR(100), + report_type reports.report_type NOT NULL DEFAULT 'custom', + + -- Query + base_query TEXT, + query_function VARCHAR(255), + is_sql_based BOOLEAN NOT NULL DEFAULT true, + + -- Parametros + parameters_schema JSONB DEFAULT '[]', + default_parameters JSONB DEFAULT '{}', + + -- Columnas + columns_config JSONB NOT NULL DEFAULT '[]', + totals_config JSONB DEFAULT '[]', + + -- Permisos + required_permissions TEXT[] DEFAULT '{}', + is_public BOOLEAN NOT NULL DEFAULT false, + is_active BOOLEAN NOT NULL DEFAULT true, + + -- Audit + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_by UUID REFERENCES auth.users(id), + + UNIQUE(tenant_id, code) +); + +-- Indices +CREATE INDEX IF NOT EXISTS idx_report_definitions_tenant ON reports.report_definitions(tenant_id); +CREATE INDEX IF NOT EXISTS idx_report_definitions_category ON reports.report_definitions(category); +CREATE INDEX IF NOT EXISTS idx_report_definitions_type ON reports.report_definitions(report_type); +CREATE INDEX IF NOT EXISTS idx_report_definitions_active ON reports.report_definitions(is_active) WHERE is_active = true; + +-- ===================== +-- TABLA: reports.report_executions +-- Historial de ejecuciones +-- ===================== +CREATE TABLE IF NOT EXISTS reports.report_executions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + + -- Relaciones + report_definition_id UUID NOT NULL REFERENCES reports.report_definitions(id) ON DELETE CASCADE, + executed_by UUID NOT NULL REFERENCES auth.users(id), + + -- Ejecucion + status reports.execution_status NOT NULL DEFAULT 'pending', + parameters JSONB DEFAULT '{}', + + -- Resultado + result_data JSONB, + result_summary JSONB, + row_count INTEGER, + export_format reports.export_format, + file_path TEXT, + file_size_bytes BIGINT, + + -- Timing + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + execution_time_ms INTEGER, + + -- Error + error_message TEXT, + error_details JSONB, + + -- Audit + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Indices +CREATE INDEX IF NOT EXISTS idx_report_executions_tenant ON reports.report_executions(tenant_id); +CREATE INDEX IF NOT EXISTS idx_report_executions_definition ON reports.report_executions(report_definition_id); +CREATE INDEX IF NOT EXISTS idx_report_executions_user ON reports.report_executions(executed_by); +CREATE INDEX IF NOT EXISTS idx_report_executions_status ON reports.report_executions(status); +CREATE INDEX IF NOT EXISTS idx_report_executions_date ON reports.report_executions(created_at DESC); + +-- ===================== +-- TABLA: reports.report_schedules +-- Programacion de reportes +-- ===================== +CREATE TABLE IF NOT EXISTS reports.report_schedules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + + -- Relaciones + report_definition_id UUID NOT NULL REFERENCES reports.report_definitions(id) ON DELETE CASCADE, + + -- Programacion + name VARCHAR(255) NOT NULL, + cron_expression VARCHAR(100) NOT NULL, + timezone VARCHAR(100) NOT NULL DEFAULT 'America/Mexico_City', + parameters JSONB DEFAULT '{}', + + -- Entrega + delivery_method reports.delivery_method NOT NULL DEFAULT 'email', + delivery_config JSONB DEFAULT '{}', + export_format reports.export_format NOT NULL DEFAULT 'pdf', + + -- Estado + is_active BOOLEAN NOT NULL DEFAULT true, + last_run_at TIMESTAMPTZ, + last_run_status reports.execution_status, + next_run_at TIMESTAMPTZ, + run_count INTEGER NOT NULL DEFAULT 0, + + -- Audit + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_by UUID REFERENCES auth.users(id) +); + +-- Indices +CREATE INDEX IF NOT EXISTS idx_report_schedules_tenant ON reports.report_schedules(tenant_id); +CREATE INDEX IF NOT EXISTS idx_report_schedules_definition ON reports.report_schedules(report_definition_id); +CREATE INDEX IF NOT EXISTS idx_report_schedules_active ON reports.report_schedules(is_active) WHERE is_active = true; +CREATE INDEX IF NOT EXISTS idx_report_schedules_next_run ON reports.report_schedules(next_run_at) WHERE is_active = true; + +-- ===================== +-- TABLA: reports.report_recipients +-- Destinatarios de reportes programados +-- ===================== +CREATE TABLE IF NOT EXISTS reports.report_recipients ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Relaciones + schedule_id UUID NOT NULL REFERENCES reports.report_schedules(id) ON DELETE CASCADE, + user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE, + + -- Destinatario externo + email VARCHAR(255), + name VARCHAR(255), + + -- Estado + is_active BOOLEAN NOT NULL DEFAULT true, + + -- Audit + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Indices +CREATE INDEX IF NOT EXISTS idx_report_recipients_schedule ON reports.report_recipients(schedule_id); +CREATE INDEX IF NOT EXISTS idx_report_recipients_user ON reports.report_recipients(user_id); + +-- ===================== +-- TABLA: reports.schedule_executions +-- Historial de ejecuciones programadas +-- ===================== +CREATE TABLE IF NOT EXISTS reports.schedule_executions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Relaciones + schedule_id UUID NOT NULL REFERENCES reports.report_schedules(id) ON DELETE CASCADE, + execution_id UUID REFERENCES reports.report_executions(id) ON DELETE SET NULL, + + -- Resultado + status reports.execution_status NOT NULL, + recipients_notified INTEGER DEFAULT 0, + delivery_status JSONB DEFAULT '{}', + + -- Error + error_message TEXT, + + -- Audit + executed_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Indices +CREATE INDEX IF NOT EXISTS idx_schedule_executions_schedule ON reports.schedule_executions(schedule_id); +CREATE INDEX IF NOT EXISTS idx_schedule_executions_date ON reports.schedule_executions(executed_at DESC); + +-- ===================== +-- TABLA: reports.dashboards +-- Dashboards personalizados +-- ===================== +CREATE TABLE IF NOT EXISTS reports.dashboards ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + + -- Identificacion + name VARCHAR(255) NOT NULL, + description TEXT, + slug VARCHAR(100), + icon VARCHAR(50), + + -- Layout + layout_config JSONB DEFAULT '{"columns": 12, "rowHeight": 80}', + + -- Estado + is_default BOOLEAN NOT NULL DEFAULT false, + is_public BOOLEAN NOT NULL DEFAULT false, + is_active BOOLEAN NOT NULL DEFAULT true, + + -- Permisos + owner_id UUID NOT NULL REFERENCES auth.users(id), + allowed_roles TEXT[] DEFAULT '{}', + + -- Audit + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Indices +CREATE INDEX IF NOT EXISTS idx_dashboards_tenant ON reports.dashboards(tenant_id); +CREATE INDEX IF NOT EXISTS idx_dashboards_owner ON reports.dashboards(owner_id); +CREATE INDEX IF NOT EXISTS idx_dashboards_default ON reports.dashboards(tenant_id, is_default) WHERE is_default = true; + +-- ===================== +-- TABLA: reports.dashboard_widgets +-- Widgets de dashboards +-- ===================== +CREATE TABLE IF NOT EXISTS reports.dashboard_widgets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Relaciones + dashboard_id UUID NOT NULL REFERENCES reports.dashboards(id) ON DELETE CASCADE, + + -- Identificacion + title VARCHAR(255) NOT NULL, + widget_type reports.widget_type NOT NULL, + + -- Posicion + position_x INTEGER NOT NULL DEFAULT 0, + position_y INTEGER NOT NULL DEFAULT 0, + width INTEGER NOT NULL DEFAULT 3, + height INTEGER NOT NULL DEFAULT 2, + + -- Configuracion + config JSONB NOT NULL DEFAULT '{}', + refresh_interval_seconds INTEGER DEFAULT 300, + + -- Estado + is_active BOOLEAN NOT NULL DEFAULT true, + sort_order INTEGER NOT NULL DEFAULT 0, + + -- Audit + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Indices +CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_dashboard ON reports.dashboard_widgets(dashboard_id); +CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_type ON reports.dashboard_widgets(widget_type); + +-- ===================== +-- TABLA: reports.widget_queries +-- Queries de widgets +-- ===================== +CREATE TABLE IF NOT EXISTS reports.widget_queries ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Relaciones + widget_id UUID NOT NULL REFERENCES reports.dashboard_widgets(id) ON DELETE CASCADE, + + -- Query + name VARCHAR(100) NOT NULL, + query_text TEXT, + query_function VARCHAR(255), + parameters JSONB DEFAULT '{}', + + -- Resultado + result_mapping JSONB DEFAULT '{}', + + -- Cache + cache_ttl_seconds INTEGER DEFAULT 300, + last_cached_at TIMESTAMPTZ, + + -- Audit + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Indices +CREATE INDEX IF NOT EXISTS idx_widget_queries_widget ON reports.widget_queries(widget_id); + +-- ===================== +-- TABLA: reports.custom_reports +-- Reportes personalizados por usuario +-- ===================== +CREATE TABLE IF NOT EXISTS reports.custom_reports ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + + -- Propietario + owner_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + + -- Base + base_definition_id UUID REFERENCES reports.report_definitions(id) ON DELETE SET NULL, + + -- Identificacion + name VARCHAR(255) NOT NULL, + description TEXT, + + -- Configuracion personalizada + custom_columns JSONB DEFAULT '[]', + custom_filters JSONB DEFAULT '[]', + custom_grouping JSONB DEFAULT '[]', + custom_sorting JSONB DEFAULT '[]', + + -- Estado + is_favorite BOOLEAN NOT NULL DEFAULT false, + + -- Audit + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Indices +CREATE INDEX IF NOT EXISTS idx_custom_reports_tenant ON reports.custom_reports(tenant_id); +CREATE INDEX IF NOT EXISTS idx_custom_reports_owner ON reports.custom_reports(owner_id); +CREATE INDEX IF NOT EXISTS idx_custom_reports_favorite ON reports.custom_reports(owner_id, is_favorite) WHERE is_favorite = true; + +-- ===================== +-- TABLA: reports.data_model_entities +-- Modelo de datos para report builder +-- ===================== +CREATE TABLE IF NOT EXISTS reports.data_model_entities ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Identificacion + name VARCHAR(100) NOT NULL UNIQUE, + display_name VARCHAR(255) NOT NULL, + description TEXT, + + -- Schema + schema_name VARCHAR(100) NOT NULL, + table_name VARCHAR(100) NOT NULL, + + -- Configuracion + primary_key_column VARCHAR(100) NOT NULL DEFAULT 'id', + tenant_column VARCHAR(100) DEFAULT 'tenant_id', + is_multi_tenant BOOLEAN NOT NULL DEFAULT true, + + -- Estado + is_active BOOLEAN NOT NULL DEFAULT true, + + -- Audit + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Indices +CREATE INDEX IF NOT EXISTS idx_data_model_entities_name ON reports.data_model_entities(name); +CREATE INDEX IF NOT EXISTS idx_data_model_entities_schema ON reports.data_model_entities(schema_name, table_name); + +-- ===================== +-- TABLA: reports.data_model_fields +-- Campos del modelo de datos +-- ===================== +CREATE TABLE IF NOT EXISTS reports.data_model_fields ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Relaciones + entity_id UUID NOT NULL REFERENCES reports.data_model_entities(id) ON DELETE CASCADE, + + -- Identificacion + name VARCHAR(100) NOT NULL, + display_name VARCHAR(255) NOT NULL, + description TEXT, + + -- Tipo + data_type VARCHAR(50) NOT NULL, + is_nullable BOOLEAN NOT NULL DEFAULT true, + + -- Configuracion UI + is_filterable BOOLEAN NOT NULL DEFAULT true, + is_sortable BOOLEAN NOT NULL DEFAULT true, + is_groupable BOOLEAN NOT NULL DEFAULT false, + is_aggregatable BOOLEAN NOT NULL DEFAULT false, + aggregation_functions TEXT[] DEFAULT '{}', + + -- Formato + format_pattern VARCHAR(100), + display_format VARCHAR(50), + + -- Estado + is_active BOOLEAN NOT NULL DEFAULT true, + sort_order INTEGER NOT NULL DEFAULT 0, + + -- Audit + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + UNIQUE(entity_id, name) +); + +-- Indices +CREATE INDEX IF NOT EXISTS idx_data_model_fields_entity ON reports.data_model_fields(entity_id); + +-- ===================== +-- TABLA: reports.data_model_relationships +-- Relaciones entre entidades del modelo +-- ===================== +CREATE TABLE IF NOT EXISTS reports.data_model_relationships ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Entidades + source_entity_id UUID NOT NULL REFERENCES reports.data_model_entities(id) ON DELETE CASCADE, + target_entity_id UUID NOT NULL REFERENCES reports.data_model_entities(id) ON DELETE CASCADE, + + -- Relacion + name VARCHAR(100) NOT NULL, + relationship_type VARCHAR(20) NOT NULL CHECK (relationship_type IN ('one_to_one', 'one_to_many', 'many_to_one', 'many_to_many')), + + -- Join + source_column VARCHAR(100) NOT NULL, + target_column VARCHAR(100) NOT NULL, + join_condition TEXT, + + -- Estado + is_active BOOLEAN NOT NULL DEFAULT true, + + -- Audit + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + UNIQUE(source_entity_id, target_entity_id, name) +); + +-- Indices +CREATE INDEX IF NOT EXISTS idx_data_model_relationships_source ON reports.data_model_relationships(source_entity_id); +CREATE INDEX IF NOT EXISTS idx_data_model_relationships_target ON reports.data_model_relationships(target_entity_id); + +-- ===================== +-- RLS POLICIES +-- ===================== + +-- Report Definitions +ALTER TABLE reports.report_definitions ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_isolation_definitions ON reports.report_definitions; +CREATE POLICY tenant_isolation_definitions ON reports.report_definitions + FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); + +-- Report Executions +ALTER TABLE reports.report_executions ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_isolation_executions ON reports.report_executions; +CREATE POLICY tenant_isolation_executions ON reports.report_executions + FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); + +-- Report Schedules +ALTER TABLE reports.report_schedules ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_isolation_schedules ON reports.report_schedules; +CREATE POLICY tenant_isolation_schedules ON reports.report_schedules + FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); + +-- Dashboards +ALTER TABLE reports.dashboards ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_isolation_dashboards ON reports.dashboards; +CREATE POLICY tenant_isolation_dashboards ON reports.dashboards + FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); + +-- Custom Reports +ALTER TABLE reports.custom_reports ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_isolation_custom_reports ON reports.custom_reports; +CREATE POLICY tenant_isolation_custom_reports ON reports.custom_reports + FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); + +-- ===================== +-- TRIGGERS +-- ===================== + +CREATE OR REPLACE FUNCTION reports.update_timestamp() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_report_definitions_updated_at + BEFORE UPDATE ON reports.report_definitions + FOR EACH ROW EXECUTE FUNCTION reports.update_timestamp(); + +CREATE TRIGGER trg_report_schedules_updated_at + BEFORE UPDATE ON reports.report_schedules + FOR EACH ROW EXECUTE FUNCTION reports.update_timestamp(); + +CREATE TRIGGER trg_dashboards_updated_at + BEFORE UPDATE ON reports.dashboards + FOR EACH ROW EXECUTE FUNCTION reports.update_timestamp(); + +CREATE TRIGGER trg_dashboard_widgets_updated_at + BEFORE UPDATE ON reports.dashboard_widgets + FOR EACH ROW EXECUTE FUNCTION reports.update_timestamp(); + +CREATE TRIGGER trg_custom_reports_updated_at + BEFORE UPDATE ON reports.custom_reports + FOR EACH ROW EXECUTE FUNCTION reports.update_timestamp(); + +-- ===================== +-- FUNCIONES DE UTILIDAD +-- ===================== + +-- Funcion para obtener reportes activos de un tenant +CREATE OR REPLACE FUNCTION reports.get_active_reports(p_tenant_id UUID) +RETURNS TABLE ( + id UUID, + code VARCHAR(50), + name VARCHAR(255), + category VARCHAR(100), + report_type reports.report_type +) AS $$ +BEGIN + RETURN QUERY + SELECT rd.id, rd.code, rd.name, rd.category, rd.report_type + FROM reports.report_definitions rd + WHERE rd.tenant_id = p_tenant_id + AND rd.is_active = true + ORDER BY rd.category, rd.name; +END; +$$ LANGUAGE plpgsql STABLE; + +-- Funcion para obtener historial de ejecuciones recientes +CREATE OR REPLACE FUNCTION reports.get_recent_executions( + p_tenant_id UUID, + p_limit INTEGER DEFAULT 10 +) +RETURNS TABLE ( + id UUID, + report_name VARCHAR(255), + status reports.execution_status, + executed_at TIMESTAMPTZ, + execution_time_ms INTEGER +) AS $$ +BEGIN + RETURN QUERY + SELECT re.id, rd.name, re.status, re.created_at, re.execution_time_ms + FROM reports.report_executions re + JOIN reports.report_definitions rd ON rd.id = re.report_definition_id + WHERE re.tenant_id = p_tenant_id + ORDER BY re.created_at DESC + LIMIT p_limit; +END; +$$ LANGUAGE plpgsql STABLE; + +-- ===================== +-- COMENTARIOS +-- ===================== +COMMENT ON SCHEMA reports IS 'Schema para sistema de reportes y analytics'; +COMMENT ON TABLE reports.report_definitions IS 'Definiciones de reportes'; +COMMENT ON TABLE reports.report_executions IS 'Historial de ejecuciones de reportes'; +COMMENT ON TABLE reports.report_schedules IS 'Programacion de reportes automaticos'; +COMMENT ON TABLE reports.report_recipients IS 'Destinatarios de reportes programados'; +COMMENT ON TABLE reports.dashboards IS 'Dashboards personalizados'; +COMMENT ON TABLE reports.dashboard_widgets IS 'Widgets de dashboards'; +COMMENT ON TABLE reports.custom_reports IS 'Reportes personalizados por usuario'; +COMMENT ON TABLE reports.data_model_entities IS 'Modelo de datos para report builder'; +COMMENT ON TABLE reports.data_model_fields IS 'Campos del modelo de datos'; +COMMENT ON TABLE reports.data_model_relationships IS 'Relaciones entre entidades'; diff --git a/ddl/45-hr.sql b/ddl/45-hr.sql new file mode 100644 index 0000000..fbdf8d5 --- /dev/null +++ b/ddl/45-hr.sql @@ -0,0 +1,666 @@ +-- ============================================================= +-- ARCHIVO: 45-hr.sql +-- DESCRIPCION: Sistema de Recursos Humanos (RRHH) +-- VERSION: 1.0.0 +-- PROYECTO: ERP-Core +-- FECHA: 2026-01-26 +-- MODULO: MGN-010 (RRHH Basico) +-- REFERENCIA: odoo-hr-analysis.md, hr-domain.md +-- ============================================================= + +-- ===================== +-- SCHEMA: hr +-- ===================== +CREATE SCHEMA IF NOT EXISTS hr; + +-- ===================== +-- ENUMS +-- ===================== + +-- Estado del empleado +DO $$ BEGIN + CREATE TYPE hr.employee_status AS ENUM ( + 'active', + 'inactive', + 'on_leave', + 'terminated' + ); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +-- Tipo de contrato +DO $$ BEGIN + CREATE TYPE hr.contract_type AS ENUM ( + 'permanent', + 'temporary', + 'contractor', + 'internship', + 'part_time' + ); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +-- Estado del contrato +DO $$ BEGIN + CREATE TYPE hr.contract_status AS ENUM ( + 'draft', + 'active', + 'expired', + 'terminated', + 'cancelled' + ); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +-- Tipo de ausencia +DO $$ BEGIN + CREATE TYPE hr.leave_type_category AS ENUM ( + 'vacation', + 'sick', + 'personal', + 'maternity', + 'paternity', + 'bereavement', + 'unpaid', + 'other' + ); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +-- Estado de ausencia +DO $$ BEGIN + CREATE TYPE hr.leave_status AS ENUM ( + 'draft', + 'submitted', + 'approved', + 'rejected', + 'cancelled' + ); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +-- Tipo de asignacion +DO $$ BEGIN + CREATE TYPE hr.allocation_type AS ENUM ( + 'fixed', + 'accrual', + 'unlimited' + ); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +-- ===================== +-- TABLA: hr.departments +-- Departamentos organizacionales +-- ===================== +CREATE TABLE IF NOT EXISTS hr.departments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + company_id UUID NOT NULL REFERENCES core.companies(id) ON DELETE CASCADE, + + -- Identificacion + name VARCHAR(255) NOT NULL, + code VARCHAR(50), + + -- Jerarquia + parent_id UUID REFERENCES hr.departments(id) ON DELETE SET NULL, + manager_id UUID, -- FK a employees (se agrega despues) + + -- Estado + is_active BOOLEAN NOT NULL DEFAULT true, + + -- Metadata + description TEXT, + color VARCHAR(7), + + -- Audit + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + UNIQUE(tenant_id, company_id, code) +); + +-- Indices +CREATE INDEX IF NOT EXISTS idx_departments_tenant ON hr.departments(tenant_id); +CREATE INDEX IF NOT EXISTS idx_departments_company ON hr.departments(company_id); +CREATE INDEX IF NOT EXISTS idx_departments_parent ON hr.departments(parent_id); +CREATE INDEX IF NOT EXISTS idx_departments_active ON hr.departments(is_active) WHERE is_active = true; + +-- ===================== +-- TABLA: hr.job_positions +-- Puestos de trabajo +-- ===================== +CREATE TABLE IF NOT EXISTS hr.job_positions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + company_id UUID NOT NULL REFERENCES core.companies(id) ON DELETE CASCADE, + + -- Identificacion + name VARCHAR(255) NOT NULL, + code VARCHAR(50), + description TEXT, + + -- Relaciones + department_id UUID REFERENCES hr.departments(id) ON DELETE SET NULL, + + -- Estado + is_active BOOLEAN NOT NULL DEFAULT true, + + -- Audit + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + UNIQUE(tenant_id, company_id, code) +); + +-- Indices +CREATE INDEX IF NOT EXISTS idx_job_positions_tenant ON hr.job_positions(tenant_id); +CREATE INDEX IF NOT EXISTS idx_job_positions_department ON hr.job_positions(department_id); +CREATE INDEX IF NOT EXISTS idx_job_positions_active ON hr.job_positions(is_active) WHERE is_active = true; + +-- ===================== +-- TABLA: hr.employees +-- Empleados +-- ===================== +CREATE TABLE IF NOT EXISTS hr.employees ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + company_id UUID NOT NULL REFERENCES core.companies(id) ON DELETE CASCADE, + + -- Identificacion + employee_number VARCHAR(50) NOT NULL, + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100) NOT NULL, + full_name VARCHAR(255) GENERATED ALWAYS AS (first_name || ' ' || last_name) STORED, + + -- Relaciones con usuario y partner + user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, + partner_id UUID, -- REFERENCES partners.partners(id) + + -- Organizacion + department_id UUID REFERENCES hr.departments(id) ON DELETE SET NULL, + job_position_id UUID REFERENCES hr.job_positions(id) ON DELETE SET NULL, + manager_id UUID REFERENCES hr.employees(id) ON DELETE SET NULL, + + -- Contacto laboral + work_email VARCHAR(255), + work_phone VARCHAR(50), + work_mobile VARCHAR(50), + work_location VARCHAR(255), + + -- Datos personales + personal_email VARCHAR(255), + personal_phone VARCHAR(50), + birth_date DATE, + gender VARCHAR(20), + marital_status VARCHAR(20), + + -- Identificacion oficial + identification_type VARCHAR(50), + identification_number VARCHAR(100), + tax_id VARCHAR(50), -- RFC en Mexico + social_security_number VARCHAR(50), -- NSS/IMSS + + -- Direccion + street VARCHAR(255), + city VARCHAR(100), + state VARCHAR(100), + postal_code VARCHAR(20), + country VARCHAR(100), + + -- Empleo + hire_date DATE NOT NULL, + termination_date DATE, + status hr.employee_status NOT NULL DEFAULT 'active', + + -- Emergencia + emergency_contact_name VARCHAR(255), + emergency_contact_phone VARCHAR(50), + emergency_contact_relationship VARCHAR(50), + + -- Metadata + notes TEXT, + avatar_url TEXT, + + -- Audit + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + UNIQUE(tenant_id, employee_number) +); + +-- Indices +CREATE INDEX IF NOT EXISTS idx_employees_tenant ON hr.employees(tenant_id); +CREATE INDEX IF NOT EXISTS idx_employees_company ON hr.employees(company_id); +CREATE INDEX IF NOT EXISTS idx_employees_department ON hr.employees(department_id); +CREATE INDEX IF NOT EXISTS idx_employees_job ON hr.employees(job_position_id); +CREATE INDEX IF NOT EXISTS idx_employees_manager ON hr.employees(manager_id); +CREATE INDEX IF NOT EXISTS idx_employees_user ON hr.employees(user_id); +CREATE INDEX IF NOT EXISTS idx_employees_status ON hr.employees(status); +CREATE INDEX IF NOT EXISTS idx_employees_active ON hr.employees(status) WHERE status = 'active'; +CREATE INDEX IF NOT EXISTS idx_employees_name ON hr.employees(full_name); + +-- Agregar FK de manager_id en departments +ALTER TABLE hr.departments + ADD CONSTRAINT fk_departments_manager + FOREIGN KEY (manager_id) REFERENCES hr.employees(id) ON DELETE SET NULL; + +-- ===================== +-- TABLA: hr.contracts +-- Contratos laborales +-- ===================== +CREATE TABLE IF NOT EXISTS hr.contracts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + company_id UUID NOT NULL REFERENCES core.companies(id) ON DELETE CASCADE, + + -- Relaciones + employee_id UUID NOT NULL REFERENCES hr.employees(id) ON DELETE CASCADE, + + -- Identificacion + name VARCHAR(255) NOT NULL, + reference VARCHAR(50), + + -- Tipo y estado + contract_type hr.contract_type NOT NULL DEFAULT 'permanent', + status hr.contract_status NOT NULL DEFAULT 'draft', + + -- Vigencia + date_start DATE NOT NULL, + date_end DATE, + + -- Puesto y departamento al momento del contrato + job_position_id UUID REFERENCES hr.job_positions(id) ON DELETE SET NULL, + department_id UUID REFERENCES hr.departments(id) ON DELETE SET NULL, + + -- Compensacion + wage DECIMAL(15,2) NOT NULL, + wage_type VARCHAR(20) NOT NULL DEFAULT 'monthly' CHECK (wage_type IN ('hourly', 'daily', 'weekly', 'biweekly', 'monthly', 'annual')), + currency VARCHAR(3) NOT NULL DEFAULT 'MXN', + + -- Jornada + hours_per_week DECIMAL(5,2) DEFAULT 48, + schedule_type VARCHAR(50), + + -- Probatorio + trial_period_months INTEGER DEFAULT 0, + trial_date_end DATE, + + -- Metadata + notes TEXT, + document_url TEXT, + + -- Audit + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + activated_at TIMESTAMPTZ, + terminated_at TIMESTAMPTZ, + terminated_by UUID REFERENCES auth.users(id), + termination_reason TEXT +); + +-- Indices +CREATE INDEX IF NOT EXISTS idx_contracts_tenant ON hr.contracts(tenant_id); +CREATE INDEX IF NOT EXISTS idx_contracts_employee ON hr.contracts(employee_id); +CREATE INDEX IF NOT EXISTS idx_contracts_status ON hr.contracts(status); +CREATE INDEX IF NOT EXISTS idx_contracts_active ON hr.contracts(employee_id, status) WHERE status = 'active'; +CREATE INDEX IF NOT EXISTS idx_contracts_dates ON hr.contracts(date_start, date_end); + +-- ===================== +-- TABLA: hr.leave_types +-- Tipos de ausencia +-- ===================== +CREATE TABLE IF NOT EXISTS hr.leave_types ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + company_id UUID NOT NULL REFERENCES core.companies(id) ON DELETE CASCADE, + + -- Identificacion + name VARCHAR(255) NOT NULL, + code VARCHAR(50) NOT NULL, + description TEXT, + color VARCHAR(7) DEFAULT '#3B82F6', + + -- Categoria + leave_category hr.leave_type_category NOT NULL DEFAULT 'other', + + -- Configuracion + allocation_type hr.allocation_type NOT NULL DEFAULT 'fixed', + requires_approval BOOLEAN NOT NULL DEFAULT true, + requires_document BOOLEAN NOT NULL DEFAULT false, + + -- Limites + max_days_per_request INTEGER, + max_days_per_year INTEGER, + min_days_notice INTEGER DEFAULT 0, + + -- Pago + is_paid BOOLEAN NOT NULL DEFAULT true, + pay_percentage DECIMAL(5,2) DEFAULT 100, + + -- Estado + is_active BOOLEAN NOT NULL DEFAULT true, + + -- Audit + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + UNIQUE(tenant_id, company_id, code) +); + +-- Indices +CREATE INDEX IF NOT EXISTS idx_leave_types_tenant ON hr.leave_types(tenant_id); +CREATE INDEX IF NOT EXISTS idx_leave_types_company ON hr.leave_types(company_id); +CREATE INDEX IF NOT EXISTS idx_leave_types_category ON hr.leave_types(leave_category); +CREATE INDEX IF NOT EXISTS idx_leave_types_active ON hr.leave_types(is_active) WHERE is_active = true; + +-- ===================== +-- TABLA: hr.leave_allocations +-- Asignacion de dias por empleado +-- ===================== +CREATE TABLE IF NOT EXISTS hr.leave_allocations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + + -- Relaciones + employee_id UUID NOT NULL REFERENCES hr.employees(id) ON DELETE CASCADE, + leave_type_id UUID NOT NULL REFERENCES hr.leave_types(id) ON DELETE CASCADE, + + -- Asignacion + days_allocated DECIMAL(5,2) NOT NULL, + days_used DECIMAL(5,2) NOT NULL DEFAULT 0, + days_remaining DECIMAL(5,2) GENERATED ALWAYS AS (days_allocated - days_used) STORED, + + -- Periodo + date_from DATE NOT NULL, + date_to DATE NOT NULL, + + -- Metadata + notes TEXT, + + -- Audit + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CHECK (date_to > date_from), + CHECK (days_allocated >= 0), + CHECK (days_used >= 0) +); + +-- Indices +CREATE INDEX IF NOT EXISTS idx_leave_allocations_tenant ON hr.leave_allocations(tenant_id); +CREATE INDEX IF NOT EXISTS idx_leave_allocations_employee ON hr.leave_allocations(employee_id); +CREATE INDEX IF NOT EXISTS idx_leave_allocations_type ON hr.leave_allocations(leave_type_id); +CREATE INDEX IF NOT EXISTS idx_leave_allocations_period ON hr.leave_allocations(date_from, date_to); + +-- ===================== +-- TABLA: hr.leaves +-- Solicitudes de ausencia +-- ===================== +CREATE TABLE IF NOT EXISTS hr.leaves ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + company_id UUID NOT NULL REFERENCES core.companies(id) ON DELETE CASCADE, + + -- Relaciones + employee_id UUID NOT NULL REFERENCES hr.employees(id) ON DELETE CASCADE, + leave_type_id UUID NOT NULL REFERENCES hr.leave_types(id) ON DELETE RESTRICT, + allocation_id UUID REFERENCES hr.leave_allocations(id) ON DELETE SET NULL, + + -- Periodo solicitado + date_from DATE NOT NULL, + date_to DATE NOT NULL, + days_requested DECIMAL(5,2) NOT NULL, + + -- Si es parcial + is_half_day BOOLEAN NOT NULL DEFAULT false, + half_day_type VARCHAR(20) CHECK (half_day_type IN ('morning', 'afternoon')), + + -- Estado + status hr.leave_status NOT NULL DEFAULT 'draft', + + -- Aprobacion + approver_id UUID REFERENCES auth.users(id), + approved_at TIMESTAMPTZ, + rejection_reason TEXT, + + -- Metadata + request_reason TEXT, + document_url TEXT, + notes TEXT, + + -- Audit + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + submitted_at TIMESTAMPTZ, + cancelled_at TIMESTAMPTZ, + cancelled_by UUID REFERENCES auth.users(id), + + CHECK (date_to >= date_from), + CHECK (days_requested > 0) +); + +-- Indices +CREATE INDEX IF NOT EXISTS idx_leaves_tenant ON hr.leaves(tenant_id); +CREATE INDEX IF NOT EXISTS idx_leaves_company ON hr.leaves(company_id); +CREATE INDEX IF NOT EXISTS idx_leaves_employee ON hr.leaves(employee_id); +CREATE INDEX IF NOT EXISTS idx_leaves_type ON hr.leaves(leave_type_id); +CREATE INDEX IF NOT EXISTS idx_leaves_status ON hr.leaves(status); +CREATE INDEX IF NOT EXISTS idx_leaves_dates ON hr.leaves(date_from, date_to); +CREATE INDEX IF NOT EXISTS idx_leaves_pending ON hr.leaves(status) WHERE status IN ('submitted'); +CREATE INDEX IF NOT EXISTS idx_leaves_approver ON hr.leaves(approver_id) WHERE status = 'submitted'; + +-- ===================== +-- RLS POLICIES +-- ===================== + +-- Departments +ALTER TABLE hr.departments ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_isolation_departments ON hr.departments; +CREATE POLICY tenant_isolation_departments ON hr.departments + FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); + +-- Job Positions +ALTER TABLE hr.job_positions ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_isolation_job_positions ON hr.job_positions; +CREATE POLICY tenant_isolation_job_positions ON hr.job_positions + FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); + +-- Employees +ALTER TABLE hr.employees ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_isolation_employees ON hr.employees; +CREATE POLICY tenant_isolation_employees ON hr.employees + FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); + +-- Contracts +ALTER TABLE hr.contracts ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_isolation_contracts ON hr.contracts; +CREATE POLICY tenant_isolation_contracts ON hr.contracts + FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); + +-- Leave Types +ALTER TABLE hr.leave_types ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_isolation_leave_types ON hr.leave_types; +CREATE POLICY tenant_isolation_leave_types ON hr.leave_types + FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); + +-- Leave Allocations +ALTER TABLE hr.leave_allocations ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_isolation_leave_allocations ON hr.leave_allocations; +CREATE POLICY tenant_isolation_leave_allocations ON hr.leave_allocations + FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); + +-- Leaves +ALTER TABLE hr.leaves ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_isolation_leaves ON hr.leaves; +CREATE POLICY tenant_isolation_leaves ON hr.leaves + FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); + +-- ===================== +-- TRIGGERS +-- ===================== + +CREATE OR REPLACE FUNCTION hr.update_timestamp() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_departments_updated_at + BEFORE UPDATE ON hr.departments + FOR EACH ROW EXECUTE FUNCTION hr.update_timestamp(); + +CREATE TRIGGER trg_job_positions_updated_at + BEFORE UPDATE ON hr.job_positions + FOR EACH ROW EXECUTE FUNCTION hr.update_timestamp(); + +CREATE TRIGGER trg_employees_updated_at + BEFORE UPDATE ON hr.employees + FOR EACH ROW EXECUTE FUNCTION hr.update_timestamp(); + +CREATE TRIGGER trg_contracts_updated_at + BEFORE UPDATE ON hr.contracts + FOR EACH ROW EXECUTE FUNCTION hr.update_timestamp(); + +CREATE TRIGGER trg_leave_types_updated_at + BEFORE UPDATE ON hr.leave_types + FOR EACH ROW EXECUTE FUNCTION hr.update_timestamp(); + +CREATE TRIGGER trg_leave_allocations_updated_at + BEFORE UPDATE ON hr.leave_allocations + FOR EACH ROW EXECUTE FUNCTION hr.update_timestamp(); + +CREATE TRIGGER trg_leaves_updated_at + BEFORE UPDATE ON hr.leaves + FOR EACH ROW EXECUTE FUNCTION hr.update_timestamp(); + +-- ===================== +-- FUNCIONES DE UTILIDAD +-- ===================== + +-- Obtener subordinados directos de un manager +CREATE OR REPLACE FUNCTION hr.get_direct_subordinates(p_manager_id UUID) +RETURNS TABLE ( + id UUID, + employee_number VARCHAR(50), + full_name VARCHAR(255), + department_name VARCHAR(255), + job_title VARCHAR(255) +) AS $$ +BEGIN + RETURN QUERY + SELECT e.id, e.employee_number, e.full_name, d.name, j.name + FROM hr.employees e + LEFT JOIN hr.departments d ON d.id = e.department_id + LEFT JOIN hr.job_positions j ON j.id = e.job_position_id + WHERE e.manager_id = p_manager_id + AND e.status = 'active' + ORDER BY e.full_name; +END; +$$ LANGUAGE plpgsql STABLE; + +-- Obtener saldo de dias de un tipo de ausencia +CREATE OR REPLACE FUNCTION hr.get_leave_balance( + p_employee_id UUID, + p_leave_type_id UUID, + p_date DATE DEFAULT CURRENT_DATE +) +RETURNS TABLE ( + days_allocated DECIMAL(5,2), + days_used DECIMAL(5,2), + days_remaining DECIMAL(5,2) +) AS $$ +BEGIN + RETURN QUERY + SELECT + COALESCE(SUM(la.days_allocated), 0), + COALESCE(SUM(la.days_used), 0), + COALESCE(SUM(la.days_remaining), 0) + FROM hr.leave_allocations la + WHERE la.employee_id = p_employee_id + AND la.leave_type_id = p_leave_type_id + AND p_date BETWEEN la.date_from AND la.date_to; +END; +$$ LANGUAGE plpgsql STABLE; + +-- Obtener contrato activo de un empleado +CREATE OR REPLACE FUNCTION hr.get_active_contract(p_employee_id UUID) +RETURNS hr.contracts AS $$ +DECLARE + v_contract hr.contracts; +BEGIN + SELECT * INTO v_contract + FROM hr.contracts + WHERE employee_id = p_employee_id + AND status = 'active' + LIMIT 1; + + RETURN v_contract; +END; +$$ LANGUAGE plpgsql STABLE; + +-- Verificar si hay solapamiento de ausencias +CREATE OR REPLACE FUNCTION hr.check_leave_overlap( + p_employee_id UUID, + p_date_from DATE, + p_date_to DATE, + p_exclude_leave_id UUID DEFAULT NULL +) +RETURNS BOOLEAN AS $$ +DECLARE + v_overlap_count INTEGER; +BEGIN + SELECT COUNT(*) INTO v_overlap_count + FROM hr.leaves l + WHERE l.employee_id = p_employee_id + AND l.status IN ('submitted', 'approved') + AND l.date_from <= p_date_to + AND l.date_to >= p_date_from + AND (p_exclude_leave_id IS NULL OR l.id != p_exclude_leave_id); + + RETURN v_overlap_count > 0; +END; +$$ LANGUAGE plpgsql STABLE; + +-- ===================== +-- SEED DATA: Tipos de Ausencia Base +-- ===================== + +-- Nota: Este seed se ejecuta por tenant cuando se crea la empresa +-- Aqui solo documentamos los tipos base + +/* +INSERT INTO hr.leave_types (tenant_id, company_id, name, code, leave_category, allocation_type, requires_approval, is_paid, max_days_per_year) VALUES +-- Vacaciones (Mexico: 12 dias primer ano, incrementa) +(tenant_id, company_id, 'Vacaciones', 'VAC', 'vacation', 'fixed', true, true, 30), +-- Enfermedad +(tenant_id, company_id, 'Incapacidad por Enfermedad', 'SICK', 'sick', 'unlimited', false, true, NULL), +-- Maternidad (Mexico: 84 dias) +(tenant_id, company_id, 'Licencia de Maternidad', 'MAT', 'maternity', 'fixed', true, true, 84), +-- Paternidad (Mexico: 5 dias) +(tenant_id, company_id, 'Licencia de Paternidad', 'PAT', 'paternity', 'fixed', true, true, 5), +-- Luto +(tenant_id, company_id, 'Permiso por Luto', 'BER', 'bereavement', 'fixed', true, true, 5), +-- Personal sin goce +(tenant_id, company_id, 'Permiso Personal (Sin Goce)', 'UNPAID', 'unpaid', 'unlimited', true, false, NULL), +-- Personal con goce +(tenant_id, company_id, 'Permiso Personal (Con Goce)', 'PERS', 'personal', 'fixed', true, true, 3); +*/ + +-- ===================== +-- COMENTARIOS +-- ===================== +COMMENT ON SCHEMA hr IS 'Schema para gestion de Recursos Humanos'; +COMMENT ON TABLE hr.departments IS 'Departamentos organizacionales con jerarquia'; +COMMENT ON TABLE hr.job_positions IS 'Puestos de trabajo'; +COMMENT ON TABLE hr.employees IS 'Empleados de la empresa'; +COMMENT ON TABLE hr.contracts IS 'Contratos laborales'; +COMMENT ON TABLE hr.leave_types IS 'Tipos de ausencia configurables'; +COMMENT ON TABLE hr.leave_allocations IS 'Asignacion de dias por empleado y tipo'; +COMMENT ON TABLE hr.leaves IS 'Solicitudes de ausencia'; +COMMENT ON FUNCTION hr.get_direct_subordinates IS 'Obtiene subordinados directos de un manager'; +COMMENT ON FUNCTION hr.get_leave_balance IS 'Obtiene saldo de dias de ausencia'; +COMMENT ON FUNCTION hr.get_active_contract IS 'Obtiene contrato activo de un empleado'; +COMMENT ON FUNCTION hr.check_leave_overlap IS 'Verifica solapamiento de ausencias';