Initial commit - erp-core-database

This commit is contained in:
rckrdmrd 2026-01-04 06:40:22 -06:00
commit e4cb9b6db6
31 changed files with 12431 additions and 0 deletions

171
README.md Normal file
View File

@ -0,0 +1,171 @@
# 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/)

207
ddl/00-prerequisites.sql Normal file
View File

@ -0,0 +1,207 @@
-- ============================================================================
-- 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 $$;

891
ddl/01-auth-extensions.sql Normal file
View File

@ -0,0 +1,891 @@
-- =====================================================
-- 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
-- =====================================================

620
ddl/01-auth.sql Normal file
View File

@ -0,0 +1,620 @@
-- =====================================================
-- 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
-- =====================================================

755
ddl/02-core.sql Normal file
View File

@ -0,0 +1,755 @@
-- =====================================================
-- 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
-- =====================================================

510
ddl/03-analytics.sql Normal file
View File

@ -0,0 +1,510 @@
-- =====================================================
-- 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
-- =====================================================

970
ddl/04-financial.sql Normal file
View File

@ -0,0 +1,970 @@
-- =====================================================
-- 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
-- =====================================================

View File

@ -0,0 +1,966 @@
-- =====================================================
-- 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
-- =====================================================

772
ddl/05-inventory.sql Normal file
View File

@ -0,0 +1,772 @@
-- =====================================================
-- 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
-- =====================================================

583
ddl/06-purchase.sql Normal file
View File

@ -0,0 +1,583 @@
-- =====================================================
-- 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
-- =====================================================

705
ddl/07-sales.sql Normal file
View File

@ -0,0 +1,705 @@
-- =====================================================
-- 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
-- =====================================================

537
ddl/08-projects.sql Normal file
View File

@ -0,0 +1,537 @@
-- =====================================================
-- 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
-- =====================================================

853
ddl/09-system.sql Normal file
View File

@ -0,0 +1,853 @@
-- =====================================================
-- 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
-- =====================================================

638
ddl/10-billing.sql Normal file
View File

@ -0,0 +1,638 @@
-- =====================================================
-- 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';

366
ddl/11-crm.sql Normal file
View File

@ -0,0 +1,366 @@
-- =====================================================
-- 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.)';

379
ddl/12-hr.sql Normal file
View File

@ -0,0 +1,379 @@
-- =====================================================
-- 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';

View File

@ -0,0 +1,159 @@
-- ============================================================================
-- 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
-- ============================================================================

51
docker-compose.yml Normal file
View File

@ -0,0 +1,51 @@
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

@ -0,0 +1,207 @@
-- ============================================================================
-- 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

@ -0,0 +1,391 @@
-- ============================================================================
-- 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

@ -0,0 +1,464 @@
-- ============================================================================
-- 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 $$;

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

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

75
scripts/drop-database.sh Executable file
View File

@ -0,0 +1,75 @@
#!/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 ""

101
scripts/load-seeds.sh Executable file
View File

@ -0,0 +1,101 @@
#!/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 ""

102
scripts/reset-database.sh Executable file
View File

@ -0,0 +1,102 @@
#!/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 ""

81
seeds/dev/00-catalogs.sql Normal file
View File

@ -0,0 +1,81 @@
-- ============================================================================
-- 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 $$;

49
seeds/dev/01-tenants.sql Normal file
View File

@ -0,0 +1,49 @@
-- ============================================================================
-- 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

@ -0,0 +1,64 @@
-- ============================================================================
-- 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 $$;

246
seeds/dev/03-roles.sql Normal file
View File

@ -0,0 +1,246 @@
-- ============================================================================
-- 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 $$;

148
seeds/dev/04-users.sql Normal file
View File

@ -0,0 +1,148 @@
-- ============================================================================
-- 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

@ -0,0 +1,228 @@
-- ============================================================================
-- 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 $$;