diff --git a/README.md b/README.md deleted file mode 100644 index 2ce697f..0000000 --- a/README.md +++ /dev/null @@ -1,171 +0,0 @@ -# Database - ERP Generic - -**Version:** 1.1.0 -**Database:** PostgreSQL 15+ -**Schemas:** 12 -**Tables:** 118 -**Last Updated:** 2025-12-06 - -## Quick Start - -### Prerequisites - -- Docker & Docker Compose -- PostgreSQL 15+ (or use Docker) -- psql CLI - -### Setup - -```bash -# 1. Start PostgreSQL with Docker -docker-compose up -d - -# 2. Create database and run migrations -./scripts/create-database.sh - -# 3. Load seed data (development) -./scripts/load-seeds.sh dev -``` - -## Directory Structure - -``` -database/ -├── ddl/ # Data Definition Language (SQL schemas) -│ ├── 00-prerequisites.sql # Extensions, common functions -│ ├── 01-auth.sql # Authentication, users, roles -│ ├── 02-core.sql # Partners, catalogs, master data -│ ├── 03-analytics.sql # Analytic accounting -│ ├── 04-financial.sql # Accounts, journals, invoices -│ ├── 05-inventory.sql # Products, stock, warehouses -│ ├── 06-purchase.sql # Purchase orders, vendors -│ ├── 07-sales.sql # Sales orders, customers -│ ├── 08-projects.sql # Projects, tasks, timesheets -│ ├── 09-system.sql # Messages, notifications, logs -│ ├── 10-billing.sql # SaaS subscriptions, plans, payments -│ ├── 11-crm.sql # Leads, opportunities, pipeline -│ └── 12-hr.sql # Employees, contracts, leaves -├── scripts/ # Shell scripts -│ ├── create-database.sh # Master creation script -│ ├── drop-database.sh # Drop database -│ ├── load-seeds.sh # Load seed data -│ └── reset-database.sh # Drop and recreate -├── seeds/ # Initial data -│ ├── dev/ # Development seeds -│ └── prod/ # Production seeds -├── migrations/ # Incremental changes (empty by design) -├── docker-compose.yml # PostgreSQL container -└── .env.example # Environment variables template -``` - -## Schemas - -| Schema | Module | Tables | Description | -|--------|--------|--------|-------------| -| `auth` | MGN-001 | 10 | Authentication, users, roles, permissions, multi-tenancy | -| `core` | MGN-002, MGN-003 | 12 | Partners, addresses, currencies, countries, UoM, categories | -| `analytics` | MGN-008 | 7 | Analytic plans, accounts, distributions, cost centers | -| `financial` | MGN-004 | 15 | Chart of accounts, journals, entries, invoices, payments | -| `inventory` | MGN-005 | 10 | Products, warehouses, locations, stock moves, pickings | -| `purchase` | MGN-006 | 8 | RFQs, purchase orders, vendor pricelists, agreements | -| `sales` | MGN-007 | 10 | Quotations, sales orders, pricelists, teams | -| `projects` | MGN-011 | 10 | Projects, tasks, milestones, timesheets | -| `system` | MGN-012, MGN-014 | 13 | Messages, notifications, activities, logs, reports | -| `billing` | MGN-015 | 11 | SaaS subscriptions, plans, payments, coupons | -| `crm` | MGN-009 | 6 | Leads, opportunities, pipeline, activities | -| `hr` | MGN-010 | 6 | Employees, departments, contracts, leaves | - -## Execution Order - -The DDL files must be executed in order due to dependencies: - -1. `00-prerequisites.sql` - Extensions, base functions -2. `01-auth.sql` - Base schema (no dependencies) -3. `02-core.sql` - Depends on auth -4. `03-analytics.sql` - Depends on auth, core -5. `04-financial.sql` - Depends on auth, core, analytics -6. `05-inventory.sql` - Depends on auth, core, analytics -7. `06-purchase.sql` - Depends on auth, core, inventory, analytics -8. `07-sales.sql` - Depends on auth, core, inventory, analytics -9. `08-projects.sql` - Depends on auth, core, analytics -10. `09-system.sql` - Depends on auth, core -11. `10-billing.sql` - Depends on auth, core -12. `11-crm.sql` - Depends on auth, core, sales -13. `12-hr.sql` - Depends on auth, core - -## Features - -### Multi-Tenancy (RLS) - -All transactional tables have: -- `tenant_id` column -- Row Level Security (RLS) policies -- Context functions: `get_current_tenant_id()`, `get_current_user_id()` - -### Audit Trail - -All tables include: -- `created_at`, `created_by` -- `updated_at`, `updated_by` -- `deleted_at`, `deleted_by` (soft delete) - -### Automatic Triggers - -- `updated_at` auto-update on all tables -- Balance validation for journal entries -- Invoice totals calculation -- Stock quantity updates - -## Environment Variables - -```bash -# Database connection -POSTGRES_HOST=localhost -POSTGRES_PORT=5432 -POSTGRES_DB=erp_generic -POSTGRES_USER=erp_admin -POSTGRES_PASSWORD=your_secure_password - -# Optional -POSTGRES_SCHEMA=public -``` - -## Commands - -```bash -# Create database from scratch (DDL only) -./scripts/create-database.sh - -# Drop database -./scripts/drop-database.sh - -# Reset (drop + create DDL + seeds dev) - RECOMENDADO -./scripts/reset-database.sh # Pide confirmación -./scripts/reset-database.sh --force # Sin confirmación (CI/CD) -./scripts/reset-database.sh --no-seeds # Solo DDL, sin seeds -./scripts/reset-database.sh --env prod # Seeds de producción - -# Load seeds manualmente -./scripts/load-seeds.sh dev # Development -./scripts/load-seeds.sh prod # Production -``` - -> **NOTA:** No se usan migrations. Ver `DIRECTIVA-POLITICA-CARGA-LIMPIA.md` para detalles. - -## Statistics - -- **Schemas:** 12 -- **Tables:** 144 (118 base + 26 extensiones) -- **DDL Files:** 15 -- **Functions:** 63 -- **Triggers:** 92 -- **Indexes:** 450+ -- **RLS Policies:** 85+ -- **ENUMs:** 64 -- **Lines of SQL:** ~10,000 - -## References - -- [ADR-007: Database Design](/docs/adr/ADR-007-database-design.md) -- [Gamilit Database Reference](/shared/reference/gamilit/database/) -- [Odoo Analysis](/docs/00-analisis-referencias/odoo/) diff --git a/ddl/00-prerequisites.sql b/ddl/00-prerequisites.sql deleted file mode 100644 index 7fc8d34..0000000 --- a/ddl/00-prerequisites.sql +++ /dev/null @@ -1,207 +0,0 @@ --- ============================================================================ --- ERP GENERIC - DATABASE PREREQUISITES --- ============================================================================ --- Version: 1.0.0 --- Description: Extensions, common types, and utility functions --- Execute: FIRST (before any schema) --- ============================================================================ - --- Enable required extensions -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -- UUID generation -CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- Password hashing -CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- Trigram similarity (fuzzy search) -CREATE EXTENSION IF NOT EXISTS "unaccent"; -- Remove accents for search - --- ============================================================================ --- UTILITY FUNCTIONS --- ============================================================================ - --- Function: Update updated_at timestamp -CREATE OR REPLACE FUNCTION update_updated_at_column() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = CURRENT_TIMESTAMP; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION update_updated_at_column() IS -'Generic trigger function to auto-update updated_at timestamp on row modification'; - --- Function: Normalize text for search (remove accents, lowercase) -CREATE OR REPLACE FUNCTION normalize_search_text(p_text TEXT) -RETURNS TEXT AS $$ -BEGIN - RETURN LOWER(unaccent(COALESCE(p_text, ''))); -END; -$$ LANGUAGE plpgsql IMMUTABLE; - -COMMENT ON FUNCTION normalize_search_text(TEXT) IS -'Normalize text for search by removing accents and converting to lowercase'; - --- Function: Generate random alphanumeric code -CREATE OR REPLACE FUNCTION generate_random_code(p_length INTEGER DEFAULT 8) -RETURNS TEXT AS $$ -DECLARE - chars TEXT := 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - result TEXT := ''; - i INTEGER; -BEGIN - FOR i IN 1..p_length LOOP - result := result || substr(chars, floor(random() * length(chars) + 1)::INTEGER, 1); - END LOOP; - RETURN result; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION generate_random_code(INTEGER) IS -'Generate random alphanumeric code of specified length (default 8)'; - --- Function: Validate email format -CREATE OR REPLACE FUNCTION is_valid_email(p_email TEXT) -RETURNS BOOLEAN AS $$ -BEGIN - RETURN p_email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'; -END; -$$ LANGUAGE plpgsql IMMUTABLE; - -COMMENT ON FUNCTION is_valid_email(TEXT) IS -'Validate email format using regex'; - --- Function: Validate phone number format (basic) -CREATE OR REPLACE FUNCTION is_valid_phone(p_phone TEXT) -RETURNS BOOLEAN AS $$ -BEGIN - -- Basic validation: only digits, spaces, dashes, parentheses, plus sign - RETURN p_phone ~ '^[\d\s\-\(\)\+]+$' AND length(regexp_replace(p_phone, '[^\d]', '', 'g')) >= 7; -END; -$$ LANGUAGE plpgsql IMMUTABLE; - -COMMENT ON FUNCTION is_valid_phone(TEXT) IS -'Validate phone number format (at least 7 digits)'; - --- Function: Clean phone number (keep only digits) -CREATE OR REPLACE FUNCTION clean_phone(p_phone TEXT) -RETURNS TEXT AS $$ -BEGIN - RETURN regexp_replace(COALESCE(p_phone, ''), '[^\d]', '', 'g'); -END; -$$ LANGUAGE plpgsql IMMUTABLE; - -COMMENT ON FUNCTION clean_phone(TEXT) IS -'Remove non-numeric characters from phone number'; - --- Function: Calculate age from date -CREATE OR REPLACE FUNCTION calculate_age(p_birthdate DATE) -RETURNS INTEGER AS $$ -BEGIN - IF p_birthdate IS NULL THEN - RETURN NULL; - END IF; - RETURN EXTRACT(YEAR FROM age(CURRENT_DATE, p_birthdate))::INTEGER; -END; -$$ LANGUAGE plpgsql IMMUTABLE; - -COMMENT ON FUNCTION calculate_age(DATE) IS -'Calculate age in years from birthdate'; - --- Function: Get current fiscal year start -CREATE OR REPLACE FUNCTION get_fiscal_year_start(p_date DATE DEFAULT CURRENT_DATE) -RETURNS DATE AS $$ -BEGIN - -- Assuming fiscal year starts January 1st - -- Modify if different fiscal year start is needed - RETURN DATE_TRUNC('year', p_date)::DATE; -END; -$$ LANGUAGE plpgsql IMMUTABLE; - -COMMENT ON FUNCTION get_fiscal_year_start(DATE) IS -'Get the start date of fiscal year for a given date (default: January 1st)'; - --- Function: Round to decimal places -CREATE OR REPLACE FUNCTION round_currency(p_amount NUMERIC, p_decimals INTEGER DEFAULT 2) -RETURNS NUMERIC AS $$ -BEGIN - RETURN ROUND(COALESCE(p_amount, 0), p_decimals); -END; -$$ LANGUAGE plpgsql IMMUTABLE; - -COMMENT ON FUNCTION round_currency(NUMERIC, INTEGER) IS -'Round numeric value to specified decimal places (default 2 for currency)'; - --- ============================================================================ --- COMMON TYPES --- ============================================================================ - --- Type: Money with currency (for multi-currency support) -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'money_amount') THEN - CREATE TYPE money_amount AS ( - amount NUMERIC(15, 2), - currency_code CHAR(3) - ); - END IF; -END $$; - -COMMENT ON TYPE money_amount IS -'Composite type for storing monetary values with currency code'; - --- Type: Address components -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'address_components') THEN - CREATE TYPE address_components AS ( - street VARCHAR(255), - street2 VARCHAR(255), - city VARCHAR(100), - state VARCHAR(100), - zip VARCHAR(20), - country_code CHAR(2) - ); - END IF; -END $$; - -COMMENT ON TYPE address_components IS -'Composite type for address components (street, city, state, zip, country)'; - --- ============================================================================ --- SCHEMA CREATION --- ============================================================================ - --- Create all schemas upfront to avoid circular dependency issues -CREATE SCHEMA IF NOT EXISTS auth; -CREATE SCHEMA IF NOT EXISTS core; -CREATE SCHEMA IF NOT EXISTS analytics; -CREATE SCHEMA IF NOT EXISTS financial; -CREATE SCHEMA IF NOT EXISTS inventory; -CREATE SCHEMA IF NOT EXISTS purchase; -CREATE SCHEMA IF NOT EXISTS sales; -CREATE SCHEMA IF NOT EXISTS projects; -CREATE SCHEMA IF NOT EXISTS system; - --- Set search path to include all schemas -ALTER DATABASE erp_generic SET search_path TO public, auth, core, analytics, financial, inventory, purchase, sales, projects, system; - --- Grant usage on schemas to public role (will be refined per-user later) -GRANT USAGE ON SCHEMA auth TO PUBLIC; -GRANT USAGE ON SCHEMA core TO PUBLIC; -GRANT USAGE ON SCHEMA analytics TO PUBLIC; -GRANT USAGE ON SCHEMA financial TO PUBLIC; -GRANT USAGE ON SCHEMA inventory TO PUBLIC; -GRANT USAGE ON SCHEMA purchase TO PUBLIC; -GRANT USAGE ON SCHEMA sales TO PUBLIC; -GRANT USAGE ON SCHEMA projects TO PUBLIC; -GRANT USAGE ON SCHEMA system TO PUBLIC; - --- ============================================================================ --- PREREQUISITES COMPLETE --- ============================================================================ - -DO $$ -BEGIN - RAISE NOTICE 'Prerequisites installed successfully!'; - RAISE NOTICE 'Extensions: uuid-ossp, pgcrypto, pg_trgm, unaccent'; - RAISE NOTICE 'Schemas created: auth, core, analytics, financial, inventory, purchase, sales, projects, system'; - RAISE NOTICE 'Utility functions: 9 functions installed'; -END $$; diff --git a/ddl/01-auth-extensions.sql b/ddl/01-auth-extensions.sql deleted file mode 100644 index dc0a46c..0000000 --- a/ddl/01-auth-extensions.sql +++ /dev/null @@ -1,891 +0,0 @@ --- ===================================================== --- SCHEMA: auth (Extensiones) --- PROPÓSITO: 2FA, API Keys, OAuth2, Grupos, ACL, Record Rules --- MÓDULOS: MGN-001 (Fundamentos), MGN-002 (Usuarios), MGN-003 (Roles) --- FECHA: 2025-12-08 --- VERSION: 1.0.0 --- DEPENDENCIAS: 01-auth.sql --- SPECS RELACIONADAS: --- - SPEC-TWO-FACTOR-AUTHENTICATION.md --- - SPEC-SEGURIDAD-API-KEYS-PERMISOS.md --- - SPEC-OAUTH2-SOCIAL-LOGIN.md --- ===================================================== - --- ===================================================== --- PARTE 1: GROUPS Y HERENCIA --- ===================================================== - --- Tabla: groups (Grupos de usuarios con herencia) -CREATE TABLE auth.groups ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - code VARCHAR(100) NOT NULL, - name VARCHAR(255) NOT NULL, - description TEXT, - - -- Configuración - is_system BOOLEAN NOT NULL DEFAULT FALSE, -- Grupos del sistema no editables - category VARCHAR(100), -- Categoría para agrupación (ventas, compras, etc.) - color VARCHAR(20), - - -- API Keys - api_key_max_duration_days INTEGER DEFAULT 30 - CHECK (api_key_max_duration_days >= 0), -- 0 = sin expiración (solo grupos system) - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMP, - updated_by UUID REFERENCES auth.users(id), - deleted_at TIMESTAMP, - deleted_by UUID REFERENCES auth.users(id), - - CONSTRAINT uq_groups_code_tenant UNIQUE (tenant_id, code) -); - --- Tabla: group_implied (Herencia de grupos) -CREATE TABLE auth.group_implied ( - group_id UUID NOT NULL REFERENCES auth.groups(id) ON DELETE CASCADE, - implied_group_id UUID NOT NULL REFERENCES auth.groups(id) ON DELETE CASCADE, - - PRIMARY KEY (group_id, implied_group_id), - CONSTRAINT chk_group_no_self_imply CHECK (group_id != implied_group_id) -); - --- Tabla: user_groups (Many-to-Many usuarios-grupos) -CREATE TABLE auth.user_groups ( - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - group_id UUID NOT NULL REFERENCES auth.groups(id) ON DELETE CASCADE, - assigned_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - assigned_by UUID REFERENCES auth.users(id), - - PRIMARY KEY (user_id, group_id) -); - --- Índices para groups -CREATE INDEX idx_groups_tenant_id ON auth.groups(tenant_id); -CREATE INDEX idx_groups_code ON auth.groups(code); -CREATE INDEX idx_groups_category ON auth.groups(category); -CREATE INDEX idx_groups_is_system ON auth.groups(is_system); - --- Índices para user_groups -CREATE INDEX idx_user_groups_user_id ON auth.user_groups(user_id); -CREATE INDEX idx_user_groups_group_id ON auth.user_groups(group_id); - --- ===================================================== --- PARTE 2: MODELS Y ACL (Access Control Lists) --- ===================================================== - --- Tabla: models (Definición de modelos del sistema) -CREATE TABLE auth.models ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - name VARCHAR(128) NOT NULL, -- Nombre técnico (ej: 'sale.order') - description VARCHAR(255), -- Descripción legible - module VARCHAR(64), -- Módulo al que pertenece - is_transient BOOLEAN NOT NULL DEFAULT FALSE, -- Modelo temporal - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP, - - CONSTRAINT uq_models_name_tenant UNIQUE (tenant_id, name) -); - --- Tabla: model_access (Permisos CRUD por modelo y grupo) -CREATE TABLE auth.model_access ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - name VARCHAR(255) NOT NULL, -- Identificador legible - - model_id UUID NOT NULL REFERENCES auth.models(id) ON DELETE CASCADE, - group_id UUID REFERENCES auth.groups(id) ON DELETE RESTRICT, -- NULL = global - - -- Permisos CRUD - perm_read BOOLEAN NOT NULL DEFAULT FALSE, - perm_create BOOLEAN NOT NULL DEFAULT FALSE, - perm_write BOOLEAN NOT NULL DEFAULT FALSE, - perm_delete BOOLEAN NOT NULL DEFAULT FALSE, - - is_active BOOLEAN NOT NULL DEFAULT TRUE, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP, - - -- Un grupo solo puede tener un registro por modelo - CONSTRAINT uq_model_access_model_group UNIQUE (model_id, group_id, tenant_id) -); - --- Índices para models -CREATE INDEX idx_models_name ON auth.models(name); -CREATE INDEX idx_models_tenant ON auth.models(tenant_id); -CREATE INDEX idx_models_module ON auth.models(module); - --- Índices para model_access -CREATE INDEX idx_model_access_model ON auth.model_access(model_id); -CREATE INDEX idx_model_access_group ON auth.model_access(group_id); -CREATE INDEX idx_model_access_active ON auth.model_access(is_active) WHERE is_active = TRUE; - --- ===================================================== --- PARTE 3: RECORD RULES (Row-Level Security) --- ===================================================== - --- Tabla: record_rules (Reglas de acceso a nivel de registro) -CREATE TABLE auth.record_rules ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - name VARCHAR(255) NOT NULL, - - model_id UUID NOT NULL REFERENCES auth.models(id) ON DELETE CASCADE, - - -- Dominio como expresión JSON - domain_expression JSONB NOT NULL, -- [["company_id", "in", "user.company_ids"]] - - -- Permisos afectados - perm_read BOOLEAN NOT NULL DEFAULT TRUE, - perm_create BOOLEAN NOT NULL DEFAULT TRUE, - perm_write BOOLEAN NOT NULL DEFAULT TRUE, - perm_delete BOOLEAN NOT NULL DEFAULT TRUE, - - -- Regla global (sin grupos = aplica a todos) - is_global BOOLEAN NOT NULL DEFAULT FALSE, - - is_active BOOLEAN NOT NULL DEFAULT TRUE, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP -); - --- Tabla: rule_groups (Relación M:N entre rules y groups) -CREATE TABLE auth.rule_groups ( - rule_id UUID NOT NULL REFERENCES auth.record_rules(id) ON DELETE CASCADE, - group_id UUID NOT NULL REFERENCES auth.groups(id) ON DELETE CASCADE, - - PRIMARY KEY (rule_id, group_id) -); - --- Índices para record_rules -CREATE INDEX idx_record_rules_model ON auth.record_rules(model_id); -CREATE INDEX idx_record_rules_global ON auth.record_rules(is_global) WHERE is_global = TRUE; -CREATE INDEX idx_record_rules_active ON auth.record_rules(is_active) WHERE is_active = TRUE; - --- Índices para rule_groups -CREATE INDEX idx_rule_groups_rule ON auth.rule_groups(rule_id); -CREATE INDEX idx_rule_groups_group ON auth.rule_groups(group_id); - --- ===================================================== --- PARTE 4: FIELD PERMISSIONS --- ===================================================== - --- Tabla: model_fields (Campos del modelo con metadatos de seguridad) -CREATE TABLE auth.model_fields ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - model_id UUID NOT NULL REFERENCES auth.models(id) ON DELETE CASCADE, - - name VARCHAR(128) NOT NULL, -- Nombre técnico del campo - field_type VARCHAR(64) NOT NULL, -- Tipo: char, int, many2one, etc. - description VARCHAR(255), -- Etiqueta legible - - -- Seguridad por defecto - is_readonly BOOLEAN NOT NULL DEFAULT FALSE, - is_required BOOLEAN NOT NULL DEFAULT FALSE, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT uq_model_field UNIQUE (model_id, name, tenant_id) -); - --- Tabla: field_permissions (Permisos de campo por grupo) -CREATE TABLE auth.field_permissions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - field_id UUID NOT NULL REFERENCES auth.model_fields(id) ON DELETE CASCADE, - group_id UUID NOT NULL REFERENCES auth.groups(id) ON DELETE CASCADE, - - -- Permisos - can_read BOOLEAN NOT NULL DEFAULT TRUE, - can_write BOOLEAN NOT NULL DEFAULT FALSE, - - CONSTRAINT uq_field_permission UNIQUE (field_id, group_id, tenant_id) -); - --- Índices para model_fields -CREATE INDEX idx_model_fields_model ON auth.model_fields(model_id); -CREATE INDEX idx_model_fields_name ON auth.model_fields(name); - --- Índices para field_permissions -CREATE INDEX idx_field_permissions_field ON auth.field_permissions(field_id); -CREATE INDEX idx_field_permissions_group ON auth.field_permissions(group_id); - --- ===================================================== --- PARTE 5: API KEYS --- ===================================================== - --- Tabla: api_keys (Autenticación para integraciones) -CREATE TABLE auth.api_keys ( - 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, - - -- Descripción - name VARCHAR(255) NOT NULL, -- Descripción del propósito - - -- Seguridad - key_index VARCHAR(16) NOT NULL, -- Primeros 8 bytes del key (para lookup rápido) - key_hash VARCHAR(255) NOT NULL, -- Hash PBKDF2-SHA512 del key completo - - -- Scope y restricciones - scope VARCHAR(100), -- NULL = acceso completo, 'rpc' = solo API - allowed_ips INET[], -- IPs permitidas (opcional) - - -- Expiración - expiration_date TIMESTAMPTZ, -- NULL = sin expiración (solo system users) - last_used_at TIMESTAMPTZ, -- Último uso - - -- Estado - is_active BOOLEAN NOT NULL DEFAULT TRUE, - - -- Auditoría - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - revoked_at TIMESTAMPTZ, - revoked_by UUID REFERENCES auth.users(id), - - -- Constraints - CONSTRAINT chk_key_index_length CHECK (LENGTH(key_index) = 16) -); - --- Índices para API Keys -CREATE INDEX idx_api_keys_lookup ON auth.api_keys (key_index, is_active) - WHERE is_active = TRUE; -CREATE INDEX idx_api_keys_expiration ON auth.api_keys (expiration_date) - WHERE expiration_date IS NOT NULL; -CREATE INDEX idx_api_keys_user ON auth.api_keys (user_id); -CREATE INDEX idx_api_keys_tenant ON auth.api_keys (tenant_id); - --- ===================================================== --- PARTE 6: TWO-FACTOR AUTHENTICATION (2FA) --- ===================================================== - --- Extensión de users para MFA -ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS - mfa_enabled BOOLEAN NOT NULL DEFAULT FALSE; - -ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS - mfa_method VARCHAR(16) DEFAULT 'none' - CHECK (mfa_method IN ('none', 'totp', 'sms', 'email')); - -ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS - mfa_secret BYTEA; -- Secreto TOTP encriptado con AES-256-GCM - -ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS - backup_codes JSONB DEFAULT '[]'; -- Códigos de respaldo (array de hashes SHA-256) - -ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS - backup_codes_count INTEGER NOT NULL DEFAULT 0; - -ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS - mfa_setup_at TIMESTAMPTZ; - -ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS - last_2fa_verification TIMESTAMPTZ; - --- Constraint de consistencia MFA -ALTER TABLE auth.users ADD CONSTRAINT chk_mfa_consistency CHECK ( - (mfa_enabled = TRUE AND mfa_secret IS NOT NULL AND mfa_method != 'none') OR - (mfa_enabled = FALSE) -); - --- Índice para usuarios con MFA -CREATE INDEX idx_users_mfa_enabled ON auth.users(mfa_enabled) WHERE mfa_enabled = TRUE; - --- Tabla: trusted_devices (Dispositivos de confianza) -CREATE TABLE auth.trusted_devices ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Relación con usuario - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - - -- Identificación del dispositivo - device_fingerprint VARCHAR(128) NOT NULL, - device_name VARCHAR(128), -- "iPhone de Juan", "Chrome en MacBook" - device_type VARCHAR(32), -- 'mobile', 'desktop', 'tablet' - - -- Información del dispositivo - user_agent TEXT, - browser_name VARCHAR(64), - browser_version VARCHAR(32), - os_name VARCHAR(64), - os_version VARCHAR(32), - - -- Ubicación del registro - registered_ip INET NOT NULL, - registered_location JSONB, -- {country, city, lat, lng} - - -- Estado de confianza - is_active BOOLEAN NOT NULL DEFAULT TRUE, - trust_level VARCHAR(16) NOT NULL DEFAULT 'standard' - CHECK (trust_level IN ('standard', 'high', 'temporary')), - trust_expires_at TIMESTAMPTZ, -- NULL = no expira - - -- Uso - last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - last_used_ip INET, - use_count INTEGER NOT NULL DEFAULT 1, - - -- Auditoría - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - revoked_at TIMESTAMPTZ, - revoked_reason VARCHAR(128), - - -- Constraints - CONSTRAINT uk_trusted_device_user_fingerprint UNIQUE (user_id, device_fingerprint) -); - --- Índices para trusted_devices -CREATE INDEX idx_trusted_devices_user ON auth.trusted_devices(user_id) WHERE is_active; -CREATE INDEX idx_trusted_devices_fingerprint ON auth.trusted_devices(device_fingerprint); -CREATE INDEX idx_trusted_devices_expires ON auth.trusted_devices(trust_expires_at) - WHERE trust_expires_at IS NOT NULL AND is_active; - --- Tabla: verification_codes (Códigos de verificación temporales) -CREATE TABLE auth.verification_codes ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Relaciones - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - session_id UUID REFERENCES auth.sessions(id) ON DELETE CASCADE, - - -- Tipo de código - code_type VARCHAR(16) NOT NULL - CHECK (code_type IN ('totp_setup', 'sms', 'email', 'backup')), - - -- Código (hash SHA-256) - code_hash VARCHAR(64) NOT NULL, - code_length INTEGER NOT NULL DEFAULT 6, - - -- Destino (para SMS/Email) - destination VARCHAR(256), -- Teléfono o email - - -- Intentos - attempts INTEGER NOT NULL DEFAULT 0, - max_attempts INTEGER NOT NULL DEFAULT 5, - - -- Validez - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - expires_at TIMESTAMPTZ NOT NULL, - used_at TIMESTAMPTZ, - - -- Metadata - ip_address INET, - user_agent TEXT, - - -- Constraint - CONSTRAINT chk_code_not_expired CHECK (used_at IS NULL OR used_at <= expires_at) -); - --- Índices para verification_codes -CREATE INDEX idx_verification_codes_user ON auth.verification_codes(user_id, code_type) - WHERE used_at IS NULL; -CREATE INDEX idx_verification_codes_expires ON auth.verification_codes(expires_at) - WHERE used_at IS NULL; - --- Tabla: mfa_audit_log (Log de auditoría MFA) -CREATE TABLE auth.mfa_audit_log ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Usuario - user_id UUID NOT NULL REFERENCES auth.users(id), - - -- Evento - event_type VARCHAR(32) NOT NULL - CHECK (event_type IN ( - 'mfa_setup_initiated', - 'mfa_setup_completed', - 'mfa_disabled', - 'totp_verified', - 'totp_failed', - 'backup_code_used', - 'backup_codes_regenerated', - 'device_trusted', - 'device_revoked', - 'anomaly_detected', - 'account_locked', - 'account_unlocked' - )), - - -- Resultado - success BOOLEAN NOT NULL, - failure_reason VARCHAR(128), - - -- Contexto - ip_address INET, - user_agent TEXT, - device_fingerprint VARCHAR(128), - location JSONB, - - -- Metadata adicional - metadata JSONB DEFAULT '{}', - - -- Timestamp - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Índices para mfa_audit_log -CREATE INDEX idx_mfa_audit_user ON auth.mfa_audit_log(user_id, created_at DESC); -CREATE INDEX idx_mfa_audit_event ON auth.mfa_audit_log(event_type, created_at DESC); -CREATE INDEX idx_mfa_audit_failures ON auth.mfa_audit_log(user_id, created_at DESC) - WHERE success = FALSE; - --- ===================================================== --- PARTE 7: OAUTH2 PROVIDERS --- ===================================================== - --- Tabla: oauth_providers (Proveedores OAuth2) -CREATE TABLE auth.oauth_providers ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID REFERENCES auth.tenants(id) ON DELETE CASCADE, -- NULL = global - - code VARCHAR(50) NOT NULL, - name VARCHAR(100) NOT NULL, - - -- Configuración OAuth2 - client_id VARCHAR(255) NOT NULL, - client_secret VARCHAR(500), -- Encriptado con AES-256 - - -- Endpoints OAuth2 - authorization_endpoint VARCHAR(500) NOT NULL, - token_endpoint VARCHAR(500) NOT NULL, - userinfo_endpoint VARCHAR(500) NOT NULL, - jwks_uri VARCHAR(500), -- Para validación de ID tokens - - -- Scopes y parámetros - scope VARCHAR(500) NOT NULL DEFAULT 'openid profile email', - response_type VARCHAR(50) NOT NULL DEFAULT 'code', - - -- PKCE Configuration - pkce_enabled BOOLEAN NOT NULL DEFAULT TRUE, - code_challenge_method VARCHAR(10) DEFAULT 'S256', - - -- Mapeo de claims - claim_mapping JSONB NOT NULL DEFAULT '{ - "sub": "oauth_uid", - "email": "email", - "name": "name", - "picture": "avatar_url" - }'::jsonb, - - -- UI - icon_class VARCHAR(100), -- fa-google, fa-microsoft, etc. - button_text VARCHAR(100), - button_color VARCHAR(20), - display_order INTEGER NOT NULL DEFAULT 10, - - -- Estado - is_enabled BOOLEAN NOT NULL DEFAULT FALSE, - is_visible BOOLEAN NOT NULL DEFAULT TRUE, - - -- Restricciones - allowed_domains TEXT[], -- NULL = todos permitidos - auto_create_users BOOLEAN NOT NULL DEFAULT FALSE, - default_role_id UUID REFERENCES auth.roles(id), - - -- Auditoría - created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_by UUID REFERENCES auth.users(id), - - -- Constraints - CONSTRAINT uq_oauth_provider_code UNIQUE (code), - CONSTRAINT chk_response_type CHECK (response_type IN ('code', 'token')), - CONSTRAINT chk_pkce_method CHECK (code_challenge_method IN ('S256', 'plain')) -); - --- Índices para oauth_providers -CREATE INDEX idx_oauth_providers_enabled ON auth.oauth_providers(is_enabled); -CREATE INDEX idx_oauth_providers_tenant ON auth.oauth_providers(tenant_id); -CREATE INDEX idx_oauth_providers_code ON auth.oauth_providers(code); - --- Tabla: oauth_user_links (Vinculación usuario-proveedor) -CREATE TABLE auth.oauth_user_links ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - provider_id UUID NOT NULL REFERENCES auth.oauth_providers(id) ON DELETE CASCADE, - - -- Identificación OAuth - oauth_uid VARCHAR(255) NOT NULL, -- Subject ID del proveedor - oauth_email VARCHAR(255), - - -- Tokens (encriptados) - access_token TEXT, - refresh_token TEXT, - id_token TEXT, - token_expires_at TIMESTAMPTZ, - - -- Metadata - raw_userinfo JSONB, -- Datos completos del proveedor - last_login_at TIMESTAMPTZ, - login_count INTEGER NOT NULL DEFAULT 0, - - -- Auditoría - created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - - -- Constraints - CONSTRAINT uq_provider_oauth_uid UNIQUE (provider_id, oauth_uid), - CONSTRAINT uq_user_provider UNIQUE (user_id, provider_id) -); - --- Índices para oauth_user_links -CREATE INDEX idx_oauth_links_user ON auth.oauth_user_links(user_id); -CREATE INDEX idx_oauth_links_provider ON auth.oauth_user_links(provider_id); -CREATE INDEX idx_oauth_links_oauth_uid ON auth.oauth_user_links(oauth_uid); - --- Tabla: oauth_states (Estados OAuth2 temporales para CSRF) -CREATE TABLE auth.oauth_states ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - state VARCHAR(64) NOT NULL UNIQUE, - - -- PKCE - code_verifier VARCHAR(128), - - -- Contexto - provider_id UUID NOT NULL REFERENCES auth.oauth_providers(id), - redirect_uri VARCHAR(500) NOT NULL, - return_url VARCHAR(500), - - -- Vinculación con usuario existente (para linking) - link_user_id UUID REFERENCES auth.users(id), - - -- Metadata - ip_address INET, - user_agent TEXT, - - -- Tiempo de vida - created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - expires_at TIMESTAMPTZ NOT NULL DEFAULT (CURRENT_TIMESTAMP + INTERVAL '10 minutes'), - used_at TIMESTAMPTZ, - - -- Constraints - CONSTRAINT chk_state_not_expired CHECK (expires_at > created_at) -); - --- Índices para oauth_states -CREATE INDEX idx_oauth_states_state ON auth.oauth_states(state); -CREATE INDEX idx_oauth_states_expires ON auth.oauth_states(expires_at); - --- Extensión de users para OAuth -ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS - oauth_only BOOLEAN NOT NULL DEFAULT FALSE; - -ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS - primary_oauth_provider_id UUID REFERENCES auth.oauth_providers(id); - --- ===================================================== --- PARTE 8: FUNCIONES DE UTILIDAD --- ===================================================== - --- Función: Obtener grupos efectivos de un usuario (incluyendo herencia) -CREATE OR REPLACE FUNCTION auth.get_user_effective_groups(p_user_id UUID) -RETURNS TABLE(group_id UUID) AS $$ -WITH RECURSIVE effective_groups AS ( - -- Grupos asignados directamente - SELECT ug.group_id - FROM auth.user_groups ug - WHERE ug.user_id = p_user_id - - UNION - - -- Grupos heredados - SELECT gi.implied_group_id - FROM auth.group_implied gi - JOIN effective_groups eg ON gi.group_id = eg.group_id -) -SELECT DISTINCT group_id FROM effective_groups; -$$ LANGUAGE SQL STABLE; - -COMMENT ON FUNCTION auth.get_user_effective_groups IS 'Obtiene todos los grupos de un usuario incluyendo herencia'; - --- Función: Verificar permiso ACL -CREATE OR REPLACE FUNCTION auth.check_model_access( - p_user_id UUID, - p_model_name VARCHAR, - p_mode VARCHAR -- 'read', 'create', 'write', 'delete' -) -RETURNS BOOLEAN AS $$ -DECLARE - v_has_access BOOLEAN; -BEGIN - -- Superusers tienen todos los permisos - IF EXISTS ( - SELECT 1 FROM auth.users - WHERE id = p_user_id AND is_superuser = TRUE AND deleted_at IS NULL - ) THEN - RETURN TRUE; - END IF; - - -- Verificar ACL - SELECT EXISTS ( - SELECT 1 - FROM auth.model_access ma - JOIN auth.models m ON ma.model_id = m.id - WHERE m.name = p_model_name - AND ma.is_active = TRUE - AND ( - ma.group_id IS NULL -- Permiso global - OR ma.group_id IN (SELECT auth.get_user_effective_groups(p_user_id)) - ) - AND CASE p_mode - WHEN 'read' THEN ma.perm_read - WHEN 'create' THEN ma.perm_create - WHEN 'write' THEN ma.perm_write - WHEN 'delete' THEN ma.perm_delete - ELSE FALSE - END - ) INTO v_has_access; - - RETURN COALESCE(v_has_access, FALSE); -END; -$$ LANGUAGE plpgsql STABLE SECURITY DEFINER; - -COMMENT ON FUNCTION auth.check_model_access IS 'Verifica si un usuario tiene permiso CRUD en un modelo'; - --- Función: Limpiar estados OAuth expirados -CREATE OR REPLACE FUNCTION auth.cleanup_expired_oauth_states() -RETURNS INTEGER AS $$ -DECLARE - v_deleted INTEGER; -BEGIN - WITH deleted AS ( - DELETE FROM auth.oauth_states - WHERE expires_at < CURRENT_TIMESTAMP - OR used_at IS NOT NULL - RETURNING id - ) - SELECT COUNT(*) INTO v_deleted FROM deleted; - - RETURN v_deleted; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION auth.cleanup_expired_oauth_states IS 'Limpia estados OAuth expirados (ejecutar periódicamente)'; - --- Función: Limpiar códigos de verificación expirados -CREATE OR REPLACE FUNCTION auth.cleanup_expired_verification_codes() -RETURNS INTEGER AS $$ -DECLARE - v_deleted INTEGER; -BEGIN - WITH deleted AS ( - DELETE FROM auth.verification_codes - WHERE expires_at < NOW() - INTERVAL '1 day' - RETURNING id - ) - SELECT COUNT(*) INTO v_deleted FROM deleted; - - RETURN v_deleted; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION auth.cleanup_expired_verification_codes IS 'Limpia códigos de verificación expirados'; - --- Función: Limpiar dispositivos de confianza expirados -CREATE OR REPLACE FUNCTION auth.cleanup_expired_trusted_devices() -RETURNS INTEGER AS $$ -DECLARE - v_deleted INTEGER; -BEGIN - WITH updated AS ( - UPDATE auth.trusted_devices - SET is_active = FALSE, - revoked_at = NOW(), - revoked_reason = 'expired' - WHERE trust_expires_at < NOW() - INTERVAL '7 days' - AND trust_expires_at IS NOT NULL - AND is_active = TRUE - RETURNING id - ) - SELECT COUNT(*) INTO v_deleted FROM updated; - - RETURN v_deleted; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION auth.cleanup_expired_trusted_devices IS 'Desactiva dispositivos de confianza expirados'; - --- Función: Limpiar API keys expiradas -CREATE OR REPLACE FUNCTION auth.cleanup_expired_api_keys() -RETURNS INTEGER AS $$ -DECLARE - v_deleted INTEGER; -BEGIN - WITH deleted AS ( - DELETE FROM auth.api_keys - WHERE expiration_date IS NOT NULL - AND expiration_date < NOW() - RETURNING id - ) - SELECT COUNT(*) INTO v_deleted FROM deleted; - - RETURN v_deleted; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION auth.cleanup_expired_api_keys IS 'Limpia API keys expiradas'; - --- ===================================================== --- PARTE 9: TRIGGERS --- ===================================================== - --- Trigger: Actualizar updated_at para grupos -CREATE TRIGGER trg_groups_updated_at - BEFORE UPDATE ON auth.groups - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at_column(); - --- Trigger: Actualizar updated_at para oauth_providers -CREATE TRIGGER trg_oauth_providers_updated_at - BEFORE UPDATE ON auth.oauth_providers - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at_column(); - --- Trigger: Actualizar updated_at para oauth_user_links -CREATE OR REPLACE FUNCTION auth.update_oauth_link_updated_at() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = CURRENT_TIMESTAMP; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER trg_oauth_user_links_updated_at - BEFORE UPDATE ON auth.oauth_user_links - FOR EACH ROW - EXECUTE FUNCTION auth.update_oauth_link_updated_at(); - --- ===================================================== --- PARTE 10: VISTAS --- ===================================================== - --- Vista: Usuarios con sus proveedores OAuth vinculados -CREATE OR REPLACE VIEW auth.users_oauth_summary AS -SELECT - u.id, - u.email, - u.full_name, - u.oauth_only, - COUNT(ol.id) as linked_providers_count, - ARRAY_AGG(op.name) FILTER (WHERE op.id IS NOT NULL) as linked_provider_names, - MAX(ol.last_login_at) as last_oauth_login -FROM auth.users u -LEFT JOIN auth.oauth_user_links ol ON ol.user_id = u.id -LEFT JOIN auth.oauth_providers op ON op.id = ol.provider_id -WHERE u.deleted_at IS NULL -GROUP BY u.id; - -COMMENT ON VIEW auth.users_oauth_summary IS 'Vista de usuarios con sus proveedores OAuth vinculados'; - --- Vista: Permisos efectivos por usuario y modelo -CREATE OR REPLACE VIEW auth.user_model_access_view AS -SELECT DISTINCT - u.id as user_id, - u.email, - m.name as model_name, - BOOL_OR(ma.perm_read) as can_read, - BOOL_OR(ma.perm_create) as can_create, - BOOL_OR(ma.perm_write) as can_write, - BOOL_OR(ma.perm_delete) as can_delete -FROM auth.users u -CROSS JOIN auth.models m -LEFT JOIN auth.user_groups ug ON ug.user_id = u.id -LEFT JOIN auth.model_access ma ON ma.model_id = m.id - AND (ma.group_id IS NULL OR ma.group_id = ug.group_id) - AND ma.is_active = TRUE -WHERE u.deleted_at IS NULL -GROUP BY u.id, u.email, m.name; - -COMMENT ON VIEW auth.user_model_access_view IS 'Vista de permisos ACL efectivos por usuario y modelo'; - --- ===================================================== --- PARTE 11: DATOS INICIALES --- ===================================================== - --- Proveedores OAuth2 preconfigurados (template) --- NOTA: Solo se insertan como template, requieren client_id y client_secret -INSERT INTO auth.oauth_providers ( - code, name, - authorization_endpoint, token_endpoint, userinfo_endpoint, jwks_uri, - scope, icon_class, button_text, button_color, - claim_mapping, display_order, is_enabled, client_id -) VALUES --- Google -( - 'google', 'Google', - 'https://accounts.google.com/o/oauth2/v2/auth', - 'https://oauth2.googleapis.com/token', - 'https://openidconnect.googleapis.com/v1/userinfo', - 'https://www.googleapis.com/oauth2/v3/certs', - 'openid profile email', - 'fa-google', 'Continuar con Google', '#4285F4', - '{"sub": "oauth_uid", "email": "email", "name": "name", "picture": "avatar_url"}', - 1, FALSE, 'CONFIGURE_ME' -), --- Microsoft Azure AD -( - 'microsoft', 'Microsoft', - 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', - 'https://login.microsoftonline.com/common/oauth2/v2.0/token', - 'https://graph.microsoft.com/v1.0/me', - 'https://login.microsoftonline.com/common/discovery/v2.0/keys', - 'openid profile email User.Read', - 'fa-microsoft', 'Continuar con Microsoft', '#00A4EF', - '{"id": "oauth_uid", "mail": "email", "displayName": "name"}', - 2, FALSE, 'CONFIGURE_ME' -), --- GitHub -( - 'github', 'GitHub', - 'https://github.com/login/oauth/authorize', - 'https://github.com/login/oauth/access_token', - 'https://api.github.com/user', - NULL, - 'read:user user:email', - 'fa-github', 'Continuar con GitHub', '#333333', - '{"id": "oauth_uid", "email": "email", "name": "name", "avatar_url": "avatar_url"}', - 3, FALSE, 'CONFIGURE_ME' -) -ON CONFLICT (code) DO NOTHING; - --- ===================================================== --- COMENTARIOS EN TABLAS --- ===================================================== - -COMMENT ON TABLE auth.groups IS 'Grupos de usuarios con herencia para control de acceso'; -COMMENT ON TABLE auth.group_implied IS 'Herencia entre grupos (A implica B)'; -COMMENT ON TABLE auth.user_groups IS 'Asignación de usuarios a grupos (many-to-many)'; -COMMENT ON TABLE auth.models IS 'Definición de modelos del sistema para ACL'; -COMMENT ON TABLE auth.model_access IS 'Permisos CRUD a nivel de modelo por grupo (ACL)'; -COMMENT ON TABLE auth.record_rules IS 'Reglas de acceso a nivel de registro (row-level security)'; -COMMENT ON TABLE auth.rule_groups IS 'Relación entre record rules y grupos'; -COMMENT ON TABLE auth.model_fields IS 'Campos de modelo con metadatos de seguridad'; -COMMENT ON TABLE auth.field_permissions IS 'Permisos de lectura/escritura por campo y grupo'; -COMMENT ON TABLE auth.api_keys IS 'API Keys para autenticación de integraciones externas'; -COMMENT ON TABLE auth.trusted_devices IS 'Dispositivos de confianza para bypass de 2FA'; -COMMENT ON TABLE auth.verification_codes IS 'Códigos de verificación temporales para 2FA'; -COMMENT ON TABLE auth.mfa_audit_log IS 'Log de auditoría de eventos MFA'; -COMMENT ON TABLE auth.oauth_providers IS 'Proveedores OAuth2 configurados'; -COMMENT ON TABLE auth.oauth_user_links IS 'Vinculación de usuarios con proveedores OAuth'; -COMMENT ON TABLE auth.oauth_states IS 'Estados OAuth2 temporales para protección CSRF'; - -COMMENT ON COLUMN auth.api_keys.key_index IS 'Primeros 16 hex chars del key para lookup O(1)'; -COMMENT ON COLUMN auth.api_keys.key_hash IS 'Hash PBKDF2-SHA512 del key completo'; -COMMENT ON COLUMN auth.api_keys.scope IS 'Scope del API key (NULL=full, rpc=API only)'; -COMMENT ON COLUMN auth.groups.api_key_max_duration_days IS 'Máxima duración en días para API keys de usuarios de este grupo (0=ilimitado)'; - --- ===================================================== --- FIN DE EXTENSIONES AUTH --- ===================================================== diff --git a/ddl/01-auth-profiles.sql b/ddl/01-auth-profiles.sql new file mode 100644 index 0000000..3580f7a --- /dev/null +++ b/ddl/01-auth-profiles.sql @@ -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'; diff --git a/ddl/01-auth.sql b/ddl/01-auth.sql deleted file mode 100644 index afa85b1..0000000 --- a/ddl/01-auth.sql +++ /dev/null @@ -1,620 +0,0 @@ --- ===================================================== --- SCHEMA: auth --- PROPÓSITO: Autenticación, usuarios, roles, permisos --- MÓDULOS: MGN-001 (Fundamentos), MGN-002 (Empresas) --- FECHA: 2025-11-24 --- ===================================================== - --- Crear schema -CREATE SCHEMA IF NOT EXISTS auth; - --- ===================================================== --- TYPES (ENUMs) --- ===================================================== - -CREATE TYPE auth.user_status AS ENUM ( - 'active', - 'inactive', - 'suspended', - 'pending_verification' -); - -CREATE TYPE auth.tenant_status AS ENUM ( - 'active', - 'suspended', - 'trial', - 'cancelled' -); - -CREATE TYPE auth.session_status AS ENUM ( - 'active', - 'expired', - 'revoked' -); - -CREATE TYPE auth.permission_action AS ENUM ( - 'create', - 'read', - 'update', - 'delete', - 'approve', - 'cancel', - 'export' -); - --- ===================================================== --- TABLES --- ===================================================== - --- Tabla: tenants (Multi-Tenancy) -CREATE TABLE auth.tenants ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name VARCHAR(255) NOT NULL, - subdomain VARCHAR(100) UNIQUE NOT NULL, - schema_name VARCHAR(100) UNIQUE NOT NULL, - status auth.tenant_status NOT NULL DEFAULT 'active', - settings JSONB DEFAULT '{}', - plan VARCHAR(50) DEFAULT 'basic', -- basic, pro, enterprise - max_users INTEGER DEFAULT 10, - - -- Auditoría (tenant no tiene tenant_id) - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID, -- Puede ser NULL para primer tenant - updated_at TIMESTAMP, - updated_by UUID, - deleted_at TIMESTAMP, - deleted_by UUID, - - CONSTRAINT chk_tenants_subdomain_format CHECK (subdomain ~ '^[a-z0-9-]+$'), - CONSTRAINT chk_tenants_max_users CHECK (max_users > 0) -); - --- Tabla: companies (Multi-Company dentro de tenant) -CREATE TABLE auth.companies ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - name VARCHAR(255) NOT NULL, - legal_name VARCHAR(255), - tax_id VARCHAR(50), - currency_id UUID, -- FK a core.currencies (se crea después) - parent_company_id UUID REFERENCES auth.companies(id), - partner_id UUID, -- FK a core.partners (se crea después) - settings JSONB DEFAULT '{}', - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID, - updated_at TIMESTAMP, - updated_by UUID, - deleted_at TIMESTAMP, - deleted_by UUID, - - CONSTRAINT uq_companies_tax_id_tenant UNIQUE (tenant_id, tax_id), - CONSTRAINT chk_companies_no_self_parent CHECK (id != parent_company_id) -); - --- Tabla: users -CREATE TABLE auth.users ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - email VARCHAR(255) NOT NULL, - password_hash VARCHAR(255) NOT NULL, - full_name VARCHAR(255) NOT NULL, - avatar_url VARCHAR(500), - status auth.user_status NOT NULL DEFAULT 'active', - is_superuser BOOLEAN NOT NULL DEFAULT FALSE, - email_verified_at TIMESTAMP, - last_login_at TIMESTAMP, - last_login_ip INET, - login_count INTEGER DEFAULT 0, - language VARCHAR(10) DEFAULT 'es', -- es, en - timezone VARCHAR(50) DEFAULT 'America/Mexico_City', - settings JSONB DEFAULT '{}', - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID, - updated_at TIMESTAMP, - updated_by UUID, - deleted_at TIMESTAMP, - deleted_by UUID, - - CONSTRAINT uq_users_email_tenant UNIQUE (tenant_id, email), - CONSTRAINT chk_users_email_format CHECK (email ~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}$') -); - --- Tabla: roles -CREATE TABLE auth.roles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - name VARCHAR(100) NOT NULL, - code VARCHAR(50) NOT NULL, - description TEXT, - is_system BOOLEAN NOT NULL DEFAULT FALSE, -- Roles del sistema no editables - color VARCHAR(20), - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMP, - updated_by UUID REFERENCES auth.users(id), - deleted_at TIMESTAMP, - deleted_by UUID REFERENCES auth.users(id), - - CONSTRAINT uq_roles_code_tenant UNIQUE (tenant_id, code) -); - --- Tabla: permissions -CREATE TABLE auth.permissions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - resource VARCHAR(100) NOT NULL, -- Tabla/endpoint - action auth.permission_action NOT NULL, - description TEXT, - module VARCHAR(50), -- MGN-001, MGN-004, etc. - - -- Sin tenant_id: permisos son globales - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT uq_permissions_resource_action UNIQUE (resource, action) -); - --- Tabla: user_roles (many-to-many) -CREATE TABLE auth.user_roles ( - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - role_id UUID NOT NULL REFERENCES auth.roles(id) ON DELETE CASCADE, - assigned_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - assigned_by UUID REFERENCES auth.users(id), - - PRIMARY KEY (user_id, role_id) -); - --- Tabla: role_permissions (many-to-many) -CREATE TABLE auth.role_permissions ( - role_id UUID NOT NULL REFERENCES auth.roles(id) ON DELETE CASCADE, - permission_id UUID NOT NULL REFERENCES auth.permissions(id) ON DELETE CASCADE, - granted_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - granted_by UUID REFERENCES auth.users(id), - - PRIMARY KEY (role_id, permission_id) -); - --- Tabla: sessions -CREATE TABLE auth.sessions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - token VARCHAR(500) NOT NULL UNIQUE, - refresh_token VARCHAR(500) UNIQUE, - status auth.session_status NOT NULL DEFAULT 'active', - expires_at TIMESTAMP NOT NULL, - refresh_expires_at TIMESTAMP, - ip_address INET, - user_agent TEXT, - device_info JSONB, - - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - revoked_at TIMESTAMP, - revoked_reason VARCHAR(100), - - CONSTRAINT chk_sessions_expiration CHECK (expires_at > created_at), - CONSTRAINT chk_sessions_refresh_expiration CHECK ( - refresh_expires_at IS NULL OR refresh_expires_at > expires_at - ) -); - --- Tabla: user_companies (many-to-many) --- Usuario puede acceder a múltiples empresas -CREATE TABLE auth.user_companies ( - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - company_id UUID NOT NULL REFERENCES auth.companies(id) ON DELETE CASCADE, - is_default BOOLEAN DEFAULT FALSE, - assigned_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - - PRIMARY KEY (user_id, company_id) -); - --- Tabla: password_resets -CREATE TABLE auth.password_resets ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - token VARCHAR(500) NOT NULL UNIQUE, - expires_at TIMESTAMP NOT NULL, - used_at TIMESTAMP, - ip_address INET, - - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT chk_password_resets_expiration CHECK (expires_at > created_at) -); - --- ===================================================== --- INDICES --- ===================================================== - --- Tenants -CREATE INDEX idx_tenants_subdomain ON auth.tenants(subdomain); -CREATE INDEX idx_tenants_status ON auth.tenants(status) WHERE deleted_at IS NULL; -CREATE INDEX idx_tenants_created_at ON auth.tenants(created_at); - --- Companies -CREATE INDEX idx_companies_tenant_id ON auth.companies(tenant_id); -CREATE INDEX idx_companies_parent_company_id ON auth.companies(parent_company_id); -CREATE INDEX idx_companies_active ON auth.companies(tenant_id) WHERE deleted_at IS NULL; -CREATE INDEX idx_companies_tax_id ON auth.companies(tax_id); - --- Users -CREATE INDEX idx_users_tenant_id ON auth.users(tenant_id); -CREATE INDEX idx_users_email ON auth.users(email); -CREATE INDEX idx_users_status ON auth.users(status) WHERE deleted_at IS NULL; -CREATE INDEX idx_users_email_tenant ON auth.users(tenant_id, email); -CREATE INDEX idx_users_created_at ON auth.users(created_at); - --- Roles -CREATE INDEX idx_roles_tenant_id ON auth.roles(tenant_id); -CREATE INDEX idx_roles_code ON auth.roles(code); -CREATE INDEX idx_roles_is_system ON auth.roles(is_system); - --- Permissions -CREATE INDEX idx_permissions_resource ON auth.permissions(resource); -CREATE INDEX idx_permissions_action ON auth.permissions(action); -CREATE INDEX idx_permissions_module ON auth.permissions(module); - --- Sessions -CREATE INDEX idx_sessions_user_id ON auth.sessions(user_id); -CREATE INDEX idx_sessions_token ON auth.sessions(token); -CREATE INDEX idx_sessions_status ON auth.sessions(status); -CREATE INDEX idx_sessions_expires_at ON auth.sessions(expires_at); - --- User Roles -CREATE INDEX idx_user_roles_user_id ON auth.user_roles(user_id); -CREATE INDEX idx_user_roles_role_id ON auth.user_roles(role_id); - --- Role Permissions -CREATE INDEX idx_role_permissions_role_id ON auth.role_permissions(role_id); -CREATE INDEX idx_role_permissions_permission_id ON auth.role_permissions(permission_id); - --- User Companies -CREATE INDEX idx_user_companies_user_id ON auth.user_companies(user_id); -CREATE INDEX idx_user_companies_company_id ON auth.user_companies(company_id); - --- Password Resets -CREATE INDEX idx_password_resets_user_id ON auth.password_resets(user_id); -CREATE INDEX idx_password_resets_token ON auth.password_resets(token); -CREATE INDEX idx_password_resets_expires_at ON auth.password_resets(expires_at); - --- ===================================================== --- FUNCTIONS --- ===================================================== - --- Función: get_current_tenant_id -CREATE OR REPLACE FUNCTION get_current_tenant_id() -RETURNS UUID AS $$ -BEGIN - RETURN current_setting('app.current_tenant_id', true)::UUID; -EXCEPTION - WHEN OTHERS THEN - RETURN NULL; -END; -$$ LANGUAGE plpgsql STABLE SECURITY DEFINER; - -COMMENT ON FUNCTION get_current_tenant_id() IS 'Obtiene el tenant_id del contexto actual'; - --- Función: get_current_user_id -CREATE OR REPLACE FUNCTION get_current_user_id() -RETURNS UUID AS $$ -BEGIN - RETURN current_setting('app.current_user_id', true)::UUID; -EXCEPTION - WHEN OTHERS THEN - RETURN NULL; -END; -$$ LANGUAGE plpgsql STABLE SECURITY DEFINER; - -COMMENT ON FUNCTION get_current_user_id() IS 'Obtiene el user_id del contexto actual'; - --- Función: get_current_company_id -CREATE OR REPLACE FUNCTION get_current_company_id() -RETURNS UUID AS $$ -BEGIN - RETURN current_setting('app.current_company_id', true)::UUID; -EXCEPTION - WHEN OTHERS THEN - RETURN NULL; -END; -$$ LANGUAGE plpgsql STABLE SECURITY DEFINER; - -COMMENT ON FUNCTION get_current_company_id() IS 'Obtiene el company_id del contexto actual'; - --- Función: user_has_permission -CREATE OR REPLACE FUNCTION auth.user_has_permission( - p_user_id UUID, - p_resource VARCHAR, - p_action auth.permission_action -) -RETURNS BOOLEAN AS $$ -DECLARE - v_has_permission BOOLEAN; -BEGIN - -- Superusers tienen todos los permisos - IF EXISTS ( - SELECT 1 FROM auth.users - WHERE id = p_user_id AND is_superuser = TRUE AND deleted_at IS NULL - ) THEN - RETURN TRUE; - END IF; - - -- Verificar si el usuario tiene el permiso a través de sus roles - SELECT EXISTS ( - SELECT 1 - FROM auth.user_roles ur - JOIN auth.role_permissions rp ON ur.role_id = rp.role_id - JOIN auth.permissions p ON rp.permission_id = p.id - WHERE ur.user_id = p_user_id - AND p.resource = p_resource - AND p.action = p_action - ) INTO v_has_permission; - - RETURN v_has_permission; -END; -$$ LANGUAGE plpgsql STABLE SECURITY DEFINER; - -COMMENT ON FUNCTION auth.user_has_permission IS 'Verifica si un usuario tiene un permiso específico'; - --- Función: clean_expired_sessions -CREATE OR REPLACE FUNCTION auth.clean_expired_sessions() -RETURNS INTEGER AS $$ -DECLARE - v_deleted_count INTEGER; -BEGIN - WITH deleted AS ( - DELETE FROM auth.sessions - WHERE status = 'active' - AND expires_at < CURRENT_TIMESTAMP - RETURNING id - ) - SELECT COUNT(*) INTO v_deleted_count FROM deleted; - - RETURN v_deleted_count; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION auth.clean_expired_sessions IS 'Limpia sesiones expiradas (ejecutar periódicamente)'; - --- ===================================================== --- TRIGGERS --- ===================================================== - --- Trigger: Actualizar updated_at automáticamente -CREATE OR REPLACE FUNCTION auth.update_updated_at_column() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = CURRENT_TIMESTAMP; - NEW.updated_by = get_current_user_id(); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER trg_tenants_updated_at - BEFORE UPDATE ON auth.tenants - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at_column(); - -CREATE TRIGGER trg_companies_updated_at - BEFORE UPDATE ON auth.companies - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at_column(); - -CREATE TRIGGER trg_users_updated_at - BEFORE UPDATE ON auth.users - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at_column(); - -CREATE TRIGGER trg_roles_updated_at - BEFORE UPDATE ON auth.roles - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at_column(); - --- Trigger: Validar que tenant tenga al menos 1 admin -CREATE OR REPLACE FUNCTION auth.validate_tenant_has_admin() -RETURNS TRIGGER AS $$ -BEGIN - -- Al eliminar user_role, verificar que no sea el último admin - IF TG_OP = 'DELETE' THEN - IF EXISTS ( - SELECT 1 - FROM auth.users u - JOIN auth.roles r ON r.tenant_id = u.tenant_id - WHERE u.id = OLD.user_id - AND r.code = 'admin' - AND r.id = OLD.role_id - ) THEN - -- Contar admins restantes - IF NOT EXISTS ( - SELECT 1 - FROM auth.user_roles ur - JOIN auth.roles r ON r.id = ur.role_id - JOIN auth.users u ON u.id = ur.user_id - WHERE r.code = 'admin' - AND u.tenant_id = (SELECT tenant_id FROM auth.users WHERE id = OLD.user_id) - AND ur.user_id != OLD.user_id - ) THEN - RAISE EXCEPTION 'Cannot remove last admin from tenant'; - END IF; - END IF; - END IF; - - RETURN OLD; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER trg_validate_tenant_has_admin - BEFORE DELETE ON auth.user_roles - FOR EACH ROW - EXECUTE FUNCTION auth.validate_tenant_has_admin(); - --- Trigger: Auto-marcar sesión como expirada -CREATE OR REPLACE FUNCTION auth.auto_expire_session() -RETURNS TRIGGER AS $$ -BEGIN - IF NEW.expires_at < CURRENT_TIMESTAMP AND NEW.status = 'active' THEN - NEW.status = 'expired'; - END IF; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER trg_auto_expire_session - BEFORE UPDATE ON auth.sessions - FOR EACH ROW - EXECUTE FUNCTION auth.auto_expire_session(); - --- ===================================================== --- ROW LEVEL SECURITY (RLS) --- ===================================================== - --- Habilitar RLS en tablas con tenant_id -ALTER TABLE auth.companies ENABLE ROW LEVEL SECURITY; -ALTER TABLE auth.users ENABLE ROW LEVEL SECURITY; -ALTER TABLE auth.roles ENABLE ROW LEVEL SECURITY; - --- Policy: Tenant Isolation - Companies -CREATE POLICY tenant_isolation_companies -ON auth.companies -USING (tenant_id = get_current_tenant_id()); - --- Policy: Tenant Isolation - Users -CREATE POLICY tenant_isolation_users -ON auth.users -USING (tenant_id = get_current_tenant_id()); - --- Policy: Tenant Isolation - Roles -CREATE POLICY tenant_isolation_roles -ON auth.roles -USING (tenant_id = get_current_tenant_id()); - --- ===================================================== --- DATOS INICIALES (Seed Data) --- ===================================================== - --- Permisos estándar para recursos comunes -INSERT INTO auth.permissions (resource, action, description, module) VALUES --- Auth -('users', 'create', 'Crear usuarios', 'MGN-001'), -('users', 'read', 'Ver usuarios', 'MGN-001'), -('users', 'update', 'Actualizar usuarios', 'MGN-001'), -('users', 'delete', 'Eliminar usuarios', 'MGN-001'), -('roles', 'create', 'Crear roles', 'MGN-001'), -('roles', 'read', 'Ver roles', 'MGN-001'), -('roles', 'update', 'Actualizar roles', 'MGN-001'), -('roles', 'delete', 'Eliminar roles', 'MGN-001'), - --- Financial -('invoices', 'create', 'Crear facturas', 'MGN-004'), -('invoices', 'read', 'Ver facturas', 'MGN-004'), -('invoices', 'update', 'Actualizar facturas', 'MGN-004'), -('invoices', 'delete', 'Eliminar facturas', 'MGN-004'), -('invoices', 'approve', 'Aprobar facturas', 'MGN-004'), -('invoices', 'cancel', 'Cancelar facturas', 'MGN-004'), -('journal_entries', 'create', 'Crear asientos contables', 'MGN-004'), -('journal_entries', 'read', 'Ver asientos contables', 'MGN-004'), -('journal_entries', 'approve', 'Aprobar asientos contables', 'MGN-004'), - --- Purchase -('purchase_orders', 'create', 'Crear órdenes de compra', 'MGN-006'), -('purchase_orders', 'read', 'Ver órdenes de compra', 'MGN-006'), -('purchase_orders', 'update', 'Actualizar órdenes de compra', 'MGN-006'), -('purchase_orders', 'delete', 'Eliminar órdenes de compra', 'MGN-006'), -('purchase_orders', 'approve', 'Aprobar órdenes de compra', 'MGN-006'), - --- Sales -('sale_orders', 'create', 'Crear órdenes de venta', 'MGN-007'), -('sale_orders', 'read', 'Ver órdenes de venta', 'MGN-007'), -('sale_orders', 'update', 'Actualizar órdenes de venta', 'MGN-007'), -('sale_orders', 'delete', 'Eliminar órdenes de venta', 'MGN-007'), -('sale_orders', 'approve', 'Aprobar órdenes de venta', 'MGN-007'), - --- Inventory -('products', 'create', 'Crear productos', 'MGN-005'), -('products', 'read', 'Ver productos', 'MGN-005'), -('products', 'update', 'Actualizar productos', 'MGN-005'), -('products', 'delete', 'Eliminar productos', 'MGN-005'), -('stock_moves', 'create', 'Crear movimientos de inventario', 'MGN-005'), -('stock_moves', 'read', 'Ver movimientos de inventario', 'MGN-005'), -('stock_moves', 'approve', 'Aprobar movimientos de inventario', 'MGN-005'), - --- Projects -('projects', 'create', 'Crear proyectos', 'MGN-011'), -('projects', 'read', 'Ver proyectos', 'MGN-011'), -('projects', 'update', 'Actualizar proyectos', 'MGN-011'), -('projects', 'delete', 'Eliminar proyectos', 'MGN-011'), -('tasks', 'create', 'Crear tareas', 'MGN-011'), -('tasks', 'read', 'Ver tareas', 'MGN-011'), -('tasks', 'update', 'Actualizar tareas', 'MGN-011'), -('tasks', 'delete', 'Eliminar tareas', 'MGN-011'), - --- Reports -('reports', 'read', 'Ver reportes', 'MGN-012'), -('reports', 'export', 'Exportar reportes', 'MGN-012'); - --- ===================================================== --- COMENTARIOS EN TABLAS --- ===================================================== - -COMMENT ON SCHEMA auth IS 'Schema de autenticación, usuarios, roles y permisos'; -COMMENT ON TABLE auth.tenants IS 'Tenants (organizaciones raíz) con schema-level isolation'; -COMMENT ON TABLE auth.companies IS 'Empresas dentro de un tenant (multi-company)'; -COMMENT ON TABLE auth.users IS 'Usuarios del sistema con RBAC'; -COMMENT ON TABLE auth.roles IS 'Roles con permisos asignados'; -COMMENT ON TABLE auth.permissions IS 'Permisos granulares por recurso y acción'; -COMMENT ON TABLE auth.user_roles IS 'Asignación de roles a usuarios (many-to-many)'; -COMMENT ON TABLE auth.role_permissions IS 'Asignación de permisos a roles (many-to-many)'; -COMMENT ON TABLE auth.sessions IS 'Sesiones JWT activas de usuarios'; -COMMENT ON TABLE auth.user_companies IS 'Asignación de usuarios a empresas (multi-company)'; -COMMENT ON TABLE auth.password_resets IS 'Tokens de reset de contraseña'; - --- ===================================================== --- VISTAS ÚTILES --- ===================================================== - --- Vista: user_permissions (permisos efectivos de usuario) -CREATE OR REPLACE VIEW auth.user_permissions_view AS -SELECT DISTINCT - ur.user_id, - u.email, - u.full_name, - p.resource, - p.action, - p.description, - r.name as role_name, - r.code as role_code -FROM auth.user_roles ur -JOIN auth.users u ON ur.user_id = u.id -JOIN auth.roles r ON ur.role_id = r.id -JOIN auth.role_permissions rp ON r.id = rp.role_id -JOIN auth.permissions p ON rp.permission_id = p.id -WHERE u.deleted_at IS NULL - AND u.status = 'active'; - -COMMENT ON VIEW auth.user_permissions_view IS 'Vista de permisos efectivos por usuario'; - --- Vista: active_sessions (sesiones activas) -CREATE OR REPLACE VIEW auth.active_sessions_view AS -SELECT - s.id, - s.user_id, - u.email, - u.full_name, - s.ip_address, - s.user_agent, - s.created_at as login_at, - s.expires_at, - EXTRACT(EPOCH FROM (s.expires_at - CURRENT_TIMESTAMP))/60 as minutes_until_expiry -FROM auth.sessions s -JOIN auth.users u ON s.user_id = u.id -WHERE s.status = 'active' - AND s.expires_at > CURRENT_TIMESTAMP; - -COMMENT ON VIEW auth.active_sessions_view IS 'Vista de sesiones activas con tiempo restante'; - --- ===================================================== --- FIN DEL SCHEMA AUTH --- ===================================================== diff --git a/ddl/02-auth-devices.sql b/ddl/02-auth-devices.sql new file mode 100644 index 0000000..aebb5be --- /dev/null +++ b/ddl/02-auth-devices.sql @@ -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'; diff --git a/ddl/02-core.sql b/ddl/02-core.sql deleted file mode 100644 index 2d8e553..0000000 --- a/ddl/02-core.sql +++ /dev/null @@ -1,755 +0,0 @@ --- ===================================================== --- SCHEMA: core --- PROPÓSITO: Catálogos maestros y entidades fundamentales --- MÓDULOS: MGN-002 (Empresas), MGN-003 (Catálogos Maestros) --- FECHA: 2025-11-24 --- ===================================================== - --- Crear schema -CREATE SCHEMA IF NOT EXISTS core; - --- ===================================================== --- TYPES (ENUMs) --- ===================================================== - -CREATE TYPE core.partner_type AS ENUM ( - 'person', - 'company' -); - -CREATE TYPE core.partner_category AS ENUM ( - 'customer', - 'supplier', - 'employee', - 'contact', - 'other' -); - -CREATE TYPE core.address_type AS ENUM ( - 'billing', - 'shipping', - 'contact', - 'other' -); - -CREATE TYPE core.uom_type AS ENUM ( - 'reference', - 'bigger', - 'smaller' -); - --- ===================================================== --- TABLES --- ===================================================== - --- Tabla: countries (Países - ISO 3166-1) -CREATE TABLE core.countries ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - code VARCHAR(2) NOT NULL UNIQUE, -- ISO 3166-1 alpha-2 - name VARCHAR(255) NOT NULL, - phone_code VARCHAR(10), - currency_code VARCHAR(3), -- ISO 4217 - - -- Sin tenant_id: catálogo global - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - --- Tabla: currencies (Monedas - ISO 4217) -CREATE TABLE core.currencies ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - code VARCHAR(3) NOT NULL UNIQUE, -- ISO 4217 - name VARCHAR(100) NOT NULL, - symbol VARCHAR(10) NOT NULL, - decimals INTEGER NOT NULL DEFAULT 2, - rounding DECIMAL(12, 6) DEFAULT 0.01, - active BOOLEAN NOT NULL DEFAULT TRUE, - - -- Sin tenant_id: catálogo global - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - --- Tabla: exchange_rates (Tasas de cambio) -CREATE TABLE core.exchange_rates ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - from_currency_id UUID NOT NULL REFERENCES core.currencies(id), - to_currency_id UUID NOT NULL REFERENCES core.currencies(id), - rate DECIMAL(12, 6) NOT NULL, - date DATE NOT NULL, - - -- Sin tenant_id: catálogo global - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT uq_exchange_rates_currencies_date UNIQUE (from_currency_id, to_currency_id, date), - CONSTRAINT chk_exchange_rates_rate CHECK (rate > 0), - CONSTRAINT chk_exchange_rates_different_currencies CHECK (from_currency_id != to_currency_id) -); - --- Tabla: uom_categories (Categorías de unidades de medida) -CREATE TABLE core.uom_categories ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name VARCHAR(100) NOT NULL UNIQUE, - description TEXT, - - -- Sin tenant_id: catálogo global - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - --- Tabla: uom (Unidades de medida) -CREATE TABLE core.uom ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - category_id UUID NOT NULL REFERENCES core.uom_categories(id), - name VARCHAR(100) NOT NULL, - code VARCHAR(20), - uom_type core.uom_type NOT NULL DEFAULT 'reference', - factor DECIMAL(12, 6) NOT NULL DEFAULT 1.0, - rounding DECIMAL(12, 6) DEFAULT 0.01, - active BOOLEAN NOT NULL DEFAULT TRUE, - - -- Sin tenant_id: catálogo global - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT uq_uom_name_category UNIQUE (category_id, name), - CONSTRAINT chk_uom_factor CHECK (factor > 0) -); - --- Tabla: partners (Partners universales - patrón Odoo) -CREATE TABLE core.partners ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - - -- Datos básicos - name VARCHAR(255) NOT NULL, - legal_name VARCHAR(255), - partner_type core.partner_type NOT NULL DEFAULT 'person', - - -- Categorización (multiple flags como Odoo) - is_customer BOOLEAN DEFAULT FALSE, - is_supplier BOOLEAN DEFAULT FALSE, - is_employee BOOLEAN DEFAULT FALSE, - is_company BOOLEAN DEFAULT FALSE, - - -- Contacto - email VARCHAR(255), - phone VARCHAR(50), - mobile VARCHAR(50), - website VARCHAR(255), - - -- Fiscal - tax_id VARCHAR(50), -- RFC en México - - -- Referencias - company_id UUID REFERENCES auth.companies(id), - parent_id UUID REFERENCES core.partners(id), -- Para jerarquía de contactos - user_id UUID REFERENCES auth.users(id), -- Usuario vinculado (si aplica) - - -- Comercial - payment_term_id UUID, -- FK a financial.payment_terms (se crea después) - pricelist_id UUID, -- FK a sales.pricelists (se crea después) - - -- Configuración - language VARCHAR(10) DEFAULT 'es', - currency_id UUID REFERENCES core.currencies(id), - - -- Notas - notes TEXT, - internal_notes TEXT, - - -- Control - active BOOLEAN NOT NULL DEFAULT TRUE, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMP, - updated_by UUID REFERENCES auth.users(id), - deleted_at TIMESTAMP, - deleted_by UUID REFERENCES auth.users(id), - - CONSTRAINT chk_partners_email_format CHECK ( - email IS NULL OR email ~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}$' - ), - CONSTRAINT chk_partners_no_self_parent CHECK (id != parent_id) -); - --- Tabla: addresses (Direcciones de partners) -CREATE TABLE core.addresses ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - partner_id UUID NOT NULL REFERENCES core.partners(id) ON DELETE CASCADE, - - -- Tipo de dirección - address_type core.address_type NOT NULL DEFAULT 'contact', - - -- Dirección - street VARCHAR(255), - street2 VARCHAR(255), - city VARCHAR(100), - state VARCHAR(100), - zip_code VARCHAR(20), - country_id UUID REFERENCES core.countries(id), - - -- Control - is_default BOOLEAN DEFAULT FALSE, - active BOOLEAN NOT NULL DEFAULT TRUE, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMP, - updated_by UUID REFERENCES auth.users(id), - deleted_at TIMESTAMP, - deleted_by UUID REFERENCES auth.users(id) -); - --- Tabla: product_categories (Categorías de productos) -CREATE TABLE core.product_categories ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - - name VARCHAR(255) NOT NULL, - code VARCHAR(50), - parent_id UUID REFERENCES core.product_categories(id), - full_path TEXT, -- Generado automáticamente: "Electrónica / Computadoras / Laptops" - - -- Configuración - notes TEXT, - active BOOLEAN NOT NULL DEFAULT TRUE, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMP, - updated_by UUID REFERENCES auth.users(id), - deleted_at TIMESTAMP, - deleted_by UUID REFERENCES auth.users(id), - - CONSTRAINT uq_product_categories_code_tenant UNIQUE (tenant_id, code), - CONSTRAINT chk_product_categories_no_self_parent CHECK (id != parent_id) -); - --- Tabla: tags (Etiquetas genéricas) -CREATE TABLE core.tags ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - - name VARCHAR(100) NOT NULL, - color VARCHAR(20), -- Color hex: #FF5733 - model VARCHAR(100), -- Para qué se usa: 'products', 'partners', 'tasks', etc. - description TEXT, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - - CONSTRAINT uq_tags_name_model_tenant UNIQUE (tenant_id, name, model) -); - --- Tabla: sequences (Generación de números secuenciales) -CREATE TABLE core.sequences ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - company_id UUID REFERENCES auth.companies(id), - - code VARCHAR(100) NOT NULL, -- Código único: 'sale.order', 'purchase.order', etc. - name VARCHAR(255) NOT NULL, - prefix VARCHAR(50), -- Prefijo: "SO-", "PO-", etc. - suffix VARCHAR(50), -- Sufijo: "/2025" - next_number INTEGER NOT NULL DEFAULT 1, - padding INTEGER NOT NULL DEFAULT 4, -- 0001, 0002, etc. - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMP, - updated_by UUID REFERENCES auth.users(id), - - CONSTRAINT uq_sequences_code_tenant UNIQUE (tenant_id, code), - CONSTRAINT chk_sequences_next_number CHECK (next_number > 0), - CONSTRAINT chk_sequences_padding CHECK (padding >= 0) -); - --- Tabla: attachments (Archivos adjuntos genéricos) -CREATE TABLE core.attachments ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - - -- Referencia polimórfica (a qué tabla/registro pertenece) - model VARCHAR(100) NOT NULL, -- 'partners', 'invoices', 'tasks', etc. - record_id UUID NOT NULL, - - -- Archivo - filename VARCHAR(255) NOT NULL, - mimetype VARCHAR(100), - size_bytes BIGINT, - url VARCHAR(1000), -- URL en S3, local storage, etc. - - -- Metadatos - description TEXT, - is_public BOOLEAN DEFAULT FALSE, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - deleted_at TIMESTAMP, - deleted_by UUID REFERENCES auth.users(id), - - CONSTRAINT chk_attachments_size CHECK (size_bytes >= 0) -); - --- Tabla: notes (Notas genéricas) -CREATE TABLE core.notes ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - - -- Referencia polimórfica - model VARCHAR(100) NOT NULL, - record_id UUID NOT NULL, - - -- Nota - subject VARCHAR(255), - content TEXT NOT NULL, - - -- Control - is_pinned BOOLEAN DEFAULT FALSE, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMP, - updated_by UUID REFERENCES auth.users(id), - deleted_at TIMESTAMP, - deleted_by UUID REFERENCES auth.users(id) -); - --- ===================================================== --- INDICES --- ===================================================== - --- Countries -CREATE INDEX idx_countries_code ON core.countries(code); -CREATE INDEX idx_countries_name ON core.countries(name); - --- Currencies -CREATE INDEX idx_currencies_code ON core.currencies(code); -CREATE INDEX idx_currencies_active ON core.currencies(active) WHERE active = TRUE; - --- Exchange Rates -CREATE INDEX idx_exchange_rates_from_currency ON core.exchange_rates(from_currency_id); -CREATE INDEX idx_exchange_rates_to_currency ON core.exchange_rates(to_currency_id); -CREATE INDEX idx_exchange_rates_date ON core.exchange_rates(date DESC); - --- UoM Categories -CREATE INDEX idx_uom_categories_name ON core.uom_categories(name); - --- UoM -CREATE INDEX idx_uom_category_id ON core.uom(category_id); -CREATE INDEX idx_uom_active ON core.uom(active) WHERE active = TRUE; - --- Partners -CREATE INDEX idx_partners_tenant_id ON core.partners(tenant_id); -CREATE INDEX idx_partners_name ON core.partners(name); -CREATE INDEX idx_partners_email ON core.partners(email); -CREATE INDEX idx_partners_tax_id ON core.partners(tax_id); -CREATE INDEX idx_partners_parent_id ON core.partners(parent_id); -CREATE INDEX idx_partners_user_id ON core.partners(user_id); -CREATE INDEX idx_partners_company_id ON core.partners(company_id); -CREATE INDEX idx_partners_currency_id ON core.partners(currency_id) WHERE currency_id IS NOT NULL; -CREATE INDEX idx_partners_payment_term_id ON core.partners(payment_term_id) WHERE payment_term_id IS NOT NULL; -CREATE INDEX idx_partners_pricelist_id ON core.partners(pricelist_id) WHERE pricelist_id IS NOT NULL; -CREATE INDEX idx_partners_is_customer ON core.partners(tenant_id, is_customer) WHERE is_customer = TRUE; -CREATE INDEX idx_partners_is_supplier ON core.partners(tenant_id, is_supplier) WHERE is_supplier = TRUE; -CREATE INDEX idx_partners_is_employee ON core.partners(tenant_id, is_employee) WHERE is_employee = TRUE; -CREATE INDEX idx_partners_active ON core.partners(tenant_id, active) WHERE active = TRUE; - --- Addresses -CREATE INDEX idx_addresses_partner_id ON core.addresses(partner_id); -CREATE INDEX idx_addresses_country_id ON core.addresses(country_id); -CREATE INDEX idx_addresses_is_default ON core.addresses(partner_id, is_default) WHERE is_default = TRUE; - --- Product Categories -CREATE INDEX idx_product_categories_tenant_id ON core.product_categories(tenant_id); -CREATE INDEX idx_product_categories_parent_id ON core.product_categories(parent_id); -CREATE INDEX idx_product_categories_code ON core.product_categories(code); - --- Tags -CREATE INDEX idx_tags_tenant_id ON core.tags(tenant_id); -CREATE INDEX idx_tags_model ON core.tags(model); -CREATE INDEX idx_tags_name ON core.tags(name); - --- Sequences -CREATE INDEX idx_sequences_tenant_id ON core.sequences(tenant_id); -CREATE INDEX idx_sequences_code ON core.sequences(code); - --- Attachments -CREATE INDEX idx_attachments_tenant_id ON core.attachments(tenant_id); -CREATE INDEX idx_attachments_model_record ON core.attachments(model, record_id); -CREATE INDEX idx_attachments_created_by ON core.attachments(created_by); - --- Notes -CREATE INDEX idx_notes_tenant_id ON core.notes(tenant_id); -CREATE INDEX idx_notes_model_record ON core.notes(model, record_id); -CREATE INDEX idx_notes_created_by ON core.notes(created_by); -CREATE INDEX idx_notes_is_pinned ON core.notes(is_pinned) WHERE is_pinned = TRUE; - --- ===================================================== --- FUNCTIONS --- ===================================================== - --- Función: generate_next_sequence --- Genera el siguiente número de secuencia -CREATE OR REPLACE FUNCTION core.generate_next_sequence(p_sequence_code VARCHAR) -RETURNS VARCHAR AS $$ -DECLARE - v_sequence RECORD; - v_next_number INTEGER; - v_result VARCHAR; -BEGIN - -- Obtener secuencia y bloquear fila (SELECT FOR UPDATE) - SELECT * INTO v_sequence - FROM core.sequences - WHERE code = p_sequence_code - AND tenant_id = get_current_tenant_id() - FOR UPDATE; - - IF NOT FOUND THEN - RAISE EXCEPTION 'Sequence % not found', p_sequence_code; - END IF; - - -- Generar número - v_next_number := v_sequence.next_number; - - -- Formatear resultado - v_result := COALESCE(v_sequence.prefix, '') || - LPAD(v_next_number::TEXT, v_sequence.padding, '0') || - COALESCE(v_sequence.suffix, ''); - - -- Incrementar contador - UPDATE core.sequences - SET next_number = next_number + 1, - updated_at = CURRENT_TIMESTAMP, - updated_by = get_current_user_id() - WHERE id = v_sequence.id; - - RETURN v_result; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION core.generate_next_sequence IS 'Genera el siguiente número de secuencia para un código dado'; - --- Función: update_product_category_path --- Actualiza el full_path de una categoría de producto -CREATE OR REPLACE FUNCTION core.update_product_category_path() -RETURNS TRIGGER AS $$ -DECLARE - v_parent_path TEXT; -BEGIN - IF NEW.parent_id IS NULL THEN - NEW.full_path := NEW.name; - ELSE - SELECT full_path INTO v_parent_path - FROM core.product_categories - WHERE id = NEW.parent_id; - - NEW.full_path := v_parent_path || ' / ' || NEW.name; - END IF; - - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION core.update_product_category_path IS 'Actualiza el path completo de la categoría al crear/actualizar'; - --- Función: get_exchange_rate --- Obtiene la tasa de cambio entre dos monedas en una fecha -CREATE OR REPLACE FUNCTION core.get_exchange_rate( - p_from_currency_id UUID, - p_to_currency_id UUID, - p_date DATE DEFAULT CURRENT_DATE -) -RETURNS DECIMAL AS $$ -DECLARE - v_rate DECIMAL; -BEGIN - -- Si son la misma moneda, tasa = 1 - IF p_from_currency_id = p_to_currency_id THEN - RETURN 1.0; - END IF; - - -- Buscar tasa directa - SELECT rate INTO v_rate - FROM core.exchange_rates - WHERE from_currency_id = p_from_currency_id - AND to_currency_id = p_to_currency_id - AND date <= p_date - ORDER BY date DESC - LIMIT 1; - - IF FOUND THEN - RETURN v_rate; - END IF; - - -- Buscar tasa inversa - SELECT 1.0 / rate INTO v_rate - FROM core.exchange_rates - WHERE from_currency_id = p_to_currency_id - AND to_currency_id = p_from_currency_id - AND date <= p_date - ORDER BY date DESC - LIMIT 1; - - IF FOUND THEN - RETURN v_rate; - END IF; - - -- No se encontró tasa - RAISE EXCEPTION 'Exchange rate not found for currencies % to % on date %', - p_from_currency_id, p_to_currency_id, p_date; -END; -$$ LANGUAGE plpgsql STABLE; - -COMMENT ON FUNCTION core.get_exchange_rate IS 'Obtiene la tasa de cambio entre dos monedas en una fecha específica'; - --- ===================================================== --- TRIGGERS --- ===================================================== - --- Trigger: Actualizar updated_at en partners -CREATE TRIGGER trg_partners_updated_at - BEFORE UPDATE ON core.partners - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at_column(); - --- Trigger: Actualizar updated_at en addresses -CREATE TRIGGER trg_addresses_updated_at - BEFORE UPDATE ON core.addresses - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at_column(); - --- Trigger: Actualizar updated_at en product_categories -CREATE TRIGGER trg_product_categories_updated_at - BEFORE UPDATE ON core.product_categories - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at_column(); - --- Trigger: Actualizar updated_at en notes -CREATE TRIGGER trg_notes_updated_at - BEFORE UPDATE ON core.notes - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at_column(); - --- Trigger: Actualizar full_path en product_categories -CREATE TRIGGER trg_product_categories_update_path - BEFORE INSERT OR UPDATE OF name, parent_id ON core.product_categories - FOR EACH ROW - EXECUTE FUNCTION core.update_product_category_path(); - --- ===================================================== --- ROW LEVEL SECURITY (RLS) --- ===================================================== - --- Habilitar RLS en tablas con tenant_id -ALTER TABLE core.partners ENABLE ROW LEVEL SECURITY; -ALTER TABLE core.product_categories ENABLE ROW LEVEL SECURITY; -ALTER TABLE core.tags ENABLE ROW LEVEL SECURITY; -ALTER TABLE core.sequences ENABLE ROW LEVEL SECURITY; -ALTER TABLE core.attachments ENABLE ROW LEVEL SECURITY; -ALTER TABLE core.notes ENABLE ROW LEVEL SECURITY; - --- Policy: Tenant Isolation - Partners -CREATE POLICY tenant_isolation_partners -ON core.partners -USING (tenant_id = get_current_tenant_id()); - --- Policy: Tenant Isolation - Product Categories -CREATE POLICY tenant_isolation_product_categories -ON core.product_categories -USING (tenant_id = get_current_tenant_id()); - --- Policy: Tenant Isolation - Tags -CREATE POLICY tenant_isolation_tags -ON core.tags -USING (tenant_id = get_current_tenant_id()); - --- Policy: Tenant Isolation - Sequences -CREATE POLICY tenant_isolation_sequences -ON core.sequences -USING (tenant_id = get_current_tenant_id()); - --- Policy: Tenant Isolation - Attachments -CREATE POLICY tenant_isolation_attachments -ON core.attachments -USING (tenant_id = get_current_tenant_id()); - --- Policy: Tenant Isolation - Notes -CREATE POLICY tenant_isolation_notes -ON core.notes -USING (tenant_id = get_current_tenant_id()); - --- ===================================================== --- SEED DATA --- ===================================================== - --- Monedas principales (ISO 4217) -INSERT INTO core.currencies (code, name, symbol, decimals) VALUES -('USD', 'US Dollar', '$', 2), -('MXN', 'Peso Mexicano', '$', 2), -('EUR', 'Euro', '€', 2), -('GBP', 'British Pound', '£', 2), -('CAD', 'Canadian Dollar', '$', 2), -('JPY', 'Japanese Yen', '¥', 0), -('CNY', 'Chinese Yuan', '¥', 2), -('BRL', 'Brazilian Real', 'R$', 2), -('ARS', 'Argentine Peso', '$', 2), -('COP', 'Colombian Peso', '$', 2) -ON CONFLICT (code) DO NOTHING; - --- Países principales (ISO 3166-1) -INSERT INTO core.countries (code, name, phone_code, currency_code) VALUES -('MX', 'México', '52', 'MXN'), -('US', 'United States', '1', 'USD'), -('CA', 'Canada', '1', 'CAD'), -('GB', 'United Kingdom', '44', 'GBP'), -('FR', 'France', '33', 'EUR'), -('DE', 'Germany', '49', 'EUR'), -('ES', 'Spain', '34', 'EUR'), -('IT', 'Italy', '39', 'EUR'), -('BR', 'Brazil', '55', 'BRL'), -('AR', 'Argentina', '54', 'ARS'), -('CO', 'Colombia', '57', 'COP'), -('CL', 'Chile', '56', 'CLP'), -('PE', 'Peru', '51', 'PEN'), -('CN', 'China', '86', 'CNY'), -('JP', 'Japan', '81', 'JPY'), -('IN', 'India', '91', 'INR') -ON CONFLICT (code) DO NOTHING; - --- Categorías de UoM -INSERT INTO core.uom_categories (name, description) VALUES -('Weight', 'Unidades de peso'), -('Volume', 'Unidades de volumen'), -('Length', 'Unidades de longitud'), -('Time', 'Unidades de tiempo'), -('Unit', 'Unidades (piezas, docenas, etc.)') -ON CONFLICT (name) DO NOTHING; - --- Unidades de medida estándar -INSERT INTO core.uom (category_id, name, code, uom_type, factor) -SELECT - cat.id, - uom.name, - uom.code, - uom.uom_type::core.uom_type, - uom.factor -FROM ( - -- Weight - SELECT (SELECT id FROM core.uom_categories WHERE name = 'Weight'), 'Kilogram', 'kg', 'reference', 1.0 UNION ALL - SELECT (SELECT id FROM core.uom_categories WHERE name = 'Weight'), 'Gram', 'g', 'smaller', 0.001 UNION ALL - SELECT (SELECT id FROM core.uom_categories WHERE name = 'Weight'), 'Ton', 't', 'bigger', 1000.0 UNION ALL - SELECT (SELECT id FROM core.uom_categories WHERE name = 'Weight'), 'Pound', 'lb', 'smaller', 0.453592 UNION ALL - - -- Volume - SELECT (SELECT id FROM core.uom_categories WHERE name = 'Volume'), 'Liter', 'L', 'reference', 1.0 UNION ALL - SELECT (SELECT id FROM core.uom_categories WHERE name = 'Volume'), 'Milliliter', 'mL', 'smaller', 0.001 UNION ALL - SELECT (SELECT id FROM core.uom_categories WHERE name = 'Volume'), 'Cubic Meter', 'm³', 'bigger', 1000.0 UNION ALL - SELECT (SELECT id FROM core.uom_categories WHERE name = 'Volume'), 'Gallon', 'gal', 'bigger', 3.78541 UNION ALL - - -- Length - SELECT (SELECT id FROM core.uom_categories WHERE name = 'Length'), 'Meter', 'm', 'reference', 1.0 UNION ALL - SELECT (SELECT id FROM core.uom_categories WHERE name = 'Length'), 'Centimeter', 'cm', 'smaller', 0.01 UNION ALL - SELECT (SELECT id FROM core.uom_categories WHERE name = 'Length'), 'Millimeter', 'mm', 'smaller', 0.001 UNION ALL - SELECT (SELECT id FROM core.uom_categories WHERE name = 'Length'), 'Kilometer', 'km', 'bigger', 1000.0 UNION ALL - SELECT (SELECT id FROM core.uom_categories WHERE name = 'Length'), 'Inch', 'in', 'smaller', 0.0254 UNION ALL - SELECT (SELECT id FROM core.uom_categories WHERE name = 'Length'), 'Foot', 'ft', 'smaller', 0.3048 UNION ALL - - -- Time - SELECT (SELECT id FROM core.uom_categories WHERE name = 'Time'), 'Hour', 'h', 'reference', 1.0 UNION ALL - SELECT (SELECT id FROM core.uom_categories WHERE name = 'Time'), 'Day', 'd', 'bigger', 24.0 UNION ALL - SELECT (SELECT id FROM core.uom_categories WHERE name = 'Time'), 'Week', 'wk', 'bigger', 168.0 UNION ALL - - -- Unit - SELECT (SELECT id FROM core.uom_categories WHERE name = 'Unit'), 'Unit', 'unit', 'reference', 1.0 UNION ALL - SELECT (SELECT id FROM core.uom_categories WHERE name = 'Unit'), 'Dozen', 'doz', 'bigger', 12.0 UNION ALL - SELECT (SELECT id FROM core.uom_categories WHERE name = 'Unit'), 'Pack', 'pack', 'bigger', 1.0 -) AS uom(category_id, name, code, uom_type, factor) -JOIN core.uom_categories cat ON cat.id = uom.category_id -ON CONFLICT DO NOTHING; - --- ===================================================== --- COMENTARIOS EN TABLAS --- ===================================================== - -COMMENT ON SCHEMA core IS 'Schema de catálogos maestros y entidades fundamentales'; -COMMENT ON TABLE core.countries IS 'Catálogo de países (ISO 3166-1)'; -COMMENT ON TABLE core.currencies IS 'Catálogo de monedas (ISO 4217)'; -COMMENT ON TABLE core.exchange_rates IS 'Tasas de cambio históricas entre monedas'; -COMMENT ON TABLE core.uom_categories IS 'Categorías de unidades de medida'; -COMMENT ON TABLE core.uom IS 'Unidades de medida (peso, volumen, longitud, etc.)'; -COMMENT ON TABLE core.partners IS 'Partners universales (clientes, proveedores, empleados, contactos) - patrón Odoo'; -COMMENT ON TABLE core.addresses IS 'Direcciones de partners (facturación, envío, contacto)'; -COMMENT ON TABLE core.product_categories IS 'Categorías jerárquicas de productos'; -COMMENT ON TABLE core.tags IS 'Etiquetas genéricas para clasificar registros'; -COMMENT ON TABLE core.sequences IS 'Generadores de números secuenciales automáticos'; -COMMENT ON TABLE core.attachments IS 'Archivos adjuntos polimórficos (cualquier tabla/registro)'; -COMMENT ON TABLE core.notes IS 'Notas polimórficas (cualquier tabla/registro)'; - --- ===================================================== --- VISTAS ÚTILES --- ===================================================== - --- Vista: customers (solo partners que son clientes) -CREATE OR REPLACE VIEW core.customers_view AS -SELECT - id, - tenant_id, - name, - legal_name, - email, - phone, - mobile, - tax_id, - company_id, - active -FROM core.partners -WHERE is_customer = TRUE - AND deleted_at IS NULL; - -COMMENT ON VIEW core.customers_view IS 'Vista de partners que son clientes'; - --- Vista: suppliers (solo partners que son proveedores) -CREATE OR REPLACE VIEW core.suppliers_view AS -SELECT - id, - tenant_id, - name, - legal_name, - email, - phone, - tax_id, - company_id, - active -FROM core.partners -WHERE is_supplier = TRUE - AND deleted_at IS NULL; - -COMMENT ON VIEW core.suppliers_view IS 'Vista de partners que son proveedores'; - --- Vista: employees (solo partners que son empleados) -CREATE OR REPLACE VIEW core.employees_view AS -SELECT - p.id, - p.tenant_id, - p.name, - p.email, - p.phone, - p.user_id, - u.full_name as user_name, - p.active -FROM core.partners p -LEFT JOIN auth.users u ON p.user_id = u.id -WHERE p.is_employee = TRUE - AND p.deleted_at IS NULL; - -COMMENT ON VIEW core.employees_view IS 'Vista de partners que son empleados'; - --- ===================================================== --- FIN DEL SCHEMA CORE --- ===================================================== diff --git a/ddl/03-analytics.sql b/ddl/03-analytics.sql deleted file mode 100644 index faea1aa..0000000 --- a/ddl/03-analytics.sql +++ /dev/null @@ -1,510 +0,0 @@ --- ===================================================== --- SCHEMA: analytics --- PROPÓSITO: Contabilidad analítica, tracking de costos/ingresos --- MÓDULOS: MGN-008 (Contabilidad Analítica) --- FECHA: 2025-11-24 --- ===================================================== - --- Crear schema -CREATE SCHEMA IF NOT EXISTS analytics; - --- ===================================================== --- TYPES (ENUMs) --- ===================================================== - -CREATE TYPE analytics.account_type AS ENUM ( - 'project', - 'department', - 'cost_center', - 'customer', - 'product', - 'other' -); - -CREATE TYPE analytics.line_type AS ENUM ( - 'expense', - 'income', - 'timesheet' -); - -CREATE TYPE analytics.account_status AS ENUM ( - 'active', - 'inactive', - 'closed' -); - --- ===================================================== --- TABLES --- ===================================================== - --- Tabla: analytic_plans (Planes analíticos - multi-dimensional) -CREATE TABLE analytics.analytic_plans ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - company_id UUID REFERENCES auth.companies(id), - - name VARCHAR(255) NOT NULL, - description TEXT, - - -- Control - active BOOLEAN NOT NULL DEFAULT TRUE, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMP, - updated_by UUID REFERENCES auth.users(id), - - CONSTRAINT uq_analytic_plans_name_tenant UNIQUE (tenant_id, name) -); - --- Tabla: analytic_accounts (Cuentas analíticas) -CREATE TABLE analytics.analytic_accounts ( - 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 auth.companies(id) ON DELETE CASCADE, - - plan_id UUID REFERENCES analytics.analytic_plans(id), - - -- Identificación - name VARCHAR(255) NOT NULL, - code VARCHAR(50), - account_type analytics.account_type NOT NULL DEFAULT 'other', - - -- Jerarquía - parent_id UUID REFERENCES analytics.analytic_accounts(id), - full_path TEXT, -- Generado automáticamente - - -- Referencias - partner_id UUID REFERENCES core.partners(id), -- Cliente/proveedor asociado - - -- Presupuesto - budget DECIMAL(15, 2) DEFAULT 0, - - -- Estado - status analytics.account_status NOT NULL DEFAULT 'active', - - -- Fechas - date_start DATE, - date_end DATE, - - -- Notas - description TEXT, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMP, - updated_by UUID REFERENCES auth.users(id), - deleted_at TIMESTAMP, - deleted_by UUID REFERENCES auth.users(id), - - CONSTRAINT uq_analytic_accounts_code_company UNIQUE (company_id, code), - CONSTRAINT chk_analytic_accounts_no_self_parent CHECK (id != parent_id), - CONSTRAINT chk_analytic_accounts_budget CHECK (budget >= 0), - CONSTRAINT chk_analytic_accounts_dates CHECK (date_end IS NULL OR date_end >= date_start) -); - --- Tabla: analytic_tags (Etiquetas analíticas - clasificación cross-cutting) -CREATE TABLE analytics.analytic_tags ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - - name VARCHAR(100) NOT NULL, - color VARCHAR(20), -- Color hex - description TEXT, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - - CONSTRAINT uq_analytic_tags_name_tenant UNIQUE (tenant_id, name) -); - --- Tabla: cost_centers (Centros de costo) -CREATE TABLE analytics.cost_centers ( - 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 auth.companies(id) ON DELETE CASCADE, - - name VARCHAR(255) NOT NULL, - code VARCHAR(50), - analytic_account_id UUID NOT NULL REFERENCES analytics.analytic_accounts(id), - - -- Responsable - manager_id UUID REFERENCES auth.users(id), - - -- Presupuesto - budget_monthly DECIMAL(15, 2) DEFAULT 0, - budget_annual DECIMAL(15, 2) DEFAULT 0, - - -- Control - active BOOLEAN NOT NULL DEFAULT TRUE, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMP, - updated_by UUID REFERENCES auth.users(id), - - CONSTRAINT uq_cost_centers_code_company UNIQUE (company_id, code) -); - --- Tabla: analytic_lines (Líneas analíticas - registro de costos/ingresos) -CREATE TABLE analytics.analytic_lines ( - 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 auth.companies(id) ON DELETE CASCADE, - - analytic_account_id UUID NOT NULL REFERENCES analytics.analytic_accounts(id), - - -- Fecha - date DATE NOT NULL, - - -- Montos - amount DECIMAL(15, 2) NOT NULL, -- Negativo=costo, Positivo=ingreso - unit_amount DECIMAL(12, 4) DEFAULT 0, -- Horas para timesheet, cantidades para productos - - -- Tipo - line_type analytics.line_type NOT NULL, - - -- Referencias - product_id UUID REFERENCES inventory.products(id), - employee_id UUID, -- FK a hr.employees (se crea después) - partner_id UUID REFERENCES core.partners(id), - - -- Descripción - name VARCHAR(255), - description TEXT, - - -- Documento origen (polimórfico) - source_model VARCHAR(100), -- 'Invoice', 'PurchaseOrder', 'SaleOrder', 'Timesheet', etc. - source_id UUID, - source_document VARCHAR(255), -- "invoice/123", "purchase_order/456" - - -- Moneda - currency_id UUID REFERENCES core.currencies(id), - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - - CONSTRAINT chk_analytic_lines_unit_amount CHECK (unit_amount >= 0) -); - --- Tabla: analytic_line_tags (Many-to-many: líneas analíticas - tags) -CREATE TABLE analytics.analytic_line_tags ( - analytic_line_id UUID NOT NULL REFERENCES analytics.analytic_lines(id) ON DELETE CASCADE, - analytic_tag_id UUID NOT NULL REFERENCES analytics.analytic_tags(id) ON DELETE CASCADE, - - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - - PRIMARY KEY (analytic_line_id, analytic_tag_id) -); - --- Tabla: analytic_distributions (Distribución analítica multi-cuenta) -CREATE TABLE analytics.analytic_distributions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Línea origen (polimórfico) - source_model VARCHAR(100) NOT NULL, -- 'PurchaseOrderLine', 'InvoiceLine', etc. - source_id UUID NOT NULL, - - -- Cuenta analítica destino - analytic_account_id UUID NOT NULL REFERENCES analytics.analytic_accounts(id), - - -- Distribución - percentage DECIMAL(5, 2) NOT NULL, -- 0-100 - amount DECIMAL(15, 2), -- Calculado automáticamente - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT chk_analytic_distributions_percentage CHECK (percentage >= 0 AND percentage <= 100) -); - --- ===================================================== --- INDICES --- ===================================================== - --- Analytic Plans -CREATE INDEX idx_analytic_plans_tenant_id ON analytics.analytic_plans(tenant_id); -CREATE INDEX idx_analytic_plans_active ON analytics.analytic_plans(active) WHERE active = TRUE; - --- Analytic Accounts -CREATE INDEX idx_analytic_accounts_tenant_id ON analytics.analytic_accounts(tenant_id); -CREATE INDEX idx_analytic_accounts_company_id ON analytics.analytic_accounts(company_id); -CREATE INDEX idx_analytic_accounts_plan_id ON analytics.analytic_accounts(plan_id); -CREATE INDEX idx_analytic_accounts_parent_id ON analytics.analytic_accounts(parent_id); -CREATE INDEX idx_analytic_accounts_partner_id ON analytics.analytic_accounts(partner_id); -CREATE INDEX idx_analytic_accounts_code ON analytics.analytic_accounts(code); -CREATE INDEX idx_analytic_accounts_type ON analytics.analytic_accounts(account_type); -CREATE INDEX idx_analytic_accounts_status ON analytics.analytic_accounts(status); - --- Analytic Tags -CREATE INDEX idx_analytic_tags_tenant_id ON analytics.analytic_tags(tenant_id); -CREATE INDEX idx_analytic_tags_name ON analytics.analytic_tags(name); - --- Cost Centers -CREATE INDEX idx_cost_centers_tenant_id ON analytics.cost_centers(tenant_id); -CREATE INDEX idx_cost_centers_company_id ON analytics.cost_centers(company_id); -CREATE INDEX idx_cost_centers_analytic_account_id ON analytics.cost_centers(analytic_account_id); -CREATE INDEX idx_cost_centers_manager_id ON analytics.cost_centers(manager_id); -CREATE INDEX idx_cost_centers_active ON analytics.cost_centers(active) WHERE active = TRUE; - --- Analytic Lines -CREATE INDEX idx_analytic_lines_tenant_id ON analytics.analytic_lines(tenant_id); -CREATE INDEX idx_analytic_lines_company_id ON analytics.analytic_lines(company_id); -CREATE INDEX idx_analytic_lines_analytic_account_id ON analytics.analytic_lines(analytic_account_id); -CREATE INDEX idx_analytic_lines_date ON analytics.analytic_lines(date); -CREATE INDEX idx_analytic_lines_line_type ON analytics.analytic_lines(line_type); -CREATE INDEX idx_analytic_lines_product_id ON analytics.analytic_lines(product_id); -CREATE INDEX idx_analytic_lines_employee_id ON analytics.analytic_lines(employee_id); -CREATE INDEX idx_analytic_lines_source ON analytics.analytic_lines(source_model, source_id); - --- Analytic Line Tags -CREATE INDEX idx_analytic_line_tags_line_id ON analytics.analytic_line_tags(analytic_line_id); -CREATE INDEX idx_analytic_line_tags_tag_id ON analytics.analytic_line_tags(analytic_tag_id); - --- Analytic Distributions -CREATE INDEX idx_analytic_distributions_source ON analytics.analytic_distributions(source_model, source_id); -CREATE INDEX idx_analytic_distributions_analytic_account_id ON analytics.analytic_distributions(analytic_account_id); - --- ===================================================== --- FUNCTIONS --- ===================================================== - --- Función: update_analytic_account_path -CREATE OR REPLACE FUNCTION analytics.update_analytic_account_path() -RETURNS TRIGGER AS $$ -DECLARE - v_parent_path TEXT; -BEGIN - IF NEW.parent_id IS NULL THEN - NEW.full_path := NEW.name; - ELSE - SELECT full_path INTO v_parent_path - FROM analytics.analytic_accounts - WHERE id = NEW.parent_id; - - NEW.full_path := v_parent_path || ' / ' || NEW.name; - END IF; - - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION analytics.update_analytic_account_path IS 'Actualiza el path completo de la cuenta analítica'; - --- Función: get_analytic_balance -CREATE OR REPLACE FUNCTION analytics.get_analytic_balance( - p_analytic_account_id UUID, - p_date_from DATE DEFAULT NULL, - p_date_to DATE DEFAULT NULL -) -RETURNS TABLE( - total_income DECIMAL, - total_expense DECIMAL, - balance DECIMAL -) AS $$ -BEGIN - RETURN QUERY - SELECT - COALESCE(SUM(CASE WHEN line_type = 'income' THEN amount ELSE 0 END), 0) AS total_income, - COALESCE(SUM(CASE WHEN line_type = 'expense' THEN ABS(amount) ELSE 0 END), 0) AS total_expense, - COALESCE(SUM(amount), 0) AS balance - FROM analytics.analytic_lines - WHERE analytic_account_id = p_analytic_account_id - AND (p_date_from IS NULL OR date >= p_date_from) - AND (p_date_to IS NULL OR date <= p_date_to); -END; -$$ LANGUAGE plpgsql STABLE; - -COMMENT ON FUNCTION analytics.get_analytic_balance IS 'Obtiene el balance de una cuenta analítica en un período'; - --- Función: validate_distribution_100_percent -CREATE OR REPLACE FUNCTION analytics.validate_distribution_100_percent() -RETURNS TRIGGER AS $$ -DECLARE - v_total_percentage DECIMAL; -BEGIN - SELECT COALESCE(SUM(percentage), 0) - INTO v_total_percentage - FROM analytics.analytic_distributions - WHERE source_model = NEW.source_model - AND source_id = NEW.source_id; - - IF TG_OP = 'INSERT' OR TG_OP = 'UPDATE' THEN - v_total_percentage := v_total_percentage + NEW.percentage; - END IF; - - IF v_total_percentage > 100 THEN - RAISE EXCEPTION 'Total distribution percentage cannot exceed 100%% (currently: %%)', v_total_percentage; - END IF; - - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION analytics.validate_distribution_100_percent IS 'Valida que la distribución analítica no exceda el 100%'; - --- Función: create_analytic_line_from_invoice -CREATE OR REPLACE FUNCTION analytics.create_analytic_line_from_invoice(p_invoice_line_id UUID) -RETURNS UUID AS $$ -DECLARE - v_line RECORD; - v_invoice RECORD; - v_analytic_line_id UUID; - v_amount DECIMAL; -BEGIN - -- Obtener datos de la línea de factura - SELECT il.*, i.invoice_type, i.company_id, i.tenant_id, i.partner_id, i.invoice_date - INTO v_line - FROM financial.invoice_lines il - JOIN financial.invoices i ON il.invoice_id = i.id - WHERE il.id = p_invoice_line_id; - - IF NOT FOUND OR v_line.analytic_account_id IS NULL THEN - RETURN NULL; -- Sin cuenta analítica, no crear línea - END IF; - - -- Determinar monto (negativo para compras, positivo para ventas) - IF v_line.invoice_type = 'supplier' THEN - v_amount := -ABS(v_line.amount_total); - ELSE - v_amount := v_line.amount_total; - END IF; - - -- Crear línea analítica - INSERT INTO analytics.analytic_lines ( - tenant_id, - company_id, - analytic_account_id, - date, - amount, - unit_amount, - line_type, - product_id, - partner_id, - name, - description, - source_model, - source_id, - source_document - ) VALUES ( - v_line.tenant_id, - v_line.company_id, - v_line.analytic_account_id, - v_line.invoice_date, - v_amount, - v_line.quantity, - CASE WHEN v_line.invoice_type = 'supplier' THEN 'expense'::analytics.line_type ELSE 'income'::analytics.line_type END, - v_line.product_id, - v_line.partner_id, - v_line.description, - v_line.description, - 'InvoiceLine', - v_line.id, - 'invoice_line/' || v_line.id::TEXT - ) RETURNING id INTO v_analytic_line_id; - - RETURN v_analytic_line_id; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION analytics.create_analytic_line_from_invoice IS 'Crea una línea analítica a partir de una línea de factura'; - --- ===================================================== --- TRIGGERS --- ===================================================== - -CREATE TRIGGER trg_analytic_plans_updated_at - BEFORE UPDATE ON analytics.analytic_plans - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at_column(); - -CREATE TRIGGER trg_analytic_accounts_updated_at - BEFORE UPDATE ON analytics.analytic_accounts - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at_column(); - -CREATE TRIGGER trg_cost_centers_updated_at - BEFORE UPDATE ON analytics.cost_centers - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at_column(); - --- Trigger: Actualizar full_path de cuenta analítica -CREATE TRIGGER trg_analytic_accounts_update_path - BEFORE INSERT OR UPDATE OF name, parent_id ON analytics.analytic_accounts - FOR EACH ROW - EXECUTE FUNCTION analytics.update_analytic_account_path(); - --- Trigger: Validar distribución 100% -CREATE TRIGGER trg_analytic_distributions_validate_100 - BEFORE INSERT OR UPDATE ON analytics.analytic_distributions - FOR EACH ROW - EXECUTE FUNCTION analytics.validate_distribution_100_percent(); - --- ===================================================== --- ROW LEVEL SECURITY (RLS) --- ===================================================== - -ALTER TABLE analytics.analytic_plans ENABLE ROW LEVEL SECURITY; -ALTER TABLE analytics.analytic_accounts ENABLE ROW LEVEL SECURITY; -ALTER TABLE analytics.analytic_tags ENABLE ROW LEVEL SECURITY; -ALTER TABLE analytics.cost_centers ENABLE ROW LEVEL SECURITY; -ALTER TABLE analytics.analytic_lines ENABLE ROW LEVEL SECURITY; - -CREATE POLICY tenant_isolation_analytic_plans ON analytics.analytic_plans - USING (tenant_id = get_current_tenant_id()); - -CREATE POLICY tenant_isolation_analytic_accounts ON analytics.analytic_accounts - USING (tenant_id = get_current_tenant_id()); - -CREATE POLICY tenant_isolation_analytic_tags ON analytics.analytic_tags - USING (tenant_id = get_current_tenant_id()); - -CREATE POLICY tenant_isolation_cost_centers ON analytics.cost_centers - USING (tenant_id = get_current_tenant_id()); - -CREATE POLICY tenant_isolation_analytic_lines ON analytics.analytic_lines - USING (tenant_id = get_current_tenant_id()); - --- ===================================================== --- COMENTARIOS --- ===================================================== - -COMMENT ON SCHEMA analytics IS 'Schema de contabilidad analítica y tracking de costos/ingresos'; -COMMENT ON TABLE analytics.analytic_plans IS 'Planes analíticos para análisis multi-dimensional'; -COMMENT ON TABLE analytics.analytic_accounts IS 'Cuentas analíticas (proyectos, departamentos, centros de costo)'; -COMMENT ON TABLE analytics.analytic_tags IS 'Etiquetas analíticas para clasificación cross-cutting'; -COMMENT ON TABLE analytics.cost_centers IS 'Centros de costo con presupuestos'; -COMMENT ON TABLE analytics.analytic_lines IS 'Líneas analíticas de costos e ingresos'; -COMMENT ON TABLE analytics.analytic_line_tags IS 'Relación many-to-many entre líneas y tags'; -COMMENT ON TABLE analytics.analytic_distributions IS 'Distribución de montos a múltiples cuentas analíticas'; - --- ===================================================== --- VISTAS ÚTILES --- ===================================================== - --- Vista: balance analítico por cuenta -CREATE OR REPLACE VIEW analytics.analytic_balance_view AS -SELECT - aa.id AS analytic_account_id, - aa.code, - aa.name, - aa.budget, - COALESCE(SUM(CASE WHEN al.line_type = 'income' THEN al.amount ELSE 0 END), 0) AS total_income, - COALESCE(SUM(CASE WHEN al.line_type = 'expense' THEN ABS(al.amount) ELSE 0 END), 0) AS total_expense, - COALESCE(SUM(al.amount), 0) AS balance, - aa.budget - COALESCE(SUM(CASE WHEN al.line_type = 'expense' THEN ABS(al.amount) ELSE 0 END), 0) AS budget_variance -FROM analytics.analytic_accounts aa -LEFT JOIN analytics.analytic_lines al ON aa.id = al.analytic_account_id -WHERE aa.deleted_at IS NULL -GROUP BY aa.id, aa.code, aa.name, aa.budget; - -COMMENT ON VIEW analytics.analytic_balance_view IS 'Vista de balance analítico por cuenta con presupuesto vs real'; - --- ===================================================== --- FIN DEL SCHEMA ANALYTICS --- ===================================================== diff --git a/ddl/03-core-branches.sql b/ddl/03-core-branches.sql new file mode 100644 index 0000000..400ec3a --- /dev/null +++ b/ddl/03-core-branches.sql @@ -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'; diff --git a/ddl/04-financial.sql b/ddl/04-financial.sql deleted file mode 100644 index 022a903..0000000 --- a/ddl/04-financial.sql +++ /dev/null @@ -1,970 +0,0 @@ --- ===================================================== --- SCHEMA: financial --- PROPÓSITO: Contabilidad, facturas, pagos, finanzas --- MÓDULOS: MGN-004 (Financiero Básico) --- FECHA: 2025-11-24 --- ===================================================== - --- Crear schema -CREATE SCHEMA IF NOT EXISTS financial; - --- ===================================================== --- TYPES (ENUMs) --- ===================================================== - -CREATE TYPE financial.account_type AS ENUM ( - 'asset', - 'liability', - 'equity', - 'revenue', - 'expense' -); - -CREATE TYPE financial.journal_type AS ENUM ( - 'sale', - 'purchase', - 'bank', - 'cash', - 'general' -); - -CREATE TYPE financial.entry_status AS ENUM ( - 'draft', - 'posted', - 'cancelled' -); - -CREATE TYPE financial.invoice_type AS ENUM ( - 'customer', - 'supplier' -); - -CREATE TYPE financial.invoice_status AS ENUM ( - 'draft', - 'open', - 'paid', - 'cancelled' -); - -CREATE TYPE financial.payment_type AS ENUM ( - 'inbound', - 'outbound' -); - -CREATE TYPE financial.payment_method AS ENUM ( - 'cash', - 'bank_transfer', - 'check', - 'card', - 'other' -); - -CREATE TYPE financial.payment_status AS ENUM ( - 'draft', - 'posted', - 'reconciled', - 'cancelled' -); - -CREATE TYPE financial.tax_type AS ENUM ( - 'sales', - 'purchase', - 'all' -); - -CREATE TYPE financial.fiscal_period_status AS ENUM ( - 'open', - 'closed' -); - --- ===================================================== --- TABLES --- ===================================================== - --- Tabla: account_types (Tipos de cuenta contable) -CREATE TABLE financial.account_types ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - code VARCHAR(20) NOT NULL UNIQUE, - name VARCHAR(100) NOT NULL, - account_type financial.account_type NOT NULL, - description TEXT, - - -- Sin tenant_id: catálogo global - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - --- Tabla: accounts (Plan de cuentas) -CREATE TABLE financial.accounts ( - 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 auth.companies(id) ON DELETE CASCADE, - - code VARCHAR(50) NOT NULL, - name VARCHAR(255) NOT NULL, - account_type_id UUID NOT NULL REFERENCES financial.account_types(id), - parent_id UUID REFERENCES financial.accounts(id), - - -- Configuración - currency_id UUID REFERENCES core.currencies(id), - is_reconcilable BOOLEAN DEFAULT FALSE, -- ¿Permite conciliación? - is_deprecated BOOLEAN DEFAULT FALSE, - - -- Notas - notes TEXT, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMP, - updated_by UUID REFERENCES auth.users(id), - deleted_at TIMESTAMP, - deleted_by UUID REFERENCES auth.users(id), - - CONSTRAINT uq_accounts_code_company UNIQUE (company_id, code), - CONSTRAINT chk_accounts_no_self_parent CHECK (id != parent_id) -); - --- Tabla: journals (Diarios contables) -CREATE TABLE financial.journals ( - 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 auth.companies(id) ON DELETE CASCADE, - - name VARCHAR(255) NOT NULL, - code VARCHAR(20) NOT NULL, - journal_type financial.journal_type NOT NULL, - - -- Configuración - default_account_id UUID REFERENCES financial.accounts(id), - sequence_id UUID REFERENCES core.sequences(id), - currency_id UUID REFERENCES core.currencies(id), - - -- Control - active BOOLEAN NOT NULL DEFAULT TRUE, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMP, - updated_by UUID REFERENCES auth.users(id), - deleted_at TIMESTAMP, - deleted_by UUID REFERENCES auth.users(id), - - CONSTRAINT uq_journals_code_company UNIQUE (company_id, code) -); - --- Tabla: fiscal_years (Años fiscales) -CREATE TABLE financial.fiscal_years ( - 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 auth.companies(id) ON DELETE CASCADE, - - name VARCHAR(100) NOT NULL, - code VARCHAR(20) NOT NULL, - start_date DATE NOT NULL, - end_date DATE NOT NULL, - status financial.fiscal_period_status NOT NULL DEFAULT 'open', - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMP, - updated_by UUID REFERENCES auth.users(id), - - CONSTRAINT uq_fiscal_years_code_company UNIQUE (company_id, code), - CONSTRAINT chk_fiscal_years_dates CHECK (end_date > start_date) -); - --- Tabla: fiscal_periods (Períodos fiscales - meses) -CREATE TABLE financial.fiscal_periods ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - fiscal_year_id UUID NOT NULL REFERENCES financial.fiscal_years(id) ON DELETE CASCADE, - - name VARCHAR(100) NOT NULL, - code VARCHAR(20) NOT NULL, - start_date DATE NOT NULL, - end_date DATE NOT NULL, - status financial.fiscal_period_status NOT NULL DEFAULT 'open', - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMP, - updated_by UUID REFERENCES auth.users(id), - - CONSTRAINT uq_fiscal_periods_code_year UNIQUE (fiscal_year_id, code), - CONSTRAINT chk_fiscal_periods_dates CHECK (end_date > start_date) -); - --- Tabla: journal_entries (Asientos contables) -CREATE TABLE financial.journal_entries ( - 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 auth.companies(id) ON DELETE CASCADE, - - journal_id UUID NOT NULL REFERENCES financial.journals(id), - name VARCHAR(100) NOT NULL, -- Número de asiento - ref VARCHAR(255), -- Referencia externa - date DATE NOT NULL, - status financial.entry_status NOT NULL DEFAULT 'draft', - - -- Metadatos - notes TEXT, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMP, - updated_by UUID REFERENCES auth.users(id), - posted_at TIMESTAMP, - posted_by UUID REFERENCES auth.users(id), - cancelled_at TIMESTAMP, - cancelled_by UUID REFERENCES auth.users(id), - - CONSTRAINT uq_journal_entries_name_journal UNIQUE (journal_id, name) -); - --- Tabla: journal_entry_lines (Líneas de asiento contable) -CREATE TABLE financial.journal_entry_lines ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - entry_id UUID NOT NULL REFERENCES financial.journal_entries(id) ON DELETE CASCADE, - - account_id UUID NOT NULL REFERENCES financial.accounts(id), - partner_id UUID REFERENCES core.partners(id), - - -- Montos - debit DECIMAL(15, 2) NOT NULL DEFAULT 0, - credit DECIMAL(15, 2) NOT NULL DEFAULT 0, - - -- Analítica - analytic_account_id UUID, -- FK a analytics.analytic_accounts (se crea después) - - -- Descripción - description TEXT, - ref VARCHAR(255), - - -- Multi-moneda - currency_id UUID REFERENCES core.currencies(id), - amount_currency DECIMAL(15, 2), - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT chk_journal_lines_debit_positive CHECK (debit >= 0), - CONSTRAINT chk_journal_lines_credit_positive CHECK (credit >= 0), - CONSTRAINT chk_journal_lines_not_both CHECK ( - (debit > 0 AND credit = 0) OR (credit > 0 AND debit = 0) - ) -); - --- Índices para journal_entry_lines -CREATE INDEX idx_journal_entry_lines_tenant_id ON financial.journal_entry_lines(tenant_id); -CREATE INDEX idx_journal_entry_lines_entry_id ON financial.journal_entry_lines(entry_id); -CREATE INDEX idx_journal_entry_lines_account_id ON financial.journal_entry_lines(account_id); - --- RLS para journal_entry_lines -ALTER TABLE financial.journal_entry_lines ENABLE ROW LEVEL SECURITY; -CREATE POLICY tenant_isolation_journal_entry_lines ON financial.journal_entry_lines - USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); - --- Tabla: taxes (Impuestos) -CREATE TABLE financial.taxes ( - 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 auth.companies(id) ON DELETE CASCADE, - - name VARCHAR(100) NOT NULL, - code VARCHAR(20) NOT NULL, - rate DECIMAL(5, 4) NOT NULL, -- 0.1600 para 16% - tax_type financial.tax_type NOT NULL, - - -- Configuración contable - account_id UUID REFERENCES financial.accounts(id), - - -- Control - active BOOLEAN NOT NULL DEFAULT TRUE, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMP, - updated_by UUID REFERENCES auth.users(id), - - CONSTRAINT uq_taxes_code_company UNIQUE (company_id, code), - CONSTRAINT chk_taxes_rate CHECK (rate >= 0 AND rate <= 1) -); - --- Tabla: payment_terms (Términos de pago) -CREATE TABLE financial.payment_terms ( - 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 auth.companies(id) ON DELETE CASCADE, - - name VARCHAR(100) NOT NULL, - code VARCHAR(20) NOT NULL, - - -- Configuración de términos (JSON) - -- Ejemplo: [{"days": 0, "percent": 100}] = Pago inmediato - -- Ejemplo: [{"days": 30, "percent": 100}] = 30 días - -- Ejemplo: [{"days": 15, "percent": 50}, {"days": 30, "percent": 50}] = 50% a 15 días, 50% a 30 días - terms JSONB NOT NULL DEFAULT '[]', - - -- Control - active BOOLEAN NOT NULL DEFAULT TRUE, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMP, - updated_by UUID REFERENCES auth.users(id), - - CONSTRAINT uq_payment_terms_code_company UNIQUE (company_id, code) -); - --- Tabla: invoices (Facturas) -CREATE TABLE financial.invoices ( - 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 auth.companies(id) ON DELETE CASCADE, - - partner_id UUID NOT NULL REFERENCES core.partners(id), - invoice_type financial.invoice_type NOT NULL, - - -- Numeración - number VARCHAR(100), -- Número de factura (generado al validar) - ref VARCHAR(100), -- Referencia del partner - - -- Fechas - invoice_date DATE NOT NULL, - due_date DATE, - - -- Montos - currency_id UUID NOT NULL REFERENCES core.currencies(id), - amount_untaxed DECIMAL(15, 2) NOT NULL DEFAULT 0, - amount_tax DECIMAL(15, 2) NOT NULL DEFAULT 0, - amount_total DECIMAL(15, 2) NOT NULL DEFAULT 0, - amount_paid DECIMAL(15, 2) NOT NULL DEFAULT 0, - amount_residual DECIMAL(15, 2) NOT NULL DEFAULT 0, - - -- Estado - status financial.invoice_status NOT NULL DEFAULT 'draft', - - -- Configuración - payment_term_id UUID REFERENCES financial.payment_terms(id), - journal_id UUID REFERENCES financial.journals(id), - - -- Asiento contable (generado al validar) - journal_entry_id UUID REFERENCES financial.journal_entries(id), - - -- Notas - notes TEXT, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMP, - updated_by UUID REFERENCES auth.users(id), - validated_at TIMESTAMP, - validated_by UUID REFERENCES auth.users(id), - cancelled_at TIMESTAMP, - cancelled_by UUID REFERENCES auth.users(id), - - CONSTRAINT uq_invoices_number_company UNIQUE (company_id, number), - CONSTRAINT chk_invoices_amounts CHECK ( - amount_total = amount_untaxed + amount_tax - ), - CONSTRAINT chk_invoices_residual CHECK ( - amount_residual = amount_total - amount_paid - ) -); - --- Tabla: invoice_lines (Líneas de factura) -CREATE TABLE financial.invoice_lines ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - invoice_id UUID NOT NULL REFERENCES financial.invoices(id) ON DELETE CASCADE, - - product_id UUID, -- FK a inventory.products (se crea después) - description TEXT NOT NULL, - - -- Cantidades y precios - quantity DECIMAL(12, 4) NOT NULL DEFAULT 1, - uom_id UUID REFERENCES core.uom(id), - price_unit DECIMAL(15, 4) NOT NULL, - - -- Impuestos (array de tax_ids) - tax_ids UUID[] DEFAULT '{}', - - -- Montos calculados - amount_untaxed DECIMAL(15, 2) NOT NULL, - amount_tax DECIMAL(15, 2) NOT NULL, - amount_total DECIMAL(15, 2) NOT NULL, - - -- Contabilidad - account_id UUID REFERENCES financial.accounts(id), - analytic_account_id UUID, -- FK a analytics.analytic_accounts - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP, - - CONSTRAINT chk_invoice_lines_quantity CHECK (quantity > 0), - CONSTRAINT chk_invoice_lines_amounts CHECK ( - amount_total = amount_untaxed + amount_tax - ) -); - --- Índices para invoice_lines -CREATE INDEX idx_invoice_lines_tenant_id ON financial.invoice_lines(tenant_id); -CREATE INDEX idx_invoice_lines_invoice_id ON financial.invoice_lines(invoice_id); -CREATE INDEX idx_invoice_lines_product_id ON financial.invoice_lines(product_id); - --- RLS para invoice_lines -ALTER TABLE financial.invoice_lines ENABLE ROW LEVEL SECURITY; -CREATE POLICY tenant_isolation_invoice_lines ON financial.invoice_lines - USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); - --- Tabla: payments (Pagos) -CREATE TABLE financial.payments ( - 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 auth.companies(id) ON DELETE CASCADE, - - partner_id UUID NOT NULL REFERENCES core.partners(id), - payment_type financial.payment_type NOT NULL, - payment_method financial.payment_method NOT NULL, - - -- Monto - amount DECIMAL(15, 2) NOT NULL, - currency_id UUID NOT NULL REFERENCES core.currencies(id), - - -- Fecha y referencia - payment_date DATE NOT NULL, - ref VARCHAR(255), - - -- Estado - status financial.payment_status NOT NULL DEFAULT 'draft', - - -- Configuración - journal_id UUID NOT NULL REFERENCES financial.journals(id), - - -- Asiento contable (generado al validar) - journal_entry_id UUID REFERENCES financial.journal_entries(id), - - -- Notas - notes TEXT, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMP, - updated_by UUID REFERENCES auth.users(id), - posted_at TIMESTAMP, - posted_by UUID REFERENCES auth.users(id), - - CONSTRAINT chk_payments_amount CHECK (amount > 0) -); - --- Tabla: payment_invoice (Conciliación pagos-facturas) -CREATE TABLE financial.payment_invoice ( - payment_id UUID NOT NULL REFERENCES financial.payments(id) ON DELETE CASCADE, - invoice_id UUID NOT NULL REFERENCES financial.invoices(id) ON DELETE CASCADE, - amount DECIMAL(15, 2) NOT NULL, - - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - - PRIMARY KEY (payment_id, invoice_id), - CONSTRAINT chk_payment_invoice_amount CHECK (amount > 0) -); - --- Tabla: bank_accounts (Cuentas bancarias) -CREATE TABLE financial.bank_accounts ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - company_id UUID REFERENCES auth.companies(id), - - partner_id UUID REFERENCES core.partners(id), -- Puede ser de la empresa o de un partner - - bank_name VARCHAR(255) NOT NULL, - account_number VARCHAR(50) NOT NULL, - account_holder VARCHAR(255), - - -- Configuración - currency_id UUID REFERENCES core.currencies(id), - journal_id UUID REFERENCES financial.journals(id), -- Diario asociado (si es cuenta de la empresa) - - -- Control - active BOOLEAN NOT NULL DEFAULT TRUE, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMP, - updated_by UUID REFERENCES auth.users(id) -); - --- Tabla: reconciliations (Conciliaciones bancarias) -CREATE TABLE financial.reconciliations ( - 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 auth.companies(id) ON DELETE CASCADE, - - bank_account_id UUID NOT NULL REFERENCES financial.bank_accounts(id), - - -- Período de conciliación - start_date DATE NOT NULL, - end_date DATE NOT NULL, - - -- Saldos - balance_start DECIMAL(15, 2) NOT NULL, - balance_end_real DECIMAL(15, 2) NOT NULL, -- Saldo real del banco - balance_end_computed DECIMAL(15, 2) NOT NULL, -- Saldo calculado - - -- Líneas conciliadas (array de journal_entry_line_ids) - reconciled_line_ids UUID[] DEFAULT '{}', - - -- Estado - status financial.entry_status NOT NULL DEFAULT 'draft', - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMP, - updated_by UUID REFERENCES auth.users(id), - validated_at TIMESTAMP, - validated_by UUID REFERENCES auth.users(id), - - CONSTRAINT chk_reconciliations_dates CHECK (end_date >= start_date) -); - --- ===================================================== --- INDICES --- ===================================================== - --- Account Types -CREATE INDEX idx_account_types_code ON financial.account_types(code); - --- Accounts -CREATE INDEX idx_accounts_tenant_id ON financial.accounts(tenant_id); -CREATE INDEX idx_accounts_company_id ON financial.accounts(company_id); -CREATE INDEX idx_accounts_code ON financial.accounts(code); -CREATE INDEX idx_accounts_parent_id ON financial.accounts(parent_id); -CREATE INDEX idx_accounts_type_id ON financial.accounts(account_type_id); - --- Journals -CREATE INDEX idx_journals_tenant_id ON financial.journals(tenant_id); -CREATE INDEX idx_journals_company_id ON financial.journals(company_id); -CREATE INDEX idx_journals_code ON financial.journals(code); -CREATE INDEX idx_journals_type ON financial.journals(journal_type); - --- Fiscal Years -CREATE INDEX idx_fiscal_years_tenant_id ON financial.fiscal_years(tenant_id); -CREATE INDEX idx_fiscal_years_company_id ON financial.fiscal_years(company_id); -CREATE INDEX idx_fiscal_years_dates ON financial.fiscal_years(start_date, end_date); - --- Fiscal Periods -CREATE INDEX idx_fiscal_periods_tenant_id ON financial.fiscal_periods(tenant_id); -CREATE INDEX idx_fiscal_periods_year_id ON financial.fiscal_periods(fiscal_year_id); -CREATE INDEX idx_fiscal_periods_dates ON financial.fiscal_periods(start_date, end_date); - --- Journal Entries -CREATE INDEX idx_journal_entries_tenant_id ON financial.journal_entries(tenant_id); -CREATE INDEX idx_journal_entries_company_id ON financial.journal_entries(company_id); -CREATE INDEX idx_journal_entries_journal_id ON financial.journal_entries(journal_id); -CREATE INDEX idx_journal_entries_date ON financial.journal_entries(date); -CREATE INDEX idx_journal_entries_status ON financial.journal_entries(status); - --- Journal Entry Lines -CREATE INDEX idx_journal_entry_lines_entry_id ON financial.journal_entry_lines(entry_id); -CREATE INDEX idx_journal_entry_lines_account_id ON financial.journal_entry_lines(account_id); -CREATE INDEX idx_journal_entry_lines_partner_id ON financial.journal_entry_lines(partner_id); -CREATE INDEX idx_journal_entry_lines_analytic ON financial.journal_entry_lines(analytic_account_id); - --- Taxes -CREATE INDEX idx_taxes_tenant_id ON financial.taxes(tenant_id); -CREATE INDEX idx_taxes_company_id ON financial.taxes(company_id); -CREATE INDEX idx_taxes_code ON financial.taxes(code); -CREATE INDEX idx_taxes_type ON financial.taxes(tax_type); -CREATE INDEX idx_taxes_active ON financial.taxes(active) WHERE active = TRUE; - --- Payment Terms -CREATE INDEX idx_payment_terms_tenant_id ON financial.payment_terms(tenant_id); -CREATE INDEX idx_payment_terms_company_id ON financial.payment_terms(company_id); - --- Invoices -CREATE INDEX idx_invoices_tenant_id ON financial.invoices(tenant_id); -CREATE INDEX idx_invoices_company_id ON financial.invoices(company_id); -CREATE INDEX idx_invoices_partner_id ON financial.invoices(partner_id); -CREATE INDEX idx_invoices_type ON financial.invoices(invoice_type); -CREATE INDEX idx_invoices_status ON financial.invoices(status); -CREATE INDEX idx_invoices_number ON financial.invoices(number); -CREATE INDEX idx_invoices_date ON financial.invoices(invoice_date); -CREATE INDEX idx_invoices_due_date ON financial.invoices(due_date); - --- Invoice Lines -CREATE INDEX idx_invoice_lines_invoice_id ON financial.invoice_lines(invoice_id); -CREATE INDEX idx_invoice_lines_product_id ON financial.invoice_lines(product_id); -CREATE INDEX idx_invoice_lines_account_id ON financial.invoice_lines(account_id); - --- Payments -CREATE INDEX idx_payments_tenant_id ON financial.payments(tenant_id); -CREATE INDEX idx_payments_company_id ON financial.payments(company_id); -CREATE INDEX idx_payments_partner_id ON financial.payments(partner_id); -CREATE INDEX idx_payments_type ON financial.payments(payment_type); -CREATE INDEX idx_payments_status ON financial.payments(status); -CREATE INDEX idx_payments_date ON financial.payments(payment_date); - --- Payment Invoice -CREATE INDEX idx_payment_invoice_payment_id ON financial.payment_invoice(payment_id); -CREATE INDEX idx_payment_invoice_invoice_id ON financial.payment_invoice(invoice_id); - --- Bank Accounts -CREATE INDEX idx_bank_accounts_tenant_id ON financial.bank_accounts(tenant_id); -CREATE INDEX idx_bank_accounts_company_id ON financial.bank_accounts(company_id); -CREATE INDEX idx_bank_accounts_partner_id ON financial.bank_accounts(partner_id); - --- Reconciliations -CREATE INDEX idx_reconciliations_tenant_id ON financial.reconciliations(tenant_id); -CREATE INDEX idx_reconciliations_company_id ON financial.reconciliations(company_id); -CREATE INDEX idx_reconciliations_bank_account_id ON financial.reconciliations(bank_account_id); -CREATE INDEX idx_reconciliations_dates ON financial.reconciliations(start_date, end_date); - --- ===================================================== --- FUNCTIONS --- ===================================================== - --- Función: validate_entry_balance --- Valida que un asiento esté balanceado (debit = credit) -CREATE OR REPLACE FUNCTION financial.validate_entry_balance(p_entry_id UUID) -RETURNS BOOLEAN AS $$ -DECLARE - v_total_debit DECIMAL; - v_total_credit DECIMAL; -BEGIN - SELECT - COALESCE(SUM(debit), 0), - COALESCE(SUM(credit), 0) - INTO v_total_debit, v_total_credit - FROM financial.journal_entry_lines - WHERE entry_id = p_entry_id; - - IF v_total_debit != v_total_credit THEN - RAISE EXCEPTION 'Journal entry % is not balanced: debit=% credit=%', - p_entry_id, v_total_debit, v_total_credit; - END IF; - - RETURN TRUE; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION financial.validate_entry_balance IS 'Valida que un asiento contable esté balanceado (debit = credit)'; - --- Función: post_journal_entry --- Contabiliza un asiento (cambiar estado a posted) -CREATE OR REPLACE FUNCTION financial.post_journal_entry(p_entry_id UUID) -RETURNS VOID AS $$ -BEGIN - -- Validar balance - PERFORM financial.validate_entry_balance(p_entry_id); - - -- Actualizar estado - UPDATE financial.journal_entries - SET status = 'posted', - posted_at = CURRENT_TIMESTAMP, - posted_by = get_current_user_id() - WHERE id = p_entry_id - AND status = 'draft'; - - IF NOT FOUND THEN - RAISE EXCEPTION 'Journal entry % not found or already posted', p_entry_id; - END IF; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION financial.post_journal_entry IS 'Contabiliza un asiento contable después de validar su balance'; - --- Función: calculate_invoice_totals --- Calcula los totales de una factura a partir de sus líneas -CREATE OR REPLACE FUNCTION financial.calculate_invoice_totals(p_invoice_id UUID) -RETURNS VOID AS $$ -DECLARE - v_amount_untaxed DECIMAL; - v_amount_tax DECIMAL; - v_amount_total DECIMAL; -BEGIN - SELECT - COALESCE(SUM(amount_untaxed), 0), - COALESCE(SUM(amount_tax), 0), - COALESCE(SUM(amount_total), 0) - INTO v_amount_untaxed, v_amount_tax, v_amount_total - FROM financial.invoice_lines - WHERE invoice_id = p_invoice_id; - - UPDATE financial.invoices - SET amount_untaxed = v_amount_untaxed, - amount_tax = v_amount_tax, - amount_total = v_amount_total, - amount_residual = v_amount_total - amount_paid, - updated_at = CURRENT_TIMESTAMP, - updated_by = get_current_user_id() - WHERE id = p_invoice_id; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION financial.calculate_invoice_totals IS 'Calcula los totales de una factura a partir de sus líneas'; - --- Función: update_invoice_paid_amount --- Actualiza el monto pagado de una factura -CREATE OR REPLACE FUNCTION financial.update_invoice_paid_amount(p_invoice_id UUID) -RETURNS VOID AS $$ -DECLARE - v_amount_paid DECIMAL; -BEGIN - SELECT COALESCE(SUM(amount), 0) - INTO v_amount_paid - FROM financial.payment_invoice - WHERE invoice_id = p_invoice_id; - - UPDATE financial.invoices - SET amount_paid = v_amount_paid, - amount_residual = amount_total - v_amount_paid, - status = CASE - WHEN v_amount_paid >= amount_total THEN 'paid'::financial.invoice_status - WHEN v_amount_paid > 0 THEN 'open'::financial.invoice_status - ELSE status - END - WHERE id = p_invoice_id; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION financial.update_invoice_paid_amount IS 'Actualiza el monto pagado y estado de una factura'; - --- ===================================================== --- TRIGGERS --- ===================================================== - --- Trigger: Actualizar updated_at -CREATE TRIGGER trg_accounts_updated_at - BEFORE UPDATE ON financial.accounts - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at_column(); - -CREATE TRIGGER trg_journals_updated_at - BEFORE UPDATE ON financial.journals - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at_column(); - -CREATE TRIGGER trg_fiscal_years_updated_at - BEFORE UPDATE ON financial.fiscal_years - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at_column(); - -CREATE TRIGGER trg_fiscal_periods_updated_at - BEFORE UPDATE ON financial.fiscal_periods - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at_column(); - -CREATE TRIGGER trg_journal_entries_updated_at - BEFORE UPDATE ON financial.journal_entries - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at_column(); - -CREATE TRIGGER trg_taxes_updated_at - BEFORE UPDATE ON financial.taxes - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at_column(); - -CREATE TRIGGER trg_payment_terms_updated_at - BEFORE UPDATE ON financial.payment_terms - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at_column(); - -CREATE TRIGGER trg_invoices_updated_at - BEFORE UPDATE ON financial.invoices - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at_column(); - -CREATE TRIGGER trg_payments_updated_at - BEFORE UPDATE ON financial.payments - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at_column(); - -CREATE TRIGGER trg_bank_accounts_updated_at - BEFORE UPDATE ON financial.bank_accounts - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at_column(); - -CREATE TRIGGER trg_reconciliations_updated_at - BEFORE UPDATE ON financial.reconciliations - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at_column(); - --- Trigger: Validar balance antes de contabilizar -CREATE OR REPLACE FUNCTION financial.trg_validate_entry_before_post() -RETURNS TRIGGER AS $$ -BEGIN - IF NEW.status = 'posted' AND OLD.status = 'draft' THEN - PERFORM financial.validate_entry_balance(NEW.id); - END IF; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER trg_journal_entries_validate_balance - BEFORE UPDATE OF status ON financial.journal_entries - FOR EACH ROW - EXECUTE FUNCTION financial.trg_validate_entry_before_post(); - --- Trigger: Actualizar totales de factura al cambiar líneas -CREATE OR REPLACE FUNCTION financial.trg_update_invoice_totals() -RETURNS TRIGGER AS $$ -BEGIN - IF TG_OP = 'DELETE' THEN - PERFORM financial.calculate_invoice_totals(OLD.invoice_id); - ELSE - PERFORM financial.calculate_invoice_totals(NEW.invoice_id); - END IF; - RETURN NULL; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER trg_invoice_lines_update_totals - AFTER INSERT OR UPDATE OR DELETE ON financial.invoice_lines - FOR EACH ROW - EXECUTE FUNCTION financial.trg_update_invoice_totals(); - --- Trigger: Actualizar monto pagado al conciliar -CREATE OR REPLACE FUNCTION financial.trg_update_invoice_paid() -RETURNS TRIGGER AS $$ -BEGIN - IF TG_OP = 'DELETE' THEN - PERFORM financial.update_invoice_paid_amount(OLD.invoice_id); - ELSE - PERFORM financial.update_invoice_paid_amount(NEW.invoice_id); - END IF; - RETURN NULL; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER trg_payment_invoice_update_paid - AFTER INSERT OR UPDATE OR DELETE ON financial.payment_invoice - FOR EACH ROW - EXECUTE FUNCTION financial.trg_update_invoice_paid(); - --- ===================================================== --- TRACKING AUTOMÁTICO (mail.thread pattern) --- ===================================================== - --- Trigger: Tracking automático para facturas -CREATE TRIGGER track_invoice_changes - AFTER INSERT OR UPDATE OR DELETE ON financial.invoices - FOR EACH ROW EXECUTE FUNCTION system.track_field_changes(); - -COMMENT ON TRIGGER track_invoice_changes ON financial.invoices IS -'Registra automáticamente cambios en facturas (estado, monto, cliente, fechas)'; - --- Trigger: Tracking automático para asientos contables -CREATE TRIGGER track_journal_entry_changes - AFTER INSERT OR UPDATE OR DELETE ON financial.journal_entries - FOR EACH ROW EXECUTE FUNCTION system.track_field_changes(); - -COMMENT ON TRIGGER track_journal_entry_changes ON financial.journal_entries IS -'Registra automáticamente cambios en asientos contables (estado, fecha, diario)'; - --- ===================================================== --- ROW LEVEL SECURITY (RLS) --- ===================================================== - -ALTER TABLE financial.accounts ENABLE ROW LEVEL SECURITY; -ALTER TABLE financial.journals ENABLE ROW LEVEL SECURITY; -ALTER TABLE financial.fiscal_years ENABLE ROW LEVEL SECURITY; -ALTER TABLE financial.fiscal_periods ENABLE ROW LEVEL SECURITY; -ALTER TABLE financial.journal_entries ENABLE ROW LEVEL SECURITY; -ALTER TABLE financial.taxes ENABLE ROW LEVEL SECURITY; -ALTER TABLE financial.payment_terms ENABLE ROW LEVEL SECURITY; -ALTER TABLE financial.invoices ENABLE ROW LEVEL SECURITY; -ALTER TABLE financial.payments ENABLE ROW LEVEL SECURITY; -ALTER TABLE financial.bank_accounts ENABLE ROW LEVEL SECURITY; -ALTER TABLE financial.reconciliations ENABLE ROW LEVEL SECURITY; - -CREATE POLICY tenant_isolation_accounts ON financial.accounts - USING (tenant_id = get_current_tenant_id()); - -CREATE POLICY tenant_isolation_journals ON financial.journals - USING (tenant_id = get_current_tenant_id()); - -CREATE POLICY tenant_isolation_fiscal_years ON financial.fiscal_years - USING (tenant_id = get_current_tenant_id()); - -CREATE POLICY tenant_isolation_fiscal_periods ON financial.fiscal_periods - USING (tenant_id = get_current_tenant_id()); - -CREATE POLICY tenant_isolation_journal_entries ON financial.journal_entries - USING (tenant_id = get_current_tenant_id()); - -CREATE POLICY tenant_isolation_taxes ON financial.taxes - USING (tenant_id = get_current_tenant_id()); - -CREATE POLICY tenant_isolation_payment_terms ON financial.payment_terms - USING (tenant_id = get_current_tenant_id()); - -CREATE POLICY tenant_isolation_invoices ON financial.invoices - USING (tenant_id = get_current_tenant_id()); - -CREATE POLICY tenant_isolation_payments ON financial.payments - USING (tenant_id = get_current_tenant_id()); - -CREATE POLICY tenant_isolation_bank_accounts ON financial.bank_accounts - USING (tenant_id = get_current_tenant_id()); - -CREATE POLICY tenant_isolation_reconciliations ON financial.reconciliations - USING (tenant_id = get_current_tenant_id()); - --- ===================================================== --- SEED DATA --- ===================================================== - --- Tipos de cuenta estándar -INSERT INTO financial.account_types (code, name, account_type, description) VALUES -('ASSET_CASH', 'Cash and Cash Equivalents', 'asset', 'Efectivo y equivalentes'), -('ASSET_RECEIVABLE', 'Accounts Receivable', 'asset', 'Cuentas por cobrar'), -('ASSET_CURRENT', 'Current Assets', 'asset', 'Activos circulantes'), -('ASSET_FIXED', 'Fixed Assets', 'asset', 'Activos fijos'), -('LIABILITY_PAYABLE', 'Accounts Payable', 'liability', 'Cuentas por pagar'), -('LIABILITY_CURRENT', 'Current Liabilities', 'liability', 'Pasivos circulantes'), -('LIABILITY_LONG', 'Long-term Liabilities', 'liability', 'Pasivos a largo plazo'), -('EQUITY_CAPITAL', 'Capital', 'equity', 'Capital social'), -('EQUITY_RETAINED', 'Retained Earnings', 'equity', 'Utilidades retenidas'), -('REVENUE_SALES', 'Sales Revenue', 'revenue', 'Ingresos por ventas'), -('REVENUE_OTHER', 'Other Revenue', 'revenue', 'Otros ingresos'), -('EXPENSE_COGS', 'Cost of Goods Sold', 'expense', 'Costo de ventas'), -('EXPENSE_OPERATING', 'Operating Expenses', 'expense', 'Gastos operativos'), -('EXPENSE_ADMIN', 'Administrative Expenses', 'expense', 'Gastos administrativos') -ON CONFLICT (code) DO NOTHING; - --- ===================================================== --- COMENTARIOS --- ===================================================== - -COMMENT ON SCHEMA financial IS 'Schema de contabilidad, facturas, pagos y finanzas'; -COMMENT ON TABLE financial.account_types IS 'Tipos de cuentas contables (asset, liability, equity, revenue, expense)'; -COMMENT ON TABLE financial.accounts IS 'Plan de cuentas contables'; -COMMENT ON TABLE financial.journals IS 'Diarios contables (ventas, compras, bancos, etc.)'; -COMMENT ON TABLE financial.fiscal_years IS 'Años fiscales'; -COMMENT ON TABLE financial.fiscal_periods IS 'Períodos fiscales (meses)'; -COMMENT ON TABLE financial.journal_entries IS 'Asientos contables'; -COMMENT ON TABLE financial.journal_entry_lines IS 'Líneas de asientos contables (partida doble)'; -COMMENT ON TABLE financial.taxes IS 'Impuestos (IVA, retenciones, etc.)'; -COMMENT ON TABLE financial.payment_terms IS 'Términos de pago (inmediato, 30 días, etc.)'; -COMMENT ON TABLE financial.invoices IS 'Facturas de cliente y proveedor'; -COMMENT ON TABLE financial.invoice_lines IS 'Líneas de factura'; -COMMENT ON TABLE financial.payments IS 'Pagos y cobros'; -COMMENT ON TABLE financial.payment_invoice IS 'Conciliación de pagos con facturas'; -COMMENT ON TABLE financial.bank_accounts IS 'Cuentas bancarias de la empresa y partners'; -COMMENT ON TABLE financial.reconciliations IS 'Conciliaciones bancarias'; - --- ===================================================== --- FIN DEL SCHEMA FINANCIAL --- ===================================================== diff --git a/ddl/04-mobile.sql b/ddl/04-mobile.sql new file mode 100644 index 0000000..521aeea --- /dev/null +++ b/ddl/04-mobile.sql @@ -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'; diff --git a/ddl/05-billing-usage.sql b/ddl/05-billing-usage.sql new file mode 100644 index 0000000..a50dfae --- /dev/null +++ b/ddl/05-billing-usage.sql @@ -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'; diff --git a/ddl/05-inventory-extensions.sql b/ddl/05-inventory-extensions.sql deleted file mode 100644 index f2b3a2f..0000000 --- a/ddl/05-inventory-extensions.sql +++ /dev/null @@ -1,966 +0,0 @@ --- ===================================================== --- SCHEMA: inventory (Extensiones) --- PROPÓSITO: Valoración de Inventario, Lotes/Series, Conteos Cíclicos --- MÓDULO: MGN-005 (Inventario) --- FECHA: 2025-12-08 --- VERSION: 1.0.0 --- DEPENDENCIAS: 05-inventory.sql --- SPECS RELACIONADAS: --- - SPEC-VALORACION-INVENTARIO.md --- - SPEC-TRAZABILIDAD-LOTES-SERIES.md --- - SPEC-INVENTARIOS-CICLICOS.md --- ===================================================== - --- ===================================================== --- PARTE 1: VALORACIÓN DE INVENTARIO (SVL) --- ===================================================== - --- Tabla: stock_valuation_layers (Capas de valoración FIFO/AVCO) -CREATE TABLE inventory.stock_valuation_layers ( - -- Identificación - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Referencias - product_id UUID NOT NULL REFERENCES inventory.products(id), - stock_move_id UUID REFERENCES inventory.stock_moves(id), - lot_id UUID REFERENCES inventory.lots(id), - company_id UUID NOT NULL REFERENCES auth.companies(id), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id), - - -- Valores de la capa - quantity DECIMAL(16,4) NOT NULL, -- Cantidad (positiva=entrada, negativa=salida) - unit_cost DECIMAL(16,6) NOT NULL, -- Costo unitario - value DECIMAL(16,4) NOT NULL, -- Valor total - currency_id UUID REFERENCES core.currencies(id), - - -- Tracking FIFO (solo para entradas) - remaining_qty DECIMAL(16,4) NOT NULL DEFAULT 0, -- Cantidad restante por consumir - remaining_value DECIMAL(16,4) NOT NULL DEFAULT 0, -- Valor restante - - -- Diferencia de precio (facturas vs recepción) - price_diff_value DECIMAL(16,4) DEFAULT 0, - - -- Referencias contables (usando journal_entries del schema financial) - journal_entry_id UUID REFERENCES financial.journal_entries(id), - journal_entry_line_id UUID REFERENCES financial.journal_entry_lines(id), - - -- Corrección de vacío (link a capa corregida) - parent_svl_id UUID REFERENCES inventory.stock_valuation_layers(id), - - -- Metadata - description VARCHAR(500), - reference VARCHAR(255), - - -- Auditoría - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - created_by UUID REFERENCES auth.users(id), - - -- Constraints - CONSTRAINT chk_svl_value CHECK ( - ABS(value - (quantity * unit_cost)) < 0.01 OR quantity = 0 - ) -); - --- Índice principal para FIFO (crítico para performance) -CREATE INDEX idx_svl_fifo_candidates ON inventory.stock_valuation_layers ( - product_id, - remaining_qty, - stock_move_id, - company_id, - created_at -) WHERE remaining_qty > 0; - --- Índice para agregación de valoración -CREATE INDEX idx_svl_valuation ON inventory.stock_valuation_layers ( - product_id, - company_id, - id, - value, - quantity -); - --- Índice por lote -CREATE INDEX idx_svl_lot ON inventory.stock_valuation_layers (lot_id) - WHERE lot_id IS NOT NULL; - --- Índice por movimiento -CREATE INDEX idx_svl_move ON inventory.stock_valuation_layers (stock_move_id); - --- Índice por tenant -CREATE INDEX idx_svl_tenant ON inventory.stock_valuation_layers (tenant_id); - --- Comentarios -COMMENT ON TABLE inventory.stock_valuation_layers IS 'Capas de valoración de inventario para costeo FIFO/AVCO'; -COMMENT ON COLUMN inventory.stock_valuation_layers.remaining_qty IS 'Cantidad aún no consumida por FIFO'; -COMMENT ON COLUMN inventory.stock_valuation_layers.parent_svl_id IS 'Referencia a capa padre cuando es corrección de vacío'; - --- Vista materializada para valores agregados de SVL por producto -CREATE MATERIALIZED VIEW inventory.product_valuation_summary AS -SELECT - svl.product_id, - svl.company_id, - svl.tenant_id, - SUM(svl.quantity) AS quantity_svl, - SUM(svl.value) AS value_svl, - CASE - WHEN SUM(svl.quantity) > 0 THEN SUM(svl.value) / SUM(svl.quantity) - ELSE 0 - END AS avg_cost -FROM inventory.stock_valuation_layers svl -GROUP BY svl.product_id, svl.company_id, svl.tenant_id; - -CREATE UNIQUE INDEX idx_product_valuation_pk - ON inventory.product_valuation_summary (product_id, company_id, tenant_id); - -COMMENT ON MATERIALIZED VIEW inventory.product_valuation_summary IS - 'Resumen de valoración por producto - refrescar con REFRESH MATERIALIZED VIEW CONCURRENTLY'; - --- Configuración de cuentas por categoría de producto -CREATE TABLE inventory.category_stock_accounts ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - category_id UUID NOT NULL REFERENCES core.product_categories(id), - company_id UUID NOT NULL REFERENCES auth.companies(id), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id), - - -- Cuentas de valoración - stock_input_account_id UUID REFERENCES financial.accounts(id), -- Entrada de stock - stock_output_account_id UUID REFERENCES financial.accounts(id), -- Salida de stock - stock_valuation_account_id UUID REFERENCES financial.accounts(id), -- Valoración (activo) - expense_account_id UUID REFERENCES financial.accounts(id), -- Gasto/COGS - - -- Diario para asientos de stock - stock_journal_id UUID REFERENCES financial.journals(id), - - -- Auditoría - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ, - - CONSTRAINT uq_category_stock_accounts - UNIQUE (category_id, company_id, tenant_id) -); - -COMMENT ON TABLE inventory.category_stock_accounts IS 'Cuentas contables para valoración de inventario por categoría'; - --- Parámetros de valoración por tenant -CREATE TABLE inventory.valuation_settings ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id), - company_id UUID REFERENCES auth.companies(id), - - allow_negative_stock BOOLEAN NOT NULL DEFAULT FALSE, - default_cost_method VARCHAR(20) NOT NULL DEFAULT 'fifo' - CHECK (default_cost_method IN ('standard', 'average', 'fifo')), - default_valuation VARCHAR(20) NOT NULL DEFAULT 'real_time' - CHECK (default_valuation IN ('manual', 'real_time')), - auto_vacuum_enabled BOOLEAN NOT NULL DEFAULT TRUE, - vacuum_batch_size INTEGER NOT NULL DEFAULT 100, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - CONSTRAINT uq_valuation_settings_tenant_company UNIQUE (tenant_id, company_id) -); - -COMMENT ON TABLE inventory.valuation_settings IS 'Configuración de valoración de inventario por tenant/empresa'; - --- Extensión de product_categories para costeo (tabla en schema core) -ALTER TABLE core.product_categories ADD COLUMN IF NOT EXISTS - cost_method VARCHAR(20) NOT NULL DEFAULT 'fifo' - CHECK (cost_method IN ('standard', 'average', 'fifo')); - -ALTER TABLE core.product_categories ADD COLUMN IF NOT EXISTS - valuation_method VARCHAR(20) NOT NULL DEFAULT 'real_time' - CHECK (valuation_method IN ('manual', 'real_time')); - --- Extensión de products para costeo -ALTER TABLE inventory.products ADD COLUMN IF NOT EXISTS - standard_price DECIMAL(16,6) NOT NULL DEFAULT 0; - -ALTER TABLE inventory.products ADD COLUMN IF NOT EXISTS - lot_valuated BOOLEAN NOT NULL DEFAULT FALSE; - --- ===================================================== --- PARTE 2: TRAZABILIDAD DE LOTES Y SERIES --- ===================================================== - --- Extensión de products para tracking -ALTER TABLE inventory.products ADD COLUMN IF NOT EXISTS - tracking VARCHAR(16) NOT NULL DEFAULT 'none' - CHECK (tracking IN ('none', 'lot', 'serial')); - --- Configuración de caducidad -ALTER TABLE inventory.products ADD COLUMN IF NOT EXISTS - use_expiration_date BOOLEAN NOT NULL DEFAULT FALSE; - -ALTER TABLE inventory.products ADD COLUMN IF NOT EXISTS - expiration_time INTEGER; -- Días hasta caducidad desde recepción - -ALTER TABLE inventory.products ADD COLUMN IF NOT EXISTS - use_time INTEGER; -- Días antes de caducidad para "consumir preferentemente" - -ALTER TABLE inventory.products ADD COLUMN IF NOT EXISTS - removal_time INTEGER; -- Días antes de caducidad para remover de venta - -ALTER TABLE inventory.products ADD COLUMN IF NOT EXISTS - alert_time INTEGER; -- Días antes de caducidad para alertar - --- Propiedades dinámicas por lote -ALTER TABLE inventory.products ADD COLUMN IF NOT EXISTS - lot_properties_definition JSONB DEFAULT '[]'; - --- Constraint de consistencia -ALTER TABLE inventory.products ADD CONSTRAINT chk_expiration_config CHECK ( - use_expiration_date = FALSE OR ( - expiration_time IS NOT NULL AND - expiration_time > 0 - ) -); - --- Índice para productos con tracking -CREATE INDEX idx_products_tracking ON inventory.products(tracking) - WHERE tracking != 'none'; - --- Tabla: lots (Lotes y números de serie) -CREATE TABLE inventory.lots ( - -- Identificación - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name VARCHAR(128) NOT NULL, - ref VARCHAR(256), -- Referencia interna/externa - - -- Relaciones - product_id UUID NOT NULL REFERENCES inventory.products(id), - company_id UUID NOT NULL REFERENCES auth.companies(id), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id), - - -- Fechas de caducidad - expiration_date TIMESTAMPTZ, - use_date TIMESTAMPTZ, -- Best-before - removal_date TIMESTAMPTZ, -- Fecha de retiro FEFO - alert_date TIMESTAMPTZ, -- Fecha de alerta - - -- Control de alertas - expiry_alerted BOOLEAN NOT NULL DEFAULT FALSE, - - -- Propiedades dinámicas (heredadas del producto) - lot_properties JSONB DEFAULT '{}', - - -- Ubicación (si solo hay una) - location_id UUID REFERENCES inventory.locations(id), - - -- Auditoría - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - created_by UUID REFERENCES auth.users(id), - - -- Constraints - CONSTRAINT uk_lot_product_company UNIQUE (product_id, name, company_id) -); - --- Índices para lots -CREATE INDEX idx_lots_product ON inventory.lots(product_id); -CREATE INDEX idx_lots_tenant ON inventory.lots(tenant_id); -CREATE INDEX idx_lots_expiration ON inventory.lots(expiration_date) - WHERE expiration_date IS NOT NULL; -CREATE INDEX idx_lots_removal ON inventory.lots(removal_date) - WHERE removal_date IS NOT NULL; -CREATE INDEX idx_lots_alert ON inventory.lots(alert_date) - WHERE alert_date IS NOT NULL AND NOT expiry_alerted; - --- Extensión para búsqueda por trigram (requiere pg_trgm) --- CREATE INDEX idx_lots_name_trgm ON inventory.lots USING GIN (name gin_trgm_ops); - -COMMENT ON TABLE inventory.lots IS 'Lotes y números de serie para trazabilidad de productos'; - --- Extensión de quants para lotes -ALTER TABLE inventory.quants ADD COLUMN IF NOT EXISTS - lot_id UUID REFERENCES inventory.lots(id); - -ALTER TABLE inventory.quants ADD COLUMN IF NOT EXISTS - in_date TIMESTAMPTZ NOT NULL DEFAULT NOW(); - --- Fecha de remoción para FEFO (heredada del lote) -ALTER TABLE inventory.quants ADD COLUMN IF NOT EXISTS - removal_date TIMESTAMPTZ; - --- Índices optimizados para quants -CREATE INDEX idx_quants_lot ON inventory.quants(lot_id) - WHERE lot_id IS NOT NULL; - -CREATE INDEX idx_quants_fefo ON inventory.quants(product_id, location_id, removal_date, in_date) - WHERE quantity > 0; - -CREATE INDEX idx_quants_fifo ON inventory.quants(product_id, location_id, in_date) - WHERE quantity > 0; - --- Extensión de stock_moves para lotes (tracking de lotes en movimientos) -ALTER TABLE inventory.stock_moves ADD COLUMN IF NOT EXISTS - lot_id UUID REFERENCES inventory.lots(id); - -ALTER TABLE inventory.stock_moves ADD COLUMN IF NOT EXISTS - lot_name VARCHAR(128); -- Para creación on-the-fly - -ALTER TABLE inventory.stock_moves ADD COLUMN IF NOT EXISTS - tracking VARCHAR(16); -- Copia del producto (none, lot, serial) - --- Índices para lotes en movimientos -CREATE INDEX IF NOT EXISTS idx_stock_moves_lot ON inventory.stock_moves(lot_id) - WHERE lot_id IS NOT NULL; -CREATE INDEX IF NOT EXISTS idx_stock_moves_lot_name ON inventory.stock_moves(lot_name) - WHERE lot_name IS NOT NULL; - --- Tabla de relación para trazabilidad de manufactura (consume/produce) -CREATE TABLE inventory.stock_move_consume_rel ( - consume_move_id UUID NOT NULL REFERENCES inventory.stock_moves(id) ON DELETE CASCADE, - produce_move_id UUID NOT NULL REFERENCES inventory.stock_moves(id) ON DELETE CASCADE, - quantity DECIMAL(16,4) NOT NULL DEFAULT 0, -- Cantidad consumida/producida - PRIMARY KEY (consume_move_id, produce_move_id) -); - -CREATE INDEX idx_consume_rel_consume ON inventory.stock_move_consume_rel(consume_move_id); -CREATE INDEX idx_consume_rel_produce ON inventory.stock_move_consume_rel(produce_move_id); - -COMMENT ON TABLE inventory.stock_move_consume_rel IS 'Relación M:N para trazabilidad de consumo en manufactura'; - --- Tabla: removal_strategies (Estrategias de salida) -CREATE TABLE inventory.removal_strategies ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name VARCHAR(64) NOT NULL, - code VARCHAR(16) NOT NULL UNIQUE - CHECK (code IN ('fifo', 'lifo', 'fefo', 'closest')), - description TEXT, - is_active BOOLEAN NOT NULL DEFAULT TRUE -); - --- Datos iniciales de estrategias -INSERT INTO inventory.removal_strategies (name, code, description) VALUES - ('First In, First Out', 'fifo', 'El stock más antiguo sale primero'), - ('Last In, First Out', 'lifo', 'El stock más reciente sale primero'), - ('First Expiry, First Out', 'fefo', 'El stock que caduca primero sale primero'), - ('Closest Location', 'closest', 'El stock de ubicación más cercana sale primero') -ON CONFLICT (code) DO NOTHING; - -COMMENT ON TABLE inventory.removal_strategies IS 'Estrategias de salida de inventario (FIFO, LIFO, FEFO)'; - --- Agregar estrategia a categorías y ubicaciones -ALTER TABLE core.product_categories ADD COLUMN IF NOT EXISTS - removal_strategy_id UUID REFERENCES inventory.removal_strategies(id); - -ALTER TABLE inventory.locations ADD COLUMN IF NOT EXISTS - removal_strategy_id UUID REFERENCES inventory.removal_strategies(id); - --- ===================================================== --- PARTE 3: CONTEOS CÍCLICOS --- ===================================================== - --- Extensión de locations para conteo cíclico -ALTER TABLE inventory.locations ADD COLUMN IF NOT EXISTS - cyclic_inventory_frequency INTEGER DEFAULT 0; - -ALTER TABLE inventory.locations ADD COLUMN IF NOT EXISTS - last_inventory_date DATE; - -ALTER TABLE inventory.locations ADD COLUMN IF NOT EXISTS - abc_classification VARCHAR(1) DEFAULT 'C' - CHECK (abc_classification IN ('A', 'B', 'C')); - -COMMENT ON COLUMN inventory.locations.cyclic_inventory_frequency IS - 'Días entre conteos cíclicos. 0 = deshabilitado'; -COMMENT ON COLUMN inventory.locations.abc_classification IS - 'Clasificación ABC: A=Alta rotación, B=Media, C=Baja'; - --- Índice para ubicaciones pendientes de conteo -CREATE INDEX idx_locations_cyclic_inventory - ON inventory.locations(last_inventory_date, cyclic_inventory_frequency) - WHERE cyclic_inventory_frequency > 0; - --- Extensión de quants para inventario -ALTER TABLE inventory.quants ADD COLUMN IF NOT EXISTS - inventory_quantity DECIMAL(18,4); - -ALTER TABLE inventory.quants ADD COLUMN IF NOT EXISTS - inventory_quantity_set BOOLEAN NOT NULL DEFAULT FALSE; - -ALTER TABLE inventory.quants ADD COLUMN IF NOT EXISTS - inventory_date DATE; - -ALTER TABLE inventory.quants ADD COLUMN IF NOT EXISTS - last_count_date DATE; - -ALTER TABLE inventory.quants ADD COLUMN IF NOT EXISTS - is_outdated BOOLEAN NOT NULL DEFAULT FALSE; - -ALTER TABLE inventory.quants ADD COLUMN IF NOT EXISTS - assigned_user_id UUID REFERENCES auth.users(id); - -ALTER TABLE inventory.quants ADD COLUMN IF NOT EXISTS - count_notes TEXT; - -COMMENT ON COLUMN inventory.quants.inventory_quantity IS - 'Cantidad contada por el usuario'; -COMMENT ON COLUMN inventory.quants.is_outdated IS - 'TRUE si quantity cambió después de establecer inventory_quantity'; - --- Índices para conteo -CREATE INDEX idx_quants_inventory_date ON inventory.quants(inventory_date) - WHERE inventory_date IS NOT NULL; - -CREATE INDEX idx_quants_assigned_user ON inventory.quants(assigned_user_id) - WHERE assigned_user_id IS NOT NULL; - --- Tabla: inventory_count_sessions (Sesiones de conteo) -CREATE TABLE inventory.inventory_count_sessions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - code VARCHAR(20) NOT NULL, - name VARCHAR(200), - - -- Alcance del conteo - location_ids UUID[] NOT NULL, -- Ubicaciones a contar - product_ids UUID[], -- NULL = todos los productos - category_ids UUID[], -- Filtrar por categorías - - -- Configuración - count_type VARCHAR(20) NOT NULL DEFAULT 'cycle' - CHECK (count_type IN ('cycle', 'full', 'spot')), - -- 'cycle': Conteo cíclico programado - -- 'full': Inventario físico completo - -- 'spot': Conteo puntual/aleatorio - - -- Estado - state VARCHAR(20) NOT NULL DEFAULT 'draft' - CHECK (state IN ('draft', 'in_progress', 'pending_review', 'done', 'cancelled')), - - -- Fechas - scheduled_date DATE, - started_at TIMESTAMPTZ, - completed_at TIMESTAMPTZ, - - -- Asignación - responsible_id UUID REFERENCES auth.users(id), - team_ids UUID[], -- Usuarios asignados al conteo - - -- Resultados - total_quants INTEGER DEFAULT 0, - counted_quants INTEGER DEFAULT 0, - discrepancy_quants INTEGER DEFAULT 0, - total_value_diff DECIMAL(18,2) DEFAULT 0, - - -- Auditoría - company_id UUID NOT NULL REFERENCES auth.companies(id), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id), - warehouse_id UUID REFERENCES inventory.warehouses(id), - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - created_by UUID NOT NULL REFERENCES auth.users(id), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Índices para sesiones -CREATE INDEX idx_count_sessions_state ON inventory.inventory_count_sessions(state); -CREATE INDEX idx_count_sessions_scheduled ON inventory.inventory_count_sessions(scheduled_date); -CREATE INDEX idx_count_sessions_tenant ON inventory.inventory_count_sessions(tenant_id); - --- Secuencia para código de sesión -CREATE SEQUENCE IF NOT EXISTS inventory.inventory_count_seq START 1; - -COMMENT ON TABLE inventory.inventory_count_sessions IS 'Sesiones de conteo cíclico de inventario'; - --- Tabla: inventory_count_lines (Líneas de conteo detalladas) -CREATE TABLE inventory.inventory_count_lines ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - session_id UUID NOT NULL REFERENCES inventory.inventory_count_sessions(id) ON DELETE CASCADE, - quant_id UUID REFERENCES inventory.quants(id), - - -- Producto - product_id UUID NOT NULL REFERENCES inventory.products(id), - location_id UUID NOT NULL REFERENCES inventory.locations(id), - lot_id UUID REFERENCES inventory.lots(id), - -- package_id: Reservado para futura extensión de empaquetado - -- package_id UUID REFERENCES inventory.packages(id), - - -- Cantidades - theoretical_qty DECIMAL(18,4) NOT NULL DEFAULT 0, -- Del sistema - counted_qty DECIMAL(18,4), -- Contada - - -- Valoración - unit_cost DECIMAL(18,6), - - -- Estado - state VARCHAR(20) NOT NULL DEFAULT 'pending' - CHECK (state IN ('pending', 'counted', 'conflict', 'applied')), - - -- Conteo - counted_by UUID REFERENCES auth.users(id), - counted_at TIMESTAMPTZ, - notes TEXT, - - -- Resolución de conflictos - conflict_reason VARCHAR(100), - resolution VARCHAR(20) - CHECK (resolution IS NULL OR resolution IN ('keep_counted', 'keep_system', 'recount')), - resolved_by UUID REFERENCES auth.users(id), - resolved_at TIMESTAMPTZ, - - -- Movimiento generado - stock_move_id UUID REFERENCES inventory.stock_moves(id) -); - --- Índices para líneas de conteo -CREATE INDEX idx_count_lines_session ON inventory.inventory_count_lines(session_id); -CREATE INDEX idx_count_lines_state ON inventory.inventory_count_lines(state); -CREATE INDEX idx_count_lines_product ON inventory.inventory_count_lines(product_id); - -COMMENT ON TABLE inventory.inventory_count_lines IS 'Líneas detalladas de conteo de inventario'; - --- Tabla: abc_classification_rules (Reglas de clasificación ABC) -CREATE TABLE inventory.abc_classification_rules ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name VARCHAR(100) NOT NULL, - - -- Criterio de clasificación - classification_method VARCHAR(20) NOT NULL DEFAULT 'value' - CHECK (classification_method IN ('value', 'movement', 'revenue')), - -- 'value': Por valor de inventario - -- 'movement': Por frecuencia de movimiento - -- 'revenue': Por ingresos generados - - -- Umbrales (porcentaje acumulado) - threshold_a DECIMAL(5,2) NOT NULL DEFAULT 80.00, -- Top 80% - threshold_b DECIMAL(5,2) NOT NULL DEFAULT 95.00, -- 80-95% - -- Resto es C (95-100%) - - -- Frecuencias de conteo recomendadas (días) - frequency_a INTEGER NOT NULL DEFAULT 7, -- Clase A: semanal - frequency_b INTEGER NOT NULL DEFAULT 30, -- Clase B: mensual - frequency_c INTEGER NOT NULL DEFAULT 90, -- Clase C: trimestral - - -- Aplicación - warehouse_id UUID REFERENCES inventory.warehouses(id), - category_ids UUID[], -- Categorías a las que aplica - - -- Estado - is_active BOOLEAN NOT NULL DEFAULT TRUE, - last_calculation TIMESTAMPTZ, - - -- Auditoría - company_id UUID NOT NULL REFERENCES auth.companies(id), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id), - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - created_by UUID NOT NULL REFERENCES auth.users(id), - - CONSTRAINT chk_thresholds CHECK (threshold_a < threshold_b AND threshold_b <= 100) -); - -COMMENT ON TABLE inventory.abc_classification_rules IS 'Reglas de clasificación ABC para priorización de conteos'; - --- Tabla: product_abc_classification (Clasificación ABC por producto) -CREATE TABLE inventory.product_abc_classification ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - product_id UUID NOT NULL REFERENCES inventory.products(id), - rule_id UUID NOT NULL REFERENCES inventory.abc_classification_rules(id), - - -- Clasificación - classification VARCHAR(1) NOT NULL - CHECK (classification IN ('A', 'B', 'C')), - - -- Métricas calculadas - metric_value DECIMAL(18,2) NOT NULL, -- Valor usado para clasificar - cumulative_percent DECIMAL(5,2) NOT NULL, -- % acumulado - rank_position INTEGER NOT NULL, -- Posición en ranking - - -- Período de cálculo - period_start DATE NOT NULL, - period_end DATE NOT NULL, - - -- Frecuencia asignada - assigned_frequency INTEGER NOT NULL, - - calculated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - CONSTRAINT uq_product_rule UNIQUE (product_id, rule_id) -); - --- Índice para búsqueda de clasificación -CREATE INDEX idx_product_abc ON inventory.product_abc_classification(product_id, rule_id); - -COMMENT ON TABLE inventory.product_abc_classification IS 'Clasificación ABC calculada por producto'; - --- Extensión de stock_moves para marcar movimientos de inventario -ALTER TABLE inventory.stock_moves ADD COLUMN IF NOT EXISTS - is_inventory BOOLEAN NOT NULL DEFAULT FALSE; - -ALTER TABLE inventory.stock_moves ADD COLUMN IF NOT EXISTS - inventory_session_id UUID REFERENCES inventory.inventory_count_sessions(id); - -CREATE INDEX idx_moves_is_inventory ON inventory.stock_moves(is_inventory) - WHERE is_inventory = TRUE; - --- ===================================================== --- PARTE 4: FUNCIONES DE UTILIDAD --- ===================================================== - --- Función: Ejecutar algoritmo FIFO para consumo de capas -CREATE OR REPLACE FUNCTION inventory.run_fifo( - p_product_id UUID, - p_quantity DECIMAL, - p_company_id UUID, - p_lot_id UUID DEFAULT NULL -) -RETURNS TABLE( - total_value DECIMAL, - unit_cost DECIMAL, - remaining_qty DECIMAL -) AS $$ -DECLARE - v_candidate RECORD; - v_qty_to_take DECIMAL; - v_qty_taken DECIMAL; - v_value_taken DECIMAL; - v_total_value DECIMAL := 0; - v_qty_pending DECIMAL := p_quantity; - v_last_unit_cost DECIMAL := 0; -BEGIN - -- Obtener candidatos FIFO ordenados - FOR v_candidate IN - SELECT id, remaining_qty as r_qty, remaining_value as r_val, unit_cost as u_cost - FROM inventory.stock_valuation_layers - WHERE product_id = p_product_id - AND remaining_qty > 0 - AND company_id = p_company_id - AND (p_lot_id IS NULL OR lot_id = p_lot_id) - ORDER BY created_at ASC, id ASC - FOR UPDATE - LOOP - EXIT WHEN v_qty_pending <= 0; - - v_qty_taken := LEAST(v_candidate.r_qty, v_qty_pending); - v_value_taken := ROUND(v_qty_taken * (v_candidate.r_val / v_candidate.r_qty), 4); - - -- Actualizar capa candidata - UPDATE inventory.stock_valuation_layers - SET remaining_qty = remaining_qty - v_qty_taken, - remaining_value = remaining_value - v_value_taken - WHERE id = v_candidate.id; - - v_qty_pending := v_qty_pending - v_qty_taken; - v_total_value := v_total_value + v_value_taken; - v_last_unit_cost := v_candidate.u_cost; - END LOOP; - - -- Si queda cantidad pendiente (stock negativo) - IF v_qty_pending > 0 THEN - v_total_value := v_total_value + (v_last_unit_cost * v_qty_pending); - RETURN QUERY SELECT - -v_total_value, - v_total_value / p_quantity, - -v_qty_pending; - ELSE - RETURN QUERY SELECT - -v_total_value, - v_total_value / p_quantity, - 0::DECIMAL; - END IF; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION inventory.run_fifo IS 'Ejecuta algoritmo FIFO y consume capas de valoración'; - --- Función: Calcular clasificación ABC -CREATE OR REPLACE FUNCTION inventory.calculate_abc_classification( - p_rule_id UUID, - p_period_months INTEGER DEFAULT 12 -) -RETURNS TABLE ( - product_id UUID, - classification VARCHAR(1), - metric_value DECIMAL, - cumulative_percent DECIMAL, - rank_position INTEGER -) AS $$ -DECLARE - v_rule RECORD; - v_total_value DECIMAL; -BEGIN - -- Obtener regla - SELECT * INTO v_rule - FROM inventory.abc_classification_rules - WHERE id = p_rule_id; - - IF NOT FOUND THEN - RAISE EXCEPTION 'Regla ABC no encontrada: %', p_rule_id; - END IF; - - -- Crear tabla temporal con métricas - CREATE TEMP TABLE tmp_abc_metrics AS - SELECT - q.product_id, - SUM(q.quantity * COALESCE(p.standard_price, 0)) as metric_value - FROM inventory.quants q - JOIN inventory.products p ON p.id = q.product_id - WHERE q.quantity > 0 - AND (v_rule.warehouse_id IS NULL OR q.warehouse_id = v_rule.warehouse_id) - GROUP BY q.product_id; - - -- Calcular total - SELECT COALESCE(SUM(metric_value), 0) INTO v_total_value FROM tmp_abc_metrics; - - -- Retornar clasificación - RETURN QUERY - WITH ranked AS ( - SELECT - tm.product_id, - tm.metric_value, - ROW_NUMBER() OVER (ORDER BY tm.metric_value DESC) as rank_pos, - SUM(tm.metric_value) OVER (ORDER BY tm.metric_value DESC) / - NULLIF(v_total_value, 0) * 100 as cum_pct - FROM tmp_abc_metrics tm - ) - SELECT - r.product_id, - CASE - WHEN r.cum_pct <= v_rule.threshold_a THEN 'A'::VARCHAR(1) - WHEN r.cum_pct <= v_rule.threshold_b THEN 'B'::VARCHAR(1) - ELSE 'C'::VARCHAR(1) - END as classification, - r.metric_value, - ROUND(r.cum_pct, 2), - r.rank_pos::INTEGER - FROM ranked r; - - DROP TABLE IF EXISTS tmp_abc_metrics; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION inventory.calculate_abc_classification IS 'Calcula clasificación ABC para productos según regla'; - --- Función: Obtener próximos conteos programados -CREATE OR REPLACE FUNCTION inventory.get_pending_counts( - p_days_ahead INTEGER DEFAULT 7 -) -RETURNS TABLE ( - location_id UUID, - location_name VARCHAR, - next_inventory_date DATE, - days_overdue INTEGER, - quant_count INTEGER, - total_value DECIMAL -) AS $$ -BEGIN - RETURN QUERY - SELECT - l.id, - l.name, - (l.last_inventory_date + (l.cyclic_inventory_frequency || ' days')::INTERVAL)::DATE as next_inv_date, - (CURRENT_DATE - (l.last_inventory_date + (l.cyclic_inventory_frequency || ' days')::INTERVAL)::DATE)::INTEGER as days_over, - COUNT(q.id)::INTEGER as q_count, - COALESCE(SUM(q.quantity * COALESCE(p.standard_price, 0)), 0) as t_value - FROM inventory.locations l - LEFT JOIN inventory.quants q ON q.location_id = l.id - LEFT JOIN inventory.products p ON p.id = q.product_id - WHERE l.cyclic_inventory_frequency > 0 - AND (l.last_inventory_date + (l.cyclic_inventory_frequency || ' days')::INTERVAL)::DATE <= CURRENT_DATE + p_days_ahead - AND l.location_type = 'internal' - GROUP BY l.id, l.name, l.last_inventory_date, l.cyclic_inventory_frequency - ORDER BY next_inv_date; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION inventory.get_pending_counts IS 'Obtiene ubicaciones con conteos cíclicos pendientes'; - --- Función: Marcar quants como desactualizados cuando cambia cantidad -CREATE OR REPLACE FUNCTION inventory.mark_quants_outdated() -RETURNS TRIGGER AS $$ -BEGIN - IF OLD.quantity != NEW.quantity AND OLD.inventory_quantity_set = TRUE THEN - NEW.is_outdated := TRUE; - END IF; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER trg_quant_outdated - BEFORE UPDATE OF quantity ON inventory.quants - FOR EACH ROW - EXECUTE FUNCTION inventory.mark_quants_outdated(); - --- Función: Calcular fechas de caducidad al crear lote -CREATE OR REPLACE FUNCTION inventory.compute_lot_expiration_dates() -RETURNS TRIGGER AS $$ -DECLARE - v_product RECORD; -BEGIN - -- Obtener configuración del producto - SELECT - use_expiration_date, - expiration_time, - use_time, - removal_time, - alert_time - INTO v_product - FROM inventory.products - WHERE id = NEW.product_id; - - -- Si el producto usa fechas de caducidad y no se especificó expiration_date - IF v_product.use_expiration_date AND NEW.expiration_date IS NULL THEN - NEW.expiration_date := NOW() + (v_product.expiration_time || ' days')::INTERVAL; - - IF v_product.use_time IS NOT NULL THEN - NEW.use_date := NEW.expiration_date - (v_product.use_time || ' days')::INTERVAL; - END IF; - - IF v_product.removal_time IS NOT NULL THEN - NEW.removal_date := NEW.expiration_date - (v_product.removal_time || ' days')::INTERVAL; - END IF; - - IF v_product.alert_time IS NOT NULL THEN - NEW.alert_date := NEW.expiration_date - (v_product.alert_time || ' days')::INTERVAL; - END IF; - END IF; - - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER trg_lot_expiration_dates - BEFORE INSERT ON inventory.lots - FOR EACH ROW - EXECUTE FUNCTION inventory.compute_lot_expiration_dates(); - --- Función: Limpiar valor de la vista materializada -CREATE OR REPLACE FUNCTION inventory.refresh_product_valuation_summary() -RETURNS void AS $$ -BEGIN - REFRESH MATERIALIZED VIEW CONCURRENTLY inventory.product_valuation_summary; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION inventory.refresh_product_valuation_summary IS 'Refresca la vista materializada de valoración de productos'; - --- ===================================================== --- PARTE 5: TRIGGERS DE ACTUALIZACIÓN --- ===================================================== - --- Trigger: Actualizar updated_at para lots -CREATE OR REPLACE FUNCTION inventory.update_lots_timestamp() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = NOW(); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER trg_lots_updated_at - BEFORE UPDATE ON inventory.lots - FOR EACH ROW - EXECUTE FUNCTION inventory.update_lots_timestamp(); - --- Trigger: Actualizar updated_at para count_sessions -CREATE TRIGGER trg_count_sessions_updated_at - BEFORE UPDATE ON inventory.inventory_count_sessions - FOR EACH ROW - EXECUTE FUNCTION inventory.update_lots_timestamp(); - --- Trigger: Actualizar estadísticas de sesión al modificar líneas -CREATE OR REPLACE FUNCTION inventory.update_session_stats() -RETURNS TRIGGER AS $$ -BEGIN - UPDATE inventory.inventory_count_sessions - SET - counted_quants = ( - SELECT COUNT(*) FROM inventory.inventory_count_lines - WHERE session_id = COALESCE(NEW.session_id, OLD.session_id) - AND state IN ('counted', 'applied') - ), - discrepancy_quants = ( - SELECT COUNT(*) FROM inventory.inventory_count_lines - WHERE session_id = COALESCE(NEW.session_id, OLD.session_id) - AND state = 'counted' - AND (counted_qty - theoretical_qty) != 0 - ), - total_value_diff = ( - SELECT COALESCE(SUM(ABS((counted_qty - theoretical_qty) * COALESCE(unit_cost, 0))), 0) - FROM inventory.inventory_count_lines - WHERE session_id = COALESCE(NEW.session_id, OLD.session_id) - AND counted_qty IS NOT NULL - ), - updated_at = NOW() - WHERE id = COALESCE(NEW.session_id, OLD.session_id); - - RETURN COALESCE(NEW, OLD); -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER trg_update_session_stats - AFTER INSERT OR UPDATE OR DELETE ON inventory.inventory_count_lines - FOR EACH ROW - EXECUTE FUNCTION inventory.update_session_stats(); - --- ===================================================== --- PARTE 6: VISTAS --- ===================================================== - --- Vista: Lotes próximos a caducar -CREATE OR REPLACE VIEW inventory.expiring_lots_view AS -SELECT - l.id, - l.name as lot_name, - l.product_id, - p.name as product_name, - p.default_code as sku, - l.expiration_date, - l.removal_date, - EXTRACT(DAY FROM l.expiration_date - NOW()) as days_until_expiry, - COALESCE(SUM(q.quantity), 0) as stock_qty, - l.company_id, - l.tenant_id -FROM inventory.lots l -JOIN inventory.products p ON p.id = l.product_id -LEFT JOIN inventory.quants q ON q.lot_id = l.id -LEFT JOIN inventory.locations loc ON q.location_id = loc.id -WHERE l.expiration_date IS NOT NULL - AND l.expiration_date > NOW() - AND loc.location_type = 'internal' -GROUP BY l.id, p.id -HAVING COALESCE(SUM(q.quantity), 0) > 0; - -COMMENT ON VIEW inventory.expiring_lots_view IS 'Vista de lotes con stock próximos a caducar'; - --- Vista: Resumen de conteos por ubicación -CREATE OR REPLACE VIEW inventory.location_count_summary_view AS -SELECT - l.id as location_id, - l.name as location_name, - l.warehouse_id, - w.name as warehouse_name, - l.cyclic_inventory_frequency, - l.last_inventory_date, - (l.last_inventory_date + (l.cyclic_inventory_frequency || ' days')::INTERVAL)::DATE as next_inventory_date, - l.abc_classification, - COUNT(q.id) as quant_count, - COALESCE(SUM(q.quantity * COALESCE(p.standard_price, 0)), 0) as total_value -FROM inventory.locations l -LEFT JOIN inventory.warehouses w ON l.warehouse_id = w.id -LEFT JOIN inventory.quants q ON q.location_id = l.id AND q.quantity > 0 -LEFT JOIN inventory.products p ON q.product_id = p.id -WHERE l.location_type = 'internal' - AND l.cyclic_inventory_frequency > 0 -GROUP BY l.id, w.id; - -COMMENT ON VIEW inventory.location_count_summary_view IS 'Resumen de configuración de conteo cíclico por ubicación'; - --- ===================================================== --- COMENTARIOS EN TABLAS --- ===================================================== - -COMMENT ON TABLE inventory.stock_valuation_layers IS 'Capas de valoración de inventario para costeo FIFO/AVCO'; -COMMENT ON TABLE inventory.lots IS 'Lotes y números de serie para trazabilidad'; --- Nota: La tabla anterior se renombró a stock_move_consume_rel -COMMENT ON TABLE inventory.removal_strategies IS 'Estrategias de salida de inventario (FIFO/LIFO/FEFO)'; -COMMENT ON TABLE inventory.inventory_count_sessions IS 'Sesiones de conteo cíclico de inventario'; -COMMENT ON TABLE inventory.inventory_count_lines IS 'Líneas detalladas de conteo de inventario'; -COMMENT ON TABLE inventory.abc_classification_rules IS 'Reglas de clasificación ABC para priorización'; -COMMENT ON TABLE inventory.product_abc_classification IS 'Clasificación ABC calculada por producto'; -COMMENT ON TABLE inventory.category_stock_accounts IS 'Cuentas contables de valoración por categoría'; -COMMENT ON TABLE inventory.valuation_settings IS 'Configuración de valoración por tenant/empresa'; - --- ===================================================== --- FIN DE EXTENSIONES INVENTORY --- ===================================================== diff --git a/ddl/05-inventory.sql b/ddl/05-inventory.sql deleted file mode 100644 index c563e39..0000000 --- a/ddl/05-inventory.sql +++ /dev/null @@ -1,772 +0,0 @@ --- ===================================================== --- SCHEMA: inventory --- PROPÓSITO: Gestión de inventarios, productos, almacenes, movimientos --- MÓDULOS: MGN-005 (Inventario Básico) --- FECHA: 2025-11-24 --- ===================================================== - --- Crear schema -CREATE SCHEMA IF NOT EXISTS inventory; - --- ===================================================== --- TYPES (ENUMs) --- ===================================================== - -CREATE TYPE inventory.product_type AS ENUM ( - 'storable', - 'consumable', - 'service' -); - -CREATE TYPE inventory.tracking_type AS ENUM ( - 'none', - 'lot', - 'serial' -); - -CREATE TYPE inventory.location_type AS ENUM ( - 'internal', - 'customer', - 'supplier', - 'inventory', - 'production', - 'transit' -); - -CREATE TYPE inventory.picking_type AS ENUM ( - 'incoming', - 'outgoing', - 'internal' -); - -CREATE TYPE inventory.move_status AS ENUM ( - 'draft', - 'confirmed', - 'assigned', - 'done', - 'cancelled' -); - -CREATE TYPE inventory.valuation_method AS ENUM ( - 'fifo', - 'average', - 'standard' -); - --- ===================================================== --- TABLES --- ===================================================== - --- Tabla: products (Productos) -CREATE TABLE inventory.products ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - - -- Identificación - name VARCHAR(255) NOT NULL, - code VARCHAR(100), - barcode VARCHAR(100), - description TEXT, - - -- Tipo - product_type inventory.product_type NOT NULL DEFAULT 'storable', - tracking inventory.tracking_type NOT NULL DEFAULT 'none', - - -- Categoría - category_id UUID REFERENCES core.product_categories(id), - - -- Unidades de medida - uom_id UUID NOT NULL REFERENCES core.uom(id), -- UoM de venta/uso - purchase_uom_id UUID REFERENCES core.uom(id), -- UoM de compra - - -- Precios - cost_price DECIMAL(15, 4) DEFAULT 0, - list_price DECIMAL(15, 4) DEFAULT 0, - - -- Configuración de inventario - valuation_method inventory.valuation_method DEFAULT 'fifo', - is_storable BOOLEAN GENERATED ALWAYS AS (product_type = 'storable') STORED, - - -- Pesos y dimensiones - weight DECIMAL(12, 4), - volume DECIMAL(12, 4), - - -- Proveedores y clientes - can_be_sold BOOLEAN DEFAULT TRUE, - can_be_purchased BOOLEAN DEFAULT TRUE, - - -- Imagen - image_url VARCHAR(500), - - -- Control - active BOOLEAN NOT NULL DEFAULT TRUE, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMP, - updated_by UUID REFERENCES auth.users(id), - deleted_at TIMESTAMP, - deleted_by UUID REFERENCES auth.users(id), - - CONSTRAINT uq_products_code_tenant UNIQUE (tenant_id, code), - CONSTRAINT uq_products_barcode UNIQUE (barcode) -); - --- Tabla: product_variants (Variantes de producto) -CREATE TABLE inventory.product_variants ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - product_template_id UUID NOT NULL REFERENCES inventory.products(id) ON DELETE CASCADE, - - -- Atributos (JSON) - -- Ejemplo: {"color": "red", "size": "XL"} - attribute_values JSONB NOT NULL DEFAULT '{}', - - -- Identificación - name VARCHAR(255), - code VARCHAR(100), - barcode VARCHAR(100), - - -- Precio diferencial - price_extra DECIMAL(15, 4) DEFAULT 0, - - -- Control - active BOOLEAN NOT NULL DEFAULT TRUE, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - - CONSTRAINT uq_product_variants_barcode UNIQUE (barcode) -); - --- Tabla: warehouses (Almacenes) -CREATE TABLE inventory.warehouses ( - 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 auth.companies(id) ON DELETE CASCADE, - - name VARCHAR(255) NOT NULL, - code VARCHAR(20) NOT NULL, - - -- Dirección - address_id UUID REFERENCES core.addresses(id), - - -- Configuración - is_default BOOLEAN DEFAULT FALSE, - - -- Control - active BOOLEAN NOT NULL DEFAULT TRUE, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMP, - updated_by UUID REFERENCES auth.users(id), - - CONSTRAINT uq_warehouses_code_company UNIQUE (company_id, code) -); - --- Tabla: locations (Ubicaciones de inventario) -CREATE TABLE inventory.locations ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - - warehouse_id UUID REFERENCES inventory.warehouses(id), - name VARCHAR(255) NOT NULL, - complete_name TEXT, -- Generado: "Warehouse / Zone A / Shelf 1" - location_type inventory.location_type NOT NULL DEFAULT 'internal', - - -- Jerarquía - parent_id UUID REFERENCES inventory.locations(id), - - -- Configuración - is_scrap_location BOOLEAN DEFAULT FALSE, - is_return_location BOOLEAN DEFAULT FALSE, - - -- Control - active BOOLEAN NOT NULL DEFAULT TRUE, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMP, - updated_by UUID REFERENCES auth.users(id), - - CONSTRAINT chk_locations_no_self_parent CHECK (id != parent_id) -); - --- Tabla: lots (Lotes/Series) - DEBE IR ANTES DE stock_quants por FK -CREATE TABLE inventory.lots ( - 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 inventory.products(id), - name VARCHAR(100) NOT NULL, - ref VARCHAR(100), -- Referencia externa - - -- Fechas - manufacture_date DATE, - expiration_date DATE, - removal_date DATE, - alert_date DATE, - - -- Notas - notes TEXT, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - - CONSTRAINT uq_lots_name_product UNIQUE (product_id, name), - CONSTRAINT chk_lots_expiration CHECK (expiration_date IS NULL OR expiration_date > manufacture_date) -); - --- Tabla: stock_quants (Cantidades en stock) -CREATE TABLE inventory.stock_quants ( - 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 inventory.products(id), - location_id UUID NOT NULL REFERENCES inventory.locations(id), - lot_id UUID REFERENCES inventory.lots(id), - - -- Cantidades - quantity DECIMAL(12, 4) NOT NULL DEFAULT 0, - reserved_quantity DECIMAL(12, 4) NOT NULL DEFAULT 0, - available_quantity DECIMAL(12, 4) GENERATED ALWAYS AS (quantity - reserved_quantity) STORED, - - -- Valoración - cost DECIMAL(15, 4) DEFAULT 0, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP, - - CONSTRAINT chk_stock_quants_reserved CHECK (reserved_quantity >= 0 AND reserved_quantity <= quantity) -); - --- Unique index for stock_quants (allows expressions unlike UNIQUE constraint) -CREATE UNIQUE INDEX uq_stock_quants_product_location_lot -ON inventory.stock_quants (tenant_id, product_id, location_id, COALESCE(lot_id, '00000000-0000-0000-0000-000000000000'::UUID)); - --- Índices para stock_quants -CREATE INDEX idx_stock_quants_tenant_id ON inventory.stock_quants(tenant_id); -CREATE INDEX idx_stock_quants_product_location ON inventory.stock_quants(product_id, location_id); - --- RLS para stock_quants -ALTER TABLE inventory.stock_quants ENABLE ROW LEVEL SECURITY; -CREATE POLICY tenant_isolation_stock_quants ON inventory.stock_quants - USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); - --- Tabla: pickings (Albaranes/Transferencias) -CREATE TABLE inventory.pickings ( - 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 auth.companies(id) ON DELETE CASCADE, - - name VARCHAR(100) NOT NULL, - picking_type inventory.picking_type NOT NULL, - - -- Ubicaciones - location_id UUID NOT NULL REFERENCES inventory.locations(id), -- Origen - location_dest_id UUID NOT NULL REFERENCES inventory.locations(id), -- Destino - - -- Partner (cliente/proveedor) - partner_id UUID REFERENCES core.partners(id), - - -- Fechas - scheduled_date TIMESTAMP, - date_done TIMESTAMP, - - -- Origen - origin VARCHAR(255), -- Referencia al documento origen (PO, SO, etc.) - - -- Estado - status inventory.move_status NOT NULL DEFAULT 'draft', - - -- Notas - notes TEXT, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMP, - updated_by UUID REFERENCES auth.users(id), - validated_at TIMESTAMP, - validated_by UUID REFERENCES auth.users(id), - - CONSTRAINT uq_pickings_name_company UNIQUE (company_id, name) -); - --- Tabla: stock_moves (Movimientos de inventario) -CREATE TABLE inventory.stock_moves ( - 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 inventory.products(id), - product_uom_id UUID NOT NULL REFERENCES core.uom(id), - - -- Ubicaciones - location_id UUID NOT NULL REFERENCES inventory.locations(id), -- Origen - location_dest_id UUID NOT NULL REFERENCES inventory.locations(id), -- Destino - - -- Cantidades - product_qty DECIMAL(12, 4) NOT NULL, - quantity_done DECIMAL(12, 4) DEFAULT 0, - - -- Lote/Serie - lot_id UUID REFERENCES inventory.lots(id), - - -- Relación con picking - picking_id UUID REFERENCES inventory.pickings(id) ON DELETE CASCADE, - - -- Origen del movimiento - origin VARCHAR(255), - ref VARCHAR(255), - - -- Estado - status inventory.move_status NOT NULL DEFAULT 'draft', - - -- Fechas - date_expected TIMESTAMP, - date TIMESTAMP, - - -- Precio (para valoración) - price_unit DECIMAL(15, 4) DEFAULT 0, - - -- Analítica - analytic_account_id UUID REFERENCES analytics.analytic_accounts(id), -- Distribución analítica - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMP, - updated_by UUID REFERENCES auth.users(id), - - CONSTRAINT chk_stock_moves_quantity CHECK (product_qty > 0), - CONSTRAINT chk_stock_moves_quantity_done CHECK (quantity_done >= 0) -); - --- Tabla: inventory_adjustments (Ajustes de inventario) -CREATE TABLE inventory.inventory_adjustments ( - 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 auth.companies(id) ON DELETE CASCADE, - - name VARCHAR(100) NOT NULL, - - -- Ubicación a ajustar - location_id UUID NOT NULL REFERENCES inventory.locations(id), - - -- Fecha de conteo - date DATE NOT NULL, - - -- Estado - status inventory.move_status NOT NULL DEFAULT 'draft', - - -- Notas - notes TEXT, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMP, - updated_by UUID REFERENCES auth.users(id), - validated_at TIMESTAMP, - validated_by UUID REFERENCES auth.users(id), - - CONSTRAINT uq_inventory_adjustments_name_company UNIQUE (company_id, name) -); - --- Tabla: inventory_adjustment_lines (Líneas de ajuste) -CREATE TABLE inventory.inventory_adjustment_lines ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - adjustment_id UUID NOT NULL REFERENCES inventory.inventory_adjustments(id) ON DELETE CASCADE, - - product_id UUID NOT NULL REFERENCES inventory.products(id), - location_id UUID NOT NULL REFERENCES inventory.locations(id), - lot_id UUID REFERENCES inventory.lots(id), - - -- Cantidades - theoretical_qty DECIMAL(12, 4) NOT NULL DEFAULT 0, -- Cantidad teórica del sistema - counted_qty DECIMAL(12, 4) NOT NULL, -- Cantidad contada físicamente - difference_qty DECIMAL(12, 4) GENERATED ALWAYS AS (counted_qty - theoretical_qty) STORED, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - --- Índices para inventory_adjustment_lines -CREATE INDEX idx_inventory_adjustment_lines_tenant_id ON inventory.inventory_adjustment_lines(tenant_id); - --- RLS para inventory_adjustment_lines -ALTER TABLE inventory.inventory_adjustment_lines ENABLE ROW LEVEL SECURITY; -CREATE POLICY tenant_isolation_inventory_adjustment_lines ON inventory.inventory_adjustment_lines - USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); - --- ===================================================== --- INDICES --- ===================================================== - --- Products -CREATE INDEX idx_products_tenant_id ON inventory.products(tenant_id); -CREATE INDEX idx_products_code ON inventory.products(code); -CREATE INDEX idx_products_barcode ON inventory.products(barcode); -CREATE INDEX idx_products_category_id ON inventory.products(category_id); -CREATE INDEX idx_products_type ON inventory.products(product_type); -CREATE INDEX idx_products_active ON inventory.products(active) WHERE active = TRUE; - --- Product Variants -CREATE INDEX idx_product_variants_template_id ON inventory.product_variants(product_template_id); -CREATE INDEX idx_product_variants_barcode ON inventory.product_variants(barcode); - --- Warehouses -CREATE INDEX idx_warehouses_tenant_id ON inventory.warehouses(tenant_id); -CREATE INDEX idx_warehouses_company_id ON inventory.warehouses(company_id); -CREATE INDEX idx_warehouses_code ON inventory.warehouses(code); - --- Locations -CREATE INDEX idx_locations_tenant_id ON inventory.locations(tenant_id); -CREATE INDEX idx_locations_warehouse_id ON inventory.locations(warehouse_id); -CREATE INDEX idx_locations_parent_id ON inventory.locations(parent_id); -CREATE INDEX idx_locations_type ON inventory.locations(location_type); - --- Stock Quants -CREATE INDEX idx_stock_quants_product_id ON inventory.stock_quants(product_id); -CREATE INDEX idx_stock_quants_location_id ON inventory.stock_quants(location_id); -CREATE INDEX idx_stock_quants_lot_id ON inventory.stock_quants(lot_id); -CREATE INDEX idx_stock_quants_available ON inventory.stock_quants(product_id, location_id) - WHERE available_quantity > 0; - --- Lots -CREATE INDEX idx_lots_tenant_id ON inventory.lots(tenant_id); -CREATE INDEX idx_lots_product_id ON inventory.lots(product_id); -CREATE INDEX idx_lots_name ON inventory.lots(name); -CREATE INDEX idx_lots_expiration_date ON inventory.lots(expiration_date); - --- Pickings -CREATE INDEX idx_pickings_tenant_id ON inventory.pickings(tenant_id); -CREATE INDEX idx_pickings_company_id ON inventory.pickings(company_id); -CREATE INDEX idx_pickings_name ON inventory.pickings(name); -CREATE INDEX idx_pickings_type ON inventory.pickings(picking_type); -CREATE INDEX idx_pickings_status ON inventory.pickings(status); -CREATE INDEX idx_pickings_partner_id ON inventory.pickings(partner_id); -CREATE INDEX idx_pickings_origin ON inventory.pickings(origin); -CREATE INDEX idx_pickings_scheduled_date ON inventory.pickings(scheduled_date); - --- Stock Moves -CREATE INDEX idx_stock_moves_tenant_id ON inventory.stock_moves(tenant_id); -CREATE INDEX idx_stock_moves_product_id ON inventory.stock_moves(product_id); -CREATE INDEX idx_stock_moves_picking_id ON inventory.stock_moves(picking_id); -CREATE INDEX idx_stock_moves_location_id ON inventory.stock_moves(location_id); -CREATE INDEX idx_stock_moves_location_dest_id ON inventory.stock_moves(location_dest_id); -CREATE INDEX idx_stock_moves_status ON inventory.stock_moves(status); -CREATE INDEX idx_stock_moves_lot_id ON inventory.stock_moves(lot_id); -CREATE INDEX idx_stock_moves_analytic_account_id ON inventory.stock_moves(analytic_account_id) WHERE analytic_account_id IS NOT NULL; - --- Inventory Adjustments -CREATE INDEX idx_inventory_adjustments_tenant_id ON inventory.inventory_adjustments(tenant_id); -CREATE INDEX idx_inventory_adjustments_company_id ON inventory.inventory_adjustments(company_id); -CREATE INDEX idx_inventory_adjustments_location_id ON inventory.inventory_adjustments(location_id); -CREATE INDEX idx_inventory_adjustments_status ON inventory.inventory_adjustments(status); -CREATE INDEX idx_inventory_adjustments_date ON inventory.inventory_adjustments(date); - --- Inventory Adjustment Lines -CREATE INDEX idx_inventory_adjustment_lines_adjustment_id ON inventory.inventory_adjustment_lines(adjustment_id); -CREATE INDEX idx_inventory_adjustment_lines_product_id ON inventory.inventory_adjustment_lines(product_id); - --- ===================================================== --- FUNCTIONS --- ===================================================== - --- Función: update_stock_quant --- Actualiza la cantidad en stock de un producto en una ubicación -CREATE OR REPLACE FUNCTION inventory.update_stock_quant( - p_product_id UUID, - p_location_id UUID, - p_lot_id UUID, - p_quantity DECIMAL -) -RETURNS VOID AS $$ -BEGIN - INSERT INTO inventory.stock_quants (product_id, location_id, lot_id, quantity) - VALUES (p_product_id, p_location_id, p_lot_id, p_quantity) - ON CONFLICT (product_id, location_id, COALESCE(lot_id, '00000000-0000-0000-0000-000000000000'::UUID)) - DO UPDATE SET - quantity = inventory.stock_quants.quantity + EXCLUDED.quantity, - updated_at = CURRENT_TIMESTAMP; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION inventory.update_stock_quant IS 'Actualiza la cantidad en stock de un producto en una ubicación'; - --- Función: reserve_quantity --- Reserva cantidad de un producto en una ubicación -CREATE OR REPLACE FUNCTION inventory.reserve_quantity( - p_product_id UUID, - p_location_id UUID, - p_lot_id UUID, - p_quantity DECIMAL -) -RETURNS BOOLEAN AS $$ -DECLARE - v_available DECIMAL; -BEGIN - -- Verificar disponibilidad - SELECT available_quantity INTO v_available - FROM inventory.stock_quants - WHERE product_id = p_product_id - AND location_id = p_location_id - AND (lot_id = p_lot_id OR (lot_id IS NULL AND p_lot_id IS NULL)); - - IF v_available IS NULL OR v_available < p_quantity THEN - RETURN FALSE; - END IF; - - -- Reservar - UPDATE inventory.stock_quants - SET reserved_quantity = reserved_quantity + p_quantity, - updated_at = CURRENT_TIMESTAMP - WHERE product_id = p_product_id - AND location_id = p_location_id - AND (lot_id = p_lot_id OR (lot_id IS NULL AND p_lot_id IS NULL)); - - RETURN TRUE; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION inventory.reserve_quantity IS 'Reserva cantidad de un producto en una ubicación'; - --- Función: get_product_stock --- Obtiene el stock disponible de un producto -CREATE OR REPLACE FUNCTION inventory.get_product_stock( - p_product_id UUID, - p_location_id UUID DEFAULT NULL -) -RETURNS TABLE( - location_id UUID, - location_name VARCHAR, - quantity DECIMAL, - reserved_quantity DECIMAL, - available_quantity DECIMAL -) AS $$ -BEGIN - RETURN QUERY - SELECT - sq.location_id, - l.name AS location_name, - sq.quantity, - sq.reserved_quantity, - sq.available_quantity - FROM inventory.stock_quants sq - JOIN inventory.locations l ON sq.location_id = l.id - WHERE sq.product_id = p_product_id - AND (p_location_id IS NULL OR sq.location_id = p_location_id) - AND sq.quantity > 0; -END; -$$ LANGUAGE plpgsql STABLE; - -COMMENT ON FUNCTION inventory.get_product_stock IS 'Obtiene el stock disponible de un producto por ubicación'; - --- Función: process_stock_move --- Procesa un movimiento de inventario (actualiza quants) -CREATE OR REPLACE FUNCTION inventory.process_stock_move(p_move_id UUID) -RETURNS VOID AS $$ -DECLARE - v_move RECORD; -BEGIN - -- Obtener datos del movimiento - SELECT * INTO v_move - FROM inventory.stock_moves - WHERE id = p_move_id; - - IF NOT FOUND THEN - RAISE EXCEPTION 'Stock move % not found', p_move_id; - END IF; - - IF v_move.status != 'confirmed' THEN - RAISE EXCEPTION 'Stock move % is not in confirmed status', p_move_id; - END IF; - - -- Decrementar en ubicación origen - PERFORM inventory.update_stock_quant( - v_move.product_id, - v_move.location_id, - v_move.lot_id, - -v_move.quantity_done - ); - - -- Incrementar en ubicación destino - PERFORM inventory.update_stock_quant( - v_move.product_id, - v_move.location_dest_id, - v_move.lot_id, - v_move.quantity_done - ); - - -- Actualizar estado del movimiento - UPDATE inventory.stock_moves - SET status = 'done', - date = CURRENT_TIMESTAMP, - updated_at = CURRENT_TIMESTAMP, - updated_by = get_current_user_id() - WHERE id = p_move_id; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION inventory.process_stock_move IS 'Procesa un movimiento de inventario y actualiza los quants'; - --- Función: update_location_complete_name --- Actualiza el nombre completo de una ubicación -CREATE OR REPLACE FUNCTION inventory.update_location_complete_name() -RETURNS TRIGGER AS $$ -DECLARE - v_parent_name TEXT; -BEGIN - IF NEW.parent_id IS NULL THEN - NEW.complete_name := NEW.name; - ELSE - SELECT complete_name INTO v_parent_name - FROM inventory.locations - WHERE id = NEW.parent_id; - - NEW.complete_name := v_parent_name || ' / ' || NEW.name; - END IF; - - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION inventory.update_location_complete_name IS 'Actualiza el nombre completo de la ubicación'; - --- ===================================================== --- TRIGGERS --- ===================================================== - --- Trigger: Actualizar updated_at -CREATE TRIGGER trg_products_updated_at - BEFORE UPDATE ON inventory.products - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at_column(); - -CREATE TRIGGER trg_warehouses_updated_at - BEFORE UPDATE ON inventory.warehouses - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at_column(); - -CREATE TRIGGER trg_locations_updated_at - BEFORE UPDATE ON inventory.locations - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at_column(); - -CREATE TRIGGER trg_pickings_updated_at - BEFORE UPDATE ON inventory.pickings - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at_column(); - -CREATE TRIGGER trg_stock_moves_updated_at - BEFORE UPDATE ON inventory.stock_moves - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at_column(); - -CREATE TRIGGER trg_inventory_adjustments_updated_at - BEFORE UPDATE ON inventory.inventory_adjustments - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at_column(); - --- Trigger: Actualizar complete_name de ubicación -CREATE TRIGGER trg_locations_update_complete_name - BEFORE INSERT OR UPDATE OF name, parent_id ON inventory.locations - FOR EACH ROW - EXECUTE FUNCTION inventory.update_location_complete_name(); - --- ===================================================== --- TRACKING AUTOMÁTICO (mail.thread pattern) --- ===================================================== - --- Trigger: Tracking automático para movimientos de stock -CREATE TRIGGER track_stock_move_changes - AFTER INSERT OR UPDATE OR DELETE ON inventory.stock_moves - FOR EACH ROW EXECUTE FUNCTION system.track_field_changes(); - -COMMENT ON TRIGGER track_stock_move_changes ON inventory.stock_moves IS -'Registra automáticamente cambios en movimientos de stock (estado, producto, cantidad, ubicaciones)'; - --- ===================================================== --- ROW LEVEL SECURITY (RLS) --- ===================================================== - -ALTER TABLE inventory.products ENABLE ROW LEVEL SECURITY; -ALTER TABLE inventory.warehouses ENABLE ROW LEVEL SECURITY; -ALTER TABLE inventory.locations ENABLE ROW LEVEL SECURITY; -ALTER TABLE inventory.lots ENABLE ROW LEVEL SECURITY; -ALTER TABLE inventory.pickings ENABLE ROW LEVEL SECURITY; -ALTER TABLE inventory.stock_moves ENABLE ROW LEVEL SECURITY; -ALTER TABLE inventory.inventory_adjustments ENABLE ROW LEVEL SECURITY; - -CREATE POLICY tenant_isolation_products ON inventory.products - USING (tenant_id = get_current_tenant_id()); - -CREATE POLICY tenant_isolation_warehouses ON inventory.warehouses - USING (tenant_id = get_current_tenant_id()); - -CREATE POLICY tenant_isolation_locations ON inventory.locations - USING (tenant_id = get_current_tenant_id()); - -CREATE POLICY tenant_isolation_lots ON inventory.lots - USING (tenant_id = get_current_tenant_id()); - -CREATE POLICY tenant_isolation_pickings ON inventory.pickings - USING (tenant_id = get_current_tenant_id()); - -CREATE POLICY tenant_isolation_stock_moves ON inventory.stock_moves - USING (tenant_id = get_current_tenant_id()); - -CREATE POLICY tenant_isolation_inventory_adjustments ON inventory.inventory_adjustments - USING (tenant_id = get_current_tenant_id()); - --- ===================================================== --- COMENTARIOS --- ===================================================== - -COMMENT ON SCHEMA inventory IS 'Schema de gestión de inventarios, productos, almacenes y movimientos'; -COMMENT ON TABLE inventory.products IS 'Productos (almacenables, consumibles, servicios)'; -COMMENT ON TABLE inventory.product_variants IS 'Variantes de productos (color, talla, etc.)'; -COMMENT ON TABLE inventory.warehouses IS 'Almacenes físicos'; -COMMENT ON TABLE inventory.locations IS 'Ubicaciones dentro de almacenes (estantes, zonas, etc.)'; -COMMENT ON TABLE inventory.stock_quants IS 'Cantidades en stock por producto/ubicación/lote'; -COMMENT ON TABLE inventory.lots IS 'Lotes de producción y números de serie'; -COMMENT ON TABLE inventory.pickings IS 'Albaranes de entrada, salida y transferencia'; -COMMENT ON TABLE inventory.stock_moves IS 'Movimientos individuales de inventario'; -COMMENT ON TABLE inventory.inventory_adjustments IS 'Ajustes de inventario (conteos físicos)'; -COMMENT ON TABLE inventory.inventory_adjustment_lines IS 'Líneas de ajuste de inventario'; - --- ===================================================== --- VISTAS ÚTILES --- ===================================================== - --- Vista: stock_by_product (Stock por producto) -CREATE OR REPLACE VIEW inventory.stock_by_product_view AS -SELECT - p.id AS product_id, - p.code AS product_code, - p.name AS product_name, - l.id AS location_id, - l.complete_name AS location_name, - COALESCE(SUM(sq.quantity), 0) AS quantity, - COALESCE(SUM(sq.reserved_quantity), 0) AS reserved_quantity, - COALESCE(SUM(sq.available_quantity), 0) AS available_quantity -FROM inventory.products p -CROSS JOIN inventory.locations l -LEFT JOIN inventory.stock_quants sq ON sq.product_id = p.id AND sq.location_id = l.id -WHERE p.product_type = 'storable' - AND l.location_type = 'internal' -GROUP BY p.id, p.code, p.name, l.id, l.complete_name; - -COMMENT ON VIEW inventory.stock_by_product_view IS 'Vista de stock disponible por producto y ubicación'; - --- ===================================================== --- FIN DEL SCHEMA INVENTORY --- ===================================================== diff --git a/ddl/06-auth-extended.sql b/ddl/06-auth-extended.sql new file mode 100644 index 0000000..e07c6e3 --- /dev/null +++ b/ddl/06-auth-extended.sql @@ -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'; diff --git a/ddl/06-purchase.sql b/ddl/06-purchase.sql deleted file mode 100644 index 8d2271b..0000000 --- a/ddl/06-purchase.sql +++ /dev/null @@ -1,583 +0,0 @@ --- ===================================================== --- SCHEMA: purchase --- PROPÓSITO: Gestión de compras, proveedores, órdenes de compra --- MÓDULOS: MGN-006 (Compras Básico) --- FECHA: 2025-11-24 --- ===================================================== - --- Crear schema -CREATE SCHEMA IF NOT EXISTS purchase; - --- ===================================================== --- TYPES (ENUMs) --- ===================================================== - -CREATE TYPE purchase.order_status AS ENUM ( - 'draft', - 'sent', - 'confirmed', - 'received', - 'billed', - 'cancelled' -); - -CREATE TYPE purchase.rfq_status AS ENUM ( - 'draft', - 'sent', - 'responded', - 'accepted', - 'rejected', - 'cancelled' -); - -CREATE TYPE purchase.agreement_type AS ENUM ( - 'price', - 'discount', - 'blanket' -); - --- ===================================================== --- TABLES --- ===================================================== - --- Tabla: purchase_orders (Órdenes de compra) -CREATE TABLE purchase.purchase_orders ( - 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 auth.companies(id) ON DELETE CASCADE, - - -- Numeración - name VARCHAR(100) NOT NULL, - ref VARCHAR(100), -- Referencia del proveedor - - -- Proveedor - partner_id UUID NOT NULL REFERENCES core.partners(id), - - -- Fechas - order_date DATE NOT NULL, - expected_date DATE, - effective_date DATE, - - -- Configuración - currency_id UUID NOT NULL REFERENCES core.currencies(id), - payment_term_id UUID REFERENCES financial.payment_terms(id), - - -- Montos - amount_untaxed DECIMAL(15, 2) NOT NULL DEFAULT 0, - amount_tax DECIMAL(15, 2) NOT NULL DEFAULT 0, - amount_total DECIMAL(15, 2) NOT NULL DEFAULT 0, - - -- Estado - status purchase.order_status NOT NULL DEFAULT 'draft', - - -- Recepciones y facturación - receipt_status VARCHAR(20) DEFAULT 'pending', -- pending, partial, received - invoice_status VARCHAR(20) DEFAULT 'pending', -- pending, partial, billed - - -- Relaciones - picking_id UUID REFERENCES inventory.pickings(id), -- Recepción generada - invoice_id UUID REFERENCES financial.invoices(id), -- Factura generada - - -- Notas - notes TEXT, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMP, - updated_by UUID REFERENCES auth.users(id), - confirmed_at TIMESTAMP, - confirmed_by UUID REFERENCES auth.users(id), - cancelled_at TIMESTAMP, - cancelled_by UUID REFERENCES auth.users(id), - - CONSTRAINT uq_purchase_orders_name_company UNIQUE (company_id, name) -); - --- Tabla: purchase_order_lines (Líneas de orden de compra) -CREATE TABLE purchase.purchase_order_lines ( - 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 purchase.purchase_orders(id) ON DELETE CASCADE, - - product_id UUID NOT NULL REFERENCES inventory.products(id), - description TEXT NOT NULL, - - -- Cantidades - quantity DECIMAL(12, 4) NOT NULL, - qty_received DECIMAL(12, 4) DEFAULT 0, - qty_invoiced DECIMAL(12, 4) DEFAULT 0, - uom_id UUID NOT NULL REFERENCES core.uom(id), - - -- Precios - price_unit DECIMAL(15, 4) NOT NULL, - discount DECIMAL(5, 2) DEFAULT 0, -- Porcentaje de descuento - - -- Impuestos - tax_ids UUID[] DEFAULT '{}', - - -- Montos - amount_untaxed DECIMAL(15, 2) NOT NULL, - amount_tax DECIMAL(15, 2) NOT NULL, - amount_total DECIMAL(15, 2) NOT NULL, - - -- Fechas esperadas - expected_date DATE, - - -- Analítica - analytic_account_id UUID REFERENCES analytics.analytic_accounts(id), -- Distribución analítica - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP, - - CONSTRAINT chk_purchase_order_lines_quantity CHECK (quantity > 0), - CONSTRAINT chk_purchase_order_lines_discount CHECK (discount >= 0 AND discount <= 100) -); - --- Índices para purchase_order_lines -CREATE INDEX idx_purchase_order_lines_tenant_id ON purchase.purchase_order_lines(tenant_id); -CREATE INDEX idx_purchase_order_lines_order_id ON purchase.purchase_order_lines(order_id); -CREATE INDEX idx_purchase_order_lines_product_id ON purchase.purchase_order_lines(product_id); - --- RLS para purchase_order_lines -ALTER TABLE purchase.purchase_order_lines ENABLE ROW LEVEL SECURITY; -CREATE POLICY tenant_isolation_purchase_order_lines ON purchase.purchase_order_lines - USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); - --- Tabla: rfqs (Request for Quotation - Solicitudes de cotización) -CREATE TABLE purchase.rfqs ( - 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 auth.companies(id) ON DELETE CASCADE, - - name VARCHAR(100) NOT NULL, - - -- Proveedores (puede ser enviada a múltiples proveedores) - partner_ids UUID[] NOT NULL, - - -- Fechas - request_date DATE NOT NULL, - deadline_date DATE, - response_date DATE, - - -- Estado - status purchase.rfq_status NOT NULL DEFAULT 'draft', - - -- Descripción - description TEXT, - notes TEXT, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMP, - updated_by UUID REFERENCES auth.users(id), - - CONSTRAINT uq_rfqs_name_company UNIQUE (company_id, name) -); - --- Tabla: rfq_lines (Líneas de RFQ) -CREATE TABLE purchase.rfq_lines ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - rfq_id UUID NOT NULL REFERENCES purchase.rfqs(id) ON DELETE CASCADE, - - product_id UUID REFERENCES inventory.products(id), - description TEXT NOT NULL, - quantity DECIMAL(12, 4) NOT NULL, - uom_id UUID NOT NULL REFERENCES core.uom(id), - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT chk_rfq_lines_quantity CHECK (quantity > 0) -); - --- Índices para rfq_lines -CREATE INDEX idx_rfq_lines_tenant_id ON purchase.rfq_lines(tenant_id); - --- RLS para rfq_lines -ALTER TABLE purchase.rfq_lines ENABLE ROW LEVEL SECURITY; -CREATE POLICY tenant_isolation_rfq_lines ON purchase.rfq_lines - USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); - --- Tabla: vendor_pricelists (Listas de precios de proveedores) -CREATE TABLE purchase.vendor_pricelists ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - - partner_id UUID NOT NULL REFERENCES core.partners(id), - product_id UUID NOT NULL REFERENCES inventory.products(id), - - -- Precio - price DECIMAL(15, 4) NOT NULL, - currency_id UUID NOT NULL REFERENCES core.currencies(id), - - -- Cantidad mínima - min_quantity DECIMAL(12, 4) DEFAULT 1, - - -- Validez - valid_from DATE, - valid_to DATE, - - -- Control - active BOOLEAN NOT NULL DEFAULT TRUE, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMP, - updated_by UUID REFERENCES auth.users(id), - - CONSTRAINT chk_vendor_pricelists_price CHECK (price >= 0), - CONSTRAINT chk_vendor_pricelists_min_qty CHECK (min_quantity > 0), - CONSTRAINT chk_vendor_pricelists_dates CHECK (valid_to IS NULL OR valid_to >= valid_from) -); - --- Tabla: purchase_agreements (Acuerdos de compra / Contratos) -CREATE TABLE purchase.purchase_agreements ( - 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 auth.companies(id) ON DELETE CASCADE, - - name VARCHAR(255) NOT NULL, - code VARCHAR(50), - agreement_type purchase.agreement_type NOT NULL, - - -- Proveedor - partner_id UUID NOT NULL REFERENCES core.partners(id), - - -- Vigencia - start_date DATE NOT NULL, - end_date DATE NOT NULL, - - -- Montos (para contratos blanket) - amount_max DECIMAL(15, 2), - currency_id UUID REFERENCES core.currencies(id), - - -- Estado - is_active BOOLEAN DEFAULT TRUE, - - -- Términos - terms TEXT, - notes TEXT, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMP, - updated_by UUID REFERENCES auth.users(id), - - CONSTRAINT uq_purchase_agreements_code_company UNIQUE (company_id, code), - CONSTRAINT chk_purchase_agreements_dates CHECK (end_date > start_date) -); - --- Tabla: purchase_agreement_lines (Líneas de acuerdo) -CREATE TABLE purchase.purchase_agreement_lines ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - agreement_id UUID NOT NULL REFERENCES purchase.purchase_agreements(id) ON DELETE CASCADE, - - product_id UUID NOT NULL REFERENCES inventory.products(id), - - -- Cantidades - quantity DECIMAL(12, 4), - qty_ordered DECIMAL(12, 4) DEFAULT 0, - - -- Precio acordado - price_unit DECIMAL(15, 4), - discount DECIMAL(5, 2) DEFAULT 0, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - --- Índices para purchase_agreement_lines -CREATE INDEX idx_purchase_agreement_lines_tenant_id ON purchase.purchase_agreement_lines(tenant_id); - --- RLS para purchase_agreement_lines -ALTER TABLE purchase.purchase_agreement_lines ENABLE ROW LEVEL SECURITY; -CREATE POLICY tenant_isolation_purchase_agreement_lines ON purchase.purchase_agreement_lines - USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); - --- Tabla: vendor_evaluations (Evaluaciones de proveedores) -CREATE TABLE purchase.vendor_evaluations ( - 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 auth.companies(id) ON DELETE CASCADE, - - partner_id UUID NOT NULL REFERENCES core.partners(id), - - -- Período de evaluación - evaluation_date DATE NOT NULL, - period_start DATE NOT NULL, - period_end DATE NOT NULL, - - -- Calificaciones (1-5) - quality_rating INTEGER, - delivery_rating INTEGER, - service_rating INTEGER, - price_rating INTEGER, - overall_rating DECIMAL(3, 2), - - -- Métricas - on_time_delivery_rate DECIMAL(5, 2), -- Porcentaje - defect_rate DECIMAL(5, 2), -- Porcentaje - - -- Comentarios - comments TEXT, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - - CONSTRAINT chk_vendor_evaluations_quality CHECK (quality_rating >= 1 AND quality_rating <= 5), - CONSTRAINT chk_vendor_evaluations_delivery CHECK (delivery_rating >= 1 AND delivery_rating <= 5), - CONSTRAINT chk_vendor_evaluations_service CHECK (service_rating >= 1 AND service_rating <= 5), - CONSTRAINT chk_vendor_evaluations_price CHECK (price_rating >= 1 AND price_rating <= 5), - CONSTRAINT chk_vendor_evaluations_overall CHECK (overall_rating >= 1 AND overall_rating <= 5), - CONSTRAINT chk_vendor_evaluations_dates CHECK (period_end >= period_start) -); - --- ===================================================== --- INDICES --- ===================================================== - --- Purchase Orders -CREATE INDEX idx_purchase_orders_tenant_id ON purchase.purchase_orders(tenant_id); -CREATE INDEX idx_purchase_orders_company_id ON purchase.purchase_orders(company_id); -CREATE INDEX idx_purchase_orders_partner_id ON purchase.purchase_orders(partner_id); -CREATE INDEX idx_purchase_orders_name ON purchase.purchase_orders(name); -CREATE INDEX idx_purchase_orders_status ON purchase.purchase_orders(status); -CREATE INDEX idx_purchase_orders_order_date ON purchase.purchase_orders(order_date); -CREATE INDEX idx_purchase_orders_expected_date ON purchase.purchase_orders(expected_date); - --- Purchase Order Lines -CREATE INDEX idx_purchase_order_lines_order_id ON purchase.purchase_order_lines(order_id); -CREATE INDEX idx_purchase_order_lines_product_id ON purchase.purchase_order_lines(product_id); -CREATE INDEX idx_purchase_order_lines_analytic_account_id ON purchase.purchase_order_lines(analytic_account_id) WHERE analytic_account_id IS NOT NULL; - --- RFQs -CREATE INDEX idx_rfqs_tenant_id ON purchase.rfqs(tenant_id); -CREATE INDEX idx_rfqs_company_id ON purchase.rfqs(company_id); -CREATE INDEX idx_rfqs_status ON purchase.rfqs(status); -CREATE INDEX idx_rfqs_request_date ON purchase.rfqs(request_date); - --- RFQ Lines -CREATE INDEX idx_rfq_lines_rfq_id ON purchase.rfq_lines(rfq_id); -CREATE INDEX idx_rfq_lines_product_id ON purchase.rfq_lines(product_id); - --- Vendor Pricelists -CREATE INDEX idx_vendor_pricelists_tenant_id ON purchase.vendor_pricelists(tenant_id); -CREATE INDEX idx_vendor_pricelists_partner_id ON purchase.vendor_pricelists(partner_id); -CREATE INDEX idx_vendor_pricelists_product_id ON purchase.vendor_pricelists(product_id); -CREATE INDEX idx_vendor_pricelists_active ON purchase.vendor_pricelists(active) WHERE active = TRUE; - --- Purchase Agreements -CREATE INDEX idx_purchase_agreements_tenant_id ON purchase.purchase_agreements(tenant_id); -CREATE INDEX idx_purchase_agreements_company_id ON purchase.purchase_agreements(company_id); -CREATE INDEX idx_purchase_agreements_partner_id ON purchase.purchase_agreements(partner_id); -CREATE INDEX idx_purchase_agreements_dates ON purchase.purchase_agreements(start_date, end_date); -CREATE INDEX idx_purchase_agreements_active ON purchase.purchase_agreements(is_active) WHERE is_active = TRUE; - --- Purchase Agreement Lines -CREATE INDEX idx_purchase_agreement_lines_agreement_id ON purchase.purchase_agreement_lines(agreement_id); -CREATE INDEX idx_purchase_agreement_lines_product_id ON purchase.purchase_agreement_lines(product_id); - --- Vendor Evaluations -CREATE INDEX idx_vendor_evaluations_tenant_id ON purchase.vendor_evaluations(tenant_id); -CREATE INDEX idx_vendor_evaluations_partner_id ON purchase.vendor_evaluations(partner_id); -CREATE INDEX idx_vendor_evaluations_date ON purchase.vendor_evaluations(evaluation_date); - --- ===================================================== --- FUNCTIONS --- ===================================================== - --- Función: calculate_purchase_order_totals -CREATE OR REPLACE FUNCTION purchase.calculate_purchase_order_totals(p_order_id UUID) -RETURNS VOID AS $$ -DECLARE - v_amount_untaxed DECIMAL; - v_amount_tax DECIMAL; - v_amount_total DECIMAL; -BEGIN - SELECT - COALESCE(SUM(amount_untaxed), 0), - COALESCE(SUM(amount_tax), 0), - COALESCE(SUM(amount_total), 0) - INTO v_amount_untaxed, v_amount_tax, v_amount_total - FROM purchase.purchase_order_lines - WHERE order_id = p_order_id; - - UPDATE purchase.purchase_orders - SET amount_untaxed = v_amount_untaxed, - amount_tax = v_amount_tax, - amount_total = v_amount_total, - updated_at = CURRENT_TIMESTAMP, - updated_by = get_current_user_id() - WHERE id = p_order_id; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION purchase.calculate_purchase_order_totals IS 'Calcula los totales de una orden de compra'; - --- Función: create_picking_from_po -CREATE OR REPLACE FUNCTION purchase.create_picking_from_po(p_order_id UUID) -RETURNS UUID AS $$ -DECLARE - v_order RECORD; - v_picking_id UUID; - v_location_supplier UUID; - v_location_stock UUID; -BEGIN - -- Obtener datos de la orden - SELECT * INTO v_order - FROM purchase.purchase_orders - WHERE id = p_order_id; - - IF NOT FOUND THEN - RAISE EXCEPTION 'Purchase order % not found', p_order_id; - END IF; - - -- Obtener ubicaciones (simplificado - en producción obtener de configuración) - SELECT id INTO v_location_supplier - FROM inventory.locations - WHERE location_type = 'supplier' - LIMIT 1; - - SELECT id INTO v_location_stock - FROM inventory.locations - WHERE location_type = 'internal' - LIMIT 1; - - -- Crear picking - INSERT INTO inventory.pickings ( - tenant_id, - company_id, - name, - picking_type, - location_id, - location_dest_id, - partner_id, - origin, - scheduled_date - ) VALUES ( - v_order.tenant_id, - v_order.company_id, - 'IN/' || v_order.name, - 'incoming', - v_location_supplier, - v_location_stock, - v_order.partner_id, - v_order.name, - v_order.expected_date - ) RETURNING id INTO v_picking_id; - - -- Actualizar la PO con el picking_id - UPDATE purchase.purchase_orders - SET picking_id = v_picking_id - WHERE id = p_order_id; - - RETURN v_picking_id; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION purchase.create_picking_from_po IS 'Crea un picking de recepción a partir de una orden de compra'; - --- ===================================================== --- TRIGGERS --- ===================================================== - -CREATE TRIGGER trg_purchase_orders_updated_at - BEFORE UPDATE ON purchase.purchase_orders - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at_column(); - -CREATE TRIGGER trg_rfqs_updated_at - BEFORE UPDATE ON purchase.rfqs - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at_column(); - -CREATE TRIGGER trg_vendor_pricelists_updated_at - BEFORE UPDATE ON purchase.vendor_pricelists - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at_column(); - -CREATE TRIGGER trg_purchase_agreements_updated_at - BEFORE UPDATE ON purchase.purchase_agreements - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at_column(); - --- Trigger: Actualizar totales de PO al cambiar líneas -CREATE OR REPLACE FUNCTION purchase.trg_update_po_totals() -RETURNS TRIGGER AS $$ -BEGIN - IF TG_OP = 'DELETE' THEN - PERFORM purchase.calculate_purchase_order_totals(OLD.order_id); - ELSE - PERFORM purchase.calculate_purchase_order_totals(NEW.order_id); - END IF; - RETURN NULL; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER trg_purchase_order_lines_update_totals - AFTER INSERT OR UPDATE OR DELETE ON purchase.purchase_order_lines - FOR EACH ROW - EXECUTE FUNCTION purchase.trg_update_po_totals(); - --- ===================================================== --- TRACKING AUTOMÁTICO (mail.thread pattern) --- ===================================================== - --- Trigger: Tracking automático para órdenes de compra -CREATE TRIGGER track_purchase_order_changes - AFTER INSERT OR UPDATE OR DELETE ON purchase.purchase_orders - FOR EACH ROW EXECUTE FUNCTION system.track_field_changes(); - -COMMENT ON TRIGGER track_purchase_order_changes ON purchase.purchase_orders IS -'Registra automáticamente cambios en órdenes de compra (estado, proveedor, monto, fecha)'; - --- ===================================================== --- ROW LEVEL SECURITY (RLS) --- ===================================================== - -ALTER TABLE purchase.purchase_orders ENABLE ROW LEVEL SECURITY; -ALTER TABLE purchase.rfqs ENABLE ROW LEVEL SECURITY; -ALTER TABLE purchase.vendor_pricelists ENABLE ROW LEVEL SECURITY; -ALTER TABLE purchase.purchase_agreements ENABLE ROW LEVEL SECURITY; -ALTER TABLE purchase.vendor_evaluations ENABLE ROW LEVEL SECURITY; - -CREATE POLICY tenant_isolation_purchase_orders ON purchase.purchase_orders - USING (tenant_id = get_current_tenant_id()); - -CREATE POLICY tenant_isolation_rfqs ON purchase.rfqs - USING (tenant_id = get_current_tenant_id()); - -CREATE POLICY tenant_isolation_vendor_pricelists ON purchase.vendor_pricelists - USING (tenant_id = get_current_tenant_id()); - -CREATE POLICY tenant_isolation_purchase_agreements ON purchase.purchase_agreements - USING (tenant_id = get_current_tenant_id()); - -CREATE POLICY tenant_isolation_vendor_evaluations ON purchase.vendor_evaluations - USING (tenant_id = get_current_tenant_id()); - --- ===================================================== --- COMENTARIOS --- ===================================================== - -COMMENT ON SCHEMA purchase IS 'Schema de gestión de compras y proveedores'; -COMMENT ON TABLE purchase.purchase_orders IS 'Órdenes de compra a proveedores'; -COMMENT ON TABLE purchase.purchase_order_lines IS 'Líneas de órdenes de compra'; -COMMENT ON TABLE purchase.rfqs IS 'Solicitudes de cotización (RFQ)'; -COMMENT ON TABLE purchase.rfq_lines IS 'Líneas de solicitudes de cotización'; -COMMENT ON TABLE purchase.vendor_pricelists IS 'Listas de precios de proveedores'; -COMMENT ON TABLE purchase.purchase_agreements IS 'Acuerdos/contratos de compra con proveedores'; -COMMENT ON TABLE purchase.purchase_agreement_lines IS 'Líneas de acuerdos de compra'; -COMMENT ON TABLE purchase.vendor_evaluations IS 'Evaluaciones de desempeño de proveedores'; - --- ===================================================== --- FIN DEL SCHEMA PURCHASE --- ===================================================== diff --git a/ddl/07-sales.sql b/ddl/07-sales.sql deleted file mode 100644 index 10ec490..0000000 --- a/ddl/07-sales.sql +++ /dev/null @@ -1,705 +0,0 @@ --- ===================================================== --- SCHEMA: sales --- PROPÓSITO: Gestión de ventas, cotizaciones, clientes --- MÓDULOS: MGN-007 (Ventas Básico) --- FECHA: 2025-11-24 --- ===================================================== - --- Crear schema -CREATE SCHEMA IF NOT EXISTS sales; - --- ===================================================== --- TYPES (ENUMs) --- ===================================================== - -CREATE TYPE sales.order_status AS ENUM ( - 'draft', - 'sent', - 'sale', - 'done', - 'cancelled' -); - -CREATE TYPE sales.quotation_status AS ENUM ( - 'draft', - 'sent', - 'approved', - 'rejected', - 'converted', - 'expired' -); - -CREATE TYPE sales.invoice_policy AS ENUM ( - 'order', - 'delivery' -); - -CREATE TYPE sales.delivery_status AS ENUM ( - 'pending', - 'partial', - 'delivered' -); - -CREATE TYPE sales.invoice_status AS ENUM ( - 'pending', - 'partial', - 'invoiced' -); - --- ===================================================== --- TABLES --- ===================================================== - --- Tabla: sales_orders (Órdenes de venta) -CREATE TABLE sales.sales_orders ( - 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 auth.companies(id) ON DELETE CASCADE, - - -- Numeración - name VARCHAR(100) NOT NULL, - client_order_ref VARCHAR(100), -- Referencia del cliente - - -- Cliente - partner_id UUID NOT NULL REFERENCES core.partners(id), - - -- Fechas - order_date DATE NOT NULL, - validity_date DATE, - commitment_date DATE, - - -- Configuración - currency_id UUID NOT NULL REFERENCES core.currencies(id), - pricelist_id UUID REFERENCES sales.pricelists(id), - payment_term_id UUID REFERENCES financial.payment_terms(id), - - -- Usuario - user_id UUID REFERENCES auth.users(id), - sales_team_id UUID REFERENCES sales.sales_teams(id), - - -- Montos - amount_untaxed DECIMAL(15, 2) NOT NULL DEFAULT 0, - amount_tax DECIMAL(15, 2) NOT NULL DEFAULT 0, - amount_total DECIMAL(15, 2) NOT NULL DEFAULT 0, - - -- Estado - status sales.order_status NOT NULL DEFAULT 'draft', - invoice_status sales.invoice_status NOT NULL DEFAULT 'pending', - delivery_status sales.delivery_status NOT NULL DEFAULT 'pending', - - -- Facturación - invoice_policy sales.invoice_policy DEFAULT 'order', - - -- Relaciones generadas - picking_id UUID REFERENCES inventory.pickings(id), - - -- Notas - notes TEXT, - terms_conditions TEXT, - - -- Firma electrónica - signature TEXT, -- base64 - signature_date TIMESTAMP, - signature_ip INET, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMP, - updated_by UUID REFERENCES auth.users(id), - confirmed_at TIMESTAMP, - confirmed_by UUID REFERENCES auth.users(id), - cancelled_at TIMESTAMP, - cancelled_by UUID REFERENCES auth.users(id), - - CONSTRAINT uq_sales_orders_name_company UNIQUE (company_id, name), - CONSTRAINT chk_sales_orders_validity CHECK (validity_date IS NULL OR validity_date >= order_date) -); - --- Tabla: sales_order_lines (Líneas de orden de venta) -CREATE TABLE sales.sales_order_lines ( - 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 sales.sales_orders(id) ON DELETE CASCADE, - - product_id UUID NOT NULL REFERENCES inventory.products(id), - description TEXT NOT NULL, - - -- Cantidades - quantity DECIMAL(12, 4) NOT NULL, - qty_delivered DECIMAL(12, 4) DEFAULT 0, - qty_invoiced DECIMAL(12, 4) DEFAULT 0, - uom_id UUID NOT NULL REFERENCES core.uom(id), - - -- Precios - price_unit DECIMAL(15, 4) NOT NULL, - discount DECIMAL(5, 2) DEFAULT 0, -- Porcentaje de descuento - - -- Impuestos - tax_ids UUID[] DEFAULT '{}', - - -- Montos - amount_untaxed DECIMAL(15, 2) NOT NULL, - amount_tax DECIMAL(15, 2) NOT NULL, - amount_total DECIMAL(15, 2) NOT NULL, - - -- Analítica - analytic_account_id UUID REFERENCES analytics.analytic_accounts(id), -- Distribución analítica - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP, - - CONSTRAINT chk_sales_order_lines_quantity CHECK (quantity > 0), - CONSTRAINT chk_sales_order_lines_discount CHECK (discount >= 0 AND discount <= 100), - CONSTRAINT chk_sales_order_lines_qty_delivered CHECK (qty_delivered >= 0 AND qty_delivered <= quantity), - CONSTRAINT chk_sales_order_lines_qty_invoiced CHECK (qty_invoiced >= 0 AND qty_invoiced <= quantity) -); - --- Índices para sales_order_lines -CREATE INDEX idx_sales_order_lines_tenant_id ON sales.sales_order_lines(tenant_id); -CREATE INDEX idx_sales_order_lines_order_id ON sales.sales_order_lines(order_id); -CREATE INDEX idx_sales_order_lines_product_id ON sales.sales_order_lines(product_id); - --- RLS para sales_order_lines -ALTER TABLE sales.sales_order_lines ENABLE ROW LEVEL SECURITY; -CREATE POLICY tenant_isolation_sales_order_lines ON sales.sales_order_lines - USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); - --- Tabla: quotations (Cotizaciones) -CREATE TABLE sales.quotations ( - 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 auth.companies(id) ON DELETE CASCADE, - - -- Numeración - name VARCHAR(100) NOT NULL, - - -- Cliente potencial - partner_id UUID NOT NULL REFERENCES core.partners(id), - - -- Fechas - quotation_date DATE NOT NULL, - validity_date DATE NOT NULL, - - -- Configuración - currency_id UUID NOT NULL REFERENCES core.currencies(id), - pricelist_id UUID REFERENCES sales.pricelists(id), - - -- Usuario - user_id UUID REFERENCES auth.users(id), - sales_team_id UUID REFERENCES sales.sales_teams(id), - - -- Montos - amount_untaxed DECIMAL(15, 2) NOT NULL DEFAULT 0, - amount_tax DECIMAL(15, 2) NOT NULL DEFAULT 0, - amount_total DECIMAL(15, 2) NOT NULL DEFAULT 0, - - -- Estado - status sales.quotation_status NOT NULL DEFAULT 'draft', - - -- Conversión - sale_order_id UUID REFERENCES sales.sales_orders(id), -- Orden generada - - -- Notas - notes TEXT, - terms_conditions TEXT, - - -- Firma electrónica - signature TEXT, -- base64 - signature_date TIMESTAMP, - signature_ip INET, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMP, - updated_by UUID REFERENCES auth.users(id), - - CONSTRAINT uq_quotations_name_company UNIQUE (company_id, name), - CONSTRAINT chk_quotations_validity CHECK (validity_date >= quotation_date) -); - --- Tabla: quotation_lines (Líneas de cotización) -CREATE TABLE sales.quotation_lines ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - quotation_id UUID NOT NULL REFERENCES sales.quotations(id) ON DELETE CASCADE, - - product_id UUID REFERENCES inventory.products(id), - description TEXT NOT NULL, - quantity DECIMAL(12, 4) NOT NULL, - uom_id UUID NOT NULL REFERENCES core.uom(id), - price_unit DECIMAL(15, 4) NOT NULL, - discount DECIMAL(5, 2) DEFAULT 0, - tax_ids UUID[] DEFAULT '{}', - amount_untaxed DECIMAL(15, 2) NOT NULL, - amount_tax DECIMAL(15, 2) NOT NULL, - amount_total DECIMAL(15, 2) NOT NULL, - - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT chk_quotation_lines_quantity CHECK (quantity > 0), - CONSTRAINT chk_quotation_lines_discount CHECK (discount >= 0 AND discount <= 100) -); - --- Índices para quotation_lines -CREATE INDEX idx_quotation_lines_tenant_id ON sales.quotation_lines(tenant_id); -CREATE INDEX idx_quotation_lines_quotation_id ON sales.quotation_lines(quotation_id); -CREATE INDEX idx_quotation_lines_product_id ON sales.quotation_lines(product_id); - --- RLS para quotation_lines -ALTER TABLE sales.quotation_lines ENABLE ROW LEVEL SECURITY; -CREATE POLICY tenant_isolation_quotation_lines ON sales.quotation_lines - USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); - --- Tabla: pricelists (Listas de precios) -CREATE TABLE sales.pricelists ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - company_id UUID REFERENCES auth.companies(id), - - name VARCHAR(255) NOT NULL, - currency_id UUID NOT NULL REFERENCES core.currencies(id), - - -- Control - active BOOLEAN NOT NULL DEFAULT TRUE, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMP, - updated_by UUID REFERENCES auth.users(id), - - CONSTRAINT uq_pricelists_name_tenant UNIQUE (tenant_id, name) -); - --- Tabla: pricelist_items (Items de lista de precios) -CREATE TABLE sales.pricelist_items ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - pricelist_id UUID NOT NULL REFERENCES sales.pricelists(id) ON DELETE CASCADE, - - product_id UUID REFERENCES inventory.products(id), - product_category_id UUID REFERENCES core.product_categories(id), - - -- Precio - price DECIMAL(15, 4) NOT NULL, - - -- Cantidad mínima - min_quantity DECIMAL(12, 4) DEFAULT 1, - - -- Validez - valid_from DATE, - valid_to DATE, - - -- Control - active BOOLEAN NOT NULL DEFAULT TRUE, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - - CONSTRAINT chk_pricelist_items_price CHECK (price >= 0), - CONSTRAINT chk_pricelist_items_min_qty CHECK (min_quantity > 0), - CONSTRAINT chk_pricelist_items_dates CHECK (valid_to IS NULL OR valid_to >= valid_from), - CONSTRAINT chk_pricelist_items_product_or_category CHECK ( - (product_id IS NOT NULL AND product_category_id IS NULL) OR - (product_id IS NULL AND product_category_id IS NOT NULL) - ) -); - --- Índices para pricelist_items -CREATE INDEX idx_pricelist_items_tenant_id ON sales.pricelist_items(tenant_id); -CREATE INDEX idx_pricelist_items_pricelist_id ON sales.pricelist_items(pricelist_id); -CREATE INDEX idx_pricelist_items_product_id ON sales.pricelist_items(product_id); - --- RLS para pricelist_items -ALTER TABLE sales.pricelist_items ENABLE ROW LEVEL SECURITY; -CREATE POLICY tenant_isolation_pricelist_items ON sales.pricelist_items - USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); - --- Tabla: customer_groups (Grupos de clientes) -CREATE TABLE sales.customer_groups ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - - name VARCHAR(255) NOT NULL, - description TEXT, - discount_percentage DECIMAL(5, 2) DEFAULT 0, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - - CONSTRAINT uq_customer_groups_name_tenant UNIQUE (tenant_id, name), - CONSTRAINT chk_customer_groups_discount CHECK (discount_percentage >= 0 AND discount_percentage <= 100) -); - --- Tabla: customer_group_members (Miembros de grupos) -CREATE TABLE sales.customer_group_members ( - customer_group_id UUID NOT NULL REFERENCES sales.customer_groups(id) ON DELETE CASCADE, - partner_id UUID NOT NULL REFERENCES core.partners(id) ON DELETE CASCADE, - joined_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - - PRIMARY KEY (customer_group_id, partner_id) -); - --- Tabla: sales_teams (Equipos de ventas) -CREATE TABLE sales.sales_teams ( - 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 auth.companies(id) ON DELETE CASCADE, - - name VARCHAR(255) NOT NULL, - code VARCHAR(50), - team_leader_id UUID REFERENCES auth.users(id), - - -- Objetivos - target_monthly DECIMAL(15, 2), - target_annual DECIMAL(15, 2), - - -- Control - active BOOLEAN NOT NULL DEFAULT TRUE, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMP, - updated_by UUID REFERENCES auth.users(id), - - CONSTRAINT uq_sales_teams_code_company UNIQUE (company_id, code) -); - --- Tabla: sales_team_members (Miembros de equipos) -CREATE TABLE sales.sales_team_members ( - sales_team_id UUID NOT NULL REFERENCES sales.sales_teams(id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - joined_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - - PRIMARY KEY (sales_team_id, user_id) -); - --- ===================================================== --- INDICES --- ===================================================== - --- Sales Orders -CREATE INDEX idx_sales_orders_tenant_id ON sales.sales_orders(tenant_id); -CREATE INDEX idx_sales_orders_company_id ON sales.sales_orders(company_id); -CREATE INDEX idx_sales_orders_partner_id ON sales.sales_orders(partner_id); -CREATE INDEX idx_sales_orders_name ON sales.sales_orders(name); -CREATE INDEX idx_sales_orders_status ON sales.sales_orders(status); -CREATE INDEX idx_sales_orders_order_date ON sales.sales_orders(order_date); -CREATE INDEX idx_sales_orders_user_id ON sales.sales_orders(user_id); -CREATE INDEX idx_sales_orders_sales_team_id ON sales.sales_orders(sales_team_id); - --- Sales Order Lines -CREATE INDEX idx_sales_order_lines_order_id ON sales.sales_order_lines(order_id); -CREATE INDEX idx_sales_order_lines_product_id ON sales.sales_order_lines(product_id); -CREATE INDEX idx_sales_order_lines_analytic_account_id ON sales.sales_order_lines(analytic_account_id) WHERE analytic_account_id IS NOT NULL; - --- Quotations -CREATE INDEX idx_quotations_tenant_id ON sales.quotations(tenant_id); -CREATE INDEX idx_quotations_company_id ON sales.quotations(company_id); -CREATE INDEX idx_quotations_partner_id ON sales.quotations(partner_id); -CREATE INDEX idx_quotations_status ON sales.quotations(status); -CREATE INDEX idx_quotations_validity_date ON sales.quotations(validity_date); - --- Quotation Lines -CREATE INDEX idx_quotation_lines_quotation_id ON sales.quotation_lines(quotation_id); -CREATE INDEX idx_quotation_lines_product_id ON sales.quotation_lines(product_id); - --- Pricelists -CREATE INDEX idx_pricelists_tenant_id ON sales.pricelists(tenant_id); -CREATE INDEX idx_pricelists_active ON sales.pricelists(active) WHERE active = TRUE; - --- Pricelist Items -CREATE INDEX idx_pricelist_items_pricelist_id ON sales.pricelist_items(pricelist_id); -CREATE INDEX idx_pricelist_items_product_id ON sales.pricelist_items(product_id); -CREATE INDEX idx_pricelist_items_category_id ON sales.pricelist_items(product_category_id); - --- Customer Groups -CREATE INDEX idx_customer_groups_tenant_id ON sales.customer_groups(tenant_id); - --- Sales Teams -CREATE INDEX idx_sales_teams_tenant_id ON sales.sales_teams(tenant_id); -CREATE INDEX idx_sales_teams_company_id ON sales.sales_teams(company_id); -CREATE INDEX idx_sales_teams_leader_id ON sales.sales_teams(team_leader_id); - --- ===================================================== --- FUNCTIONS --- ===================================================== - --- Función: calculate_sales_order_totals -CREATE OR REPLACE FUNCTION sales.calculate_sales_order_totals(p_order_id UUID) -RETURNS VOID AS $$ -DECLARE - v_amount_untaxed DECIMAL; - v_amount_tax DECIMAL; - v_amount_total DECIMAL; -BEGIN - SELECT - COALESCE(SUM(amount_untaxed), 0), - COALESCE(SUM(amount_tax), 0), - COALESCE(SUM(amount_total), 0) - INTO v_amount_untaxed, v_amount_tax, v_amount_total - FROM sales.sales_order_lines - WHERE order_id = p_order_id; - - UPDATE sales.sales_orders - SET amount_untaxed = v_amount_untaxed, - amount_tax = v_amount_tax, - amount_total = v_amount_total, - updated_at = CURRENT_TIMESTAMP, - updated_by = get_current_user_id() - WHERE id = p_order_id; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION sales.calculate_sales_order_totals IS 'Calcula los totales de una orden de venta'; - --- Función: calculate_quotation_totals -CREATE OR REPLACE FUNCTION sales.calculate_quotation_totals(p_quotation_id UUID) -RETURNS VOID AS $$ -DECLARE - v_amount_untaxed DECIMAL; - v_amount_tax DECIMAL; - v_amount_total DECIMAL; -BEGIN - SELECT - COALESCE(SUM(amount_untaxed), 0), - COALESCE(SUM(amount_tax), 0), - COALESCE(SUM(amount_total), 0) - INTO v_amount_untaxed, v_amount_tax, v_amount_total - FROM sales.quotation_lines - WHERE quotation_id = p_quotation_id; - - UPDATE sales.quotations - SET amount_untaxed = v_amount_untaxed, - amount_tax = v_amount_tax, - amount_total = v_amount_total, - updated_at = CURRENT_TIMESTAMP, - updated_by = get_current_user_id() - WHERE id = p_quotation_id; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION sales.calculate_quotation_totals IS 'Calcula los totales de una cotización'; - --- Función: convert_quotation_to_order -CREATE OR REPLACE FUNCTION sales.convert_quotation_to_order(p_quotation_id UUID) -RETURNS UUID AS $$ -DECLARE - v_quotation RECORD; - v_order_id UUID; -BEGIN - -- Obtener cotización - SELECT * INTO v_quotation - FROM sales.quotations - WHERE id = p_quotation_id; - - IF NOT FOUND THEN - RAISE EXCEPTION 'Quotation % not found', p_quotation_id; - END IF; - - IF v_quotation.status != 'approved' THEN - RAISE EXCEPTION 'Quotation must be approved before conversion'; - END IF; - - -- Crear orden de venta - INSERT INTO sales.sales_orders ( - tenant_id, - company_id, - name, - partner_id, - order_date, - currency_id, - pricelist_id, - user_id, - sales_team_id, - amount_untaxed, - amount_tax, - amount_total, - notes, - terms_conditions, - signature, - signature_date, - signature_ip - ) VALUES ( - v_quotation.tenant_id, - v_quotation.company_id, - REPLACE(v_quotation.name, 'QT', 'SO'), - v_quotation.partner_id, - CURRENT_DATE, - v_quotation.currency_id, - v_quotation.pricelist_id, - v_quotation.user_id, - v_quotation.sales_team_id, - v_quotation.amount_untaxed, - v_quotation.amount_tax, - v_quotation.amount_total, - v_quotation.notes, - v_quotation.terms_conditions, - v_quotation.signature, - v_quotation.signature_date, - v_quotation.signature_ip - ) RETURNING id INTO v_order_id; - - -- Copiar líneas - INSERT INTO sales.sales_order_lines ( - order_id, - product_id, - description, - quantity, - uom_id, - price_unit, - discount, - tax_ids, - amount_untaxed, - amount_tax, - amount_total - ) - SELECT - v_order_id, - product_id, - description, - quantity, - uom_id, - price_unit, - discount, - tax_ids, - amount_untaxed, - amount_tax, - amount_total - FROM sales.quotation_lines - WHERE quotation_id = p_quotation_id; - - -- Actualizar cotización - UPDATE sales.quotations - SET status = 'converted', - sale_order_id = v_order_id, - updated_at = CURRENT_TIMESTAMP, - updated_by = get_current_user_id() - WHERE id = p_quotation_id; - - RETURN v_order_id; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION sales.convert_quotation_to_order IS 'Convierte una cotización aprobada en orden de venta'; - --- ===================================================== --- TRIGGERS --- ===================================================== - -CREATE TRIGGER trg_sales_orders_updated_at - BEFORE UPDATE ON sales.sales_orders - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at_column(); - -CREATE TRIGGER trg_quotations_updated_at - BEFORE UPDATE ON sales.quotations - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at_column(); - -CREATE TRIGGER trg_pricelists_updated_at - BEFORE UPDATE ON sales.pricelists - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at_column(); - -CREATE TRIGGER trg_sales_teams_updated_at - BEFORE UPDATE ON sales.sales_teams - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at_column(); - --- Trigger: Actualizar totales de orden al cambiar líneas -CREATE OR REPLACE FUNCTION sales.trg_update_so_totals() -RETURNS TRIGGER AS $$ -BEGIN - IF TG_OP = 'DELETE' THEN - PERFORM sales.calculate_sales_order_totals(OLD.order_id); - ELSE - PERFORM sales.calculate_sales_order_totals(NEW.order_id); - END IF; - RETURN NULL; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER trg_sales_order_lines_update_totals - AFTER INSERT OR UPDATE OR DELETE ON sales.sales_order_lines - FOR EACH ROW - EXECUTE FUNCTION sales.trg_update_so_totals(); - --- Trigger: Actualizar totales de cotización al cambiar líneas -CREATE OR REPLACE FUNCTION sales.trg_update_quotation_totals() -RETURNS TRIGGER AS $$ -BEGIN - IF TG_OP = 'DELETE' THEN - PERFORM sales.calculate_quotation_totals(OLD.quotation_id); - ELSE - PERFORM sales.calculate_quotation_totals(NEW.quotation_id); - END IF; - RETURN NULL; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER trg_quotation_lines_update_totals - AFTER INSERT OR UPDATE OR DELETE ON sales.quotation_lines - FOR EACH ROW - EXECUTE FUNCTION sales.trg_update_quotation_totals(); - --- ===================================================== --- TRACKING AUTOMÁTICO (mail.thread pattern) --- ===================================================== - --- Trigger: Tracking automático para órdenes de venta -CREATE TRIGGER track_sales_order_changes - AFTER INSERT OR UPDATE OR DELETE ON sales.sales_orders - FOR EACH ROW EXECUTE FUNCTION system.track_field_changes(); - -COMMENT ON TRIGGER track_sales_order_changes ON sales.sales_orders IS -'Registra automáticamente cambios en órdenes de venta (estado, cliente, monto, fecha, facturación, entrega)'; - --- ===================================================== --- ROW LEVEL SECURITY (RLS) --- ===================================================== - -ALTER TABLE sales.sales_orders ENABLE ROW LEVEL SECURITY; -ALTER TABLE sales.quotations ENABLE ROW LEVEL SECURITY; -ALTER TABLE sales.pricelists ENABLE ROW LEVEL SECURITY; -ALTER TABLE sales.customer_groups ENABLE ROW LEVEL SECURITY; -ALTER TABLE sales.sales_teams ENABLE ROW LEVEL SECURITY; - -CREATE POLICY tenant_isolation_sales_orders ON sales.sales_orders - USING (tenant_id = get_current_tenant_id()); - -CREATE POLICY tenant_isolation_quotations ON sales.quotations - USING (tenant_id = get_current_tenant_id()); - -CREATE POLICY tenant_isolation_pricelists ON sales.pricelists - USING (tenant_id = get_current_tenant_id()); - -CREATE POLICY tenant_isolation_customer_groups ON sales.customer_groups - USING (tenant_id = get_current_tenant_id()); - -CREATE POLICY tenant_isolation_sales_teams ON sales.sales_teams - USING (tenant_id = get_current_tenant_id()); - --- ===================================================== --- COMENTARIOS --- ===================================================== - -COMMENT ON SCHEMA sales IS 'Schema de gestión de ventas, cotizaciones y clientes'; -COMMENT ON TABLE sales.sales_orders IS 'Órdenes de venta confirmadas'; -COMMENT ON TABLE sales.sales_order_lines IS 'Líneas de órdenes de venta'; -COMMENT ON TABLE sales.quotations IS 'Cotizaciones enviadas a clientes'; -COMMENT ON TABLE sales.quotation_lines IS 'Líneas de cotizaciones'; -COMMENT ON TABLE sales.pricelists IS 'Listas de precios para clientes'; -COMMENT ON TABLE sales.pricelist_items IS 'Items de listas de precios por producto/categoría'; -COMMENT ON TABLE sales.customer_groups IS 'Grupos de clientes para descuentos y segmentación'; -COMMENT ON TABLE sales.sales_teams IS 'Equipos de ventas con objetivos'; - --- ===================================================== --- FIN DEL SCHEMA SALES --- ===================================================== diff --git a/ddl/07-users-rbac.sql b/ddl/07-users-rbac.sql new file mode 100644 index 0000000..85ca1f3 --- /dev/null +++ b/ddl/07-users-rbac.sql @@ -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'; diff --git a/ddl/08-plans.sql b/ddl/08-plans.sql new file mode 100644 index 0000000..f2efeda --- /dev/null +++ b/ddl/08-plans.sql @@ -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'; diff --git a/ddl/08-projects.sql b/ddl/08-projects.sql deleted file mode 100644 index e8cc807..0000000 --- a/ddl/08-projects.sql +++ /dev/null @@ -1,537 +0,0 @@ --- ===================================================== --- SCHEMA: projects --- PROPÓSITO: Gestión de proyectos, tareas, milestones --- MÓDULOS: MGN-011 (Proyectos Genéricos) --- FECHA: 2025-11-24 --- ===================================================== - --- Crear schema -CREATE SCHEMA IF NOT EXISTS projects; - --- ===================================================== --- TYPES (ENUMs) --- ===================================================== - -CREATE TYPE projects.project_status AS ENUM ( - 'draft', - 'active', - 'completed', - 'cancelled', - 'on_hold' -); - -CREATE TYPE projects.privacy_type AS ENUM ( - 'public', - 'private', - 'followers' -); - -CREATE TYPE projects.task_status AS ENUM ( - 'todo', - 'in_progress', - 'review', - 'done', - 'cancelled' -); - -CREATE TYPE projects.task_priority AS ENUM ( - 'low', - 'normal', - 'high', - 'urgent' -); - -CREATE TYPE projects.dependency_type AS ENUM ( - 'finish_to_start', - 'start_to_start', - 'finish_to_finish', - 'start_to_finish' -); - -CREATE TYPE projects.milestone_status AS ENUM ( - 'pending', - 'completed' -); - --- ===================================================== --- TABLES --- ===================================================== - --- Tabla: projects (Proyectos) -CREATE TABLE projects.projects ( - 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 auth.companies(id) ON DELETE CASCADE, - - -- Identificación - name VARCHAR(255) NOT NULL, - code VARCHAR(50), - description TEXT, - - -- Responsables - manager_id UUID REFERENCES auth.users(id), - partner_id UUID REFERENCES core.partners(id), -- Cliente - - -- Analítica (1-1) - analytic_account_id UUID REFERENCES analytics.analytic_accounts(id), - - -- Fechas - date_start DATE, - date_end DATE, - - -- Estado - status projects.project_status NOT NULL DEFAULT 'draft', - privacy projects.privacy_type NOT NULL DEFAULT 'public', - - -- Configuración - allow_timesheets BOOLEAN DEFAULT TRUE, - color VARCHAR(20), -- Color para UI - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMP, - updated_by UUID REFERENCES auth.users(id), - deleted_at TIMESTAMP, - deleted_by UUID REFERENCES auth.users(id), - - CONSTRAINT uq_projects_code_company UNIQUE (company_id, code), - CONSTRAINT chk_projects_dates CHECK (date_end IS NULL OR date_end >= date_start) -); - --- Tabla: project_stages (Etapas de tareas) -CREATE TABLE projects.project_stages ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - - project_id UUID REFERENCES projects.projects(id) ON DELETE CASCADE, - - name VARCHAR(100) NOT NULL, - sequence INTEGER NOT NULL DEFAULT 1, - is_closed BOOLEAN DEFAULT FALSE, -- Etapa final - fold BOOLEAN DEFAULT FALSE, -- Plegada en kanban - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT chk_project_stages_sequence CHECK (sequence > 0) -); - --- Tabla: tasks (Tareas) -CREATE TABLE projects.tasks ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - - project_id UUID NOT NULL REFERENCES projects.projects(id) ON DELETE CASCADE, - stage_id UUID REFERENCES projects.project_stages(id), - - -- Identificación - name VARCHAR(255) NOT NULL, - description TEXT, - - -- Asignación - assigned_to UUID REFERENCES auth.users(id), - partner_id UUID REFERENCES core.partners(id), - - -- Jerarquía - parent_id UUID REFERENCES projects.tasks(id), - - -- Fechas - date_start DATE, - date_deadline DATE, - - -- Esfuerzo - planned_hours DECIMAL(8, 2) DEFAULT 0, - actual_hours DECIMAL(8, 2) DEFAULT 0, - progress INTEGER DEFAULT 0, -- 0-100 - - -- Prioridad y estado - priority projects.task_priority NOT NULL DEFAULT 'normal', - status projects.task_status NOT NULL DEFAULT 'todo', - - -- Milestone - milestone_id UUID REFERENCES projects.milestones(id), - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMP, - updated_by UUID REFERENCES auth.users(id), - deleted_at TIMESTAMP, - deleted_by UUID REFERENCES auth.users(id), - - CONSTRAINT chk_tasks_no_self_parent CHECK (id != parent_id), - CONSTRAINT chk_tasks_dates CHECK (date_deadline IS NULL OR date_deadline >= date_start), - CONSTRAINT chk_tasks_planned_hours CHECK (planned_hours >= 0), - CONSTRAINT chk_tasks_actual_hours CHECK (actual_hours >= 0), - CONSTRAINT chk_tasks_progress CHECK (progress >= 0 AND progress <= 100) -); - --- Tabla: milestones (Hitos) -CREATE TABLE projects.milestones ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - - project_id UUID NOT NULL REFERENCES projects.projects(id) ON DELETE CASCADE, - - name VARCHAR(255) NOT NULL, - description TEXT, - target_date DATE NOT NULL, - status projects.milestone_status NOT NULL DEFAULT 'pending', - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMP, - updated_by UUID REFERENCES auth.users(id), - completed_at TIMESTAMP, - completed_by UUID REFERENCES auth.users(id) -); - --- Tabla: task_dependencies (Dependencias entre tareas) -CREATE TABLE projects.task_dependencies ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - task_id UUID NOT NULL REFERENCES projects.tasks(id) ON DELETE CASCADE, - depends_on_id UUID NOT NULL REFERENCES projects.tasks(id) ON DELETE CASCADE, - - dependency_type projects.dependency_type NOT NULL DEFAULT 'finish_to_start', - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT uq_task_dependencies UNIQUE (task_id, depends_on_id), - CONSTRAINT chk_task_dependencies_no_self CHECK (task_id != depends_on_id) -); - --- Tabla: task_tags (Etiquetas de tareas) -CREATE TABLE projects.task_tags ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - - name VARCHAR(100) NOT NULL, - color VARCHAR(20), - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT uq_task_tags_name_tenant UNIQUE (tenant_id, name) -); - --- Tabla: task_tag_assignments (Many-to-many) -CREATE TABLE projects.task_tag_assignments ( - task_id UUID NOT NULL REFERENCES projects.tasks(id) ON DELETE CASCADE, - tag_id UUID NOT NULL REFERENCES projects.task_tags(id) ON DELETE CASCADE, - - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - - PRIMARY KEY (task_id, tag_id) -); - --- Tabla: timesheets (Registro de horas) -CREATE TABLE projects.timesheets ( - 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 auth.companies(id) ON DELETE CASCADE, - - task_id UUID REFERENCES projects.tasks(id) ON DELETE SET NULL, - project_id UUID NOT NULL REFERENCES projects.projects(id), - - employee_id UUID, -- FK a hr.employees (se crea después) - user_id UUID REFERENCES auth.users(id), - - -- Fecha y horas - date DATE NOT NULL, - hours DECIMAL(8, 2) NOT NULL, - - -- Descripción - description TEXT, - - -- Analítica - analytic_account_id UUID REFERENCES analytics.analytic_accounts(id), -- Distribución analítica - analytic_line_id UUID REFERENCES analytics.analytic_lines(id), -- Línea analítica generada - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMP, - updated_by UUID REFERENCES auth.users(id), - - CONSTRAINT chk_timesheets_hours CHECK (hours > 0) -); - --- Tabla: task_checklists (Checklists dentro de tareas) -CREATE TABLE projects.task_checklists ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - task_id UUID NOT NULL REFERENCES projects.tasks(id) ON DELETE CASCADE, - - item_name VARCHAR(255) NOT NULL, - is_completed BOOLEAN DEFAULT FALSE, - sequence INTEGER DEFAULT 1, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - completed_at TIMESTAMP, - completed_by UUID REFERENCES auth.users(id) -); - --- Tabla: project_templates (Plantillas de proyectos) -CREATE TABLE projects.project_templates ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - - name VARCHAR(255) NOT NULL, - description TEXT, - - -- Template data (JSON con estructura de proyecto, tareas, etc.) - template_data JSONB DEFAULT '{}', - - -- Control - active BOOLEAN DEFAULT TRUE, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMP, - updated_by UUID REFERENCES auth.users(id), - - CONSTRAINT uq_project_templates_name_tenant UNIQUE (tenant_id, name) -); - --- ===================================================== --- INDICES --- ===================================================== - --- Projects -CREATE INDEX idx_projects_tenant_id ON projects.projects(tenant_id); -CREATE INDEX idx_projects_company_id ON projects.projects(company_id); -CREATE INDEX idx_projects_manager_id ON projects.projects(manager_id); -CREATE INDEX idx_projects_partner_id ON projects.projects(partner_id); -CREATE INDEX idx_projects_analytic_account_id ON projects.projects(analytic_account_id); -CREATE INDEX idx_projects_status ON projects.projects(status); - --- Project Stages -CREATE INDEX idx_project_stages_tenant_id ON projects.project_stages(tenant_id); -CREATE INDEX idx_project_stages_project_id ON projects.project_stages(project_id); -CREATE INDEX idx_project_stages_sequence ON projects.project_stages(sequence); - --- Tasks -CREATE INDEX idx_tasks_tenant_id ON projects.tasks(tenant_id); -CREATE INDEX idx_tasks_project_id ON projects.tasks(project_id); -CREATE INDEX idx_tasks_stage_id ON projects.tasks(stage_id); -CREATE INDEX idx_tasks_assigned_to ON projects.tasks(assigned_to); -CREATE INDEX idx_tasks_parent_id ON projects.tasks(parent_id); -CREATE INDEX idx_tasks_milestone_id ON projects.tasks(milestone_id); -CREATE INDEX idx_tasks_status ON projects.tasks(status); -CREATE INDEX idx_tasks_priority ON projects.tasks(priority); -CREATE INDEX idx_tasks_date_deadline ON projects.tasks(date_deadline); - --- Milestones -CREATE INDEX idx_milestones_tenant_id ON projects.milestones(tenant_id); -CREATE INDEX idx_milestones_project_id ON projects.milestones(project_id); -CREATE INDEX idx_milestones_status ON projects.milestones(status); -CREATE INDEX idx_milestones_target_date ON projects.milestones(target_date); - --- Task Dependencies -CREATE INDEX idx_task_dependencies_task_id ON projects.task_dependencies(task_id); -CREATE INDEX idx_task_dependencies_depends_on_id ON projects.task_dependencies(depends_on_id); - --- Timesheets -CREATE INDEX idx_timesheets_tenant_id ON projects.timesheets(tenant_id); -CREATE INDEX idx_timesheets_company_id ON projects.timesheets(company_id); -CREATE INDEX idx_timesheets_task_id ON projects.timesheets(task_id); -CREATE INDEX idx_timesheets_project_id ON projects.timesheets(project_id); -CREATE INDEX idx_timesheets_employee_id ON projects.timesheets(employee_id); -CREATE INDEX idx_timesheets_date ON projects.timesheets(date); -CREATE INDEX idx_timesheets_analytic_account_id ON projects.timesheets(analytic_account_id) WHERE analytic_account_id IS NOT NULL; - --- Task Checklists -CREATE INDEX idx_task_checklists_task_id ON projects.task_checklists(task_id); - --- Project Templates -CREATE INDEX idx_project_templates_tenant_id ON projects.project_templates(tenant_id); -CREATE INDEX idx_project_templates_active ON projects.project_templates(active) WHERE active = TRUE; - --- ===================================================== --- FUNCTIONS --- ===================================================== - --- Función: update_task_actual_hours -CREATE OR REPLACE FUNCTION projects.update_task_actual_hours() -RETURNS TRIGGER AS $$ -BEGIN - IF TG_OP = 'DELETE' THEN - UPDATE projects.tasks - SET actual_hours = ( - SELECT COALESCE(SUM(hours), 0) - FROM projects.timesheets - WHERE task_id = OLD.task_id - ) - WHERE id = OLD.task_id; - ELSE - UPDATE projects.tasks - SET actual_hours = ( - SELECT COALESCE(SUM(hours), 0) - FROM projects.timesheets - WHERE task_id = NEW.task_id - ) - WHERE id = NEW.task_id; - END IF; - RETURN NULL; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION projects.update_task_actual_hours IS 'Actualiza las horas reales de una tarea al cambiar timesheets'; - --- Función: check_task_dependencies -CREATE OR REPLACE FUNCTION projects.check_task_dependencies(p_task_id UUID) -RETURNS BOOLEAN AS $$ -DECLARE - v_unfinished_count INTEGER; -BEGIN - SELECT COUNT(*) - INTO v_unfinished_count - FROM projects.task_dependencies td - JOIN projects.tasks t ON td.depends_on_id = t.id - WHERE td.task_id = p_task_id - AND t.status != 'done'; - - RETURN v_unfinished_count = 0; -END; -$$ LANGUAGE plpgsql STABLE; - -COMMENT ON FUNCTION projects.check_task_dependencies IS 'Verifica si todas las dependencias de una tarea están completadas'; - --- Función: prevent_circular_dependencies -CREATE OR REPLACE FUNCTION projects.prevent_circular_dependencies() -RETURNS TRIGGER AS $$ -BEGIN - -- Verificar si crear esta dependencia crea un ciclo - IF EXISTS ( - WITH RECURSIVE dep_chain AS ( - SELECT task_id, depends_on_id - FROM projects.task_dependencies - WHERE task_id = NEW.depends_on_id - - UNION ALL - - SELECT td.task_id, td.depends_on_id - FROM projects.task_dependencies td - JOIN dep_chain dc ON td.task_id = dc.depends_on_id - ) - SELECT 1 FROM dep_chain WHERE depends_on_id = NEW.task_id - ) THEN - RAISE EXCEPTION 'Cannot create circular dependency'; - END IF; - - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION projects.prevent_circular_dependencies IS 'Previene la creación de dependencias circulares entre tareas'; - --- ===================================================== --- TRIGGERS --- ===================================================== - -CREATE TRIGGER trg_projects_updated_at - BEFORE UPDATE ON projects.projects - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at_column(); - -CREATE TRIGGER trg_tasks_updated_at - BEFORE UPDATE ON projects.tasks - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at_column(); - -CREATE TRIGGER trg_milestones_updated_at - BEFORE UPDATE ON projects.milestones - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at_column(); - -CREATE TRIGGER trg_timesheets_updated_at - BEFORE UPDATE ON projects.timesheets - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at_column(); - -CREATE TRIGGER trg_project_templates_updated_at - BEFORE UPDATE ON projects.project_templates - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at_column(); - --- Trigger: Actualizar horas reales de tarea al cambiar timesheet -CREATE TRIGGER trg_timesheets_update_task_hours - AFTER INSERT OR UPDATE OR DELETE ON projects.timesheets - FOR EACH ROW - EXECUTE FUNCTION projects.update_task_actual_hours(); - --- Trigger: Prevenir dependencias circulares -CREATE TRIGGER trg_task_dependencies_prevent_circular - BEFORE INSERT ON projects.task_dependencies - FOR EACH ROW - EXECUTE FUNCTION projects.prevent_circular_dependencies(); - --- ===================================================== --- TRACKING AUTOMÁTICO (mail.thread pattern) --- ===================================================== - --- Trigger: Tracking automático para proyectos -CREATE TRIGGER track_project_changes - AFTER INSERT OR UPDATE OR DELETE ON projects.projects - FOR EACH ROW EXECUTE FUNCTION system.track_field_changes(); - -COMMENT ON TRIGGER track_project_changes ON projects.projects IS -'Registra automáticamente cambios en proyectos (estado, nombre, responsable, fechas)'; - --- ===================================================== --- ROW LEVEL SECURITY (RLS) --- ===================================================== - -ALTER TABLE projects.projects ENABLE ROW LEVEL SECURITY; -ALTER TABLE projects.project_stages ENABLE ROW LEVEL SECURITY; -ALTER TABLE projects.tasks ENABLE ROW LEVEL SECURITY; -ALTER TABLE projects.milestones ENABLE ROW LEVEL SECURITY; -ALTER TABLE projects.task_tags ENABLE ROW LEVEL SECURITY; -ALTER TABLE projects.timesheets ENABLE ROW LEVEL SECURITY; -ALTER TABLE projects.project_templates ENABLE ROW LEVEL SECURITY; - -CREATE POLICY tenant_isolation_projects ON projects.projects - USING (tenant_id = get_current_tenant_id()); - -CREATE POLICY tenant_isolation_project_stages ON projects.project_stages - USING (tenant_id = get_current_tenant_id()); - -CREATE POLICY tenant_isolation_tasks ON projects.tasks - USING (tenant_id = get_current_tenant_id()); - -CREATE POLICY tenant_isolation_milestones ON projects.milestones - USING (tenant_id = get_current_tenant_id()); - -CREATE POLICY tenant_isolation_task_tags ON projects.task_tags - USING (tenant_id = get_current_tenant_id()); - -CREATE POLICY tenant_isolation_timesheets ON projects.timesheets - USING (tenant_id = get_current_tenant_id()); - -CREATE POLICY tenant_isolation_project_templates ON projects.project_templates - USING (tenant_id = get_current_tenant_id()); - --- ===================================================== --- COMENTARIOS --- ===================================================== - -COMMENT ON SCHEMA projects IS 'Schema de gestión de proyectos, tareas y timesheets'; -COMMENT ON TABLE projects.projects IS 'Proyectos genéricos con tracking de tareas'; -COMMENT ON TABLE projects.project_stages IS 'Etapas/columnas para tablero Kanban de tareas'; -COMMENT ON TABLE projects.tasks IS 'Tareas dentro de proyectos con jerarquía y dependencias'; -COMMENT ON TABLE projects.milestones IS 'Hitos importantes en proyectos'; -COMMENT ON TABLE projects.task_dependencies IS 'Dependencias entre tareas (precedencia)'; -COMMENT ON TABLE projects.task_tags IS 'Etiquetas para categorizar tareas'; -COMMENT ON TABLE projects.timesheets IS 'Registro de horas trabajadas en tareas'; -COMMENT ON TABLE projects.task_checklists IS 'Checklists dentro de tareas'; -COMMENT ON TABLE projects.project_templates IS 'Plantillas de proyectos para reutilización'; - --- ===================================================== --- FIN DEL SCHEMA PROJECTS --- ===================================================== diff --git a/ddl/09-notifications.sql b/ddl/09-notifications.sql new file mode 100644 index 0000000..5ef8f76 --- /dev/null +++ b/ddl/09-notifications.sql @@ -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'; diff --git a/ddl/09-system.sql b/ddl/09-system.sql deleted file mode 100644 index 07e4053..0000000 --- a/ddl/09-system.sql +++ /dev/null @@ -1,853 +0,0 @@ --- ===================================================== --- SCHEMA: system --- PROPÓSITO: Mensajería, notificaciones, logs, reportes --- MÓDULOS: MGN-012 (Reportes), MGN-014 (Mensajería) --- FECHA: 2025-11-24 --- ===================================================== - --- Crear schema -CREATE SCHEMA IF NOT EXISTS system; - --- ===================================================== --- TYPES (ENUMs) --- ===================================================== - -CREATE TYPE system.message_type AS ENUM ( - 'comment', - 'note', - 'email', - 'notification', - 'system' -); - -CREATE TYPE system.notification_status AS ENUM ( - 'pending', - 'sent', - 'read', - 'failed' -); - -CREATE TYPE system.activity_type AS ENUM ( - 'call', - 'meeting', - 'email', - 'todo', - 'follow_up', - 'custom' -); - -CREATE TYPE system.activity_status AS ENUM ( - 'planned', - 'done', - 'cancelled', - 'overdue' -); - -CREATE TYPE system.email_status AS ENUM ( - 'draft', - 'queued', - 'sending', - 'sent', - 'failed', - 'bounced' -); - -CREATE TYPE system.log_level AS ENUM ( - 'debug', - 'info', - 'warning', - 'error', - 'critical' -); - -CREATE TYPE system.report_format AS ENUM ( - 'pdf', - 'excel', - 'csv', - 'html' -); - --- ===================================================== --- TABLES --- ===================================================== - --- Tabla: messages (Chatter - mensajes en registros) -CREATE TABLE system.messages ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - - -- Referencia polimórfica (a qué registro pertenece) - model VARCHAR(100) NOT NULL, -- 'SaleOrder', 'Task', 'Invoice', etc. - record_id UUID NOT NULL, - - -- Tipo y contenido - message_type system.message_type NOT NULL DEFAULT 'comment', - subject VARCHAR(255), - body TEXT NOT NULL, - - -- Autor - author_id UUID REFERENCES auth.users(id), - author_name VARCHAR(255), - author_email VARCHAR(255), - - -- Email tracking - email_from VARCHAR(255), - reply_to VARCHAR(255), - message_id VARCHAR(500), -- Message-ID para threading - - -- Relación (respuesta a mensaje) - parent_id UUID REFERENCES system.messages(id), - - -- Attachments - attachment_ids UUID[] DEFAULT '{}', - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP -); - --- Tabla: message_followers (Seguidores de registros) -CREATE TABLE system.message_followers ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Referencia polimórfica - model VARCHAR(100) NOT NULL, - record_id UUID NOT NULL, - - -- Seguidor - partner_id UUID REFERENCES core.partners(id), - user_id UUID REFERENCES auth.users(id), - - -- Configuración - email_notifications BOOLEAN DEFAULT TRUE, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT uq_message_followers UNIQUE (model, record_id, COALESCE(user_id, partner_id)), - CONSTRAINT chk_message_followers_user_or_partner CHECK ( - (user_id IS NOT NULL AND partner_id IS NULL) OR - (partner_id IS NOT NULL AND user_id IS NULL) - ) -); - --- Tabla: notifications (Notificaciones a usuarios) -CREATE TABLE system.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(255) NOT NULL, - message TEXT NOT NULL, - url VARCHAR(500), -- URL para acción (ej: /sales/orders/123) - - -- Referencia (opcional) - model VARCHAR(100), - record_id UUID, - - -- Estado - status system.notification_status NOT NULL DEFAULT 'pending', - read_at TIMESTAMP, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - sent_at TIMESTAMP -); - --- Tabla: activities (Actividades programadas) -CREATE TABLE system.activities ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - - -- Referencia polimórfica - model VARCHAR(100) NOT NULL, - record_id UUID NOT NULL, - - -- Actividad - activity_type system.activity_type NOT NULL, - summary VARCHAR(255) NOT NULL, - description TEXT, - - -- Asignación - assigned_to UUID REFERENCES auth.users(id), - assigned_by UUID REFERENCES auth.users(id), - - -- Fechas - due_date DATE NOT NULL, - due_time TIME, - - -- Estado - status system.activity_status NOT NULL DEFAULT 'planned', - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - completed_at TIMESTAMP, - completed_by UUID REFERENCES auth.users(id) -); - --- Tabla: message_templates (Plantillas de mensajes/emails) -CREATE TABLE system.message_templates ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - - name VARCHAR(255) NOT NULL, - model VARCHAR(100), -- Para qué modelo se usa - - -- Contenido - subject VARCHAR(255), - body_html TEXT, - body_text TEXT, - - -- Configuración email - email_from VARCHAR(255), - reply_to VARCHAR(255), - cc VARCHAR(255), - bcc VARCHAR(255), - - -- Control - active BOOLEAN DEFAULT TRUE, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMP, - updated_by UUID REFERENCES auth.users(id), - - CONSTRAINT uq_message_templates_name_tenant UNIQUE (tenant_id, name) -); - --- Tabla: email_queue (Cola de envío de emails) -CREATE TABLE system.email_queue ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID REFERENCES auth.tenants(id), - - -- Destinatarios - email_to VARCHAR(255) NOT NULL, - email_cc VARCHAR(500), - email_bcc VARCHAR(500), - - -- Contenido - subject VARCHAR(255) NOT NULL, - body_html TEXT, - body_text TEXT, - - -- Remitente - email_from VARCHAR(255) NOT NULL, - reply_to VARCHAR(255), - - -- Attachments - attachment_ids UUID[] DEFAULT '{}', - - -- Estado - status system.email_status NOT NULL DEFAULT 'queued', - attempts INTEGER DEFAULT 0, - max_attempts INTEGER DEFAULT 3, - error_message TEXT, - - -- Tracking - message_id VARCHAR(500), - opened_at TIMESTAMP, - clicked_at TIMESTAMP, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - scheduled_at TIMESTAMP, - sent_at TIMESTAMP, - failed_at TIMESTAMP -); - --- Tabla: logs (Logs del sistema) -CREATE TABLE system.logs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID REFERENCES auth.tenants(id), - - -- Nivel y fuente - level system.log_level NOT NULL, - logger VARCHAR(100), -- Módulo que genera el log - - -- Mensaje - message TEXT NOT NULL, - stack_trace TEXT, - - -- Contexto - user_id UUID REFERENCES auth.users(id), - ip_address INET, - user_agent TEXT, - request_id UUID, - - -- Referencia (opcional) - model VARCHAR(100), - record_id UUID, - - -- Metadata adicional - metadata JSONB DEFAULT '{}', - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - --- Tabla: reports (Definiciones de reportes) -CREATE TABLE system.reports ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - - name VARCHAR(255) NOT NULL, - code VARCHAR(50) NOT NULL, - description TEXT, - - -- Tipo - model VARCHAR(100), -- Para qué modelo es el reporte - report_type VARCHAR(50), -- 'standard', 'custom', 'dashboard' - - -- Query/Template - query_template TEXT, -- SQL template o JSON query - template_file VARCHAR(255), -- Path al archivo de plantilla - - -- Configuración - default_format system.report_format DEFAULT 'pdf', - is_public BOOLEAN DEFAULT FALSE, - - -- Control - active BOOLEAN DEFAULT TRUE, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMP, - updated_by UUID REFERENCES auth.users(id), - - CONSTRAINT uq_reports_code_tenant UNIQUE (tenant_id, code) -); - --- Tabla: report_executions (Ejecuciones de reportes) -CREATE TABLE system.report_executions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - - report_id UUID NOT NULL REFERENCES system.reports(id) ON DELETE CASCADE, - - -- Parámetros de ejecución - parameters JSONB DEFAULT '{}', - format system.report_format NOT NULL, - - -- Resultado - file_url VARCHAR(500), - file_size BIGINT, - error_message TEXT, - - -- Estado - status VARCHAR(20) DEFAULT 'pending', -- pending, running, completed, failed - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - started_at TIMESTAMP, - completed_at TIMESTAMP -); - --- Tabla: dashboards (Dashboards configurables) -CREATE TABLE system.dashboards ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - - name VARCHAR(255) NOT NULL, - description TEXT, - - -- Configuración - layout JSONB DEFAULT '{}', -- Grid layout configuration - is_default BOOLEAN DEFAULT FALSE, - - -- Visibilidad - user_id UUID REFERENCES auth.users(id), -- NULL = compartido - is_public BOOLEAN DEFAULT FALSE, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMP, - updated_by UUID REFERENCES auth.users(id), - - CONSTRAINT uq_dashboards_name_user UNIQUE (tenant_id, name, COALESCE(user_id, '00000000-0000-0000-0000-000000000000'::UUID)) -); - --- Tabla: dashboard_widgets (Widgets en dashboards) -CREATE TABLE system.dashboard_widgets ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - dashboard_id UUID NOT NULL REFERENCES system.dashboards(id) ON DELETE CASCADE, - - -- Tipo de widget - widget_type VARCHAR(50) NOT NULL, -- 'chart', 'kpi', 'table', 'calendar', etc. - title VARCHAR(255), - - -- Configuración - config JSONB NOT NULL DEFAULT '{}', -- Widget-specific configuration - position JSONB DEFAULT '{}', -- {x, y, w, h} para grid - - -- Data source - data_source VARCHAR(100), -- Model o query - query_params JSONB DEFAULT '{}', - - -- Refresh - refresh_interval INTEGER, -- Segundos - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP -); - --- ===================================================== --- INDICES --- ===================================================== - --- Messages -CREATE INDEX idx_messages_tenant_id ON system.messages(tenant_id); -CREATE INDEX idx_messages_model_record ON system.messages(model, record_id); -CREATE INDEX idx_messages_author_id ON system.messages(author_id); -CREATE INDEX idx_messages_parent_id ON system.messages(parent_id); -CREATE INDEX idx_messages_created_at ON system.messages(created_at DESC); - --- Message Followers -CREATE INDEX idx_message_followers_model_record ON system.message_followers(model, record_id); -CREATE INDEX idx_message_followers_user_id ON system.message_followers(user_id); -CREATE INDEX idx_message_followers_partner_id ON system.message_followers(partner_id); - --- Notifications -CREATE INDEX idx_notifications_tenant_id ON system.notifications(tenant_id); -CREATE INDEX idx_notifications_user_id ON system.notifications(user_id); -CREATE INDEX idx_notifications_status ON system.notifications(status); -CREATE INDEX idx_notifications_model_record ON system.notifications(model, record_id); -CREATE INDEX idx_notifications_created_at ON system.notifications(created_at DESC); - --- Activities -CREATE INDEX idx_activities_tenant_id ON system.activities(tenant_id); -CREATE INDEX idx_activities_model_record ON system.activities(model, record_id); -CREATE INDEX idx_activities_assigned_to ON system.activities(assigned_to); -CREATE INDEX idx_activities_due_date ON system.activities(due_date); -CREATE INDEX idx_activities_status ON system.activities(status); - --- Message Templates -CREATE INDEX idx_message_templates_tenant_id ON system.message_templates(tenant_id); -CREATE INDEX idx_message_templates_model ON system.message_templates(model); -CREATE INDEX idx_message_templates_active ON system.message_templates(active) WHERE active = TRUE; - --- Email Queue -CREATE INDEX idx_email_queue_status ON system.email_queue(status); -CREATE INDEX idx_email_queue_scheduled_at ON system.email_queue(scheduled_at); -CREATE INDEX idx_email_queue_created_at ON system.email_queue(created_at); - --- Logs -CREATE INDEX idx_logs_tenant_id ON system.logs(tenant_id); -CREATE INDEX idx_logs_level ON system.logs(level); -CREATE INDEX idx_logs_logger ON system.logs(logger); -CREATE INDEX idx_logs_user_id ON system.logs(user_id); -CREATE INDEX idx_logs_created_at ON system.logs(created_at DESC); -CREATE INDEX idx_logs_model_record ON system.logs(model, record_id); - --- Reports -CREATE INDEX idx_reports_tenant_id ON system.reports(tenant_id); -CREATE INDEX idx_reports_code ON system.reports(code); -CREATE INDEX idx_reports_active ON system.reports(active) WHERE active = TRUE; - --- Report Executions -CREATE INDEX idx_report_executions_tenant_id ON system.report_executions(tenant_id); -CREATE INDEX idx_report_executions_report_id ON system.report_executions(report_id); -CREATE INDEX idx_report_executions_created_by ON system.report_executions(created_by); -CREATE INDEX idx_report_executions_created_at ON system.report_executions(created_at DESC); - --- Dashboards -CREATE INDEX idx_dashboards_tenant_id ON system.dashboards(tenant_id); -CREATE INDEX idx_dashboards_user_id ON system.dashboards(user_id); -CREATE INDEX idx_dashboards_is_public ON system.dashboards(is_public) WHERE is_public = TRUE; - --- Dashboard Widgets -CREATE INDEX idx_dashboard_widgets_dashboard_id ON system.dashboard_widgets(dashboard_id); -CREATE INDEX idx_dashboard_widgets_type ON system.dashboard_widgets(widget_type); - --- ===================================================== --- FUNCTIONS --- ===================================================== - --- Función: notify_followers -CREATE OR REPLACE FUNCTION system.notify_followers( - p_model VARCHAR, - p_record_id UUID, - p_message_id UUID -) -RETURNS VOID AS $$ -BEGIN - INSERT INTO system.notifications (tenant_id, user_id, title, message, model, record_id) - SELECT - get_current_tenant_id(), - mf.user_id, - 'New message in ' || p_model, - m.body, - p_model, - p_record_id - FROM system.message_followers mf - JOIN system.messages m ON m.id = p_message_id - WHERE mf.model = p_model - AND mf.record_id = p_record_id - AND mf.user_id IS NOT NULL - AND mf.email_notifications = TRUE; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION system.notify_followers IS 'Notifica a los seguidores de un registro cuando hay un nuevo mensaje'; - --- Función: mark_activity_as_overdue -CREATE OR REPLACE FUNCTION system.mark_activities_as_overdue() -RETURNS INTEGER AS $$ -DECLARE - v_updated_count INTEGER; -BEGIN - WITH updated AS ( - UPDATE system.activities - SET status = 'overdue' - WHERE status = 'planned' - AND due_date < CURRENT_DATE - RETURNING id - ) - SELECT COUNT(*) INTO v_updated_count FROM updated; - - RETURN v_updated_count; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION system.mark_activities_as_overdue IS 'Marca actividades vencidas como overdue (ejecutar diariamente)'; - --- Función: clean_old_logs -CREATE OR REPLACE FUNCTION system.clean_old_logs(p_days_to_keep INTEGER DEFAULT 90) -RETURNS INTEGER AS $$ -DECLARE - v_deleted_count INTEGER; -BEGIN - WITH deleted AS ( - DELETE FROM system.logs - WHERE created_at < CURRENT_TIMESTAMP - (p_days_to_keep || ' days')::INTERVAL - AND level != 'critical' - RETURNING id - ) - SELECT COUNT(*) INTO v_deleted_count FROM deleted; - - RETURN v_deleted_count; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION system.clean_old_logs IS 'Limpia logs antiguos (mantener solo críticos)'; - --- ===================================================== --- TRIGGERS --- ===================================================== - -CREATE TRIGGER trg_messages_updated_at - BEFORE UPDATE ON system.messages - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at_column(); - -CREATE TRIGGER trg_message_templates_updated_at - BEFORE UPDATE ON system.message_templates - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at_column(); - -CREATE TRIGGER trg_reports_updated_at - BEFORE UPDATE ON system.reports - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at_column(); - -CREATE TRIGGER trg_dashboards_updated_at - BEFORE UPDATE ON system.dashboards - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at_column(); - -CREATE TRIGGER trg_dashboard_widgets_updated_at - BEFORE UPDATE ON system.dashboard_widgets - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at_column(); - --- ===================================================== --- ROW LEVEL SECURITY (RLS) --- ===================================================== - -ALTER TABLE system.messages ENABLE ROW LEVEL SECURITY; -ALTER TABLE system.notifications ENABLE ROW LEVEL SECURITY; -ALTER TABLE system.activities ENABLE ROW LEVEL SECURITY; -ALTER TABLE system.message_templates ENABLE ROW LEVEL SECURITY; -ALTER TABLE system.logs ENABLE ROW LEVEL SECURITY; -ALTER TABLE system.reports ENABLE ROW LEVEL SECURITY; -ALTER TABLE system.report_executions ENABLE ROW LEVEL SECURITY; -ALTER TABLE system.dashboards ENABLE ROW LEVEL SECURITY; - -CREATE POLICY tenant_isolation_messages ON system.messages - USING (tenant_id = get_current_tenant_id()); - -CREATE POLICY tenant_isolation_notifications ON system.notifications - USING (tenant_id = get_current_tenant_id()); - -CREATE POLICY tenant_isolation_activities ON system.activities - USING (tenant_id = get_current_tenant_id()); - -CREATE POLICY tenant_isolation_message_templates ON system.message_templates - USING (tenant_id = get_current_tenant_id()); - -CREATE POLICY tenant_isolation_logs ON system.logs - USING (tenant_id = get_current_tenant_id() OR tenant_id IS NULL); - -CREATE POLICY tenant_isolation_reports ON system.reports - USING (tenant_id = get_current_tenant_id()); - -CREATE POLICY tenant_isolation_report_executions ON system.report_executions - USING (tenant_id = get_current_tenant_id()); - -CREATE POLICY tenant_isolation_dashboards ON system.dashboards - USING (tenant_id = get_current_tenant_id()); - --- ===================================================== --- TRACKING AUTOMÁTICO (mail.thread pattern de Odoo) --- ===================================================== - --- Tabla: field_tracking_config (Configuración de campos a trackear) -CREATE TABLE system.field_tracking_config ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - table_schema VARCHAR(50) NOT NULL, - table_name VARCHAR(100) NOT NULL, - field_name VARCHAR(100) NOT NULL, - track_changes BOOLEAN NOT NULL DEFAULT true, - field_type VARCHAR(50) NOT NULL, -- 'text', 'integer', 'numeric', 'boolean', 'uuid', 'timestamp', 'json' - display_label VARCHAR(255) NOT NULL, -- Para mostrar en UI: "Estado", "Monto", "Cliente", etc. - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT uq_field_tracking UNIQUE (table_schema, table_name, field_name) -); - --- Índice para búsqueda rápida -CREATE INDEX idx_field_tracking_config_table -ON system.field_tracking_config(table_schema, table_name); - --- Tabla: change_log (Historial de cambios en registros) -CREATE TABLE system.change_log ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id), - - -- Referencia al registro modificado - table_schema VARCHAR(50) NOT NULL, - table_name VARCHAR(100) NOT NULL, - record_id UUID NOT NULL, - - -- Usuario que hizo el cambio - changed_by UUID NOT NULL REFERENCES auth.users(id), - changed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - - -- Tipo de cambio - change_type VARCHAR(20) NOT NULL CHECK (change_type IN ('create', 'update', 'delete', 'state_change')), - - -- Campo modificado (NULL para create/delete) - field_name VARCHAR(100), - field_label VARCHAR(255), -- Para UI: "Estado", "Monto Total", etc. - - -- Valores anterior y nuevo - old_value TEXT, - new_value TEXT, - - -- Metadata adicional - change_context JSONB, -- Info adicional: IP, user agent, módulo, etc. - - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - --- Índices para performance del change_log -CREATE INDEX idx_change_log_tenant_id ON system.change_log(tenant_id); -CREATE INDEX idx_change_log_record ON system.change_log(table_schema, table_name, record_id); -CREATE INDEX idx_change_log_changed_by ON system.change_log(changed_by); -CREATE INDEX idx_change_log_changed_at ON system.change_log(changed_at DESC); -CREATE INDEX idx_change_log_type ON system.change_log(change_type); - --- Índice compuesto para queries comunes -CREATE INDEX idx_change_log_record_date -ON system.change_log(table_schema, table_name, record_id, changed_at DESC); - --- RLS Policy para multi-tenancy -ALTER TABLE system.change_log ENABLE ROW LEVEL SECURITY; - -CREATE POLICY tenant_isolation_change_log ON system.change_log - USING (tenant_id = get_current_tenant_id()); - --- ===================================================== --- FUNCIÓN DE TRACKING AUTOMÁTICO --- ===================================================== - --- Función: track_field_changes --- Función genérica para trackear cambios automáticamente -CREATE OR REPLACE FUNCTION system.track_field_changes() -RETURNS TRIGGER AS $$ -DECLARE - v_tenant_id UUID; - v_user_id UUID; - v_field_name TEXT; - v_field_label TEXT; - v_old_value TEXT; - v_new_value TEXT; - v_field_config RECORD; -BEGIN - -- Obtener tenant_id y user_id del registro - IF TG_OP = 'DELETE' THEN - v_tenant_id := OLD.tenant_id; - v_user_id := OLD.deleted_by; - ELSE - v_tenant_id := NEW.tenant_id; - v_user_id := NEW.updated_by; - END IF; - - -- Registrar creación - IF TG_OP = 'INSERT' THEN - INSERT INTO system.change_log ( - tenant_id, table_schema, table_name, record_id, - changed_by, change_type, change_context - ) VALUES ( - v_tenant_id, TG_TABLE_SCHEMA, TG_TABLE_NAME, NEW.id, - NEW.created_by, 'create', - jsonb_build_object('operation', 'INSERT') - ); - RETURN NEW; - END IF; - - -- Registrar eliminación (soft delete) - IF TG_OP = 'UPDATE' AND OLD.deleted_at IS NULL AND NEW.deleted_at IS NOT NULL THEN - INSERT INTO system.change_log ( - tenant_id, table_schema, table_name, record_id, - changed_by, change_type, change_context - ) VALUES ( - v_tenant_id, TG_TABLE_SCHEMA, TG_TABLE_NAME, NEW.id, - NEW.deleted_by, 'delete', - jsonb_build_object('operation', 'SOFT_DELETE', 'deleted_at', NEW.deleted_at) - ); - RETURN NEW; - END IF; - - -- Registrar cambios en campos configurados - IF TG_OP = 'UPDATE' THEN - -- Iterar sobre campos configurados para esta tabla - FOR v_field_config IN - SELECT field_name, display_label, field_type - FROM system.field_tracking_config - WHERE table_schema = TG_TABLE_SCHEMA - AND table_name = TG_TABLE_NAME - AND track_changes = true - LOOP - v_field_name := v_field_config.field_name; - v_field_label := v_field_config.display_label; - - -- Obtener valores antiguo y nuevo (usar EXECUTE para campos dinámicos) - EXECUTE format('SELECT ($1).%I::TEXT, ($2).%I::TEXT', v_field_name, v_field_name) - INTO v_old_value, v_new_value - USING OLD, NEW; - - -- Si el valor cambió, registrarlo - IF v_old_value IS DISTINCT FROM v_new_value THEN - INSERT INTO system.change_log ( - tenant_id, table_schema, table_name, record_id, - changed_by, change_type, field_name, field_label, - old_value, new_value, change_context - ) VALUES ( - v_tenant_id, TG_TABLE_SCHEMA, TG_TABLE_NAME, NEW.id, - v_user_id, - CASE - WHEN v_field_name = 'status' OR v_field_name = 'state' THEN 'state_change' - ELSE 'update' - END, - v_field_name, v_field_label, - v_old_value, v_new_value, - jsonb_build_object('operation', 'UPDATE', 'field_type', v_field_config.field_type) - ); - END IF; - END LOOP; - END IF; - - RETURN NEW; -END; -$$ LANGUAGE plpgsql SECURITY DEFINER; - -COMMENT ON FUNCTION system.track_field_changes IS -'Función trigger para trackear cambios automáticamente según configuración en field_tracking_config (patrón mail.thread de Odoo)'; - --- ===================================================== --- SEED DATA: Configuración de campos a trackear --- ===================================================== - --- FINANCIAL: Facturas -INSERT INTO system.field_tracking_config (table_schema, table_name, field_name, field_type, display_label) VALUES -('financial', 'invoices', 'status', 'text', 'Estado'), -('financial', 'invoices', 'partner_id', 'uuid', 'Cliente/Proveedor'), -('financial', 'invoices', 'invoice_date', 'timestamp', 'Fecha de Factura'), -('financial', 'invoices', 'amount_total', 'numeric', 'Monto Total'), -('financial', 'invoices', 'payment_term_id', 'uuid', 'Término de Pago') -ON CONFLICT (table_schema, table_name, field_name) DO NOTHING; - --- FINANCIAL: Asientos contables -INSERT INTO system.field_tracking_config (table_schema, table_name, field_name, field_type, display_label) VALUES -('financial', 'journal_entries', 'status', 'text', 'Estado'), -('financial', 'journal_entries', 'date', 'timestamp', 'Fecha del Asiento'), -('financial', 'journal_entries', 'journal_id', 'uuid', 'Diario Contable') -ON CONFLICT (table_schema, table_name, field_name) DO NOTHING; - --- PURCHASE: Órdenes de compra -INSERT INTO system.field_tracking_config (table_schema, table_name, field_name, field_type, display_label) VALUES -('purchase', 'purchase_orders', 'status', 'text', 'Estado'), -('purchase', 'purchase_orders', 'partner_id', 'uuid', 'Proveedor'), -('purchase', 'purchase_orders', 'order_date', 'timestamp', 'Fecha de Orden'), -('purchase', 'purchase_orders', 'amount_total', 'numeric', 'Monto Total'), -('purchase', 'purchase_orders', 'receipt_status', 'text', 'Estado de Recepción') -ON CONFLICT (table_schema, table_name, field_name) DO NOTHING; - --- SALES: Órdenes de venta -INSERT INTO system.field_tracking_config (table_schema, table_name, field_name, field_type, display_label) VALUES -('sales', 'sales_orders', 'status', 'text', 'Estado'), -('sales', 'sales_orders', 'partner_id', 'uuid', 'Cliente'), -('sales', 'sales_orders', 'order_date', 'timestamp', 'Fecha de Orden'), -('sales', 'sales_orders', 'amount_total', 'numeric', 'Monto Total'), -('sales', 'sales_orders', 'invoice_status', 'text', 'Estado de Facturación'), -('sales', 'sales_orders', 'delivery_status', 'text', 'Estado de Entrega') -ON CONFLICT (table_schema, table_name, field_name) DO NOTHING; - --- INVENTORY: Movimientos de stock -INSERT INTO system.field_tracking_config (table_schema, table_name, field_name, field_type, display_label) VALUES -('inventory', 'stock_moves', 'status', 'text', 'Estado'), -('inventory', 'stock_moves', 'product_id', 'uuid', 'Producto'), -('inventory', 'stock_moves', 'product_qty', 'numeric', 'Cantidad'), -('inventory', 'stock_moves', 'location_id', 'uuid', 'Ubicación Origen'), -('inventory', 'stock_moves', 'location_dest_id', 'uuid', 'Ubicación Destino') -ON CONFLICT (table_schema, table_name, field_name) DO NOTHING; - --- PROJECTS: Proyectos -INSERT INTO system.field_tracking_config (table_schema, table_name, field_name, field_type, display_label) VALUES -('projects', 'projects', 'status', 'text', 'Estado'), -('projects', 'projects', 'name', 'text', 'Nombre del Proyecto'), -('projects', 'projects', 'manager_id', 'uuid', 'Responsable'), -('projects', 'projects', 'date_start', 'timestamp', 'Fecha de Inicio'), -('projects', 'projects', 'date_end', 'timestamp', 'Fecha de Fin') -ON CONFLICT (table_schema, table_name, field_name) DO NOTHING; - --- ===================================================== --- COMENTARIOS --- ===================================================== - -COMMENT ON SCHEMA system IS 'Schema de mensajería, notificaciones, logs, reportes y tracking automático'; -COMMENT ON TABLE system.messages IS 'Mensajes del chatter (comentarios, notas, emails)'; -COMMENT ON TABLE system.message_followers IS 'Seguidores de registros para notificaciones'; -COMMENT ON TABLE system.notifications IS 'Notificaciones a usuarios'; -COMMENT ON TABLE system.activities IS 'Actividades programadas (llamadas, reuniones, tareas)'; -COMMENT ON TABLE system.message_templates IS 'Plantillas de mensajes y emails'; -COMMENT ON TABLE system.email_queue IS 'Cola de envío de emails'; -COMMENT ON TABLE system.logs IS 'Logs del sistema y auditoría'; -COMMENT ON TABLE system.reports IS 'Definiciones de reportes'; -COMMENT ON TABLE system.report_executions IS 'Ejecuciones de reportes con resultados'; -COMMENT ON TABLE system.dashboards IS 'Dashboards configurables por usuario'; -COMMENT ON TABLE system.dashboard_widgets IS 'Widgets dentro de dashboards'; -COMMENT ON TABLE system.field_tracking_config IS 'Configuración de campos a trackear automáticamente por tabla (patrón mail.thread de Odoo)'; -COMMENT ON TABLE system.change_log IS 'Historial de cambios en registros (mail.thread pattern de Odoo). Registra automáticamente cambios de estado y campos críticos.'; - --- ===================================================== --- FIN DEL SCHEMA SYSTEM --- ===================================================== diff --git a/ddl/10-audit.sql b/ddl/10-audit.sql new file mode 100644 index 0000000..31cd92a --- /dev/null +++ b/ddl/10-audit.sql @@ -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'; diff --git a/ddl/10-billing.sql b/ddl/10-billing.sql deleted file mode 100644 index e816d02..0000000 --- a/ddl/10-billing.sql +++ /dev/null @@ -1,638 +0,0 @@ --- ===================================================== --- SCHEMA: billing --- PROPÓSITO: Suscripciones SaaS, planes, pagos, facturación --- MÓDULOS: MGN-015 (Billing y Suscripciones) --- FECHA: 2025-11-24 --- ===================================================== --- NOTA: Este schema permite que el sistema opere como SaaS multi-tenant --- o como instalación single-tenant (on-premise). En modo single-tenant, --- las tablas de este schema pueden ignorarse o tener un único plan "unlimited". --- ===================================================== - --- Crear schema -CREATE SCHEMA IF NOT EXISTS billing; - --- ===================================================== --- TYPES (ENUMs) --- ===================================================== - -CREATE TYPE billing.subscription_status AS ENUM ( - 'trialing', -- En período de prueba - 'active', -- Suscripción activa - 'past_due', -- Pago atrasado - 'paused', -- Suscripción pausada - 'cancelled', -- Cancelada por usuario - 'suspended', -- Suspendida por falta de pago - 'expired' -- Expirada -); - -CREATE TYPE billing.billing_cycle AS ENUM ( - 'monthly', - 'quarterly', - 'semi_annual', - 'annual' -); - -CREATE TYPE billing.payment_method_type AS ENUM ( - 'card', - 'bank_transfer', - 'paypal', - 'oxxo', -- México - 'spei', -- México - 'other' -); - -CREATE TYPE billing.invoice_status AS ENUM ( - 'draft', - 'open', - 'paid', - 'void', - 'uncollectible' -); - -CREATE TYPE billing.payment_status AS ENUM ( - 'pending', - 'processing', - 'succeeded', - 'failed', - 'cancelled', - 'refunded' -); - --- ===================================================== --- TABLES --- ===================================================== - --- Tabla: subscription_plans (Planes disponibles - global, no por tenant) --- Esta tabla no tiene tenant_id porque los planes son globales del sistema SaaS -CREATE TABLE billing.subscription_plans ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - code VARCHAR(50) UNIQUE NOT NULL, - name VARCHAR(100) NOT NULL, - description TEXT, - - -- Precios - price_monthly DECIMAL(12,2) NOT NULL DEFAULT 0, - price_yearly DECIMAL(12,2) NOT NULL DEFAULT 0, - currency_code VARCHAR(3) NOT NULL DEFAULT 'MXN', - - -- Límites - max_users INTEGER DEFAULT 10, - max_companies INTEGER DEFAULT 1, - max_storage_gb INTEGER DEFAULT 5, - max_api_calls_month INTEGER DEFAULT 10000, - - -- Características incluidas (JSON para flexibilidad) - features JSONB DEFAULT '{}'::jsonb, - -- Ejemplo: {"inventory": true, "sales": true, "financial": true, "purchase": true, "crm": false} - - -- Metadata - is_active BOOLEAN NOT NULL DEFAULT true, - is_public BOOLEAN NOT NULL DEFAULT true, -- Visible en página de precios - is_default BOOLEAN NOT NULL DEFAULT false, -- Plan por defecto para nuevos tenants - trial_days INTEGER DEFAULT 14, - sort_order INTEGER DEFAULT 0, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID, - updated_at TIMESTAMP, - updated_by UUID, - - CONSTRAINT chk_plans_price_monthly CHECK (price_monthly >= 0), - CONSTRAINT chk_plans_price_yearly CHECK (price_yearly >= 0), - CONSTRAINT chk_plans_max_users CHECK (max_users > 0 OR max_users IS NULL), - CONSTRAINT chk_plans_trial_days CHECK (trial_days >= 0) -); - --- Tabla: tenant_owners (Propietarios/Contratantes de tenant) --- Usuario(s) que contratan y pagan por el tenant -CREATE TABLE billing.tenant_owners ( - 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, - ownership_type VARCHAR(20) NOT NULL DEFAULT 'owner', - -- owner: Propietario principal (puede haber solo 1) - -- billing_admin: Puede gestionar facturación - - -- Contacto de facturación (puede diferir del usuario) - billing_email VARCHAR(255), - billing_phone VARCHAR(50), - billing_name VARCHAR(255), - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID, - - CONSTRAINT uq_tenant_owners UNIQUE (tenant_id, user_id), - CONSTRAINT chk_ownership_type CHECK (ownership_type IN ('owner', 'billing_admin')) -); - --- Tabla: subscriptions (Suscripciones activas de cada tenant) -CREATE TABLE billing.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), - - -- Estado - status billing.subscription_status NOT NULL DEFAULT 'trialing', - billing_cycle billing.billing_cycle NOT NULL DEFAULT 'monthly', - - -- Fechas importantes - trial_start_at TIMESTAMP, - trial_end_at TIMESTAMP, - current_period_start TIMESTAMP NOT NULL, - current_period_end TIMESTAMP NOT NULL, - cancelled_at TIMESTAMP, - cancel_at_period_end BOOLEAN NOT NULL DEFAULT false, - paused_at TIMESTAMP, - - -- Descuentos/Cupones - discount_percent DECIMAL(5,2) DEFAULT 0, - coupon_code VARCHAR(50), - - -- Integración pasarela de pago - stripe_subscription_id VARCHAR(255), - stripe_customer_id VARCHAR(255), - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID, - updated_at TIMESTAMP, - updated_by UUID, - - CONSTRAINT uq_subscriptions_tenant UNIQUE (tenant_id), -- Solo 1 suscripción activa por tenant - CONSTRAINT chk_subscriptions_discount CHECK (discount_percent >= 0 AND discount_percent <= 100), - CONSTRAINT chk_subscriptions_period CHECK (current_period_end > current_period_start) -); - --- Tabla: payment_methods (Métodos de pago por tenant) -CREATE TABLE billing.payment_methods ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - - type billing.payment_method_type NOT NULL, - is_default BOOLEAN NOT NULL DEFAULT false, - - -- Información de tarjeta (solo últimos 4 dígitos por seguridad) - card_last_four VARCHAR(4), - card_brand VARCHAR(20), -- visa, mastercard, amex - card_exp_month INTEGER, - card_exp_year INTEGER, - - -- Dirección de facturación - billing_name VARCHAR(255), - billing_email VARCHAR(255), - billing_address_line1 VARCHAR(255), - billing_address_line2 VARCHAR(255), - billing_city VARCHAR(100), - billing_state VARCHAR(100), - billing_postal_code VARCHAR(20), - billing_country VARCHAR(2), -- ISO 3166-1 alpha-2 - - -- Integración pasarela - stripe_payment_method_id VARCHAR(255), - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID, - updated_at TIMESTAMP, - deleted_at TIMESTAMP, -- Soft delete - - CONSTRAINT chk_payment_methods_card_exp CHECK ( - (type != 'card') OR - (card_exp_month BETWEEN 1 AND 12 AND card_exp_year >= EXTRACT(YEAR FROM CURRENT_DATE)) - ) -); - --- Tabla: billing_invoices (Facturas de suscripción) -CREATE TABLE 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.subscriptions(id), - - -- Número de factura - invoice_number VARCHAR(50) NOT NULL, - - -- Estado y fechas - status billing.invoice_status NOT NULL DEFAULT 'draft', - period_start TIMESTAMP, - period_end TIMESTAMP, - due_date DATE NOT NULL, - paid_at TIMESTAMP, - voided_at TIMESTAMP, - - -- Montos - subtotal DECIMAL(12,2) NOT NULL DEFAULT 0, - tax_amount DECIMAL(12,2) NOT NULL DEFAULT 0, - discount_amount DECIMAL(12,2) NOT NULL DEFAULT 0, - total DECIMAL(12,2) NOT NULL DEFAULT 0, - amount_paid DECIMAL(12,2) NOT NULL DEFAULT 0, - amount_due DECIMAL(12,2) NOT NULL DEFAULT 0, - currency_code VARCHAR(3) NOT NULL DEFAULT 'MXN', - - -- Datos fiscales del cliente - customer_name VARCHAR(255), - customer_tax_id VARCHAR(50), - customer_email VARCHAR(255), - customer_address TEXT, - - -- PDF y CFDI (México) - pdf_url VARCHAR(500), - cfdi_uuid VARCHAR(36), -- UUID del CFDI si aplica - cfdi_xml_url VARCHAR(500), - - -- Integración pasarela - stripe_invoice_id VARCHAR(255), - - -- Notas - notes TEXT, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID, - updated_at TIMESTAMP, - - CONSTRAINT uq_invoices_number UNIQUE (invoice_number), - CONSTRAINT chk_invoices_amounts CHECK (total >= 0 AND subtotal >= 0 AND amount_due >= 0) -); - --- Tabla: invoice_lines (Líneas de detalle de factura) -CREATE TABLE billing.invoice_lines ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - invoice_id UUID NOT NULL REFERENCES billing.invoices(id) ON DELETE CASCADE, - - description VARCHAR(255) NOT NULL, - quantity DECIMAL(12,4) NOT NULL DEFAULT 1, - unit_price DECIMAL(12,2) NOT NULL, - amount DECIMAL(12,2) NOT NULL, - - -- Para facturación por uso - period_start TIMESTAMP, - period_end TIMESTAMP, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT chk_invoice_lines_qty CHECK (quantity > 0), - CONSTRAINT chk_invoice_lines_price CHECK (unit_price >= 0) -); - --- Tabla: payments (Pagos recibidos) -CREATE TABLE billing.payments ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - invoice_id UUID REFERENCES billing.invoices(id), - payment_method_id UUID REFERENCES billing.payment_methods(id), - - -- Monto y moneda - amount DECIMAL(12,2) NOT NULL, - currency_code VARCHAR(3) NOT NULL DEFAULT 'MXN', - - -- Estado - status billing.payment_status NOT NULL DEFAULT 'pending', - - -- Fechas - paid_at TIMESTAMP, - failed_at TIMESTAMP, - refunded_at TIMESTAMP, - - -- Detalles del error (si falló) - failure_reason VARCHAR(255), - failure_code VARCHAR(50), - - -- Referencia de transacción - transaction_id VARCHAR(255), - stripe_payment_intent_id VARCHAR(255), - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT chk_payments_amount CHECK (amount > 0) -); - --- Tabla: usage_records (Registros de uso para billing por consumo) -CREATE TABLE billing.usage_records ( - 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.subscriptions(id), - - -- Tipo de métrica - metric_type VARCHAR(50) NOT NULL, - -- Ejemplos: 'users', 'storage_gb', 'api_calls', 'invoices_sent', 'emails_sent' - - quantity DECIMAL(12,4) NOT NULL, - billing_period DATE NOT NULL, -- Mes de facturación (YYYY-MM-01) - - -- Auditoría - recorded_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT chk_usage_quantity CHECK (quantity >= 0) -); - --- Tabla: coupons (Cupones de descuento) -CREATE TABLE billing.coupons ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - code VARCHAR(50) UNIQUE NOT NULL, - name VARCHAR(100) NOT NULL, - description TEXT, - - -- Tipo de descuento - discount_type VARCHAR(20) NOT NULL DEFAULT 'percent', - -- 'percent': Porcentaje de descuento - -- 'fixed': Monto fijo de descuento - - discount_value DECIMAL(12,2) NOT NULL, - currency_code VARCHAR(3) DEFAULT 'MXN', -- Solo para tipo 'fixed' - - -- Restricciones - max_redemptions INTEGER, -- Máximo de usos totales - max_redemptions_per_tenant INTEGER DEFAULT 1, -- Máximo por tenant - redemptions_count INTEGER NOT NULL DEFAULT 0, - - -- Vigencia - valid_from TIMESTAMP, - valid_until TIMESTAMP, - - -- Aplicable a - applicable_plans UUID[], -- Array de plan_ids, NULL = todos - - -- Estado - is_active BOOLEAN NOT NULL DEFAULT true, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID, - - CONSTRAINT chk_coupons_discount CHECK ( - (discount_type = 'percent' AND discount_value > 0 AND discount_value <= 100) OR - (discount_type = 'fixed' AND discount_value > 0) - ), - CONSTRAINT chk_coupons_dates CHECK (valid_until IS NULL OR valid_until > valid_from) -); - --- Tabla: coupon_redemptions (Uso de cupones) -CREATE TABLE 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) ON DELETE CASCADE, - subscription_id UUID REFERENCES billing.subscriptions(id), - - -- Auditoría - redeemed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - redeemed_by UUID, - - CONSTRAINT uq_coupon_redemptions UNIQUE (coupon_id, tenant_id) -); - --- Tabla: subscription_history (Historial de cambios de suscripción) -CREATE TABLE billing.subscription_history ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - subscription_id UUID NOT NULL REFERENCES billing.subscriptions(id) ON DELETE CASCADE, - - event_type VARCHAR(50) NOT NULL, - -- 'created', 'upgraded', 'downgraded', 'renewed', 'cancelled', - -- 'paused', 'resumed', 'payment_failed', 'payment_succeeded' - - previous_plan_id UUID REFERENCES billing.subscription_plans(id), - new_plan_id UUID REFERENCES billing.subscription_plans(id), - previous_status billing.subscription_status, - new_status billing.subscription_status, - - -- Metadata adicional - metadata JSONB DEFAULT '{}'::jsonb, - notes TEXT, - - -- Auditoría - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - created_by UUID -); - --- ===================================================== --- ÍNDICES --- ===================================================== - --- subscription_plans -CREATE INDEX idx_plans_is_active ON billing.subscription_plans(is_active) WHERE is_active = true; -CREATE INDEX idx_plans_is_public ON billing.subscription_plans(is_public) WHERE is_public = true; - --- tenant_owners -CREATE INDEX idx_tenant_owners_tenant_id ON billing.tenant_owners(tenant_id); -CREATE INDEX idx_tenant_owners_user_id ON billing.tenant_owners(user_id); - --- subscriptions -CREATE INDEX idx_subscriptions_tenant_id ON billing.subscriptions(tenant_id); -CREATE INDEX idx_subscriptions_status ON billing.subscriptions(status); -CREATE INDEX idx_subscriptions_period_end ON billing.subscriptions(current_period_end); - --- payment_methods -CREATE INDEX idx_payment_methods_tenant_id ON billing.payment_methods(tenant_id); -CREATE INDEX idx_payment_methods_default ON billing.payment_methods(tenant_id, is_default) WHERE is_default = true; - --- invoices -CREATE INDEX idx_invoices_tenant_id ON billing.invoices(tenant_id); -CREATE INDEX idx_invoices_status ON billing.invoices(status); -CREATE INDEX idx_invoices_due_date ON billing.invoices(due_date); -CREATE INDEX idx_invoices_stripe_id ON billing.invoices(stripe_invoice_id); - --- payments -CREATE INDEX idx_payments_tenant_id ON billing.payments(tenant_id); -CREATE INDEX idx_payments_status ON billing.payments(status); -CREATE INDEX idx_payments_invoice_id ON billing.payments(invoice_id); - --- usage_records -CREATE INDEX idx_usage_records_tenant_id ON billing.usage_records(tenant_id); -CREATE INDEX idx_usage_records_period ON billing.usage_records(billing_period); -CREATE INDEX idx_usage_records_metric ON billing.usage_records(metric_type, billing_period); - --- coupons -CREATE INDEX idx_coupons_code ON billing.coupons(code); -CREATE INDEX idx_coupons_active ON billing.coupons(is_active) WHERE is_active = true; - --- subscription_history -CREATE INDEX idx_subscription_history_subscription ON billing.subscription_history(subscription_id); -CREATE INDEX idx_subscription_history_created ON billing.subscription_history(created_at); - --- ===================================================== --- TRIGGERS --- ===================================================== - --- Trigger updated_at para subscriptions -CREATE TRIGGER trg_subscriptions_updated_at - BEFORE UPDATE ON billing.subscriptions - FOR EACH ROW EXECUTE FUNCTION auth.update_updated_at_column(); - --- Trigger updated_at para payment_methods -CREATE TRIGGER trg_payment_methods_updated_at - BEFORE UPDATE ON billing.payment_methods - FOR EACH ROW EXECUTE FUNCTION auth.update_updated_at_column(); - --- Trigger updated_at para invoices -CREATE TRIGGER trg_invoices_updated_at - BEFORE UPDATE ON billing.invoices - FOR EACH ROW EXECUTE FUNCTION auth.update_updated_at_column(); - --- Trigger updated_at para subscription_plans -CREATE TRIGGER trg_plans_updated_at - BEFORE UPDATE ON billing.subscription_plans - FOR EACH ROW EXECUTE FUNCTION auth.update_updated_at_column(); - --- ===================================================== --- FUNCIONES --- ===================================================== - --- Función para obtener el plan actual de un tenant -CREATE OR REPLACE FUNCTION billing.get_tenant_plan(p_tenant_id UUID) -RETURNS TABLE( - plan_code VARCHAR, - plan_name VARCHAR, - max_users INTEGER, - max_companies INTEGER, - features JSONB, - subscription_status billing.subscription_status, - days_until_renewal INTEGER -) AS $$ -BEGIN - RETURN QUERY - SELECT - sp.code, - sp.name, - sp.max_users, - sp.max_companies, - sp.features, - s.status, - EXTRACT(DAY FROM s.current_period_end - CURRENT_TIMESTAMP)::INTEGER - FROM billing.subscriptions s - JOIN billing.subscription_plans sp ON s.plan_id = sp.id - WHERE s.tenant_id = p_tenant_id; -END; -$$ LANGUAGE plpgsql; - --- Función para verificar si tenant puede agregar más usuarios -CREATE OR REPLACE FUNCTION billing.can_add_user(p_tenant_id UUID) -RETURNS BOOLEAN AS $$ -DECLARE - v_max_users INTEGER; - v_current_users INTEGER; -BEGIN - -- Obtener límite del plan - SELECT sp.max_users INTO v_max_users - FROM billing.subscriptions s - JOIN billing.subscription_plans sp ON s.plan_id = sp.id - WHERE s.tenant_id = p_tenant_id AND s.status IN ('active', 'trialing'); - - -- Si no hay límite (NULL), permitir - IF v_max_users IS NULL THEN - RETURN true; - END IF; - - -- Contar usuarios actuales - SELECT COUNT(*) INTO v_current_users - FROM auth.users - WHERE tenant_id = p_tenant_id AND deleted_at IS NULL; - - RETURN v_current_users < v_max_users; -END; -$$ LANGUAGE plpgsql; - --- Función para verificar si una feature está habilitada para el tenant -CREATE OR REPLACE FUNCTION billing.has_feature(p_tenant_id UUID, p_feature VARCHAR) -RETURNS BOOLEAN AS $$ -DECLARE - v_features JSONB; -BEGIN - SELECT sp.features INTO v_features - FROM billing.subscriptions s - JOIN billing.subscription_plans sp ON s.plan_id = sp.id - WHERE s.tenant_id = p_tenant_id AND s.status IN ('active', 'trialing'); - - -- Si no hay plan o features, denegar - IF v_features IS NULL THEN - RETURN false; - END IF; - - -- Verificar feature - RETURN COALESCE((v_features ->> p_feature)::boolean, false); -END; -$$ LANGUAGE plpgsql; - --- ===================================================== --- DATOS INICIALES (Plans por defecto) --- ===================================================== - --- Plan Free/Trial -INSERT INTO billing.subscription_plans (code, name, description, price_monthly, price_yearly, max_users, max_companies, max_storage_gb, trial_days, is_default, sort_order, features) -VALUES ( - 'free', - 'Free / Trial', - 'Plan gratuito para probar el sistema', - 0, 0, - 3, 1, 1, 14, true, 1, - '{"inventory": true, "sales": true, "financial": false, "purchase": false, "crm": false, "projects": false, "reports_basic": true, "reports_advanced": false, "api_access": false}'::jsonb -); - --- Plan Básico -INSERT INTO billing.subscription_plans (code, name, description, price_monthly, price_yearly, max_users, max_companies, max_storage_gb, trial_days, sort_order, features) -VALUES ( - 'basic', - 'Básico', - 'Ideal para pequeños negocios', - 499, 4990, - 5, 1, 5, 14, 2, - '{"inventory": true, "sales": true, "financial": true, "purchase": true, "crm": false, "projects": false, "reports_basic": true, "reports_advanced": false, "api_access": false}'::jsonb -); - --- Plan Profesional -INSERT INTO billing.subscription_plans (code, name, description, price_monthly, price_yearly, max_users, max_companies, max_storage_gb, trial_days, sort_order, features) -VALUES ( - 'professional', - 'Profesional', - 'Para empresas en crecimiento', - 999, 9990, - 15, 3, 20, 14, 3, - '{"inventory": true, "sales": true, "financial": true, "purchase": true, "crm": true, "projects": true, "reports_basic": true, "reports_advanced": true, "api_access": true}'::jsonb -); - --- Plan Enterprise -INSERT INTO billing.subscription_plans (code, name, description, price_monthly, price_yearly, max_users, max_companies, max_storage_gb, trial_days, sort_order, features) -VALUES ( - 'enterprise', - 'Enterprise', - 'Solución completa para grandes empresas', - 2499, 24990, - NULL, NULL, 100, 30, 4, -- NULL = ilimitado - '{"inventory": true, "sales": true, "financial": true, "purchase": true, "crm": true, "projects": true, "reports_basic": true, "reports_advanced": true, "api_access": true, "white_label": true, "priority_support": true, "custom_integrations": true}'::jsonb -); - --- Plan Single-Tenant (para instalaciones on-premise) -INSERT INTO billing.subscription_plans (code, name, description, price_monthly, price_yearly, max_users, max_companies, max_storage_gb, trial_days, is_public, sort_order, features) -VALUES ( - 'single_tenant', - 'Single Tenant / On-Premise', - 'Instalación dedicada sin restricciones', - 0, 0, - NULL, NULL, NULL, 0, false, 99, -- No público, solo asignación manual - '{"inventory": true, "sales": true, "financial": true, "purchase": true, "crm": true, "projects": true, "reports_basic": true, "reports_advanced": true, "api_access": true, "white_label": true, "priority_support": true, "custom_integrations": true, "unlimited": true}'::jsonb -); - --- ===================================================== --- COMENTARIOS --- ===================================================== - -COMMENT ON SCHEMA billing IS 'Schema para gestión de suscripciones SaaS, planes, pagos y facturación'; - -COMMENT ON TABLE billing.subscription_plans IS 'Planes de suscripción disponibles (global, no por tenant)'; -COMMENT ON TABLE billing.tenant_owners IS 'Propietarios/administradores de facturación de cada tenant'; -COMMENT ON TABLE billing.subscriptions IS 'Suscripciones activas de cada tenant'; -COMMENT ON TABLE billing.payment_methods IS 'Métodos de pago registrados por tenant'; -COMMENT ON TABLE billing.invoices IS 'Facturas de suscripción'; -COMMENT ON TABLE billing.invoice_lines IS 'Líneas de detalle de facturas'; -COMMENT ON TABLE billing.payments IS 'Pagos recibidos'; -COMMENT ON TABLE billing.usage_records IS 'Registros de uso para billing por consumo'; -COMMENT ON TABLE billing.coupons IS 'Cupones de descuento'; -COMMENT ON TABLE billing.coupon_redemptions IS 'Registro de cupones usados'; -COMMENT ON TABLE billing.subscription_history IS 'Historial de cambios de suscripción'; - -COMMENT ON FUNCTION billing.get_tenant_plan IS 'Obtiene información del plan actual de un tenant'; -COMMENT ON FUNCTION billing.can_add_user IS 'Verifica si el tenant puede agregar más usuarios según su plan'; -COMMENT ON FUNCTION billing.has_feature IS 'Verifica si una feature está habilitada para el tenant'; diff --git a/ddl/11-crm.sql b/ddl/11-crm.sql deleted file mode 100644 index 8428e54..0000000 --- a/ddl/11-crm.sql +++ /dev/null @@ -1,366 +0,0 @@ --- ===================================================== --- SCHEMA: crm --- PROPOSITO: Customer Relationship Management --- MODULOS: MGN-CRM (CRM) --- FECHA: 2025-11-24 --- ===================================================== - --- Crear schema -CREATE SCHEMA IF NOT EXISTS crm; - --- ===================================================== --- TYPES (ENUMs) --- ===================================================== - -CREATE TYPE crm.lead_status AS ENUM ( - 'new', - 'contacted', - 'qualified', - 'converted', - 'lost' -); - -CREATE TYPE crm.opportunity_status AS ENUM ( - 'open', - 'won', - 'lost' -); - -CREATE TYPE crm.activity_type AS ENUM ( - 'call', - 'email', - 'meeting', - 'task', - 'note' -); - -CREATE TYPE crm.lead_source AS ENUM ( - 'website', - 'phone', - 'email', - 'referral', - 'social_media', - 'advertising', - 'event', - 'other' -); - --- ===================================================== --- TABLES --- ===================================================== - --- Tabla: lead_stages (Etapas del pipeline de leads) -CREATE TABLE crm.lead_stages ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - - name VARCHAR(100) NOT NULL, - sequence INTEGER NOT NULL DEFAULT 10, - is_won BOOLEAN DEFAULT FALSE, - probability DECIMAL(5, 2) DEFAULT 0, - requirements TEXT, - - active BOOLEAN DEFAULT TRUE, - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - - UNIQUE(tenant_id, name) -); - --- Tabla: opportunity_stages (Etapas del pipeline de oportunidades) -CREATE TABLE crm.opportunity_stages ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - - name VARCHAR(100) NOT NULL, - sequence INTEGER NOT NULL DEFAULT 10, - is_won BOOLEAN DEFAULT FALSE, - probability DECIMAL(5, 2) DEFAULT 0, - requirements TEXT, - - active BOOLEAN DEFAULT TRUE, - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - - UNIQUE(tenant_id, name) -); - --- Tabla: lost_reasons (Razones de perdida) -CREATE TABLE crm.lost_reasons ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - - name VARCHAR(100) NOT NULL, - description TEXT, - - active BOOLEAN DEFAULT TRUE, - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - - UNIQUE(tenant_id, name) -); - --- Tabla: leads (Prospectos/Leads) -CREATE TABLE crm.leads ( - 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 auth.companies(id) ON DELETE CASCADE, - - -- Numeracion - name VARCHAR(255) NOT NULL, - ref VARCHAR(100), - - -- Contacto - contact_name VARCHAR(255), - email VARCHAR(255), - phone VARCHAR(50), - mobile VARCHAR(50), - website VARCHAR(255), - - -- Empresa del prospecto - company_name VARCHAR(255), - job_position VARCHAR(100), - industry VARCHAR(100), - employee_count VARCHAR(50), - annual_revenue DECIMAL(15, 2), - - -- Direccion - street VARCHAR(255), - city VARCHAR(100), - state VARCHAR(100), - zip VARCHAR(20), - country VARCHAR(100), - - -- Pipeline - stage_id UUID REFERENCES crm.lead_stages(id), - status crm.lead_status NOT NULL DEFAULT 'new', - - -- Asignacion - user_id UUID REFERENCES auth.users(id), - sales_team_id UUID REFERENCES sales.sales_teams(id), - - -- Origen - source crm.lead_source, - campaign_id UUID, -- Para futuro modulo marketing - medium VARCHAR(100), - - -- Valoracion - priority INTEGER DEFAULT 0 CHECK (priority >= 0 AND priority <= 3), - probability DECIMAL(5, 2) DEFAULT 0, - expected_revenue DECIMAL(15, 2), - - -- Fechas - date_open TIMESTAMP WITH TIME ZONE, - date_closed TIMESTAMP WITH TIME ZONE, - date_deadline DATE, - date_last_activity TIMESTAMP WITH TIME ZONE, - - -- Conversion - partner_id UUID REFERENCES core.partners(id), - opportunity_id UUID, -- Se llena al convertir - - -- Perdida - lost_reason_id UUID REFERENCES crm.lost_reasons(id), - lost_notes TEXT, - - -- Notas - description TEXT, - notes TEXT, - tags VARCHAR(255)[], - - -- Auditoria - created_by UUID REFERENCES auth.users(id), - updated_by UUID REFERENCES auth.users(id), - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP -); - --- Tabla: opportunities (Oportunidades de venta) -CREATE TABLE crm.opportunities ( - 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 auth.companies(id) ON DELETE CASCADE, - - -- Numeracion - name VARCHAR(255) NOT NULL, - ref VARCHAR(100), - - -- Cliente - partner_id UUID NOT NULL REFERENCES core.partners(id), - contact_name VARCHAR(255), - email VARCHAR(255), - phone VARCHAR(50), - - -- Pipeline - stage_id UUID REFERENCES crm.opportunity_stages(id), - status crm.opportunity_status NOT NULL DEFAULT 'open', - - -- Asignacion - user_id UUID REFERENCES auth.users(id), - sales_team_id UUID REFERENCES sales.sales_teams(id), - - -- Valoracion - priority INTEGER DEFAULT 0 CHECK (priority >= 0 AND priority <= 3), - probability DECIMAL(5, 2) DEFAULT 0, - expected_revenue DECIMAL(15, 2), - recurring_revenue DECIMAL(15, 2), - recurring_plan VARCHAR(50), - - -- Fechas - date_deadline DATE, - date_closed TIMESTAMP WITH TIME ZONE, - date_last_activity TIMESTAMP WITH TIME ZONE, - - -- Origen (si viene de lead) - lead_id UUID REFERENCES crm.leads(id), - source crm.lead_source, - campaign_id UUID, - medium VARCHAR(100), - - -- Cierre - lost_reason_id UUID REFERENCES crm.lost_reasons(id), - lost_notes TEXT, - - -- Relaciones - quotation_id UUID REFERENCES sales.quotations(id), - order_id UUID REFERENCES sales.sales_orders(id), - - -- Notas - description TEXT, - notes TEXT, - tags VARCHAR(255)[], - - -- Auditoria - created_by UUID REFERENCES auth.users(id), - updated_by UUID REFERENCES auth.users(id), - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP -); - --- Actualizar referencia circular en leads -ALTER TABLE crm.leads ADD CONSTRAINT fk_leads_opportunity - FOREIGN KEY (opportunity_id) REFERENCES crm.opportunities(id); - --- Tabla: crm_activities (Actividades CRM) -CREATE TABLE crm.activities ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - - -- Referencia polimorfica - res_model VARCHAR(100) NOT NULL, - res_id UUID NOT NULL, - - -- Actividad - activity_type crm.activity_type NOT NULL, - summary VARCHAR(255), - description TEXT, - - -- Fechas - date_deadline DATE, - date_done TIMESTAMP WITH TIME ZONE, - - -- Asignacion - user_id UUID REFERENCES auth.users(id), - assigned_to UUID REFERENCES auth.users(id), - - -- Estado - done BOOLEAN DEFAULT FALSE, - - -- Auditoria - created_by UUID REFERENCES auth.users(id), - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP -); - --- ===================================================== --- INDEXES --- ===================================================== - -CREATE INDEX idx_lead_stages_tenant ON crm.lead_stages(tenant_id); -CREATE INDEX idx_opportunity_stages_tenant ON crm.opportunity_stages(tenant_id); -CREATE INDEX idx_lost_reasons_tenant ON crm.lost_reasons(tenant_id); - -CREATE INDEX idx_leads_tenant ON crm.leads(tenant_id); -CREATE INDEX idx_leads_company ON crm.leads(company_id); -CREATE INDEX idx_leads_status ON crm.leads(status); -CREATE INDEX idx_leads_stage ON crm.leads(stage_id); -CREATE INDEX idx_leads_user ON crm.leads(user_id); -CREATE INDEX idx_leads_partner ON crm.leads(partner_id); -CREATE INDEX idx_leads_email ON crm.leads(email); - -CREATE INDEX idx_opportunities_tenant ON crm.opportunities(tenant_id); -CREATE INDEX idx_opportunities_company ON crm.opportunities(company_id); -CREATE INDEX idx_opportunities_status ON crm.opportunities(status); -CREATE INDEX idx_opportunities_stage ON crm.opportunities(stage_id); -CREATE INDEX idx_opportunities_user ON crm.opportunities(user_id); -CREATE INDEX idx_opportunities_partner ON crm.opportunities(partner_id); - -CREATE INDEX idx_crm_activities_tenant ON crm.activities(tenant_id); -CREATE INDEX idx_crm_activities_model ON crm.activities(res_model, res_id); -CREATE INDEX idx_crm_activities_user ON crm.activities(assigned_to); -CREATE INDEX idx_crm_activities_deadline ON crm.activities(date_deadline); - --- ===================================================== --- TRIGGERS --- ===================================================== - -CREATE TRIGGER update_lead_stages_timestamp - BEFORE UPDATE ON crm.lead_stages - FOR EACH ROW EXECUTE FUNCTION core.update_timestamp(); - -CREATE TRIGGER update_opportunity_stages_timestamp - BEFORE UPDATE ON crm.opportunity_stages - FOR EACH ROW EXECUTE FUNCTION core.update_timestamp(); - -CREATE TRIGGER update_leads_timestamp - BEFORE UPDATE ON crm.leads - FOR EACH ROW EXECUTE FUNCTION core.update_timestamp(); - -CREATE TRIGGER update_opportunities_timestamp - BEFORE UPDATE ON crm.opportunities - FOR EACH ROW EXECUTE FUNCTION core.update_timestamp(); - -CREATE TRIGGER update_crm_activities_timestamp - BEFORE UPDATE ON crm.activities - FOR EACH ROW EXECUTE FUNCTION core.update_timestamp(); - --- ===================================================== --- ROW LEVEL SECURITY --- ===================================================== - --- Habilitar RLS -ALTER TABLE crm.lead_stages ENABLE ROW LEVEL SECURITY; -ALTER TABLE crm.opportunity_stages ENABLE ROW LEVEL SECURITY; -ALTER TABLE crm.lost_reasons ENABLE ROW LEVEL SECURITY; -ALTER TABLE crm.leads ENABLE ROW LEVEL SECURITY; -ALTER TABLE crm.opportunities ENABLE ROW LEVEL SECURITY; -ALTER TABLE crm.activities ENABLE ROW LEVEL SECURITY; - --- Políticas de aislamiento por tenant -CREATE POLICY tenant_isolation_lead_stages ON crm.lead_stages - USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); - -CREATE POLICY tenant_isolation_opportunity_stages ON crm.opportunity_stages - USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); - -CREATE POLICY tenant_isolation_lost_reasons ON crm.lost_reasons - USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); - -CREATE POLICY tenant_isolation_leads ON crm.leads - USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); - -CREATE POLICY tenant_isolation_opportunities ON crm.opportunities - USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); - -CREATE POLICY tenant_isolation_crm_activities ON crm.activities - USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); - --- ===================================================== --- COMMENTS --- ===================================================== - -COMMENT ON TABLE crm.lead_stages IS 'Etapas del pipeline de leads'; -COMMENT ON TABLE crm.opportunity_stages IS 'Etapas del pipeline de oportunidades'; -COMMENT ON TABLE crm.lost_reasons IS 'Razones de perdida de leads/oportunidades'; -COMMENT ON TABLE crm.leads IS 'Prospectos/leads de ventas'; -COMMENT ON TABLE crm.opportunities IS 'Oportunidades de venta'; -COMMENT ON TABLE crm.activities IS 'Actividades CRM (llamadas, reuniones, etc.)'; diff --git a/ddl/11-feature-flags.sql b/ddl/11-feature-flags.sql new file mode 100644 index 0000000..8aa4005 --- /dev/null +++ b/ddl/11-feature-flags.sql @@ -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'; diff --git a/ddl/12-hr.sql b/ddl/12-hr.sql deleted file mode 100644 index 7e8d6c2..0000000 --- a/ddl/12-hr.sql +++ /dev/null @@ -1,379 +0,0 @@ --- ===================================================== --- SCHEMA: hr --- PROPOSITO: Human Resources Management --- MODULOS: MGN-HR (Recursos Humanos) --- FECHA: 2025-11-24 --- ===================================================== - --- Crear schema -CREATE SCHEMA IF NOT EXISTS hr; - --- ===================================================== --- TYPES (ENUMs) --- ===================================================== - -CREATE TYPE hr.contract_status AS ENUM ( - 'draft', - 'active', - 'expired', - 'terminated', - 'cancelled' -); - -CREATE TYPE hr.contract_type AS ENUM ( - 'permanent', - 'temporary', - 'contractor', - 'internship', - 'part_time' -); - -CREATE TYPE hr.leave_status AS ENUM ( - 'draft', - 'submitted', - 'approved', - 'rejected', - 'cancelled' -); - -CREATE TYPE hr.leave_type AS ENUM ( - 'vacation', - 'sick', - 'personal', - 'maternity', - 'paternity', - 'bereavement', - 'unpaid', - 'other' -); - -CREATE TYPE hr.employee_status AS ENUM ( - 'active', - 'inactive', - 'on_leave', - 'terminated' -); - --- ===================================================== --- TABLES --- ===================================================== - --- Tabla: departments (Departamentos) -CREATE TABLE 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 auth.companies(id) ON DELETE CASCADE, - - name VARCHAR(100) NOT NULL, - code VARCHAR(20), - parent_id UUID REFERENCES hr.departments(id), - manager_id UUID, -- References employees, set after table creation - - description TEXT, - color VARCHAR(20), - - active BOOLEAN DEFAULT TRUE, - created_by UUID REFERENCES auth.users(id), - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - - UNIQUE(tenant_id, company_id, name) -); - --- Tabla: job_positions (Puestos de trabajo) -CREATE TABLE hr.job_positions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - - name VARCHAR(100) NOT NULL, - department_id UUID REFERENCES hr.departments(id), - - description TEXT, - requirements TEXT, - responsibilities TEXT, - - min_salary DECIMAL(15, 2), - max_salary DECIMAL(15, 2), - - active BOOLEAN DEFAULT TRUE, - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - - UNIQUE(tenant_id, name) -); - --- Tabla: employees (Empleados) -CREATE TABLE 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 auth.companies(id) ON DELETE CASCADE, - - -- Identificacion - employee_number VARCHAR(50) NOT NULL, - first_name VARCHAR(100) NOT NULL, - last_name VARCHAR(100) NOT NULL, - middle_name VARCHAR(100), - - -- Usuario vinculado (opcional) - user_id UUID REFERENCES auth.users(id), - - -- Informacion personal - birth_date DATE, - gender VARCHAR(20), - marital_status VARCHAR(20), - nationality VARCHAR(100), - identification_id VARCHAR(50), - identification_type VARCHAR(50), - social_security_number VARCHAR(50), - tax_id VARCHAR(50), - - -- Contacto - email VARCHAR(255), - work_email VARCHAR(255), - phone VARCHAR(50), - work_phone VARCHAR(50), - mobile VARCHAR(50), - emergency_contact VARCHAR(255), - emergency_phone VARCHAR(50), - - -- Direccion - street VARCHAR(255), - city VARCHAR(100), - state VARCHAR(100), - zip VARCHAR(20), - country VARCHAR(100), - - -- Trabajo - department_id UUID REFERENCES hr.departments(id), - job_position_id UUID REFERENCES hr.job_positions(id), - manager_id UUID REFERENCES hr.employees(id), - - hire_date DATE NOT NULL, - termination_date DATE, - status hr.employee_status NOT NULL DEFAULT 'active', - - -- Datos bancarios - bank_name VARCHAR(100), - bank_account VARCHAR(50), - bank_clabe VARCHAR(20), - - -- Foto - photo_url VARCHAR(500), - - -- Notas - notes TEXT, - - -- Auditoria - created_by UUID REFERENCES auth.users(id), - updated_by UUID REFERENCES auth.users(id), - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - - UNIQUE(tenant_id, employee_number) -); - --- Add manager_id reference to departments -ALTER TABLE hr.departments ADD CONSTRAINT fk_departments_manager - FOREIGN KEY (manager_id) REFERENCES hr.employees(id); - --- Tabla: contracts (Contratos laborales) -CREATE TABLE 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 auth.companies(id) ON DELETE CASCADE, - - employee_id UUID NOT NULL REFERENCES hr.employees(id) ON DELETE CASCADE, - - -- Identificacion - name VARCHAR(100) NOT NULL, - reference VARCHAR(100), - - -- Tipo y estado - contract_type hr.contract_type NOT NULL, - status hr.contract_status NOT NULL DEFAULT 'draft', - - -- Puesto - job_position_id UUID REFERENCES hr.job_positions(id), - department_id UUID REFERENCES hr.departments(id), - - -- Vigencia - date_start DATE NOT NULL, - date_end DATE, - trial_date_end DATE, - - -- Compensacion - wage DECIMAL(15, 2) NOT NULL, - wage_type VARCHAR(20) DEFAULT 'monthly', -- hourly, daily, weekly, monthly, yearly - currency_id UUID REFERENCES core.currencies(id), - - -- Horas - resource_calendar_id UUID, -- For future scheduling module - hours_per_week DECIMAL(5, 2) DEFAULT 40, - - -- Beneficios y deducciones - vacation_days INTEGER DEFAULT 6, - christmas_bonus_days INTEGER DEFAULT 15, - - -- Documentos - document_url VARCHAR(500), - - -- Notas - notes TEXT, - - -- Auditoria - created_by UUID REFERENCES auth.users(id), - updated_by UUID REFERENCES auth.users(id), - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP -); - --- Tabla: leave_types (Tipos de ausencia configurables) -CREATE TABLE hr.leave_types ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - - name VARCHAR(100) NOT NULL, - code VARCHAR(20), - leave_type hr.leave_type NOT NULL, - - requires_approval BOOLEAN DEFAULT TRUE, - max_days INTEGER, - is_paid BOOLEAN DEFAULT TRUE, - - color VARCHAR(20), - - active BOOLEAN DEFAULT TRUE, - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - - UNIQUE(tenant_id, name) -); - --- Tabla: leaves (Ausencias/Permisos) -CREATE TABLE 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 auth.companies(id) ON DELETE CASCADE, - - employee_id UUID NOT NULL REFERENCES hr.employees(id) ON DELETE CASCADE, - leave_type_id UUID NOT NULL REFERENCES hr.leave_types(id), - - -- Solicitud - name VARCHAR(255), - date_from DATE NOT NULL, - date_to DATE NOT NULL, - number_of_days DECIMAL(5, 2) NOT NULL, - - -- Estado - status hr.leave_status NOT NULL DEFAULT 'draft', - - -- Descripcion - description TEXT, - - -- Aprobacion - approved_by UUID REFERENCES auth.users(id), - approved_at TIMESTAMP WITH TIME ZONE, - rejection_reason TEXT, - - -- Auditoria - created_by UUID REFERENCES auth.users(id), - updated_by UUID REFERENCES auth.users(id), - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP -); - --- ===================================================== --- INDEXES --- ===================================================== - -CREATE INDEX idx_departments_tenant ON hr.departments(tenant_id); -CREATE INDEX idx_departments_company ON hr.departments(company_id); -CREATE INDEX idx_departments_parent ON hr.departments(parent_id); - -CREATE INDEX idx_job_positions_tenant ON hr.job_positions(tenant_id); -CREATE INDEX idx_job_positions_department ON hr.job_positions(department_id); - -CREATE INDEX idx_employees_tenant ON hr.employees(tenant_id); -CREATE INDEX idx_employees_company ON hr.employees(company_id); -CREATE INDEX idx_employees_department ON hr.employees(department_id); -CREATE INDEX idx_employees_manager ON hr.employees(manager_id); -CREATE INDEX idx_employees_user ON hr.employees(user_id); -CREATE INDEX idx_employees_status ON hr.employees(status); -CREATE INDEX idx_employees_number ON hr.employees(employee_number); - -CREATE INDEX idx_contracts_tenant ON hr.contracts(tenant_id); -CREATE INDEX idx_contracts_employee ON hr.contracts(employee_id); -CREATE INDEX idx_contracts_status ON hr.contracts(status); -CREATE INDEX idx_contracts_dates ON hr.contracts(date_start, date_end); - -CREATE INDEX idx_leave_types_tenant ON hr.leave_types(tenant_id); - -CREATE INDEX idx_leaves_tenant ON hr.leaves(tenant_id); -CREATE INDEX idx_leaves_employee ON hr.leaves(employee_id); -CREATE INDEX idx_leaves_status ON hr.leaves(status); -CREATE INDEX idx_leaves_dates ON hr.leaves(date_from, date_to); - --- ===================================================== --- TRIGGERS --- ===================================================== - -CREATE TRIGGER update_departments_timestamp - BEFORE UPDATE ON hr.departments - FOR EACH ROW EXECUTE FUNCTION core.update_timestamp(); - -CREATE TRIGGER update_job_positions_timestamp - BEFORE UPDATE ON hr.job_positions - FOR EACH ROW EXECUTE FUNCTION core.update_timestamp(); - -CREATE TRIGGER update_employees_timestamp - BEFORE UPDATE ON hr.employees - FOR EACH ROW EXECUTE FUNCTION core.update_timestamp(); - -CREATE TRIGGER update_contracts_timestamp - BEFORE UPDATE ON hr.contracts - FOR EACH ROW EXECUTE FUNCTION core.update_timestamp(); - -CREATE TRIGGER update_leaves_timestamp - BEFORE UPDATE ON hr.leaves - FOR EACH ROW EXECUTE FUNCTION core.update_timestamp(); - --- ===================================================== --- ROW LEVEL SECURITY --- ===================================================== - --- Habilitar RLS -ALTER TABLE hr.departments ENABLE ROW LEVEL SECURITY; -ALTER TABLE hr.job_positions ENABLE ROW LEVEL SECURITY; -ALTER TABLE hr.employees ENABLE ROW LEVEL SECURITY; -ALTER TABLE hr.contracts ENABLE ROW LEVEL SECURITY; -ALTER TABLE hr.leave_types ENABLE ROW LEVEL SECURITY; -ALTER TABLE hr.leaves ENABLE ROW LEVEL SECURITY; - --- Políticas de aislamiento por tenant -CREATE POLICY tenant_isolation_departments ON hr.departments - USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); - -CREATE POLICY tenant_isolation_job_positions ON hr.job_positions - USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); - -CREATE POLICY tenant_isolation_employees ON hr.employees - USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); - -CREATE POLICY tenant_isolation_contracts ON hr.contracts - USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); - -CREATE POLICY tenant_isolation_leave_types ON hr.leave_types - USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); - -CREATE POLICY tenant_isolation_leaves ON hr.leaves - USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); - --- ===================================================== --- COMMENTS --- ===================================================== - -COMMENT ON TABLE hr.departments IS 'Departamentos de la organizacion'; -COMMENT ON TABLE hr.job_positions IS 'Puestos de trabajo/posiciones'; -COMMENT ON TABLE hr.employees IS 'Empleados de la organizacion'; -COMMENT ON TABLE hr.contracts IS 'Contratos laborales'; -COMMENT ON TABLE hr.leave_types IS 'Tipos de ausencia configurables'; -COMMENT ON TABLE hr.leaves IS 'Solicitudes de ausencias/permisos'; diff --git a/ddl/12-webhooks.sql b/ddl/12-webhooks.sql new file mode 100644 index 0000000..0346453 --- /dev/null +++ b/ddl/12-webhooks.sql @@ -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'; diff --git a/ddl/13-storage.sql b/ddl/13-storage.sql new file mode 100644 index 0000000..f226323 --- /dev/null +++ b/ddl/13-storage.sql @@ -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'; diff --git a/ddl/14-ai.sql b/ddl/14-ai.sql new file mode 100644 index 0000000..2004090 --- /dev/null +++ b/ddl/14-ai.sql @@ -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'; diff --git a/ddl/15-whatsapp.sql b/ddl/15-whatsapp.sql new file mode 100644 index 0000000..d699fbe --- /dev/null +++ b/ddl/15-whatsapp.sql @@ -0,0 +1,1018 @@ +-- ============================================================= +-- ARCHIVO: 15-whatsapp.sql +-- DESCRIPCION: Integracion WhatsApp Business API, mensajes, templates +-- VERSION: 1.0.0 +-- PROYECTO: ERP-Core V2 +-- FECHA: 2026-01-10 +-- EPIC: SAAS-WHATSAPP (EPIC-SAAS-008) +-- HISTORIAS: US-090, US-091, US-092 +-- ============================================================= + +-- ===================== +-- SCHEMA: whatsapp +-- ===================== +CREATE SCHEMA IF NOT EXISTS whatsapp; + +-- ===================== +-- TABLA: whatsapp.accounts +-- Cuentas de WhatsApp Business configuradas +-- ===================== +CREATE TABLE IF NOT EXISTS whatsapp.accounts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + + -- Identificación de la cuenta + name VARCHAR(200) NOT NULL, + phone_number VARCHAR(20) NOT NULL, + phone_number_id VARCHAR(50) NOT NULL, -- ID en Meta + business_account_id VARCHAR(50) NOT NULL, -- WABA ID + + -- Configuración de API + access_token TEXT, -- Encriptado + webhook_verify_token VARCHAR(255), + webhook_secret VARCHAR(255), + + -- Perfil de negocio + business_name VARCHAR(200), + business_description TEXT, + business_category VARCHAR(100), + business_website TEXT, + profile_picture_url TEXT, + + -- Configuración + default_language VARCHAR(10) DEFAULT 'es_MX', + auto_reply_enabled BOOLEAN DEFAULT FALSE, + auto_reply_message TEXT, + business_hours JSONB DEFAULT '{}', + -- {monday: {start: "09:00", end: "18:00"}, ...} + + -- Estado + status VARCHAR(20) DEFAULT 'pending', -- pending, active, suspended, disconnected + verified_at TIMESTAMPTZ, + + -- Límites + daily_message_limit INTEGER DEFAULT 1000, + messages_sent_today INTEGER DEFAULT 0, + last_limit_reset TIMESTAMPTZ, + + -- Estadísticas + total_messages_sent BIGINT DEFAULT 0, + total_messages_received 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, phone_number) +); + +-- ===================== +-- TABLA: whatsapp.templates +-- Templates aprobados por Meta +-- ===================== +CREATE TABLE IF NOT EXISTS whatsapp.templates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + account_id UUID NOT NULL REFERENCES whatsapp.accounts(id) ON DELETE CASCADE, + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + + -- Identificación + name VARCHAR(512) NOT NULL, -- Nombre en Meta (snake_case) + display_name VARCHAR(200) NOT NULL, -- Nombre legible + description TEXT, + + -- Categoría (requerida por Meta) + category VARCHAR(30) NOT NULL, -- MARKETING, UTILITY, AUTHENTICATION + + -- Idioma + language VARCHAR(10) NOT NULL DEFAULT 'es_MX', + + -- Componentes del template + header_type VARCHAR(20), -- TEXT, IMAGE, VIDEO, DOCUMENT + header_text TEXT, + header_media_url TEXT, + + body_text TEXT NOT NULL, + body_variables TEXT[] DEFAULT '{}', -- {{1}}, {{2}}, etc. + + footer_text VARCHAR(60), + + -- Botones + buttons JSONB DEFAULT '[]', + -- [{type: "QUICK_REPLY", text: "Sí"}, {type: "URL", text: "Ver más", url: "..."}] + + -- Estado en Meta + meta_template_id VARCHAR(50), + meta_status VARCHAR(20) DEFAULT 'PENDING', + -- PENDING, APPROVED, REJECTED, PAUSED, DISABLED + rejection_reason TEXT, + + -- Uso + is_active BOOLEAN DEFAULT TRUE, + usage_count INTEGER DEFAULT 0, + last_used_at TIMESTAMPTZ, + + -- Versionamiento + version INTEGER DEFAULT 1, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + submitted_at TIMESTAMPTZ, + approved_at TIMESTAMPTZ, + + UNIQUE(account_id, name, language) +); + +-- ===================== +-- TABLA: whatsapp.contacts +-- Contactos de WhatsApp +-- ===================== +CREATE TABLE IF NOT EXISTS whatsapp.contacts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + account_id UUID NOT NULL REFERENCES whatsapp.accounts(id) ON DELETE CASCADE, + + -- Número de teléfono + phone_number VARCHAR(20) NOT NULL, + wa_id VARCHAR(50), -- WhatsApp ID + + -- Perfil (obtenido de WhatsApp) + profile_name VARCHAR(200), + profile_picture_url TEXT, + + -- Asociación con entidades + customer_id UUID, + user_id UUID REFERENCES auth.users(id), + + -- Estado de conversación + conversation_status VARCHAR(20) DEFAULT 'active', + -- active, waiting, resolved, blocked + last_message_at TIMESTAMPTZ, + last_message_direction VARCHAR(10), -- inbound, outbound + + -- Ventana de 24 horas + conversation_window_expires_at TIMESTAMPTZ, + can_send_template_only BOOLEAN DEFAULT TRUE, + + -- Opt-in/Opt-out + opted_in BOOLEAN DEFAULT FALSE, + opted_in_at TIMESTAMPTZ, + opted_out BOOLEAN DEFAULT FALSE, + opted_out_at TIMESTAMPTZ, + + -- Tags y categorización + tags TEXT[] DEFAULT '{}', + notes TEXT, + + -- Estadísticas + total_messages_sent INTEGER DEFAULT 0, + total_messages_received INTEGER DEFAULT 0, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + + UNIQUE(account_id, phone_number) +); + +-- ===================== +-- TABLA: whatsapp.conversations +-- Conversaciones (hilos de chat) +-- ===================== +CREATE TABLE IF NOT EXISTS whatsapp.conversations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + account_id UUID NOT NULL REFERENCES whatsapp.accounts(id) ON DELETE CASCADE, + contact_id UUID NOT NULL REFERENCES whatsapp.contacts(id) ON DELETE CASCADE, + + -- Estado + status VARCHAR(20) DEFAULT 'open', -- open, pending, resolved, closed + priority VARCHAR(20) DEFAULT 'normal', -- low, normal, high, urgent + + -- Asignación + assigned_to UUID REFERENCES auth.users(id), + assigned_at TIMESTAMPTZ, + team_id UUID, + + -- Categorización + category VARCHAR(50), + tags TEXT[] DEFAULT '{}', + + -- Contexto + context_type VARCHAR(50), -- support, sales, order, general + context_id UUID, + + -- Tiempos + first_response_at TIMESTAMPTZ, + resolved_at TIMESTAMPTZ, + + -- Estadísticas + message_count INTEGER DEFAULT 0, + unread_count INTEGER DEFAULT 0, + + -- Metadata + metadata JSONB DEFAULT '{}', + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +-- ===================== +-- TABLA: whatsapp.messages +-- Mensajes enviados y recibidos +-- ===================== +CREATE TABLE IF NOT EXISTS whatsapp.messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + account_id UUID NOT NULL REFERENCES whatsapp.accounts(id) ON DELETE CASCADE, + contact_id UUID NOT NULL REFERENCES whatsapp.contacts(id) ON DELETE CASCADE, + conversation_id UUID REFERENCES whatsapp.conversations(id), + + -- Identificadores de Meta + wa_message_id VARCHAR(100), -- ID del mensaje en WhatsApp + wa_conversation_id VARCHAR(100), -- ID de conversación en Meta + + -- Dirección + direction VARCHAR(10) NOT NULL, -- inbound, outbound + + -- Tipo de mensaje + message_type VARCHAR(20) NOT NULL, + -- text, image, video, audio, document, sticker, location, contacts, interactive, template, reaction + + -- Contenido + content TEXT, + caption TEXT, + + -- Media + media_id VARCHAR(100), + media_url TEXT, + media_mime_type VARCHAR(100), + media_sha256 VARCHAR(64), + media_size_bytes INTEGER, + + -- Template (si aplica) + template_id UUID REFERENCES whatsapp.templates(id), + template_name VARCHAR(512), + template_variables JSONB DEFAULT '[]', + + -- Interactivo (si aplica) + interactive_type VARCHAR(30), -- button, list, product, product_list + interactive_data JSONB DEFAULT '{}', + + -- Contexto (respuesta a otro mensaje) + context_message_id VARCHAR(100), + quoted_message_id UUID REFERENCES whatsapp.messages(id), + + -- Estado del mensaje + status VARCHAR(20) DEFAULT 'pending', + -- pending, sent, delivered, read, failed + status_updated_at TIMESTAMPTZ, + + -- Error (si falló) + error_code VARCHAR(20), + error_message TEXT, + + -- Costos (para mensajes de template) + is_billable BOOLEAN DEFAULT FALSE, + cost_category VARCHAR(30), -- utility, authentication, marketing + + -- Metadata + metadata JSONB DEFAULT '{}', + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + sent_at TIMESTAMPTZ, + delivered_at TIMESTAMPTZ, + read_at TIMESTAMPTZ +); + +-- ===================== +-- TABLA: whatsapp.message_status_updates +-- Actualizaciones de estado de mensajes +-- ===================== +CREATE TABLE IF NOT EXISTS whatsapp.message_status_updates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + message_id UUID NOT NULL REFERENCES whatsapp.messages(id) ON DELETE CASCADE, + + -- Estado + status VARCHAR(20) NOT NULL, + previous_status VARCHAR(20), + + -- Error (si aplica) + error_code VARCHAR(20), + error_title VARCHAR(200), + error_message TEXT, + + -- Timestamp de Meta + meta_timestamp TIMESTAMPTZ, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +-- ===================== +-- TABLA: whatsapp.quick_replies +-- Respuestas rápidas predefinidas +-- ===================== +CREATE TABLE IF NOT EXISTS whatsapp.quick_replies ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + account_id UUID REFERENCES whatsapp.accounts(id) ON DELETE CASCADE, + + -- Identificación + shortcut VARCHAR(50) NOT NULL, -- /gracias, /horario + title VARCHAR(200) NOT NULL, + category VARCHAR(50), + + -- Contenido + message_type VARCHAR(20) DEFAULT 'text', + content TEXT NOT NULL, + media_url TEXT, + + -- Uso + usage_count INTEGER DEFAULT 0, + last_used_at TIMESTAMPTZ, + + -- Estado + is_active BOOLEAN DEFAULT TRUE, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + created_by UUID REFERENCES auth.users(id), + + UNIQUE(tenant_id, shortcut) +); + +-- ===================== +-- TABLA: whatsapp.automations +-- Automatizaciones de WhatsApp +-- ===================== +CREATE TABLE IF NOT EXISTS whatsapp.automations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + account_id UUID NOT NULL REFERENCES whatsapp.accounts(id) ON DELETE CASCADE, + + -- Identificación + name VARCHAR(200) NOT NULL, + description TEXT, + + -- Trigger + trigger_type VARCHAR(30) NOT NULL, + -- keyword, first_message, after_hours, no_response, webhook + trigger_config JSONB NOT NULL DEFAULT '{}', + -- keyword: {keywords: ["hola", "info"]} + -- after_hours: {message: "..."} + -- no_response: {delay_minutes: 30} + + -- Acción + action_type VARCHAR(30) NOT NULL, + -- send_message, send_template, assign_agent, add_tag, create_ticket + action_config JSONB NOT NULL DEFAULT '{}', + + -- Condiciones adicionales + conditions JSONB DEFAULT '[]', + + -- Estado + is_active BOOLEAN DEFAULT TRUE, + priority INTEGER DEFAULT 0, + + -- Estadísticas + trigger_count INTEGER DEFAULT 0, + last_triggered_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + created_by UUID REFERENCES auth.users(id) +); + +-- ===================== +-- TABLA: whatsapp.broadcasts +-- Envíos masivos de mensajes +-- ===================== +CREATE TABLE IF NOT EXISTS whatsapp.broadcasts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + account_id UUID NOT NULL REFERENCES whatsapp.accounts(id) ON DELETE CASCADE, + + -- Identificación + name VARCHAR(200) NOT NULL, + description TEXT, + + -- Template a usar + template_id UUID NOT NULL REFERENCES whatsapp.templates(id), + + -- Audiencia + audience_type VARCHAR(30) NOT NULL, -- all, segment, custom, file + audience_filter JSONB DEFAULT '{}', + recipient_count INTEGER DEFAULT 0, + + -- Programación + scheduled_at TIMESTAMPTZ, + timezone VARCHAR(50) DEFAULT 'America/Mexico_City', + + -- Estado + status VARCHAR(20) DEFAULT 'draft', + -- draft, scheduled, sending, completed, cancelled, failed + + -- Progreso + sent_count INTEGER DEFAULT 0, + delivered_count INTEGER DEFAULT 0, + read_count INTEGER DEFAULT 0, + failed_count INTEGER DEFAULT 0, + reply_count INTEGER DEFAULT 0, + + -- Tiempos + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + + -- Costos estimados + estimated_cost DECIMAL(10,2), + actual_cost DECIMAL(10,2), + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + created_by UUID REFERENCES auth.users(id) +); + +-- ===================== +-- TABLA: whatsapp.broadcast_recipients +-- Destinatarios de broadcasts +-- ===================== +CREATE TABLE IF NOT EXISTS whatsapp.broadcast_recipients ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + broadcast_id UUID NOT NULL REFERENCES whatsapp.broadcasts(id) ON DELETE CASCADE, + contact_id UUID NOT NULL REFERENCES whatsapp.contacts(id) ON DELETE CASCADE, + + -- Variables del template + template_variables JSONB DEFAULT '[]', + + -- Estado + status VARCHAR(20) DEFAULT 'pending', + -- pending, sent, delivered, read, failed + + -- Mensaje enviado + message_id UUID REFERENCES whatsapp.messages(id), + + -- Error + error_code VARCHAR(20), + error_message TEXT, + + -- Tiempos + sent_at TIMESTAMPTZ, + delivered_at TIMESTAMPTZ, + read_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + + UNIQUE(broadcast_id, contact_id) +); + +-- ===================== +-- INDICES +-- ===================== + +-- Indices para accounts +CREATE INDEX IF NOT EXISTS idx_wa_accounts_tenant ON whatsapp.accounts(tenant_id); +CREATE INDEX IF NOT EXISTS idx_wa_accounts_phone ON whatsapp.accounts(phone_number); +CREATE INDEX IF NOT EXISTS idx_wa_accounts_status ON whatsapp.accounts(status); + +-- Indices para templates +CREATE INDEX IF NOT EXISTS idx_wa_templates_account ON whatsapp.templates(account_id); +CREATE INDEX IF NOT EXISTS idx_wa_templates_tenant ON whatsapp.templates(tenant_id); +CREATE INDEX IF NOT EXISTS idx_wa_templates_status ON whatsapp.templates(meta_status); +CREATE INDEX IF NOT EXISTS idx_wa_templates_category ON whatsapp.templates(category); + +-- Indices para contacts +CREATE INDEX IF NOT EXISTS idx_wa_contacts_tenant ON whatsapp.contacts(tenant_id); +CREATE INDEX IF NOT EXISTS idx_wa_contacts_account ON whatsapp.contacts(account_id); +CREATE INDEX IF NOT EXISTS idx_wa_contacts_phone ON whatsapp.contacts(phone_number); +CREATE INDEX IF NOT EXISTS idx_wa_contacts_tags ON whatsapp.contacts USING GIN(tags); +CREATE INDEX IF NOT EXISTS idx_wa_contacts_opted_in ON whatsapp.contacts(opted_in) WHERE opted_in = TRUE; + +-- Indices para conversations +CREATE INDEX IF NOT EXISTS idx_wa_convos_tenant ON whatsapp.conversations(tenant_id); +CREATE INDEX IF NOT EXISTS idx_wa_convos_contact ON whatsapp.conversations(contact_id); +CREATE INDEX IF NOT EXISTS idx_wa_convos_status ON whatsapp.conversations(status); +CREATE INDEX IF NOT EXISTS idx_wa_convos_assigned ON whatsapp.conversations(assigned_to); +CREATE INDEX IF NOT EXISTS idx_wa_convos_created ON whatsapp.conversations(created_at DESC); + +-- Indices para messages +CREATE INDEX IF NOT EXISTS idx_wa_messages_tenant ON whatsapp.messages(tenant_id); +CREATE INDEX IF NOT EXISTS idx_wa_messages_account ON whatsapp.messages(account_id); +CREATE INDEX IF NOT EXISTS idx_wa_messages_contact ON whatsapp.messages(contact_id); +CREATE INDEX IF NOT EXISTS idx_wa_messages_conversation ON whatsapp.messages(conversation_id); +CREATE INDEX IF NOT EXISTS idx_wa_messages_wa_id ON whatsapp.messages(wa_message_id); +CREATE INDEX IF NOT EXISTS idx_wa_messages_status ON whatsapp.messages(status); +CREATE INDEX IF NOT EXISTS idx_wa_messages_created ON whatsapp.messages(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_wa_messages_direction ON whatsapp.messages(direction); + +-- Indices para status updates +CREATE INDEX IF NOT EXISTS idx_wa_status_message ON whatsapp.message_status_updates(message_id); + +-- Indices para quick_replies +CREATE INDEX IF NOT EXISTS idx_wa_quick_replies_tenant ON whatsapp.quick_replies(tenant_id); +CREATE INDEX IF NOT EXISTS idx_wa_quick_replies_shortcut ON whatsapp.quick_replies(shortcut); + +-- Indices para automations +CREATE INDEX IF NOT EXISTS idx_wa_automations_account ON whatsapp.automations(account_id); +CREATE INDEX IF NOT EXISTS idx_wa_automations_active ON whatsapp.automations(is_active) WHERE is_active = TRUE; + +-- Indices para broadcasts +CREATE INDEX IF NOT EXISTS idx_wa_broadcasts_account ON whatsapp.broadcasts(account_id); +CREATE INDEX IF NOT EXISTS idx_wa_broadcasts_status ON whatsapp.broadcasts(status); +CREATE INDEX IF NOT EXISTS idx_wa_broadcasts_scheduled ON whatsapp.broadcasts(scheduled_at) + WHERE status = 'scheduled'; + +-- Indices para broadcast_recipients +CREATE INDEX IF NOT EXISTS idx_wa_bcast_recip_broadcast ON whatsapp.broadcast_recipients(broadcast_id); +CREATE INDEX IF NOT EXISTS idx_wa_bcast_recip_status ON whatsapp.broadcast_recipients(status); + +-- ===================== +-- RLS POLICIES +-- ===================== + +ALTER TABLE whatsapp.accounts ENABLE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation_wa_accounts ON whatsapp.accounts + USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); + +ALTER TABLE whatsapp.templates ENABLE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation_wa_templates ON whatsapp.templates + USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); + +ALTER TABLE whatsapp.contacts ENABLE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation_wa_contacts ON whatsapp.contacts + USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); + +ALTER TABLE whatsapp.conversations ENABLE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation_wa_convos ON whatsapp.conversations + USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); + +ALTER TABLE whatsapp.messages ENABLE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation_wa_messages ON whatsapp.messages + USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); + +ALTER TABLE whatsapp.quick_replies ENABLE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation_wa_quick ON whatsapp.quick_replies + USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); + +ALTER TABLE whatsapp.automations ENABLE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation_wa_auto ON whatsapp.automations + USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); + +ALTER TABLE whatsapp.broadcasts ENABLE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation_wa_broadcasts ON whatsapp.broadcasts + USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); + +-- ===================== +-- FUNCIONES +-- ===================== + +-- Función para obtener o crear contacto +CREATE OR REPLACE FUNCTION whatsapp.get_or_create_contact( + p_account_id UUID, + p_phone_number VARCHAR(20), + p_profile_name VARCHAR(200) DEFAULT NULL, + p_wa_id VARCHAR(50) DEFAULT NULL +) +RETURNS UUID AS $$ +DECLARE + v_contact_id UUID; + v_tenant_id UUID; +BEGIN + -- Obtener tenant de la cuenta + SELECT tenant_id INTO v_tenant_id + FROM whatsapp.accounts WHERE id = p_account_id; + + -- Buscar contacto existente + SELECT id INTO v_contact_id + FROM whatsapp.contacts + WHERE account_id = p_account_id AND phone_number = p_phone_number; + + IF FOUND THEN + -- Actualizar perfil si hay nuevos datos + IF p_profile_name IS NOT NULL OR p_wa_id IS NOT NULL THEN + UPDATE whatsapp.contacts + SET + profile_name = COALESCE(p_profile_name, profile_name), + wa_id = COALESCE(p_wa_id, wa_id), + updated_at = CURRENT_TIMESTAMP + WHERE id = v_contact_id; + END IF; + RETURN v_contact_id; + END IF; + + -- Crear nuevo contacto + INSERT INTO whatsapp.contacts ( + tenant_id, account_id, phone_number, profile_name, wa_id + ) VALUES ( + v_tenant_id, p_account_id, p_phone_number, p_profile_name, p_wa_id + ) RETURNING id INTO v_contact_id; + + RETURN v_contact_id; +END; +$$ LANGUAGE plpgsql; + +-- Función para crear o obtener conversación activa +CREATE OR REPLACE FUNCTION whatsapp.get_active_conversation( + p_contact_id UUID +) +RETURNS UUID AS $$ +DECLARE + v_conversation_id UUID; + v_contact RECORD; +BEGIN + -- Obtener datos del contacto + SELECT * INTO v_contact FROM whatsapp.contacts WHERE id = p_contact_id; + + -- Buscar conversación abierta + SELECT id INTO v_conversation_id + FROM whatsapp.conversations + WHERE contact_id = p_contact_id + AND status IN ('open', 'pending') + ORDER BY created_at DESC + LIMIT 1; + + IF FOUND THEN + RETURN v_conversation_id; + END IF; + + -- Crear nueva conversación + INSERT INTO whatsapp.conversations ( + tenant_id, account_id, contact_id + ) VALUES ( + v_contact.tenant_id, v_contact.account_id, p_contact_id + ) RETURNING id INTO v_conversation_id; + + RETURN v_conversation_id; +END; +$$ LANGUAGE plpgsql; + +-- Función para registrar mensaje entrante +CREATE OR REPLACE FUNCTION whatsapp.receive_message( + p_account_id UUID, + p_phone_number VARCHAR(20), + p_wa_message_id VARCHAR(100), + p_message_type VARCHAR(20), + p_content TEXT, + p_media_id VARCHAR(100) DEFAULT NULL, + p_media_url TEXT DEFAULT NULL, + p_context_message_id VARCHAR(100) DEFAULT NULL, + p_profile_name VARCHAR(200) DEFAULT NULL +) +RETURNS UUID AS $$ +DECLARE + v_message_id UUID; + v_contact_id UUID; + v_conversation_id UUID; + v_tenant_id UUID; +BEGIN + -- Obtener tenant + SELECT tenant_id INTO v_tenant_id + FROM whatsapp.accounts WHERE id = p_account_id; + + -- Obtener o crear contacto + v_contact_id := whatsapp.get_or_create_contact( + p_account_id, p_phone_number, p_profile_name + ); + + -- Actualizar ventana de conversación + UPDATE whatsapp.contacts + SET + conversation_window_expires_at = CURRENT_TIMESTAMP + INTERVAL '24 hours', + can_send_template_only = FALSE, + last_message_at = CURRENT_TIMESTAMP, + last_message_direction = 'inbound', + total_messages_received = total_messages_received + 1, + updated_at = CURRENT_TIMESTAMP + WHERE id = v_contact_id; + + -- Obtener conversación activa + v_conversation_id := whatsapp.get_active_conversation(v_contact_id); + + -- Crear mensaje + INSERT INTO whatsapp.messages ( + tenant_id, account_id, contact_id, conversation_id, + wa_message_id, direction, message_type, content, + media_id, media_url, context_message_id, status + ) VALUES ( + v_tenant_id, p_account_id, v_contact_id, v_conversation_id, + p_wa_message_id, 'inbound', p_message_type, p_content, + p_media_id, p_media_url, p_context_message_id, 'received' + ) RETURNING id INTO v_message_id; + + -- Actualizar conversación + UPDATE whatsapp.conversations + SET + message_count = message_count + 1, + unread_count = unread_count + 1, + updated_at = CURRENT_TIMESTAMP + WHERE id = v_conversation_id; + + -- Actualizar estadísticas de la cuenta + UPDATE whatsapp.accounts + SET + total_messages_received = total_messages_received + 1, + updated_at = CURRENT_TIMESTAMP + WHERE id = p_account_id; + + RETURN v_message_id; +END; +$$ LANGUAGE plpgsql; + +-- Función para enviar mensaje de texto +CREATE OR REPLACE FUNCTION whatsapp.send_text_message( + p_account_id UUID, + p_contact_id UUID, + p_content TEXT, + p_sent_by UUID DEFAULT NULL +) +RETURNS UUID AS $$ +DECLARE + v_message_id UUID; + v_conversation_id UUID; + v_contact RECORD; + v_tenant_id UUID; +BEGIN + -- Obtener contacto + SELECT * INTO v_contact FROM whatsapp.contacts WHERE id = p_contact_id; + v_tenant_id := v_contact.tenant_id; + + -- Verificar ventana de conversación + IF v_contact.can_send_template_only AND + (v_contact.conversation_window_expires_at IS NULL OR + v_contact.conversation_window_expires_at < CURRENT_TIMESTAMP) THEN + RAISE EXCEPTION 'Cannot send text message outside conversation window. Use template.'; + END IF; + + -- Obtener conversación + v_conversation_id := whatsapp.get_active_conversation(p_contact_id); + + -- Crear mensaje + INSERT INTO whatsapp.messages ( + tenant_id, account_id, contact_id, conversation_id, + direction, message_type, content, status + ) VALUES ( + v_tenant_id, p_account_id, p_contact_id, v_conversation_id, + 'outbound', 'text', p_content, 'pending' + ) RETURNING id INTO v_message_id; + + -- Actualizar contacto + UPDATE whatsapp.contacts + SET + last_message_at = CURRENT_TIMESTAMP, + last_message_direction = 'outbound', + total_messages_sent = total_messages_sent + 1, + updated_at = CURRENT_TIMESTAMP + WHERE id = p_contact_id; + + -- Actualizar conversación + UPDATE whatsapp.conversations + SET + message_count = message_count + 1, + updated_at = CURRENT_TIMESTAMP + WHERE id = v_conversation_id; + + -- Actualizar cuenta + UPDATE whatsapp.accounts + SET + total_messages_sent = total_messages_sent + 1, + messages_sent_today = messages_sent_today + 1, + updated_at = CURRENT_TIMESTAMP + WHERE id = p_account_id; + + RETURN v_message_id; +END; +$$ LANGUAGE plpgsql; + +-- Función para enviar template +CREATE OR REPLACE FUNCTION whatsapp.send_template_message( + p_account_id UUID, + p_contact_id UUID, + p_template_id UUID, + p_variables JSONB DEFAULT '[]' +) +RETURNS UUID AS $$ +DECLARE + v_message_id UUID; + v_conversation_id UUID; + v_template RECORD; + v_contact RECORD; + v_tenant_id UUID; +BEGIN + -- Obtener template + SELECT * INTO v_template FROM whatsapp.templates WHERE id = p_template_id; + IF NOT FOUND OR v_template.meta_status != 'APPROVED' THEN + RAISE EXCEPTION 'Template not found or not approved'; + END IF; + + -- Obtener contacto + SELECT * INTO v_contact FROM whatsapp.contacts WHERE id = p_contact_id; + v_tenant_id := v_contact.tenant_id; + + -- Obtener conversación + v_conversation_id := whatsapp.get_active_conversation(p_contact_id); + + -- Crear mensaje + INSERT INTO whatsapp.messages ( + tenant_id, account_id, contact_id, conversation_id, + direction, message_type, content, + template_id, template_name, template_variables, + is_billable, cost_category, status + ) VALUES ( + v_tenant_id, p_account_id, p_contact_id, v_conversation_id, + 'outbound', 'template', v_template.body_text, + p_template_id, v_template.name, p_variables, + TRUE, v_template.category, 'pending' + ) RETURNING id INTO v_message_id; + + -- Actualizar uso del template + UPDATE whatsapp.templates + SET usage_count = usage_count + 1, last_used_at = CURRENT_TIMESTAMP + WHERE id = p_template_id; + + -- Actualizar contacto + UPDATE whatsapp.contacts + SET + last_message_at = CURRENT_TIMESTAMP, + last_message_direction = 'outbound', + total_messages_sent = total_messages_sent + 1, + updated_at = CURRENT_TIMESTAMP + WHERE id = p_contact_id; + + -- Actualizar cuenta + UPDATE whatsapp.accounts + SET + total_messages_sent = total_messages_sent + 1, + messages_sent_today = messages_sent_today + 1, + updated_at = CURRENT_TIMESTAMP + WHERE id = p_account_id; + + RETURN v_message_id; +END; +$$ LANGUAGE plpgsql; + +-- Función para actualizar estado de mensaje +CREATE OR REPLACE FUNCTION whatsapp.update_message_status( + p_wa_message_id VARCHAR(100), + p_status VARCHAR(20), + p_meta_timestamp TIMESTAMPTZ DEFAULT NULL, + p_error_code VARCHAR(20) DEFAULT NULL, + p_error_message TEXT DEFAULT NULL +) +RETURNS BOOLEAN AS $$ +DECLARE + v_message RECORD; +BEGIN + SELECT * INTO v_message + FROM whatsapp.messages + WHERE wa_message_id = p_wa_message_id; + + IF NOT FOUND THEN + RETURN FALSE; + END IF; + + -- Registrar actualización de estado + INSERT INTO whatsapp.message_status_updates ( + message_id, status, previous_status, error_code, error_message, meta_timestamp + ) VALUES ( + v_message.id, p_status, v_message.status, p_error_code, p_error_message, p_meta_timestamp + ); + + -- Actualizar mensaje + UPDATE whatsapp.messages + SET + status = p_status, + status_updated_at = CURRENT_TIMESTAMP, + sent_at = CASE WHEN p_status = 'sent' AND sent_at IS NULL THEN COALESCE(p_meta_timestamp, CURRENT_TIMESTAMP) ELSE sent_at END, + delivered_at = CASE WHEN p_status = 'delivered' THEN COALESCE(p_meta_timestamp, CURRENT_TIMESTAMP) ELSE delivered_at END, + read_at = CASE WHEN p_status = 'read' THEN COALESCE(p_meta_timestamp, CURRENT_TIMESTAMP) ELSE read_at END, + error_code = COALESCE(p_error_code, error_code), + error_message = COALESCE(p_error_message, error_message) + WHERE id = v_message.id; + + RETURN TRUE; +END; +$$ LANGUAGE plpgsql; + +-- Función para obtener estadísticas de cuenta +CREATE OR REPLACE FUNCTION whatsapp.get_account_stats( + p_account_id UUID, + p_days INTEGER DEFAULT 30 +) +RETURNS TABLE ( + total_sent BIGINT, + total_received BIGINT, + total_delivered BIGINT, + total_read BIGINT, + total_failed BIGINT, + delivery_rate DECIMAL, + read_rate DECIMAL, + active_contacts BIGINT, + messages_by_day JSONB +) AS $$ +BEGIN + RETURN QUERY + SELECT + COUNT(*) FILTER (WHERE m.direction = 'outbound')::BIGINT as total_sent, + COUNT(*) FILTER (WHERE m.direction = 'inbound')::BIGINT as total_received, + COUNT(*) FILTER (WHERE m.status = 'delivered')::BIGINT as total_delivered, + COUNT(*) FILTER (WHERE m.status = 'read')::BIGINT as total_read, + COUNT(*) FILTER (WHERE m.status = 'failed')::BIGINT as total_failed, + ROUND( + COUNT(*) FILTER (WHERE m.status IN ('delivered', 'read'))::DECIMAL / + NULLIF(COUNT(*) FILTER (WHERE m.direction = 'outbound'), 0) * 100, 2 + ) as delivery_rate, + ROUND( + COUNT(*) FILTER (WHERE m.status = 'read')::DECIMAL / + NULLIF(COUNT(*) FILTER (WHERE m.status IN ('delivered', 'read')), 0) * 100, 2 + ) as read_rate, + COUNT(DISTINCT m.contact_id)::BIGINT as active_contacts, + jsonb_object_agg( + DATE(m.created_at)::TEXT, + day_count + ) as messages_by_day + FROM whatsapp.messages m + LEFT JOIN ( + SELECT DATE(created_at) as day, COUNT(*) as day_count + FROM whatsapp.messages + WHERE account_id = p_account_id + AND created_at > CURRENT_TIMESTAMP - (p_days || ' days')::INTERVAL + GROUP BY DATE(created_at) + ) days ON TRUE + WHERE m.account_id = p_account_id + AND m.created_at > CURRENT_TIMESTAMP - (p_days || ' days')::INTERVAL; +END; +$$ LANGUAGE plpgsql STABLE; + +-- Función para resetear límite diario +CREATE OR REPLACE FUNCTION whatsapp.reset_daily_limits() +RETURNS INTEGER AS $$ +DECLARE + updated_count INTEGER; +BEGIN + UPDATE whatsapp.accounts + SET + messages_sent_today = 0, + last_limit_reset = CURRENT_TIMESTAMP + WHERE DATE(last_limit_reset) < CURRENT_DATE + OR last_limit_reset IS NULL; + + GET DIAGNOSTICS updated_count = ROW_COUNT; + RETURN updated_count; +END; +$$ LANGUAGE plpgsql; + +-- ===================== +-- TRIGGERS +-- ===================== + +CREATE OR REPLACE FUNCTION whatsapp.update_timestamp() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_wa_accounts_updated_at + BEFORE UPDATE ON whatsapp.accounts + FOR EACH ROW EXECUTE FUNCTION whatsapp.update_timestamp(); + +CREATE TRIGGER trg_wa_templates_updated_at + BEFORE UPDATE ON whatsapp.templates + FOR EACH ROW EXECUTE FUNCTION whatsapp.update_timestamp(); + +CREATE TRIGGER trg_wa_contacts_updated_at + BEFORE UPDATE ON whatsapp.contacts + FOR EACH ROW EXECUTE FUNCTION whatsapp.update_timestamp(); + +CREATE TRIGGER trg_wa_convos_updated_at + BEFORE UPDATE ON whatsapp.conversations + FOR EACH ROW EXECUTE FUNCTION whatsapp.update_timestamp(); + +CREATE TRIGGER trg_wa_quick_updated_at + BEFORE UPDATE ON whatsapp.quick_replies + FOR EACH ROW EXECUTE FUNCTION whatsapp.update_timestamp(); + +CREATE TRIGGER trg_wa_auto_updated_at + BEFORE UPDATE ON whatsapp.automations + FOR EACH ROW EXECUTE FUNCTION whatsapp.update_timestamp(); + +CREATE TRIGGER trg_wa_bcast_updated_at + BEFORE UPDATE ON whatsapp.broadcasts + FOR EACH ROW EXECUTE FUNCTION whatsapp.update_timestamp(); + +-- ===================== +-- COMENTARIOS +-- ===================== +COMMENT ON TABLE whatsapp.accounts IS 'Cuentas de WhatsApp Business configuradas por tenant'; +COMMENT ON TABLE whatsapp.templates IS 'Templates de mensaje aprobados por Meta'; +COMMENT ON TABLE whatsapp.contacts IS 'Contactos de WhatsApp con historial de interacción'; +COMMENT ON TABLE whatsapp.conversations IS 'Conversaciones (hilos de chat) con contactos'; +COMMENT ON TABLE whatsapp.messages IS 'Mensajes enviados y recibidos'; +COMMENT ON TABLE whatsapp.message_status_updates IS 'Historial de cambios de estado de mensajes'; +COMMENT ON TABLE whatsapp.quick_replies IS 'Respuestas rápidas predefinidas'; +COMMENT ON TABLE whatsapp.automations IS 'Reglas de automatización de respuestas'; +COMMENT ON TABLE whatsapp.broadcasts IS 'Envíos masivos de mensajes'; +COMMENT ON TABLE whatsapp.broadcast_recipients IS 'Destinatarios de broadcasts'; + +COMMENT ON FUNCTION whatsapp.receive_message IS 'Registra un mensaje entrante de WhatsApp'; +COMMENT ON FUNCTION whatsapp.send_text_message IS 'Envía un mensaje de texto'; +COMMENT ON FUNCTION whatsapp.send_template_message IS 'Envía un mensaje de template'; +COMMENT ON FUNCTION whatsapp.update_message_status IS 'Actualiza el estado de un mensaje desde webhook'; diff --git a/ddl/16-partners.sql b/ddl/16-partners.sql new file mode 100644 index 0000000..711c368 --- /dev/null +++ b/ddl/16-partners.sql @@ -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'; diff --git a/ddl/17-products.sql b/ddl/17-products.sql new file mode 100644 index 0000000..ef9703a --- /dev/null +++ b/ddl/17-products.sql @@ -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'; diff --git a/ddl/18-warehouses.sql b/ddl/18-warehouses.sql new file mode 100644 index 0000000..46096d8 --- /dev/null +++ b/ddl/18-warehouses.sql @@ -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'; diff --git a/ddl/21-inventory.sql b/ddl/21-inventory.sql new file mode 100644 index 0000000..4ed53c8 --- /dev/null +++ b/ddl/21-inventory.sql @@ -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'; diff --git a/ddl/22-sales.sql b/ddl/22-sales.sql new file mode 100644 index 0000000..5c61964 --- /dev/null +++ b/ddl/22-sales.sql @@ -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'; diff --git a/ddl/23-purchases.sql b/ddl/23-purchases.sql new file mode 100644 index 0000000..aac06a8 --- /dev/null +++ b/ddl/23-purchases.sql @@ -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'; diff --git a/ddl/24-invoices.sql b/ddl/24-invoices.sql new file mode 100644 index 0000000..e583f91 --- /dev/null +++ b/ddl/24-invoices.sql @@ -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'; diff --git a/ddl/schemas/core_shared/00-schema.sql b/ddl/schemas/core_shared/00-schema.sql deleted file mode 100644 index a23a5f7..0000000 --- a/ddl/schemas/core_shared/00-schema.sql +++ /dev/null @@ -1,159 +0,0 @@ --- ============================================================================ --- Schema: core_shared --- Descripcion: Funciones y tipos compartidos entre todos los modulos --- Proyecto: ERP Core --- Autor: Database-Agent --- Fecha: 2025-12-06 --- ============================================================================ - --- Crear schema -CREATE SCHEMA IF NOT EXISTS core_shared; - -COMMENT ON SCHEMA core_shared IS 'Funciones, tipos y utilidades compartidas entre modulos'; - --- ============================================================================ --- FUNCIONES DE AUDITORIA --- ============================================================================ - --- Funcion para actualizar updated_at automaticamente -CREATE OR REPLACE FUNCTION core_shared.set_updated_at() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = NOW(); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION core_shared.set_updated_at() IS -'Trigger function para actualizar automaticamente el campo updated_at en cada UPDATE'; - --- Funcion para establecer tenant_id desde contexto -CREATE OR REPLACE FUNCTION core_shared.set_tenant_id() -RETURNS TRIGGER AS $$ -BEGIN - IF NEW.tenant_id IS NULL THEN - NEW.tenant_id = current_setting('app.current_tenant_id', true)::uuid; - END IF; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION core_shared.set_tenant_id() IS -'Trigger function para establecer tenant_id automaticamente desde el contexto de sesion'; - --- Funcion para establecer created_by desde contexto -CREATE OR REPLACE FUNCTION core_shared.set_created_by() -RETURNS TRIGGER AS $$ -BEGIN - IF NEW.created_by IS NULL THEN - NEW.created_by = current_setting('app.current_user_id', true)::uuid; - END IF; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION core_shared.set_created_by() IS -'Trigger function para establecer created_by automaticamente desde el contexto de sesion'; - --- Funcion para establecer updated_by desde contexto -CREATE OR REPLACE FUNCTION core_shared.set_updated_by() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_by = current_setting('app.current_user_id', true)::uuid; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION core_shared.set_updated_by() IS -'Trigger function para establecer updated_by automaticamente desde el contexto de sesion'; - --- ============================================================================ --- FUNCIONES DE CONTEXTO --- ============================================================================ - --- Obtener tenant_id actual del contexto -CREATE OR REPLACE FUNCTION core_shared.get_current_tenant_id() -RETURNS UUID AS $$ -BEGIN - RETURN NULLIF(current_setting('app.current_tenant_id', true), '')::UUID; -EXCEPTION - WHEN OTHERS THEN - RETURN NULL; -END; -$$ LANGUAGE plpgsql STABLE; - -COMMENT ON FUNCTION core_shared.get_current_tenant_id() IS -'Obtiene el ID del tenant actual desde el contexto de sesion'; - --- Obtener user_id actual del contexto -CREATE OR REPLACE FUNCTION core_shared.get_current_user_id() -RETURNS UUID AS $$ -BEGIN - RETURN NULLIF(current_setting('app.current_user_id', true), '')::UUID; -EXCEPTION - WHEN OTHERS THEN - RETURN NULL; -END; -$$ LANGUAGE plpgsql STABLE; - -COMMENT ON FUNCTION core_shared.get_current_user_id() IS -'Obtiene el ID del usuario actual desde el contexto de sesion'; - --- ============================================================================ --- FUNCIONES DE UTILIDAD --- ============================================================================ - --- Generar slug desde texto -CREATE OR REPLACE FUNCTION core_shared.generate_slug(input_text TEXT) -RETURNS TEXT AS $$ -BEGIN - RETURN LOWER( - REGEXP_REPLACE( - REGEXP_REPLACE( - TRIM(input_text), - '[^a-zA-Z0-9\s-]', '', 'g' - ), - '\s+', '-', 'g' - ) - ); -END; -$$ LANGUAGE plpgsql IMMUTABLE; - -COMMENT ON FUNCTION core_shared.generate_slug(TEXT) IS -'Genera un slug URL-friendly desde un texto'; - --- Validar formato de email -CREATE OR REPLACE FUNCTION core_shared.is_valid_email(email TEXT) -RETURNS BOOLEAN AS $$ -BEGIN - RETURN email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'; -END; -$$ LANGUAGE plpgsql IMMUTABLE; - -COMMENT ON FUNCTION core_shared.is_valid_email(TEXT) IS -'Valida si un texto tiene formato de email valido'; - --- Validar formato de RFC mexicano -CREATE OR REPLACE FUNCTION core_shared.is_valid_rfc(rfc TEXT) -RETURNS BOOLEAN AS $$ -BEGIN - -- RFC persona moral: 3 letras + 6 digitos + 3 caracteres - -- RFC persona fisica: 4 letras + 6 digitos + 3 caracteres - RETURN rfc ~* '^[A-Z&Ñ]{3,4}[0-9]{6}[A-Z0-9]{3}$'; -END; -$$ LANGUAGE plpgsql IMMUTABLE; - -COMMENT ON FUNCTION core_shared.is_valid_rfc(TEXT) IS -'Valida si un texto tiene formato de RFC mexicano valido'; - --- ============================================================================ --- GRANT PERMISOS --- ============================================================================ - --- Permitir uso del schema a todos los roles de la aplicacion -GRANT USAGE ON SCHEMA core_shared TO PUBLIC; -GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA core_shared TO PUBLIC; - --- ============================================================================ --- FIN --- ============================================================================ diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index b9e89cc..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,51 +0,0 @@ -version: '3.8' - -services: - postgres: - image: postgres:15-alpine - container_name: erp-generic-db - restart: unless-stopped - environment: - POSTGRES_DB: ${POSTGRES_DB:-erp_generic} - POSTGRES_USER: ${POSTGRES_USER:-erp_admin} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-erp_secret_2024} - PGDATA: /var/lib/postgresql/data/pgdata - ports: - - "${POSTGRES_PORT:-5432}:5432" - volumes: - - postgres_data:/var/lib/postgresql/data - - ./ddl:/docker-entrypoint-initdb.d:ro - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-erp_admin} -d ${POSTGRES_DB:-erp_generic}"] - interval: 10s - timeout: 5s - retries: 5 - - # Optional: pgAdmin for database management - pgadmin: - image: dpage/pgadmin4:latest - container_name: erp-generic-pgadmin - restart: unless-stopped - environment: - PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL:-admin@erp-generic.local} - PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD:-admin123} - PGADMIN_CONFIG_SERVER_MODE: 'False' - ports: - - "${PGADMIN_PORT:-5050}:80" - volumes: - - pgadmin_data:/var/lib/pgadmin - depends_on: - postgres: - condition: service_healthy - profiles: - - tools - -volumes: - postgres_data: - driver: local - pgadmin_data: - driver: local - -networks: - default: - name: erp-generic-network diff --git a/migrations/20251212_001_fiscal_period_validation.sql b/migrations/20251212_001_fiscal_period_validation.sql deleted file mode 100644 index 48841be..0000000 --- a/migrations/20251212_001_fiscal_period_validation.sql +++ /dev/null @@ -1,207 +0,0 @@ --- ============================================================================ --- MIGRACIÓN: Validación de Período Fiscal Cerrado --- Fecha: 2025-12-12 --- Descripción: Agrega trigger para prevenir asientos en períodos cerrados --- Impacto: Todas las verticales que usan el módulo financiero --- Rollback: DROP TRIGGER y DROP FUNCTION incluidos al final --- ============================================================================ - --- ============================================================================ --- 1. FUNCIÓN DE VALIDACIÓN --- ============================================================================ - -CREATE OR REPLACE FUNCTION financial.validate_period_not_closed() -RETURNS TRIGGER AS $$ -DECLARE - v_period_status TEXT; - v_period_name TEXT; -BEGIN - -- Solo validar si hay un fiscal_period_id - IF NEW.fiscal_period_id IS NULL THEN - RETURN NEW; - END IF; - - -- Obtener el estado del período - SELECT fp.status, fp.name INTO v_period_status, v_period_name - FROM financial.fiscal_periods fp - WHERE fp.id = NEW.fiscal_period_id; - - -- Validar que el período no esté cerrado - IF v_period_status = 'closed' THEN - RAISE EXCEPTION 'ERR_PERIOD_CLOSED: No se pueden crear o modificar asientos en el período cerrado: %', v_period_name - USING ERRCODE = 'P0001'; - END IF; - - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION financial.validate_period_not_closed() IS -'Valida que no se creen asientos contables en períodos fiscales cerrados. -Lanza excepción ERR_PERIOD_CLOSED si el período está cerrado.'; - --- ============================================================================ --- 2. TRIGGER EN JOURNAL_ENTRIES --- ============================================================================ - --- Eliminar trigger si existe (idempotente) -DROP TRIGGER IF EXISTS trg_validate_period_before_entry ON financial.journal_entries; - --- Crear trigger BEFORE INSERT OR UPDATE -CREATE TRIGGER trg_validate_period_before_entry - BEFORE INSERT OR UPDATE ON financial.journal_entries - FOR EACH ROW - EXECUTE FUNCTION financial.validate_period_not_closed(); - -COMMENT ON TRIGGER trg_validate_period_before_entry ON financial.journal_entries IS -'Previene la creación o modificación de asientos en períodos fiscales cerrados'; - --- ============================================================================ --- 3. FUNCIÓN PARA CERRAR PERÍODO --- ============================================================================ - -CREATE OR REPLACE FUNCTION financial.close_fiscal_period( - p_period_id UUID, - p_user_id UUID -) -RETURNS financial.fiscal_periods AS $$ -DECLARE - v_period financial.fiscal_periods; - v_unposted_count INTEGER; -BEGIN - -- Obtener período - SELECT * INTO v_period - FROM financial.fiscal_periods - WHERE id = p_period_id - FOR UPDATE; - - IF NOT FOUND THEN - RAISE EXCEPTION 'Período fiscal no encontrado' USING ERRCODE = 'P0002'; - END IF; - - IF v_period.status = 'closed' THEN - RAISE EXCEPTION 'El período ya está cerrado' USING ERRCODE = 'P0003'; - END IF; - - -- Verificar que no haya asientos sin postear - SELECT COUNT(*) INTO v_unposted_count - FROM financial.journal_entries je - WHERE je.fiscal_period_id = p_period_id - AND je.status = 'draft'; - - IF v_unposted_count > 0 THEN - RAISE EXCEPTION 'Existen % asientos sin postear en este período. Postéelos antes de cerrar.', - v_unposted_count USING ERRCODE = 'P0004'; - END IF; - - -- Cerrar el período - UPDATE financial.fiscal_periods - SET status = 'closed', - closed_at = NOW(), - closed_by = p_user_id, - updated_at = NOW() - WHERE id = p_period_id - RETURNING * INTO v_period; - - RETURN v_period; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION financial.close_fiscal_period(UUID, UUID) IS -'Cierra un período fiscal. Valida que todos los asientos estén posteados.'; - --- ============================================================================ --- 4. FUNCIÓN PARA REABRIR PERÍODO (Solo admins) --- ============================================================================ - -CREATE OR REPLACE FUNCTION financial.reopen_fiscal_period( - p_period_id UUID, - p_user_id UUID, - p_reason TEXT DEFAULT NULL -) -RETURNS financial.fiscal_periods AS $$ -DECLARE - v_period financial.fiscal_periods; -BEGIN - -- Obtener período - SELECT * INTO v_period - FROM financial.fiscal_periods - WHERE id = p_period_id - FOR UPDATE; - - IF NOT FOUND THEN - RAISE EXCEPTION 'Período fiscal no encontrado' USING ERRCODE = 'P0002'; - END IF; - - IF v_period.status = 'open' THEN - RAISE EXCEPTION 'El período ya está abierto' USING ERRCODE = 'P0005'; - END IF; - - -- Reabrir el período - UPDATE financial.fiscal_periods - SET status = 'open', - closed_at = NULL, - closed_by = NULL, - updated_at = NOW() - WHERE id = p_period_id - RETURNING * INTO v_period; - - -- Registrar en log de auditoría - INSERT INTO system.logs ( - tenant_id, level, module, message, context, user_id - ) - SELECT - v_period.tenant_id, - 'warning', - 'financial', - 'Período fiscal reabierto', - jsonb_build_object( - 'period_id', p_period_id, - 'period_name', v_period.name, - 'reason', p_reason, - 'reopened_by', p_user_id - ), - p_user_id; - - RETURN v_period; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION financial.reopen_fiscal_period(UUID, UUID, TEXT) IS -'Reabre un período fiscal cerrado. Registra en auditoría. Solo para administradores.'; - --- ============================================================================ --- 5. ÍNDICE PARA PERFORMANCE --- ============================================================================ - -CREATE INDEX IF NOT EXISTS idx_journal_entries_fiscal_period - ON financial.journal_entries(fiscal_period_id) - WHERE fiscal_period_id IS NOT NULL; - --- ============================================================================ --- ROLLBACK SCRIPT (ejecutar si es necesario revertir) --- ============================================================================ -/* -DROP TRIGGER IF EXISTS trg_validate_period_before_entry ON financial.journal_entries; -DROP FUNCTION IF EXISTS financial.validate_period_not_closed(); -DROP FUNCTION IF EXISTS financial.close_fiscal_period(UUID, UUID); -DROP FUNCTION IF EXISTS financial.reopen_fiscal_period(UUID, UUID, TEXT); -DROP INDEX IF EXISTS financial.idx_journal_entries_fiscal_period; -*/ - --- ============================================================================ --- VERIFICACIÓN --- ============================================================================ - -DO $$ -BEGIN - -- Verificar que el trigger existe - IF NOT EXISTS ( - SELECT 1 FROM pg_trigger - WHERE tgname = 'trg_validate_period_before_entry' - ) THEN - RAISE EXCEPTION 'Error: Trigger no fue creado correctamente'; - END IF; - - RAISE NOTICE 'Migración completada exitosamente: Validación de período fiscal'; -END $$; diff --git a/migrations/20251212_002_partner_rankings.sql b/migrations/20251212_002_partner_rankings.sql deleted file mode 100644 index 7f0cbe5..0000000 --- a/migrations/20251212_002_partner_rankings.sql +++ /dev/null @@ -1,391 +0,0 @@ --- ============================================================================ --- MIGRACIÓN: Sistema de Ranking de Partners (Clientes/Proveedores) --- Fecha: 2025-12-12 --- Descripción: Crea tablas y funciones para clasificación ABC de partners --- Impacto: Verticales que usan módulo de partners/ventas/compras --- Rollback: DROP TABLE y DROP FUNCTION incluidos al final --- ============================================================================ - --- ============================================================================ --- 1. TABLA DE RANKINGS POR PERÍODO --- ============================================================================ - -CREATE TABLE IF NOT EXISTS core.partner_rankings ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - partner_id UUID NOT NULL REFERENCES core.partners(id) ON DELETE CASCADE, - company_id UUID REFERENCES auth.companies(id) ON DELETE SET NULL, - - -- Período de análisis - period_start DATE NOT NULL, - period_end DATE NOT NULL, - - -- Métricas de Cliente - total_sales DECIMAL(16,2) DEFAULT 0, - sales_order_count INTEGER DEFAULT 0, - avg_order_value DECIMAL(16,2) DEFAULT 0, - - -- Métricas de Proveedor - total_purchases DECIMAL(16,2) DEFAULT 0, - purchase_order_count INTEGER DEFAULT 0, - avg_purchase_value DECIMAL(16,2) DEFAULT 0, - - -- Métricas de Pago - avg_payment_days INTEGER, - on_time_payment_rate DECIMAL(5,2), -- Porcentaje 0-100 - - -- Rankings (posición relativa dentro del período) - sales_rank INTEGER, - purchase_rank INTEGER, - - -- Clasificación ABC - customer_abc CHAR(1) CHECK (customer_abc IN ('A', 'B', 'C', NULL)), - supplier_abc CHAR(1) CHECK (supplier_abc IN ('A', 'B', 'C', NULL)), - - -- Scores calculados (0-100) - customer_score DECIMAL(5,2) CHECK (customer_score IS NULL OR customer_score BETWEEN 0 AND 100), - supplier_score DECIMAL(5,2) CHECK (supplier_score IS NULL OR supplier_score BETWEEN 0 AND 100), - overall_score DECIMAL(5,2) CHECK (overall_score IS NULL OR overall_score BETWEEN 0 AND 100), - - -- Tendencia vs período anterior - sales_trend DECIMAL(5,2), -- % cambio - purchase_trend DECIMAL(5,2), - - -- Metadatos - calculated_at TIMESTAMPTZ DEFAULT NOW(), - created_at TIMESTAMPTZ DEFAULT NOW(), - - -- Constraints - UNIQUE(tenant_id, partner_id, company_id, period_start, period_end), - CHECK (period_end >= period_start) -); - --- ============================================================================ --- 2. CAMPOS DESNORMALIZADOS EN PARTNERS (para consultas rápidas) --- ============================================================================ - -DO $$ -BEGIN - -- Agregar columnas si no existen - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_schema = 'core' AND table_name = 'partners' - AND column_name = 'customer_rank') THEN - ALTER TABLE core.partners ADD COLUMN customer_rank INTEGER; - END IF; - - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_schema = 'core' AND table_name = 'partners' - AND column_name = 'supplier_rank') THEN - ALTER TABLE core.partners ADD COLUMN supplier_rank INTEGER; - END IF; - - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_schema = 'core' AND table_name = 'partners' - AND column_name = 'customer_abc') THEN - ALTER TABLE core.partners ADD COLUMN customer_abc CHAR(1); - END IF; - - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_schema = 'core' AND table_name = 'partners' - AND column_name = 'supplier_abc') THEN - ALTER TABLE core.partners ADD COLUMN supplier_abc CHAR(1); - END IF; - - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_schema = 'core' AND table_name = 'partners' - AND column_name = 'last_ranking_date') THEN - ALTER TABLE core.partners ADD COLUMN last_ranking_date DATE; - END IF; - - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_schema = 'core' AND table_name = 'partners' - AND column_name = 'total_sales_ytd') THEN - ALTER TABLE core.partners ADD COLUMN total_sales_ytd DECIMAL(16,2) DEFAULT 0; - END IF; - - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_schema = 'core' AND table_name = 'partners' - AND column_name = 'total_purchases_ytd') THEN - ALTER TABLE core.partners ADD COLUMN total_purchases_ytd DECIMAL(16,2) DEFAULT 0; - END IF; -END $$; - --- ============================================================================ --- 3. ÍNDICES --- ============================================================================ - -CREATE INDEX IF NOT EXISTS idx_partner_rankings_tenant_period - ON core.partner_rankings(tenant_id, period_start, period_end); - -CREATE INDEX IF NOT EXISTS idx_partner_rankings_partner - ON core.partner_rankings(partner_id); - -CREATE INDEX IF NOT EXISTS idx_partner_rankings_abc - ON core.partner_rankings(tenant_id, customer_abc) - WHERE customer_abc IS NOT NULL; - -CREATE INDEX IF NOT EXISTS idx_partners_customer_rank - ON core.partners(tenant_id, customer_rank) - WHERE customer_rank IS NOT NULL; - -CREATE INDEX IF NOT EXISTS idx_partners_supplier_rank - ON core.partners(tenant_id, supplier_rank) - WHERE supplier_rank IS NOT NULL; - --- ============================================================================ --- 4. RLS (Row Level Security) --- ============================================================================ - -ALTER TABLE core.partner_rankings ENABLE ROW LEVEL SECURITY; - -DROP POLICY IF EXISTS partner_rankings_tenant_isolation ON core.partner_rankings; -CREATE POLICY partner_rankings_tenant_isolation ON core.partner_rankings - USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); - --- ============================================================================ --- 5. FUNCIÓN: Calcular rankings de partners --- ============================================================================ - -CREATE OR REPLACE FUNCTION core.calculate_partner_rankings( - p_tenant_id UUID, - p_company_id UUID DEFAULT NULL, - p_period_start DATE DEFAULT (CURRENT_DATE - INTERVAL '1 year')::date, - p_period_end DATE DEFAULT CURRENT_DATE -) -RETURNS TABLE ( - partners_processed INTEGER, - customers_ranked INTEGER, - suppliers_ranked INTEGER -) AS $$ -DECLARE - v_partners_processed INTEGER := 0; - v_customers_ranked INTEGER := 0; - v_suppliers_ranked INTEGER := 0; -BEGIN - -- 1. Calcular métricas de ventas por partner - INSERT INTO core.partner_rankings ( - tenant_id, partner_id, company_id, period_start, period_end, - total_sales, sales_order_count, avg_order_value - ) - SELECT - p_tenant_id, - so.partner_id, - COALESCE(p_company_id, so.company_id), - p_period_start, - p_period_end, - COALESCE(SUM(so.amount_total), 0), - COUNT(*), - COALESCE(AVG(so.amount_total), 0) - FROM sales.sales_orders so - WHERE so.tenant_id = p_tenant_id - AND so.status IN ('sale', 'done') - AND so.order_date BETWEEN p_period_start AND p_period_end - AND (p_company_id IS NULL OR so.company_id = p_company_id) - GROUP BY so.partner_id, so.company_id - ON CONFLICT (tenant_id, partner_id, company_id, period_start, period_end) - DO UPDATE SET - total_sales = EXCLUDED.total_sales, - sales_order_count = EXCLUDED.sales_order_count, - avg_order_value = EXCLUDED.avg_order_value, - calculated_at = NOW(); - - GET DIAGNOSTICS v_customers_ranked = ROW_COUNT; - - -- 2. Calcular métricas de compras por partner - INSERT INTO core.partner_rankings ( - tenant_id, partner_id, company_id, period_start, period_end, - total_purchases, purchase_order_count, avg_purchase_value - ) - SELECT - p_tenant_id, - po.partner_id, - COALESCE(p_company_id, po.company_id), - p_period_start, - p_period_end, - COALESCE(SUM(po.amount_total), 0), - COUNT(*), - COALESCE(AVG(po.amount_total), 0) - FROM purchase.purchase_orders po - WHERE po.tenant_id = p_tenant_id - AND po.status IN ('confirmed', 'done') - AND po.order_date BETWEEN p_period_start AND p_period_end - AND (p_company_id IS NULL OR po.company_id = p_company_id) - GROUP BY po.partner_id, po.company_id - ON CONFLICT (tenant_id, partner_id, company_id, period_start, period_end) - DO UPDATE SET - total_purchases = EXCLUDED.total_purchases, - purchase_order_count = EXCLUDED.purchase_order_count, - avg_purchase_value = EXCLUDED.avg_purchase_value, - calculated_at = NOW(); - - GET DIAGNOSTICS v_suppliers_ranked = ROW_COUNT; - - -- 3. Calcular rankings de clientes (por total de ventas) - WITH ranked AS ( - SELECT - id, - ROW_NUMBER() OVER (ORDER BY total_sales DESC) as rank, - total_sales, - SUM(total_sales) OVER () as grand_total, - SUM(total_sales) OVER (ORDER BY total_sales DESC) as cumulative_total - FROM core.partner_rankings - WHERE tenant_id = p_tenant_id - AND period_start = p_period_start - AND period_end = p_period_end - AND total_sales > 0 - ) - UPDATE core.partner_rankings pr - SET - sales_rank = r.rank, - customer_abc = CASE - WHEN r.cumulative_total / NULLIF(r.grand_total, 0) <= 0.80 THEN 'A' - WHEN r.cumulative_total / NULLIF(r.grand_total, 0) <= 0.95 THEN 'B' - ELSE 'C' - END, - customer_score = CASE - WHEN r.rank = 1 THEN 100 - ELSE GREATEST(0, 100 - (r.rank - 1) * 5) - END - FROM ranked r - WHERE pr.id = r.id; - - -- 4. Calcular rankings de proveedores (por total de compras) - WITH ranked AS ( - SELECT - id, - ROW_NUMBER() OVER (ORDER BY total_purchases DESC) as rank, - total_purchases, - SUM(total_purchases) OVER () as grand_total, - SUM(total_purchases) OVER (ORDER BY total_purchases DESC) as cumulative_total - FROM core.partner_rankings - WHERE tenant_id = p_tenant_id - AND period_start = p_period_start - AND period_end = p_period_end - AND total_purchases > 0 - ) - UPDATE core.partner_rankings pr - SET - purchase_rank = r.rank, - supplier_abc = CASE - WHEN r.cumulative_total / NULLIF(r.grand_total, 0) <= 0.80 THEN 'A' - WHEN r.cumulative_total / NULLIF(r.grand_total, 0) <= 0.95 THEN 'B' - ELSE 'C' - END, - supplier_score = CASE - WHEN r.rank = 1 THEN 100 - ELSE GREATEST(0, 100 - (r.rank - 1) * 5) - END - FROM ranked r - WHERE pr.id = r.id; - - -- 5. Calcular score overall - UPDATE core.partner_rankings - SET overall_score = COALESCE( - (COALESCE(customer_score, 0) + COALESCE(supplier_score, 0)) / - NULLIF( - CASE WHEN customer_score IS NOT NULL THEN 1 ELSE 0 END + - CASE WHEN supplier_score IS NOT NULL THEN 1 ELSE 0 END, - 0 - ), - 0 - ) - WHERE tenant_id = p_tenant_id - AND period_start = p_period_start - AND period_end = p_period_end; - - -- 6. Actualizar campos desnormalizados en partners - UPDATE core.partners p - SET - customer_rank = pr.sales_rank, - supplier_rank = pr.purchase_rank, - customer_abc = pr.customer_abc, - supplier_abc = pr.supplier_abc, - total_sales_ytd = pr.total_sales, - total_purchases_ytd = pr.total_purchases, - last_ranking_date = CURRENT_DATE - FROM core.partner_rankings pr - WHERE p.id = pr.partner_id - AND p.tenant_id = p_tenant_id - AND pr.tenant_id = p_tenant_id - AND pr.period_start = p_period_start - AND pr.period_end = p_period_end; - - GET DIAGNOSTICS v_partners_processed = ROW_COUNT; - - RETURN QUERY SELECT v_partners_processed, v_customers_ranked, v_suppliers_ranked; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION core.calculate_partner_rankings IS -'Calcula rankings ABC de partners basado en ventas/compras. -Parámetros: - - p_tenant_id: Tenant obligatorio - - p_company_id: Opcional, filtrar por empresa - - p_period_start: Inicio del período (default: hace 1 año) - - p_period_end: Fin del período (default: hoy)'; - --- ============================================================================ --- 6. VISTA: Top Partners --- ============================================================================ - -CREATE OR REPLACE VIEW core.top_partners_view AS -SELECT - p.id, - p.tenant_id, - p.name, - p.email, - p.is_customer, - p.is_supplier, - p.customer_rank, - p.supplier_rank, - p.customer_abc, - p.supplier_abc, - p.total_sales_ytd, - p.total_purchases_ytd, - p.last_ranking_date, - CASE - WHEN p.customer_abc = 'A' THEN 'Cliente VIP' - WHEN p.customer_abc = 'B' THEN 'Cliente Regular' - WHEN p.customer_abc = 'C' THEN 'Cliente Ocasional' - ELSE NULL - END as customer_category, - CASE - WHEN p.supplier_abc = 'A' THEN 'Proveedor Estratégico' - WHEN p.supplier_abc = 'B' THEN 'Proveedor Regular' - WHEN p.supplier_abc = 'C' THEN 'Proveedor Ocasional' - ELSE NULL - END as supplier_category -FROM core.partners p -WHERE p.deleted_at IS NULL - AND (p.customer_rank IS NOT NULL OR p.supplier_rank IS NOT NULL); - --- ============================================================================ --- ROLLBACK SCRIPT --- ============================================================================ -/* -DROP VIEW IF EXISTS core.top_partners_view; -DROP FUNCTION IF EXISTS core.calculate_partner_rankings(UUID, UUID, DATE, DATE); -DROP TABLE IF EXISTS core.partner_rankings; - -ALTER TABLE core.partners - DROP COLUMN IF EXISTS customer_rank, - DROP COLUMN IF EXISTS supplier_rank, - DROP COLUMN IF EXISTS customer_abc, - DROP COLUMN IF EXISTS supplier_abc, - DROP COLUMN IF EXISTS last_ranking_date, - DROP COLUMN IF EXISTS total_sales_ytd, - DROP COLUMN IF EXISTS total_purchases_ytd; -*/ - --- ============================================================================ --- VERIFICACIÓN --- ============================================================================ - -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'core' AND tablename = 'partner_rankings') THEN - RAISE EXCEPTION 'Error: Tabla partner_rankings no fue creada'; - END IF; - - RAISE NOTICE 'Migración completada exitosamente: Partner Rankings'; -END $$; diff --git a/migrations/20251212_003_financial_reports.sql b/migrations/20251212_003_financial_reports.sql deleted file mode 100644 index 7203e8f..0000000 --- a/migrations/20251212_003_financial_reports.sql +++ /dev/null @@ -1,464 +0,0 @@ --- ============================================================================ --- MIGRACIÓN: Sistema de Reportes Financieros --- Fecha: 2025-12-12 --- Descripción: Crea tablas para definición, ejecución y programación de reportes --- Impacto: Módulo financiero y verticales que requieren reportes contables --- Rollback: DROP TABLE y DROP FUNCTION incluidos al final --- ============================================================================ - --- ============================================================================ --- 1. TABLA DE DEFINICIONES DE REPORTES --- ============================================================================ - -CREATE TABLE IF NOT EXISTS reports.report_definitions ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - - -- Identificación - code VARCHAR(50) NOT NULL, - name VARCHAR(255) NOT NULL, - description TEXT, - - -- Clasificación - report_type VARCHAR(50) NOT NULL DEFAULT 'financial', - -- financial, accounting, tax, management, custom - category VARCHAR(100), - -- balance_sheet, income_statement, cash_flow, trial_balance, ledger, etc. - - -- Configuración de consulta - base_query TEXT, -- SQL base o referencia a función - query_function VARCHAR(255), -- Nombre de función PostgreSQL si usa función - - -- Parámetros requeridos (JSON Schema) - parameters_schema JSONB DEFAULT '{}', - -- Ejemplo: {"date_from": {"type": "date", "required": true}, "company_id": {"type": "uuid"}} - - -- Configuración de columnas - columns_config JSONB DEFAULT '[]', - -- Ejemplo: [{"name": "account", "label": "Cuenta", "type": "string"}, {"name": "debit", "label": "Debe", "type": "currency"}] - - -- Agrupaciones disponibles - grouping_options JSONB DEFAULT '[]', - -- Ejemplo: ["account_type", "company", "period"] - - -- Configuración de totales - totals_config JSONB DEFAULT '{}', - -- Ejemplo: {"show_totals": true, "total_columns": ["debit", "credit", "balance"]} - - -- Plantillas de exportación - export_formats JSONB DEFAULT '["pdf", "xlsx", "csv"]', - pdf_template VARCHAR(255), -- Referencia a plantilla PDF - xlsx_template VARCHAR(255), - - -- Estado y visibilidad - is_system BOOLEAN DEFAULT false, -- Reportes del sistema vs personalizados - is_active BOOLEAN DEFAULT true, - - -- Permisos requeridos - required_permissions JSONB DEFAULT '[]', - -- Ejemplo: ["financial.reports.view", "financial.reports.balance_sheet"] - - -- Metadata - version INTEGER DEFAULT 1, - created_at TIMESTAMPTZ DEFAULT NOW(), - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMPTZ DEFAULT NOW(), - updated_by UUID REFERENCES auth.users(id), - - -- Constraints - UNIQUE(tenant_id, code) -); - -COMMENT ON TABLE reports.report_definitions IS -'Definiciones de reportes disponibles en el sistema. Incluye reportes predefinidos y personalizados.'; - --- ============================================================================ --- 2. TABLA DE EJECUCIONES DE REPORTES --- ============================================================================ - -CREATE TABLE IF NOT EXISTS reports.report_executions ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - definition_id UUID NOT NULL REFERENCES reports.report_definitions(id) ON DELETE CASCADE, - - -- Parámetros de ejecución - parameters JSONB NOT NULL DEFAULT '{}', - -- Los valores específicos usados para esta ejecución - - -- Estado de ejecución - status VARCHAR(20) NOT NULL DEFAULT 'pending', - -- pending, running, completed, failed, cancelled - - -- Tiempos - started_at TIMESTAMPTZ, - completed_at TIMESTAMPTZ, - execution_time_ms INTEGER, - - -- Resultados - row_count INTEGER, - result_data JSONB, -- Datos del reporte (puede ser grande) - result_summary JSONB, -- Resumen/totales - - -- Archivos generados - output_files JSONB DEFAULT '[]', - -- Ejemplo: [{"format": "pdf", "path": "/reports/...", "size": 12345}] - - -- Errores - error_message TEXT, - error_details JSONB, - - -- Metadata - requested_by UUID NOT NULL REFERENCES auth.users(id), - created_at TIMESTAMPTZ DEFAULT NOW() -); - -COMMENT ON TABLE reports.report_executions IS -'Historial de ejecuciones de reportes con sus resultados y archivos generados.'; - --- ============================================================================ --- 3. TABLA DE PROGRAMACIÓN DE REPORTES --- ============================================================================ - -CREATE TABLE IF NOT EXISTS reports.report_schedules ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - definition_id UUID NOT NULL REFERENCES reports.report_definitions(id) ON DELETE CASCADE, - company_id UUID REFERENCES auth.companies(id) ON DELETE CASCADE, - - -- Nombre del schedule - name VARCHAR(255) NOT NULL, - - -- Parámetros predeterminados - default_parameters JSONB DEFAULT '{}', - - -- Programación (cron expression) - cron_expression VARCHAR(100) NOT NULL, - -- Ejemplo: "0 8 1 * *" (primer día del mes a las 8am) - - timezone VARCHAR(50) DEFAULT 'America/Mexico_City', - - -- Estado - is_active BOOLEAN DEFAULT true, - - -- Última ejecución - last_execution_id UUID REFERENCES reports.report_executions(id), - last_run_at TIMESTAMPTZ, - next_run_at TIMESTAMPTZ, - - -- Destino de entrega - delivery_method VARCHAR(50) DEFAULT 'none', - -- none, email, storage, webhook - delivery_config JSONB DEFAULT '{}', - -- Para email: {"recipients": ["a@b.com"], "subject": "...", "format": "pdf"} - -- Para storage: {"path": "/reports/scheduled/", "retention_days": 30} - - -- Metadata - created_at TIMESTAMPTZ DEFAULT NOW(), - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMPTZ DEFAULT NOW(), - updated_by UUID REFERENCES auth.users(id) -); - -COMMENT ON TABLE reports.report_schedules IS -'Programación automática de reportes con opciones de entrega.'; - --- ============================================================================ --- 4. TABLA DE PLANTILLAS DE REPORTES --- ============================================================================ - -CREATE TABLE IF NOT EXISTS reports.report_templates ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, - - -- Identificación - code VARCHAR(50) NOT NULL, - name VARCHAR(255) NOT NULL, - - -- Tipo de plantilla - template_type VARCHAR(20) NOT NULL, - -- pdf, xlsx, html - - -- Contenido de la plantilla - template_content BYTEA, -- Para plantillas binarias (XLSX) - template_html TEXT, -- Para plantillas HTML/PDF - - -- Estilos CSS (para PDF/HTML) - styles TEXT, - - -- Variables disponibles - available_variables JSONB DEFAULT '[]', - - -- Estado - is_active BOOLEAN DEFAULT true, - - -- Metadata - created_at TIMESTAMPTZ DEFAULT NOW(), - created_by UUID REFERENCES auth.users(id), - updated_at TIMESTAMPTZ DEFAULT NOW(), - - UNIQUE(tenant_id, code) -); - -COMMENT ON TABLE reports.report_templates IS -'Plantillas personalizables para la generación de reportes en diferentes formatos.'; - --- ============================================================================ --- 5. ÍNDICES --- ============================================================================ - -CREATE INDEX IF NOT EXISTS idx_report_definitions_tenant_type - ON reports.report_definitions(tenant_id, report_type); - -CREATE INDEX IF NOT EXISTS idx_report_definitions_tenant_category - ON reports.report_definitions(tenant_id, category); - -CREATE INDEX IF NOT EXISTS idx_report_executions_tenant_status - ON reports.report_executions(tenant_id, status); - -CREATE INDEX IF NOT EXISTS idx_report_executions_definition - ON reports.report_executions(definition_id); - -CREATE INDEX IF NOT EXISTS idx_report_executions_created - ON reports.report_executions(tenant_id, created_at DESC); - -CREATE INDEX IF NOT EXISTS idx_report_schedules_next_run - ON reports.report_schedules(next_run_at) - WHERE is_active = true; - --- ============================================================================ --- 6. RLS (Row Level Security) --- ============================================================================ - -ALTER TABLE reports.report_definitions ENABLE ROW LEVEL SECURITY; -ALTER TABLE reports.report_executions ENABLE ROW LEVEL SECURITY; -ALTER TABLE reports.report_schedules ENABLE ROW LEVEL SECURITY; -ALTER TABLE reports.report_templates ENABLE ROW LEVEL SECURITY; - --- Políticas para report_definitions -DROP POLICY IF EXISTS report_definitions_tenant_isolation ON reports.report_definitions; -CREATE POLICY report_definitions_tenant_isolation ON reports.report_definitions - USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); - --- Políticas para report_executions -DROP POLICY IF EXISTS report_executions_tenant_isolation ON reports.report_executions; -CREATE POLICY report_executions_tenant_isolation ON reports.report_executions - USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); - --- Políticas para report_schedules -DROP POLICY IF EXISTS report_schedules_tenant_isolation ON reports.report_schedules; -CREATE POLICY report_schedules_tenant_isolation ON reports.report_schedules - USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); - --- Políticas para report_templates -DROP POLICY IF EXISTS report_templates_tenant_isolation ON reports.report_templates; -CREATE POLICY report_templates_tenant_isolation ON reports.report_templates - USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); - --- ============================================================================ --- 7. FUNCIONES DE REPORTES PREDEFINIDOS --- ============================================================================ - --- Balance de Comprobación -CREATE OR REPLACE FUNCTION reports.generate_trial_balance( - p_tenant_id UUID, - p_company_id UUID, - p_date_from DATE, - p_date_to DATE, - p_include_zero_balance BOOLEAN DEFAULT false -) -RETURNS TABLE ( - account_id UUID, - account_code VARCHAR(20), - account_name VARCHAR(255), - account_type VARCHAR(50), - initial_debit DECIMAL(16,2), - initial_credit DECIMAL(16,2), - period_debit DECIMAL(16,2), - period_credit DECIMAL(16,2), - final_debit DECIMAL(16,2), - final_credit DECIMAL(16,2) -) AS $$ -BEGIN - RETURN QUERY - WITH account_balances AS ( - -- Saldos iniciales (antes del período) - SELECT - a.id as account_id, - a.code as account_code, - a.name as account_name, - a.account_type, - COALESCE(SUM(CASE WHEN jel.transaction_date < p_date_from THEN jel.debit ELSE 0 END), 0) as initial_debit, - COALESCE(SUM(CASE WHEN jel.transaction_date < p_date_from THEN jel.credit ELSE 0 END), 0) as initial_credit, - COALESCE(SUM(CASE WHEN jel.transaction_date BETWEEN p_date_from AND p_date_to THEN jel.debit ELSE 0 END), 0) as period_debit, - COALESCE(SUM(CASE WHEN jel.transaction_date BETWEEN p_date_from AND p_date_to THEN jel.credit ELSE 0 END), 0) as period_credit - FROM financial.accounts a - LEFT JOIN financial.journal_entry_lines jel ON a.id = jel.account_id - LEFT JOIN financial.journal_entries je ON jel.journal_entry_id = je.id AND je.status = 'posted' - WHERE a.tenant_id = p_tenant_id - AND (p_company_id IS NULL OR a.company_id = p_company_id) - AND a.is_active = true - GROUP BY a.id, a.code, a.name, a.account_type - ) - SELECT - ab.account_id, - ab.account_code, - ab.account_name, - ab.account_type, - ab.initial_debit, - ab.initial_credit, - ab.period_debit, - ab.period_credit, - ab.initial_debit + ab.period_debit as final_debit, - ab.initial_credit + ab.period_credit as final_credit - FROM account_balances ab - WHERE p_include_zero_balance = true - OR (ab.initial_debit + ab.period_debit) != 0 - OR (ab.initial_credit + ab.period_credit) != 0 - ORDER BY ab.account_code; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION reports.generate_trial_balance IS -'Genera el balance de comprobación para un período específico.'; - --- Libro Mayor -CREATE OR REPLACE FUNCTION reports.generate_general_ledger( - p_tenant_id UUID, - p_company_id UUID, - p_account_id UUID, - p_date_from DATE, - p_date_to DATE -) -RETURNS TABLE ( - entry_date DATE, - journal_entry_id UUID, - entry_number VARCHAR(50), - description TEXT, - partner_name VARCHAR(255), - debit DECIMAL(16,2), - credit DECIMAL(16,2), - running_balance DECIMAL(16,2) -) AS $$ -BEGIN - RETURN QUERY - WITH movements AS ( - SELECT - je.entry_date, - je.id as journal_entry_id, - je.entry_number, - je.description, - p.name as partner_name, - jel.debit, - jel.credit, - ROW_NUMBER() OVER (ORDER BY je.entry_date, je.id) as rn - FROM financial.journal_entry_lines jel - JOIN financial.journal_entries je ON jel.journal_entry_id = je.id - LEFT JOIN core.partners p ON je.partner_id = p.id - WHERE jel.account_id = p_account_id - AND jel.tenant_id = p_tenant_id - AND je.status = 'posted' - AND je.entry_date BETWEEN p_date_from AND p_date_to - AND (p_company_id IS NULL OR je.company_id = p_company_id) - ORDER BY je.entry_date, je.id - ) - SELECT - m.entry_date, - m.journal_entry_id, - m.entry_number, - m.description, - m.partner_name, - m.debit, - m.credit, - SUM(m.debit - m.credit) OVER (ORDER BY m.rn) as running_balance - FROM movements m; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION reports.generate_general_ledger IS -'Genera el libro mayor para una cuenta específica.'; - --- ============================================================================ --- 8. DATOS SEMILLA: REPORTES PREDEFINIDOS DEL SISTEMA --- ============================================================================ - --- Nota: Los reportes del sistema se insertan con is_system = true --- y se insertan solo si no existen (usando ON CONFLICT) - -DO $$ -DECLARE - v_system_tenant_id UUID; -BEGIN - -- Obtener el tenant del sistema (si existe) - SELECT id INTO v_system_tenant_id - FROM auth.tenants - WHERE code = 'system' OR is_system = true - LIMIT 1; - - -- Solo insertar si hay un tenant sistema - IF v_system_tenant_id IS NOT NULL THEN - -- Balance de Comprobación - INSERT INTO reports.report_definitions ( - tenant_id, code, name, description, report_type, category, - query_function, parameters_schema, columns_config, is_system - ) VALUES ( - v_system_tenant_id, - 'TRIAL_BALANCE', - 'Balance de Comprobación', - 'Reporte de balance de comprobación con saldos iniciales, movimientos y saldos finales', - 'financial', - 'trial_balance', - 'reports.generate_trial_balance', - '{"company_id": {"type": "uuid", "required": false}, "date_from": {"type": "date", "required": true}, "date_to": {"type": "date", "required": true}, "include_zero": {"type": "boolean", "default": false}}', - '[{"name": "account_code", "label": "Código", "type": "string"}, {"name": "account_name", "label": "Cuenta", "type": "string"}, {"name": "initial_debit", "label": "Debe Inicial", "type": "currency"}, {"name": "initial_credit", "label": "Haber Inicial", "type": "currency"}, {"name": "period_debit", "label": "Debe Período", "type": "currency"}, {"name": "period_credit", "label": "Haber Período", "type": "currency"}, {"name": "final_debit", "label": "Debe Final", "type": "currency"}, {"name": "final_credit", "label": "Haber Final", "type": "currency"}]', - true - ) ON CONFLICT (tenant_id, code) DO NOTHING; - - -- Libro Mayor - INSERT INTO reports.report_definitions ( - tenant_id, code, name, description, report_type, category, - query_function, parameters_schema, columns_config, is_system - ) VALUES ( - v_system_tenant_id, - 'GENERAL_LEDGER', - 'Libro Mayor', - 'Detalle de movimientos por cuenta con saldo acumulado', - 'financial', - 'ledger', - 'reports.generate_general_ledger', - '{"company_id": {"type": "uuid", "required": false}, "account_id": {"type": "uuid", "required": true}, "date_from": {"type": "date", "required": true}, "date_to": {"type": "date", "required": true}}', - '[{"name": "entry_date", "label": "Fecha", "type": "date"}, {"name": "entry_number", "label": "Número", "type": "string"}, {"name": "description", "label": "Descripción", "type": "string"}, {"name": "partner_name", "label": "Tercero", "type": "string"}, {"name": "debit", "label": "Debe", "type": "currency"}, {"name": "credit", "label": "Haber", "type": "currency"}, {"name": "running_balance", "label": "Saldo", "type": "currency"}]', - true - ) ON CONFLICT (tenant_id, code) DO NOTHING; - - RAISE NOTICE 'Reportes del sistema insertados correctamente'; - END IF; -END $$; - --- ============================================================================ --- ROLLBACK SCRIPT --- ============================================================================ -/* -DROP FUNCTION IF EXISTS reports.generate_general_ledger(UUID, UUID, UUID, DATE, DATE); -DROP FUNCTION IF EXISTS reports.generate_trial_balance(UUID, UUID, DATE, DATE, BOOLEAN); -DROP TABLE IF EXISTS reports.report_templates; -DROP TABLE IF EXISTS reports.report_schedules; -DROP TABLE IF EXISTS reports.report_executions; -DROP TABLE IF EXISTS reports.report_definitions; -*/ - --- ============================================================================ --- VERIFICACIÓN --- ============================================================================ - -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'reports' AND tablename = 'report_definitions') THEN - RAISE EXCEPTION 'Error: Tabla report_definitions no fue creada'; - END IF; - - IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'reports' AND tablename = 'report_executions') THEN - RAISE EXCEPTION 'Error: Tabla report_executions no fue creada'; - END IF; - - RAISE NOTICE 'Migración completada exitosamente: Reportes Financieros'; -END $$; diff --git a/migrations/20260110_001_add_tenant_user_fields.sql b/migrations/20260110_001_add_tenant_user_fields.sql new file mode 100644 index 0000000..56daf46 --- /dev/null +++ b/migrations/20260110_001_add_tenant_user_fields.sql @@ -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; +*/ diff --git a/scripts/create-database.sh b/scripts/create-database.sh index ca1e08a..64402d0 100755 --- a/scripts/create-database.sh +++ b/scripts/create-database.sh @@ -77,7 +77,9 @@ 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" @@ -86,9 +88,19 @@ DDL_FILES=( "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[@]} diff --git a/scripts/create-test-database.sh b/scripts/create-test-database.sh new file mode 100755 index 0000000..230848e --- /dev/null +++ b/scripts/create-test-database.sh @@ -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=" +echo "" diff --git a/scripts/drop-database.sh b/scripts/drop-database.sh deleted file mode 100755 index 2ea8c6d..0000000 --- a/scripts/drop-database.sh +++ /dev/null @@ -1,75 +0,0 @@ -#!/bin/bash -# ============================================================================ -# ERP GENERIC - DROP DATABASE SCRIPT -# ============================================================================ -# Description: Drops the ERP Generic database -# Usage: ./scripts/drop-database.sh [--force] -# ============================================================================ - -set -e - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' - -# Script directory -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -DATABASE_DIR="$(dirname "$SCRIPT_DIR")" - -# Load environment variables -if [ -f "$DATABASE_DIR/.env" ]; then - source "$DATABASE_DIR/.env" -elif [ -f "$DATABASE_DIR/.env.example" ]; then - 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" - -# Check for --force flag -FORCE=false -if [ "$1" == "--force" ]; then - FORCE=true -fi - -echo -e "${RED}============================================${NC}" -echo -e "${RED} ERP GENERIC - DROP DATABASE${NC}" -echo -e "${RED}============================================${NC}" -echo "" -echo -e "Database: ${YELLOW}$POSTGRES_DB${NC}" -echo "" - -if [ "$FORCE" != true ]; then - echo -e "${RED}WARNING: This will permanently delete all data!${NC}" - read -p "Are you sure you want to drop the database? (y/N): " confirm - if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then - echo "Aborted." - exit 0 - fi -fi - -echo -e "${BLUE}Terminating active connections...${NC}" -$PSQL_CMD -d postgres -c " -SELECT pg_terminate_backend(pg_stat_activity.pid) -FROM pg_stat_activity -WHERE pg_stat_activity.datname = '$POSTGRES_DB' - AND pid <> pg_backend_pid(); -" 2>/dev/null || true - -echo -e "${BLUE}Dropping database...${NC}" -$PSQL_CMD -d postgres -c "DROP DATABASE IF EXISTS $POSTGRES_DB;" - -echo "" -echo -e "${GREEN}Database '$POSTGRES_DB' has been dropped.${NC}" -echo "" diff --git a/scripts/load-seeds.sh b/scripts/load-seeds.sh deleted file mode 100755 index 6cfbfd3..0000000 --- a/scripts/load-seeds.sh +++ /dev/null @@ -1,101 +0,0 @@ -#!/bin/bash -# ============================================================================ -# ERP GENERIC - LOAD SEEDS SCRIPT -# ============================================================================ -# Description: Loads seed data for the specified environment -# Usage: ./scripts/load-seeds.sh [dev|prod] -# ============================================================================ - -set -e - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' - -# Script directory -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -DATABASE_DIR="$(dirname "$SCRIPT_DIR")" -SEEDS_DIR="$DATABASE_DIR/seeds" - -# Environment (default: dev) -ENV="${1:-dev}" - -# Load environment variables -if [ -f "$DATABASE_DIR/.env" ]; then - source "$DATABASE_DIR/.env" -elif [ -f "$DATABASE_DIR/.env.example" ]; then - 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 -d $POSTGRES_DB" - -echo -e "${BLUE}============================================${NC}" -echo -e "${BLUE} ERP GENERIC - LOAD SEED DATA${NC}" -echo -e "${BLUE}============================================${NC}" -echo "" -echo -e "Environment: ${GREEN}$ENV${NC}" -echo -e "Database: ${GREEN}$POSTGRES_DB${NC}" -echo "" - -# Check if seeds directory exists -SEED_ENV_DIR="$SEEDS_DIR/$ENV" -if [ ! -d "$SEED_ENV_DIR" ]; then - echo -e "${RED}Error: Seeds directory not found: $SEED_ENV_DIR${NC}" - echo "Available environments:" - ls -1 "$SEEDS_DIR" 2>/dev/null || echo " (none)" - exit 1 -fi - -# Check if there are SQL files -SEED_FILES=($(find "$SEED_ENV_DIR" -name "*.sql" -type f | sort)) - -if [ ${#SEED_FILES[@]} -eq 0 ]; then - echo -e "${YELLOW}No seed files found in $SEED_ENV_DIR${NC}" - echo "Create seed files with format: XX-description.sql" - exit 0 -fi - -echo -e "${BLUE}Loading ${#SEED_FILES[@]} seed file(s)...${NC}" -echo "" - -TOTAL=${#SEED_FILES[@]} -CURRENT=0 -FAILED=0 - -for seed_file in "${SEED_FILES[@]}"; do - CURRENT=$((CURRENT + 1)) - filename=$(basename "$seed_file") - - echo -e " [${CURRENT}/${TOTAL}] Loading ${YELLOW}$filename${NC}..." - - if $PSQL_CMD -f "$seed_file" > /dev/null 2>&1; then - echo -e " [${CURRENT}/${TOTAL}] ${GREEN}$filename loaded successfully${NC}" - else - echo -e " [${CURRENT}/${TOTAL}] ${RED}Error loading $filename${NC}" - FAILED=$((FAILED + 1)) - fi -done - -echo "" -if [ $FAILED -eq 0 ]; then - echo -e "${GREEN}============================================${NC}" - echo -e "${GREEN} ALL SEEDS LOADED SUCCESSFULLY!${NC}" - echo -e "${GREEN}============================================${NC}" -else - echo -e "${YELLOW}============================================${NC}" - echo -e "${YELLOW} SEEDS LOADED WITH $FAILED ERRORS${NC}" - echo -e "${YELLOW}============================================${NC}" -fi -echo "" diff --git a/scripts/recreate-database.sh b/scripts/recreate-database.sh new file mode 100755 index 0000000..7a181d8 --- /dev/null +++ b/scripts/recreate-database.sh @@ -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 "$@" diff --git a/scripts/reset-database.sh b/scripts/reset-database.sh deleted file mode 100755 index c27ccd8..0000000 --- a/scripts/reset-database.sh +++ /dev/null @@ -1,102 +0,0 @@ -#!/bin/bash -# ============================================================================ -# ERP GENERIC - RESET DATABASE SCRIPT -# ============================================================================ -# Description: Drops and recreates the database with fresh data -# Usage: ./scripts/reset-database.sh [--no-seeds] [--env dev|prod] [--force] -# -# Por defecto: -# - Carga DDL completo -# - Carga seeds de desarrollo (dev) -# - Pide confirmación -# -# Opciones: -# --no-seeds No cargar seeds después del DDL -# --env ENV Ambiente de seeds: dev (default) o prod -# --force No pedir confirmación (para CI/CD) -# ============================================================================ - -set -e - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' - -# Script directory -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -# Defaults - Seeds activados por defecto -WITH_SEEDS=true -ENV="dev" -FORCE=false - -while [[ $# -gt 0 ]]; do - case $1 in - --no-seeds) - WITH_SEEDS=false - shift - ;; - --env) - ENV="$2" - shift 2 - ;; - --force) - FORCE=true - shift - ;; - *) - shift - ;; - esac -done - -echo -e "${YELLOW}============================================${NC}" -echo -e "${YELLOW} ERP GENERIC - RESET DATABASE${NC}" -echo -e "${YELLOW}============================================${NC}" -echo "" -echo -e "Ambiente: ${GREEN}$ENV${NC}" -echo -e "Seeds: ${GREEN}$WITH_SEEDS${NC}" -echo "" -echo -e "${RED}WARNING: This will DELETE all data and recreate the database!${NC}" -echo "" - -if [ "$FORCE" = false ]; then - read -p "Are you sure you want to reset? (y/N): " confirm - if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then - echo "Aborted." - exit 0 - fi -fi - -# Drop database -echo "" -echo -e "${BLUE}Step 1: Dropping database...${NC}" -"$SCRIPT_DIR/drop-database.sh" --force - -# Create database (DDL) -echo "" -echo -e "${BLUE}Step 2: Creating database (DDL)...${NC}" -"$SCRIPT_DIR/create-database.sh" - -# Load seeds (por defecto) -if [ "$WITH_SEEDS" = true ]; then - echo "" - echo -e "${BLUE}Step 3: Loading seed data ($ENV)...${NC}" - "$SCRIPT_DIR/load-seeds.sh" "$ENV" -else - echo "" - echo -e "${YELLOW}Step 3: Skipping seeds (--no-seeds)${NC}" -fi - -echo "" -echo -e "${GREEN}============================================${NC}" -echo -e "${GREEN} DATABASE RESET COMPLETE!${NC}" -echo -e "${GREEN}============================================${NC}" -echo "" -echo -e "Resumen:" -echo -e " - DDL ejecutados: ${GREEN}15 archivos${NC}" -echo -e " - Seeds cargados: ${GREEN}$WITH_SEEDS ($ENV)${NC}" -echo "" diff --git a/seeds/dev/00-catalogs.sql b/seeds/dev/00-catalogs.sql deleted file mode 100644 index 37b7d2d..0000000 --- a/seeds/dev/00-catalogs.sql +++ /dev/null @@ -1,81 +0,0 @@ --- ============================================================================ --- ERP GENERIC - SEED DATA: CATALOGS (Development) --- ============================================================================ --- Description: Base catalogs needed before other seeds (currencies, countries, UOMs) --- Order: Must be loaded FIRST (before tenants, companies, etc.) --- ============================================================================ - --- =========================================== --- CURRENCIES (ISO 4217) --- =========================================== - -INSERT INTO core.currencies (id, code, name, symbol, decimals, rounding, active) -VALUES - ('00000000-0000-0000-0000-000000000001', 'MXN', 'Peso Mexicano', '$', 2, 0.01, true), - ('00000000-0000-0000-0000-000000000002', 'USD', 'US Dollar', '$', 2, 0.01, true), - ('00000000-0000-0000-0000-000000000003', 'EUR', 'Euro', '€', 2, 0.01, true) -ON CONFLICT (code) DO NOTHING; - --- =========================================== --- COUNTRIES (ISO 3166-1 alpha-2) --- =========================================== - -INSERT INTO core.countries (id, code, name, phone_code, currency_code) -VALUES - ('00000000-0000-0000-0001-000000000001', 'MX', 'México', '+52', 'MXN'), - ('00000000-0000-0000-0001-000000000002', 'US', 'United States', '+1', 'USD'), - ('00000000-0000-0000-0001-000000000003', 'CA', 'Canada', '+1', 'CAD'), - ('00000000-0000-0000-0001-000000000004', 'ES', 'España', '+34', 'EUR'), - ('00000000-0000-0000-0001-000000000005', 'DE', 'Alemania', '+49', 'EUR') -ON CONFLICT (code) DO NOTHING; - --- =========================================== --- UOM CATEGORIES --- =========================================== - -INSERT INTO core.uom_categories (id, name, description) -VALUES - ('00000000-0000-0000-0002-000000000001', 'Unit', 'Unidades individuales'), - ('00000000-0000-0000-0002-000000000002', 'Weight', 'Unidades de peso'), - ('00000000-0000-0000-0002-000000000003', 'Volume', 'Unidades de volumen'), - ('00000000-0000-0000-0002-000000000004', 'Length', 'Unidades de longitud'), - ('00000000-0000-0000-0002-000000000005', 'Time', 'Unidades de tiempo') -ON CONFLICT (name) DO NOTHING; - --- =========================================== --- UNITS OF MEASURE --- =========================================== - -INSERT INTO core.uom (id, category_id, name, code, uom_type, factor, rounding, active) -VALUES - -- Units - ('00000000-0000-0000-0003-000000000001', '00000000-0000-0000-0002-000000000001', 'Unit', 'UNIT', 'reference', 1.0, 1, true), - ('00000000-0000-0000-0003-000000000002', '00000000-0000-0000-0002-000000000001', 'Dozen', 'DOZ', 'bigger', 12.0, 1, true), - ('00000000-0000-0000-0003-000000000003', '00000000-0000-0000-0002-000000000001', 'Sheet', 'SHEET', 'reference', 1.0, 1, true), - -- Weight - ('00000000-0000-0000-0003-000000000010', '00000000-0000-0000-0002-000000000002', 'Kilogram', 'KG', 'reference', 1.0, 0.001, true), - ('00000000-0000-0000-0003-000000000011', '00000000-0000-0000-0002-000000000002', 'Gram', 'G', 'smaller', 0.001, 0.01, true), - ('00000000-0000-0000-0003-000000000012', '00000000-0000-0000-0002-000000000002', 'Pound', 'LB', 'bigger', 0.453592, 0.01, true), - -- Volume - ('00000000-0000-0000-0003-000000000020', '00000000-0000-0000-0002-000000000003', 'Liter', 'L', 'reference', 1.0, 0.001, true), - ('00000000-0000-0000-0003-000000000021', '00000000-0000-0000-0002-000000000003', 'Milliliter', 'ML', 'smaller', 0.001, 1, true), - ('00000000-0000-0000-0003-000000000022', '00000000-0000-0000-0002-000000000003', 'Gallon', 'GAL', 'bigger', 3.78541, 0.01, true), - -- Length - ('00000000-0000-0000-0003-000000000030', '00000000-0000-0000-0002-000000000004', 'Meter', 'M', 'reference', 1.0, 0.001, true), - ('00000000-0000-0000-0003-000000000031', '00000000-0000-0000-0002-000000000004', 'Centimeter', 'CM', 'smaller', 0.01, 0.1, true), - ('00000000-0000-0000-0003-000000000032', '00000000-0000-0000-0002-000000000004', 'Inch', 'IN', 'smaller', 0.0254, 0.1, true), - -- Time - ('00000000-0000-0000-0003-000000000040', '00000000-0000-0000-0002-000000000005', 'Hour', 'HOUR', 'reference', 1.0, 0.01, true), - ('00000000-0000-0000-0003-000000000041', '00000000-0000-0000-0002-000000000005', 'Day', 'DAY', 'bigger', 8.0, 0.01, true), - ('00000000-0000-0000-0003-000000000042', '00000000-0000-0000-0002-000000000005', 'Minute', 'MIN', 'smaller', 0.016667, 1, true) -ON CONFLICT (id) DO NOTHING; - --- Output confirmation -DO $$ -BEGIN - RAISE NOTICE 'Catalogs seed data loaded:'; - RAISE NOTICE ' - 3 currencies (MXN, USD, EUR)'; - RAISE NOTICE ' - 5 countries'; - RAISE NOTICE ' - 5 UOM categories'; - RAISE NOTICE ' - 15 units of measure'; -END $$; diff --git a/seeds/dev/01-seed-tenants.sql b/seeds/dev/01-seed-tenants.sql new file mode 100644 index 0000000..dcca511 --- /dev/null +++ b/seeds/dev/01-seed-tenants.sql @@ -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'; diff --git a/seeds/dev/01-tenants.sql b/seeds/dev/01-tenants.sql deleted file mode 100644 index 1def231..0000000 --- a/seeds/dev/01-tenants.sql +++ /dev/null @@ -1,49 +0,0 @@ --- ============================================================================ --- ERP GENERIC - SEED DATA: TENANTS (Development) --- ============================================================================ --- Description: Initial tenants for development environment --- ============================================================================ - --- Default tenant for development -INSERT INTO auth.tenants (id, name, subdomain, schema_name, status, settings, plan, max_users, created_at) -VALUES ( - '1c7dfbb0-19b8-4e87-a225-a74da6f26dbf', - 'Demo Company', - 'demo', - 'tenant_demo', - 'active', - jsonb_build_object( - 'locale', 'es_MX', - 'timezone', 'America/Mexico_City', - 'currency', 'MXN', - 'date_format', 'DD/MM/YYYY' - ), - 'pro', - 50, - CURRENT_TIMESTAMP -) ON CONFLICT (id) DO NOTHING; - --- Second tenant for multi-tenancy testing -INSERT INTO auth.tenants (id, name, subdomain, schema_name, status, settings, plan, max_users, created_at) -VALUES ( - '204c4748-09b2-4a98-bb5a-183ec263f205', - 'Test Corporation', - 'test-corp', - 'tenant_test_corp', - 'active', - jsonb_build_object( - 'locale', 'en_US', - 'timezone', 'America/New_York', - 'currency', 'USD', - 'date_format', 'MM/DD/YYYY' - ), - 'basic', - 10, - CURRENT_TIMESTAMP -) ON CONFLICT (id) DO NOTHING; - --- Output confirmation -DO $$ -BEGIN - RAISE NOTICE 'Tenants seed data loaded: 2 tenants created'; -END $$; diff --git a/seeds/dev/02-companies.sql b/seeds/dev/02-companies.sql deleted file mode 100644 index 2eb0010..0000000 --- a/seeds/dev/02-companies.sql +++ /dev/null @@ -1,64 +0,0 @@ --- ============================================================================ --- ERP GENERIC - SEED DATA: COMPANIES (Development) --- ============================================================================ --- Description: Initial companies for development environment --- ============================================================================ - --- Default company for Demo tenant -INSERT INTO auth.companies (id, tenant_id, name, legal_name, tax_id, currency_id, settings, created_at) -VALUES ( - '50fa9b29-504f-4c45-8f8a-3d129cfc6095', - '1c7dfbb0-19b8-4e87-a225-a74da6f26dbf', - 'Demo Company S.A. de C.V.', - 'Demo Company Sociedad Anónima de Capital Variable', - 'DCO123456ABC', - (SELECT id FROM core.currencies WHERE code = 'MXN' LIMIT 1), - jsonb_build_object( - 'fiscal_position', 'general', - 'tax_regime', '601', - 'email', 'contacto@demo-company.mx', - 'phone', '+52 55 1234 5678', - 'website', 'https://demo-company.mx' - ), - CURRENT_TIMESTAMP -) ON CONFLICT (id) DO NOTHING; - --- Second company (subsidiary) for Demo tenant -INSERT INTO auth.companies (id, tenant_id, parent_company_id, name, legal_name, tax_id, currency_id, settings, created_at) -VALUES ( - 'e347be2e-483e-4ab5-8d73-5ed454e304c6', - '1c7dfbb0-19b8-4e87-a225-a74da6f26dbf', - '50fa9b29-504f-4c45-8f8a-3d129cfc6095', - 'Demo Subsidiary', - 'Demo Subsidiary S. de R.L.', - 'DSU789012DEF', - (SELECT id FROM core.currencies WHERE code = 'MXN' LIMIT 1), - jsonb_build_object( - 'email', 'subsidiary@demo-company.mx', - 'phone', '+52 55 8765 4321' - ), - CURRENT_TIMESTAMP -) ON CONFLICT (id) DO NOTHING; - --- Company for Test Corp tenant -INSERT INTO auth.companies (id, tenant_id, name, legal_name, tax_id, currency_id, settings, created_at) -VALUES ( - '2f24ea46-7828-4125-add2-3f12644d796f', - '204c4748-09b2-4a98-bb5a-183ec263f205', - 'Test Corporation Inc.', - 'Test Corporation Incorporated', - '12-3456789', - (SELECT id FROM core.currencies WHERE code = 'USD' LIMIT 1), - jsonb_build_object( - 'email', 'info@test-corp.com', - 'phone', '+1 555 123 4567', - 'website', 'https://test-corp.com' - ), - CURRENT_TIMESTAMP -) ON CONFLICT (id) DO NOTHING; - --- Output confirmation -DO $$ -BEGIN - RAISE NOTICE 'Companies seed data loaded: 3 companies created'; -END $$; diff --git a/seeds/dev/02-seed-branches.sql b/seeds/dev/02-seed-branches.sql new file mode 100644 index 0000000..7959583 --- /dev/null +++ b/seeds/dev/02-seed-branches.sql @@ -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'; diff --git a/seeds/dev/03-roles.sql b/seeds/dev/03-roles.sql deleted file mode 100644 index 599952f..0000000 --- a/seeds/dev/03-roles.sql +++ /dev/null @@ -1,246 +0,0 @@ --- ============================================================================ --- ERP GENERIC - SEED DATA: ROLES (Development) --- ============================================================================ --- Description: Default roles and permissions for development --- ============================================================================ - --- =========================================== --- TENANT-SPECIFIC ROLES (Demo Company) --- =========================================== - --- Super Admin for Demo tenant -INSERT INTO auth.roles (id, tenant_id, name, code, description, is_system, color, created_at) -VALUES ( - '5e29aadd-1d9f-4280-a38b-fefe7cdece5a', - '1c7dfbb0-19b8-4e87-a225-a74da6f26dbf', - 'Super Administrator', - 'super_admin', - 'Full system access. Reserved for system administrators.', - true, - '#FF0000', - CURRENT_TIMESTAMP -) ON CONFLICT (id) DO NOTHING; - --- Admin -INSERT INTO auth.roles (id, tenant_id, name, code, description, is_system, color, created_at) -VALUES ( - 'fed1cfa2-8ea1-4d86-bfef-b3dcc08801c2', - '1c7dfbb0-19b8-4e87-a225-a74da6f26dbf', - 'Administrator', - 'admin', - 'Full access within the tenant. Can manage users, settings, and all modules.', - true, - '#4CAF50', - CURRENT_TIMESTAMP -) ON CONFLICT (id) DO NOTHING; - --- Manager -INSERT INTO auth.roles (id, tenant_id, name, code, description, is_system, color, created_at) -VALUES ( - '1a35fbf0-a282-487d-95ef-13b3f702e8d6', - '1c7dfbb0-19b8-4e87-a225-a74da6f26dbf', - 'Manager', - 'manager', - 'Can manage operations, approve documents, and view reports.', - false, - '#2196F3', - CURRENT_TIMESTAMP -) ON CONFLICT (id) DO NOTHING; - --- Accountant -INSERT INTO auth.roles (id, tenant_id, name, code, description, is_system, color, created_at) -VALUES ( - 'c91f1a60-bd0d-40d3-91b8-36c226ce3d29', - '1c7dfbb0-19b8-4e87-a225-a74da6f26dbf', - 'Accountant', - 'accountant', - 'Access to financial module: journals, invoices, payments, reports.', - false, - '#9C27B0', - CURRENT_TIMESTAMP -) ON CONFLICT (id) DO NOTHING; - --- Sales -INSERT INTO auth.roles (id, tenant_id, name, code, description, is_system, color, created_at) -VALUES ( - '493568ed-972f-472f-9ac1-236a32438936', - '1c7dfbb0-19b8-4e87-a225-a74da6f26dbf', - 'Sales Representative', - 'sales', - 'Access to sales module: quotations, orders, customers.', - false, - '#FF9800', - CURRENT_TIMESTAMP -) ON CONFLICT (id) DO NOTHING; - --- Purchasing -INSERT INTO auth.roles (id, tenant_id, name, code, description, is_system, color, created_at) -VALUES ( - '80515d77-fc15-4a5a-a213-7b9f869db15a', - '1c7dfbb0-19b8-4e87-a225-a74da6f26dbf', - 'Purchasing Agent', - 'purchasing', - 'Access to purchase module: RFQs, purchase orders, vendors.', - false, - '#00BCD4', - CURRENT_TIMESTAMP -) ON CONFLICT (id) DO NOTHING; - --- Warehouse -INSERT INTO auth.roles (id, tenant_id, name, code, description, is_system, color, created_at) -VALUES ( - '0a86a34a-7fd6-47e2-9e0c-4c547c6af9f1', - '1c7dfbb0-19b8-4e87-a225-a74da6f26dbf', - 'Warehouse Operator', - 'warehouse', - 'Access to inventory module: stock moves, pickings, adjustments.', - false, - '#795548', - CURRENT_TIMESTAMP -) ON CONFLICT (id) DO NOTHING; - --- Employee (basic) -INSERT INTO auth.roles (id, tenant_id, name, code, description, is_system, color, created_at) -VALUES ( - '88e299e6-8cda-4fd1-a32f-afc2aa7b8975', - '1c7dfbb0-19b8-4e87-a225-a74da6f26dbf', - 'Employee', - 'employee', - 'Basic access: timesheets, expenses, personal information.', - false, - '#607D8B', - CURRENT_TIMESTAMP -) ON CONFLICT (id) DO NOTHING; - --- =========================================== --- PERMISSIONS (using resource + action pattern) --- =========================================== - -INSERT INTO auth.permissions (id, resource, action, description, module, created_at) -VALUES - -- Users - ('26389d69-6b88-48a5-9ca9-118394d32cd6', 'users', 'read', 'View user list and details', 'auth', CURRENT_TIMESTAMP), - ('be0f398a-7c7f-4bd0-a9b7-fd74cde7e5a0', 'users', 'create', 'Create new users', 'auth', CURRENT_TIMESTAMP), - ('4a584c2f-0485-453c-a93d-8c6df33e18d4', 'users', 'update', 'Edit existing users', 'auth', CURRENT_TIMESTAMP), - ('4650549e-b016-438a-bf4b-5cfcb0e9d3bb', 'users', 'delete', 'Delete users', 'auth', CURRENT_TIMESTAMP), - -- Companies - ('22f7d6c6-c65f-4aa4-b15c-dc6c3efd9baa', 'companies', 'read', 'View companies', 'core', CURRENT_TIMESTAMP), - ('11b94a84-65f2-40f6-b468-748fbc56a30a', 'companies', 'create', 'Create companies', 'core', CURRENT_TIMESTAMP), - ('3f1858a5-4381-4763-b23e-dee57e7cb3cf', 'companies', 'update', 'Edit companies', 'core', CURRENT_TIMESTAMP), - -- Partners - ('abc6a21a-1674-4acf-8155-3a0d5b130586', 'partners', 'read', 'View customers/vendors', 'core', CURRENT_TIMESTAMP), - ('a52fab21-24e0-446e-820f-9288b1468a36', 'partners', 'create', 'Create partners', 'core', CURRENT_TIMESTAMP), - ('bd453537-ba4c-4497-a982-1c923009a399', 'partners', 'update', 'Edit partners', 'core', CURRENT_TIMESTAMP), - -- Financial - Accounting - ('7a22be70-b5f7-446f-a9b9-8d6ba50615cc', 'journal_entries', 'read', 'View journal entries', 'financial', CURRENT_TIMESTAMP), - ('41eb796e-952f-4e34-8811-5adc4967d8ce', 'journal_entries', 'create', 'Create journal entries', 'financial', CURRENT_TIMESTAMP), - ('f5a77c95-f771-4854-8bc3-d1922f63deb7', 'journal_entries', 'approve', 'Approve/post journal entries', 'financial', CURRENT_TIMESTAMP), - -- Financial - Invoices - ('546ce323-7f80-49b1-a11f-76939d2b4289', 'invoices', 'read', 'View invoices', 'financial', CURRENT_TIMESTAMP), - ('139b4ed3-59e7-44d7-b4d9-7a2d02529152', 'invoices', 'create', 'Create invoices', 'financial', CURRENT_TIMESTAMP), - ('dacf3592-a892-4374-82e5-7f10603c107a', 'invoices', 'approve', 'Validate invoices', 'financial', CURRENT_TIMESTAMP), - -- Inventory - ('04481809-1d01-4516-afa2-dcaae8a1b331', 'products', 'read', 'View products', 'inventory', CURRENT_TIMESTAMP), - ('3df9671e-db5a-4a22-b570-9210d3c0a2e3', 'products', 'create', 'Create products', 'inventory', CURRENT_TIMESTAMP), - ('101f7d9f-f50f-4673-94da-d2002e65348b', 'stock_moves', 'read', 'View stock movements', 'inventory', CURRENT_TIMESTAMP), - ('5e5de64d-68b6-46bc-9ec4-d34ca145b1cc', 'stock_moves', 'create', 'Create stock movements', 'inventory', CURRENT_TIMESTAMP), - -- Purchase - ('7c602d68-d1d2-4ba1-b0fd-9d7b70d3f12a', 'purchase_orders', 'read', 'View purchase orders', 'purchase', CURRENT_TIMESTAMP), - ('38cf2a54-60db-4ba5-8a95-fd34d2cba6cf', 'purchase_orders', 'create', 'Create purchase orders', 'purchase', CURRENT_TIMESTAMP), - ('3356eb5b-538e-4bde-a12c-3b7d35ebd657', 'purchase_orders', 'approve', 'Approve purchase orders', 'purchase', CURRENT_TIMESTAMP), - -- Sales - ('ffc586d2-3928-4fc7-bf72-47d52ec5e692', 'sales_orders', 'read', 'View sales orders', 'sales', CURRENT_TIMESTAMP), - ('5d3a2eee-98e7-429f-b907-07452de3fb0e', 'sales_orders', 'create', 'Create sales orders', 'sales', CURRENT_TIMESTAMP), - ('00481e6e-571c-475d-a4a2-81620866ff1a', 'sales_orders', 'approve', 'Confirm sales orders', 'sales', CURRENT_TIMESTAMP), - -- Reports - ('c699419a-e99c-4808-abd6-c6352e2eeb67', 'reports', 'read', 'View reports', 'system', CURRENT_TIMESTAMP), - ('c648cac1-d3cc-4e9b-a84a-533f28132768', 'reports', 'export', 'Export reports', 'system', CURRENT_TIMESTAMP) -ON CONFLICT (resource, action) DO NOTHING; - --- =========================================== --- ROLE-PERMISSION ASSIGNMENTS --- =========================================== - --- Admin role gets all permissions -INSERT INTO auth.role_permissions (role_id, permission_id, granted_at) -SELECT - 'fed1cfa2-8ea1-4d86-bfef-b3dcc08801c2', - id, - CURRENT_TIMESTAMP -FROM auth.permissions -ON CONFLICT DO NOTHING; - --- Manager role (most permissions except user management) -INSERT INTO auth.role_permissions (role_id, permission_id, granted_at) -SELECT - '1a35fbf0-a282-487d-95ef-13b3f702e8d6', - id, - CURRENT_TIMESTAMP -FROM auth.permissions -WHERE resource NOT IN ('users') -ON CONFLICT DO NOTHING; - --- Accountant role (financial MGN-004 + read partners + reports) -INSERT INTO auth.role_permissions (role_id, permission_id, granted_at) -SELECT - 'c91f1a60-bd0d-40d3-91b8-36c226ce3d29', - id, - CURRENT_TIMESTAMP -FROM auth.permissions -WHERE module = 'MGN-004' - OR (resource = 'partners' AND action = 'read') - OR (resource = 'reports') -ON CONFLICT DO NOTHING; - --- Sales role (MGN-007 + sales + partners + read invoices/products/reports) -INSERT INTO auth.role_permissions (role_id, permission_id, granted_at) -SELECT - '493568ed-972f-472f-9ac1-236a32438936', - id, - CURRENT_TIMESTAMP -FROM auth.permissions -WHERE module IN ('sales', 'MGN-007') - OR (resource = 'partners') - OR (resource = 'invoices' AND action = 'read') - OR (resource = 'products' AND action = 'read') - OR (resource = 'reports' AND action = 'read') -ON CONFLICT DO NOTHING; - --- Purchasing role (MGN-006 + partners + products read) -INSERT INTO auth.role_permissions (role_id, permission_id, granted_at) -SELECT - '80515d77-fc15-4a5a-a213-7b9f869db15a', - id, - CURRENT_TIMESTAMP -FROM auth.permissions -WHERE module = 'MGN-006' - OR (resource = 'partners') - OR (resource = 'products' AND action = 'read') -ON CONFLICT DO NOTHING; - --- Warehouse role (MGN-005 inventory + products) -INSERT INTO auth.role_permissions (role_id, permission_id, granted_at) -SELECT - '0a86a34a-7fd6-47e2-9e0c-4c547c6af9f1', - id, - CURRENT_TIMESTAMP -FROM auth.permissions -WHERE module = 'MGN-005' -ON CONFLICT DO NOTHING; - --- Employee role (basic read permissions) -INSERT INTO auth.role_permissions (role_id, permission_id, granted_at) -SELECT - '88e299e6-8cda-4fd1-a32f-afc2aa7b8975', - id, - CURRENT_TIMESTAMP -FROM auth.permissions -WHERE action = 'read' - AND resource IN ('companies', 'partners', 'products', 'reports') -ON CONFLICT DO NOTHING; - --- Output confirmation -DO $$ -BEGIN - RAISE NOTICE 'Roles seed data loaded: 8 roles, 28 permissions'; -END $$; diff --git a/seeds/dev/03-seed-subscriptions.sql b/seeds/dev/03-seed-subscriptions.sql new file mode 100644 index 0000000..f10f855 --- /dev/null +++ b/seeds/dev/03-seed-subscriptions.sql @@ -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; diff --git a/seeds/dev/04-users.sql b/seeds/dev/04-users.sql deleted file mode 100644 index e23e410..0000000 --- a/seeds/dev/04-users.sql +++ /dev/null @@ -1,148 +0,0 @@ --- ============================================================================ --- ERP GENERIC - SEED DATA: USERS (Development) --- ============================================================================ --- Description: Development users for testing --- Password for all users: Test1234 (bcrypt hash) --- ============================================================================ - --- Password hash for "Test1234" using bcrypt (generated with bcryptjs, 10 rounds) --- Hash: $2a$10$EbJ0czCHXi8LatF2e7NfkeA/RLMn2R8w1ShDnHVUtQRnosEokense --- Note: You should regenerate this in production - --- Super Admin (is_superuser=true, assigned to Demo tenant) -INSERT INTO auth.users (id, tenant_id, email, password_hash, full_name, status, is_superuser, email_verified_at, created_at) -VALUES ( - '0bb44df3-ec99-4306-85e9-50c34dd7d27a', - '1c7dfbb0-19b8-4e87-a225-a74da6f26dbf', - 'superadmin@erp-generic.local', - '$2a$10$EbJ0czCHXi8LatF2e7NfkeA/RLMn2R8w1ShDnHVUtQRnosEokense', - 'Super Admin', - 'active', - true, - CURRENT_TIMESTAMP, - CURRENT_TIMESTAMP -) ON CONFLICT (id) DO NOTHING; - --- Assign super_admin role -INSERT INTO auth.user_roles (user_id, role_id, assigned_at) -VALUES ( - '0bb44df3-ec99-4306-85e9-50c34dd7d27a', - '5e29aadd-1d9f-4280-a38b-fefe7cdece5a', - CURRENT_TIMESTAMP -) ON CONFLICT DO NOTHING; - --- =========================================== --- DEMO COMPANY USERS --- =========================================== - --- Admin user -INSERT INTO auth.users (id, tenant_id, email, password_hash, full_name, status, email_verified_at, created_at) -VALUES ( - 'e6f9a1fd-2a56-496c-9dc5-f603e1a910dd', - '1c7dfbb0-19b8-4e87-a225-a74da6f26dbf', - 'admin@demo-company.mx', - '$2a$10$EbJ0czCHXi8LatF2e7NfkeA/RLMn2R8w1ShDnHVUtQRnosEokense', - 'Carlos Administrador', - 'active', - CURRENT_TIMESTAMP, - CURRENT_TIMESTAMP -) ON CONFLICT (id) DO NOTHING; - -INSERT INTO auth.user_roles (user_id, role_id, assigned_at) -VALUES ('e6f9a1fd-2a56-496c-9dc5-f603e1a910dd', 'fed1cfa2-8ea1-4d86-bfef-b3dcc08801c2', CURRENT_TIMESTAMP) -ON CONFLICT DO NOTHING; - -INSERT INTO auth.user_companies (user_id, company_id, is_default, assigned_at) -VALUES ('e6f9a1fd-2a56-496c-9dc5-f603e1a910dd', '50fa9b29-504f-4c45-8f8a-3d129cfc6095', true, CURRENT_TIMESTAMP) -ON CONFLICT DO NOTHING; - --- Manager user -INSERT INTO auth.users (id, tenant_id, email, password_hash, full_name, status, email_verified_at, created_at) -VALUES ( - 'c8013936-53ad-4c6a-8f50-d7c7be1da9de', - '1c7dfbb0-19b8-4e87-a225-a74da6f26dbf', - 'manager@demo-company.mx', - '$2a$10$EbJ0czCHXi8LatF2e7NfkeA/RLMn2R8w1ShDnHVUtQRnosEokense', - 'María Gerente', - 'active', - CURRENT_TIMESTAMP, - CURRENT_TIMESTAMP -) ON CONFLICT (id) DO NOTHING; - -INSERT INTO auth.user_roles (user_id, role_id, assigned_at) -VALUES ('c8013936-53ad-4c6a-8f50-d7c7be1da9de', '1a35fbf0-a282-487d-95ef-13b3f702e8d6', CURRENT_TIMESTAMP) -ON CONFLICT DO NOTHING; - -INSERT INTO auth.user_companies (user_id, company_id, is_default, assigned_at) -VALUES ('c8013936-53ad-4c6a-8f50-d7c7be1da9de', '50fa9b29-504f-4c45-8f8a-3d129cfc6095', true, CURRENT_TIMESTAMP) -ON CONFLICT DO NOTHING; - --- Accountant user -INSERT INTO auth.users (id, tenant_id, email, password_hash, full_name, status, email_verified_at, created_at) -VALUES ( - '1110b920-a7ab-4303-aa9e-4b2fafe44f84', - '1c7dfbb0-19b8-4e87-a225-a74da6f26dbf', - 'contador@demo-company.mx', - '$2a$10$EbJ0czCHXi8LatF2e7NfkeA/RLMn2R8w1ShDnHVUtQRnosEokense', - 'Juan Contador', - 'active', - CURRENT_TIMESTAMP, - CURRENT_TIMESTAMP -) ON CONFLICT (id) DO NOTHING; - -INSERT INTO auth.user_roles (user_id, role_id, assigned_at) -VALUES ('1110b920-a7ab-4303-aa9e-4b2fafe44f84', 'c91f1a60-bd0d-40d3-91b8-36c226ce3d29', CURRENT_TIMESTAMP) -ON CONFLICT DO NOTHING; - -INSERT INTO auth.user_companies (user_id, company_id, is_default, assigned_at) -VALUES ('1110b920-a7ab-4303-aa9e-4b2fafe44f84', '50fa9b29-504f-4c45-8f8a-3d129cfc6095', true, CURRENT_TIMESTAMP) -ON CONFLICT DO NOTHING; - --- Sales user -INSERT INTO auth.users (id, tenant_id, email, password_hash, full_name, status, email_verified_at, created_at) -VALUES ( - '607fc4d8-374c-4693-b601-81f522a857ab', - '1c7dfbb0-19b8-4e87-a225-a74da6f26dbf', - 'ventas@demo-company.mx', - '$2a$10$EbJ0czCHXi8LatF2e7NfkeA/RLMn2R8w1ShDnHVUtQRnosEokense', - 'Ana Ventas', - 'active', - CURRENT_TIMESTAMP, - CURRENT_TIMESTAMP -) ON CONFLICT (id) DO NOTHING; - -INSERT INTO auth.user_roles (user_id, role_id, assigned_at) -VALUES ('607fc4d8-374c-4693-b601-81f522a857ab', '493568ed-972f-472f-9ac1-236a32438936', CURRENT_TIMESTAMP) -ON CONFLICT DO NOTHING; - -INSERT INTO auth.user_companies (user_id, company_id, is_default, assigned_at) -VALUES ('607fc4d8-374c-4693-b601-81f522a857ab', '50fa9b29-504f-4c45-8f8a-3d129cfc6095', true, CURRENT_TIMESTAMP) -ON CONFLICT DO NOTHING; - --- Warehouse user -INSERT INTO auth.users (id, tenant_id, email, password_hash, full_name, status, email_verified_at, created_at) -VALUES ( - '7c7f132b-4551-4864-bafd-36147e626bb7', - '1c7dfbb0-19b8-4e87-a225-a74da6f26dbf', - 'almacen@demo-company.mx', - '$2a$10$EbJ0czCHXi8LatF2e7NfkeA/RLMn2R8w1ShDnHVUtQRnosEokense', - 'Pedro Almacén', - 'active', - CURRENT_TIMESTAMP, - CURRENT_TIMESTAMP -) ON CONFLICT (id) DO NOTHING; - -INSERT INTO auth.user_roles (user_id, role_id, assigned_at) -VALUES ('7c7f132b-4551-4864-bafd-36147e626bb7', '0a86a34a-7fd6-47e2-9e0c-4c547c6af9f1', CURRENT_TIMESTAMP) -ON CONFLICT DO NOTHING; - -INSERT INTO auth.user_companies (user_id, company_id, is_default, assigned_at) -VALUES ('7c7f132b-4551-4864-bafd-36147e626bb7', '50fa9b29-504f-4c45-8f8a-3d129cfc6095', true, CURRENT_TIMESTAMP) -ON CONFLICT DO NOTHING; - --- Output confirmation -DO $$ -BEGIN - RAISE NOTICE 'Users seed data loaded: 6 users created'; - RAISE NOTICE 'Default password for all users: Test1234'; -END $$; diff --git a/seeds/dev/05-sample-data.sql b/seeds/dev/05-sample-data.sql deleted file mode 100644 index fb2e526..0000000 --- a/seeds/dev/05-sample-data.sql +++ /dev/null @@ -1,228 +0,0 @@ --- ============================================================================ --- ERP GENERIC - SEED DATA: SAMPLE DATA (Development) --- ============================================================================ --- Description: Sample partners, products, and transactions for testing --- ============================================================================ - --- =========================================== --- UUID REFERENCE (from previous seeds) --- =========================================== --- TENANT_DEMO: 1c7dfbb0-19b8-4e87-a225-a74da6f26dbf --- COMPANY_DEMO: 50fa9b29-504f-4c45-8f8a-3d129cfc6095 - --- =========================================== --- SAMPLE PARTNERS (Customers & Vendors) --- =========================================== - --- Customer 1 - Acme Corporation -INSERT INTO core.partners (id, tenant_id, name, legal_name, partner_type, is_customer, is_supplier, is_company, email, phone, tax_id, created_at) -VALUES ( - 'dda3e76c-0f92-49ea-b647-62fde7d6e1d1', - '1c7dfbb0-19b8-4e87-a225-a74da6f26dbf', - 'Acme Corporation', - 'Acme Corporation S.A. de C.V.', - 'company', - true, - false, - true, - 'ventas@acme.mx', - '+52 55 1111 2222', - 'ACM123456ABC', - CURRENT_TIMESTAMP -) ON CONFLICT (id) DO NOTHING; - --- Customer 2 - Tech Solutions -INSERT INTO core.partners (id, tenant_id, name, legal_name, partner_type, is_customer, is_supplier, is_company, email, phone, tax_id, created_at) -VALUES ( - '78291258-da01-4560-a49e-5047d92cf11f', - '1c7dfbb0-19b8-4e87-a225-a74da6f26dbf', - 'Tech Solutions', - 'Tech Solutions de México S.A.', - 'company', - true, - false, - true, - 'contacto@techsolutions.mx', - '+52 55 3333 4444', - 'TSM987654XYZ', - CURRENT_TIMESTAMP -) ON CONFLICT (id) DO NOTHING; - --- Vendor 1 - Materiales del Centro -INSERT INTO core.partners (id, tenant_id, name, legal_name, partner_type, is_customer, is_supplier, is_company, email, phone, tax_id, created_at) -VALUES ( - '643c97e3-bf44-40ed-bd01-ae1f5f0d861b', - '1c7dfbb0-19b8-4e87-a225-a74da6f26dbf', - 'Materiales del Centro', - 'Materiales del Centro S. de R.L.', - 'company', - false, - true, - true, - 'ventas@materialescentro.mx', - '+52 55 5555 6666', - 'MDC456789DEF', - CURRENT_TIMESTAMP -) ON CONFLICT (id) DO NOTHING; - --- Vendor 2 - Distribuidora Nacional -INSERT INTO core.partners (id, tenant_id, name, legal_name, partner_type, is_customer, is_supplier, is_company, email, phone, tax_id, created_at) -VALUES ( - '79f3d083-375e-4e50-920b-a3630f74d4b1', - '1c7dfbb0-19b8-4e87-a225-a74da6f26dbf', - 'Distribuidora Nacional', - 'Distribuidora Nacional de Productos S.A.', - 'company', - false, - true, - true, - 'pedidos@distnacional.mx', - '+52 55 7777 8888', - 'DNP321654GHI', - CURRENT_TIMESTAMP -) ON CONFLICT (id) DO NOTHING; - --- =========================================== --- SAMPLE PRODUCT CATEGORIES --- =========================================== - -INSERT INTO core.product_categories (id, tenant_id, name, code, parent_id, full_path, active, created_at) -VALUES - ('f10ee8c4-e52e-41f5-93b3-a140d09dd807', '1c7dfbb0-19b8-4e87-a225-a74da6f26dbf', 'All Products', 'ALL', NULL, 'All Products', true, CURRENT_TIMESTAMP), - ('b1517141-470a-4835-98ff-9250ffd18121', '1c7dfbb0-19b8-4e87-a225-a74da6f26dbf', 'Raw Materials', 'RAW', 'f10ee8c4-e52e-41f5-93b3-a140d09dd807', 'All Products / Raw Materials', true, CURRENT_TIMESTAMP), - ('0b55e26b-ec64-4a80-aab3-be5a55b0ca88', '1c7dfbb0-19b8-4e87-a225-a74da6f26dbf', 'Finished Goods', 'FIN', 'f10ee8c4-e52e-41f5-93b3-a140d09dd807', 'All Products / Finished Goods', true, CURRENT_TIMESTAMP), - ('e92fbdc8-998f-4bf2-8a00-c7efd3e8eb64', '1c7dfbb0-19b8-4e87-a225-a74da6f26dbf', 'Services', 'SRV', 'f10ee8c4-e52e-41f5-93b3-a140d09dd807', 'All Products / Services', true, CURRENT_TIMESTAMP) -ON CONFLICT (id) DO NOTHING; - --- =========================================== --- SAMPLE PRODUCTS --- =========================================== - -INSERT INTO inventory.products (id, tenant_id, name, code, barcode, category_id, product_type, uom_id, cost_price, list_price, created_at) -VALUES - -- Product 1: Raw material - Steel Sheet - ( - 'ccbc64d7-06f9-47a1-9ad7-6dbfbbf82955', - '1c7dfbb0-19b8-4e87-a225-a74da6f26dbf', - 'Steel Sheet 4x8', - 'MAT-001', - '7501234567890', - 'b1517141-470a-4835-98ff-9250ffd18121', - 'storable', - (SELECT id FROM core.uom WHERE code = 'unit' LIMIT 1), - 350.00, - 500.00, - CURRENT_TIMESTAMP - ), - -- Product 2: Finished good - Metal Cabinet - ( - '1d4bbccb-1d83-4b15-a85d-687e378fff96', - '1c7dfbb0-19b8-4e87-a225-a74da6f26dbf', - 'Metal Cabinet Large', - 'PROD-001', - '7501234567891', - '0b55e26b-ec64-4a80-aab3-be5a55b0ca88', - 'storable', - (SELECT id FROM core.uom WHERE code = 'unit' LIMIT 1), - 1800.00, - 2500.00, - CURRENT_TIMESTAMP - ), - -- Product 3: Service - Installation - ( - 'aae17b73-5bd2-433e-bb99-d9187df398b8', - '1c7dfbb0-19b8-4e87-a225-a74da6f26dbf', - 'Installation Service', - 'SRV-001', - NULL, - 'e92fbdc8-998f-4bf2-8a00-c7efd3e8eb64', - 'service', - (SELECT id FROM core.uom WHERE code = 'h' LIMIT 1), - 300.00, - 500.00, - CURRENT_TIMESTAMP - ) -ON CONFLICT (id) DO NOTHING; - --- =========================================== --- SAMPLE WAREHOUSE & LOCATIONS --- =========================================== - -INSERT INTO inventory.warehouses (id, tenant_id, company_id, name, code, is_default, active, created_at) -VALUES ( - '40ea2e44-31aa-4e4c-856d-e5c3dd0b942f', - '1c7dfbb0-19b8-4e87-a225-a74da6f26dbf', - '50fa9b29-504f-4c45-8f8a-3d129cfc6095', - 'Main Warehouse', - 'WH-MAIN', - true, - true, - CURRENT_TIMESTAMP -) ON CONFLICT (id) DO NOTHING; - -INSERT INTO inventory.locations (id, tenant_id, warehouse_id, name, complete_name, location_type, active, created_at) -VALUES - ('7a57d418-4ea6-47d7-a3e0-2ade4c95e240', '1c7dfbb0-19b8-4e87-a225-a74da6f26dbf', '40ea2e44-31aa-4e4c-856d-e5c3dd0b942f', 'Stock', 'WH-MAIN/Stock', 'internal', true, CURRENT_TIMESTAMP), - ('3bea067b-5023-474b-88cf-97bb0461538b', '1c7dfbb0-19b8-4e87-a225-a74da6f26dbf', '40ea2e44-31aa-4e4c-856d-e5c3dd0b942f', 'Input', 'WH-MAIN/Input', 'internal', true, CURRENT_TIMESTAMP), - ('8f97bcf7-a34f-406e-8292-bfb04502a4f8', '1c7dfbb0-19b8-4e87-a225-a74da6f26dbf', '40ea2e44-31aa-4e4c-856d-e5c3dd0b942f', 'Output', 'WH-MAIN/Output', 'internal', true, CURRENT_TIMESTAMP) -ON CONFLICT (id) DO NOTHING; - --- =========================================== --- SAMPLE STOCK QUANTITIES --- =========================================== - -DO $$ -BEGIN - IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'inventory' AND table_name = 'stock_quants') THEN - -- Steel Sheet 4x8 - 100 units in Stock location - PERFORM inventory.update_stock_quant( - 'ccbc64d7-06f9-47a1-9ad7-6dbfbbf82955'::uuid, - '7a57d418-4ea6-47d7-a3e0-2ade4c95e240'::uuid, - NULL, - 100.00 - ); - -- Metal Cabinet Large - 25 units in Stock location - PERFORM inventory.update_stock_quant( - '1d4bbccb-1d83-4b15-a85d-687e378fff96'::uuid, - '7a57d418-4ea6-47d7-a3e0-2ade4c95e240'::uuid, - NULL, - 25.00 - ); - RAISE NOTICE 'Stock quantities added via update_stock_quant function'; - ELSE - RAISE NOTICE 'inventory.stock_quants table does not exist, skipping stock initialization'; - END IF; -END $$; - --- =========================================== --- SAMPLE ANALYTIC ACCOUNTS --- =========================================== - -INSERT INTO analytics.analytic_plans (id, tenant_id, company_id, name, description, active, created_at) -VALUES ( - 'f6085dcd-dcd7-4ef5-affc-0fa3b037b1d2', - '1c7dfbb0-19b8-4e87-a225-a74da6f26dbf', - '50fa9b29-504f-4c45-8f8a-3d129cfc6095', - 'Projects', - 'Plan for project-based analytics', - true, - CURRENT_TIMESTAMP -) ON CONFLICT (id) DO NOTHING; - -INSERT INTO analytics.analytic_accounts (id, tenant_id, company_id, plan_id, name, code, account_type, status, created_at) -VALUES - ('858e16c0-773d-4cec-ac94-0241ab0c90e3', '1c7dfbb0-19b8-4e87-a225-a74da6f26dbf', '50fa9b29-504f-4c45-8f8a-3d129cfc6095', 'f6085dcd-dcd7-4ef5-affc-0fa3b037b1d2', 'Project Alpha', 'PROJ-ALPHA', 'project', 'active', CURRENT_TIMESTAMP), - ('41b6a320-021d-473d-b643-038b1bb86055', '1c7dfbb0-19b8-4e87-a225-a74da6f26dbf', '50fa9b29-504f-4c45-8f8a-3d129cfc6095', 'f6085dcd-dcd7-4ef5-affc-0fa3b037b1d2', 'Project Beta', 'PROJ-BETA', 'project', 'active', CURRENT_TIMESTAMP), - ('b950ada5-2f11-4dd7-a91b-5696dbb8fabc', '1c7dfbb0-19b8-4e87-a225-a74da6f26dbf', '50fa9b29-504f-4c45-8f8a-3d129cfc6095', 'f6085dcd-dcd7-4ef5-affc-0fa3b037b1d2', 'Operations', 'OPS', 'department', 'active', CURRENT_TIMESTAMP) -ON CONFLICT (id) DO NOTHING; - --- Output confirmation -DO $$ -BEGIN - RAISE NOTICE 'Sample data loaded:'; - RAISE NOTICE ' - 4 partners (2 customers, 2 vendors)'; - RAISE NOTICE ' - 4 product categories'; - RAISE NOTICE ' - 3 products'; - RAISE NOTICE ' - 1 warehouse with 3 locations'; - RAISE NOTICE ' - 3 analytic accounts'; -END $$;