refactor: Restructure DDL with numbered schema files

- Replace old DDL structure with new numbered files (01-24)
- Update migrations and seeds for new schema
- Clean up deprecated files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rckrdmrd 2026-01-16 00:40:32 -06:00
parent e4cb9b6db6
commit 5043a640e4
59 changed files with 11455 additions and 12289 deletions

171
README.md
View File

@ -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/)

View File

@ -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 $$;

View File

@ -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
-- =====================================================

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

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

View File

@ -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
-- =====================================================

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

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

View File

@ -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', '', '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
-- =====================================================

View File

@ -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
-- =====================================================

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

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

View File

@ -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
-- =====================================================

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

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

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

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

View File

@ -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
-- =====================================================

View File

@ -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
-- =====================================================

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

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

View File

@ -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
-- =====================================================

View File

@ -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
-- =====================================================

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

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

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

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

View File

@ -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
-- =====================================================

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

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

View File

@ -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
-- =====================================================

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

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

View File

@ -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';

View File

@ -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.)';

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

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

View File

@ -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';

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

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

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

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

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

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

1018
ddl/15-whatsapp.sql Normal file

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@ -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
-- ============================================================================

View File

@ -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

View File

@ -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 $$;

View File

@ -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 $$;

View File

@ -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 $$;

View File

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

View File

@ -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[@]}

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

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

View File

@ -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 ""

View File

@ -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 ""

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

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

View File

@ -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 ""

View File

@ -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 $$;

View File

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

View File

@ -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 $$;

View File

@ -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 $$;

View File

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

View File

@ -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 $$;

View File

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

View File

@ -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 $$;

View File

@ -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 $$;