Initial commit - erp-core-database
This commit is contained in:
commit
e4cb9b6db6
171
README.md
Normal file
171
README.md
Normal 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
207
ddl/00-prerequisites.sql
Normal 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
891
ddl/01-auth-extensions.sql
Normal 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
620
ddl/01-auth.sql
Normal 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
755
ddl/02-core.sql
Normal 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', 'm³', 'bigger', 1000.0 UNION ALL
|
||||
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Volume'), 'Gallon', 'gal', 'bigger', 3.78541 UNION ALL
|
||||
|
||||
-- Length
|
||||
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Length'), 'Meter', 'm', 'reference', 1.0 UNION ALL
|
||||
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Length'), 'Centimeter', 'cm', 'smaller', 0.01 UNION ALL
|
||||
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Length'), 'Millimeter', 'mm', 'smaller', 0.001 UNION ALL
|
||||
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Length'), 'Kilometer', 'km', 'bigger', 1000.0 UNION ALL
|
||||
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Length'), 'Inch', 'in', 'smaller', 0.0254 UNION ALL
|
||||
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Length'), 'Foot', 'ft', 'smaller', 0.3048 UNION ALL
|
||||
|
||||
-- Time
|
||||
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Time'), 'Hour', 'h', 'reference', 1.0 UNION ALL
|
||||
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Time'), 'Day', 'd', 'bigger', 24.0 UNION ALL
|
||||
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Time'), 'Week', 'wk', 'bigger', 168.0 UNION ALL
|
||||
|
||||
-- Unit
|
||||
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Unit'), 'Unit', 'unit', 'reference', 1.0 UNION ALL
|
||||
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Unit'), 'Dozen', 'doz', 'bigger', 12.0 UNION ALL
|
||||
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Unit'), 'Pack', 'pack', 'bigger', 1.0
|
||||
) AS uom(category_id, name, code, uom_type, factor)
|
||||
JOIN core.uom_categories cat ON cat.id = uom.category_id
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- =====================================================
|
||||
-- COMENTARIOS EN TABLAS
|
||||
-- =====================================================
|
||||
|
||||
COMMENT ON SCHEMA core IS 'Schema de catálogos maestros y entidades fundamentales';
|
||||
COMMENT ON TABLE core.countries IS 'Catálogo de países (ISO 3166-1)';
|
||||
COMMENT ON TABLE core.currencies IS 'Catálogo de monedas (ISO 4217)';
|
||||
COMMENT ON TABLE core.exchange_rates IS 'Tasas de cambio históricas entre monedas';
|
||||
COMMENT ON TABLE core.uom_categories IS 'Categorías de unidades de medida';
|
||||
COMMENT ON TABLE core.uom IS 'Unidades de medida (peso, volumen, longitud, etc.)';
|
||||
COMMENT ON TABLE core.partners IS 'Partners universales (clientes, proveedores, empleados, contactos) - patrón Odoo';
|
||||
COMMENT ON TABLE core.addresses IS 'Direcciones de partners (facturación, envío, contacto)';
|
||||
COMMENT ON TABLE core.product_categories IS 'Categorías jerárquicas de productos';
|
||||
COMMENT ON TABLE core.tags IS 'Etiquetas genéricas para clasificar registros';
|
||||
COMMENT ON TABLE core.sequences IS 'Generadores de números secuenciales automáticos';
|
||||
COMMENT ON TABLE core.attachments IS 'Archivos adjuntos polimórficos (cualquier tabla/registro)';
|
||||
COMMENT ON TABLE core.notes IS 'Notas polimórficas (cualquier tabla/registro)';
|
||||
|
||||
-- =====================================================
|
||||
-- VISTAS ÚTILES
|
||||
-- =====================================================
|
||||
|
||||
-- Vista: customers (solo partners que son clientes)
|
||||
CREATE OR REPLACE VIEW core.customers_view AS
|
||||
SELECT
|
||||
id,
|
||||
tenant_id,
|
||||
name,
|
||||
legal_name,
|
||||
email,
|
||||
phone,
|
||||
mobile,
|
||||
tax_id,
|
||||
company_id,
|
||||
active
|
||||
FROM core.partners
|
||||
WHERE is_customer = TRUE
|
||||
AND deleted_at IS NULL;
|
||||
|
||||
COMMENT ON VIEW core.customers_view IS 'Vista de partners que son clientes';
|
||||
|
||||
-- Vista: suppliers (solo partners que son proveedores)
|
||||
CREATE OR REPLACE VIEW core.suppliers_view AS
|
||||
SELECT
|
||||
id,
|
||||
tenant_id,
|
||||
name,
|
||||
legal_name,
|
||||
email,
|
||||
phone,
|
||||
tax_id,
|
||||
company_id,
|
||||
active
|
||||
FROM core.partners
|
||||
WHERE is_supplier = TRUE
|
||||
AND deleted_at IS NULL;
|
||||
|
||||
COMMENT ON VIEW core.suppliers_view IS 'Vista de partners que son proveedores';
|
||||
|
||||
-- Vista: employees (solo partners que son empleados)
|
||||
CREATE OR REPLACE VIEW core.employees_view AS
|
||||
SELECT
|
||||
p.id,
|
||||
p.tenant_id,
|
||||
p.name,
|
||||
p.email,
|
||||
p.phone,
|
||||
p.user_id,
|
||||
u.full_name as user_name,
|
||||
p.active
|
||||
FROM core.partners p
|
||||
LEFT JOIN auth.users u ON p.user_id = u.id
|
||||
WHERE p.is_employee = TRUE
|
||||
AND p.deleted_at IS NULL;
|
||||
|
||||
COMMENT ON VIEW core.employees_view IS 'Vista de partners que son empleados';
|
||||
|
||||
-- =====================================================
|
||||
-- FIN DEL SCHEMA CORE
|
||||
-- =====================================================
|
||||
510
ddl/03-analytics.sql
Normal file
510
ddl/03-analytics.sql
Normal 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
970
ddl/04-financial.sql
Normal 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
|
||||
-- =====================================================
|
||||
966
ddl/05-inventory-extensions.sql
Normal file
966
ddl/05-inventory-extensions.sql
Normal 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
772
ddl/05-inventory.sql
Normal 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
583
ddl/06-purchase.sql
Normal 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
705
ddl/07-sales.sql
Normal 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
537
ddl/08-projects.sql
Normal 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
853
ddl/09-system.sql
Normal 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
638
ddl/10-billing.sql
Normal 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
366
ddl/11-crm.sql
Normal 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
379
ddl/12-hr.sql
Normal 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';
|
||||
159
ddl/schemas/core_shared/00-schema.sql
Normal file
159
ddl/schemas/core_shared/00-schema.sql
Normal 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
51
docker-compose.yml
Normal 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
|
||||
207
migrations/20251212_001_fiscal_period_validation.sql
Normal file
207
migrations/20251212_001_fiscal_period_validation.sql
Normal 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 $$;
|
||||
391
migrations/20251212_002_partner_rankings.sql
Normal file
391
migrations/20251212_002_partner_rankings.sql
Normal 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 $$;
|
||||
464
migrations/20251212_003_financial_reports.sql
Normal file
464
migrations/20251212_003_financial_reports.sql
Normal 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
142
scripts/create-database.sh
Executable 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
75
scripts/drop-database.sh
Executable 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
101
scripts/load-seeds.sh
Executable 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
102
scripts/reset-database.sh
Executable 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
81
seeds/dev/00-catalogs.sql
Normal 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
49
seeds/dev/01-tenants.sql
Normal 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 $$;
|
||||
64
seeds/dev/02-companies.sql
Normal file
64
seeds/dev/02-companies.sql
Normal 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
246
seeds/dev/03-roles.sql
Normal 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
148
seeds/dev/04-users.sql
Normal 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 $$;
|
||||
228
seeds/dev/05-sample-data.sql
Normal file
228
seeds/dev/05-sample-data.sql
Normal 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 $$;
|
||||
Loading…
Reference in New Issue
Block a user