From 7a51c5005c5d4c12c1b458c6a3c32e953ecfc210 Mon Sep 17 00:00:00 2001 From: rckrdmrd Date: Fri, 12 Dec 2025 14:39:32 -0600 Subject: [PATCH] Initial deploy commit --- .gitignore | 33 + README.md | 171 +++ ddl/00-prerequisites.sql | 207 ++++ ddl/01-auth-extensions.sql | 891 ++++++++++++++++ ddl/01-auth.sql | 620 +++++++++++ ddl/02-core.sql | 755 ++++++++++++++ ddl/03-analytics.sql | 510 +++++++++ ddl/04-financial.sql | 970 ++++++++++++++++++ ddl/05-inventory-extensions.sql | 966 +++++++++++++++++ ddl/05-inventory.sql | 772 ++++++++++++++ ddl/06-purchase.sql | 583 +++++++++++ ddl/07-sales.sql | 705 +++++++++++++ ddl/08-projects.sql | 537 ++++++++++ ddl/09-system.sql | 853 +++++++++++++++ ddl/10-billing.sql | 638 ++++++++++++ ddl/11-crm.sql | 366 +++++++ ddl/12-hr.sql | 379 +++++++ ddl/schemas/core_shared/00-schema.sql | 159 +++ docker-compose.yml | 51 + .../20251212_001_fiscal_period_validation.sql | 207 ++++ migrations/20251212_002_partner_rankings.sql | 391 +++++++ migrations/20251212_003_financial_reports.sql | 464 +++++++++ scripts/create-database.sh | 142 +++ scripts/drop-database.sh | 75 ++ scripts/load-seeds.sh | 101 ++ scripts/reset-database.sh | 102 ++ seeds/dev/00-catalogs.sql | 81 ++ seeds/dev/01-tenants.sql | 49 + seeds/dev/02-companies.sql | 64 ++ seeds/dev/03-roles.sql | 246 +++++ seeds/dev/04-users.sql | 148 +++ seeds/dev/05-sample-data.sql | 228 ++++ 32 files changed, 12464 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 ddl/00-prerequisites.sql create mode 100644 ddl/01-auth-extensions.sql create mode 100644 ddl/01-auth.sql create mode 100644 ddl/02-core.sql create mode 100644 ddl/03-analytics.sql create mode 100644 ddl/04-financial.sql create mode 100644 ddl/05-inventory-extensions.sql create mode 100644 ddl/05-inventory.sql create mode 100644 ddl/06-purchase.sql create mode 100644 ddl/07-sales.sql create mode 100644 ddl/08-projects.sql create mode 100644 ddl/09-system.sql create mode 100644 ddl/10-billing.sql create mode 100644 ddl/11-crm.sql create mode 100644 ddl/12-hr.sql create mode 100644 ddl/schemas/core_shared/00-schema.sql create mode 100644 docker-compose.yml create mode 100644 migrations/20251212_001_fiscal_period_validation.sql create mode 100644 migrations/20251212_002_partner_rankings.sql create mode 100644 migrations/20251212_003_financial_reports.sql create mode 100755 scripts/create-database.sh create mode 100755 scripts/drop-database.sh create mode 100755 scripts/load-seeds.sh create mode 100755 scripts/reset-database.sh create mode 100644 seeds/dev/00-catalogs.sql create mode 100644 seeds/dev/01-tenants.sql create mode 100644 seeds/dev/02-companies.sql create mode 100644 seeds/dev/03-roles.sql create mode 100644 seeds/dev/04-users.sql create mode 100644 seeds/dev/05-sample-data.sql diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b94e4e9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Dependencies +node_modules/ + +# Build output +dist/ +build/ + +# Environment files (local) +.env +.env.local +.env.*.local + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Logs +*.log +npm-debug.log* + +# Coverage +coverage/ +.nyc_output/ + +# OS +.DS_Store +Thumbs.db + +# Cache +.cache/ +.parcel-cache/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..2ce697f --- /dev/null +++ b/README.md @@ -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/) diff --git a/ddl/00-prerequisites.sql b/ddl/00-prerequisites.sql new file mode 100644 index 0000000..7fc8d34 --- /dev/null +++ b/ddl/00-prerequisites.sql @@ -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 $$; diff --git a/ddl/01-auth-extensions.sql b/ddl/01-auth-extensions.sql new file mode 100644 index 0000000..dc0a46c --- /dev/null +++ b/ddl/01-auth-extensions.sql @@ -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 +-- ===================================================== diff --git a/ddl/01-auth.sql b/ddl/01-auth.sql new file mode 100644 index 0000000..afa85b1 --- /dev/null +++ b/ddl/01-auth.sql @@ -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 +-- ===================================================== diff --git a/ddl/02-core.sql b/ddl/02-core.sql new file mode 100644 index 0000000..2d8e553 --- /dev/null +++ b/ddl/02-core.sql @@ -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 +-- ===================================================== diff --git a/ddl/03-analytics.sql b/ddl/03-analytics.sql new file mode 100644 index 0000000..faea1aa --- /dev/null +++ b/ddl/03-analytics.sql @@ -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 +-- ===================================================== diff --git a/ddl/04-financial.sql b/ddl/04-financial.sql new file mode 100644 index 0000000..022a903 --- /dev/null +++ b/ddl/04-financial.sql @@ -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 +-- ===================================================== diff --git a/ddl/05-inventory-extensions.sql b/ddl/05-inventory-extensions.sql new file mode 100644 index 0000000..f2b3a2f --- /dev/null +++ b/ddl/05-inventory-extensions.sql @@ -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 +-- ===================================================== diff --git a/ddl/05-inventory.sql b/ddl/05-inventory.sql new file mode 100644 index 0000000..c563e39 --- /dev/null +++ b/ddl/05-inventory.sql @@ -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 +-- ===================================================== diff --git a/ddl/06-purchase.sql b/ddl/06-purchase.sql new file mode 100644 index 0000000..8d2271b --- /dev/null +++ b/ddl/06-purchase.sql @@ -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 +-- ===================================================== diff --git a/ddl/07-sales.sql b/ddl/07-sales.sql new file mode 100644 index 0000000..10ec490 --- /dev/null +++ b/ddl/07-sales.sql @@ -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 +-- ===================================================== diff --git a/ddl/08-projects.sql b/ddl/08-projects.sql new file mode 100644 index 0000000..e8cc807 --- /dev/null +++ b/ddl/08-projects.sql @@ -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 +-- ===================================================== diff --git a/ddl/09-system.sql b/ddl/09-system.sql new file mode 100644 index 0000000..07e4053 --- /dev/null +++ b/ddl/09-system.sql @@ -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 +-- ===================================================== diff --git a/ddl/10-billing.sql b/ddl/10-billing.sql new file mode 100644 index 0000000..e816d02 --- /dev/null +++ b/ddl/10-billing.sql @@ -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'; diff --git a/ddl/11-crm.sql b/ddl/11-crm.sql new file mode 100644 index 0000000..8428e54 --- /dev/null +++ b/ddl/11-crm.sql @@ -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.)'; diff --git a/ddl/12-hr.sql b/ddl/12-hr.sql new file mode 100644 index 0000000..7e8d6c2 --- /dev/null +++ b/ddl/12-hr.sql @@ -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'; diff --git a/ddl/schemas/core_shared/00-schema.sql b/ddl/schemas/core_shared/00-schema.sql new file mode 100644 index 0000000..a23a5f7 --- /dev/null +++ b/ddl/schemas/core_shared/00-schema.sql @@ -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 +-- ============================================================================ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b9e89cc --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/migrations/20251212_001_fiscal_period_validation.sql b/migrations/20251212_001_fiscal_period_validation.sql new file mode 100644 index 0000000..48841be --- /dev/null +++ b/migrations/20251212_001_fiscal_period_validation.sql @@ -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 $$; diff --git a/migrations/20251212_002_partner_rankings.sql b/migrations/20251212_002_partner_rankings.sql new file mode 100644 index 0000000..7f0cbe5 --- /dev/null +++ b/migrations/20251212_002_partner_rankings.sql @@ -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 $$; diff --git a/migrations/20251212_003_financial_reports.sql b/migrations/20251212_003_financial_reports.sql new file mode 100644 index 0000000..7203e8f --- /dev/null +++ b/migrations/20251212_003_financial_reports.sql @@ -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 $$; diff --git a/scripts/create-database.sh b/scripts/create-database.sh new file mode 100755 index 0000000..ca1e08a --- /dev/null +++ b/scripts/create-database.sh @@ -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 "" diff --git a/scripts/drop-database.sh b/scripts/drop-database.sh new file mode 100755 index 0000000..2ea8c6d --- /dev/null +++ b/scripts/drop-database.sh @@ -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 "" diff --git a/scripts/load-seeds.sh b/scripts/load-seeds.sh new file mode 100755 index 0000000..6cfbfd3 --- /dev/null +++ b/scripts/load-seeds.sh @@ -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 "" diff --git a/scripts/reset-database.sh b/scripts/reset-database.sh new file mode 100755 index 0000000..c27ccd8 --- /dev/null +++ b/scripts/reset-database.sh @@ -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 "" diff --git a/seeds/dev/00-catalogs.sql b/seeds/dev/00-catalogs.sql new file mode 100644 index 0000000..37b7d2d --- /dev/null +++ b/seeds/dev/00-catalogs.sql @@ -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 $$; diff --git a/seeds/dev/01-tenants.sql b/seeds/dev/01-tenants.sql new file mode 100644 index 0000000..1def231 --- /dev/null +++ b/seeds/dev/01-tenants.sql @@ -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 $$; diff --git a/seeds/dev/02-companies.sql b/seeds/dev/02-companies.sql new file mode 100644 index 0000000..2eb0010 --- /dev/null +++ b/seeds/dev/02-companies.sql @@ -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 $$; diff --git a/seeds/dev/03-roles.sql b/seeds/dev/03-roles.sql new file mode 100644 index 0000000..599952f --- /dev/null +++ b/seeds/dev/03-roles.sql @@ -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 $$; diff --git a/seeds/dev/04-users.sql b/seeds/dev/04-users.sql new file mode 100644 index 0000000..e23e410 --- /dev/null +++ b/seeds/dev/04-users.sql @@ -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 $$; diff --git a/seeds/dev/05-sample-data.sql b/seeds/dev/05-sample-data.sql new file mode 100644 index 0000000..fb2e526 --- /dev/null +++ b/seeds/dev/05-sample-data.sql @@ -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 $$;