refactor: Restructure DDL with numbered schema files
- Replace old DDL structure with new numbered files (01-24) - Update migrations and seeds for new schema - Clean up deprecated files Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
e4cb9b6db6
commit
5043a640e4
171
README.md
171
README.md
@ -1,171 +0,0 @@
|
|||||||
# Database - ERP Generic
|
|
||||||
|
|
||||||
**Version:** 1.1.0
|
|
||||||
**Database:** PostgreSQL 15+
|
|
||||||
**Schemas:** 12
|
|
||||||
**Tables:** 118
|
|
||||||
**Last Updated:** 2025-12-06
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- Docker & Docker Compose
|
|
||||||
- PostgreSQL 15+ (or use Docker)
|
|
||||||
- psql CLI
|
|
||||||
|
|
||||||
### Setup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Start PostgreSQL with Docker
|
|
||||||
docker-compose up -d
|
|
||||||
|
|
||||||
# 2. Create database and run migrations
|
|
||||||
./scripts/create-database.sh
|
|
||||||
|
|
||||||
# 3. Load seed data (development)
|
|
||||||
./scripts/load-seeds.sh dev
|
|
||||||
```
|
|
||||||
|
|
||||||
## Directory Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
database/
|
|
||||||
├── ddl/ # Data Definition Language (SQL schemas)
|
|
||||||
│ ├── 00-prerequisites.sql # Extensions, common functions
|
|
||||||
│ ├── 01-auth.sql # Authentication, users, roles
|
|
||||||
│ ├── 02-core.sql # Partners, catalogs, master data
|
|
||||||
│ ├── 03-analytics.sql # Analytic accounting
|
|
||||||
│ ├── 04-financial.sql # Accounts, journals, invoices
|
|
||||||
│ ├── 05-inventory.sql # Products, stock, warehouses
|
|
||||||
│ ├── 06-purchase.sql # Purchase orders, vendors
|
|
||||||
│ ├── 07-sales.sql # Sales orders, customers
|
|
||||||
│ ├── 08-projects.sql # Projects, tasks, timesheets
|
|
||||||
│ ├── 09-system.sql # Messages, notifications, logs
|
|
||||||
│ ├── 10-billing.sql # SaaS subscriptions, plans, payments
|
|
||||||
│ ├── 11-crm.sql # Leads, opportunities, pipeline
|
|
||||||
│ └── 12-hr.sql # Employees, contracts, leaves
|
|
||||||
├── scripts/ # Shell scripts
|
|
||||||
│ ├── create-database.sh # Master creation script
|
|
||||||
│ ├── drop-database.sh # Drop database
|
|
||||||
│ ├── load-seeds.sh # Load seed data
|
|
||||||
│ └── reset-database.sh # Drop and recreate
|
|
||||||
├── seeds/ # Initial data
|
|
||||||
│ ├── dev/ # Development seeds
|
|
||||||
│ └── prod/ # Production seeds
|
|
||||||
├── migrations/ # Incremental changes (empty by design)
|
|
||||||
├── docker-compose.yml # PostgreSQL container
|
|
||||||
└── .env.example # Environment variables template
|
|
||||||
```
|
|
||||||
|
|
||||||
## Schemas
|
|
||||||
|
|
||||||
| Schema | Module | Tables | Description |
|
|
||||||
|--------|--------|--------|-------------|
|
|
||||||
| `auth` | MGN-001 | 10 | Authentication, users, roles, permissions, multi-tenancy |
|
|
||||||
| `core` | MGN-002, MGN-003 | 12 | Partners, addresses, currencies, countries, UoM, categories |
|
|
||||||
| `analytics` | MGN-008 | 7 | Analytic plans, accounts, distributions, cost centers |
|
|
||||||
| `financial` | MGN-004 | 15 | Chart of accounts, journals, entries, invoices, payments |
|
|
||||||
| `inventory` | MGN-005 | 10 | Products, warehouses, locations, stock moves, pickings |
|
|
||||||
| `purchase` | MGN-006 | 8 | RFQs, purchase orders, vendor pricelists, agreements |
|
|
||||||
| `sales` | MGN-007 | 10 | Quotations, sales orders, pricelists, teams |
|
|
||||||
| `projects` | MGN-011 | 10 | Projects, tasks, milestones, timesheets |
|
|
||||||
| `system` | MGN-012, MGN-014 | 13 | Messages, notifications, activities, logs, reports |
|
|
||||||
| `billing` | MGN-015 | 11 | SaaS subscriptions, plans, payments, coupons |
|
|
||||||
| `crm` | MGN-009 | 6 | Leads, opportunities, pipeline, activities |
|
|
||||||
| `hr` | MGN-010 | 6 | Employees, departments, contracts, leaves |
|
|
||||||
|
|
||||||
## Execution Order
|
|
||||||
|
|
||||||
The DDL files must be executed in order due to dependencies:
|
|
||||||
|
|
||||||
1. `00-prerequisites.sql` - Extensions, base functions
|
|
||||||
2. `01-auth.sql` - Base schema (no dependencies)
|
|
||||||
3. `02-core.sql` - Depends on auth
|
|
||||||
4. `03-analytics.sql` - Depends on auth, core
|
|
||||||
5. `04-financial.sql` - Depends on auth, core, analytics
|
|
||||||
6. `05-inventory.sql` - Depends on auth, core, analytics
|
|
||||||
7. `06-purchase.sql` - Depends on auth, core, inventory, analytics
|
|
||||||
8. `07-sales.sql` - Depends on auth, core, inventory, analytics
|
|
||||||
9. `08-projects.sql` - Depends on auth, core, analytics
|
|
||||||
10. `09-system.sql` - Depends on auth, core
|
|
||||||
11. `10-billing.sql` - Depends on auth, core
|
|
||||||
12. `11-crm.sql` - Depends on auth, core, sales
|
|
||||||
13. `12-hr.sql` - Depends on auth, core
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
### Multi-Tenancy (RLS)
|
|
||||||
|
|
||||||
All transactional tables have:
|
|
||||||
- `tenant_id` column
|
|
||||||
- Row Level Security (RLS) policies
|
|
||||||
- Context functions: `get_current_tenant_id()`, `get_current_user_id()`
|
|
||||||
|
|
||||||
### Audit Trail
|
|
||||||
|
|
||||||
All tables include:
|
|
||||||
- `created_at`, `created_by`
|
|
||||||
- `updated_at`, `updated_by`
|
|
||||||
- `deleted_at`, `deleted_by` (soft delete)
|
|
||||||
|
|
||||||
### Automatic Triggers
|
|
||||||
|
|
||||||
- `updated_at` auto-update on all tables
|
|
||||||
- Balance validation for journal entries
|
|
||||||
- Invoice totals calculation
|
|
||||||
- Stock quantity updates
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Database connection
|
|
||||||
POSTGRES_HOST=localhost
|
|
||||||
POSTGRES_PORT=5432
|
|
||||||
POSTGRES_DB=erp_generic
|
|
||||||
POSTGRES_USER=erp_admin
|
|
||||||
POSTGRES_PASSWORD=your_secure_password
|
|
||||||
|
|
||||||
# Optional
|
|
||||||
POSTGRES_SCHEMA=public
|
|
||||||
```
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create database from scratch (DDL only)
|
|
||||||
./scripts/create-database.sh
|
|
||||||
|
|
||||||
# Drop database
|
|
||||||
./scripts/drop-database.sh
|
|
||||||
|
|
||||||
# Reset (drop + create DDL + seeds dev) - RECOMENDADO
|
|
||||||
./scripts/reset-database.sh # Pide confirmación
|
|
||||||
./scripts/reset-database.sh --force # Sin confirmación (CI/CD)
|
|
||||||
./scripts/reset-database.sh --no-seeds # Solo DDL, sin seeds
|
|
||||||
./scripts/reset-database.sh --env prod # Seeds de producción
|
|
||||||
|
|
||||||
# Load seeds manualmente
|
|
||||||
./scripts/load-seeds.sh dev # Development
|
|
||||||
./scripts/load-seeds.sh prod # Production
|
|
||||||
```
|
|
||||||
|
|
||||||
> **NOTA:** No se usan migrations. Ver `DIRECTIVA-POLITICA-CARGA-LIMPIA.md` para detalles.
|
|
||||||
|
|
||||||
## Statistics
|
|
||||||
|
|
||||||
- **Schemas:** 12
|
|
||||||
- **Tables:** 144 (118 base + 26 extensiones)
|
|
||||||
- **DDL Files:** 15
|
|
||||||
- **Functions:** 63
|
|
||||||
- **Triggers:** 92
|
|
||||||
- **Indexes:** 450+
|
|
||||||
- **RLS Policies:** 85+
|
|
||||||
- **ENUMs:** 64
|
|
||||||
- **Lines of SQL:** ~10,000
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [ADR-007: Database Design](/docs/adr/ADR-007-database-design.md)
|
|
||||||
- [Gamilit Database Reference](/shared/reference/gamilit/database/)
|
|
||||||
- [Odoo Analysis](/docs/00-analisis-referencias/odoo/)
|
|
||||||
@ -1,207 +0,0 @@
|
|||||||
-- ============================================================================
|
|
||||||
-- ERP GENERIC - DATABASE PREREQUISITES
|
|
||||||
-- ============================================================================
|
|
||||||
-- Version: 1.0.0
|
|
||||||
-- Description: Extensions, common types, and utility functions
|
|
||||||
-- Execute: FIRST (before any schema)
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- Enable required extensions
|
|
||||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -- UUID generation
|
|
||||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- Password hashing
|
|
||||||
CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- Trigram similarity (fuzzy search)
|
|
||||||
CREATE EXTENSION IF NOT EXISTS "unaccent"; -- Remove accents for search
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- UTILITY FUNCTIONS
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- Function: Update updated_at timestamp
|
|
||||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION update_updated_at_column() IS
|
|
||||||
'Generic trigger function to auto-update updated_at timestamp on row modification';
|
|
||||||
|
|
||||||
-- Function: Normalize text for search (remove accents, lowercase)
|
|
||||||
CREATE OR REPLACE FUNCTION normalize_search_text(p_text TEXT)
|
|
||||||
RETURNS TEXT AS $$
|
|
||||||
BEGIN
|
|
||||||
RETURN LOWER(unaccent(COALESCE(p_text, '')));
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql IMMUTABLE;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION normalize_search_text(TEXT) IS
|
|
||||||
'Normalize text for search by removing accents and converting to lowercase';
|
|
||||||
|
|
||||||
-- Function: Generate random alphanumeric code
|
|
||||||
CREATE OR REPLACE FUNCTION generate_random_code(p_length INTEGER DEFAULT 8)
|
|
||||||
RETURNS TEXT AS $$
|
|
||||||
DECLARE
|
|
||||||
chars TEXT := 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
|
||||||
result TEXT := '';
|
|
||||||
i INTEGER;
|
|
||||||
BEGIN
|
|
||||||
FOR i IN 1..p_length LOOP
|
|
||||||
result := result || substr(chars, floor(random() * length(chars) + 1)::INTEGER, 1);
|
|
||||||
END LOOP;
|
|
||||||
RETURN result;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION generate_random_code(INTEGER) IS
|
|
||||||
'Generate random alphanumeric code of specified length (default 8)';
|
|
||||||
|
|
||||||
-- Function: Validate email format
|
|
||||||
CREATE OR REPLACE FUNCTION is_valid_email(p_email TEXT)
|
|
||||||
RETURNS BOOLEAN AS $$
|
|
||||||
BEGIN
|
|
||||||
RETURN p_email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$';
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql IMMUTABLE;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION is_valid_email(TEXT) IS
|
|
||||||
'Validate email format using regex';
|
|
||||||
|
|
||||||
-- Function: Validate phone number format (basic)
|
|
||||||
CREATE OR REPLACE FUNCTION is_valid_phone(p_phone TEXT)
|
|
||||||
RETURNS BOOLEAN AS $$
|
|
||||||
BEGIN
|
|
||||||
-- Basic validation: only digits, spaces, dashes, parentheses, plus sign
|
|
||||||
RETURN p_phone ~ '^[\d\s\-\(\)\+]+$' AND length(regexp_replace(p_phone, '[^\d]', '', 'g')) >= 7;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql IMMUTABLE;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION is_valid_phone(TEXT) IS
|
|
||||||
'Validate phone number format (at least 7 digits)';
|
|
||||||
|
|
||||||
-- Function: Clean phone number (keep only digits)
|
|
||||||
CREATE OR REPLACE FUNCTION clean_phone(p_phone TEXT)
|
|
||||||
RETURNS TEXT AS $$
|
|
||||||
BEGIN
|
|
||||||
RETURN regexp_replace(COALESCE(p_phone, ''), '[^\d]', '', 'g');
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql IMMUTABLE;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION clean_phone(TEXT) IS
|
|
||||||
'Remove non-numeric characters from phone number';
|
|
||||||
|
|
||||||
-- Function: Calculate age from date
|
|
||||||
CREATE OR REPLACE FUNCTION calculate_age(p_birthdate DATE)
|
|
||||||
RETURNS INTEGER AS $$
|
|
||||||
BEGIN
|
|
||||||
IF p_birthdate IS NULL THEN
|
|
||||||
RETURN NULL;
|
|
||||||
END IF;
|
|
||||||
RETURN EXTRACT(YEAR FROM age(CURRENT_DATE, p_birthdate))::INTEGER;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql IMMUTABLE;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION calculate_age(DATE) IS
|
|
||||||
'Calculate age in years from birthdate';
|
|
||||||
|
|
||||||
-- Function: Get current fiscal year start
|
|
||||||
CREATE OR REPLACE FUNCTION get_fiscal_year_start(p_date DATE DEFAULT CURRENT_DATE)
|
|
||||||
RETURNS DATE AS $$
|
|
||||||
BEGIN
|
|
||||||
-- Assuming fiscal year starts January 1st
|
|
||||||
-- Modify if different fiscal year start is needed
|
|
||||||
RETURN DATE_TRUNC('year', p_date)::DATE;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql IMMUTABLE;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION get_fiscal_year_start(DATE) IS
|
|
||||||
'Get the start date of fiscal year for a given date (default: January 1st)';
|
|
||||||
|
|
||||||
-- Function: Round to decimal places
|
|
||||||
CREATE OR REPLACE FUNCTION round_currency(p_amount NUMERIC, p_decimals INTEGER DEFAULT 2)
|
|
||||||
RETURNS NUMERIC AS $$
|
|
||||||
BEGIN
|
|
||||||
RETURN ROUND(COALESCE(p_amount, 0), p_decimals);
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql IMMUTABLE;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION round_currency(NUMERIC, INTEGER) IS
|
|
||||||
'Round numeric value to specified decimal places (default 2 for currency)';
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- COMMON TYPES
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- Type: Money with currency (for multi-currency support)
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'money_amount') THEN
|
|
||||||
CREATE TYPE money_amount AS (
|
|
||||||
amount NUMERIC(15, 2),
|
|
||||||
currency_code CHAR(3)
|
|
||||||
);
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
COMMENT ON TYPE money_amount IS
|
|
||||||
'Composite type for storing monetary values with currency code';
|
|
||||||
|
|
||||||
-- Type: Address components
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'address_components') THEN
|
|
||||||
CREATE TYPE address_components AS (
|
|
||||||
street VARCHAR(255),
|
|
||||||
street2 VARCHAR(255),
|
|
||||||
city VARCHAR(100),
|
|
||||||
state VARCHAR(100),
|
|
||||||
zip VARCHAR(20),
|
|
||||||
country_code CHAR(2)
|
|
||||||
);
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
COMMENT ON TYPE address_components IS
|
|
||||||
'Composite type for address components (street, city, state, zip, country)';
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- SCHEMA CREATION
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- Create all schemas upfront to avoid circular dependency issues
|
|
||||||
CREATE SCHEMA IF NOT EXISTS auth;
|
|
||||||
CREATE SCHEMA IF NOT EXISTS core;
|
|
||||||
CREATE SCHEMA IF NOT EXISTS analytics;
|
|
||||||
CREATE SCHEMA IF NOT EXISTS financial;
|
|
||||||
CREATE SCHEMA IF NOT EXISTS inventory;
|
|
||||||
CREATE SCHEMA IF NOT EXISTS purchase;
|
|
||||||
CREATE SCHEMA IF NOT EXISTS sales;
|
|
||||||
CREATE SCHEMA IF NOT EXISTS projects;
|
|
||||||
CREATE SCHEMA IF NOT EXISTS system;
|
|
||||||
|
|
||||||
-- Set search path to include all schemas
|
|
||||||
ALTER DATABASE erp_generic SET search_path TO public, auth, core, analytics, financial, inventory, purchase, sales, projects, system;
|
|
||||||
|
|
||||||
-- Grant usage on schemas to public role (will be refined per-user later)
|
|
||||||
GRANT USAGE ON SCHEMA auth TO PUBLIC;
|
|
||||||
GRANT USAGE ON SCHEMA core TO PUBLIC;
|
|
||||||
GRANT USAGE ON SCHEMA analytics TO PUBLIC;
|
|
||||||
GRANT USAGE ON SCHEMA financial TO PUBLIC;
|
|
||||||
GRANT USAGE ON SCHEMA inventory TO PUBLIC;
|
|
||||||
GRANT USAGE ON SCHEMA purchase TO PUBLIC;
|
|
||||||
GRANT USAGE ON SCHEMA sales TO PUBLIC;
|
|
||||||
GRANT USAGE ON SCHEMA projects TO PUBLIC;
|
|
||||||
GRANT USAGE ON SCHEMA system TO PUBLIC;
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- PREREQUISITES COMPLETE
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
RAISE NOTICE 'Prerequisites installed successfully!';
|
|
||||||
RAISE NOTICE 'Extensions: uuid-ossp, pgcrypto, pg_trgm, unaccent';
|
|
||||||
RAISE NOTICE 'Schemas created: auth, core, analytics, financial, inventory, purchase, sales, projects, system';
|
|
||||||
RAISE NOTICE 'Utility functions: 9 functions installed';
|
|
||||||
END $$;
|
|
||||||
@ -1,891 +0,0 @@
|
|||||||
-- =====================================================
|
|
||||||
-- SCHEMA: auth (Extensiones)
|
|
||||||
-- PROPÓSITO: 2FA, API Keys, OAuth2, Grupos, ACL, Record Rules
|
|
||||||
-- MÓDULOS: MGN-001 (Fundamentos), MGN-002 (Usuarios), MGN-003 (Roles)
|
|
||||||
-- FECHA: 2025-12-08
|
|
||||||
-- VERSION: 1.0.0
|
|
||||||
-- DEPENDENCIAS: 01-auth.sql
|
|
||||||
-- SPECS RELACIONADAS:
|
|
||||||
-- - SPEC-TWO-FACTOR-AUTHENTICATION.md
|
|
||||||
-- - SPEC-SEGURIDAD-API-KEYS-PERMISOS.md
|
|
||||||
-- - SPEC-OAUTH2-SOCIAL-LOGIN.md
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- PARTE 1: GROUPS Y HERENCIA
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Tabla: groups (Grupos de usuarios con herencia)
|
|
||||||
CREATE TABLE auth.groups (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
code VARCHAR(100) NOT NULL,
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
|
|
||||||
-- Configuración
|
|
||||||
is_system BOOLEAN NOT NULL DEFAULT FALSE, -- Grupos del sistema no editables
|
|
||||||
category VARCHAR(100), -- Categoría para agrupación (ventas, compras, etc.)
|
|
||||||
color VARCHAR(20),
|
|
||||||
|
|
||||||
-- API Keys
|
|
||||||
api_key_max_duration_days INTEGER DEFAULT 30
|
|
||||||
CHECK (api_key_max_duration_days >= 0), -- 0 = sin expiración (solo grupos system)
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
deleted_at TIMESTAMP,
|
|
||||||
deleted_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT uq_groups_code_tenant UNIQUE (tenant_id, code)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: group_implied (Herencia de grupos)
|
|
||||||
CREATE TABLE auth.group_implied (
|
|
||||||
group_id UUID NOT NULL REFERENCES auth.groups(id) ON DELETE CASCADE,
|
|
||||||
implied_group_id UUID NOT NULL REFERENCES auth.groups(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
PRIMARY KEY (group_id, implied_group_id),
|
|
||||||
CONSTRAINT chk_group_no_self_imply CHECK (group_id != implied_group_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: user_groups (Many-to-Many usuarios-grupos)
|
|
||||||
CREATE TABLE auth.user_groups (
|
|
||||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
||||||
group_id UUID NOT NULL REFERENCES auth.groups(id) ON DELETE CASCADE,
|
|
||||||
assigned_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
assigned_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
PRIMARY KEY (user_id, group_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Índices para groups
|
|
||||||
CREATE INDEX idx_groups_tenant_id ON auth.groups(tenant_id);
|
|
||||||
CREATE INDEX idx_groups_code ON auth.groups(code);
|
|
||||||
CREATE INDEX idx_groups_category ON auth.groups(category);
|
|
||||||
CREATE INDEX idx_groups_is_system ON auth.groups(is_system);
|
|
||||||
|
|
||||||
-- Índices para user_groups
|
|
||||||
CREATE INDEX idx_user_groups_user_id ON auth.user_groups(user_id);
|
|
||||||
CREATE INDEX idx_user_groups_group_id ON auth.user_groups(group_id);
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- PARTE 2: MODELS Y ACL (Access Control Lists)
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Tabla: models (Definición de modelos del sistema)
|
|
||||||
CREATE TABLE auth.models (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
name VARCHAR(128) NOT NULL, -- Nombre técnico (ej: 'sale.order')
|
|
||||||
description VARCHAR(255), -- Descripción legible
|
|
||||||
module VARCHAR(64), -- Módulo al que pertenece
|
|
||||||
is_transient BOOLEAN NOT NULL DEFAULT FALSE, -- Modelo temporal
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT uq_models_name_tenant UNIQUE (tenant_id, name)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: model_access (Permisos CRUD por modelo y grupo)
|
|
||||||
CREATE TABLE auth.model_access (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
name VARCHAR(255) NOT NULL, -- Identificador legible
|
|
||||||
|
|
||||||
model_id UUID NOT NULL REFERENCES auth.models(id) ON DELETE CASCADE,
|
|
||||||
group_id UUID REFERENCES auth.groups(id) ON DELETE RESTRICT, -- NULL = global
|
|
||||||
|
|
||||||
-- Permisos CRUD
|
|
||||||
perm_read BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
perm_create BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
perm_write BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
perm_delete BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
|
|
||||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
|
|
||||||
-- Un grupo solo puede tener un registro por modelo
|
|
||||||
CONSTRAINT uq_model_access_model_group UNIQUE (model_id, group_id, tenant_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Índices para models
|
|
||||||
CREATE INDEX idx_models_name ON auth.models(name);
|
|
||||||
CREATE INDEX idx_models_tenant ON auth.models(tenant_id);
|
|
||||||
CREATE INDEX idx_models_module ON auth.models(module);
|
|
||||||
|
|
||||||
-- Índices para model_access
|
|
||||||
CREATE INDEX idx_model_access_model ON auth.model_access(model_id);
|
|
||||||
CREATE INDEX idx_model_access_group ON auth.model_access(group_id);
|
|
||||||
CREATE INDEX idx_model_access_active ON auth.model_access(is_active) WHERE is_active = TRUE;
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- PARTE 3: RECORD RULES (Row-Level Security)
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Tabla: record_rules (Reglas de acceso a nivel de registro)
|
|
||||||
CREATE TABLE auth.record_rules (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
|
|
||||||
model_id UUID NOT NULL REFERENCES auth.models(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
-- Dominio como expresión JSON
|
|
||||||
domain_expression JSONB NOT NULL, -- [["company_id", "in", "user.company_ids"]]
|
|
||||||
|
|
||||||
-- Permisos afectados
|
|
||||||
perm_read BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
perm_create BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
perm_write BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
perm_delete BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
|
|
||||||
-- Regla global (sin grupos = aplica a todos)
|
|
||||||
is_global BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
|
|
||||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: rule_groups (Relación M:N entre rules y groups)
|
|
||||||
CREATE TABLE auth.rule_groups (
|
|
||||||
rule_id UUID NOT NULL REFERENCES auth.record_rules(id) ON DELETE CASCADE,
|
|
||||||
group_id UUID NOT NULL REFERENCES auth.groups(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
PRIMARY KEY (rule_id, group_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Índices para record_rules
|
|
||||||
CREATE INDEX idx_record_rules_model ON auth.record_rules(model_id);
|
|
||||||
CREATE INDEX idx_record_rules_global ON auth.record_rules(is_global) WHERE is_global = TRUE;
|
|
||||||
CREATE INDEX idx_record_rules_active ON auth.record_rules(is_active) WHERE is_active = TRUE;
|
|
||||||
|
|
||||||
-- Índices para rule_groups
|
|
||||||
CREATE INDEX idx_rule_groups_rule ON auth.rule_groups(rule_id);
|
|
||||||
CREATE INDEX idx_rule_groups_group ON auth.rule_groups(group_id);
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- PARTE 4: FIELD PERMISSIONS
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Tabla: model_fields (Campos del modelo con metadatos de seguridad)
|
|
||||||
CREATE TABLE auth.model_fields (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
model_id UUID NOT NULL REFERENCES auth.models(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
name VARCHAR(128) NOT NULL, -- Nombre técnico del campo
|
|
||||||
field_type VARCHAR(64) NOT NULL, -- Tipo: char, int, many2one, etc.
|
|
||||||
description VARCHAR(255), -- Etiqueta legible
|
|
||||||
|
|
||||||
-- Seguridad por defecto
|
|
||||||
is_readonly BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
is_required BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT uq_model_field UNIQUE (model_id, name, tenant_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: field_permissions (Permisos de campo por grupo)
|
|
||||||
CREATE TABLE auth.field_permissions (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
field_id UUID NOT NULL REFERENCES auth.model_fields(id) ON DELETE CASCADE,
|
|
||||||
group_id UUID NOT NULL REFERENCES auth.groups(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
-- Permisos
|
|
||||||
can_read BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
can_write BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
|
|
||||||
CONSTRAINT uq_field_permission UNIQUE (field_id, group_id, tenant_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Índices para model_fields
|
|
||||||
CREATE INDEX idx_model_fields_model ON auth.model_fields(model_id);
|
|
||||||
CREATE INDEX idx_model_fields_name ON auth.model_fields(name);
|
|
||||||
|
|
||||||
-- Índices para field_permissions
|
|
||||||
CREATE INDEX idx_field_permissions_field ON auth.field_permissions(field_id);
|
|
||||||
CREATE INDEX idx_field_permissions_group ON auth.field_permissions(group_id);
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- PARTE 5: API KEYS
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Tabla: api_keys (Autenticación para integraciones)
|
|
||||||
CREATE TABLE auth.api_keys (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
-- Descripción
|
|
||||||
name VARCHAR(255) NOT NULL, -- Descripción del propósito
|
|
||||||
|
|
||||||
-- Seguridad
|
|
||||||
key_index VARCHAR(16) NOT NULL, -- Primeros 8 bytes del key (para lookup rápido)
|
|
||||||
key_hash VARCHAR(255) NOT NULL, -- Hash PBKDF2-SHA512 del key completo
|
|
||||||
|
|
||||||
-- Scope y restricciones
|
|
||||||
scope VARCHAR(100), -- NULL = acceso completo, 'rpc' = solo API
|
|
||||||
allowed_ips INET[], -- IPs permitidas (opcional)
|
|
||||||
|
|
||||||
-- Expiración
|
|
||||||
expiration_date TIMESTAMPTZ, -- NULL = sin expiración (solo system users)
|
|
||||||
last_used_at TIMESTAMPTZ, -- Último uso
|
|
||||||
|
|
||||||
-- Estado
|
|
||||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
revoked_at TIMESTAMPTZ,
|
|
||||||
revoked_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
-- Constraints
|
|
||||||
CONSTRAINT chk_key_index_length CHECK (LENGTH(key_index) = 16)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Índices para API Keys
|
|
||||||
CREATE INDEX idx_api_keys_lookup ON auth.api_keys (key_index, is_active)
|
|
||||||
WHERE is_active = TRUE;
|
|
||||||
CREATE INDEX idx_api_keys_expiration ON auth.api_keys (expiration_date)
|
|
||||||
WHERE expiration_date IS NOT NULL;
|
|
||||||
CREATE INDEX idx_api_keys_user ON auth.api_keys (user_id);
|
|
||||||
CREATE INDEX idx_api_keys_tenant ON auth.api_keys (tenant_id);
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- PARTE 6: TWO-FACTOR AUTHENTICATION (2FA)
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Extensión de users para MFA
|
|
||||||
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS
|
|
||||||
mfa_enabled BOOLEAN NOT NULL DEFAULT FALSE;
|
|
||||||
|
|
||||||
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS
|
|
||||||
mfa_method VARCHAR(16) DEFAULT 'none'
|
|
||||||
CHECK (mfa_method IN ('none', 'totp', 'sms', 'email'));
|
|
||||||
|
|
||||||
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS
|
|
||||||
mfa_secret BYTEA; -- Secreto TOTP encriptado con AES-256-GCM
|
|
||||||
|
|
||||||
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS
|
|
||||||
backup_codes JSONB DEFAULT '[]'; -- Códigos de respaldo (array de hashes SHA-256)
|
|
||||||
|
|
||||||
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS
|
|
||||||
backup_codes_count INTEGER NOT NULL DEFAULT 0;
|
|
||||||
|
|
||||||
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS
|
|
||||||
mfa_setup_at TIMESTAMPTZ;
|
|
||||||
|
|
||||||
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS
|
|
||||||
last_2fa_verification TIMESTAMPTZ;
|
|
||||||
|
|
||||||
-- Constraint de consistencia MFA
|
|
||||||
ALTER TABLE auth.users ADD CONSTRAINT chk_mfa_consistency CHECK (
|
|
||||||
(mfa_enabled = TRUE AND mfa_secret IS NOT NULL AND mfa_method != 'none') OR
|
|
||||||
(mfa_enabled = FALSE)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Índice para usuarios con MFA
|
|
||||||
CREATE INDEX idx_users_mfa_enabled ON auth.users(mfa_enabled) WHERE mfa_enabled = TRUE;
|
|
||||||
|
|
||||||
-- Tabla: trusted_devices (Dispositivos de confianza)
|
|
||||||
CREATE TABLE auth.trusted_devices (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
|
|
||||||
-- Relación con usuario
|
|
||||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
-- Identificación del dispositivo
|
|
||||||
device_fingerprint VARCHAR(128) NOT NULL,
|
|
||||||
device_name VARCHAR(128), -- "iPhone de Juan", "Chrome en MacBook"
|
|
||||||
device_type VARCHAR(32), -- 'mobile', 'desktop', 'tablet'
|
|
||||||
|
|
||||||
-- Información del dispositivo
|
|
||||||
user_agent TEXT,
|
|
||||||
browser_name VARCHAR(64),
|
|
||||||
browser_version VARCHAR(32),
|
|
||||||
os_name VARCHAR(64),
|
|
||||||
os_version VARCHAR(32),
|
|
||||||
|
|
||||||
-- Ubicación del registro
|
|
||||||
registered_ip INET NOT NULL,
|
|
||||||
registered_location JSONB, -- {country, city, lat, lng}
|
|
||||||
|
|
||||||
-- Estado de confianza
|
|
||||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
trust_level VARCHAR(16) NOT NULL DEFAULT 'standard'
|
|
||||||
CHECK (trust_level IN ('standard', 'high', 'temporary')),
|
|
||||||
trust_expires_at TIMESTAMPTZ, -- NULL = no expira
|
|
||||||
|
|
||||||
-- Uso
|
|
||||||
last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
last_used_ip INET,
|
|
||||||
use_count INTEGER NOT NULL DEFAULT 1,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
revoked_at TIMESTAMPTZ,
|
|
||||||
revoked_reason VARCHAR(128),
|
|
||||||
|
|
||||||
-- Constraints
|
|
||||||
CONSTRAINT uk_trusted_device_user_fingerprint UNIQUE (user_id, device_fingerprint)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Índices para trusted_devices
|
|
||||||
CREATE INDEX idx_trusted_devices_user ON auth.trusted_devices(user_id) WHERE is_active;
|
|
||||||
CREATE INDEX idx_trusted_devices_fingerprint ON auth.trusted_devices(device_fingerprint);
|
|
||||||
CREATE INDEX idx_trusted_devices_expires ON auth.trusted_devices(trust_expires_at)
|
|
||||||
WHERE trust_expires_at IS NOT NULL AND is_active;
|
|
||||||
|
|
||||||
-- Tabla: verification_codes (Códigos de verificación temporales)
|
|
||||||
CREATE TABLE auth.verification_codes (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
|
|
||||||
-- Relaciones
|
|
||||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
||||||
session_id UUID REFERENCES auth.sessions(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
-- Tipo de código
|
|
||||||
code_type VARCHAR(16) NOT NULL
|
|
||||||
CHECK (code_type IN ('totp_setup', 'sms', 'email', 'backup')),
|
|
||||||
|
|
||||||
-- Código (hash SHA-256)
|
|
||||||
code_hash VARCHAR(64) NOT NULL,
|
|
||||||
code_length INTEGER NOT NULL DEFAULT 6,
|
|
||||||
|
|
||||||
-- Destino (para SMS/Email)
|
|
||||||
destination VARCHAR(256), -- Teléfono o email
|
|
||||||
|
|
||||||
-- Intentos
|
|
||||||
attempts INTEGER NOT NULL DEFAULT 0,
|
|
||||||
max_attempts INTEGER NOT NULL DEFAULT 5,
|
|
||||||
|
|
||||||
-- Validez
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
expires_at TIMESTAMPTZ NOT NULL,
|
|
||||||
used_at TIMESTAMPTZ,
|
|
||||||
|
|
||||||
-- Metadata
|
|
||||||
ip_address INET,
|
|
||||||
user_agent TEXT,
|
|
||||||
|
|
||||||
-- Constraint
|
|
||||||
CONSTRAINT chk_code_not_expired CHECK (used_at IS NULL OR used_at <= expires_at)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Índices para verification_codes
|
|
||||||
CREATE INDEX idx_verification_codes_user ON auth.verification_codes(user_id, code_type)
|
|
||||||
WHERE used_at IS NULL;
|
|
||||||
CREATE INDEX idx_verification_codes_expires ON auth.verification_codes(expires_at)
|
|
||||||
WHERE used_at IS NULL;
|
|
||||||
|
|
||||||
-- Tabla: mfa_audit_log (Log de auditoría MFA)
|
|
||||||
CREATE TABLE auth.mfa_audit_log (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
|
|
||||||
-- Usuario
|
|
||||||
user_id UUID NOT NULL REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
-- Evento
|
|
||||||
event_type VARCHAR(32) NOT NULL
|
|
||||||
CHECK (event_type IN (
|
|
||||||
'mfa_setup_initiated',
|
|
||||||
'mfa_setup_completed',
|
|
||||||
'mfa_disabled',
|
|
||||||
'totp_verified',
|
|
||||||
'totp_failed',
|
|
||||||
'backup_code_used',
|
|
||||||
'backup_codes_regenerated',
|
|
||||||
'device_trusted',
|
|
||||||
'device_revoked',
|
|
||||||
'anomaly_detected',
|
|
||||||
'account_locked',
|
|
||||||
'account_unlocked'
|
|
||||||
)),
|
|
||||||
|
|
||||||
-- Resultado
|
|
||||||
success BOOLEAN NOT NULL,
|
|
||||||
failure_reason VARCHAR(128),
|
|
||||||
|
|
||||||
-- Contexto
|
|
||||||
ip_address INET,
|
|
||||||
user_agent TEXT,
|
|
||||||
device_fingerprint VARCHAR(128),
|
|
||||||
location JSONB,
|
|
||||||
|
|
||||||
-- Metadata adicional
|
|
||||||
metadata JSONB DEFAULT '{}',
|
|
||||||
|
|
||||||
-- Timestamp
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Índices para mfa_audit_log
|
|
||||||
CREATE INDEX idx_mfa_audit_user ON auth.mfa_audit_log(user_id, created_at DESC);
|
|
||||||
CREATE INDEX idx_mfa_audit_event ON auth.mfa_audit_log(event_type, created_at DESC);
|
|
||||||
CREATE INDEX idx_mfa_audit_failures ON auth.mfa_audit_log(user_id, created_at DESC)
|
|
||||||
WHERE success = FALSE;
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- PARTE 7: OAUTH2 PROVIDERS
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Tabla: oauth_providers (Proveedores OAuth2)
|
|
||||||
CREATE TABLE auth.oauth_providers (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID REFERENCES auth.tenants(id) ON DELETE CASCADE, -- NULL = global
|
|
||||||
|
|
||||||
code VARCHAR(50) NOT NULL,
|
|
||||||
name VARCHAR(100) NOT NULL,
|
|
||||||
|
|
||||||
-- Configuración OAuth2
|
|
||||||
client_id VARCHAR(255) NOT NULL,
|
|
||||||
client_secret VARCHAR(500), -- Encriptado con AES-256
|
|
||||||
|
|
||||||
-- Endpoints OAuth2
|
|
||||||
authorization_endpoint VARCHAR(500) NOT NULL,
|
|
||||||
token_endpoint VARCHAR(500) NOT NULL,
|
|
||||||
userinfo_endpoint VARCHAR(500) NOT NULL,
|
|
||||||
jwks_uri VARCHAR(500), -- Para validación de ID tokens
|
|
||||||
|
|
||||||
-- Scopes y parámetros
|
|
||||||
scope VARCHAR(500) NOT NULL DEFAULT 'openid profile email',
|
|
||||||
response_type VARCHAR(50) NOT NULL DEFAULT 'code',
|
|
||||||
|
|
||||||
-- PKCE Configuration
|
|
||||||
pkce_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
code_challenge_method VARCHAR(10) DEFAULT 'S256',
|
|
||||||
|
|
||||||
-- Mapeo de claims
|
|
||||||
claim_mapping JSONB NOT NULL DEFAULT '{
|
|
||||||
"sub": "oauth_uid",
|
|
||||||
"email": "email",
|
|
||||||
"name": "name",
|
|
||||||
"picture": "avatar_url"
|
|
||||||
}'::jsonb,
|
|
||||||
|
|
||||||
-- UI
|
|
||||||
icon_class VARCHAR(100), -- fa-google, fa-microsoft, etc.
|
|
||||||
button_text VARCHAR(100),
|
|
||||||
button_color VARCHAR(20),
|
|
||||||
display_order INTEGER NOT NULL DEFAULT 10,
|
|
||||||
|
|
||||||
-- Estado
|
|
||||||
is_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
is_visible BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
|
|
||||||
-- Restricciones
|
|
||||||
allowed_domains TEXT[], -- NULL = todos permitidos
|
|
||||||
auto_create_users BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
default_role_id UUID REFERENCES auth.roles(id),
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
-- Constraints
|
|
||||||
CONSTRAINT uq_oauth_provider_code UNIQUE (code),
|
|
||||||
CONSTRAINT chk_response_type CHECK (response_type IN ('code', 'token')),
|
|
||||||
CONSTRAINT chk_pkce_method CHECK (code_challenge_method IN ('S256', 'plain'))
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Índices para oauth_providers
|
|
||||||
CREATE INDEX idx_oauth_providers_enabled ON auth.oauth_providers(is_enabled);
|
|
||||||
CREATE INDEX idx_oauth_providers_tenant ON auth.oauth_providers(tenant_id);
|
|
||||||
CREATE INDEX idx_oauth_providers_code ON auth.oauth_providers(code);
|
|
||||||
|
|
||||||
-- Tabla: oauth_user_links (Vinculación usuario-proveedor)
|
|
||||||
CREATE TABLE auth.oauth_user_links (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
||||||
provider_id UUID NOT NULL REFERENCES auth.oauth_providers(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
-- Identificación OAuth
|
|
||||||
oauth_uid VARCHAR(255) NOT NULL, -- Subject ID del proveedor
|
|
||||||
oauth_email VARCHAR(255),
|
|
||||||
|
|
||||||
-- Tokens (encriptados)
|
|
||||||
access_token TEXT,
|
|
||||||
refresh_token TEXT,
|
|
||||||
id_token TEXT,
|
|
||||||
token_expires_at TIMESTAMPTZ,
|
|
||||||
|
|
||||||
-- Metadata
|
|
||||||
raw_userinfo JSONB, -- Datos completos del proveedor
|
|
||||||
last_login_at TIMESTAMPTZ,
|
|
||||||
login_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
-- Constraints
|
|
||||||
CONSTRAINT uq_provider_oauth_uid UNIQUE (provider_id, oauth_uid),
|
|
||||||
CONSTRAINT uq_user_provider UNIQUE (user_id, provider_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Índices para oauth_user_links
|
|
||||||
CREATE INDEX idx_oauth_links_user ON auth.oauth_user_links(user_id);
|
|
||||||
CREATE INDEX idx_oauth_links_provider ON auth.oauth_user_links(provider_id);
|
|
||||||
CREATE INDEX idx_oauth_links_oauth_uid ON auth.oauth_user_links(oauth_uid);
|
|
||||||
|
|
||||||
-- Tabla: oauth_states (Estados OAuth2 temporales para CSRF)
|
|
||||||
CREATE TABLE auth.oauth_states (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
state VARCHAR(64) NOT NULL UNIQUE,
|
|
||||||
|
|
||||||
-- PKCE
|
|
||||||
code_verifier VARCHAR(128),
|
|
||||||
|
|
||||||
-- Contexto
|
|
||||||
provider_id UUID NOT NULL REFERENCES auth.oauth_providers(id),
|
|
||||||
redirect_uri VARCHAR(500) NOT NULL,
|
|
||||||
return_url VARCHAR(500),
|
|
||||||
|
|
||||||
-- Vinculación con usuario existente (para linking)
|
|
||||||
link_user_id UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
-- Metadata
|
|
||||||
ip_address INET,
|
|
||||||
user_agent TEXT,
|
|
||||||
|
|
||||||
-- Tiempo de vida
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
expires_at TIMESTAMPTZ NOT NULL DEFAULT (CURRENT_TIMESTAMP + INTERVAL '10 minutes'),
|
|
||||||
used_at TIMESTAMPTZ,
|
|
||||||
|
|
||||||
-- Constraints
|
|
||||||
CONSTRAINT chk_state_not_expired CHECK (expires_at > created_at)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Índices para oauth_states
|
|
||||||
CREATE INDEX idx_oauth_states_state ON auth.oauth_states(state);
|
|
||||||
CREATE INDEX idx_oauth_states_expires ON auth.oauth_states(expires_at);
|
|
||||||
|
|
||||||
-- Extensión de users para OAuth
|
|
||||||
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS
|
|
||||||
oauth_only BOOLEAN NOT NULL DEFAULT FALSE;
|
|
||||||
|
|
||||||
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS
|
|
||||||
primary_oauth_provider_id UUID REFERENCES auth.oauth_providers(id);
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- PARTE 8: FUNCIONES DE UTILIDAD
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Función: Obtener grupos efectivos de un usuario (incluyendo herencia)
|
|
||||||
CREATE OR REPLACE FUNCTION auth.get_user_effective_groups(p_user_id UUID)
|
|
||||||
RETURNS TABLE(group_id UUID) AS $$
|
|
||||||
WITH RECURSIVE effective_groups AS (
|
|
||||||
-- Grupos asignados directamente
|
|
||||||
SELECT ug.group_id
|
|
||||||
FROM auth.user_groups ug
|
|
||||||
WHERE ug.user_id = p_user_id
|
|
||||||
|
|
||||||
UNION
|
|
||||||
|
|
||||||
-- Grupos heredados
|
|
||||||
SELECT gi.implied_group_id
|
|
||||||
FROM auth.group_implied gi
|
|
||||||
JOIN effective_groups eg ON gi.group_id = eg.group_id
|
|
||||||
)
|
|
||||||
SELECT DISTINCT group_id FROM effective_groups;
|
|
||||||
$$ LANGUAGE SQL STABLE;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION auth.get_user_effective_groups IS 'Obtiene todos los grupos de un usuario incluyendo herencia';
|
|
||||||
|
|
||||||
-- Función: Verificar permiso ACL
|
|
||||||
CREATE OR REPLACE FUNCTION auth.check_model_access(
|
|
||||||
p_user_id UUID,
|
|
||||||
p_model_name VARCHAR,
|
|
||||||
p_mode VARCHAR -- 'read', 'create', 'write', 'delete'
|
|
||||||
)
|
|
||||||
RETURNS BOOLEAN AS $$
|
|
||||||
DECLARE
|
|
||||||
v_has_access BOOLEAN;
|
|
||||||
BEGIN
|
|
||||||
-- Superusers tienen todos los permisos
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1 FROM auth.users
|
|
||||||
WHERE id = p_user_id AND is_superuser = TRUE AND deleted_at IS NULL
|
|
||||||
) THEN
|
|
||||||
RETURN TRUE;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- Verificar ACL
|
|
||||||
SELECT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM auth.model_access ma
|
|
||||||
JOIN auth.models m ON ma.model_id = m.id
|
|
||||||
WHERE m.name = p_model_name
|
|
||||||
AND ma.is_active = TRUE
|
|
||||||
AND (
|
|
||||||
ma.group_id IS NULL -- Permiso global
|
|
||||||
OR ma.group_id IN (SELECT auth.get_user_effective_groups(p_user_id))
|
|
||||||
)
|
|
||||||
AND CASE p_mode
|
|
||||||
WHEN 'read' THEN ma.perm_read
|
|
||||||
WHEN 'create' THEN ma.perm_create
|
|
||||||
WHEN 'write' THEN ma.perm_write
|
|
||||||
WHEN 'delete' THEN ma.perm_delete
|
|
||||||
ELSE FALSE
|
|
||||||
END
|
|
||||||
) INTO v_has_access;
|
|
||||||
|
|
||||||
RETURN COALESCE(v_has_access, FALSE);
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION auth.check_model_access IS 'Verifica si un usuario tiene permiso CRUD en un modelo';
|
|
||||||
|
|
||||||
-- Función: Limpiar estados OAuth expirados
|
|
||||||
CREATE OR REPLACE FUNCTION auth.cleanup_expired_oauth_states()
|
|
||||||
RETURNS INTEGER AS $$
|
|
||||||
DECLARE
|
|
||||||
v_deleted INTEGER;
|
|
||||||
BEGIN
|
|
||||||
WITH deleted AS (
|
|
||||||
DELETE FROM auth.oauth_states
|
|
||||||
WHERE expires_at < CURRENT_TIMESTAMP
|
|
||||||
OR used_at IS NOT NULL
|
|
||||||
RETURNING id
|
|
||||||
)
|
|
||||||
SELECT COUNT(*) INTO v_deleted FROM deleted;
|
|
||||||
|
|
||||||
RETURN v_deleted;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION auth.cleanup_expired_oauth_states IS 'Limpia estados OAuth expirados (ejecutar periódicamente)';
|
|
||||||
|
|
||||||
-- Función: Limpiar códigos de verificación expirados
|
|
||||||
CREATE OR REPLACE FUNCTION auth.cleanup_expired_verification_codes()
|
|
||||||
RETURNS INTEGER AS $$
|
|
||||||
DECLARE
|
|
||||||
v_deleted INTEGER;
|
|
||||||
BEGIN
|
|
||||||
WITH deleted AS (
|
|
||||||
DELETE FROM auth.verification_codes
|
|
||||||
WHERE expires_at < NOW() - INTERVAL '1 day'
|
|
||||||
RETURNING id
|
|
||||||
)
|
|
||||||
SELECT COUNT(*) INTO v_deleted FROM deleted;
|
|
||||||
|
|
||||||
RETURN v_deleted;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION auth.cleanup_expired_verification_codes IS 'Limpia códigos de verificación expirados';
|
|
||||||
|
|
||||||
-- Función: Limpiar dispositivos de confianza expirados
|
|
||||||
CREATE OR REPLACE FUNCTION auth.cleanup_expired_trusted_devices()
|
|
||||||
RETURNS INTEGER AS $$
|
|
||||||
DECLARE
|
|
||||||
v_deleted INTEGER;
|
|
||||||
BEGIN
|
|
||||||
WITH updated AS (
|
|
||||||
UPDATE auth.trusted_devices
|
|
||||||
SET is_active = FALSE,
|
|
||||||
revoked_at = NOW(),
|
|
||||||
revoked_reason = 'expired'
|
|
||||||
WHERE trust_expires_at < NOW() - INTERVAL '7 days'
|
|
||||||
AND trust_expires_at IS NOT NULL
|
|
||||||
AND is_active = TRUE
|
|
||||||
RETURNING id
|
|
||||||
)
|
|
||||||
SELECT COUNT(*) INTO v_deleted FROM updated;
|
|
||||||
|
|
||||||
RETURN v_deleted;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION auth.cleanup_expired_trusted_devices IS 'Desactiva dispositivos de confianza expirados';
|
|
||||||
|
|
||||||
-- Función: Limpiar API keys expiradas
|
|
||||||
CREATE OR REPLACE FUNCTION auth.cleanup_expired_api_keys()
|
|
||||||
RETURNS INTEGER AS $$
|
|
||||||
DECLARE
|
|
||||||
v_deleted INTEGER;
|
|
||||||
BEGIN
|
|
||||||
WITH deleted AS (
|
|
||||||
DELETE FROM auth.api_keys
|
|
||||||
WHERE expiration_date IS NOT NULL
|
|
||||||
AND expiration_date < NOW()
|
|
||||||
RETURNING id
|
|
||||||
)
|
|
||||||
SELECT COUNT(*) INTO v_deleted FROM deleted;
|
|
||||||
|
|
||||||
RETURN v_deleted;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION auth.cleanup_expired_api_keys IS 'Limpia API keys expiradas';
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- PARTE 9: TRIGGERS
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Trigger: Actualizar updated_at para grupos
|
|
||||||
CREATE TRIGGER trg_groups_updated_at
|
|
||||||
BEFORE UPDATE ON auth.groups
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
-- Trigger: Actualizar updated_at para oauth_providers
|
|
||||||
CREATE TRIGGER trg_oauth_providers_updated_at
|
|
||||||
BEFORE UPDATE ON auth.oauth_providers
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
-- Trigger: Actualizar updated_at para oauth_user_links
|
|
||||||
CREATE OR REPLACE FUNCTION auth.update_oauth_link_updated_at()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_oauth_user_links_updated_at
|
|
||||||
BEFORE UPDATE ON auth.oauth_user_links
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_oauth_link_updated_at();
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- PARTE 10: VISTAS
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Vista: Usuarios con sus proveedores OAuth vinculados
|
|
||||||
CREATE OR REPLACE VIEW auth.users_oauth_summary AS
|
|
||||||
SELECT
|
|
||||||
u.id,
|
|
||||||
u.email,
|
|
||||||
u.full_name,
|
|
||||||
u.oauth_only,
|
|
||||||
COUNT(ol.id) as linked_providers_count,
|
|
||||||
ARRAY_AGG(op.name) FILTER (WHERE op.id IS NOT NULL) as linked_provider_names,
|
|
||||||
MAX(ol.last_login_at) as last_oauth_login
|
|
||||||
FROM auth.users u
|
|
||||||
LEFT JOIN auth.oauth_user_links ol ON ol.user_id = u.id
|
|
||||||
LEFT JOIN auth.oauth_providers op ON op.id = ol.provider_id
|
|
||||||
WHERE u.deleted_at IS NULL
|
|
||||||
GROUP BY u.id;
|
|
||||||
|
|
||||||
COMMENT ON VIEW auth.users_oauth_summary IS 'Vista de usuarios con sus proveedores OAuth vinculados';
|
|
||||||
|
|
||||||
-- Vista: Permisos efectivos por usuario y modelo
|
|
||||||
CREATE OR REPLACE VIEW auth.user_model_access_view AS
|
|
||||||
SELECT DISTINCT
|
|
||||||
u.id as user_id,
|
|
||||||
u.email,
|
|
||||||
m.name as model_name,
|
|
||||||
BOOL_OR(ma.perm_read) as can_read,
|
|
||||||
BOOL_OR(ma.perm_create) as can_create,
|
|
||||||
BOOL_OR(ma.perm_write) as can_write,
|
|
||||||
BOOL_OR(ma.perm_delete) as can_delete
|
|
||||||
FROM auth.users u
|
|
||||||
CROSS JOIN auth.models m
|
|
||||||
LEFT JOIN auth.user_groups ug ON ug.user_id = u.id
|
|
||||||
LEFT JOIN auth.model_access ma ON ma.model_id = m.id
|
|
||||||
AND (ma.group_id IS NULL OR ma.group_id = ug.group_id)
|
|
||||||
AND ma.is_active = TRUE
|
|
||||||
WHERE u.deleted_at IS NULL
|
|
||||||
GROUP BY u.id, u.email, m.name;
|
|
||||||
|
|
||||||
COMMENT ON VIEW auth.user_model_access_view IS 'Vista de permisos ACL efectivos por usuario y modelo';
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- PARTE 11: DATOS INICIALES
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Proveedores OAuth2 preconfigurados (template)
|
|
||||||
-- NOTA: Solo se insertan como template, requieren client_id y client_secret
|
|
||||||
INSERT INTO auth.oauth_providers (
|
|
||||||
code, name,
|
|
||||||
authorization_endpoint, token_endpoint, userinfo_endpoint, jwks_uri,
|
|
||||||
scope, icon_class, button_text, button_color,
|
|
||||||
claim_mapping, display_order, is_enabled, client_id
|
|
||||||
) VALUES
|
|
||||||
-- Google
|
|
||||||
(
|
|
||||||
'google', 'Google',
|
|
||||||
'https://accounts.google.com/o/oauth2/v2/auth',
|
|
||||||
'https://oauth2.googleapis.com/token',
|
|
||||||
'https://openidconnect.googleapis.com/v1/userinfo',
|
|
||||||
'https://www.googleapis.com/oauth2/v3/certs',
|
|
||||||
'openid profile email',
|
|
||||||
'fa-google', 'Continuar con Google', '#4285F4',
|
|
||||||
'{"sub": "oauth_uid", "email": "email", "name": "name", "picture": "avatar_url"}',
|
|
||||||
1, FALSE, 'CONFIGURE_ME'
|
|
||||||
),
|
|
||||||
-- Microsoft Azure AD
|
|
||||||
(
|
|
||||||
'microsoft', 'Microsoft',
|
|
||||||
'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
|
|
||||||
'https://login.microsoftonline.com/common/oauth2/v2.0/token',
|
|
||||||
'https://graph.microsoft.com/v1.0/me',
|
|
||||||
'https://login.microsoftonline.com/common/discovery/v2.0/keys',
|
|
||||||
'openid profile email User.Read',
|
|
||||||
'fa-microsoft', 'Continuar con Microsoft', '#00A4EF',
|
|
||||||
'{"id": "oauth_uid", "mail": "email", "displayName": "name"}',
|
|
||||||
2, FALSE, 'CONFIGURE_ME'
|
|
||||||
),
|
|
||||||
-- GitHub
|
|
||||||
(
|
|
||||||
'github', 'GitHub',
|
|
||||||
'https://github.com/login/oauth/authorize',
|
|
||||||
'https://github.com/login/oauth/access_token',
|
|
||||||
'https://api.github.com/user',
|
|
||||||
NULL,
|
|
||||||
'read:user user:email',
|
|
||||||
'fa-github', 'Continuar con GitHub', '#333333',
|
|
||||||
'{"id": "oauth_uid", "email": "email", "name": "name", "avatar_url": "avatar_url"}',
|
|
||||||
3, FALSE, 'CONFIGURE_ME'
|
|
||||||
)
|
|
||||||
ON CONFLICT (code) DO NOTHING;
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- COMENTARIOS EN TABLAS
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
COMMENT ON TABLE auth.groups IS 'Grupos de usuarios con herencia para control de acceso';
|
|
||||||
COMMENT ON TABLE auth.group_implied IS 'Herencia entre grupos (A implica B)';
|
|
||||||
COMMENT ON TABLE auth.user_groups IS 'Asignación de usuarios a grupos (many-to-many)';
|
|
||||||
COMMENT ON TABLE auth.models IS 'Definición de modelos del sistema para ACL';
|
|
||||||
COMMENT ON TABLE auth.model_access IS 'Permisos CRUD a nivel de modelo por grupo (ACL)';
|
|
||||||
COMMENT ON TABLE auth.record_rules IS 'Reglas de acceso a nivel de registro (row-level security)';
|
|
||||||
COMMENT ON TABLE auth.rule_groups IS 'Relación entre record rules y grupos';
|
|
||||||
COMMENT ON TABLE auth.model_fields IS 'Campos de modelo con metadatos de seguridad';
|
|
||||||
COMMENT ON TABLE auth.field_permissions IS 'Permisos de lectura/escritura por campo y grupo';
|
|
||||||
COMMENT ON TABLE auth.api_keys IS 'API Keys para autenticación de integraciones externas';
|
|
||||||
COMMENT ON TABLE auth.trusted_devices IS 'Dispositivos de confianza para bypass de 2FA';
|
|
||||||
COMMENT ON TABLE auth.verification_codes IS 'Códigos de verificación temporales para 2FA';
|
|
||||||
COMMENT ON TABLE auth.mfa_audit_log IS 'Log de auditoría de eventos MFA';
|
|
||||||
COMMENT ON TABLE auth.oauth_providers IS 'Proveedores OAuth2 configurados';
|
|
||||||
COMMENT ON TABLE auth.oauth_user_links IS 'Vinculación de usuarios con proveedores OAuth';
|
|
||||||
COMMENT ON TABLE auth.oauth_states IS 'Estados OAuth2 temporales para protección CSRF';
|
|
||||||
|
|
||||||
COMMENT ON COLUMN auth.api_keys.key_index IS 'Primeros 16 hex chars del key para lookup O(1)';
|
|
||||||
COMMENT ON COLUMN auth.api_keys.key_hash IS 'Hash PBKDF2-SHA512 del key completo';
|
|
||||||
COMMENT ON COLUMN auth.api_keys.scope IS 'Scope del API key (NULL=full, rpc=API only)';
|
|
||||||
COMMENT ON COLUMN auth.groups.api_key_max_duration_days IS 'Máxima duración en días para API keys de usuarios de este grupo (0=ilimitado)';
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- FIN DE EXTENSIONES AUTH
|
|
||||||
-- =====================================================
|
|
||||||
271
ddl/01-auth-profiles.sql
Normal file
271
ddl/01-auth-profiles.sql
Normal file
@ -0,0 +1,271 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- ARCHIVO: 01-auth-profiles.sql
|
||||||
|
-- DESCRIPCION: Perfiles de usuario, herramientas y personas responsables
|
||||||
|
-- VERSION: 1.0.0
|
||||||
|
-- PROYECTO: ERP-Core V2
|
||||||
|
-- FECHA: 2026-01-10
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- SCHEMA: auth (si no existe)
|
||||||
|
-- =====================
|
||||||
|
CREATE SCHEMA IF NOT EXISTS auth;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: persons
|
||||||
|
-- Personas fisicas responsables de cuentas (Persona Fisica/Moral)
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS auth.persons (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
-- Datos personales
|
||||||
|
full_name VARCHAR(200) NOT NULL,
|
||||||
|
first_name VARCHAR(100),
|
||||||
|
last_name VARCHAR(100),
|
||||||
|
maternal_name VARCHAR(100),
|
||||||
|
|
||||||
|
-- Contacto
|
||||||
|
email VARCHAR(255) NOT NULL,
|
||||||
|
phone VARCHAR(20),
|
||||||
|
mobile_phone VARCHAR(20),
|
||||||
|
|
||||||
|
-- Identificacion oficial
|
||||||
|
identification_type VARCHAR(50), -- INE, pasaporte, cedula_profesional
|
||||||
|
identification_number VARCHAR(50),
|
||||||
|
identification_expiry DATE,
|
||||||
|
|
||||||
|
-- Direccion
|
||||||
|
address JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
is_verified BOOLEAN DEFAULT FALSE,
|
||||||
|
verified_at TIMESTAMPTZ,
|
||||||
|
verified_by UUID,
|
||||||
|
is_responsible_for_tenant BOOLEAN DEFAULT FALSE,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para persons
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_persons_email ON auth.persons(email);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_persons_identification ON auth.persons(identification_type, identification_number);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: user_profiles
|
||||||
|
-- Perfiles de usuario del sistema (ADM, CNT, VNT, etc.)
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS auth.user_profiles (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
code VARCHAR(10) NOT NULL,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
is_system BOOLEAN DEFAULT FALSE,
|
||||||
|
color VARCHAR(20),
|
||||||
|
icon VARCHAR(50),
|
||||||
|
|
||||||
|
-- Permisos base
|
||||||
|
base_permissions JSONB DEFAULT '[]',
|
||||||
|
available_modules TEXT[] DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Precios y plataformas
|
||||||
|
monthly_price DECIMAL(10,2) DEFAULT 0,
|
||||||
|
included_platforms TEXT[] DEFAULT '{web}',
|
||||||
|
|
||||||
|
-- Configuracion de herramientas
|
||||||
|
default_tools TEXT[] DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Feature flags especificos del perfil
|
||||||
|
feature_flags JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by UUID REFERENCES auth.users(id),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_by UUID REFERENCES auth.users(id),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
UNIQUE(tenant_id, code)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para user_profiles
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_profiles_tenant ON auth.user_profiles(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_profiles_code ON auth.user_profiles(code);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: profile_tools
|
||||||
|
-- Herramientas disponibles por perfil
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS auth.profile_tools (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
profile_id UUID NOT NULL REFERENCES auth.user_profiles(id) ON DELETE CASCADE,
|
||||||
|
tool_code VARCHAR(50) NOT NULL,
|
||||||
|
tool_name VARCHAR(100) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
category VARCHAR(50),
|
||||||
|
is_mobile_only BOOLEAN DEFAULT FALSE,
|
||||||
|
is_web_only BOOLEAN DEFAULT FALSE,
|
||||||
|
icon VARCHAR(50),
|
||||||
|
configuration JSONB DEFAULT '{}',
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
UNIQUE(profile_id, tool_code)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para profile_tools
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_profile_tools_profile ON auth.profile_tools(profile_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_profile_tools_code ON auth.profile_tools(tool_code);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: profile_modules
|
||||||
|
-- Modulos accesibles por perfil
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS auth.profile_modules (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
profile_id UUID NOT NULL REFERENCES auth.user_profiles(id) ON DELETE CASCADE,
|
||||||
|
module_code VARCHAR(50) NOT NULL,
|
||||||
|
access_level VARCHAR(20) NOT NULL DEFAULT 'read', -- read, write, admin
|
||||||
|
can_export BOOLEAN DEFAULT FALSE,
|
||||||
|
can_print BOOLEAN DEFAULT TRUE,
|
||||||
|
|
||||||
|
UNIQUE(profile_id, module_code)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para profile_modules
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_profile_modules_profile ON auth.profile_modules(profile_id);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: user_profile_assignments
|
||||||
|
-- Asignacion de perfiles a usuarios
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS auth.user_profile_assignments (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
profile_id UUID NOT NULL REFERENCES auth.user_profiles(id) ON DELETE CASCADE,
|
||||||
|
is_primary BOOLEAN DEFAULT FALSE,
|
||||||
|
assigned_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
assigned_by UUID REFERENCES auth.users(id),
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
UNIQUE(user_id, profile_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para user_profile_assignments
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_profile_assignments_user ON auth.user_profile_assignments(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_profile_assignments_profile ON auth.user_profile_assignments(profile_id);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- RLS POLICIES
|
||||||
|
-- =====================
|
||||||
|
ALTER TABLE auth.user_profiles ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_profiles ON auth.user_profiles
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid OR tenant_id IS NULL);
|
||||||
|
|
||||||
|
ALTER TABLE auth.profile_tools ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_profile_tools ON auth.profile_tools
|
||||||
|
USING (profile_id IN (
|
||||||
|
SELECT id FROM auth.user_profiles
|
||||||
|
WHERE tenant_id = current_setting('app.current_tenant_id', true)::uuid OR tenant_id IS NULL
|
||||||
|
));
|
||||||
|
|
||||||
|
ALTER TABLE auth.profile_modules ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_profile_modules ON auth.profile_modules
|
||||||
|
USING (profile_id IN (
|
||||||
|
SELECT id FROM auth.user_profiles
|
||||||
|
WHERE tenant_id = current_setting('app.current_tenant_id', true)::uuid OR tenant_id IS NULL
|
||||||
|
));
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- SEED DATA: Perfiles del Sistema
|
||||||
|
-- =====================
|
||||||
|
INSERT INTO auth.user_profiles (id, tenant_id, code, name, description, is_system, monthly_price, included_platforms, available_modules, icon, color) VALUES
|
||||||
|
('00000000-0000-0000-0000-000000000001', NULL, 'ADM', 'Administrador', 'Control total del sistema', TRUE, 500, '{web,mobile,desktop}', '{all}', 'shield', '#dc2626'),
|
||||||
|
('00000000-0000-0000-0000-000000000002', NULL, 'CNT', 'Contabilidad', 'Operaciones contables y fiscales', TRUE, 350, '{web}', '{financial,reports,partners,audit}', 'calculator', '#059669'),
|
||||||
|
('00000000-0000-0000-0000-000000000003', NULL, 'VNT', 'Ventas', 'Punto de venta y CRM', TRUE, 250, '{web,mobile}', '{sales,crm,inventory,partners,reports}', 'shopping-cart', '#2563eb'),
|
||||||
|
('00000000-0000-0000-0000-000000000004', NULL, 'CMP', 'Compras', 'Gestion de proveedores y compras', TRUE, 200, '{web}', '{purchases,inventory,partners}', 'truck', '#7c3aed'),
|
||||||
|
('00000000-0000-0000-0000-000000000005', NULL, 'ALM', 'Almacen', 'Inventario y logistica', TRUE, 150, '{mobile}', '{inventory}', 'package', '#ea580c'),
|
||||||
|
('00000000-0000-0000-0000-000000000006', NULL, 'HRH', 'Recursos Humanos', 'Gestion de personal', TRUE, 300, '{web}', '{hr,partners,reports}', 'users', '#db2777'),
|
||||||
|
('00000000-0000-0000-0000-000000000007', NULL, 'PRD', 'Produccion', 'Manufactura y proyectos', TRUE, 200, '{web,mobile}', '{projects,inventory}', 'factory', '#ca8a04'),
|
||||||
|
('00000000-0000-0000-0000-000000000008', NULL, 'EMP', 'Empleado', 'Acceso self-service basico', TRUE, 50, '{mobile}', '{hr}', 'user', '#64748b'),
|
||||||
|
('00000000-0000-0000-0000-000000000009', NULL, 'GER', 'Gerente', 'Reportes y dashboards ejecutivos', TRUE, 400, '{web,mobile}', '{reports,dashboards,financial,sales,inventory}', 'bar-chart', '#0891b2'),
|
||||||
|
('00000000-0000-0000-0000-00000000000A', NULL, 'AUD', 'Auditor', 'Acceso de solo lectura para auditorias', TRUE, 150, '{web}', '{audit,reports,financial}', 'search', '#4b5563')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- SEED DATA: Herramientas por Perfil
|
||||||
|
-- =====================
|
||||||
|
|
||||||
|
-- Herramientas para CONTABILIDAD (CNT)
|
||||||
|
INSERT INTO auth.profile_tools (profile_id, tool_code, tool_name, description, category, is_web_only, icon, sort_order) VALUES
|
||||||
|
('00000000-0000-0000-0000-000000000002', 'calculadora_fiscal', 'Calculadora Fiscal', 'Calculo de impuestos y retenciones', 'fiscal', TRUE, 'calculator', 1),
|
||||||
|
('00000000-0000-0000-0000-000000000002', 'generador_cfdi', 'Generador CFDI', 'Generacion de comprobantes fiscales', 'fiscal', TRUE, 'file-text', 2),
|
||||||
|
('00000000-0000-0000-0000-000000000002', 'conciliacion_bancaria', 'Conciliacion Bancaria', 'Conciliar movimientos bancarios', 'contabilidad', TRUE, 'git-merge', 3),
|
||||||
|
('00000000-0000-0000-0000-000000000002', 'reportes_sat', 'Reportes SAT', 'Generacion de reportes para SAT', 'fiscal', TRUE, 'file-spreadsheet', 4),
|
||||||
|
('00000000-0000-0000-0000-000000000002', 'balance_general', 'Balance General', 'Generacion de balance general', 'contabilidad', TRUE, 'scale', 5),
|
||||||
|
('00000000-0000-0000-0000-000000000002', 'estado_resultados', 'Estado de Resultados', 'Generacion de estado de resultados', 'contabilidad', TRUE, 'trending-up', 6)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Herramientas para VENTAS (VNT)
|
||||||
|
INSERT INTO auth.profile_tools (profile_id, tool_code, tool_name, description, category, is_mobile_only, icon, sort_order) VALUES
|
||||||
|
('00000000-0000-0000-0000-000000000003', 'pos_movil', 'POS Movil', 'Punto de venta en dispositivo movil', 'ventas', TRUE, 'smartphone', 1),
|
||||||
|
('00000000-0000-0000-0000-000000000003', 'cotizador_rapido', 'Cotizador Rapido', 'Generar cotizaciones rapidamente', 'ventas', FALSE, 'file-plus', 2),
|
||||||
|
('00000000-0000-0000-0000-000000000003', 'catalogo_productos', 'Catalogo de Productos', 'Consultar catalogo con precios', 'ventas', FALSE, 'book-open', 3),
|
||||||
|
('00000000-0000-0000-0000-000000000003', 'terminal_pago', 'Terminal de Pago', 'Cobrar con terminal Clip/MercadoPago', 'ventas', TRUE, 'credit-card', 4),
|
||||||
|
('00000000-0000-0000-0000-000000000003', 'registro_visitas', 'Registro de Visitas', 'Registrar visitas a clientes con GPS', 'crm', TRUE, 'map-pin', 5)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Herramientas para ALMACEN (ALM)
|
||||||
|
INSERT INTO auth.profile_tools (profile_id, tool_code, tool_name, description, category, is_mobile_only, icon, sort_order) VALUES
|
||||||
|
('00000000-0000-0000-0000-000000000005', 'escaner_barcode', 'Escaner Codigo de Barras', 'Escanear productos por codigo de barras', 'inventario', TRUE, 'scan-line', 1),
|
||||||
|
('00000000-0000-0000-0000-000000000005', 'escaner_qr', 'Escaner QR', 'Escanear codigos QR', 'inventario', TRUE, 'qr-code', 2),
|
||||||
|
('00000000-0000-0000-0000-000000000005', 'conteo_fisico', 'Conteo Fisico', 'Realizar conteos de inventario', 'inventario', TRUE, 'clipboard-list', 3),
|
||||||
|
('00000000-0000-0000-0000-000000000005', 'recepcion_mercancia', 'Recepcion de Mercancia', 'Registrar recepciones de compras', 'inventario', TRUE, 'package-check', 4),
|
||||||
|
('00000000-0000-0000-0000-000000000005', 'transferencias', 'Transferencias', 'Transferir entre ubicaciones', 'inventario', TRUE, 'repeat', 5),
|
||||||
|
('00000000-0000-0000-0000-000000000005', 'etiquetado', 'Etiquetado', 'Imprimir etiquetas de productos', 'inventario', FALSE, 'tag', 6)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Herramientas para RRHH (HRH)
|
||||||
|
INSERT INTO auth.profile_tools (profile_id, tool_code, tool_name, description, category, icon, sort_order) VALUES
|
||||||
|
('00000000-0000-0000-0000-000000000006', 'reloj_checador', 'Reloj Checador', 'Control de asistencia con biometrico', 'asistencia', 'clock', 1),
|
||||||
|
('00000000-0000-0000-0000-000000000006', 'control_asistencia', 'Control de Asistencia', 'Reportes de asistencia', 'asistencia', 'calendar-check', 2),
|
||||||
|
('00000000-0000-0000-0000-000000000006', 'nomina', 'Nomina', 'Gestion de nomina', 'nomina', 'dollar-sign', 3),
|
||||||
|
('00000000-0000-0000-0000-000000000006', 'expedientes', 'Expedientes', 'Gestion de expedientes de empleados', 'personal', 'folder', 4),
|
||||||
|
('00000000-0000-0000-0000-000000000006', 'vacaciones_permisos', 'Vacaciones y Permisos', 'Gestion de ausencias', 'personal', 'calendar-x', 5),
|
||||||
|
('00000000-0000-0000-0000-000000000006', 'organigrama', 'Organigrama', 'Visualizar estructura organizacional', 'personal', 'git-branch', 6)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Herramientas para EMPLEADO (EMP)
|
||||||
|
INSERT INTO auth.profile_tools (profile_id, tool_code, tool_name, description, category, is_mobile_only, icon, sort_order) VALUES
|
||||||
|
('00000000-0000-0000-0000-000000000008', 'checada_entrada', 'Checada Entrada/Salida', 'Registrar entrada y salida con GPS y biometrico', 'asistencia', TRUE, 'log-in', 1),
|
||||||
|
('00000000-0000-0000-0000-000000000008', 'mis_recibos', 'Mis Recibos de Nomina', 'Consultar recibos de nomina', 'nomina', TRUE, 'file-text', 2),
|
||||||
|
('00000000-0000-0000-0000-000000000008', 'solicitar_permiso', 'Solicitar Permiso', 'Solicitar permisos o vacaciones', 'personal', TRUE, 'calendar-plus', 3),
|
||||||
|
('00000000-0000-0000-0000-000000000008', 'mi_horario', 'Mi Horario', 'Consultar mi horario asignado', 'asistencia', TRUE, 'clock', 4)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Herramientas para GERENTE (GER)
|
||||||
|
INSERT INTO auth.profile_tools (profile_id, tool_code, tool_name, description, category, icon, sort_order) VALUES
|
||||||
|
('00000000-0000-0000-0000-000000000009', 'dashboard_ejecutivo', 'Dashboard Ejecutivo', 'Vista general de KPIs del negocio', 'reportes', 'layout-dashboard', 1),
|
||||||
|
('00000000-0000-0000-0000-000000000009', 'reportes_ventas', 'Reportes de Ventas', 'Analisis de ventas y tendencias', 'reportes', 'trending-up', 2),
|
||||||
|
('00000000-0000-0000-0000-000000000009', 'reportes_financieros', 'Reportes Financieros', 'Estados financieros resumidos', 'reportes', 'pie-chart', 3),
|
||||||
|
('00000000-0000-0000-0000-000000000009', 'alertas_negocio', 'Alertas de Negocio', 'Notificaciones de eventos importantes', 'alertas', 'bell', 4)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Herramientas para AUDITOR (AUD)
|
||||||
|
INSERT INTO auth.profile_tools (profile_id, tool_code, tool_name, description, category, is_web_only, icon, sort_order) VALUES
|
||||||
|
('00000000-0000-0000-0000-00000000000A', 'visor_auditoria', 'Visor de Auditoria', 'Consultar logs de auditoria', 'auditoria', TRUE, 'search', 1),
|
||||||
|
('00000000-0000-0000-0000-00000000000A', 'exportador_datos', 'Exportador de Datos', 'Exportar datos para analisis', 'auditoria', TRUE, 'download', 2),
|
||||||
|
('00000000-0000-0000-0000-00000000000A', 'comparador_periodos', 'Comparador de Periodos', 'Comparar datos entre periodos', 'auditoria', TRUE, 'git-compare', 3)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- COMENTARIOS DE TABLAS
|
||||||
|
-- =====================
|
||||||
|
COMMENT ON TABLE auth.persons IS 'Personas fisicas responsables de cuentas (representante legal de Persona Moral o titular de Persona Fisica)';
|
||||||
|
COMMENT ON TABLE auth.user_profiles IS 'Perfiles de usuario del sistema con precios y configuraciones';
|
||||||
|
COMMENT ON TABLE auth.profile_tools IS 'Herramientas disponibles para cada perfil';
|
||||||
|
COMMENT ON TABLE auth.profile_modules IS 'Modulos del sistema accesibles por perfil';
|
||||||
|
COMMENT ON TABLE auth.user_profile_assignments IS 'Asignacion de perfiles a usuarios';
|
||||||
620
ddl/01-auth.sql
620
ddl/01-auth.sql
@ -1,620 +0,0 @@
|
|||||||
-- =====================================================
|
|
||||||
-- SCHEMA: auth
|
|
||||||
-- PROPÓSITO: Autenticación, usuarios, roles, permisos
|
|
||||||
-- MÓDULOS: MGN-001 (Fundamentos), MGN-002 (Empresas)
|
|
||||||
-- FECHA: 2025-11-24
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Crear schema
|
|
||||||
CREATE SCHEMA IF NOT EXISTS auth;
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- TYPES (ENUMs)
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
CREATE TYPE auth.user_status AS ENUM (
|
|
||||||
'active',
|
|
||||||
'inactive',
|
|
||||||
'suspended',
|
|
||||||
'pending_verification'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE auth.tenant_status AS ENUM (
|
|
||||||
'active',
|
|
||||||
'suspended',
|
|
||||||
'trial',
|
|
||||||
'cancelled'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE auth.session_status AS ENUM (
|
|
||||||
'active',
|
|
||||||
'expired',
|
|
||||||
'revoked'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE auth.permission_action AS ENUM (
|
|
||||||
'create',
|
|
||||||
'read',
|
|
||||||
'update',
|
|
||||||
'delete',
|
|
||||||
'approve',
|
|
||||||
'cancel',
|
|
||||||
'export'
|
|
||||||
);
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- TABLES
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Tabla: tenants (Multi-Tenancy)
|
|
||||||
CREATE TABLE auth.tenants (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
subdomain VARCHAR(100) UNIQUE NOT NULL,
|
|
||||||
schema_name VARCHAR(100) UNIQUE NOT NULL,
|
|
||||||
status auth.tenant_status NOT NULL DEFAULT 'active',
|
|
||||||
settings JSONB DEFAULT '{}',
|
|
||||||
plan VARCHAR(50) DEFAULT 'basic', -- basic, pro, enterprise
|
|
||||||
max_users INTEGER DEFAULT 10,
|
|
||||||
|
|
||||||
-- Auditoría (tenant no tiene tenant_id)
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID, -- Puede ser NULL para primer tenant
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by UUID,
|
|
||||||
deleted_at TIMESTAMP,
|
|
||||||
deleted_by UUID,
|
|
||||||
|
|
||||||
CONSTRAINT chk_tenants_subdomain_format CHECK (subdomain ~ '^[a-z0-9-]+$'),
|
|
||||||
CONSTRAINT chk_tenants_max_users CHECK (max_users > 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: companies (Multi-Company dentro de tenant)
|
|
||||||
CREATE TABLE auth.companies (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
legal_name VARCHAR(255),
|
|
||||||
tax_id VARCHAR(50),
|
|
||||||
currency_id UUID, -- FK a core.currencies (se crea después)
|
|
||||||
parent_company_id UUID REFERENCES auth.companies(id),
|
|
||||||
partner_id UUID, -- FK a core.partners (se crea después)
|
|
||||||
settings JSONB DEFAULT '{}',
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID,
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by UUID,
|
|
||||||
deleted_at TIMESTAMP,
|
|
||||||
deleted_by UUID,
|
|
||||||
|
|
||||||
CONSTRAINT uq_companies_tax_id_tenant UNIQUE (tenant_id, tax_id),
|
|
||||||
CONSTRAINT chk_companies_no_self_parent CHECK (id != parent_company_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: users
|
|
||||||
CREATE TABLE auth.users (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
email VARCHAR(255) NOT NULL,
|
|
||||||
password_hash VARCHAR(255) NOT NULL,
|
|
||||||
full_name VARCHAR(255) NOT NULL,
|
|
||||||
avatar_url VARCHAR(500),
|
|
||||||
status auth.user_status NOT NULL DEFAULT 'active',
|
|
||||||
is_superuser BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
email_verified_at TIMESTAMP,
|
|
||||||
last_login_at TIMESTAMP,
|
|
||||||
last_login_ip INET,
|
|
||||||
login_count INTEGER DEFAULT 0,
|
|
||||||
language VARCHAR(10) DEFAULT 'es', -- es, en
|
|
||||||
timezone VARCHAR(50) DEFAULT 'America/Mexico_City',
|
|
||||||
settings JSONB DEFAULT '{}',
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID,
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by UUID,
|
|
||||||
deleted_at TIMESTAMP,
|
|
||||||
deleted_by UUID,
|
|
||||||
|
|
||||||
CONSTRAINT uq_users_email_tenant UNIQUE (tenant_id, email),
|
|
||||||
CONSTRAINT chk_users_email_format CHECK (email ~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}$')
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: roles
|
|
||||||
CREATE TABLE auth.roles (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
name VARCHAR(100) NOT NULL,
|
|
||||||
code VARCHAR(50) NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
is_system BOOLEAN NOT NULL DEFAULT FALSE, -- Roles del sistema no editables
|
|
||||||
color VARCHAR(20),
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
deleted_at TIMESTAMP,
|
|
||||||
deleted_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT uq_roles_code_tenant UNIQUE (tenant_id, code)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: permissions
|
|
||||||
CREATE TABLE auth.permissions (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
resource VARCHAR(100) NOT NULL, -- Tabla/endpoint
|
|
||||||
action auth.permission_action NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
module VARCHAR(50), -- MGN-001, MGN-004, etc.
|
|
||||||
|
|
||||||
-- Sin tenant_id: permisos son globales
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT uq_permissions_resource_action UNIQUE (resource, action)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: user_roles (many-to-many)
|
|
||||||
CREATE TABLE auth.user_roles (
|
|
||||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
||||||
role_id UUID NOT NULL REFERENCES auth.roles(id) ON DELETE CASCADE,
|
|
||||||
assigned_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
assigned_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
PRIMARY KEY (user_id, role_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: role_permissions (many-to-many)
|
|
||||||
CREATE TABLE auth.role_permissions (
|
|
||||||
role_id UUID NOT NULL REFERENCES auth.roles(id) ON DELETE CASCADE,
|
|
||||||
permission_id UUID NOT NULL REFERENCES auth.permissions(id) ON DELETE CASCADE,
|
|
||||||
granted_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
granted_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
PRIMARY KEY (role_id, permission_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: sessions
|
|
||||||
CREATE TABLE auth.sessions (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
||||||
token VARCHAR(500) NOT NULL UNIQUE,
|
|
||||||
refresh_token VARCHAR(500) UNIQUE,
|
|
||||||
status auth.session_status NOT NULL DEFAULT 'active',
|
|
||||||
expires_at TIMESTAMP NOT NULL,
|
|
||||||
refresh_expires_at TIMESTAMP,
|
|
||||||
ip_address INET,
|
|
||||||
user_agent TEXT,
|
|
||||||
device_info JSONB,
|
|
||||||
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
revoked_at TIMESTAMP,
|
|
||||||
revoked_reason VARCHAR(100),
|
|
||||||
|
|
||||||
CONSTRAINT chk_sessions_expiration CHECK (expires_at > created_at),
|
|
||||||
CONSTRAINT chk_sessions_refresh_expiration CHECK (
|
|
||||||
refresh_expires_at IS NULL OR refresh_expires_at > expires_at
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: user_companies (many-to-many)
|
|
||||||
-- Usuario puede acceder a múltiples empresas
|
|
||||||
CREATE TABLE auth.user_companies (
|
|
||||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
||||||
company_id UUID NOT NULL REFERENCES auth.companies(id) ON DELETE CASCADE,
|
|
||||||
is_default BOOLEAN DEFAULT FALSE,
|
|
||||||
assigned_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
PRIMARY KEY (user_id, company_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: password_resets
|
|
||||||
CREATE TABLE auth.password_resets (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
||||||
token VARCHAR(500) NOT NULL UNIQUE,
|
|
||||||
expires_at TIMESTAMP NOT NULL,
|
|
||||||
used_at TIMESTAMP,
|
|
||||||
ip_address INET,
|
|
||||||
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT chk_password_resets_expiration CHECK (expires_at > created_at)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- INDICES
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Tenants
|
|
||||||
CREATE INDEX idx_tenants_subdomain ON auth.tenants(subdomain);
|
|
||||||
CREATE INDEX idx_tenants_status ON auth.tenants(status) WHERE deleted_at IS NULL;
|
|
||||||
CREATE INDEX idx_tenants_created_at ON auth.tenants(created_at);
|
|
||||||
|
|
||||||
-- Companies
|
|
||||||
CREATE INDEX idx_companies_tenant_id ON auth.companies(tenant_id);
|
|
||||||
CREATE INDEX idx_companies_parent_company_id ON auth.companies(parent_company_id);
|
|
||||||
CREATE INDEX idx_companies_active ON auth.companies(tenant_id) WHERE deleted_at IS NULL;
|
|
||||||
CREATE INDEX idx_companies_tax_id ON auth.companies(tax_id);
|
|
||||||
|
|
||||||
-- Users
|
|
||||||
CREATE INDEX idx_users_tenant_id ON auth.users(tenant_id);
|
|
||||||
CREATE INDEX idx_users_email ON auth.users(email);
|
|
||||||
CREATE INDEX idx_users_status ON auth.users(status) WHERE deleted_at IS NULL;
|
|
||||||
CREATE INDEX idx_users_email_tenant ON auth.users(tenant_id, email);
|
|
||||||
CREATE INDEX idx_users_created_at ON auth.users(created_at);
|
|
||||||
|
|
||||||
-- Roles
|
|
||||||
CREATE INDEX idx_roles_tenant_id ON auth.roles(tenant_id);
|
|
||||||
CREATE INDEX idx_roles_code ON auth.roles(code);
|
|
||||||
CREATE INDEX idx_roles_is_system ON auth.roles(is_system);
|
|
||||||
|
|
||||||
-- Permissions
|
|
||||||
CREATE INDEX idx_permissions_resource ON auth.permissions(resource);
|
|
||||||
CREATE INDEX idx_permissions_action ON auth.permissions(action);
|
|
||||||
CREATE INDEX idx_permissions_module ON auth.permissions(module);
|
|
||||||
|
|
||||||
-- Sessions
|
|
||||||
CREATE INDEX idx_sessions_user_id ON auth.sessions(user_id);
|
|
||||||
CREATE INDEX idx_sessions_token ON auth.sessions(token);
|
|
||||||
CREATE INDEX idx_sessions_status ON auth.sessions(status);
|
|
||||||
CREATE INDEX idx_sessions_expires_at ON auth.sessions(expires_at);
|
|
||||||
|
|
||||||
-- User Roles
|
|
||||||
CREATE INDEX idx_user_roles_user_id ON auth.user_roles(user_id);
|
|
||||||
CREATE INDEX idx_user_roles_role_id ON auth.user_roles(role_id);
|
|
||||||
|
|
||||||
-- Role Permissions
|
|
||||||
CREATE INDEX idx_role_permissions_role_id ON auth.role_permissions(role_id);
|
|
||||||
CREATE INDEX idx_role_permissions_permission_id ON auth.role_permissions(permission_id);
|
|
||||||
|
|
||||||
-- User Companies
|
|
||||||
CREATE INDEX idx_user_companies_user_id ON auth.user_companies(user_id);
|
|
||||||
CREATE INDEX idx_user_companies_company_id ON auth.user_companies(company_id);
|
|
||||||
|
|
||||||
-- Password Resets
|
|
||||||
CREATE INDEX idx_password_resets_user_id ON auth.password_resets(user_id);
|
|
||||||
CREATE INDEX idx_password_resets_token ON auth.password_resets(token);
|
|
||||||
CREATE INDEX idx_password_resets_expires_at ON auth.password_resets(expires_at);
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- FUNCTIONS
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Función: get_current_tenant_id
|
|
||||||
CREATE OR REPLACE FUNCTION get_current_tenant_id()
|
|
||||||
RETURNS UUID AS $$
|
|
||||||
BEGIN
|
|
||||||
RETURN current_setting('app.current_tenant_id', true)::UUID;
|
|
||||||
EXCEPTION
|
|
||||||
WHEN OTHERS THEN
|
|
||||||
RETURN NULL;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION get_current_tenant_id() IS 'Obtiene el tenant_id del contexto actual';
|
|
||||||
|
|
||||||
-- Función: get_current_user_id
|
|
||||||
CREATE OR REPLACE FUNCTION get_current_user_id()
|
|
||||||
RETURNS UUID AS $$
|
|
||||||
BEGIN
|
|
||||||
RETURN current_setting('app.current_user_id', true)::UUID;
|
|
||||||
EXCEPTION
|
|
||||||
WHEN OTHERS THEN
|
|
||||||
RETURN NULL;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION get_current_user_id() IS 'Obtiene el user_id del contexto actual';
|
|
||||||
|
|
||||||
-- Función: get_current_company_id
|
|
||||||
CREATE OR REPLACE FUNCTION get_current_company_id()
|
|
||||||
RETURNS UUID AS $$
|
|
||||||
BEGIN
|
|
||||||
RETURN current_setting('app.current_company_id', true)::UUID;
|
|
||||||
EXCEPTION
|
|
||||||
WHEN OTHERS THEN
|
|
||||||
RETURN NULL;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION get_current_company_id() IS 'Obtiene el company_id del contexto actual';
|
|
||||||
|
|
||||||
-- Función: user_has_permission
|
|
||||||
CREATE OR REPLACE FUNCTION auth.user_has_permission(
|
|
||||||
p_user_id UUID,
|
|
||||||
p_resource VARCHAR,
|
|
||||||
p_action auth.permission_action
|
|
||||||
)
|
|
||||||
RETURNS BOOLEAN AS $$
|
|
||||||
DECLARE
|
|
||||||
v_has_permission BOOLEAN;
|
|
||||||
BEGIN
|
|
||||||
-- Superusers tienen todos los permisos
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1 FROM auth.users
|
|
||||||
WHERE id = p_user_id AND is_superuser = TRUE AND deleted_at IS NULL
|
|
||||||
) THEN
|
|
||||||
RETURN TRUE;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- Verificar si el usuario tiene el permiso a través de sus roles
|
|
||||||
SELECT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM auth.user_roles ur
|
|
||||||
JOIN auth.role_permissions rp ON ur.role_id = rp.role_id
|
|
||||||
JOIN auth.permissions p ON rp.permission_id = p.id
|
|
||||||
WHERE ur.user_id = p_user_id
|
|
||||||
AND p.resource = p_resource
|
|
||||||
AND p.action = p_action
|
|
||||||
) INTO v_has_permission;
|
|
||||||
|
|
||||||
RETURN v_has_permission;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION auth.user_has_permission IS 'Verifica si un usuario tiene un permiso específico';
|
|
||||||
|
|
||||||
-- Función: clean_expired_sessions
|
|
||||||
CREATE OR REPLACE FUNCTION auth.clean_expired_sessions()
|
|
||||||
RETURNS INTEGER AS $$
|
|
||||||
DECLARE
|
|
||||||
v_deleted_count INTEGER;
|
|
||||||
BEGIN
|
|
||||||
WITH deleted AS (
|
|
||||||
DELETE FROM auth.sessions
|
|
||||||
WHERE status = 'active'
|
|
||||||
AND expires_at < CURRENT_TIMESTAMP
|
|
||||||
RETURNING id
|
|
||||||
)
|
|
||||||
SELECT COUNT(*) INTO v_deleted_count FROM deleted;
|
|
||||||
|
|
||||||
RETURN v_deleted_count;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION auth.clean_expired_sessions IS 'Limpia sesiones expiradas (ejecutar periódicamente)';
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- TRIGGERS
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Trigger: Actualizar updated_at automáticamente
|
|
||||||
CREATE OR REPLACE FUNCTION auth.update_updated_at_column()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
|
||||||
NEW.updated_by = get_current_user_id();
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_tenants_updated_at
|
|
||||||
BEFORE UPDATE ON auth.tenants
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_companies_updated_at
|
|
||||||
BEFORE UPDATE ON auth.companies
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_users_updated_at
|
|
||||||
BEFORE UPDATE ON auth.users
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_roles_updated_at
|
|
||||||
BEFORE UPDATE ON auth.roles
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
-- Trigger: Validar que tenant tenga al menos 1 admin
|
|
||||||
CREATE OR REPLACE FUNCTION auth.validate_tenant_has_admin()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
-- Al eliminar user_role, verificar que no sea el último admin
|
|
||||||
IF TG_OP = 'DELETE' THEN
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM auth.users u
|
|
||||||
JOIN auth.roles r ON r.tenant_id = u.tenant_id
|
|
||||||
WHERE u.id = OLD.user_id
|
|
||||||
AND r.code = 'admin'
|
|
||||||
AND r.id = OLD.role_id
|
|
||||||
) THEN
|
|
||||||
-- Contar admins restantes
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM auth.user_roles ur
|
|
||||||
JOIN auth.roles r ON r.id = ur.role_id
|
|
||||||
JOIN auth.users u ON u.id = ur.user_id
|
|
||||||
WHERE r.code = 'admin'
|
|
||||||
AND u.tenant_id = (SELECT tenant_id FROM auth.users WHERE id = OLD.user_id)
|
|
||||||
AND ur.user_id != OLD.user_id
|
|
||||||
) THEN
|
|
||||||
RAISE EXCEPTION 'Cannot remove last admin from tenant';
|
|
||||||
END IF;
|
|
||||||
END IF;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
RETURN OLD;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_validate_tenant_has_admin
|
|
||||||
BEFORE DELETE ON auth.user_roles
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.validate_tenant_has_admin();
|
|
||||||
|
|
||||||
-- Trigger: Auto-marcar sesión como expirada
|
|
||||||
CREATE OR REPLACE FUNCTION auth.auto_expire_session()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
IF NEW.expires_at < CURRENT_TIMESTAMP AND NEW.status = 'active' THEN
|
|
||||||
NEW.status = 'expired';
|
|
||||||
END IF;
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_auto_expire_session
|
|
||||||
BEFORE UPDATE ON auth.sessions
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.auto_expire_session();
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- ROW LEVEL SECURITY (RLS)
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Habilitar RLS en tablas con tenant_id
|
|
||||||
ALTER TABLE auth.companies ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE auth.users ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE auth.roles ENABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
-- Policy: Tenant Isolation - Companies
|
|
||||||
CREATE POLICY tenant_isolation_companies
|
|
||||||
ON auth.companies
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
-- Policy: Tenant Isolation - Users
|
|
||||||
CREATE POLICY tenant_isolation_users
|
|
||||||
ON auth.users
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
-- Policy: Tenant Isolation - Roles
|
|
||||||
CREATE POLICY tenant_isolation_roles
|
|
||||||
ON auth.roles
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- DATOS INICIALES (Seed Data)
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Permisos estándar para recursos comunes
|
|
||||||
INSERT INTO auth.permissions (resource, action, description, module) VALUES
|
|
||||||
-- Auth
|
|
||||||
('users', 'create', 'Crear usuarios', 'MGN-001'),
|
|
||||||
('users', 'read', 'Ver usuarios', 'MGN-001'),
|
|
||||||
('users', 'update', 'Actualizar usuarios', 'MGN-001'),
|
|
||||||
('users', 'delete', 'Eliminar usuarios', 'MGN-001'),
|
|
||||||
('roles', 'create', 'Crear roles', 'MGN-001'),
|
|
||||||
('roles', 'read', 'Ver roles', 'MGN-001'),
|
|
||||||
('roles', 'update', 'Actualizar roles', 'MGN-001'),
|
|
||||||
('roles', 'delete', 'Eliminar roles', 'MGN-001'),
|
|
||||||
|
|
||||||
-- Financial
|
|
||||||
('invoices', 'create', 'Crear facturas', 'MGN-004'),
|
|
||||||
('invoices', 'read', 'Ver facturas', 'MGN-004'),
|
|
||||||
('invoices', 'update', 'Actualizar facturas', 'MGN-004'),
|
|
||||||
('invoices', 'delete', 'Eliminar facturas', 'MGN-004'),
|
|
||||||
('invoices', 'approve', 'Aprobar facturas', 'MGN-004'),
|
|
||||||
('invoices', 'cancel', 'Cancelar facturas', 'MGN-004'),
|
|
||||||
('journal_entries', 'create', 'Crear asientos contables', 'MGN-004'),
|
|
||||||
('journal_entries', 'read', 'Ver asientos contables', 'MGN-004'),
|
|
||||||
('journal_entries', 'approve', 'Aprobar asientos contables', 'MGN-004'),
|
|
||||||
|
|
||||||
-- Purchase
|
|
||||||
('purchase_orders', 'create', 'Crear órdenes de compra', 'MGN-006'),
|
|
||||||
('purchase_orders', 'read', 'Ver órdenes de compra', 'MGN-006'),
|
|
||||||
('purchase_orders', 'update', 'Actualizar órdenes de compra', 'MGN-006'),
|
|
||||||
('purchase_orders', 'delete', 'Eliminar órdenes de compra', 'MGN-006'),
|
|
||||||
('purchase_orders', 'approve', 'Aprobar órdenes de compra', 'MGN-006'),
|
|
||||||
|
|
||||||
-- Sales
|
|
||||||
('sale_orders', 'create', 'Crear órdenes de venta', 'MGN-007'),
|
|
||||||
('sale_orders', 'read', 'Ver órdenes de venta', 'MGN-007'),
|
|
||||||
('sale_orders', 'update', 'Actualizar órdenes de venta', 'MGN-007'),
|
|
||||||
('sale_orders', 'delete', 'Eliminar órdenes de venta', 'MGN-007'),
|
|
||||||
('sale_orders', 'approve', 'Aprobar órdenes de venta', 'MGN-007'),
|
|
||||||
|
|
||||||
-- Inventory
|
|
||||||
('products', 'create', 'Crear productos', 'MGN-005'),
|
|
||||||
('products', 'read', 'Ver productos', 'MGN-005'),
|
|
||||||
('products', 'update', 'Actualizar productos', 'MGN-005'),
|
|
||||||
('products', 'delete', 'Eliminar productos', 'MGN-005'),
|
|
||||||
('stock_moves', 'create', 'Crear movimientos de inventario', 'MGN-005'),
|
|
||||||
('stock_moves', 'read', 'Ver movimientos de inventario', 'MGN-005'),
|
|
||||||
('stock_moves', 'approve', 'Aprobar movimientos de inventario', 'MGN-005'),
|
|
||||||
|
|
||||||
-- Projects
|
|
||||||
('projects', 'create', 'Crear proyectos', 'MGN-011'),
|
|
||||||
('projects', 'read', 'Ver proyectos', 'MGN-011'),
|
|
||||||
('projects', 'update', 'Actualizar proyectos', 'MGN-011'),
|
|
||||||
('projects', 'delete', 'Eliminar proyectos', 'MGN-011'),
|
|
||||||
('tasks', 'create', 'Crear tareas', 'MGN-011'),
|
|
||||||
('tasks', 'read', 'Ver tareas', 'MGN-011'),
|
|
||||||
('tasks', 'update', 'Actualizar tareas', 'MGN-011'),
|
|
||||||
('tasks', 'delete', 'Eliminar tareas', 'MGN-011'),
|
|
||||||
|
|
||||||
-- Reports
|
|
||||||
('reports', 'read', 'Ver reportes', 'MGN-012'),
|
|
||||||
('reports', 'export', 'Exportar reportes', 'MGN-012');
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- COMENTARIOS EN TABLAS
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
COMMENT ON SCHEMA auth IS 'Schema de autenticación, usuarios, roles y permisos';
|
|
||||||
COMMENT ON TABLE auth.tenants IS 'Tenants (organizaciones raíz) con schema-level isolation';
|
|
||||||
COMMENT ON TABLE auth.companies IS 'Empresas dentro de un tenant (multi-company)';
|
|
||||||
COMMENT ON TABLE auth.users IS 'Usuarios del sistema con RBAC';
|
|
||||||
COMMENT ON TABLE auth.roles IS 'Roles con permisos asignados';
|
|
||||||
COMMENT ON TABLE auth.permissions IS 'Permisos granulares por recurso y acción';
|
|
||||||
COMMENT ON TABLE auth.user_roles IS 'Asignación de roles a usuarios (many-to-many)';
|
|
||||||
COMMENT ON TABLE auth.role_permissions IS 'Asignación de permisos a roles (many-to-many)';
|
|
||||||
COMMENT ON TABLE auth.sessions IS 'Sesiones JWT activas de usuarios';
|
|
||||||
COMMENT ON TABLE auth.user_companies IS 'Asignación de usuarios a empresas (multi-company)';
|
|
||||||
COMMENT ON TABLE auth.password_resets IS 'Tokens de reset de contraseña';
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- VISTAS ÚTILES
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Vista: user_permissions (permisos efectivos de usuario)
|
|
||||||
CREATE OR REPLACE VIEW auth.user_permissions_view AS
|
|
||||||
SELECT DISTINCT
|
|
||||||
ur.user_id,
|
|
||||||
u.email,
|
|
||||||
u.full_name,
|
|
||||||
p.resource,
|
|
||||||
p.action,
|
|
||||||
p.description,
|
|
||||||
r.name as role_name,
|
|
||||||
r.code as role_code
|
|
||||||
FROM auth.user_roles ur
|
|
||||||
JOIN auth.users u ON ur.user_id = u.id
|
|
||||||
JOIN auth.roles r ON ur.role_id = r.id
|
|
||||||
JOIN auth.role_permissions rp ON r.id = rp.role_id
|
|
||||||
JOIN auth.permissions p ON rp.permission_id = p.id
|
|
||||||
WHERE u.deleted_at IS NULL
|
|
||||||
AND u.status = 'active';
|
|
||||||
|
|
||||||
COMMENT ON VIEW auth.user_permissions_view IS 'Vista de permisos efectivos por usuario';
|
|
||||||
|
|
||||||
-- Vista: active_sessions (sesiones activas)
|
|
||||||
CREATE OR REPLACE VIEW auth.active_sessions_view AS
|
|
||||||
SELECT
|
|
||||||
s.id,
|
|
||||||
s.user_id,
|
|
||||||
u.email,
|
|
||||||
u.full_name,
|
|
||||||
s.ip_address,
|
|
||||||
s.user_agent,
|
|
||||||
s.created_at as login_at,
|
|
||||||
s.expires_at,
|
|
||||||
EXTRACT(EPOCH FROM (s.expires_at - CURRENT_TIMESTAMP))/60 as minutes_until_expiry
|
|
||||||
FROM auth.sessions s
|
|
||||||
JOIN auth.users u ON s.user_id = u.id
|
|
||||||
WHERE s.status = 'active'
|
|
||||||
AND s.expires_at > CURRENT_TIMESTAMP;
|
|
||||||
|
|
||||||
COMMENT ON VIEW auth.active_sessions_view IS 'Vista de sesiones activas con tiempo restante';
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- FIN DEL SCHEMA AUTH
|
|
||||||
-- =====================================================
|
|
||||||
252
ddl/02-auth-devices.sql
Normal file
252
ddl/02-auth-devices.sql
Normal file
@ -0,0 +1,252 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- ARCHIVO: 02-auth-devices.sql
|
||||||
|
-- DESCRIPCION: Dispositivos, credenciales biometricas y sesiones
|
||||||
|
-- VERSION: 1.0.0
|
||||||
|
-- PROYECTO: ERP-Core V2
|
||||||
|
-- FECHA: 2026-01-10
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: devices
|
||||||
|
-- Dispositivos registrados por usuario
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS auth.devices (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
tenant_id UUID REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Identificacion del dispositivo
|
||||||
|
device_uuid VARCHAR(100) NOT NULL,
|
||||||
|
device_name VARCHAR(100),
|
||||||
|
device_model VARCHAR(100),
|
||||||
|
device_brand VARCHAR(50),
|
||||||
|
|
||||||
|
-- Plataforma
|
||||||
|
platform VARCHAR(20) NOT NULL, -- ios, android, web, desktop
|
||||||
|
platform_version VARCHAR(20),
|
||||||
|
app_version VARCHAR(20),
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
is_trusted BOOLEAN DEFAULT FALSE,
|
||||||
|
trust_level INTEGER DEFAULT 0, -- 0=none, 1=low, 2=medium, 3=high
|
||||||
|
|
||||||
|
-- Biometricos habilitados
|
||||||
|
biometric_enabled BOOLEAN DEFAULT FALSE,
|
||||||
|
biometric_type VARCHAR(50), -- fingerprint, face_id, face_recognition
|
||||||
|
|
||||||
|
-- Push notifications
|
||||||
|
push_token TEXT,
|
||||||
|
push_token_updated_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Ubicacion ultima conocida
|
||||||
|
last_latitude DECIMAL(10, 8),
|
||||||
|
last_longitude DECIMAL(11, 8),
|
||||||
|
last_location_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Seguridad
|
||||||
|
last_ip_address INET,
|
||||||
|
last_user_agent TEXT,
|
||||||
|
|
||||||
|
-- Registro
|
||||||
|
first_seen_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_seen_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
UNIQUE(user_id, device_uuid)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para devices
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_devices_user ON auth.devices(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_devices_tenant ON auth.devices(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_devices_uuid ON auth.devices(device_uuid);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_devices_platform ON auth.devices(platform);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_devices_active ON auth.devices(is_active) WHERE is_active = TRUE;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: biometric_credentials
|
||||||
|
-- Credenciales biometricas por dispositivo
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS auth.biometric_credentials (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
device_id UUID NOT NULL REFERENCES auth.devices(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Tipo de biometrico
|
||||||
|
biometric_type VARCHAR(50) NOT NULL, -- fingerprint, face_id, face_recognition, iris
|
||||||
|
|
||||||
|
-- Credencial (public key para WebAuthn/FIDO2)
|
||||||
|
credential_id TEXT NOT NULL,
|
||||||
|
public_key TEXT NOT NULL,
|
||||||
|
algorithm VARCHAR(20) DEFAULT 'ES256',
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
credential_name VARCHAR(100), -- "Huella indice derecho", "Face ID iPhone"
|
||||||
|
is_primary BOOLEAN DEFAULT FALSE,
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
last_used_at TIMESTAMPTZ,
|
||||||
|
use_count INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
-- Seguridad
|
||||||
|
failed_attempts INTEGER DEFAULT 0,
|
||||||
|
locked_until TIMESTAMPTZ,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
UNIQUE(device_id, credential_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para biometric_credentials
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_biometric_credentials_device ON auth.biometric_credentials(device_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_biometric_credentials_user ON auth.biometric_credentials(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_biometric_credentials_type ON auth.biometric_credentials(biometric_type);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: device_sessions
|
||||||
|
-- Sesiones activas por dispositivo
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS auth.device_sessions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
device_id UUID NOT NULL REFERENCES auth.devices(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
tenant_id UUID REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Tokens
|
||||||
|
access_token_hash VARCHAR(255) NOT NULL,
|
||||||
|
refresh_token_hash VARCHAR(255),
|
||||||
|
|
||||||
|
-- Metodo de autenticacion
|
||||||
|
auth_method VARCHAR(50) NOT NULL, -- password, biometric, oauth, mfa
|
||||||
|
|
||||||
|
-- Validez
|
||||||
|
issued_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
refresh_expires_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
revoked_at TIMESTAMPTZ,
|
||||||
|
revoked_reason VARCHAR(100),
|
||||||
|
|
||||||
|
-- Ubicacion
|
||||||
|
ip_address INET,
|
||||||
|
user_agent TEXT,
|
||||||
|
latitude DECIMAL(10, 8),
|
||||||
|
longitude DECIMAL(11, 8),
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para device_sessions
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_device_sessions_device ON auth.device_sessions(device_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_device_sessions_user ON auth.device_sessions(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_device_sessions_tenant ON auth.device_sessions(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_device_sessions_token ON auth.device_sessions(access_token_hash);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_device_sessions_active ON auth.device_sessions(is_active, expires_at) WHERE is_active = TRUE;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: device_activity_log
|
||||||
|
-- Log de actividad de dispositivos
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS auth.device_activity_log (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
device_id UUID NOT NULL REFERENCES auth.devices(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID REFERENCES auth.users(id),
|
||||||
|
|
||||||
|
-- Actividad
|
||||||
|
activity_type VARCHAR(50) NOT NULL, -- login, logout, biometric_auth, location_update, app_open
|
||||||
|
activity_status VARCHAR(20) NOT NULL, -- success, failed, blocked
|
||||||
|
|
||||||
|
-- Detalles
|
||||||
|
details JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Ubicacion
|
||||||
|
ip_address INET,
|
||||||
|
latitude DECIMAL(10, 8),
|
||||||
|
longitude DECIMAL(11, 8),
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para device_activity_log
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_device_activity_device ON auth.device_activity_log(device_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_device_activity_user ON auth.device_activity_log(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_device_activity_type ON auth.device_activity_log(activity_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_device_activity_created ON auth.device_activity_log(created_at DESC);
|
||||||
|
|
||||||
|
-- Particionar por fecha para mejor rendimiento
|
||||||
|
-- CREATE TABLE auth.device_activity_log_y2026m01 PARTITION OF auth.device_activity_log
|
||||||
|
-- FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- RLS POLICIES
|
||||||
|
-- =====================
|
||||||
|
ALTER TABLE auth.devices ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_devices ON auth.devices
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
ALTER TABLE auth.biometric_credentials ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY user_own_biometrics ON auth.biometric_credentials
|
||||||
|
USING (user_id = current_setting('app.current_user_id', true)::uuid);
|
||||||
|
|
||||||
|
ALTER TABLE auth.device_sessions ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_sessions ON auth.device_sessions
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
ALTER TABLE auth.device_activity_log ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY device_owner_activity ON auth.device_activity_log
|
||||||
|
USING (device_id IN (
|
||||||
|
SELECT id FROM auth.devices
|
||||||
|
WHERE tenant_id = current_setting('app.current_tenant_id', true)::uuid
|
||||||
|
));
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- FUNCIONES
|
||||||
|
-- =====================
|
||||||
|
|
||||||
|
-- Funcion para actualizar last_seen_at del dispositivo
|
||||||
|
CREATE OR REPLACE FUNCTION auth.update_device_last_seen()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
UPDATE auth.devices
|
||||||
|
SET last_seen_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = NEW.device_id;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Trigger para actualizar last_seen_at cuando hay actividad
|
||||||
|
CREATE TRIGGER trg_update_device_last_seen
|
||||||
|
AFTER INSERT ON auth.device_activity_log
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION auth.update_device_last_seen();
|
||||||
|
|
||||||
|
-- Funcion para limpiar sesiones expiradas
|
||||||
|
CREATE OR REPLACE FUNCTION auth.cleanup_expired_sessions()
|
||||||
|
RETURNS INTEGER AS $$
|
||||||
|
DECLARE
|
||||||
|
deleted_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
DELETE FROM auth.device_sessions
|
||||||
|
WHERE expires_at < CURRENT_TIMESTAMP
|
||||||
|
AND is_active = FALSE;
|
||||||
|
|
||||||
|
GET DIAGNOSTICS deleted_count = ROW_COUNT;
|
||||||
|
RETURN deleted_count;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- COMENTARIOS DE TABLAS
|
||||||
|
-- =====================
|
||||||
|
COMMENT ON TABLE auth.devices IS 'Dispositivos registrados por usuario (moviles, web, desktop)';
|
||||||
|
COMMENT ON TABLE auth.biometric_credentials IS 'Credenciales biometricas registradas por dispositivo (huella, face ID)';
|
||||||
|
COMMENT ON TABLE auth.device_sessions IS 'Sesiones activas por dispositivo con tokens';
|
||||||
|
COMMENT ON TABLE auth.device_activity_log IS 'Log de actividad de dispositivos para auditoria';
|
||||||
755
ddl/02-core.sql
755
ddl/02-core.sql
@ -1,755 +0,0 @@
|
|||||||
-- =====================================================
|
|
||||||
-- SCHEMA: core
|
|
||||||
-- PROPÓSITO: Catálogos maestros y entidades fundamentales
|
|
||||||
-- MÓDULOS: MGN-002 (Empresas), MGN-003 (Catálogos Maestros)
|
|
||||||
-- FECHA: 2025-11-24
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Crear schema
|
|
||||||
CREATE SCHEMA IF NOT EXISTS core;
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- TYPES (ENUMs)
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
CREATE TYPE core.partner_type AS ENUM (
|
|
||||||
'person',
|
|
||||||
'company'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE core.partner_category AS ENUM (
|
|
||||||
'customer',
|
|
||||||
'supplier',
|
|
||||||
'employee',
|
|
||||||
'contact',
|
|
||||||
'other'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE core.address_type AS ENUM (
|
|
||||||
'billing',
|
|
||||||
'shipping',
|
|
||||||
'contact',
|
|
||||||
'other'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE core.uom_type AS ENUM (
|
|
||||||
'reference',
|
|
||||||
'bigger',
|
|
||||||
'smaller'
|
|
||||||
);
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- TABLES
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Tabla: countries (Países - ISO 3166-1)
|
|
||||||
CREATE TABLE core.countries (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
code VARCHAR(2) NOT NULL UNIQUE, -- ISO 3166-1 alpha-2
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
phone_code VARCHAR(10),
|
|
||||||
currency_code VARCHAR(3), -- ISO 4217
|
|
||||||
|
|
||||||
-- Sin tenant_id: catálogo global
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: currencies (Monedas - ISO 4217)
|
|
||||||
CREATE TABLE core.currencies (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
code VARCHAR(3) NOT NULL UNIQUE, -- ISO 4217
|
|
||||||
name VARCHAR(100) NOT NULL,
|
|
||||||
symbol VARCHAR(10) NOT NULL,
|
|
||||||
decimals INTEGER NOT NULL DEFAULT 2,
|
|
||||||
rounding DECIMAL(12, 6) DEFAULT 0.01,
|
|
||||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
|
|
||||||
-- Sin tenant_id: catálogo global
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: exchange_rates (Tasas de cambio)
|
|
||||||
CREATE TABLE core.exchange_rates (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
from_currency_id UUID NOT NULL REFERENCES core.currencies(id),
|
|
||||||
to_currency_id UUID NOT NULL REFERENCES core.currencies(id),
|
|
||||||
rate DECIMAL(12, 6) NOT NULL,
|
|
||||||
date DATE NOT NULL,
|
|
||||||
|
|
||||||
-- Sin tenant_id: catálogo global
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT uq_exchange_rates_currencies_date UNIQUE (from_currency_id, to_currency_id, date),
|
|
||||||
CONSTRAINT chk_exchange_rates_rate CHECK (rate > 0),
|
|
||||||
CONSTRAINT chk_exchange_rates_different_currencies CHECK (from_currency_id != to_currency_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: uom_categories (Categorías de unidades de medida)
|
|
||||||
CREATE TABLE core.uom_categories (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
name VARCHAR(100) NOT NULL UNIQUE,
|
|
||||||
description TEXT,
|
|
||||||
|
|
||||||
-- Sin tenant_id: catálogo global
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: uom (Unidades de medida)
|
|
||||||
CREATE TABLE core.uom (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
category_id UUID NOT NULL REFERENCES core.uom_categories(id),
|
|
||||||
name VARCHAR(100) NOT NULL,
|
|
||||||
code VARCHAR(20),
|
|
||||||
uom_type core.uom_type NOT NULL DEFAULT 'reference',
|
|
||||||
factor DECIMAL(12, 6) NOT NULL DEFAULT 1.0,
|
|
||||||
rounding DECIMAL(12, 6) DEFAULT 0.01,
|
|
||||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
|
|
||||||
-- Sin tenant_id: catálogo global
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT uq_uom_name_category UNIQUE (category_id, name),
|
|
||||||
CONSTRAINT chk_uom_factor CHECK (factor > 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: partners (Partners universales - patrón Odoo)
|
|
||||||
CREATE TABLE core.partners (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
-- Datos básicos
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
legal_name VARCHAR(255),
|
|
||||||
partner_type core.partner_type NOT NULL DEFAULT 'person',
|
|
||||||
|
|
||||||
-- Categorización (multiple flags como Odoo)
|
|
||||||
is_customer BOOLEAN DEFAULT FALSE,
|
|
||||||
is_supplier BOOLEAN DEFAULT FALSE,
|
|
||||||
is_employee BOOLEAN DEFAULT FALSE,
|
|
||||||
is_company BOOLEAN DEFAULT FALSE,
|
|
||||||
|
|
||||||
-- Contacto
|
|
||||||
email VARCHAR(255),
|
|
||||||
phone VARCHAR(50),
|
|
||||||
mobile VARCHAR(50),
|
|
||||||
website VARCHAR(255),
|
|
||||||
|
|
||||||
-- Fiscal
|
|
||||||
tax_id VARCHAR(50), -- RFC en México
|
|
||||||
|
|
||||||
-- Referencias
|
|
||||||
company_id UUID REFERENCES auth.companies(id),
|
|
||||||
parent_id UUID REFERENCES core.partners(id), -- Para jerarquía de contactos
|
|
||||||
user_id UUID REFERENCES auth.users(id), -- Usuario vinculado (si aplica)
|
|
||||||
|
|
||||||
-- Comercial
|
|
||||||
payment_term_id UUID, -- FK a financial.payment_terms (se crea después)
|
|
||||||
pricelist_id UUID, -- FK a sales.pricelists (se crea después)
|
|
||||||
|
|
||||||
-- Configuración
|
|
||||||
language VARCHAR(10) DEFAULT 'es',
|
|
||||||
currency_id UUID REFERENCES core.currencies(id),
|
|
||||||
|
|
||||||
-- Notas
|
|
||||||
notes TEXT,
|
|
||||||
internal_notes TEXT,
|
|
||||||
|
|
||||||
-- Control
|
|
||||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
deleted_at TIMESTAMP,
|
|
||||||
deleted_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT chk_partners_email_format CHECK (
|
|
||||||
email IS NULL OR email ~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}$'
|
|
||||||
),
|
|
||||||
CONSTRAINT chk_partners_no_self_parent CHECK (id != parent_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: addresses (Direcciones de partners)
|
|
||||||
CREATE TABLE core.addresses (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
partner_id UUID NOT NULL REFERENCES core.partners(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
-- Tipo de dirección
|
|
||||||
address_type core.address_type NOT NULL DEFAULT 'contact',
|
|
||||||
|
|
||||||
-- Dirección
|
|
||||||
street VARCHAR(255),
|
|
||||||
street2 VARCHAR(255),
|
|
||||||
city VARCHAR(100),
|
|
||||||
state VARCHAR(100),
|
|
||||||
zip_code VARCHAR(20),
|
|
||||||
country_id UUID REFERENCES core.countries(id),
|
|
||||||
|
|
||||||
-- Control
|
|
||||||
is_default BOOLEAN DEFAULT FALSE,
|
|
||||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
deleted_at TIMESTAMP,
|
|
||||||
deleted_by UUID REFERENCES auth.users(id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: product_categories (Categorías de productos)
|
|
||||||
CREATE TABLE core.product_categories (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
code VARCHAR(50),
|
|
||||||
parent_id UUID REFERENCES core.product_categories(id),
|
|
||||||
full_path TEXT, -- Generado automáticamente: "Electrónica / Computadoras / Laptops"
|
|
||||||
|
|
||||||
-- Configuración
|
|
||||||
notes TEXT,
|
|
||||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
deleted_at TIMESTAMP,
|
|
||||||
deleted_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT uq_product_categories_code_tenant UNIQUE (tenant_id, code),
|
|
||||||
CONSTRAINT chk_product_categories_no_self_parent CHECK (id != parent_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: tags (Etiquetas genéricas)
|
|
||||||
CREATE TABLE core.tags (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
name VARCHAR(100) NOT NULL,
|
|
||||||
color VARCHAR(20), -- Color hex: #FF5733
|
|
||||||
model VARCHAR(100), -- Para qué se usa: 'products', 'partners', 'tasks', etc.
|
|
||||||
description TEXT,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT uq_tags_name_model_tenant UNIQUE (tenant_id, name, model)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: sequences (Generación de números secuenciales)
|
|
||||||
CREATE TABLE core.sequences (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
company_id UUID REFERENCES auth.companies(id),
|
|
||||||
|
|
||||||
code VARCHAR(100) NOT NULL, -- Código único: 'sale.order', 'purchase.order', etc.
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
prefix VARCHAR(50), -- Prefijo: "SO-", "PO-", etc.
|
|
||||||
suffix VARCHAR(50), -- Sufijo: "/2025"
|
|
||||||
next_number INTEGER NOT NULL DEFAULT 1,
|
|
||||||
padding INTEGER NOT NULL DEFAULT 4, -- 0001, 0002, etc.
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT uq_sequences_code_tenant UNIQUE (tenant_id, code),
|
|
||||||
CONSTRAINT chk_sequences_next_number CHECK (next_number > 0),
|
|
||||||
CONSTRAINT chk_sequences_padding CHECK (padding >= 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: attachments (Archivos adjuntos genéricos)
|
|
||||||
CREATE TABLE core.attachments (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
-- Referencia polimórfica (a qué tabla/registro pertenece)
|
|
||||||
model VARCHAR(100) NOT NULL, -- 'partners', 'invoices', 'tasks', etc.
|
|
||||||
record_id UUID NOT NULL,
|
|
||||||
|
|
||||||
-- Archivo
|
|
||||||
filename VARCHAR(255) NOT NULL,
|
|
||||||
mimetype VARCHAR(100),
|
|
||||||
size_bytes BIGINT,
|
|
||||||
url VARCHAR(1000), -- URL en S3, local storage, etc.
|
|
||||||
|
|
||||||
-- Metadatos
|
|
||||||
description TEXT,
|
|
||||||
is_public BOOLEAN DEFAULT FALSE,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
deleted_at TIMESTAMP,
|
|
||||||
deleted_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT chk_attachments_size CHECK (size_bytes >= 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: notes (Notas genéricas)
|
|
||||||
CREATE TABLE core.notes (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
-- Referencia polimórfica
|
|
||||||
model VARCHAR(100) NOT NULL,
|
|
||||||
record_id UUID NOT NULL,
|
|
||||||
|
|
||||||
-- Nota
|
|
||||||
subject VARCHAR(255),
|
|
||||||
content TEXT NOT NULL,
|
|
||||||
|
|
||||||
-- Control
|
|
||||||
is_pinned BOOLEAN DEFAULT FALSE,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
deleted_at TIMESTAMP,
|
|
||||||
deleted_by UUID REFERENCES auth.users(id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- INDICES
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Countries
|
|
||||||
CREATE INDEX idx_countries_code ON core.countries(code);
|
|
||||||
CREATE INDEX idx_countries_name ON core.countries(name);
|
|
||||||
|
|
||||||
-- Currencies
|
|
||||||
CREATE INDEX idx_currencies_code ON core.currencies(code);
|
|
||||||
CREATE INDEX idx_currencies_active ON core.currencies(active) WHERE active = TRUE;
|
|
||||||
|
|
||||||
-- Exchange Rates
|
|
||||||
CREATE INDEX idx_exchange_rates_from_currency ON core.exchange_rates(from_currency_id);
|
|
||||||
CREATE INDEX idx_exchange_rates_to_currency ON core.exchange_rates(to_currency_id);
|
|
||||||
CREATE INDEX idx_exchange_rates_date ON core.exchange_rates(date DESC);
|
|
||||||
|
|
||||||
-- UoM Categories
|
|
||||||
CREATE INDEX idx_uom_categories_name ON core.uom_categories(name);
|
|
||||||
|
|
||||||
-- UoM
|
|
||||||
CREATE INDEX idx_uom_category_id ON core.uom(category_id);
|
|
||||||
CREATE INDEX idx_uom_active ON core.uom(active) WHERE active = TRUE;
|
|
||||||
|
|
||||||
-- Partners
|
|
||||||
CREATE INDEX idx_partners_tenant_id ON core.partners(tenant_id);
|
|
||||||
CREATE INDEX idx_partners_name ON core.partners(name);
|
|
||||||
CREATE INDEX idx_partners_email ON core.partners(email);
|
|
||||||
CREATE INDEX idx_partners_tax_id ON core.partners(tax_id);
|
|
||||||
CREATE INDEX idx_partners_parent_id ON core.partners(parent_id);
|
|
||||||
CREATE INDEX idx_partners_user_id ON core.partners(user_id);
|
|
||||||
CREATE INDEX idx_partners_company_id ON core.partners(company_id);
|
|
||||||
CREATE INDEX idx_partners_currency_id ON core.partners(currency_id) WHERE currency_id IS NOT NULL;
|
|
||||||
CREATE INDEX idx_partners_payment_term_id ON core.partners(payment_term_id) WHERE payment_term_id IS NOT NULL;
|
|
||||||
CREATE INDEX idx_partners_pricelist_id ON core.partners(pricelist_id) WHERE pricelist_id IS NOT NULL;
|
|
||||||
CREATE INDEX idx_partners_is_customer ON core.partners(tenant_id, is_customer) WHERE is_customer = TRUE;
|
|
||||||
CREATE INDEX idx_partners_is_supplier ON core.partners(tenant_id, is_supplier) WHERE is_supplier = TRUE;
|
|
||||||
CREATE INDEX idx_partners_is_employee ON core.partners(tenant_id, is_employee) WHERE is_employee = TRUE;
|
|
||||||
CREATE INDEX idx_partners_active ON core.partners(tenant_id, active) WHERE active = TRUE;
|
|
||||||
|
|
||||||
-- Addresses
|
|
||||||
CREATE INDEX idx_addresses_partner_id ON core.addresses(partner_id);
|
|
||||||
CREATE INDEX idx_addresses_country_id ON core.addresses(country_id);
|
|
||||||
CREATE INDEX idx_addresses_is_default ON core.addresses(partner_id, is_default) WHERE is_default = TRUE;
|
|
||||||
|
|
||||||
-- Product Categories
|
|
||||||
CREATE INDEX idx_product_categories_tenant_id ON core.product_categories(tenant_id);
|
|
||||||
CREATE INDEX idx_product_categories_parent_id ON core.product_categories(parent_id);
|
|
||||||
CREATE INDEX idx_product_categories_code ON core.product_categories(code);
|
|
||||||
|
|
||||||
-- Tags
|
|
||||||
CREATE INDEX idx_tags_tenant_id ON core.tags(tenant_id);
|
|
||||||
CREATE INDEX idx_tags_model ON core.tags(model);
|
|
||||||
CREATE INDEX idx_tags_name ON core.tags(name);
|
|
||||||
|
|
||||||
-- Sequences
|
|
||||||
CREATE INDEX idx_sequences_tenant_id ON core.sequences(tenant_id);
|
|
||||||
CREATE INDEX idx_sequences_code ON core.sequences(code);
|
|
||||||
|
|
||||||
-- Attachments
|
|
||||||
CREATE INDEX idx_attachments_tenant_id ON core.attachments(tenant_id);
|
|
||||||
CREATE INDEX idx_attachments_model_record ON core.attachments(model, record_id);
|
|
||||||
CREATE INDEX idx_attachments_created_by ON core.attachments(created_by);
|
|
||||||
|
|
||||||
-- Notes
|
|
||||||
CREATE INDEX idx_notes_tenant_id ON core.notes(tenant_id);
|
|
||||||
CREATE INDEX idx_notes_model_record ON core.notes(model, record_id);
|
|
||||||
CREATE INDEX idx_notes_created_by ON core.notes(created_by);
|
|
||||||
CREATE INDEX idx_notes_is_pinned ON core.notes(is_pinned) WHERE is_pinned = TRUE;
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- FUNCTIONS
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Función: generate_next_sequence
|
|
||||||
-- Genera el siguiente número de secuencia
|
|
||||||
CREATE OR REPLACE FUNCTION core.generate_next_sequence(p_sequence_code VARCHAR)
|
|
||||||
RETURNS VARCHAR AS $$
|
|
||||||
DECLARE
|
|
||||||
v_sequence RECORD;
|
|
||||||
v_next_number INTEGER;
|
|
||||||
v_result VARCHAR;
|
|
||||||
BEGIN
|
|
||||||
-- Obtener secuencia y bloquear fila (SELECT FOR UPDATE)
|
|
||||||
SELECT * INTO v_sequence
|
|
||||||
FROM core.sequences
|
|
||||||
WHERE code = p_sequence_code
|
|
||||||
AND tenant_id = get_current_tenant_id()
|
|
||||||
FOR UPDATE;
|
|
||||||
|
|
||||||
IF NOT FOUND THEN
|
|
||||||
RAISE EXCEPTION 'Sequence % not found', p_sequence_code;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- Generar número
|
|
||||||
v_next_number := v_sequence.next_number;
|
|
||||||
|
|
||||||
-- Formatear resultado
|
|
||||||
v_result := COALESCE(v_sequence.prefix, '') ||
|
|
||||||
LPAD(v_next_number::TEXT, v_sequence.padding, '0') ||
|
|
||||||
COALESCE(v_sequence.suffix, '');
|
|
||||||
|
|
||||||
-- Incrementar contador
|
|
||||||
UPDATE core.sequences
|
|
||||||
SET next_number = next_number + 1,
|
|
||||||
updated_at = CURRENT_TIMESTAMP,
|
|
||||||
updated_by = get_current_user_id()
|
|
||||||
WHERE id = v_sequence.id;
|
|
||||||
|
|
||||||
RETURN v_result;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION core.generate_next_sequence IS 'Genera el siguiente número de secuencia para un código dado';
|
|
||||||
|
|
||||||
-- Función: update_product_category_path
|
|
||||||
-- Actualiza el full_path de una categoría de producto
|
|
||||||
CREATE OR REPLACE FUNCTION core.update_product_category_path()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
DECLARE
|
|
||||||
v_parent_path TEXT;
|
|
||||||
BEGIN
|
|
||||||
IF NEW.parent_id IS NULL THEN
|
|
||||||
NEW.full_path := NEW.name;
|
|
||||||
ELSE
|
|
||||||
SELECT full_path INTO v_parent_path
|
|
||||||
FROM core.product_categories
|
|
||||||
WHERE id = NEW.parent_id;
|
|
||||||
|
|
||||||
NEW.full_path := v_parent_path || ' / ' || NEW.name;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION core.update_product_category_path IS 'Actualiza el path completo de la categoría al crear/actualizar';
|
|
||||||
|
|
||||||
-- Función: get_exchange_rate
|
|
||||||
-- Obtiene la tasa de cambio entre dos monedas en una fecha
|
|
||||||
CREATE OR REPLACE FUNCTION core.get_exchange_rate(
|
|
||||||
p_from_currency_id UUID,
|
|
||||||
p_to_currency_id UUID,
|
|
||||||
p_date DATE DEFAULT CURRENT_DATE
|
|
||||||
)
|
|
||||||
RETURNS DECIMAL AS $$
|
|
||||||
DECLARE
|
|
||||||
v_rate DECIMAL;
|
|
||||||
BEGIN
|
|
||||||
-- Si son la misma moneda, tasa = 1
|
|
||||||
IF p_from_currency_id = p_to_currency_id THEN
|
|
||||||
RETURN 1.0;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- Buscar tasa directa
|
|
||||||
SELECT rate INTO v_rate
|
|
||||||
FROM core.exchange_rates
|
|
||||||
WHERE from_currency_id = p_from_currency_id
|
|
||||||
AND to_currency_id = p_to_currency_id
|
|
||||||
AND date <= p_date
|
|
||||||
ORDER BY date DESC
|
|
||||||
LIMIT 1;
|
|
||||||
|
|
||||||
IF FOUND THEN
|
|
||||||
RETURN v_rate;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- Buscar tasa inversa
|
|
||||||
SELECT 1.0 / rate INTO v_rate
|
|
||||||
FROM core.exchange_rates
|
|
||||||
WHERE from_currency_id = p_to_currency_id
|
|
||||||
AND to_currency_id = p_from_currency_id
|
|
||||||
AND date <= p_date
|
|
||||||
ORDER BY date DESC
|
|
||||||
LIMIT 1;
|
|
||||||
|
|
||||||
IF FOUND THEN
|
|
||||||
RETURN v_rate;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- No se encontró tasa
|
|
||||||
RAISE EXCEPTION 'Exchange rate not found for currencies % to % on date %',
|
|
||||||
p_from_currency_id, p_to_currency_id, p_date;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql STABLE;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION core.get_exchange_rate IS 'Obtiene la tasa de cambio entre dos monedas en una fecha específica';
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- TRIGGERS
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Trigger: Actualizar updated_at en partners
|
|
||||||
CREATE TRIGGER trg_partners_updated_at
|
|
||||||
BEFORE UPDATE ON core.partners
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
-- Trigger: Actualizar updated_at en addresses
|
|
||||||
CREATE TRIGGER trg_addresses_updated_at
|
|
||||||
BEFORE UPDATE ON core.addresses
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
-- Trigger: Actualizar updated_at en product_categories
|
|
||||||
CREATE TRIGGER trg_product_categories_updated_at
|
|
||||||
BEFORE UPDATE ON core.product_categories
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
-- Trigger: Actualizar updated_at en notes
|
|
||||||
CREATE TRIGGER trg_notes_updated_at
|
|
||||||
BEFORE UPDATE ON core.notes
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
-- Trigger: Actualizar full_path en product_categories
|
|
||||||
CREATE TRIGGER trg_product_categories_update_path
|
|
||||||
BEFORE INSERT OR UPDATE OF name, parent_id ON core.product_categories
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION core.update_product_category_path();
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- ROW LEVEL SECURITY (RLS)
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Habilitar RLS en tablas con tenant_id
|
|
||||||
ALTER TABLE core.partners ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE core.product_categories ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE core.tags ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE core.sequences ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE core.attachments ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE core.notes ENABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
-- Policy: Tenant Isolation - Partners
|
|
||||||
CREATE POLICY tenant_isolation_partners
|
|
||||||
ON core.partners
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
-- Policy: Tenant Isolation - Product Categories
|
|
||||||
CREATE POLICY tenant_isolation_product_categories
|
|
||||||
ON core.product_categories
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
-- Policy: Tenant Isolation - Tags
|
|
||||||
CREATE POLICY tenant_isolation_tags
|
|
||||||
ON core.tags
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
-- Policy: Tenant Isolation - Sequences
|
|
||||||
CREATE POLICY tenant_isolation_sequences
|
|
||||||
ON core.sequences
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
-- Policy: Tenant Isolation - Attachments
|
|
||||||
CREATE POLICY tenant_isolation_attachments
|
|
||||||
ON core.attachments
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
-- Policy: Tenant Isolation - Notes
|
|
||||||
CREATE POLICY tenant_isolation_notes
|
|
||||||
ON core.notes
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- SEED DATA
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Monedas principales (ISO 4217)
|
|
||||||
INSERT INTO core.currencies (code, name, symbol, decimals) VALUES
|
|
||||||
('USD', 'US Dollar', '$', 2),
|
|
||||||
('MXN', 'Peso Mexicano', '$', 2),
|
|
||||||
('EUR', 'Euro', '€', 2),
|
|
||||||
('GBP', 'British Pound', '£', 2),
|
|
||||||
('CAD', 'Canadian Dollar', '$', 2),
|
|
||||||
('JPY', 'Japanese Yen', '¥', 0),
|
|
||||||
('CNY', 'Chinese Yuan', '¥', 2),
|
|
||||||
('BRL', 'Brazilian Real', 'R$', 2),
|
|
||||||
('ARS', 'Argentine Peso', '$', 2),
|
|
||||||
('COP', 'Colombian Peso', '$', 2)
|
|
||||||
ON CONFLICT (code) DO NOTHING;
|
|
||||||
|
|
||||||
-- Países principales (ISO 3166-1)
|
|
||||||
INSERT INTO core.countries (code, name, phone_code, currency_code) VALUES
|
|
||||||
('MX', 'México', '52', 'MXN'),
|
|
||||||
('US', 'United States', '1', 'USD'),
|
|
||||||
('CA', 'Canada', '1', 'CAD'),
|
|
||||||
('GB', 'United Kingdom', '44', 'GBP'),
|
|
||||||
('FR', 'France', '33', 'EUR'),
|
|
||||||
('DE', 'Germany', '49', 'EUR'),
|
|
||||||
('ES', 'Spain', '34', 'EUR'),
|
|
||||||
('IT', 'Italy', '39', 'EUR'),
|
|
||||||
('BR', 'Brazil', '55', 'BRL'),
|
|
||||||
('AR', 'Argentina', '54', 'ARS'),
|
|
||||||
('CO', 'Colombia', '57', 'COP'),
|
|
||||||
('CL', 'Chile', '56', 'CLP'),
|
|
||||||
('PE', 'Peru', '51', 'PEN'),
|
|
||||||
('CN', 'China', '86', 'CNY'),
|
|
||||||
('JP', 'Japan', '81', 'JPY'),
|
|
||||||
('IN', 'India', '91', 'INR')
|
|
||||||
ON CONFLICT (code) DO NOTHING;
|
|
||||||
|
|
||||||
-- Categorías de UoM
|
|
||||||
INSERT INTO core.uom_categories (name, description) VALUES
|
|
||||||
('Weight', 'Unidades de peso'),
|
|
||||||
('Volume', 'Unidades de volumen'),
|
|
||||||
('Length', 'Unidades de longitud'),
|
|
||||||
('Time', 'Unidades de tiempo'),
|
|
||||||
('Unit', 'Unidades (piezas, docenas, etc.)')
|
|
||||||
ON CONFLICT (name) DO NOTHING;
|
|
||||||
|
|
||||||
-- Unidades de medida estándar
|
|
||||||
INSERT INTO core.uom (category_id, name, code, uom_type, factor)
|
|
||||||
SELECT
|
|
||||||
cat.id,
|
|
||||||
uom.name,
|
|
||||||
uom.code,
|
|
||||||
uom.uom_type::core.uom_type,
|
|
||||||
uom.factor
|
|
||||||
FROM (
|
|
||||||
-- Weight
|
|
||||||
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Weight'), 'Kilogram', 'kg', 'reference', 1.0 UNION ALL
|
|
||||||
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Weight'), 'Gram', 'g', 'smaller', 0.001 UNION ALL
|
|
||||||
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Weight'), 'Ton', 't', 'bigger', 1000.0 UNION ALL
|
|
||||||
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Weight'), 'Pound', 'lb', 'smaller', 0.453592 UNION ALL
|
|
||||||
|
|
||||||
-- Volume
|
|
||||||
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Volume'), 'Liter', 'L', 'reference', 1.0 UNION ALL
|
|
||||||
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Volume'), 'Milliliter', 'mL', 'smaller', 0.001 UNION ALL
|
|
||||||
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Volume'), 'Cubic Meter', 'm³', 'bigger', 1000.0 UNION ALL
|
|
||||||
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Volume'), 'Gallon', 'gal', 'bigger', 3.78541 UNION ALL
|
|
||||||
|
|
||||||
-- Length
|
|
||||||
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Length'), 'Meter', 'm', 'reference', 1.0 UNION ALL
|
|
||||||
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Length'), 'Centimeter', 'cm', 'smaller', 0.01 UNION ALL
|
|
||||||
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Length'), 'Millimeter', 'mm', 'smaller', 0.001 UNION ALL
|
|
||||||
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Length'), 'Kilometer', 'km', 'bigger', 1000.0 UNION ALL
|
|
||||||
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Length'), 'Inch', 'in', 'smaller', 0.0254 UNION ALL
|
|
||||||
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Length'), 'Foot', 'ft', 'smaller', 0.3048 UNION ALL
|
|
||||||
|
|
||||||
-- Time
|
|
||||||
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Time'), 'Hour', 'h', 'reference', 1.0 UNION ALL
|
|
||||||
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Time'), 'Day', 'd', 'bigger', 24.0 UNION ALL
|
|
||||||
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Time'), 'Week', 'wk', 'bigger', 168.0 UNION ALL
|
|
||||||
|
|
||||||
-- Unit
|
|
||||||
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Unit'), 'Unit', 'unit', 'reference', 1.0 UNION ALL
|
|
||||||
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Unit'), 'Dozen', 'doz', 'bigger', 12.0 UNION ALL
|
|
||||||
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Unit'), 'Pack', 'pack', 'bigger', 1.0
|
|
||||||
) AS uom(category_id, name, code, uom_type, factor)
|
|
||||||
JOIN core.uom_categories cat ON cat.id = uom.category_id
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- COMENTARIOS EN TABLAS
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
COMMENT ON SCHEMA core IS 'Schema de catálogos maestros y entidades fundamentales';
|
|
||||||
COMMENT ON TABLE core.countries IS 'Catálogo de países (ISO 3166-1)';
|
|
||||||
COMMENT ON TABLE core.currencies IS 'Catálogo de monedas (ISO 4217)';
|
|
||||||
COMMENT ON TABLE core.exchange_rates IS 'Tasas de cambio históricas entre monedas';
|
|
||||||
COMMENT ON TABLE core.uom_categories IS 'Categorías de unidades de medida';
|
|
||||||
COMMENT ON TABLE core.uom IS 'Unidades de medida (peso, volumen, longitud, etc.)';
|
|
||||||
COMMENT ON TABLE core.partners IS 'Partners universales (clientes, proveedores, empleados, contactos) - patrón Odoo';
|
|
||||||
COMMENT ON TABLE core.addresses IS 'Direcciones de partners (facturación, envío, contacto)';
|
|
||||||
COMMENT ON TABLE core.product_categories IS 'Categorías jerárquicas de productos';
|
|
||||||
COMMENT ON TABLE core.tags IS 'Etiquetas genéricas para clasificar registros';
|
|
||||||
COMMENT ON TABLE core.sequences IS 'Generadores de números secuenciales automáticos';
|
|
||||||
COMMENT ON TABLE core.attachments IS 'Archivos adjuntos polimórficos (cualquier tabla/registro)';
|
|
||||||
COMMENT ON TABLE core.notes IS 'Notas polimórficas (cualquier tabla/registro)';
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- VISTAS ÚTILES
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Vista: customers (solo partners que son clientes)
|
|
||||||
CREATE OR REPLACE VIEW core.customers_view AS
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
tenant_id,
|
|
||||||
name,
|
|
||||||
legal_name,
|
|
||||||
email,
|
|
||||||
phone,
|
|
||||||
mobile,
|
|
||||||
tax_id,
|
|
||||||
company_id,
|
|
||||||
active
|
|
||||||
FROM core.partners
|
|
||||||
WHERE is_customer = TRUE
|
|
||||||
AND deleted_at IS NULL;
|
|
||||||
|
|
||||||
COMMENT ON VIEW core.customers_view IS 'Vista de partners que son clientes';
|
|
||||||
|
|
||||||
-- Vista: suppliers (solo partners que son proveedores)
|
|
||||||
CREATE OR REPLACE VIEW core.suppliers_view AS
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
tenant_id,
|
|
||||||
name,
|
|
||||||
legal_name,
|
|
||||||
email,
|
|
||||||
phone,
|
|
||||||
tax_id,
|
|
||||||
company_id,
|
|
||||||
active
|
|
||||||
FROM core.partners
|
|
||||||
WHERE is_supplier = TRUE
|
|
||||||
AND deleted_at IS NULL;
|
|
||||||
|
|
||||||
COMMENT ON VIEW core.suppliers_view IS 'Vista de partners que son proveedores';
|
|
||||||
|
|
||||||
-- Vista: employees (solo partners que son empleados)
|
|
||||||
CREATE OR REPLACE VIEW core.employees_view AS
|
|
||||||
SELECT
|
|
||||||
p.id,
|
|
||||||
p.tenant_id,
|
|
||||||
p.name,
|
|
||||||
p.email,
|
|
||||||
p.phone,
|
|
||||||
p.user_id,
|
|
||||||
u.full_name as user_name,
|
|
||||||
p.active
|
|
||||||
FROM core.partners p
|
|
||||||
LEFT JOIN auth.users u ON p.user_id = u.id
|
|
||||||
WHERE p.is_employee = TRUE
|
|
||||||
AND p.deleted_at IS NULL;
|
|
||||||
|
|
||||||
COMMENT ON VIEW core.employees_view IS 'Vista de partners que son empleados';
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- FIN DEL SCHEMA CORE
|
|
||||||
-- =====================================================
|
|
||||||
@ -1,510 +0,0 @@
|
|||||||
-- =====================================================
|
|
||||||
-- SCHEMA: analytics
|
|
||||||
-- PROPÓSITO: Contabilidad analítica, tracking de costos/ingresos
|
|
||||||
-- MÓDULOS: MGN-008 (Contabilidad Analítica)
|
|
||||||
-- FECHA: 2025-11-24
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Crear schema
|
|
||||||
CREATE SCHEMA IF NOT EXISTS analytics;
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- TYPES (ENUMs)
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
CREATE TYPE analytics.account_type AS ENUM (
|
|
||||||
'project',
|
|
||||||
'department',
|
|
||||||
'cost_center',
|
|
||||||
'customer',
|
|
||||||
'product',
|
|
||||||
'other'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE analytics.line_type AS ENUM (
|
|
||||||
'expense',
|
|
||||||
'income',
|
|
||||||
'timesheet'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE analytics.account_status AS ENUM (
|
|
||||||
'active',
|
|
||||||
'inactive',
|
|
||||||
'closed'
|
|
||||||
);
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- TABLES
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Tabla: analytic_plans (Planes analíticos - multi-dimensional)
|
|
||||||
CREATE TABLE analytics.analytic_plans (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
company_id UUID REFERENCES auth.companies(id),
|
|
||||||
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
|
|
||||||
-- Control
|
|
||||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT uq_analytic_plans_name_tenant UNIQUE (tenant_id, name)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: analytic_accounts (Cuentas analíticas)
|
|
||||||
CREATE TABLE analytics.analytic_accounts (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
company_id UUID NOT NULL REFERENCES auth.companies(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
plan_id UUID REFERENCES analytics.analytic_plans(id),
|
|
||||||
|
|
||||||
-- Identificación
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
code VARCHAR(50),
|
|
||||||
account_type analytics.account_type NOT NULL DEFAULT 'other',
|
|
||||||
|
|
||||||
-- Jerarquía
|
|
||||||
parent_id UUID REFERENCES analytics.analytic_accounts(id),
|
|
||||||
full_path TEXT, -- Generado automáticamente
|
|
||||||
|
|
||||||
-- Referencias
|
|
||||||
partner_id UUID REFERENCES core.partners(id), -- Cliente/proveedor asociado
|
|
||||||
|
|
||||||
-- Presupuesto
|
|
||||||
budget DECIMAL(15, 2) DEFAULT 0,
|
|
||||||
|
|
||||||
-- Estado
|
|
||||||
status analytics.account_status NOT NULL DEFAULT 'active',
|
|
||||||
|
|
||||||
-- Fechas
|
|
||||||
date_start DATE,
|
|
||||||
date_end DATE,
|
|
||||||
|
|
||||||
-- Notas
|
|
||||||
description TEXT,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
deleted_at TIMESTAMP,
|
|
||||||
deleted_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT uq_analytic_accounts_code_company UNIQUE (company_id, code),
|
|
||||||
CONSTRAINT chk_analytic_accounts_no_self_parent CHECK (id != parent_id),
|
|
||||||
CONSTRAINT chk_analytic_accounts_budget CHECK (budget >= 0),
|
|
||||||
CONSTRAINT chk_analytic_accounts_dates CHECK (date_end IS NULL OR date_end >= date_start)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: analytic_tags (Etiquetas analíticas - clasificación cross-cutting)
|
|
||||||
CREATE TABLE analytics.analytic_tags (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
name VARCHAR(100) NOT NULL,
|
|
||||||
color VARCHAR(20), -- Color hex
|
|
||||||
description TEXT,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT uq_analytic_tags_name_tenant UNIQUE (tenant_id, name)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: cost_centers (Centros de costo)
|
|
||||||
CREATE TABLE analytics.cost_centers (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
company_id UUID NOT NULL REFERENCES auth.companies(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
code VARCHAR(50),
|
|
||||||
analytic_account_id UUID NOT NULL REFERENCES analytics.analytic_accounts(id),
|
|
||||||
|
|
||||||
-- Responsable
|
|
||||||
manager_id UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
-- Presupuesto
|
|
||||||
budget_monthly DECIMAL(15, 2) DEFAULT 0,
|
|
||||||
budget_annual DECIMAL(15, 2) DEFAULT 0,
|
|
||||||
|
|
||||||
-- Control
|
|
||||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT uq_cost_centers_code_company UNIQUE (company_id, code)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: analytic_lines (Líneas analíticas - registro de costos/ingresos)
|
|
||||||
CREATE TABLE analytics.analytic_lines (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
company_id UUID NOT NULL REFERENCES auth.companies(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
analytic_account_id UUID NOT NULL REFERENCES analytics.analytic_accounts(id),
|
|
||||||
|
|
||||||
-- Fecha
|
|
||||||
date DATE NOT NULL,
|
|
||||||
|
|
||||||
-- Montos
|
|
||||||
amount DECIMAL(15, 2) NOT NULL, -- Negativo=costo, Positivo=ingreso
|
|
||||||
unit_amount DECIMAL(12, 4) DEFAULT 0, -- Horas para timesheet, cantidades para productos
|
|
||||||
|
|
||||||
-- Tipo
|
|
||||||
line_type analytics.line_type NOT NULL,
|
|
||||||
|
|
||||||
-- Referencias
|
|
||||||
product_id UUID REFERENCES inventory.products(id),
|
|
||||||
employee_id UUID, -- FK a hr.employees (se crea después)
|
|
||||||
partner_id UUID REFERENCES core.partners(id),
|
|
||||||
|
|
||||||
-- Descripción
|
|
||||||
name VARCHAR(255),
|
|
||||||
description TEXT,
|
|
||||||
|
|
||||||
-- Documento origen (polimórfico)
|
|
||||||
source_model VARCHAR(100), -- 'Invoice', 'PurchaseOrder', 'SaleOrder', 'Timesheet', etc.
|
|
||||||
source_id UUID,
|
|
||||||
source_document VARCHAR(255), -- "invoice/123", "purchase_order/456"
|
|
||||||
|
|
||||||
-- Moneda
|
|
||||||
currency_id UUID REFERENCES core.currencies(id),
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT chk_analytic_lines_unit_amount CHECK (unit_amount >= 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: analytic_line_tags (Many-to-many: líneas analíticas - tags)
|
|
||||||
CREATE TABLE analytics.analytic_line_tags (
|
|
||||||
analytic_line_id UUID NOT NULL REFERENCES analytics.analytic_lines(id) ON DELETE CASCADE,
|
|
||||||
analytic_tag_id UUID NOT NULL REFERENCES analytics.analytic_tags(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
PRIMARY KEY (analytic_line_id, analytic_tag_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: analytic_distributions (Distribución analítica multi-cuenta)
|
|
||||||
CREATE TABLE analytics.analytic_distributions (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
|
|
||||||
-- Línea origen (polimórfico)
|
|
||||||
source_model VARCHAR(100) NOT NULL, -- 'PurchaseOrderLine', 'InvoiceLine', etc.
|
|
||||||
source_id UUID NOT NULL,
|
|
||||||
|
|
||||||
-- Cuenta analítica destino
|
|
||||||
analytic_account_id UUID NOT NULL REFERENCES analytics.analytic_accounts(id),
|
|
||||||
|
|
||||||
-- Distribución
|
|
||||||
percentage DECIMAL(5, 2) NOT NULL, -- 0-100
|
|
||||||
amount DECIMAL(15, 2), -- Calculado automáticamente
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT chk_analytic_distributions_percentage CHECK (percentage >= 0 AND percentage <= 100)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- INDICES
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Analytic Plans
|
|
||||||
CREATE INDEX idx_analytic_plans_tenant_id ON analytics.analytic_plans(tenant_id);
|
|
||||||
CREATE INDEX idx_analytic_plans_active ON analytics.analytic_plans(active) WHERE active = TRUE;
|
|
||||||
|
|
||||||
-- Analytic Accounts
|
|
||||||
CREATE INDEX idx_analytic_accounts_tenant_id ON analytics.analytic_accounts(tenant_id);
|
|
||||||
CREATE INDEX idx_analytic_accounts_company_id ON analytics.analytic_accounts(company_id);
|
|
||||||
CREATE INDEX idx_analytic_accounts_plan_id ON analytics.analytic_accounts(plan_id);
|
|
||||||
CREATE INDEX idx_analytic_accounts_parent_id ON analytics.analytic_accounts(parent_id);
|
|
||||||
CREATE INDEX idx_analytic_accounts_partner_id ON analytics.analytic_accounts(partner_id);
|
|
||||||
CREATE INDEX idx_analytic_accounts_code ON analytics.analytic_accounts(code);
|
|
||||||
CREATE INDEX idx_analytic_accounts_type ON analytics.analytic_accounts(account_type);
|
|
||||||
CREATE INDEX idx_analytic_accounts_status ON analytics.analytic_accounts(status);
|
|
||||||
|
|
||||||
-- Analytic Tags
|
|
||||||
CREATE INDEX idx_analytic_tags_tenant_id ON analytics.analytic_tags(tenant_id);
|
|
||||||
CREATE INDEX idx_analytic_tags_name ON analytics.analytic_tags(name);
|
|
||||||
|
|
||||||
-- Cost Centers
|
|
||||||
CREATE INDEX idx_cost_centers_tenant_id ON analytics.cost_centers(tenant_id);
|
|
||||||
CREATE INDEX idx_cost_centers_company_id ON analytics.cost_centers(company_id);
|
|
||||||
CREATE INDEX idx_cost_centers_analytic_account_id ON analytics.cost_centers(analytic_account_id);
|
|
||||||
CREATE INDEX idx_cost_centers_manager_id ON analytics.cost_centers(manager_id);
|
|
||||||
CREATE INDEX idx_cost_centers_active ON analytics.cost_centers(active) WHERE active = TRUE;
|
|
||||||
|
|
||||||
-- Analytic Lines
|
|
||||||
CREATE INDEX idx_analytic_lines_tenant_id ON analytics.analytic_lines(tenant_id);
|
|
||||||
CREATE INDEX idx_analytic_lines_company_id ON analytics.analytic_lines(company_id);
|
|
||||||
CREATE INDEX idx_analytic_lines_analytic_account_id ON analytics.analytic_lines(analytic_account_id);
|
|
||||||
CREATE INDEX idx_analytic_lines_date ON analytics.analytic_lines(date);
|
|
||||||
CREATE INDEX idx_analytic_lines_line_type ON analytics.analytic_lines(line_type);
|
|
||||||
CREATE INDEX idx_analytic_lines_product_id ON analytics.analytic_lines(product_id);
|
|
||||||
CREATE INDEX idx_analytic_lines_employee_id ON analytics.analytic_lines(employee_id);
|
|
||||||
CREATE INDEX idx_analytic_lines_source ON analytics.analytic_lines(source_model, source_id);
|
|
||||||
|
|
||||||
-- Analytic Line Tags
|
|
||||||
CREATE INDEX idx_analytic_line_tags_line_id ON analytics.analytic_line_tags(analytic_line_id);
|
|
||||||
CREATE INDEX idx_analytic_line_tags_tag_id ON analytics.analytic_line_tags(analytic_tag_id);
|
|
||||||
|
|
||||||
-- Analytic Distributions
|
|
||||||
CREATE INDEX idx_analytic_distributions_source ON analytics.analytic_distributions(source_model, source_id);
|
|
||||||
CREATE INDEX idx_analytic_distributions_analytic_account_id ON analytics.analytic_distributions(analytic_account_id);
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- FUNCTIONS
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Función: update_analytic_account_path
|
|
||||||
CREATE OR REPLACE FUNCTION analytics.update_analytic_account_path()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
DECLARE
|
|
||||||
v_parent_path TEXT;
|
|
||||||
BEGIN
|
|
||||||
IF NEW.parent_id IS NULL THEN
|
|
||||||
NEW.full_path := NEW.name;
|
|
||||||
ELSE
|
|
||||||
SELECT full_path INTO v_parent_path
|
|
||||||
FROM analytics.analytic_accounts
|
|
||||||
WHERE id = NEW.parent_id;
|
|
||||||
|
|
||||||
NEW.full_path := v_parent_path || ' / ' || NEW.name;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION analytics.update_analytic_account_path IS 'Actualiza el path completo de la cuenta analítica';
|
|
||||||
|
|
||||||
-- Función: get_analytic_balance
|
|
||||||
CREATE OR REPLACE FUNCTION analytics.get_analytic_balance(
|
|
||||||
p_analytic_account_id UUID,
|
|
||||||
p_date_from DATE DEFAULT NULL,
|
|
||||||
p_date_to DATE DEFAULT NULL
|
|
||||||
)
|
|
||||||
RETURNS TABLE(
|
|
||||||
total_income DECIMAL,
|
|
||||||
total_expense DECIMAL,
|
|
||||||
balance DECIMAL
|
|
||||||
) AS $$
|
|
||||||
BEGIN
|
|
||||||
RETURN QUERY
|
|
||||||
SELECT
|
|
||||||
COALESCE(SUM(CASE WHEN line_type = 'income' THEN amount ELSE 0 END), 0) AS total_income,
|
|
||||||
COALESCE(SUM(CASE WHEN line_type = 'expense' THEN ABS(amount) ELSE 0 END), 0) AS total_expense,
|
|
||||||
COALESCE(SUM(amount), 0) AS balance
|
|
||||||
FROM analytics.analytic_lines
|
|
||||||
WHERE analytic_account_id = p_analytic_account_id
|
|
||||||
AND (p_date_from IS NULL OR date >= p_date_from)
|
|
||||||
AND (p_date_to IS NULL OR date <= p_date_to);
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql STABLE;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION analytics.get_analytic_balance IS 'Obtiene el balance de una cuenta analítica en un período';
|
|
||||||
|
|
||||||
-- Función: validate_distribution_100_percent
|
|
||||||
CREATE OR REPLACE FUNCTION analytics.validate_distribution_100_percent()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
DECLARE
|
|
||||||
v_total_percentage DECIMAL;
|
|
||||||
BEGIN
|
|
||||||
SELECT COALESCE(SUM(percentage), 0)
|
|
||||||
INTO v_total_percentage
|
|
||||||
FROM analytics.analytic_distributions
|
|
||||||
WHERE source_model = NEW.source_model
|
|
||||||
AND source_id = NEW.source_id;
|
|
||||||
|
|
||||||
IF TG_OP = 'INSERT' OR TG_OP = 'UPDATE' THEN
|
|
||||||
v_total_percentage := v_total_percentage + NEW.percentage;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF v_total_percentage > 100 THEN
|
|
||||||
RAISE EXCEPTION 'Total distribution percentage cannot exceed 100%% (currently: %%)', v_total_percentage;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION analytics.validate_distribution_100_percent IS 'Valida que la distribución analítica no exceda el 100%';
|
|
||||||
|
|
||||||
-- Función: create_analytic_line_from_invoice
|
|
||||||
CREATE OR REPLACE FUNCTION analytics.create_analytic_line_from_invoice(p_invoice_line_id UUID)
|
|
||||||
RETURNS UUID AS $$
|
|
||||||
DECLARE
|
|
||||||
v_line RECORD;
|
|
||||||
v_invoice RECORD;
|
|
||||||
v_analytic_line_id UUID;
|
|
||||||
v_amount DECIMAL;
|
|
||||||
BEGIN
|
|
||||||
-- Obtener datos de la línea de factura
|
|
||||||
SELECT il.*, i.invoice_type, i.company_id, i.tenant_id, i.partner_id, i.invoice_date
|
|
||||||
INTO v_line
|
|
||||||
FROM financial.invoice_lines il
|
|
||||||
JOIN financial.invoices i ON il.invoice_id = i.id
|
|
||||||
WHERE il.id = p_invoice_line_id;
|
|
||||||
|
|
||||||
IF NOT FOUND OR v_line.analytic_account_id IS NULL THEN
|
|
||||||
RETURN NULL; -- Sin cuenta analítica, no crear línea
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- Determinar monto (negativo para compras, positivo para ventas)
|
|
||||||
IF v_line.invoice_type = 'supplier' THEN
|
|
||||||
v_amount := -ABS(v_line.amount_total);
|
|
||||||
ELSE
|
|
||||||
v_amount := v_line.amount_total;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- Crear línea analítica
|
|
||||||
INSERT INTO analytics.analytic_lines (
|
|
||||||
tenant_id,
|
|
||||||
company_id,
|
|
||||||
analytic_account_id,
|
|
||||||
date,
|
|
||||||
amount,
|
|
||||||
unit_amount,
|
|
||||||
line_type,
|
|
||||||
product_id,
|
|
||||||
partner_id,
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
source_model,
|
|
||||||
source_id,
|
|
||||||
source_document
|
|
||||||
) VALUES (
|
|
||||||
v_line.tenant_id,
|
|
||||||
v_line.company_id,
|
|
||||||
v_line.analytic_account_id,
|
|
||||||
v_line.invoice_date,
|
|
||||||
v_amount,
|
|
||||||
v_line.quantity,
|
|
||||||
CASE WHEN v_line.invoice_type = 'supplier' THEN 'expense'::analytics.line_type ELSE 'income'::analytics.line_type END,
|
|
||||||
v_line.product_id,
|
|
||||||
v_line.partner_id,
|
|
||||||
v_line.description,
|
|
||||||
v_line.description,
|
|
||||||
'InvoiceLine',
|
|
||||||
v_line.id,
|
|
||||||
'invoice_line/' || v_line.id::TEXT
|
|
||||||
) RETURNING id INTO v_analytic_line_id;
|
|
||||||
|
|
||||||
RETURN v_analytic_line_id;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION analytics.create_analytic_line_from_invoice IS 'Crea una línea analítica a partir de una línea de factura';
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- TRIGGERS
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_analytic_plans_updated_at
|
|
||||||
BEFORE UPDATE ON analytics.analytic_plans
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_analytic_accounts_updated_at
|
|
||||||
BEFORE UPDATE ON analytics.analytic_accounts
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_cost_centers_updated_at
|
|
||||||
BEFORE UPDATE ON analytics.cost_centers
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
-- Trigger: Actualizar full_path de cuenta analítica
|
|
||||||
CREATE TRIGGER trg_analytic_accounts_update_path
|
|
||||||
BEFORE INSERT OR UPDATE OF name, parent_id ON analytics.analytic_accounts
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION analytics.update_analytic_account_path();
|
|
||||||
|
|
||||||
-- Trigger: Validar distribución 100%
|
|
||||||
CREATE TRIGGER trg_analytic_distributions_validate_100
|
|
||||||
BEFORE INSERT OR UPDATE ON analytics.analytic_distributions
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION analytics.validate_distribution_100_percent();
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- ROW LEVEL SECURITY (RLS)
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
ALTER TABLE analytics.analytic_plans ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE analytics.analytic_accounts ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE analytics.analytic_tags ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE analytics.cost_centers ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE analytics.analytic_lines ENABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_analytic_plans ON analytics.analytic_plans
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_analytic_accounts ON analytics.analytic_accounts
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_analytic_tags ON analytics.analytic_tags
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_cost_centers ON analytics.cost_centers
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_analytic_lines ON analytics.analytic_lines
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- COMENTARIOS
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
COMMENT ON SCHEMA analytics IS 'Schema de contabilidad analítica y tracking de costos/ingresos';
|
|
||||||
COMMENT ON TABLE analytics.analytic_plans IS 'Planes analíticos para análisis multi-dimensional';
|
|
||||||
COMMENT ON TABLE analytics.analytic_accounts IS 'Cuentas analíticas (proyectos, departamentos, centros de costo)';
|
|
||||||
COMMENT ON TABLE analytics.analytic_tags IS 'Etiquetas analíticas para clasificación cross-cutting';
|
|
||||||
COMMENT ON TABLE analytics.cost_centers IS 'Centros de costo con presupuestos';
|
|
||||||
COMMENT ON TABLE analytics.analytic_lines IS 'Líneas analíticas de costos e ingresos';
|
|
||||||
COMMENT ON TABLE analytics.analytic_line_tags IS 'Relación many-to-many entre líneas y tags';
|
|
||||||
COMMENT ON TABLE analytics.analytic_distributions IS 'Distribución de montos a múltiples cuentas analíticas';
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- VISTAS ÚTILES
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Vista: balance analítico por cuenta
|
|
||||||
CREATE OR REPLACE VIEW analytics.analytic_balance_view AS
|
|
||||||
SELECT
|
|
||||||
aa.id AS analytic_account_id,
|
|
||||||
aa.code,
|
|
||||||
aa.name,
|
|
||||||
aa.budget,
|
|
||||||
COALESCE(SUM(CASE WHEN al.line_type = 'income' THEN al.amount ELSE 0 END), 0) AS total_income,
|
|
||||||
COALESCE(SUM(CASE WHEN al.line_type = 'expense' THEN ABS(al.amount) ELSE 0 END), 0) AS total_expense,
|
|
||||||
COALESCE(SUM(al.amount), 0) AS balance,
|
|
||||||
aa.budget - COALESCE(SUM(CASE WHEN al.line_type = 'expense' THEN ABS(al.amount) ELSE 0 END), 0) AS budget_variance
|
|
||||||
FROM analytics.analytic_accounts aa
|
|
||||||
LEFT JOIN analytics.analytic_lines al ON aa.id = al.analytic_account_id
|
|
||||||
WHERE aa.deleted_at IS NULL
|
|
||||||
GROUP BY aa.id, aa.code, aa.name, aa.budget;
|
|
||||||
|
|
||||||
COMMENT ON VIEW analytics.analytic_balance_view IS 'Vista de balance analítico por cuenta con presupuesto vs real';
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- FIN DEL SCHEMA ANALYTICS
|
|
||||||
-- =====================================================
|
|
||||||
366
ddl/03-core-branches.sql
Normal file
366
ddl/03-core-branches.sql
Normal file
@ -0,0 +1,366 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- ARCHIVO: 03-core-branches.sql
|
||||||
|
-- DESCRIPCION: Sucursales, jerarquia y asignaciones de usuarios
|
||||||
|
-- VERSION: 1.0.0
|
||||||
|
-- PROYECTO: ERP-Core V2
|
||||||
|
-- FECHA: 2026-01-10
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- EXTENSIONES REQUERIDAS
|
||||||
|
-- =====================
|
||||||
|
CREATE EXTENSION IF NOT EXISTS cube;
|
||||||
|
CREATE EXTENSION IF NOT EXISTS earthdistance;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- SCHEMA: core (si no existe)
|
||||||
|
-- =====================
|
||||||
|
CREATE SCHEMA IF NOT EXISTS core;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: branches
|
||||||
|
-- Sucursales/ubicaciones del negocio
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS core.branches (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
parent_id UUID REFERENCES core.branches(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
-- Identificacion
|
||||||
|
code VARCHAR(20) NOT NULL,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
short_name VARCHAR(50),
|
||||||
|
|
||||||
|
-- Tipo
|
||||||
|
branch_type VARCHAR(30) NOT NULL DEFAULT 'store', -- headquarters, regional, store, warehouse, office, factory
|
||||||
|
|
||||||
|
-- Contacto
|
||||||
|
phone VARCHAR(20),
|
||||||
|
email VARCHAR(255),
|
||||||
|
manager_id UUID REFERENCES auth.users(id),
|
||||||
|
|
||||||
|
-- Direccion
|
||||||
|
address_line1 VARCHAR(200),
|
||||||
|
address_line2 VARCHAR(200),
|
||||||
|
city VARCHAR(100),
|
||||||
|
state VARCHAR(100),
|
||||||
|
postal_code VARCHAR(20),
|
||||||
|
country VARCHAR(3) DEFAULT 'MEX',
|
||||||
|
|
||||||
|
-- Geolocalizacion
|
||||||
|
latitude DECIMAL(10, 8),
|
||||||
|
longitude DECIMAL(11, 8),
|
||||||
|
geofence_radius INTEGER DEFAULT 100, -- Radio en metros para validacion de ubicacion
|
||||||
|
geofence_enabled BOOLEAN DEFAULT TRUE,
|
||||||
|
|
||||||
|
-- Configuracion
|
||||||
|
timezone VARCHAR(50) DEFAULT 'America/Mexico_City',
|
||||||
|
currency VARCHAR(3) DEFAULT 'MXN',
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
is_main BOOLEAN DEFAULT FALSE, -- Sucursal principal/matriz
|
||||||
|
|
||||||
|
-- Horarios de operacion
|
||||||
|
operating_hours JSONB DEFAULT '{}',
|
||||||
|
-- Ejemplo: {"monday": {"open": "09:00", "close": "18:00"}, ...}
|
||||||
|
|
||||||
|
-- Configuraciones especificas
|
||||||
|
settings JSONB DEFAULT '{}',
|
||||||
|
-- Ejemplo: {"allow_pos": true, "allow_warehouse": true, ...}
|
||||||
|
|
||||||
|
-- Jerarquia (path materializado para consultas eficientes)
|
||||||
|
hierarchy_path TEXT, -- Ejemplo: /root/regional-norte/sucursal-01
|
||||||
|
hierarchy_level INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by UUID REFERENCES auth.users(id),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_by UUID REFERENCES auth.users(id),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
UNIQUE(tenant_id, code)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para branches
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_branches_tenant ON core.branches(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_branches_parent ON core.branches(parent_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_branches_code ON core.branches(code);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_branches_type ON core.branches(branch_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_branches_active ON core.branches(is_active) WHERE is_active = TRUE;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_branches_hierarchy ON core.branches(hierarchy_path);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_branches_location ON core.branches USING gist (
|
||||||
|
ll_to_earth(latitude, longitude)
|
||||||
|
) WHERE latitude IS NOT NULL AND longitude IS NOT NULL;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: user_branch_assignments
|
||||||
|
-- Asignacion de usuarios a sucursales
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS core.user_branch_assignments (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
branch_id UUID NOT NULL REFERENCES core.branches(id) ON DELETE CASCADE,
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Tipo de asignacion
|
||||||
|
assignment_type VARCHAR(30) NOT NULL DEFAULT 'primary', -- primary, secondary, temporary, floating
|
||||||
|
|
||||||
|
-- Rol en la sucursal
|
||||||
|
branch_role VARCHAR(50), -- manager, supervisor, staff
|
||||||
|
|
||||||
|
-- Permisos especificos
|
||||||
|
permissions JSONB DEFAULT '[]',
|
||||||
|
|
||||||
|
-- Vigencia (para asignaciones temporales)
|
||||||
|
valid_from TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
valid_until TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by UUID REFERENCES auth.users(id),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
UNIQUE(user_id, branch_id, assignment_type)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para user_branch_assignments
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_branch_user ON core.user_branch_assignments(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_branch_branch ON core.user_branch_assignments(branch_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_branch_tenant ON core.user_branch_assignments(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_branch_active ON core.user_branch_assignments(is_active) WHERE is_active = TRUE;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: branch_schedules
|
||||||
|
-- Horarios de trabajo por sucursal
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS core.branch_schedules (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
branch_id UUID NOT NULL REFERENCES core.branches(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Identificacion
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
|
||||||
|
-- Tipo
|
||||||
|
schedule_type VARCHAR(30) NOT NULL DEFAULT 'regular', -- regular, holiday, special
|
||||||
|
|
||||||
|
-- Dia de la semana (0=domingo, 1=lunes, ..., 6=sabado) o fecha especifica
|
||||||
|
day_of_week INTEGER, -- NULL para fechas especificas
|
||||||
|
specific_date DATE, -- Para dias festivos o especiales
|
||||||
|
|
||||||
|
-- Horarios
|
||||||
|
open_time TIME NOT NULL,
|
||||||
|
close_time TIME NOT NULL,
|
||||||
|
|
||||||
|
-- Turnos (si aplica)
|
||||||
|
shifts JSONB DEFAULT '[]',
|
||||||
|
-- Ejemplo: [{"name": "Matutino", "start": "08:00", "end": "14:00"}, ...]
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para branch_schedules
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_branch_schedules_branch ON core.branch_schedules(branch_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_branch_schedules_day ON core.branch_schedules(day_of_week);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_branch_schedules_date ON core.branch_schedules(specific_date);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: branch_inventory_settings
|
||||||
|
-- Configuracion de inventario por sucursal
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS core.branch_inventory_settings (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
branch_id UUID NOT NULL REFERENCES core.branches(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Almacen asociado
|
||||||
|
warehouse_id UUID, -- Referencia a inventory.warehouses
|
||||||
|
|
||||||
|
-- Configuracion de stock
|
||||||
|
default_stock_min INTEGER DEFAULT 0,
|
||||||
|
default_stock_max INTEGER DEFAULT 1000,
|
||||||
|
auto_reorder_enabled BOOLEAN DEFAULT FALSE,
|
||||||
|
|
||||||
|
-- Configuracion de precios
|
||||||
|
price_list_id UUID, -- Referencia a sales.price_lists
|
||||||
|
allow_price_override BOOLEAN DEFAULT FALSE,
|
||||||
|
max_discount_percent DECIMAL(5,2) DEFAULT 0,
|
||||||
|
|
||||||
|
-- Configuracion de impuestos
|
||||||
|
tax_config JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
UNIQUE(branch_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para branch_inventory_settings
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_branch_inventory_branch ON core.branch_inventory_settings(branch_id);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: branch_payment_terminals
|
||||||
|
-- Terminales de pago asociadas a sucursal
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS core.branch_payment_terminals (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
branch_id UUID NOT NULL REFERENCES core.branches(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Terminal
|
||||||
|
terminal_provider VARCHAR(30) NOT NULL, -- clip, mercadopago, stripe
|
||||||
|
terminal_id VARCHAR(100) NOT NULL,
|
||||||
|
terminal_name VARCHAR(100),
|
||||||
|
|
||||||
|
-- Credenciales (encriptadas)
|
||||||
|
credentials JSONB NOT NULL DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Configuracion
|
||||||
|
is_primary BOOLEAN DEFAULT FALSE,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
|
||||||
|
-- Limites
|
||||||
|
daily_limit DECIMAL(12,2),
|
||||||
|
transaction_limit DECIMAL(12,2),
|
||||||
|
|
||||||
|
-- Ultima actividad
|
||||||
|
last_transaction_at TIMESTAMPTZ,
|
||||||
|
last_health_check_at TIMESTAMPTZ,
|
||||||
|
health_status VARCHAR(20) DEFAULT 'unknown', -- healthy, degraded, offline, unknown
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
UNIQUE(branch_id, terminal_provider, terminal_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para branch_payment_terminals
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_branch_terminals_branch ON core.branch_payment_terminals(branch_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_branch_terminals_provider ON core.branch_payment_terminals(terminal_provider);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_branch_terminals_active ON core.branch_payment_terminals(is_active) WHERE is_active = TRUE;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- RLS POLICIES
|
||||||
|
-- =====================
|
||||||
|
ALTER TABLE core.branches ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_branches ON core.branches
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
ALTER TABLE core.user_branch_assignments ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_branch_assignments ON core.user_branch_assignments
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
ALTER TABLE core.branch_schedules ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_branch_schedules ON core.branch_schedules
|
||||||
|
USING (branch_id IN (
|
||||||
|
SELECT id FROM core.branches
|
||||||
|
WHERE tenant_id = current_setting('app.current_tenant_id', true)::uuid
|
||||||
|
));
|
||||||
|
|
||||||
|
ALTER TABLE core.branch_inventory_settings ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_branch_inventory ON core.branch_inventory_settings
|
||||||
|
USING (branch_id IN (
|
||||||
|
SELECT id FROM core.branches
|
||||||
|
WHERE tenant_id = current_setting('app.current_tenant_id', true)::uuid
|
||||||
|
));
|
||||||
|
|
||||||
|
ALTER TABLE core.branch_payment_terminals ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_branch_terminals ON core.branch_payment_terminals
|
||||||
|
USING (branch_id IN (
|
||||||
|
SELECT id FROM core.branches
|
||||||
|
WHERE tenant_id = current_setting('app.current_tenant_id', true)::uuid
|
||||||
|
));
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- FUNCIONES
|
||||||
|
-- =====================
|
||||||
|
|
||||||
|
-- Funcion para actualizar hierarchy_path
|
||||||
|
CREATE OR REPLACE FUNCTION core.update_branch_hierarchy()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
parent_path TEXT;
|
||||||
|
BEGIN
|
||||||
|
IF NEW.parent_id IS NULL THEN
|
||||||
|
NEW.hierarchy_path := '/' || NEW.code;
|
||||||
|
NEW.hierarchy_level := 0;
|
||||||
|
ELSE
|
||||||
|
SELECT hierarchy_path, hierarchy_level + 1
|
||||||
|
INTO parent_path, NEW.hierarchy_level
|
||||||
|
FROM core.branches
|
||||||
|
WHERE id = NEW.parent_id;
|
||||||
|
|
||||||
|
NEW.hierarchy_path := parent_path || '/' || NEW.code;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Trigger para actualizar hierarchy_path automaticamente
|
||||||
|
CREATE TRIGGER trg_update_branch_hierarchy
|
||||||
|
BEFORE INSERT OR UPDATE OF parent_id, code ON core.branches
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION core.update_branch_hierarchy();
|
||||||
|
|
||||||
|
-- Funcion para obtener todas las sucursales hijas
|
||||||
|
CREATE OR REPLACE FUNCTION core.get_branch_children(parent_branch_id UUID)
|
||||||
|
RETURNS SETOF core.branches AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
WITH RECURSIVE branch_tree AS (
|
||||||
|
SELECT * FROM core.branches WHERE id = parent_branch_id
|
||||||
|
UNION ALL
|
||||||
|
SELECT b.* FROM core.branches b
|
||||||
|
JOIN branch_tree bt ON b.parent_id = bt.id
|
||||||
|
)
|
||||||
|
SELECT * FROM branch_tree WHERE id != parent_branch_id;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Funcion para validar si usuario esta en rango de geofence
|
||||||
|
CREATE OR REPLACE FUNCTION core.is_within_geofence(
|
||||||
|
branch_id UUID,
|
||||||
|
user_lat DECIMAL(10, 8),
|
||||||
|
user_lon DECIMAL(11, 8)
|
||||||
|
)
|
||||||
|
RETURNS BOOLEAN AS $$
|
||||||
|
DECLARE
|
||||||
|
branch_record RECORD;
|
||||||
|
distance_meters FLOAT;
|
||||||
|
BEGIN
|
||||||
|
SELECT latitude, longitude, geofence_radius, geofence_enabled
|
||||||
|
INTO branch_record
|
||||||
|
FROM core.branches
|
||||||
|
WHERE id = branch_id;
|
||||||
|
|
||||||
|
IF NOT branch_record.geofence_enabled THEN
|
||||||
|
RETURN TRUE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF branch_record.latitude IS NULL OR branch_record.longitude IS NULL THEN
|
||||||
|
RETURN TRUE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Calcular distancia usando formula Haversine (aproximada)
|
||||||
|
distance_meters := 6371000 * acos(
|
||||||
|
cos(radians(user_lat)) * cos(radians(branch_record.latitude)) *
|
||||||
|
cos(radians(branch_record.longitude) - radians(user_lon)) +
|
||||||
|
sin(radians(user_lat)) * sin(radians(branch_record.latitude))
|
||||||
|
);
|
||||||
|
|
||||||
|
RETURN distance_meters <= branch_record.geofence_radius;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- COMENTARIOS DE TABLAS
|
||||||
|
-- =====================
|
||||||
|
COMMENT ON TABLE core.branches IS 'Sucursales/ubicaciones del negocio con soporte para jerarquia';
|
||||||
|
COMMENT ON TABLE core.user_branch_assignments IS 'Asignacion de usuarios a sucursales';
|
||||||
|
COMMENT ON TABLE core.branch_schedules IS 'Horarios de operacion por sucursal';
|
||||||
|
COMMENT ON TABLE core.branch_inventory_settings IS 'Configuracion de inventario especifica por sucursal';
|
||||||
|
COMMENT ON TABLE core.branch_payment_terminals IS 'Terminales de pago asociadas a cada sucursal';
|
||||||
@ -1,970 +0,0 @@
|
|||||||
-- =====================================================
|
|
||||||
-- SCHEMA: financial
|
|
||||||
-- PROPÓSITO: Contabilidad, facturas, pagos, finanzas
|
|
||||||
-- MÓDULOS: MGN-004 (Financiero Básico)
|
|
||||||
-- FECHA: 2025-11-24
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Crear schema
|
|
||||||
CREATE SCHEMA IF NOT EXISTS financial;
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- TYPES (ENUMs)
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
CREATE TYPE financial.account_type AS ENUM (
|
|
||||||
'asset',
|
|
||||||
'liability',
|
|
||||||
'equity',
|
|
||||||
'revenue',
|
|
||||||
'expense'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE financial.journal_type AS ENUM (
|
|
||||||
'sale',
|
|
||||||
'purchase',
|
|
||||||
'bank',
|
|
||||||
'cash',
|
|
||||||
'general'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE financial.entry_status AS ENUM (
|
|
||||||
'draft',
|
|
||||||
'posted',
|
|
||||||
'cancelled'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE financial.invoice_type AS ENUM (
|
|
||||||
'customer',
|
|
||||||
'supplier'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE financial.invoice_status AS ENUM (
|
|
||||||
'draft',
|
|
||||||
'open',
|
|
||||||
'paid',
|
|
||||||
'cancelled'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE financial.payment_type AS ENUM (
|
|
||||||
'inbound',
|
|
||||||
'outbound'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE financial.payment_method AS ENUM (
|
|
||||||
'cash',
|
|
||||||
'bank_transfer',
|
|
||||||
'check',
|
|
||||||
'card',
|
|
||||||
'other'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE financial.payment_status AS ENUM (
|
|
||||||
'draft',
|
|
||||||
'posted',
|
|
||||||
'reconciled',
|
|
||||||
'cancelled'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE financial.tax_type AS ENUM (
|
|
||||||
'sales',
|
|
||||||
'purchase',
|
|
||||||
'all'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE financial.fiscal_period_status AS ENUM (
|
|
||||||
'open',
|
|
||||||
'closed'
|
|
||||||
);
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- TABLES
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Tabla: account_types (Tipos de cuenta contable)
|
|
||||||
CREATE TABLE financial.account_types (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
code VARCHAR(20) NOT NULL UNIQUE,
|
|
||||||
name VARCHAR(100) NOT NULL,
|
|
||||||
account_type financial.account_type NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
|
|
||||||
-- Sin tenant_id: catálogo global
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: accounts (Plan de cuentas)
|
|
||||||
CREATE TABLE financial.accounts (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
company_id UUID NOT NULL REFERENCES auth.companies(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
code VARCHAR(50) NOT NULL,
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
account_type_id UUID NOT NULL REFERENCES financial.account_types(id),
|
|
||||||
parent_id UUID REFERENCES financial.accounts(id),
|
|
||||||
|
|
||||||
-- Configuración
|
|
||||||
currency_id UUID REFERENCES core.currencies(id),
|
|
||||||
is_reconcilable BOOLEAN DEFAULT FALSE, -- ¿Permite conciliación?
|
|
||||||
is_deprecated BOOLEAN DEFAULT FALSE,
|
|
||||||
|
|
||||||
-- Notas
|
|
||||||
notes TEXT,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
deleted_at TIMESTAMP,
|
|
||||||
deleted_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT uq_accounts_code_company UNIQUE (company_id, code),
|
|
||||||
CONSTRAINT chk_accounts_no_self_parent CHECK (id != parent_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: journals (Diarios contables)
|
|
||||||
CREATE TABLE financial.journals (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
company_id UUID NOT NULL REFERENCES auth.companies(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
code VARCHAR(20) NOT NULL,
|
|
||||||
journal_type financial.journal_type NOT NULL,
|
|
||||||
|
|
||||||
-- Configuración
|
|
||||||
default_account_id UUID REFERENCES financial.accounts(id),
|
|
||||||
sequence_id UUID REFERENCES core.sequences(id),
|
|
||||||
currency_id UUID REFERENCES core.currencies(id),
|
|
||||||
|
|
||||||
-- Control
|
|
||||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
deleted_at TIMESTAMP,
|
|
||||||
deleted_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT uq_journals_code_company UNIQUE (company_id, code)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: fiscal_years (Años fiscales)
|
|
||||||
CREATE TABLE financial.fiscal_years (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
company_id UUID NOT NULL REFERENCES auth.companies(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
name VARCHAR(100) NOT NULL,
|
|
||||||
code VARCHAR(20) NOT NULL,
|
|
||||||
start_date DATE NOT NULL,
|
|
||||||
end_date DATE NOT NULL,
|
|
||||||
status financial.fiscal_period_status NOT NULL DEFAULT 'open',
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT uq_fiscal_years_code_company UNIQUE (company_id, code),
|
|
||||||
CONSTRAINT chk_fiscal_years_dates CHECK (end_date > start_date)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: fiscal_periods (Períodos fiscales - meses)
|
|
||||||
CREATE TABLE financial.fiscal_periods (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
fiscal_year_id UUID NOT NULL REFERENCES financial.fiscal_years(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
name VARCHAR(100) NOT NULL,
|
|
||||||
code VARCHAR(20) NOT NULL,
|
|
||||||
start_date DATE NOT NULL,
|
|
||||||
end_date DATE NOT NULL,
|
|
||||||
status financial.fiscal_period_status NOT NULL DEFAULT 'open',
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT uq_fiscal_periods_code_year UNIQUE (fiscal_year_id, code),
|
|
||||||
CONSTRAINT chk_fiscal_periods_dates CHECK (end_date > start_date)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: journal_entries (Asientos contables)
|
|
||||||
CREATE TABLE financial.journal_entries (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
company_id UUID NOT NULL REFERENCES auth.companies(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
journal_id UUID NOT NULL REFERENCES financial.journals(id),
|
|
||||||
name VARCHAR(100) NOT NULL, -- Número de asiento
|
|
||||||
ref VARCHAR(255), -- Referencia externa
|
|
||||||
date DATE NOT NULL,
|
|
||||||
status financial.entry_status NOT NULL DEFAULT 'draft',
|
|
||||||
|
|
||||||
-- Metadatos
|
|
||||||
notes TEXT,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
posted_at TIMESTAMP,
|
|
||||||
posted_by UUID REFERENCES auth.users(id),
|
|
||||||
cancelled_at TIMESTAMP,
|
|
||||||
cancelled_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT uq_journal_entries_name_journal UNIQUE (journal_id, name)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: journal_entry_lines (Líneas de asiento contable)
|
|
||||||
CREATE TABLE financial.journal_entry_lines (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
entry_id UUID NOT NULL REFERENCES financial.journal_entries(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
account_id UUID NOT NULL REFERENCES financial.accounts(id),
|
|
||||||
partner_id UUID REFERENCES core.partners(id),
|
|
||||||
|
|
||||||
-- Montos
|
|
||||||
debit DECIMAL(15, 2) NOT NULL DEFAULT 0,
|
|
||||||
credit DECIMAL(15, 2) NOT NULL DEFAULT 0,
|
|
||||||
|
|
||||||
-- Analítica
|
|
||||||
analytic_account_id UUID, -- FK a analytics.analytic_accounts (se crea después)
|
|
||||||
|
|
||||||
-- Descripción
|
|
||||||
description TEXT,
|
|
||||||
ref VARCHAR(255),
|
|
||||||
|
|
||||||
-- Multi-moneda
|
|
||||||
currency_id UUID REFERENCES core.currencies(id),
|
|
||||||
amount_currency DECIMAL(15, 2),
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT chk_journal_lines_debit_positive CHECK (debit >= 0),
|
|
||||||
CONSTRAINT chk_journal_lines_credit_positive CHECK (credit >= 0),
|
|
||||||
CONSTRAINT chk_journal_lines_not_both CHECK (
|
|
||||||
(debit > 0 AND credit = 0) OR (credit > 0 AND debit = 0)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Índices para journal_entry_lines
|
|
||||||
CREATE INDEX idx_journal_entry_lines_tenant_id ON financial.journal_entry_lines(tenant_id);
|
|
||||||
CREATE INDEX idx_journal_entry_lines_entry_id ON financial.journal_entry_lines(entry_id);
|
|
||||||
CREATE INDEX idx_journal_entry_lines_account_id ON financial.journal_entry_lines(account_id);
|
|
||||||
|
|
||||||
-- RLS para journal_entry_lines
|
|
||||||
ALTER TABLE financial.journal_entry_lines ENABLE ROW LEVEL SECURITY;
|
|
||||||
CREATE POLICY tenant_isolation_journal_entry_lines ON financial.journal_entry_lines
|
|
||||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
|
||||||
|
|
||||||
-- Tabla: taxes (Impuestos)
|
|
||||||
CREATE TABLE financial.taxes (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
company_id UUID NOT NULL REFERENCES auth.companies(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
name VARCHAR(100) NOT NULL,
|
|
||||||
code VARCHAR(20) NOT NULL,
|
|
||||||
rate DECIMAL(5, 4) NOT NULL, -- 0.1600 para 16%
|
|
||||||
tax_type financial.tax_type NOT NULL,
|
|
||||||
|
|
||||||
-- Configuración contable
|
|
||||||
account_id UUID REFERENCES financial.accounts(id),
|
|
||||||
|
|
||||||
-- Control
|
|
||||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT uq_taxes_code_company UNIQUE (company_id, code),
|
|
||||||
CONSTRAINT chk_taxes_rate CHECK (rate >= 0 AND rate <= 1)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: payment_terms (Términos de pago)
|
|
||||||
CREATE TABLE financial.payment_terms (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
company_id UUID NOT NULL REFERENCES auth.companies(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
name VARCHAR(100) NOT NULL,
|
|
||||||
code VARCHAR(20) NOT NULL,
|
|
||||||
|
|
||||||
-- Configuración de términos (JSON)
|
|
||||||
-- Ejemplo: [{"days": 0, "percent": 100}] = Pago inmediato
|
|
||||||
-- Ejemplo: [{"days": 30, "percent": 100}] = 30 días
|
|
||||||
-- Ejemplo: [{"days": 15, "percent": 50}, {"days": 30, "percent": 50}] = 50% a 15 días, 50% a 30 días
|
|
||||||
terms JSONB NOT NULL DEFAULT '[]',
|
|
||||||
|
|
||||||
-- Control
|
|
||||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT uq_payment_terms_code_company UNIQUE (company_id, code)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: invoices (Facturas)
|
|
||||||
CREATE TABLE financial.invoices (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
company_id UUID NOT NULL REFERENCES auth.companies(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
partner_id UUID NOT NULL REFERENCES core.partners(id),
|
|
||||||
invoice_type financial.invoice_type NOT NULL,
|
|
||||||
|
|
||||||
-- Numeración
|
|
||||||
number VARCHAR(100), -- Número de factura (generado al validar)
|
|
||||||
ref VARCHAR(100), -- Referencia del partner
|
|
||||||
|
|
||||||
-- Fechas
|
|
||||||
invoice_date DATE NOT NULL,
|
|
||||||
due_date DATE,
|
|
||||||
|
|
||||||
-- Montos
|
|
||||||
currency_id UUID NOT NULL REFERENCES core.currencies(id),
|
|
||||||
amount_untaxed DECIMAL(15, 2) NOT NULL DEFAULT 0,
|
|
||||||
amount_tax DECIMAL(15, 2) NOT NULL DEFAULT 0,
|
|
||||||
amount_total DECIMAL(15, 2) NOT NULL DEFAULT 0,
|
|
||||||
amount_paid DECIMAL(15, 2) NOT NULL DEFAULT 0,
|
|
||||||
amount_residual DECIMAL(15, 2) NOT NULL DEFAULT 0,
|
|
||||||
|
|
||||||
-- Estado
|
|
||||||
status financial.invoice_status NOT NULL DEFAULT 'draft',
|
|
||||||
|
|
||||||
-- Configuración
|
|
||||||
payment_term_id UUID REFERENCES financial.payment_terms(id),
|
|
||||||
journal_id UUID REFERENCES financial.journals(id),
|
|
||||||
|
|
||||||
-- Asiento contable (generado al validar)
|
|
||||||
journal_entry_id UUID REFERENCES financial.journal_entries(id),
|
|
||||||
|
|
||||||
-- Notas
|
|
||||||
notes TEXT,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
validated_at TIMESTAMP,
|
|
||||||
validated_by UUID REFERENCES auth.users(id),
|
|
||||||
cancelled_at TIMESTAMP,
|
|
||||||
cancelled_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT uq_invoices_number_company UNIQUE (company_id, number),
|
|
||||||
CONSTRAINT chk_invoices_amounts CHECK (
|
|
||||||
amount_total = amount_untaxed + amount_tax
|
|
||||||
),
|
|
||||||
CONSTRAINT chk_invoices_residual CHECK (
|
|
||||||
amount_residual = amount_total - amount_paid
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: invoice_lines (Líneas de factura)
|
|
||||||
CREATE TABLE financial.invoice_lines (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
invoice_id UUID NOT NULL REFERENCES financial.invoices(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
product_id UUID, -- FK a inventory.products (se crea después)
|
|
||||||
description TEXT NOT NULL,
|
|
||||||
|
|
||||||
-- Cantidades y precios
|
|
||||||
quantity DECIMAL(12, 4) NOT NULL DEFAULT 1,
|
|
||||||
uom_id UUID REFERENCES core.uom(id),
|
|
||||||
price_unit DECIMAL(15, 4) NOT NULL,
|
|
||||||
|
|
||||||
-- Impuestos (array de tax_ids)
|
|
||||||
tax_ids UUID[] DEFAULT '{}',
|
|
||||||
|
|
||||||
-- Montos calculados
|
|
||||||
amount_untaxed DECIMAL(15, 2) NOT NULL,
|
|
||||||
amount_tax DECIMAL(15, 2) NOT NULL,
|
|
||||||
amount_total DECIMAL(15, 2) NOT NULL,
|
|
||||||
|
|
||||||
-- Contabilidad
|
|
||||||
account_id UUID REFERENCES financial.accounts(id),
|
|
||||||
analytic_account_id UUID, -- FK a analytics.analytic_accounts
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT chk_invoice_lines_quantity CHECK (quantity > 0),
|
|
||||||
CONSTRAINT chk_invoice_lines_amounts CHECK (
|
|
||||||
amount_total = amount_untaxed + amount_tax
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Índices para invoice_lines
|
|
||||||
CREATE INDEX idx_invoice_lines_tenant_id ON financial.invoice_lines(tenant_id);
|
|
||||||
CREATE INDEX idx_invoice_lines_invoice_id ON financial.invoice_lines(invoice_id);
|
|
||||||
CREATE INDEX idx_invoice_lines_product_id ON financial.invoice_lines(product_id);
|
|
||||||
|
|
||||||
-- RLS para invoice_lines
|
|
||||||
ALTER TABLE financial.invoice_lines ENABLE ROW LEVEL SECURITY;
|
|
||||||
CREATE POLICY tenant_isolation_invoice_lines ON financial.invoice_lines
|
|
||||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
|
||||||
|
|
||||||
-- Tabla: payments (Pagos)
|
|
||||||
CREATE TABLE financial.payments (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
company_id UUID NOT NULL REFERENCES auth.companies(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
partner_id UUID NOT NULL REFERENCES core.partners(id),
|
|
||||||
payment_type financial.payment_type NOT NULL,
|
|
||||||
payment_method financial.payment_method NOT NULL,
|
|
||||||
|
|
||||||
-- Monto
|
|
||||||
amount DECIMAL(15, 2) NOT NULL,
|
|
||||||
currency_id UUID NOT NULL REFERENCES core.currencies(id),
|
|
||||||
|
|
||||||
-- Fecha y referencia
|
|
||||||
payment_date DATE NOT NULL,
|
|
||||||
ref VARCHAR(255),
|
|
||||||
|
|
||||||
-- Estado
|
|
||||||
status financial.payment_status NOT NULL DEFAULT 'draft',
|
|
||||||
|
|
||||||
-- Configuración
|
|
||||||
journal_id UUID NOT NULL REFERENCES financial.journals(id),
|
|
||||||
|
|
||||||
-- Asiento contable (generado al validar)
|
|
||||||
journal_entry_id UUID REFERENCES financial.journal_entries(id),
|
|
||||||
|
|
||||||
-- Notas
|
|
||||||
notes TEXT,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
posted_at TIMESTAMP,
|
|
||||||
posted_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT chk_payments_amount CHECK (amount > 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: payment_invoice (Conciliación pagos-facturas)
|
|
||||||
CREATE TABLE financial.payment_invoice (
|
|
||||||
payment_id UUID NOT NULL REFERENCES financial.payments(id) ON DELETE CASCADE,
|
|
||||||
invoice_id UUID NOT NULL REFERENCES financial.invoices(id) ON DELETE CASCADE,
|
|
||||||
amount DECIMAL(15, 2) NOT NULL,
|
|
||||||
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
PRIMARY KEY (payment_id, invoice_id),
|
|
||||||
CONSTRAINT chk_payment_invoice_amount CHECK (amount > 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: bank_accounts (Cuentas bancarias)
|
|
||||||
CREATE TABLE financial.bank_accounts (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
company_id UUID REFERENCES auth.companies(id),
|
|
||||||
|
|
||||||
partner_id UUID REFERENCES core.partners(id), -- Puede ser de la empresa o de un partner
|
|
||||||
|
|
||||||
bank_name VARCHAR(255) NOT NULL,
|
|
||||||
account_number VARCHAR(50) NOT NULL,
|
|
||||||
account_holder VARCHAR(255),
|
|
||||||
|
|
||||||
-- Configuración
|
|
||||||
currency_id UUID REFERENCES core.currencies(id),
|
|
||||||
journal_id UUID REFERENCES financial.journals(id), -- Diario asociado (si es cuenta de la empresa)
|
|
||||||
|
|
||||||
-- Control
|
|
||||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by UUID REFERENCES auth.users(id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: reconciliations (Conciliaciones bancarias)
|
|
||||||
CREATE TABLE financial.reconciliations (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
company_id UUID NOT NULL REFERENCES auth.companies(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
bank_account_id UUID NOT NULL REFERENCES financial.bank_accounts(id),
|
|
||||||
|
|
||||||
-- Período de conciliación
|
|
||||||
start_date DATE NOT NULL,
|
|
||||||
end_date DATE NOT NULL,
|
|
||||||
|
|
||||||
-- Saldos
|
|
||||||
balance_start DECIMAL(15, 2) NOT NULL,
|
|
||||||
balance_end_real DECIMAL(15, 2) NOT NULL, -- Saldo real del banco
|
|
||||||
balance_end_computed DECIMAL(15, 2) NOT NULL, -- Saldo calculado
|
|
||||||
|
|
||||||
-- Líneas conciliadas (array de journal_entry_line_ids)
|
|
||||||
reconciled_line_ids UUID[] DEFAULT '{}',
|
|
||||||
|
|
||||||
-- Estado
|
|
||||||
status financial.entry_status NOT NULL DEFAULT 'draft',
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
validated_at TIMESTAMP,
|
|
||||||
validated_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT chk_reconciliations_dates CHECK (end_date >= start_date)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- INDICES
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Account Types
|
|
||||||
CREATE INDEX idx_account_types_code ON financial.account_types(code);
|
|
||||||
|
|
||||||
-- Accounts
|
|
||||||
CREATE INDEX idx_accounts_tenant_id ON financial.accounts(tenant_id);
|
|
||||||
CREATE INDEX idx_accounts_company_id ON financial.accounts(company_id);
|
|
||||||
CREATE INDEX idx_accounts_code ON financial.accounts(code);
|
|
||||||
CREATE INDEX idx_accounts_parent_id ON financial.accounts(parent_id);
|
|
||||||
CREATE INDEX idx_accounts_type_id ON financial.accounts(account_type_id);
|
|
||||||
|
|
||||||
-- Journals
|
|
||||||
CREATE INDEX idx_journals_tenant_id ON financial.journals(tenant_id);
|
|
||||||
CREATE INDEX idx_journals_company_id ON financial.journals(company_id);
|
|
||||||
CREATE INDEX idx_journals_code ON financial.journals(code);
|
|
||||||
CREATE INDEX idx_journals_type ON financial.journals(journal_type);
|
|
||||||
|
|
||||||
-- Fiscal Years
|
|
||||||
CREATE INDEX idx_fiscal_years_tenant_id ON financial.fiscal_years(tenant_id);
|
|
||||||
CREATE INDEX idx_fiscal_years_company_id ON financial.fiscal_years(company_id);
|
|
||||||
CREATE INDEX idx_fiscal_years_dates ON financial.fiscal_years(start_date, end_date);
|
|
||||||
|
|
||||||
-- Fiscal Periods
|
|
||||||
CREATE INDEX idx_fiscal_periods_tenant_id ON financial.fiscal_periods(tenant_id);
|
|
||||||
CREATE INDEX idx_fiscal_periods_year_id ON financial.fiscal_periods(fiscal_year_id);
|
|
||||||
CREATE INDEX idx_fiscal_periods_dates ON financial.fiscal_periods(start_date, end_date);
|
|
||||||
|
|
||||||
-- Journal Entries
|
|
||||||
CREATE INDEX idx_journal_entries_tenant_id ON financial.journal_entries(tenant_id);
|
|
||||||
CREATE INDEX idx_journal_entries_company_id ON financial.journal_entries(company_id);
|
|
||||||
CREATE INDEX idx_journal_entries_journal_id ON financial.journal_entries(journal_id);
|
|
||||||
CREATE INDEX idx_journal_entries_date ON financial.journal_entries(date);
|
|
||||||
CREATE INDEX idx_journal_entries_status ON financial.journal_entries(status);
|
|
||||||
|
|
||||||
-- Journal Entry Lines
|
|
||||||
CREATE INDEX idx_journal_entry_lines_entry_id ON financial.journal_entry_lines(entry_id);
|
|
||||||
CREATE INDEX idx_journal_entry_lines_account_id ON financial.journal_entry_lines(account_id);
|
|
||||||
CREATE INDEX idx_journal_entry_lines_partner_id ON financial.journal_entry_lines(partner_id);
|
|
||||||
CREATE INDEX idx_journal_entry_lines_analytic ON financial.journal_entry_lines(analytic_account_id);
|
|
||||||
|
|
||||||
-- Taxes
|
|
||||||
CREATE INDEX idx_taxes_tenant_id ON financial.taxes(tenant_id);
|
|
||||||
CREATE INDEX idx_taxes_company_id ON financial.taxes(company_id);
|
|
||||||
CREATE INDEX idx_taxes_code ON financial.taxes(code);
|
|
||||||
CREATE INDEX idx_taxes_type ON financial.taxes(tax_type);
|
|
||||||
CREATE INDEX idx_taxes_active ON financial.taxes(active) WHERE active = TRUE;
|
|
||||||
|
|
||||||
-- Payment Terms
|
|
||||||
CREATE INDEX idx_payment_terms_tenant_id ON financial.payment_terms(tenant_id);
|
|
||||||
CREATE INDEX idx_payment_terms_company_id ON financial.payment_terms(company_id);
|
|
||||||
|
|
||||||
-- Invoices
|
|
||||||
CREATE INDEX idx_invoices_tenant_id ON financial.invoices(tenant_id);
|
|
||||||
CREATE INDEX idx_invoices_company_id ON financial.invoices(company_id);
|
|
||||||
CREATE INDEX idx_invoices_partner_id ON financial.invoices(partner_id);
|
|
||||||
CREATE INDEX idx_invoices_type ON financial.invoices(invoice_type);
|
|
||||||
CREATE INDEX idx_invoices_status ON financial.invoices(status);
|
|
||||||
CREATE INDEX idx_invoices_number ON financial.invoices(number);
|
|
||||||
CREATE INDEX idx_invoices_date ON financial.invoices(invoice_date);
|
|
||||||
CREATE INDEX idx_invoices_due_date ON financial.invoices(due_date);
|
|
||||||
|
|
||||||
-- Invoice Lines
|
|
||||||
CREATE INDEX idx_invoice_lines_invoice_id ON financial.invoice_lines(invoice_id);
|
|
||||||
CREATE INDEX idx_invoice_lines_product_id ON financial.invoice_lines(product_id);
|
|
||||||
CREATE INDEX idx_invoice_lines_account_id ON financial.invoice_lines(account_id);
|
|
||||||
|
|
||||||
-- Payments
|
|
||||||
CREATE INDEX idx_payments_tenant_id ON financial.payments(tenant_id);
|
|
||||||
CREATE INDEX idx_payments_company_id ON financial.payments(company_id);
|
|
||||||
CREATE INDEX idx_payments_partner_id ON financial.payments(partner_id);
|
|
||||||
CREATE INDEX idx_payments_type ON financial.payments(payment_type);
|
|
||||||
CREATE INDEX idx_payments_status ON financial.payments(status);
|
|
||||||
CREATE INDEX idx_payments_date ON financial.payments(payment_date);
|
|
||||||
|
|
||||||
-- Payment Invoice
|
|
||||||
CREATE INDEX idx_payment_invoice_payment_id ON financial.payment_invoice(payment_id);
|
|
||||||
CREATE INDEX idx_payment_invoice_invoice_id ON financial.payment_invoice(invoice_id);
|
|
||||||
|
|
||||||
-- Bank Accounts
|
|
||||||
CREATE INDEX idx_bank_accounts_tenant_id ON financial.bank_accounts(tenant_id);
|
|
||||||
CREATE INDEX idx_bank_accounts_company_id ON financial.bank_accounts(company_id);
|
|
||||||
CREATE INDEX idx_bank_accounts_partner_id ON financial.bank_accounts(partner_id);
|
|
||||||
|
|
||||||
-- Reconciliations
|
|
||||||
CREATE INDEX idx_reconciliations_tenant_id ON financial.reconciliations(tenant_id);
|
|
||||||
CREATE INDEX idx_reconciliations_company_id ON financial.reconciliations(company_id);
|
|
||||||
CREATE INDEX idx_reconciliations_bank_account_id ON financial.reconciliations(bank_account_id);
|
|
||||||
CREATE INDEX idx_reconciliations_dates ON financial.reconciliations(start_date, end_date);
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- FUNCTIONS
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Función: validate_entry_balance
|
|
||||||
-- Valida que un asiento esté balanceado (debit = credit)
|
|
||||||
CREATE OR REPLACE FUNCTION financial.validate_entry_balance(p_entry_id UUID)
|
|
||||||
RETURNS BOOLEAN AS $$
|
|
||||||
DECLARE
|
|
||||||
v_total_debit DECIMAL;
|
|
||||||
v_total_credit DECIMAL;
|
|
||||||
BEGIN
|
|
||||||
SELECT
|
|
||||||
COALESCE(SUM(debit), 0),
|
|
||||||
COALESCE(SUM(credit), 0)
|
|
||||||
INTO v_total_debit, v_total_credit
|
|
||||||
FROM financial.journal_entry_lines
|
|
||||||
WHERE entry_id = p_entry_id;
|
|
||||||
|
|
||||||
IF v_total_debit != v_total_credit THEN
|
|
||||||
RAISE EXCEPTION 'Journal entry % is not balanced: debit=% credit=%',
|
|
||||||
p_entry_id, v_total_debit, v_total_credit;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
RETURN TRUE;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION financial.validate_entry_balance IS 'Valida que un asiento contable esté balanceado (debit = credit)';
|
|
||||||
|
|
||||||
-- Función: post_journal_entry
|
|
||||||
-- Contabiliza un asiento (cambiar estado a posted)
|
|
||||||
CREATE OR REPLACE FUNCTION financial.post_journal_entry(p_entry_id UUID)
|
|
||||||
RETURNS VOID AS $$
|
|
||||||
BEGIN
|
|
||||||
-- Validar balance
|
|
||||||
PERFORM financial.validate_entry_balance(p_entry_id);
|
|
||||||
|
|
||||||
-- Actualizar estado
|
|
||||||
UPDATE financial.journal_entries
|
|
||||||
SET status = 'posted',
|
|
||||||
posted_at = CURRENT_TIMESTAMP,
|
|
||||||
posted_by = get_current_user_id()
|
|
||||||
WHERE id = p_entry_id
|
|
||||||
AND status = 'draft';
|
|
||||||
|
|
||||||
IF NOT FOUND THEN
|
|
||||||
RAISE EXCEPTION 'Journal entry % not found or already posted', p_entry_id;
|
|
||||||
END IF;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION financial.post_journal_entry IS 'Contabiliza un asiento contable después de validar su balance';
|
|
||||||
|
|
||||||
-- Función: calculate_invoice_totals
|
|
||||||
-- Calcula los totales de una factura a partir de sus líneas
|
|
||||||
CREATE OR REPLACE FUNCTION financial.calculate_invoice_totals(p_invoice_id UUID)
|
|
||||||
RETURNS VOID AS $$
|
|
||||||
DECLARE
|
|
||||||
v_amount_untaxed DECIMAL;
|
|
||||||
v_amount_tax DECIMAL;
|
|
||||||
v_amount_total DECIMAL;
|
|
||||||
BEGIN
|
|
||||||
SELECT
|
|
||||||
COALESCE(SUM(amount_untaxed), 0),
|
|
||||||
COALESCE(SUM(amount_tax), 0),
|
|
||||||
COALESCE(SUM(amount_total), 0)
|
|
||||||
INTO v_amount_untaxed, v_amount_tax, v_amount_total
|
|
||||||
FROM financial.invoice_lines
|
|
||||||
WHERE invoice_id = p_invoice_id;
|
|
||||||
|
|
||||||
UPDATE financial.invoices
|
|
||||||
SET amount_untaxed = v_amount_untaxed,
|
|
||||||
amount_tax = v_amount_tax,
|
|
||||||
amount_total = v_amount_total,
|
|
||||||
amount_residual = v_amount_total - amount_paid,
|
|
||||||
updated_at = CURRENT_TIMESTAMP,
|
|
||||||
updated_by = get_current_user_id()
|
|
||||||
WHERE id = p_invoice_id;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION financial.calculate_invoice_totals IS 'Calcula los totales de una factura a partir de sus líneas';
|
|
||||||
|
|
||||||
-- Función: update_invoice_paid_amount
|
|
||||||
-- Actualiza el monto pagado de una factura
|
|
||||||
CREATE OR REPLACE FUNCTION financial.update_invoice_paid_amount(p_invoice_id UUID)
|
|
||||||
RETURNS VOID AS $$
|
|
||||||
DECLARE
|
|
||||||
v_amount_paid DECIMAL;
|
|
||||||
BEGIN
|
|
||||||
SELECT COALESCE(SUM(amount), 0)
|
|
||||||
INTO v_amount_paid
|
|
||||||
FROM financial.payment_invoice
|
|
||||||
WHERE invoice_id = p_invoice_id;
|
|
||||||
|
|
||||||
UPDATE financial.invoices
|
|
||||||
SET amount_paid = v_amount_paid,
|
|
||||||
amount_residual = amount_total - v_amount_paid,
|
|
||||||
status = CASE
|
|
||||||
WHEN v_amount_paid >= amount_total THEN 'paid'::financial.invoice_status
|
|
||||||
WHEN v_amount_paid > 0 THEN 'open'::financial.invoice_status
|
|
||||||
ELSE status
|
|
||||||
END
|
|
||||||
WHERE id = p_invoice_id;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION financial.update_invoice_paid_amount IS 'Actualiza el monto pagado y estado de una factura';
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- TRIGGERS
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Trigger: Actualizar updated_at
|
|
||||||
CREATE TRIGGER trg_accounts_updated_at
|
|
||||||
BEFORE UPDATE ON financial.accounts
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_journals_updated_at
|
|
||||||
BEFORE UPDATE ON financial.journals
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_fiscal_years_updated_at
|
|
||||||
BEFORE UPDATE ON financial.fiscal_years
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_fiscal_periods_updated_at
|
|
||||||
BEFORE UPDATE ON financial.fiscal_periods
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_journal_entries_updated_at
|
|
||||||
BEFORE UPDATE ON financial.journal_entries
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_taxes_updated_at
|
|
||||||
BEFORE UPDATE ON financial.taxes
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_payment_terms_updated_at
|
|
||||||
BEFORE UPDATE ON financial.payment_terms
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_invoices_updated_at
|
|
||||||
BEFORE UPDATE ON financial.invoices
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_payments_updated_at
|
|
||||||
BEFORE UPDATE ON financial.payments
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_bank_accounts_updated_at
|
|
||||||
BEFORE UPDATE ON financial.bank_accounts
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_reconciliations_updated_at
|
|
||||||
BEFORE UPDATE ON financial.reconciliations
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
-- Trigger: Validar balance antes de contabilizar
|
|
||||||
CREATE OR REPLACE FUNCTION financial.trg_validate_entry_before_post()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
IF NEW.status = 'posted' AND OLD.status = 'draft' THEN
|
|
||||||
PERFORM financial.validate_entry_balance(NEW.id);
|
|
||||||
END IF;
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_journal_entries_validate_balance
|
|
||||||
BEFORE UPDATE OF status ON financial.journal_entries
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION financial.trg_validate_entry_before_post();
|
|
||||||
|
|
||||||
-- Trigger: Actualizar totales de factura al cambiar líneas
|
|
||||||
CREATE OR REPLACE FUNCTION financial.trg_update_invoice_totals()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
IF TG_OP = 'DELETE' THEN
|
|
||||||
PERFORM financial.calculate_invoice_totals(OLD.invoice_id);
|
|
||||||
ELSE
|
|
||||||
PERFORM financial.calculate_invoice_totals(NEW.invoice_id);
|
|
||||||
END IF;
|
|
||||||
RETURN NULL;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_invoice_lines_update_totals
|
|
||||||
AFTER INSERT OR UPDATE OR DELETE ON financial.invoice_lines
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION financial.trg_update_invoice_totals();
|
|
||||||
|
|
||||||
-- Trigger: Actualizar monto pagado al conciliar
|
|
||||||
CREATE OR REPLACE FUNCTION financial.trg_update_invoice_paid()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
IF TG_OP = 'DELETE' THEN
|
|
||||||
PERFORM financial.update_invoice_paid_amount(OLD.invoice_id);
|
|
||||||
ELSE
|
|
||||||
PERFORM financial.update_invoice_paid_amount(NEW.invoice_id);
|
|
||||||
END IF;
|
|
||||||
RETURN NULL;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_payment_invoice_update_paid
|
|
||||||
AFTER INSERT OR UPDATE OR DELETE ON financial.payment_invoice
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION financial.trg_update_invoice_paid();
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- TRACKING AUTOMÁTICO (mail.thread pattern)
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Trigger: Tracking automático para facturas
|
|
||||||
CREATE TRIGGER track_invoice_changes
|
|
||||||
AFTER INSERT OR UPDATE OR DELETE ON financial.invoices
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION system.track_field_changes();
|
|
||||||
|
|
||||||
COMMENT ON TRIGGER track_invoice_changes ON financial.invoices IS
|
|
||||||
'Registra automáticamente cambios en facturas (estado, monto, cliente, fechas)';
|
|
||||||
|
|
||||||
-- Trigger: Tracking automático para asientos contables
|
|
||||||
CREATE TRIGGER track_journal_entry_changes
|
|
||||||
AFTER INSERT OR UPDATE OR DELETE ON financial.journal_entries
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION system.track_field_changes();
|
|
||||||
|
|
||||||
COMMENT ON TRIGGER track_journal_entry_changes ON financial.journal_entries IS
|
|
||||||
'Registra automáticamente cambios en asientos contables (estado, fecha, diario)';
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- ROW LEVEL SECURITY (RLS)
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
ALTER TABLE financial.accounts ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE financial.journals ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE financial.fiscal_years ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE financial.fiscal_periods ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE financial.journal_entries ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE financial.taxes ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE financial.payment_terms ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE financial.invoices ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE financial.payments ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE financial.bank_accounts ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE financial.reconciliations ENABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_accounts ON financial.accounts
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_journals ON financial.journals
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_fiscal_years ON financial.fiscal_years
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_fiscal_periods ON financial.fiscal_periods
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_journal_entries ON financial.journal_entries
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_taxes ON financial.taxes
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_payment_terms ON financial.payment_terms
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_invoices ON financial.invoices
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_payments ON financial.payments
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_bank_accounts ON financial.bank_accounts
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_reconciliations ON financial.reconciliations
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- SEED DATA
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Tipos de cuenta estándar
|
|
||||||
INSERT INTO financial.account_types (code, name, account_type, description) VALUES
|
|
||||||
('ASSET_CASH', 'Cash and Cash Equivalents', 'asset', 'Efectivo y equivalentes'),
|
|
||||||
('ASSET_RECEIVABLE', 'Accounts Receivable', 'asset', 'Cuentas por cobrar'),
|
|
||||||
('ASSET_CURRENT', 'Current Assets', 'asset', 'Activos circulantes'),
|
|
||||||
('ASSET_FIXED', 'Fixed Assets', 'asset', 'Activos fijos'),
|
|
||||||
('LIABILITY_PAYABLE', 'Accounts Payable', 'liability', 'Cuentas por pagar'),
|
|
||||||
('LIABILITY_CURRENT', 'Current Liabilities', 'liability', 'Pasivos circulantes'),
|
|
||||||
('LIABILITY_LONG', 'Long-term Liabilities', 'liability', 'Pasivos a largo plazo'),
|
|
||||||
('EQUITY_CAPITAL', 'Capital', 'equity', 'Capital social'),
|
|
||||||
('EQUITY_RETAINED', 'Retained Earnings', 'equity', 'Utilidades retenidas'),
|
|
||||||
('REVENUE_SALES', 'Sales Revenue', 'revenue', 'Ingresos por ventas'),
|
|
||||||
('REVENUE_OTHER', 'Other Revenue', 'revenue', 'Otros ingresos'),
|
|
||||||
('EXPENSE_COGS', 'Cost of Goods Sold', 'expense', 'Costo de ventas'),
|
|
||||||
('EXPENSE_OPERATING', 'Operating Expenses', 'expense', 'Gastos operativos'),
|
|
||||||
('EXPENSE_ADMIN', 'Administrative Expenses', 'expense', 'Gastos administrativos')
|
|
||||||
ON CONFLICT (code) DO NOTHING;
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- COMENTARIOS
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
COMMENT ON SCHEMA financial IS 'Schema de contabilidad, facturas, pagos y finanzas';
|
|
||||||
COMMENT ON TABLE financial.account_types IS 'Tipos de cuentas contables (asset, liability, equity, revenue, expense)';
|
|
||||||
COMMENT ON TABLE financial.accounts IS 'Plan de cuentas contables';
|
|
||||||
COMMENT ON TABLE financial.journals IS 'Diarios contables (ventas, compras, bancos, etc.)';
|
|
||||||
COMMENT ON TABLE financial.fiscal_years IS 'Años fiscales';
|
|
||||||
COMMENT ON TABLE financial.fiscal_periods IS 'Períodos fiscales (meses)';
|
|
||||||
COMMENT ON TABLE financial.journal_entries IS 'Asientos contables';
|
|
||||||
COMMENT ON TABLE financial.journal_entry_lines IS 'Líneas de asientos contables (partida doble)';
|
|
||||||
COMMENT ON TABLE financial.taxes IS 'Impuestos (IVA, retenciones, etc.)';
|
|
||||||
COMMENT ON TABLE financial.payment_terms IS 'Términos de pago (inmediato, 30 días, etc.)';
|
|
||||||
COMMENT ON TABLE financial.invoices IS 'Facturas de cliente y proveedor';
|
|
||||||
COMMENT ON TABLE financial.invoice_lines IS 'Líneas de factura';
|
|
||||||
COMMENT ON TABLE financial.payments IS 'Pagos y cobros';
|
|
||||||
COMMENT ON TABLE financial.payment_invoice IS 'Conciliación de pagos con facturas';
|
|
||||||
COMMENT ON TABLE financial.bank_accounts IS 'Cuentas bancarias de la empresa y partners';
|
|
||||||
COMMENT ON TABLE financial.reconciliations IS 'Conciliaciones bancarias';
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- FIN DEL SCHEMA FINANCIAL
|
|
||||||
-- =====================================================
|
|
||||||
393
ddl/04-mobile.sql
Normal file
393
ddl/04-mobile.sql
Normal file
@ -0,0 +1,393 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- ARCHIVO: 04-mobile.sql
|
||||||
|
-- DESCRIPCION: Sesiones moviles, sincronizacion offline, push tokens
|
||||||
|
-- VERSION: 1.0.0
|
||||||
|
-- PROYECTO: ERP-Core V2
|
||||||
|
-- FECHA: 2026-01-10
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- SCHEMA: mobile
|
||||||
|
-- =====================
|
||||||
|
CREATE SCHEMA IF NOT EXISTS mobile;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: mobile_sessions
|
||||||
|
-- Sesiones activas de la aplicacion movil
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS mobile.mobile_sessions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
device_id UUID NOT NULL REFERENCES auth.devices(id) ON DELETE CASCADE,
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
branch_id UUID REFERENCES core.branches(id),
|
||||||
|
|
||||||
|
-- Estado de la sesion
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'active', -- active, paused, expired, terminated
|
||||||
|
|
||||||
|
-- Perfil activo
|
||||||
|
active_profile_id UUID REFERENCES auth.user_profiles(id),
|
||||||
|
active_profile_code VARCHAR(10),
|
||||||
|
|
||||||
|
-- Modo de operacion
|
||||||
|
is_offline_mode BOOLEAN DEFAULT FALSE,
|
||||||
|
offline_since TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Sincronizacion
|
||||||
|
last_sync_at TIMESTAMPTZ,
|
||||||
|
pending_sync_count INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
-- Ubicacion
|
||||||
|
last_latitude DECIMAL(10, 8),
|
||||||
|
last_longitude DECIMAL(11, 8),
|
||||||
|
last_location_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
app_version VARCHAR(20),
|
||||||
|
platform VARCHAR(20), -- ios, android
|
||||||
|
os_version VARCHAR(20),
|
||||||
|
|
||||||
|
-- Tiempos
|
||||||
|
started_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_activity_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
ended_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para mobile_sessions
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mobile_sessions_user ON mobile.mobile_sessions(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mobile_sessions_device ON mobile.mobile_sessions(device_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mobile_sessions_tenant ON mobile.mobile_sessions(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mobile_sessions_branch ON mobile.mobile_sessions(branch_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mobile_sessions_active ON mobile.mobile_sessions(status) WHERE status = 'active';
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: offline_sync_queue
|
||||||
|
-- Cola de operaciones pendientes de sincronizar
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS mobile.offline_sync_queue (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
device_id UUID NOT NULL REFERENCES auth.devices(id) ON DELETE CASCADE,
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
session_id UUID REFERENCES mobile.mobile_sessions(id),
|
||||||
|
|
||||||
|
-- Operacion
|
||||||
|
entity_type VARCHAR(50) NOT NULL, -- sale, attendance, inventory_count, etc.
|
||||||
|
entity_id UUID, -- ID local del registro
|
||||||
|
operation VARCHAR(20) NOT NULL, -- create, update, delete
|
||||||
|
|
||||||
|
-- Datos
|
||||||
|
payload JSONB NOT NULL,
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Orden y dependencias
|
||||||
|
sequence_number BIGINT NOT NULL,
|
||||||
|
depends_on UUID, -- ID de otra operacion que debe procesarse primero
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending, processing, completed, failed, conflict
|
||||||
|
|
||||||
|
-- Procesamiento
|
||||||
|
retry_count INTEGER DEFAULT 0,
|
||||||
|
max_retries INTEGER DEFAULT 3,
|
||||||
|
last_error TEXT,
|
||||||
|
processed_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Conflicto
|
||||||
|
conflict_data JSONB,
|
||||||
|
conflict_resolved_at TIMESTAMPTZ,
|
||||||
|
conflict_resolution VARCHAR(20), -- local_wins, server_wins, merged, manual
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para offline_sync_queue
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_offline_sync_user ON mobile.offline_sync_queue(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_offline_sync_device ON mobile.offline_sync_queue(device_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_offline_sync_tenant ON mobile.offline_sync_queue(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_offline_sync_status ON mobile.offline_sync_queue(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_offline_sync_sequence ON mobile.offline_sync_queue(device_id, sequence_number);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_offline_sync_pending ON mobile.offline_sync_queue(status, created_at) WHERE status = 'pending';
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: sync_conflicts
|
||||||
|
-- Registro de conflictos de sincronizacion
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS mobile.sync_conflicts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
sync_queue_id UUID NOT NULL REFERENCES mobile.offline_sync_queue(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id),
|
||||||
|
|
||||||
|
-- Tipo de conflicto
|
||||||
|
conflict_type VARCHAR(30) NOT NULL, -- version_mismatch, deleted_on_server, concurrent_edit
|
||||||
|
|
||||||
|
-- Datos en conflicto
|
||||||
|
local_data JSONB NOT NULL,
|
||||||
|
server_data JSONB NOT NULL,
|
||||||
|
|
||||||
|
-- Resolucion
|
||||||
|
resolution VARCHAR(20), -- local_wins, server_wins, merged, manual
|
||||||
|
merged_data JSONB,
|
||||||
|
resolved_by UUID REFERENCES auth.users(id),
|
||||||
|
resolved_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para sync_conflicts
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sync_conflicts_queue ON mobile.sync_conflicts(sync_queue_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sync_conflicts_user ON mobile.sync_conflicts(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sync_conflicts_unresolved ON mobile.sync_conflicts(resolved_at) WHERE resolved_at IS NULL;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: push_tokens
|
||||||
|
-- Tokens de notificaciones push
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS mobile.push_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
device_id UUID NOT NULL REFERENCES auth.devices(id) ON DELETE CASCADE,
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Token
|
||||||
|
token TEXT NOT NULL,
|
||||||
|
platform VARCHAR(20) NOT NULL, -- ios, android
|
||||||
|
provider VARCHAR(30) NOT NULL DEFAULT 'firebase', -- firebase, apns, fcm
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
is_valid BOOLEAN DEFAULT TRUE,
|
||||||
|
invalid_reason TEXT,
|
||||||
|
|
||||||
|
-- Topics suscritos
|
||||||
|
subscribed_topics TEXT[] DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Ultima actividad
|
||||||
|
last_used_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
UNIQUE(device_id, platform)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para push_tokens
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_push_tokens_user ON mobile.push_tokens(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_push_tokens_device ON mobile.push_tokens(device_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_push_tokens_tenant ON mobile.push_tokens(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_push_tokens_active ON mobile.push_tokens(is_active, is_valid) WHERE is_active = TRUE AND is_valid = TRUE;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: push_notifications_log
|
||||||
|
-- Log de notificaciones enviadas
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS mobile.push_notifications_log (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Destino
|
||||||
|
user_id UUID REFERENCES auth.users(id),
|
||||||
|
device_id UUID REFERENCES auth.devices(id),
|
||||||
|
push_token_id UUID REFERENCES mobile.push_tokens(id),
|
||||||
|
|
||||||
|
-- Notificacion
|
||||||
|
title VARCHAR(200) NOT NULL,
|
||||||
|
body TEXT,
|
||||||
|
data JSONB DEFAULT '{}',
|
||||||
|
category VARCHAR(50), -- attendance, sale, inventory, alert, system
|
||||||
|
|
||||||
|
-- Envio
|
||||||
|
sent_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
provider_message_id VARCHAR(255),
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'sent', -- sent, delivered, failed, read
|
||||||
|
delivered_at TIMESTAMPTZ,
|
||||||
|
read_at TIMESTAMPTZ,
|
||||||
|
error_message TEXT,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para push_notifications_log
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_push_log_tenant ON mobile.push_notifications_log(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_push_log_user ON mobile.push_notifications_log(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_push_log_device ON mobile.push_notifications_log(device_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_push_log_created ON mobile.push_notifications_log(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_push_log_category ON mobile.push_notifications_log(category);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: payment_transactions
|
||||||
|
-- Transacciones de pago desde terminales moviles
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS mobile.payment_transactions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
branch_id UUID REFERENCES core.branches(id),
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id),
|
||||||
|
device_id UUID REFERENCES auth.devices(id),
|
||||||
|
|
||||||
|
-- Referencia al documento origen
|
||||||
|
source_type VARCHAR(30) NOT NULL, -- sale, invoice, subscription
|
||||||
|
source_id UUID NOT NULL,
|
||||||
|
|
||||||
|
-- Terminal de pago
|
||||||
|
terminal_provider VARCHAR(30) NOT NULL, -- clip, mercadopago, stripe
|
||||||
|
terminal_id VARCHAR(100),
|
||||||
|
|
||||||
|
-- Transaccion
|
||||||
|
external_transaction_id VARCHAR(255),
|
||||||
|
amount DECIMAL(12,2) NOT NULL,
|
||||||
|
currency VARCHAR(3) DEFAULT 'MXN',
|
||||||
|
tip_amount DECIMAL(12,2) DEFAULT 0,
|
||||||
|
total_amount DECIMAL(12,2) NOT NULL,
|
||||||
|
|
||||||
|
-- Metodo de pago
|
||||||
|
payment_method VARCHAR(30) NOT NULL, -- card, contactless, qr, link
|
||||||
|
card_brand VARCHAR(20), -- visa, mastercard, amex
|
||||||
|
card_last_four VARCHAR(4),
|
||||||
|
card_type VARCHAR(20), -- credit, debit
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending, processing, completed, failed, refunded, cancelled
|
||||||
|
failure_reason TEXT,
|
||||||
|
|
||||||
|
-- Tiempos
|
||||||
|
initiated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Metadata del proveedor
|
||||||
|
provider_response JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Recibo
|
||||||
|
receipt_url TEXT,
|
||||||
|
receipt_sent BOOLEAN DEFAULT FALSE,
|
||||||
|
receipt_sent_to VARCHAR(255),
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para payment_transactions
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_payment_tx_tenant ON mobile.payment_transactions(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_payment_tx_branch ON mobile.payment_transactions(branch_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_payment_tx_user ON mobile.payment_transactions(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_payment_tx_source ON mobile.payment_transactions(source_type, source_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_payment_tx_external ON mobile.payment_transactions(external_transaction_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_payment_tx_status ON mobile.payment_transactions(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_payment_tx_created ON mobile.payment_transactions(created_at DESC);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- RLS POLICIES
|
||||||
|
-- =====================
|
||||||
|
ALTER TABLE mobile.mobile_sessions ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_mobile_sessions ON mobile.mobile_sessions
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
ALTER TABLE mobile.offline_sync_queue ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_sync_queue ON mobile.offline_sync_queue
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
ALTER TABLE mobile.sync_conflicts ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_sync_conflicts ON mobile.sync_conflicts
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
ALTER TABLE mobile.push_tokens ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_push_tokens ON mobile.push_tokens
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
ALTER TABLE mobile.push_notifications_log ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_push_log ON mobile.push_notifications_log
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
ALTER TABLE mobile.payment_transactions ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_payment_tx ON mobile.payment_transactions
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- FUNCIONES
|
||||||
|
-- =====================
|
||||||
|
|
||||||
|
-- Funcion para obtener siguiente numero de secuencia
|
||||||
|
CREATE OR REPLACE FUNCTION mobile.get_next_sync_sequence(p_device_id UUID)
|
||||||
|
RETURNS BIGINT AS $$
|
||||||
|
DECLARE
|
||||||
|
next_seq BIGINT;
|
||||||
|
BEGIN
|
||||||
|
SELECT COALESCE(MAX(sequence_number), 0) + 1
|
||||||
|
INTO next_seq
|
||||||
|
FROM mobile.offline_sync_queue
|
||||||
|
WHERE device_id = p_device_id;
|
||||||
|
|
||||||
|
RETURN next_seq;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Funcion para procesar cola de sincronizacion
|
||||||
|
CREATE OR REPLACE FUNCTION mobile.process_sync_queue(p_device_id UUID, p_batch_size INTEGER DEFAULT 100)
|
||||||
|
RETURNS TABLE (
|
||||||
|
processed_count INTEGER,
|
||||||
|
failed_count INTEGER,
|
||||||
|
conflict_count INTEGER
|
||||||
|
) AS $$
|
||||||
|
DECLARE
|
||||||
|
v_processed INTEGER := 0;
|
||||||
|
v_failed INTEGER := 0;
|
||||||
|
v_conflicts INTEGER := 0;
|
||||||
|
BEGIN
|
||||||
|
-- Marcar items como processing (usando subquery para ORDER BY y LIMIT en PostgreSQL)
|
||||||
|
UPDATE mobile.offline_sync_queue
|
||||||
|
SET status = 'processing', updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT osq.id FROM mobile.offline_sync_queue osq
|
||||||
|
WHERE osq.device_id = p_device_id
|
||||||
|
AND osq.status = 'pending'
|
||||||
|
AND (osq.depends_on IS NULL OR osq.depends_on IN (
|
||||||
|
SELECT id FROM mobile.offline_sync_queue WHERE status = 'completed'
|
||||||
|
))
|
||||||
|
ORDER BY osq.sequence_number
|
||||||
|
LIMIT p_batch_size
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Aqui iria la logica de procesamiento real
|
||||||
|
-- Por ahora solo retornamos conteos
|
||||||
|
|
||||||
|
SELECT COUNT(*) INTO v_processed
|
||||||
|
FROM mobile.offline_sync_queue
|
||||||
|
WHERE device_id = p_device_id AND status = 'processing';
|
||||||
|
|
||||||
|
RETURN QUERY SELECT v_processed, v_failed, v_conflicts;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Funcion para limpiar sesiones inactivas
|
||||||
|
CREATE OR REPLACE FUNCTION mobile.cleanup_inactive_sessions(p_hours INTEGER DEFAULT 24)
|
||||||
|
RETURNS INTEGER AS $$
|
||||||
|
DECLARE
|
||||||
|
cleaned_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
UPDATE mobile.mobile_sessions
|
||||||
|
SET status = 'expired', ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE status = 'active'
|
||||||
|
AND last_activity_at < CURRENT_TIMESTAMP - (p_hours || ' hours')::INTERVAL;
|
||||||
|
|
||||||
|
GET DIAGNOSTICS cleaned_count = ROW_COUNT;
|
||||||
|
RETURN cleaned_count;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- COMENTARIOS DE TABLAS
|
||||||
|
-- =====================
|
||||||
|
COMMENT ON TABLE mobile.mobile_sessions IS 'Sesiones activas de la aplicacion movil';
|
||||||
|
COMMENT ON TABLE mobile.offline_sync_queue IS 'Cola de operaciones pendientes de sincronizar desde modo offline';
|
||||||
|
COMMENT ON TABLE mobile.sync_conflicts IS 'Registro de conflictos de sincronizacion detectados';
|
||||||
|
COMMENT ON TABLE mobile.push_tokens IS 'Tokens de notificaciones push por dispositivo';
|
||||||
|
COMMENT ON TABLE mobile.push_notifications_log IS 'Log de notificaciones push enviadas';
|
||||||
|
COMMENT ON TABLE mobile.payment_transactions IS 'Transacciones de pago desde terminales moviles';
|
||||||
622
ddl/05-billing-usage.sql
Normal file
622
ddl/05-billing-usage.sql
Normal file
@ -0,0 +1,622 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- ARCHIVO: 05-billing-usage.sql
|
||||||
|
-- DESCRIPCION: Facturacion por uso, tracking de consumo, suscripciones
|
||||||
|
-- VERSION: 1.0.0
|
||||||
|
-- PROYECTO: ERP-Core V2
|
||||||
|
-- FECHA: 2026-01-10
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- SCHEMA: billing
|
||||||
|
-- =====================
|
||||||
|
CREATE SCHEMA IF NOT EXISTS billing;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: subscription_plans
|
||||||
|
-- Planes de suscripcion disponibles
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS billing.subscription_plans (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
-- Identificacion
|
||||||
|
code VARCHAR(30) NOT NULL UNIQUE,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
|
||||||
|
-- Tipo
|
||||||
|
plan_type VARCHAR(20) NOT NULL DEFAULT 'saas', -- saas, on_premise, hybrid
|
||||||
|
|
||||||
|
-- Precios base
|
||||||
|
base_monthly_price DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||||
|
base_annual_price DECIMAL(12,2), -- Precio anual con descuento
|
||||||
|
setup_fee DECIMAL(12,2) DEFAULT 0,
|
||||||
|
|
||||||
|
-- Limites base
|
||||||
|
max_users INTEGER DEFAULT 5,
|
||||||
|
max_branches INTEGER DEFAULT 1,
|
||||||
|
storage_gb INTEGER DEFAULT 10,
|
||||||
|
api_calls_monthly INTEGER DEFAULT 10000,
|
||||||
|
|
||||||
|
-- Modulos incluidos
|
||||||
|
included_modules TEXT[] DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Plataformas incluidas
|
||||||
|
included_platforms TEXT[] DEFAULT '{web}',
|
||||||
|
|
||||||
|
-- Features
|
||||||
|
features JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
is_public BOOLEAN DEFAULT TRUE,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: tenant_subscriptions
|
||||||
|
-- Suscripciones activas de tenants
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS billing.tenant_subscriptions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
plan_id UUID NOT NULL REFERENCES billing.subscription_plans(id),
|
||||||
|
|
||||||
|
-- Periodo
|
||||||
|
billing_cycle VARCHAR(20) NOT NULL DEFAULT 'monthly', -- monthly, annual
|
||||||
|
current_period_start TIMESTAMPTZ NOT NULL,
|
||||||
|
current_period_end TIMESTAMPTZ NOT NULL,
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'active', -- trial, active, past_due, cancelled, suspended
|
||||||
|
|
||||||
|
-- Trial
|
||||||
|
trial_start TIMESTAMPTZ,
|
||||||
|
trial_end TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Configuracion de facturacion
|
||||||
|
billing_email VARCHAR(255),
|
||||||
|
billing_name VARCHAR(200),
|
||||||
|
billing_address JSONB DEFAULT '{}',
|
||||||
|
tax_id VARCHAR(20), -- RFC para Mexico
|
||||||
|
|
||||||
|
-- Metodo de pago
|
||||||
|
payment_method_id UUID,
|
||||||
|
payment_provider VARCHAR(30), -- stripe, mercadopago, bank_transfer
|
||||||
|
|
||||||
|
-- Precios actuales (pueden diferir del plan por descuentos)
|
||||||
|
current_price DECIMAL(12,2) NOT NULL,
|
||||||
|
discount_percent DECIMAL(5,2) DEFAULT 0,
|
||||||
|
discount_reason VARCHAR(100),
|
||||||
|
|
||||||
|
-- Uso contratado
|
||||||
|
contracted_users INTEGER,
|
||||||
|
contracted_branches INTEGER,
|
||||||
|
|
||||||
|
-- Facturacion automatica
|
||||||
|
auto_renew BOOLEAN DEFAULT TRUE,
|
||||||
|
next_invoice_date DATE,
|
||||||
|
|
||||||
|
-- Cancelacion
|
||||||
|
cancel_at_period_end BOOLEAN DEFAULT FALSE,
|
||||||
|
cancelled_at TIMESTAMPTZ,
|
||||||
|
cancellation_reason TEXT,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
UNIQUE(tenant_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para tenant_subscriptions
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_subscriptions_tenant ON billing.tenant_subscriptions(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_subscriptions_plan ON billing.tenant_subscriptions(plan_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON billing.tenant_subscriptions(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_subscriptions_period_end ON billing.tenant_subscriptions(current_period_end);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: usage_tracking
|
||||||
|
-- Tracking de uso por tenant
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS billing.usage_tracking (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Periodo
|
||||||
|
period_start DATE NOT NULL,
|
||||||
|
period_end DATE NOT NULL,
|
||||||
|
|
||||||
|
-- Usuarios
|
||||||
|
active_users INTEGER DEFAULT 0,
|
||||||
|
peak_concurrent_users INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
-- Por perfil
|
||||||
|
users_by_profile JSONB DEFAULT '{}',
|
||||||
|
-- Ejemplo: {"ADM": 2, "VNT": 5, "ALM": 3}
|
||||||
|
|
||||||
|
-- Por plataforma
|
||||||
|
users_by_platform JSONB DEFAULT '{}',
|
||||||
|
-- Ejemplo: {"web": 8, "mobile": 5, "desktop": 0}
|
||||||
|
|
||||||
|
-- Sucursales
|
||||||
|
active_branches INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
-- Storage
|
||||||
|
storage_used_gb DECIMAL(10,2) DEFAULT 0,
|
||||||
|
documents_count INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
-- API
|
||||||
|
api_calls INTEGER DEFAULT 0,
|
||||||
|
api_errors INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
-- Transacciones
|
||||||
|
sales_count INTEGER DEFAULT 0,
|
||||||
|
sales_amount DECIMAL(14,2) DEFAULT 0,
|
||||||
|
invoices_generated INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
-- Mobile
|
||||||
|
mobile_sessions INTEGER DEFAULT 0,
|
||||||
|
offline_syncs INTEGER DEFAULT 0,
|
||||||
|
payment_transactions INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
-- Calculado
|
||||||
|
total_billable_amount DECIMAL(12,2) DEFAULT 0,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
UNIQUE(tenant_id, period_start)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para usage_tracking
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_usage_tenant ON billing.usage_tracking(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_usage_period ON billing.usage_tracking(period_start, period_end);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: usage_events
|
||||||
|
-- Eventos de uso en tiempo real (para calculo de billing)
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS billing.usage_events (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID REFERENCES auth.users(id),
|
||||||
|
device_id UUID REFERENCES auth.devices(id),
|
||||||
|
branch_id UUID REFERENCES core.branches(id),
|
||||||
|
|
||||||
|
-- Evento
|
||||||
|
event_type VARCHAR(50) NOT NULL, -- login, api_call, document_upload, sale, invoice, sync
|
||||||
|
event_category VARCHAR(30) NOT NULL, -- user, api, storage, transaction, mobile
|
||||||
|
|
||||||
|
-- Detalles
|
||||||
|
profile_code VARCHAR(10),
|
||||||
|
platform VARCHAR(20),
|
||||||
|
resource_id UUID,
|
||||||
|
resource_type VARCHAR(50),
|
||||||
|
|
||||||
|
-- Metricas
|
||||||
|
quantity INTEGER DEFAULT 1,
|
||||||
|
bytes_used BIGINT DEFAULT 0,
|
||||||
|
duration_ms INTEGER,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para usage_events (particionado por fecha recomendado)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_usage_events_tenant ON billing.usage_events(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_usage_events_type ON billing.usage_events(event_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_usage_events_category ON billing.usage_events(event_category);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_usage_events_created ON billing.usage_events(created_at DESC);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: invoices
|
||||||
|
-- Facturas generadas
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS billing.invoices (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
subscription_id UUID REFERENCES billing.tenant_subscriptions(id),
|
||||||
|
|
||||||
|
-- Numero de factura
|
||||||
|
invoice_number VARCHAR(30) NOT NULL UNIQUE,
|
||||||
|
invoice_date DATE NOT NULL,
|
||||||
|
|
||||||
|
-- Periodo facturado
|
||||||
|
period_start DATE NOT NULL,
|
||||||
|
period_end DATE NOT NULL,
|
||||||
|
|
||||||
|
-- Cliente
|
||||||
|
billing_name VARCHAR(200),
|
||||||
|
billing_email VARCHAR(255),
|
||||||
|
billing_address JSONB DEFAULT '{}',
|
||||||
|
tax_id VARCHAR(20),
|
||||||
|
|
||||||
|
-- Montos
|
||||||
|
subtotal DECIMAL(12,2) NOT NULL,
|
||||||
|
tax_amount DECIMAL(12,2) DEFAULT 0,
|
||||||
|
discount_amount DECIMAL(12,2) DEFAULT 0,
|
||||||
|
total DECIMAL(12,2) NOT NULL,
|
||||||
|
currency VARCHAR(3) DEFAULT 'MXN',
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'draft', -- draft, sent, paid, partial, overdue, void, refunded
|
||||||
|
|
||||||
|
-- Fechas de pago
|
||||||
|
due_date DATE NOT NULL,
|
||||||
|
paid_at TIMESTAMPTZ,
|
||||||
|
paid_amount DECIMAL(12,2) DEFAULT 0,
|
||||||
|
|
||||||
|
-- Detalles de pago
|
||||||
|
payment_method VARCHAR(30),
|
||||||
|
payment_reference VARCHAR(100),
|
||||||
|
|
||||||
|
-- CFDI (para Mexico)
|
||||||
|
cfdi_uuid VARCHAR(36),
|
||||||
|
cfdi_xml TEXT,
|
||||||
|
cfdi_pdf_url TEXT,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
notes TEXT,
|
||||||
|
internal_notes TEXT,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para invoices
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoices_tenant ON billing.invoices(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoices_subscription ON billing.invoices(subscription_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoices_number ON billing.invoices(invoice_number);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoices_status ON billing.invoices(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoices_date ON billing.invoices(invoice_date DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoices_due ON billing.invoices(due_date) WHERE status IN ('sent', 'partial', 'overdue');
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: invoice_items
|
||||||
|
-- Items de cada factura
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS billing.invoice_items (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
invoice_id UUID NOT NULL REFERENCES billing.invoices(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Descripcion
|
||||||
|
description VARCHAR(500) NOT NULL,
|
||||||
|
item_type VARCHAR(30) NOT NULL, -- subscription, user, profile, overage, addon
|
||||||
|
|
||||||
|
-- Cantidades
|
||||||
|
quantity INTEGER NOT NULL DEFAULT 1,
|
||||||
|
unit_price DECIMAL(12,2) NOT NULL,
|
||||||
|
subtotal DECIMAL(12,2) NOT NULL,
|
||||||
|
|
||||||
|
-- Detalles adicionales
|
||||||
|
profile_code VARCHAR(10), -- Si es cargo por perfil
|
||||||
|
platform VARCHAR(20), -- Si es cargo por plataforma
|
||||||
|
period_start DATE,
|
||||||
|
period_end DATE,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para invoice_items
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoice_items_invoice ON billing.invoice_items(invoice_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoice_items_type ON billing.invoice_items(item_type);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: payment_methods
|
||||||
|
-- Metodos de pago guardados
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS billing.payment_methods (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Proveedor
|
||||||
|
provider VARCHAR(30) NOT NULL, -- stripe, mercadopago, bank_transfer
|
||||||
|
|
||||||
|
-- Tipo
|
||||||
|
method_type VARCHAR(20) NOT NULL, -- card, bank_account, wallet
|
||||||
|
|
||||||
|
-- Datos (encriptados/tokenizados)
|
||||||
|
provider_customer_id VARCHAR(255),
|
||||||
|
provider_method_id VARCHAR(255),
|
||||||
|
|
||||||
|
-- Display info (no sensible)
|
||||||
|
display_name VARCHAR(100),
|
||||||
|
card_brand VARCHAR(20),
|
||||||
|
card_last_four VARCHAR(4),
|
||||||
|
card_exp_month INTEGER,
|
||||||
|
card_exp_year INTEGER,
|
||||||
|
bank_name VARCHAR(100),
|
||||||
|
bank_last_four VARCHAR(4),
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
is_default BOOLEAN DEFAULT FALSE,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
is_verified BOOLEAN DEFAULT FALSE,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para payment_methods
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_payment_methods_tenant ON billing.payment_methods(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_payment_methods_provider ON billing.payment_methods(provider);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_payment_methods_default ON billing.payment_methods(is_default) WHERE is_default = TRUE;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: billing_alerts
|
||||||
|
-- Alertas de facturacion
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS billing.billing_alerts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Tipo de alerta
|
||||||
|
alert_type VARCHAR(30) NOT NULL, -- usage_limit, payment_due, payment_failed, trial_ending, subscription_ending
|
||||||
|
|
||||||
|
-- Detalles
|
||||||
|
title VARCHAR(200) NOT NULL,
|
||||||
|
message TEXT,
|
||||||
|
severity VARCHAR(20) NOT NULL DEFAULT 'info', -- info, warning, critical
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'active', -- active, acknowledged, resolved
|
||||||
|
|
||||||
|
-- Notificacion
|
||||||
|
notified_at TIMESTAMPTZ,
|
||||||
|
acknowledged_at TIMESTAMPTZ,
|
||||||
|
acknowledged_by UUID REFERENCES auth.users(id),
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para billing_alerts
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_billing_alerts_tenant ON billing.billing_alerts(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_billing_alerts_type ON billing.billing_alerts(alert_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_billing_alerts_status ON billing.billing_alerts(status) WHERE status = 'active';
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- RLS POLICIES
|
||||||
|
-- =====================
|
||||||
|
ALTER TABLE billing.tenant_subscriptions ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_subscriptions ON billing.tenant_subscriptions
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
ALTER TABLE billing.usage_tracking ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_usage ON billing.usage_tracking
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
ALTER TABLE billing.usage_events ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_usage_events ON billing.usage_events
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
ALTER TABLE billing.invoices ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_invoices ON billing.invoices
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
ALTER TABLE billing.invoice_items ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_invoice_items ON billing.invoice_items
|
||||||
|
USING (invoice_id IN (
|
||||||
|
SELECT id FROM billing.invoices
|
||||||
|
WHERE tenant_id = current_setting('app.current_tenant_id', true)::uuid
|
||||||
|
));
|
||||||
|
|
||||||
|
ALTER TABLE billing.payment_methods ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_payment_methods ON billing.payment_methods
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
ALTER TABLE billing.billing_alerts ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_billing_alerts ON billing.billing_alerts
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- FUNCIONES
|
||||||
|
-- =====================
|
||||||
|
|
||||||
|
-- Funcion para calcular uso mensual de un tenant
|
||||||
|
CREATE OR REPLACE FUNCTION billing.calculate_monthly_usage(
|
||||||
|
p_tenant_id UUID,
|
||||||
|
p_period_start DATE,
|
||||||
|
p_period_end DATE
|
||||||
|
)
|
||||||
|
RETURNS TABLE (
|
||||||
|
active_users INTEGER,
|
||||||
|
users_by_profile JSONB,
|
||||||
|
users_by_platform JSONB,
|
||||||
|
active_branches INTEGER,
|
||||||
|
storage_used_gb DECIMAL,
|
||||||
|
api_calls INTEGER,
|
||||||
|
total_billable DECIMAL
|
||||||
|
) AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
WITH user_stats AS (
|
||||||
|
SELECT
|
||||||
|
COUNT(DISTINCT ue.user_id) as active_users,
|
||||||
|
jsonb_object_agg(
|
||||||
|
COALESCE(ue.profile_code, 'unknown'),
|
||||||
|
COUNT(DISTINCT ue.user_id)
|
||||||
|
) as by_profile,
|
||||||
|
jsonb_object_agg(
|
||||||
|
COALESCE(ue.platform, 'unknown'),
|
||||||
|
COUNT(DISTINCT ue.user_id)
|
||||||
|
) as by_platform
|
||||||
|
FROM billing.usage_events ue
|
||||||
|
WHERE ue.tenant_id = p_tenant_id
|
||||||
|
AND ue.created_at >= p_period_start
|
||||||
|
AND ue.created_at < p_period_end
|
||||||
|
AND ue.event_category = 'user'
|
||||||
|
),
|
||||||
|
branch_stats AS (
|
||||||
|
SELECT COUNT(DISTINCT branch_id) as active_branches
|
||||||
|
FROM billing.usage_events
|
||||||
|
WHERE tenant_id = p_tenant_id
|
||||||
|
AND created_at >= p_period_start
|
||||||
|
AND created_at < p_period_end
|
||||||
|
AND branch_id IS NOT NULL
|
||||||
|
),
|
||||||
|
storage_stats AS (
|
||||||
|
SELECT COALESCE(SUM(bytes_used), 0)::DECIMAL / (1024*1024*1024) as storage_gb
|
||||||
|
FROM billing.usage_events
|
||||||
|
WHERE tenant_id = p_tenant_id
|
||||||
|
AND created_at >= p_period_start
|
||||||
|
AND created_at < p_period_end
|
||||||
|
AND event_category = 'storage'
|
||||||
|
),
|
||||||
|
api_stats AS (
|
||||||
|
SELECT COUNT(*) as api_calls
|
||||||
|
FROM billing.usage_events
|
||||||
|
WHERE tenant_id = p_tenant_id
|
||||||
|
AND created_at >= p_period_start
|
||||||
|
AND created_at < p_period_end
|
||||||
|
AND event_category = 'api'
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
us.active_users::INTEGER,
|
||||||
|
us.by_profile,
|
||||||
|
us.by_platform,
|
||||||
|
bs.active_branches::INTEGER,
|
||||||
|
ss.storage_gb,
|
||||||
|
api.api_calls::INTEGER,
|
||||||
|
0::DECIMAL as total_billable -- Se calcula aparte basado en plan
|
||||||
|
FROM user_stats us, branch_stats bs, storage_stats ss, api_stats api;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Funcion para generar numero de factura
|
||||||
|
CREATE OR REPLACE FUNCTION billing.generate_invoice_number()
|
||||||
|
RETURNS VARCHAR(30) AS $$
|
||||||
|
DECLARE
|
||||||
|
v_year VARCHAR(4);
|
||||||
|
v_sequence INTEGER;
|
||||||
|
v_number VARCHAR(30);
|
||||||
|
BEGIN
|
||||||
|
v_year := to_char(CURRENT_DATE, 'YYYY');
|
||||||
|
|
||||||
|
SELECT COALESCE(MAX(
|
||||||
|
CAST(SUBSTRING(invoice_number FROM 6 FOR 6) AS INTEGER)
|
||||||
|
), 0) + 1
|
||||||
|
INTO v_sequence
|
||||||
|
FROM billing.invoices
|
||||||
|
WHERE invoice_number LIKE 'INV-' || v_year || '-%';
|
||||||
|
|
||||||
|
v_number := 'INV-' || v_year || '-' || LPAD(v_sequence::TEXT, 6, '0');
|
||||||
|
|
||||||
|
RETURN v_number;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Funcion para verificar limites de uso
|
||||||
|
CREATE OR REPLACE FUNCTION billing.check_usage_limits(p_tenant_id UUID)
|
||||||
|
RETURNS TABLE (
|
||||||
|
limit_type VARCHAR,
|
||||||
|
current_value INTEGER,
|
||||||
|
max_value INTEGER,
|
||||||
|
percentage DECIMAL,
|
||||||
|
is_exceeded BOOLEAN
|
||||||
|
) AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
WITH subscription AS (
|
||||||
|
SELECT
|
||||||
|
ts.contracted_users,
|
||||||
|
ts.contracted_branches,
|
||||||
|
sp.storage_gb,
|
||||||
|
sp.api_calls_monthly
|
||||||
|
FROM billing.tenant_subscriptions ts
|
||||||
|
JOIN billing.subscription_plans sp ON sp.id = ts.plan_id
|
||||||
|
WHERE ts.tenant_id = p_tenant_id
|
||||||
|
AND ts.status = 'active'
|
||||||
|
),
|
||||||
|
current_usage AS (
|
||||||
|
SELECT
|
||||||
|
ut.active_users,
|
||||||
|
ut.active_branches,
|
||||||
|
ut.storage_used_gb::INTEGER,
|
||||||
|
ut.api_calls
|
||||||
|
FROM billing.usage_tracking ut
|
||||||
|
WHERE ut.tenant_id = p_tenant_id
|
||||||
|
AND ut.period_start <= CURRENT_DATE
|
||||||
|
AND ut.period_end >= CURRENT_DATE
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'users'::VARCHAR as limit_type,
|
||||||
|
COALESCE(cu.active_users, 0) as current_value,
|
||||||
|
s.contracted_users as max_value,
|
||||||
|
CASE WHEN s.contracted_users > 0
|
||||||
|
THEN (COALESCE(cu.active_users, 0)::DECIMAL / s.contracted_users * 100)
|
||||||
|
ELSE 0 END as percentage,
|
||||||
|
COALESCE(cu.active_users, 0) > s.contracted_users as is_exceeded
|
||||||
|
FROM subscription s, current_usage cu
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
'branches'::VARCHAR,
|
||||||
|
COALESCE(cu.active_branches, 0),
|
||||||
|
s.contracted_branches,
|
||||||
|
CASE WHEN s.contracted_branches > 0
|
||||||
|
THEN (COALESCE(cu.active_branches, 0)::DECIMAL / s.contracted_branches * 100)
|
||||||
|
ELSE 0 END,
|
||||||
|
COALESCE(cu.active_branches, 0) > s.contracted_branches
|
||||||
|
FROM subscription s, current_usage cu
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
'storage'::VARCHAR,
|
||||||
|
COALESCE(cu.storage_used_gb, 0),
|
||||||
|
s.storage_gb,
|
||||||
|
CASE WHEN s.storage_gb > 0
|
||||||
|
THEN (COALESCE(cu.storage_used_gb, 0)::DECIMAL / s.storage_gb * 100)
|
||||||
|
ELSE 0 END,
|
||||||
|
COALESCE(cu.storage_used_gb, 0) > s.storage_gb
|
||||||
|
FROM subscription s, current_usage cu
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
'api_calls'::VARCHAR,
|
||||||
|
COALESCE(cu.api_calls, 0),
|
||||||
|
s.api_calls_monthly,
|
||||||
|
CASE WHEN s.api_calls_monthly > 0
|
||||||
|
THEN (COALESCE(cu.api_calls, 0)::DECIMAL / s.api_calls_monthly * 100)
|
||||||
|
ELSE 0 END,
|
||||||
|
COALESCE(cu.api_calls, 0) > s.api_calls_monthly
|
||||||
|
FROM subscription s, current_usage cu;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- SEED DATA: Planes de suscripcion
|
||||||
|
-- =====================
|
||||||
|
INSERT INTO billing.subscription_plans (code, name, description, plan_type, base_monthly_price, max_users, max_branches, storage_gb, api_calls_monthly, included_modules, included_platforms) VALUES
|
||||||
|
('starter', 'Starter', 'Plan basico para pequenos negocios', 'saas', 499, 3, 1, 5, 5000, '{core,sales,inventory}', '{web}'),
|
||||||
|
('professional', 'Professional', 'Plan profesional con app movil', 'saas', 999, 10, 3, 20, 25000, '{core,sales,inventory,purchases,financial,reports}', '{web,mobile}'),
|
||||||
|
('business', 'Business', 'Plan empresarial completo', 'saas', 2499, 25, 10, 100, 100000, '{all}', '{web,mobile,desktop}'),
|
||||||
|
('enterprise', 'Enterprise', 'Plan enterprise personalizado', 'saas', 0, 0, 0, 0, 0, '{all}', '{web,mobile,desktop}'),
|
||||||
|
('on_premise', 'On-Premise', 'Instalacion en servidor propio', 'on_premise', 0, 0, 0, 0, 0, '{all}', '{web,mobile,desktop}')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- COMENTARIOS DE TABLAS
|
||||||
|
-- =====================
|
||||||
|
COMMENT ON TABLE billing.subscription_plans IS 'Planes de suscripcion disponibles para tenants';
|
||||||
|
COMMENT ON TABLE billing.tenant_subscriptions IS 'Suscripciones activas de cada tenant';
|
||||||
|
COMMENT ON TABLE billing.usage_tracking IS 'Resumen de uso por periodo para calculo de facturacion';
|
||||||
|
COMMENT ON TABLE billing.usage_events IS 'Eventos de uso en tiempo real para tracking granular';
|
||||||
|
COMMENT ON TABLE billing.invoices IS 'Facturas generadas para cada tenant';
|
||||||
|
COMMENT ON TABLE billing.invoice_items IS 'Items detallados de cada factura';
|
||||||
|
COMMENT ON TABLE billing.payment_methods IS 'Metodos de pago guardados por tenant';
|
||||||
|
COMMENT ON TABLE billing.billing_alerts IS 'Alertas de facturacion y limites de uso';
|
||||||
@ -1,966 +0,0 @@
|
|||||||
-- =====================================================
|
|
||||||
-- SCHEMA: inventory (Extensiones)
|
|
||||||
-- PROPÓSITO: Valoración de Inventario, Lotes/Series, Conteos Cíclicos
|
|
||||||
-- MÓDULO: MGN-005 (Inventario)
|
|
||||||
-- FECHA: 2025-12-08
|
|
||||||
-- VERSION: 1.0.0
|
|
||||||
-- DEPENDENCIAS: 05-inventory.sql
|
|
||||||
-- SPECS RELACIONADAS:
|
|
||||||
-- - SPEC-VALORACION-INVENTARIO.md
|
|
||||||
-- - SPEC-TRAZABILIDAD-LOTES-SERIES.md
|
|
||||||
-- - SPEC-INVENTARIOS-CICLICOS.md
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- PARTE 1: VALORACIÓN DE INVENTARIO (SVL)
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Tabla: stock_valuation_layers (Capas de valoración FIFO/AVCO)
|
|
||||||
CREATE TABLE inventory.stock_valuation_layers (
|
|
||||||
-- Identificación
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
|
|
||||||
-- Referencias
|
|
||||||
product_id UUID NOT NULL REFERENCES inventory.products(id),
|
|
||||||
stock_move_id UUID REFERENCES inventory.stock_moves(id),
|
|
||||||
lot_id UUID REFERENCES inventory.lots(id),
|
|
||||||
company_id UUID NOT NULL REFERENCES auth.companies(id),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id),
|
|
||||||
|
|
||||||
-- Valores de la capa
|
|
||||||
quantity DECIMAL(16,4) NOT NULL, -- Cantidad (positiva=entrada, negativa=salida)
|
|
||||||
unit_cost DECIMAL(16,6) NOT NULL, -- Costo unitario
|
|
||||||
value DECIMAL(16,4) NOT NULL, -- Valor total
|
|
||||||
currency_id UUID REFERENCES core.currencies(id),
|
|
||||||
|
|
||||||
-- Tracking FIFO (solo para entradas)
|
|
||||||
remaining_qty DECIMAL(16,4) NOT NULL DEFAULT 0, -- Cantidad restante por consumir
|
|
||||||
remaining_value DECIMAL(16,4) NOT NULL DEFAULT 0, -- Valor restante
|
|
||||||
|
|
||||||
-- Diferencia de precio (facturas vs recepción)
|
|
||||||
price_diff_value DECIMAL(16,4) DEFAULT 0,
|
|
||||||
|
|
||||||
-- Referencias contables (usando journal_entries del schema financial)
|
|
||||||
journal_entry_id UUID REFERENCES financial.journal_entries(id),
|
|
||||||
journal_entry_line_id UUID REFERENCES financial.journal_entry_lines(id),
|
|
||||||
|
|
||||||
-- Corrección de vacío (link a capa corregida)
|
|
||||||
parent_svl_id UUID REFERENCES inventory.stock_valuation_layers(id),
|
|
||||||
|
|
||||||
-- Metadata
|
|
||||||
description VARCHAR(500),
|
|
||||||
reference VARCHAR(255),
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
-- Constraints
|
|
||||||
CONSTRAINT chk_svl_value CHECK (
|
|
||||||
ABS(value - (quantity * unit_cost)) < 0.01 OR quantity = 0
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Índice principal para FIFO (crítico para performance)
|
|
||||||
CREATE INDEX idx_svl_fifo_candidates ON inventory.stock_valuation_layers (
|
|
||||||
product_id,
|
|
||||||
remaining_qty,
|
|
||||||
stock_move_id,
|
|
||||||
company_id,
|
|
||||||
created_at
|
|
||||||
) WHERE remaining_qty > 0;
|
|
||||||
|
|
||||||
-- Índice para agregación de valoración
|
|
||||||
CREATE INDEX idx_svl_valuation ON inventory.stock_valuation_layers (
|
|
||||||
product_id,
|
|
||||||
company_id,
|
|
||||||
id,
|
|
||||||
value,
|
|
||||||
quantity
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Índice por lote
|
|
||||||
CREATE INDEX idx_svl_lot ON inventory.stock_valuation_layers (lot_id)
|
|
||||||
WHERE lot_id IS NOT NULL;
|
|
||||||
|
|
||||||
-- Índice por movimiento
|
|
||||||
CREATE INDEX idx_svl_move ON inventory.stock_valuation_layers (stock_move_id);
|
|
||||||
|
|
||||||
-- Índice por tenant
|
|
||||||
CREATE INDEX idx_svl_tenant ON inventory.stock_valuation_layers (tenant_id);
|
|
||||||
|
|
||||||
-- Comentarios
|
|
||||||
COMMENT ON TABLE inventory.stock_valuation_layers IS 'Capas de valoración de inventario para costeo FIFO/AVCO';
|
|
||||||
COMMENT ON COLUMN inventory.stock_valuation_layers.remaining_qty IS 'Cantidad aún no consumida por FIFO';
|
|
||||||
COMMENT ON COLUMN inventory.stock_valuation_layers.parent_svl_id IS 'Referencia a capa padre cuando es corrección de vacío';
|
|
||||||
|
|
||||||
-- Vista materializada para valores agregados de SVL por producto
|
|
||||||
CREATE MATERIALIZED VIEW inventory.product_valuation_summary AS
|
|
||||||
SELECT
|
|
||||||
svl.product_id,
|
|
||||||
svl.company_id,
|
|
||||||
svl.tenant_id,
|
|
||||||
SUM(svl.quantity) AS quantity_svl,
|
|
||||||
SUM(svl.value) AS value_svl,
|
|
||||||
CASE
|
|
||||||
WHEN SUM(svl.quantity) > 0 THEN SUM(svl.value) / SUM(svl.quantity)
|
|
||||||
ELSE 0
|
|
||||||
END AS avg_cost
|
|
||||||
FROM inventory.stock_valuation_layers svl
|
|
||||||
GROUP BY svl.product_id, svl.company_id, svl.tenant_id;
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX idx_product_valuation_pk
|
|
||||||
ON inventory.product_valuation_summary (product_id, company_id, tenant_id);
|
|
||||||
|
|
||||||
COMMENT ON MATERIALIZED VIEW inventory.product_valuation_summary IS
|
|
||||||
'Resumen de valoración por producto - refrescar con REFRESH MATERIALIZED VIEW CONCURRENTLY';
|
|
||||||
|
|
||||||
-- Configuración de cuentas por categoría de producto
|
|
||||||
CREATE TABLE inventory.category_stock_accounts (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
category_id UUID NOT NULL REFERENCES core.product_categories(id),
|
|
||||||
company_id UUID NOT NULL REFERENCES auth.companies(id),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id),
|
|
||||||
|
|
||||||
-- Cuentas de valoración
|
|
||||||
stock_input_account_id UUID REFERENCES financial.accounts(id), -- Entrada de stock
|
|
||||||
stock_output_account_id UUID REFERENCES financial.accounts(id), -- Salida de stock
|
|
||||||
stock_valuation_account_id UUID REFERENCES financial.accounts(id), -- Valoración (activo)
|
|
||||||
expense_account_id UUID REFERENCES financial.accounts(id), -- Gasto/COGS
|
|
||||||
|
|
||||||
-- Diario para asientos de stock
|
|
||||||
stock_journal_id UUID REFERENCES financial.journals(id),
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ,
|
|
||||||
|
|
||||||
CONSTRAINT uq_category_stock_accounts
|
|
||||||
UNIQUE (category_id, company_id, tenant_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
COMMENT ON TABLE inventory.category_stock_accounts IS 'Cuentas contables para valoración de inventario por categoría';
|
|
||||||
|
|
||||||
-- Parámetros de valoración por tenant
|
|
||||||
CREATE TABLE inventory.valuation_settings (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id),
|
|
||||||
company_id UUID REFERENCES auth.companies(id),
|
|
||||||
|
|
||||||
allow_negative_stock BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
default_cost_method VARCHAR(20) NOT NULL DEFAULT 'fifo'
|
|
||||||
CHECK (default_cost_method IN ('standard', 'average', 'fifo')),
|
|
||||||
default_valuation VARCHAR(20) NOT NULL DEFAULT 'real_time'
|
|
||||||
CHECK (default_valuation IN ('manual', 'real_time')),
|
|
||||||
auto_vacuum_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
vacuum_batch_size INTEGER NOT NULL DEFAULT 100,
|
|
||||||
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
|
|
||||||
CONSTRAINT uq_valuation_settings_tenant_company UNIQUE (tenant_id, company_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
COMMENT ON TABLE inventory.valuation_settings IS 'Configuración de valoración de inventario por tenant/empresa';
|
|
||||||
|
|
||||||
-- Extensión de product_categories para costeo (tabla en schema core)
|
|
||||||
ALTER TABLE core.product_categories ADD COLUMN IF NOT EXISTS
|
|
||||||
cost_method VARCHAR(20) NOT NULL DEFAULT 'fifo'
|
|
||||||
CHECK (cost_method IN ('standard', 'average', 'fifo'));
|
|
||||||
|
|
||||||
ALTER TABLE core.product_categories ADD COLUMN IF NOT EXISTS
|
|
||||||
valuation_method VARCHAR(20) NOT NULL DEFAULT 'real_time'
|
|
||||||
CHECK (valuation_method IN ('manual', 'real_time'));
|
|
||||||
|
|
||||||
-- Extensión de products para costeo
|
|
||||||
ALTER TABLE inventory.products ADD COLUMN IF NOT EXISTS
|
|
||||||
standard_price DECIMAL(16,6) NOT NULL DEFAULT 0;
|
|
||||||
|
|
||||||
ALTER TABLE inventory.products ADD COLUMN IF NOT EXISTS
|
|
||||||
lot_valuated BOOLEAN NOT NULL DEFAULT FALSE;
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- PARTE 2: TRAZABILIDAD DE LOTES Y SERIES
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Extensión de products para tracking
|
|
||||||
ALTER TABLE inventory.products ADD COLUMN IF NOT EXISTS
|
|
||||||
tracking VARCHAR(16) NOT NULL DEFAULT 'none'
|
|
||||||
CHECK (tracking IN ('none', 'lot', 'serial'));
|
|
||||||
|
|
||||||
-- Configuración de caducidad
|
|
||||||
ALTER TABLE inventory.products ADD COLUMN IF NOT EXISTS
|
|
||||||
use_expiration_date BOOLEAN NOT NULL DEFAULT FALSE;
|
|
||||||
|
|
||||||
ALTER TABLE inventory.products ADD COLUMN IF NOT EXISTS
|
|
||||||
expiration_time INTEGER; -- Días hasta caducidad desde recepción
|
|
||||||
|
|
||||||
ALTER TABLE inventory.products ADD COLUMN IF NOT EXISTS
|
|
||||||
use_time INTEGER; -- Días antes de caducidad para "consumir preferentemente"
|
|
||||||
|
|
||||||
ALTER TABLE inventory.products ADD COLUMN IF NOT EXISTS
|
|
||||||
removal_time INTEGER; -- Días antes de caducidad para remover de venta
|
|
||||||
|
|
||||||
ALTER TABLE inventory.products ADD COLUMN IF NOT EXISTS
|
|
||||||
alert_time INTEGER; -- Días antes de caducidad para alertar
|
|
||||||
|
|
||||||
-- Propiedades dinámicas por lote
|
|
||||||
ALTER TABLE inventory.products ADD COLUMN IF NOT EXISTS
|
|
||||||
lot_properties_definition JSONB DEFAULT '[]';
|
|
||||||
|
|
||||||
-- Constraint de consistencia
|
|
||||||
ALTER TABLE inventory.products ADD CONSTRAINT chk_expiration_config CHECK (
|
|
||||||
use_expiration_date = FALSE OR (
|
|
||||||
expiration_time IS NOT NULL AND
|
|
||||||
expiration_time > 0
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Índice para productos con tracking
|
|
||||||
CREATE INDEX idx_products_tracking ON inventory.products(tracking)
|
|
||||||
WHERE tracking != 'none';
|
|
||||||
|
|
||||||
-- Tabla: lots (Lotes y números de serie)
|
|
||||||
CREATE TABLE inventory.lots (
|
|
||||||
-- Identificación
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
name VARCHAR(128) NOT NULL,
|
|
||||||
ref VARCHAR(256), -- Referencia interna/externa
|
|
||||||
|
|
||||||
-- Relaciones
|
|
||||||
product_id UUID NOT NULL REFERENCES inventory.products(id),
|
|
||||||
company_id UUID NOT NULL REFERENCES auth.companies(id),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id),
|
|
||||||
|
|
||||||
-- Fechas de caducidad
|
|
||||||
expiration_date TIMESTAMPTZ,
|
|
||||||
use_date TIMESTAMPTZ, -- Best-before
|
|
||||||
removal_date TIMESTAMPTZ, -- Fecha de retiro FEFO
|
|
||||||
alert_date TIMESTAMPTZ, -- Fecha de alerta
|
|
||||||
|
|
||||||
-- Control de alertas
|
|
||||||
expiry_alerted BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
|
|
||||||
-- Propiedades dinámicas (heredadas del producto)
|
|
||||||
lot_properties JSONB DEFAULT '{}',
|
|
||||||
|
|
||||||
-- Ubicación (si solo hay una)
|
|
||||||
location_id UUID REFERENCES inventory.locations(id),
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
-- Constraints
|
|
||||||
CONSTRAINT uk_lot_product_company UNIQUE (product_id, name, company_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Índices para lots
|
|
||||||
CREATE INDEX idx_lots_product ON inventory.lots(product_id);
|
|
||||||
CREATE INDEX idx_lots_tenant ON inventory.lots(tenant_id);
|
|
||||||
CREATE INDEX idx_lots_expiration ON inventory.lots(expiration_date)
|
|
||||||
WHERE expiration_date IS NOT NULL;
|
|
||||||
CREATE INDEX idx_lots_removal ON inventory.lots(removal_date)
|
|
||||||
WHERE removal_date IS NOT NULL;
|
|
||||||
CREATE INDEX idx_lots_alert ON inventory.lots(alert_date)
|
|
||||||
WHERE alert_date IS NOT NULL AND NOT expiry_alerted;
|
|
||||||
|
|
||||||
-- Extensión para búsqueda por trigram (requiere pg_trgm)
|
|
||||||
-- CREATE INDEX idx_lots_name_trgm ON inventory.lots USING GIN (name gin_trgm_ops);
|
|
||||||
|
|
||||||
COMMENT ON TABLE inventory.lots IS 'Lotes y números de serie para trazabilidad de productos';
|
|
||||||
|
|
||||||
-- Extensión de quants para lotes
|
|
||||||
ALTER TABLE inventory.quants ADD COLUMN IF NOT EXISTS
|
|
||||||
lot_id UUID REFERENCES inventory.lots(id);
|
|
||||||
|
|
||||||
ALTER TABLE inventory.quants ADD COLUMN IF NOT EXISTS
|
|
||||||
in_date TIMESTAMPTZ NOT NULL DEFAULT NOW();
|
|
||||||
|
|
||||||
-- Fecha de remoción para FEFO (heredada del lote)
|
|
||||||
ALTER TABLE inventory.quants ADD COLUMN IF NOT EXISTS
|
|
||||||
removal_date TIMESTAMPTZ;
|
|
||||||
|
|
||||||
-- Índices optimizados para quants
|
|
||||||
CREATE INDEX idx_quants_lot ON inventory.quants(lot_id)
|
|
||||||
WHERE lot_id IS NOT NULL;
|
|
||||||
|
|
||||||
CREATE INDEX idx_quants_fefo ON inventory.quants(product_id, location_id, removal_date, in_date)
|
|
||||||
WHERE quantity > 0;
|
|
||||||
|
|
||||||
CREATE INDEX idx_quants_fifo ON inventory.quants(product_id, location_id, in_date)
|
|
||||||
WHERE quantity > 0;
|
|
||||||
|
|
||||||
-- Extensión de stock_moves para lotes (tracking de lotes en movimientos)
|
|
||||||
ALTER TABLE inventory.stock_moves ADD COLUMN IF NOT EXISTS
|
|
||||||
lot_id UUID REFERENCES inventory.lots(id);
|
|
||||||
|
|
||||||
ALTER TABLE inventory.stock_moves ADD COLUMN IF NOT EXISTS
|
|
||||||
lot_name VARCHAR(128); -- Para creación on-the-fly
|
|
||||||
|
|
||||||
ALTER TABLE inventory.stock_moves ADD COLUMN IF NOT EXISTS
|
|
||||||
tracking VARCHAR(16); -- Copia del producto (none, lot, serial)
|
|
||||||
|
|
||||||
-- Índices para lotes en movimientos
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_stock_moves_lot ON inventory.stock_moves(lot_id)
|
|
||||||
WHERE lot_id IS NOT NULL;
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_stock_moves_lot_name ON inventory.stock_moves(lot_name)
|
|
||||||
WHERE lot_name IS NOT NULL;
|
|
||||||
|
|
||||||
-- Tabla de relación para trazabilidad de manufactura (consume/produce)
|
|
||||||
CREATE TABLE inventory.stock_move_consume_rel (
|
|
||||||
consume_move_id UUID NOT NULL REFERENCES inventory.stock_moves(id) ON DELETE CASCADE,
|
|
||||||
produce_move_id UUID NOT NULL REFERENCES inventory.stock_moves(id) ON DELETE CASCADE,
|
|
||||||
quantity DECIMAL(16,4) NOT NULL DEFAULT 0, -- Cantidad consumida/producida
|
|
||||||
PRIMARY KEY (consume_move_id, produce_move_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX idx_consume_rel_consume ON inventory.stock_move_consume_rel(consume_move_id);
|
|
||||||
CREATE INDEX idx_consume_rel_produce ON inventory.stock_move_consume_rel(produce_move_id);
|
|
||||||
|
|
||||||
COMMENT ON TABLE inventory.stock_move_consume_rel IS 'Relación M:N para trazabilidad de consumo en manufactura';
|
|
||||||
|
|
||||||
-- Tabla: removal_strategies (Estrategias de salida)
|
|
||||||
CREATE TABLE inventory.removal_strategies (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
name VARCHAR(64) NOT NULL,
|
|
||||||
code VARCHAR(16) NOT NULL UNIQUE
|
|
||||||
CHECK (code IN ('fifo', 'lifo', 'fefo', 'closest')),
|
|
||||||
description TEXT,
|
|
||||||
is_active BOOLEAN NOT NULL DEFAULT TRUE
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Datos iniciales de estrategias
|
|
||||||
INSERT INTO inventory.removal_strategies (name, code, description) VALUES
|
|
||||||
('First In, First Out', 'fifo', 'El stock más antiguo sale primero'),
|
|
||||||
('Last In, First Out', 'lifo', 'El stock más reciente sale primero'),
|
|
||||||
('First Expiry, First Out', 'fefo', 'El stock que caduca primero sale primero'),
|
|
||||||
('Closest Location', 'closest', 'El stock de ubicación más cercana sale primero')
|
|
||||||
ON CONFLICT (code) DO NOTHING;
|
|
||||||
|
|
||||||
COMMENT ON TABLE inventory.removal_strategies IS 'Estrategias de salida de inventario (FIFO, LIFO, FEFO)';
|
|
||||||
|
|
||||||
-- Agregar estrategia a categorías y ubicaciones
|
|
||||||
ALTER TABLE core.product_categories ADD COLUMN IF NOT EXISTS
|
|
||||||
removal_strategy_id UUID REFERENCES inventory.removal_strategies(id);
|
|
||||||
|
|
||||||
ALTER TABLE inventory.locations ADD COLUMN IF NOT EXISTS
|
|
||||||
removal_strategy_id UUID REFERENCES inventory.removal_strategies(id);
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- PARTE 3: CONTEOS CÍCLICOS
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Extensión de locations para conteo cíclico
|
|
||||||
ALTER TABLE inventory.locations ADD COLUMN IF NOT EXISTS
|
|
||||||
cyclic_inventory_frequency INTEGER DEFAULT 0;
|
|
||||||
|
|
||||||
ALTER TABLE inventory.locations ADD COLUMN IF NOT EXISTS
|
|
||||||
last_inventory_date DATE;
|
|
||||||
|
|
||||||
ALTER TABLE inventory.locations ADD COLUMN IF NOT EXISTS
|
|
||||||
abc_classification VARCHAR(1) DEFAULT 'C'
|
|
||||||
CHECK (abc_classification IN ('A', 'B', 'C'));
|
|
||||||
|
|
||||||
COMMENT ON COLUMN inventory.locations.cyclic_inventory_frequency IS
|
|
||||||
'Días entre conteos cíclicos. 0 = deshabilitado';
|
|
||||||
COMMENT ON COLUMN inventory.locations.abc_classification IS
|
|
||||||
'Clasificación ABC: A=Alta rotación, B=Media, C=Baja';
|
|
||||||
|
|
||||||
-- Índice para ubicaciones pendientes de conteo
|
|
||||||
CREATE INDEX idx_locations_cyclic_inventory
|
|
||||||
ON inventory.locations(last_inventory_date, cyclic_inventory_frequency)
|
|
||||||
WHERE cyclic_inventory_frequency > 0;
|
|
||||||
|
|
||||||
-- Extensión de quants para inventario
|
|
||||||
ALTER TABLE inventory.quants ADD COLUMN IF NOT EXISTS
|
|
||||||
inventory_quantity DECIMAL(18,4);
|
|
||||||
|
|
||||||
ALTER TABLE inventory.quants ADD COLUMN IF NOT EXISTS
|
|
||||||
inventory_quantity_set BOOLEAN NOT NULL DEFAULT FALSE;
|
|
||||||
|
|
||||||
ALTER TABLE inventory.quants ADD COLUMN IF NOT EXISTS
|
|
||||||
inventory_date DATE;
|
|
||||||
|
|
||||||
ALTER TABLE inventory.quants ADD COLUMN IF NOT EXISTS
|
|
||||||
last_count_date DATE;
|
|
||||||
|
|
||||||
ALTER TABLE inventory.quants ADD COLUMN IF NOT EXISTS
|
|
||||||
is_outdated BOOLEAN NOT NULL DEFAULT FALSE;
|
|
||||||
|
|
||||||
ALTER TABLE inventory.quants ADD COLUMN IF NOT EXISTS
|
|
||||||
assigned_user_id UUID REFERENCES auth.users(id);
|
|
||||||
|
|
||||||
ALTER TABLE inventory.quants ADD COLUMN IF NOT EXISTS
|
|
||||||
count_notes TEXT;
|
|
||||||
|
|
||||||
COMMENT ON COLUMN inventory.quants.inventory_quantity IS
|
|
||||||
'Cantidad contada por el usuario';
|
|
||||||
COMMENT ON COLUMN inventory.quants.is_outdated IS
|
|
||||||
'TRUE si quantity cambió después de establecer inventory_quantity';
|
|
||||||
|
|
||||||
-- Índices para conteo
|
|
||||||
CREATE INDEX idx_quants_inventory_date ON inventory.quants(inventory_date)
|
|
||||||
WHERE inventory_date IS NOT NULL;
|
|
||||||
|
|
||||||
CREATE INDEX idx_quants_assigned_user ON inventory.quants(assigned_user_id)
|
|
||||||
WHERE assigned_user_id IS NOT NULL;
|
|
||||||
|
|
||||||
-- Tabla: inventory_count_sessions (Sesiones de conteo)
|
|
||||||
CREATE TABLE inventory.inventory_count_sessions (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
code VARCHAR(20) NOT NULL,
|
|
||||||
name VARCHAR(200),
|
|
||||||
|
|
||||||
-- Alcance del conteo
|
|
||||||
location_ids UUID[] NOT NULL, -- Ubicaciones a contar
|
|
||||||
product_ids UUID[], -- NULL = todos los productos
|
|
||||||
category_ids UUID[], -- Filtrar por categorías
|
|
||||||
|
|
||||||
-- Configuración
|
|
||||||
count_type VARCHAR(20) NOT NULL DEFAULT 'cycle'
|
|
||||||
CHECK (count_type IN ('cycle', 'full', 'spot')),
|
|
||||||
-- 'cycle': Conteo cíclico programado
|
|
||||||
-- 'full': Inventario físico completo
|
|
||||||
-- 'spot': Conteo puntual/aleatorio
|
|
||||||
|
|
||||||
-- Estado
|
|
||||||
state VARCHAR(20) NOT NULL DEFAULT 'draft'
|
|
||||||
CHECK (state IN ('draft', 'in_progress', 'pending_review', 'done', 'cancelled')),
|
|
||||||
|
|
||||||
-- Fechas
|
|
||||||
scheduled_date DATE,
|
|
||||||
started_at TIMESTAMPTZ,
|
|
||||||
completed_at TIMESTAMPTZ,
|
|
||||||
|
|
||||||
-- Asignación
|
|
||||||
responsible_id UUID REFERENCES auth.users(id),
|
|
||||||
team_ids UUID[], -- Usuarios asignados al conteo
|
|
||||||
|
|
||||||
-- Resultados
|
|
||||||
total_quants INTEGER DEFAULT 0,
|
|
||||||
counted_quants INTEGER DEFAULT 0,
|
|
||||||
discrepancy_quants INTEGER DEFAULT 0,
|
|
||||||
total_value_diff DECIMAL(18,2) DEFAULT 0,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
company_id UUID NOT NULL REFERENCES auth.companies(id),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id),
|
|
||||||
warehouse_id UUID REFERENCES inventory.warehouses(id),
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
created_by UUID NOT NULL REFERENCES auth.users(id),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Índices para sesiones
|
|
||||||
CREATE INDEX idx_count_sessions_state ON inventory.inventory_count_sessions(state);
|
|
||||||
CREATE INDEX idx_count_sessions_scheduled ON inventory.inventory_count_sessions(scheduled_date);
|
|
||||||
CREATE INDEX idx_count_sessions_tenant ON inventory.inventory_count_sessions(tenant_id);
|
|
||||||
|
|
||||||
-- Secuencia para código de sesión
|
|
||||||
CREATE SEQUENCE IF NOT EXISTS inventory.inventory_count_seq START 1;
|
|
||||||
|
|
||||||
COMMENT ON TABLE inventory.inventory_count_sessions IS 'Sesiones de conteo cíclico de inventario';
|
|
||||||
|
|
||||||
-- Tabla: inventory_count_lines (Líneas de conteo detalladas)
|
|
||||||
CREATE TABLE inventory.inventory_count_lines (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
session_id UUID NOT NULL REFERENCES inventory.inventory_count_sessions(id) ON DELETE CASCADE,
|
|
||||||
quant_id UUID REFERENCES inventory.quants(id),
|
|
||||||
|
|
||||||
-- Producto
|
|
||||||
product_id UUID NOT NULL REFERENCES inventory.products(id),
|
|
||||||
location_id UUID NOT NULL REFERENCES inventory.locations(id),
|
|
||||||
lot_id UUID REFERENCES inventory.lots(id),
|
|
||||||
-- package_id: Reservado para futura extensión de empaquetado
|
|
||||||
-- package_id UUID REFERENCES inventory.packages(id),
|
|
||||||
|
|
||||||
-- Cantidades
|
|
||||||
theoretical_qty DECIMAL(18,4) NOT NULL DEFAULT 0, -- Del sistema
|
|
||||||
counted_qty DECIMAL(18,4), -- Contada
|
|
||||||
|
|
||||||
-- Valoración
|
|
||||||
unit_cost DECIMAL(18,6),
|
|
||||||
|
|
||||||
-- Estado
|
|
||||||
state VARCHAR(20) NOT NULL DEFAULT 'pending'
|
|
||||||
CHECK (state IN ('pending', 'counted', 'conflict', 'applied')),
|
|
||||||
|
|
||||||
-- Conteo
|
|
||||||
counted_by UUID REFERENCES auth.users(id),
|
|
||||||
counted_at TIMESTAMPTZ,
|
|
||||||
notes TEXT,
|
|
||||||
|
|
||||||
-- Resolución de conflictos
|
|
||||||
conflict_reason VARCHAR(100),
|
|
||||||
resolution VARCHAR(20)
|
|
||||||
CHECK (resolution IS NULL OR resolution IN ('keep_counted', 'keep_system', 'recount')),
|
|
||||||
resolved_by UUID REFERENCES auth.users(id),
|
|
||||||
resolved_at TIMESTAMPTZ,
|
|
||||||
|
|
||||||
-- Movimiento generado
|
|
||||||
stock_move_id UUID REFERENCES inventory.stock_moves(id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Índices para líneas de conteo
|
|
||||||
CREATE INDEX idx_count_lines_session ON inventory.inventory_count_lines(session_id);
|
|
||||||
CREATE INDEX idx_count_lines_state ON inventory.inventory_count_lines(state);
|
|
||||||
CREATE INDEX idx_count_lines_product ON inventory.inventory_count_lines(product_id);
|
|
||||||
|
|
||||||
COMMENT ON TABLE inventory.inventory_count_lines IS 'Líneas detalladas de conteo de inventario';
|
|
||||||
|
|
||||||
-- Tabla: abc_classification_rules (Reglas de clasificación ABC)
|
|
||||||
CREATE TABLE inventory.abc_classification_rules (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
name VARCHAR(100) NOT NULL,
|
|
||||||
|
|
||||||
-- Criterio de clasificación
|
|
||||||
classification_method VARCHAR(20) NOT NULL DEFAULT 'value'
|
|
||||||
CHECK (classification_method IN ('value', 'movement', 'revenue')),
|
|
||||||
-- 'value': Por valor de inventario
|
|
||||||
-- 'movement': Por frecuencia de movimiento
|
|
||||||
-- 'revenue': Por ingresos generados
|
|
||||||
|
|
||||||
-- Umbrales (porcentaje acumulado)
|
|
||||||
threshold_a DECIMAL(5,2) NOT NULL DEFAULT 80.00, -- Top 80%
|
|
||||||
threshold_b DECIMAL(5,2) NOT NULL DEFAULT 95.00, -- 80-95%
|
|
||||||
-- Resto es C (95-100%)
|
|
||||||
|
|
||||||
-- Frecuencias de conteo recomendadas (días)
|
|
||||||
frequency_a INTEGER NOT NULL DEFAULT 7, -- Clase A: semanal
|
|
||||||
frequency_b INTEGER NOT NULL DEFAULT 30, -- Clase B: mensual
|
|
||||||
frequency_c INTEGER NOT NULL DEFAULT 90, -- Clase C: trimestral
|
|
||||||
|
|
||||||
-- Aplicación
|
|
||||||
warehouse_id UUID REFERENCES inventory.warehouses(id),
|
|
||||||
category_ids UUID[], -- Categorías a las que aplica
|
|
||||||
|
|
||||||
-- Estado
|
|
||||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
last_calculation TIMESTAMPTZ,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
company_id UUID NOT NULL REFERENCES auth.companies(id),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id),
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
created_by UUID NOT NULL REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT chk_thresholds CHECK (threshold_a < threshold_b AND threshold_b <= 100)
|
|
||||||
);
|
|
||||||
|
|
||||||
COMMENT ON TABLE inventory.abc_classification_rules IS 'Reglas de clasificación ABC para priorización de conteos';
|
|
||||||
|
|
||||||
-- Tabla: product_abc_classification (Clasificación ABC por producto)
|
|
||||||
CREATE TABLE inventory.product_abc_classification (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
product_id UUID NOT NULL REFERENCES inventory.products(id),
|
|
||||||
rule_id UUID NOT NULL REFERENCES inventory.abc_classification_rules(id),
|
|
||||||
|
|
||||||
-- Clasificación
|
|
||||||
classification VARCHAR(1) NOT NULL
|
|
||||||
CHECK (classification IN ('A', 'B', 'C')),
|
|
||||||
|
|
||||||
-- Métricas calculadas
|
|
||||||
metric_value DECIMAL(18,2) NOT NULL, -- Valor usado para clasificar
|
|
||||||
cumulative_percent DECIMAL(5,2) NOT NULL, -- % acumulado
|
|
||||||
rank_position INTEGER NOT NULL, -- Posición en ranking
|
|
||||||
|
|
||||||
-- Período de cálculo
|
|
||||||
period_start DATE NOT NULL,
|
|
||||||
period_end DATE NOT NULL,
|
|
||||||
|
|
||||||
-- Frecuencia asignada
|
|
||||||
assigned_frequency INTEGER NOT NULL,
|
|
||||||
|
|
||||||
calculated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
|
|
||||||
CONSTRAINT uq_product_rule UNIQUE (product_id, rule_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Índice para búsqueda de clasificación
|
|
||||||
CREATE INDEX idx_product_abc ON inventory.product_abc_classification(product_id, rule_id);
|
|
||||||
|
|
||||||
COMMENT ON TABLE inventory.product_abc_classification IS 'Clasificación ABC calculada por producto';
|
|
||||||
|
|
||||||
-- Extensión de stock_moves para marcar movimientos de inventario
|
|
||||||
ALTER TABLE inventory.stock_moves ADD COLUMN IF NOT EXISTS
|
|
||||||
is_inventory BOOLEAN NOT NULL DEFAULT FALSE;
|
|
||||||
|
|
||||||
ALTER TABLE inventory.stock_moves ADD COLUMN IF NOT EXISTS
|
|
||||||
inventory_session_id UUID REFERENCES inventory.inventory_count_sessions(id);
|
|
||||||
|
|
||||||
CREATE INDEX idx_moves_is_inventory ON inventory.stock_moves(is_inventory)
|
|
||||||
WHERE is_inventory = TRUE;
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- PARTE 4: FUNCIONES DE UTILIDAD
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Función: Ejecutar algoritmo FIFO para consumo de capas
|
|
||||||
CREATE OR REPLACE FUNCTION inventory.run_fifo(
|
|
||||||
p_product_id UUID,
|
|
||||||
p_quantity DECIMAL,
|
|
||||||
p_company_id UUID,
|
|
||||||
p_lot_id UUID DEFAULT NULL
|
|
||||||
)
|
|
||||||
RETURNS TABLE(
|
|
||||||
total_value DECIMAL,
|
|
||||||
unit_cost DECIMAL,
|
|
||||||
remaining_qty DECIMAL
|
|
||||||
) AS $$
|
|
||||||
DECLARE
|
|
||||||
v_candidate RECORD;
|
|
||||||
v_qty_to_take DECIMAL;
|
|
||||||
v_qty_taken DECIMAL;
|
|
||||||
v_value_taken DECIMAL;
|
|
||||||
v_total_value DECIMAL := 0;
|
|
||||||
v_qty_pending DECIMAL := p_quantity;
|
|
||||||
v_last_unit_cost DECIMAL := 0;
|
|
||||||
BEGIN
|
|
||||||
-- Obtener candidatos FIFO ordenados
|
|
||||||
FOR v_candidate IN
|
|
||||||
SELECT id, remaining_qty as r_qty, remaining_value as r_val, unit_cost as u_cost
|
|
||||||
FROM inventory.stock_valuation_layers
|
|
||||||
WHERE product_id = p_product_id
|
|
||||||
AND remaining_qty > 0
|
|
||||||
AND company_id = p_company_id
|
|
||||||
AND (p_lot_id IS NULL OR lot_id = p_lot_id)
|
|
||||||
ORDER BY created_at ASC, id ASC
|
|
||||||
FOR UPDATE
|
|
||||||
LOOP
|
|
||||||
EXIT WHEN v_qty_pending <= 0;
|
|
||||||
|
|
||||||
v_qty_taken := LEAST(v_candidate.r_qty, v_qty_pending);
|
|
||||||
v_value_taken := ROUND(v_qty_taken * (v_candidate.r_val / v_candidate.r_qty), 4);
|
|
||||||
|
|
||||||
-- Actualizar capa candidata
|
|
||||||
UPDATE inventory.stock_valuation_layers
|
|
||||||
SET remaining_qty = remaining_qty - v_qty_taken,
|
|
||||||
remaining_value = remaining_value - v_value_taken
|
|
||||||
WHERE id = v_candidate.id;
|
|
||||||
|
|
||||||
v_qty_pending := v_qty_pending - v_qty_taken;
|
|
||||||
v_total_value := v_total_value + v_value_taken;
|
|
||||||
v_last_unit_cost := v_candidate.u_cost;
|
|
||||||
END LOOP;
|
|
||||||
|
|
||||||
-- Si queda cantidad pendiente (stock negativo)
|
|
||||||
IF v_qty_pending > 0 THEN
|
|
||||||
v_total_value := v_total_value + (v_last_unit_cost * v_qty_pending);
|
|
||||||
RETURN QUERY SELECT
|
|
||||||
-v_total_value,
|
|
||||||
v_total_value / p_quantity,
|
|
||||||
-v_qty_pending;
|
|
||||||
ELSE
|
|
||||||
RETURN QUERY SELECT
|
|
||||||
-v_total_value,
|
|
||||||
v_total_value / p_quantity,
|
|
||||||
0::DECIMAL;
|
|
||||||
END IF;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION inventory.run_fifo IS 'Ejecuta algoritmo FIFO y consume capas de valoración';
|
|
||||||
|
|
||||||
-- Función: Calcular clasificación ABC
|
|
||||||
CREATE OR REPLACE FUNCTION inventory.calculate_abc_classification(
|
|
||||||
p_rule_id UUID,
|
|
||||||
p_period_months INTEGER DEFAULT 12
|
|
||||||
)
|
|
||||||
RETURNS TABLE (
|
|
||||||
product_id UUID,
|
|
||||||
classification VARCHAR(1),
|
|
||||||
metric_value DECIMAL,
|
|
||||||
cumulative_percent DECIMAL,
|
|
||||||
rank_position INTEGER
|
|
||||||
) AS $$
|
|
||||||
DECLARE
|
|
||||||
v_rule RECORD;
|
|
||||||
v_total_value DECIMAL;
|
|
||||||
BEGIN
|
|
||||||
-- Obtener regla
|
|
||||||
SELECT * INTO v_rule
|
|
||||||
FROM inventory.abc_classification_rules
|
|
||||||
WHERE id = p_rule_id;
|
|
||||||
|
|
||||||
IF NOT FOUND THEN
|
|
||||||
RAISE EXCEPTION 'Regla ABC no encontrada: %', p_rule_id;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- Crear tabla temporal con métricas
|
|
||||||
CREATE TEMP TABLE tmp_abc_metrics AS
|
|
||||||
SELECT
|
|
||||||
q.product_id,
|
|
||||||
SUM(q.quantity * COALESCE(p.standard_price, 0)) as metric_value
|
|
||||||
FROM inventory.quants q
|
|
||||||
JOIN inventory.products p ON p.id = q.product_id
|
|
||||||
WHERE q.quantity > 0
|
|
||||||
AND (v_rule.warehouse_id IS NULL OR q.warehouse_id = v_rule.warehouse_id)
|
|
||||||
GROUP BY q.product_id;
|
|
||||||
|
|
||||||
-- Calcular total
|
|
||||||
SELECT COALESCE(SUM(metric_value), 0) INTO v_total_value FROM tmp_abc_metrics;
|
|
||||||
|
|
||||||
-- Retornar clasificación
|
|
||||||
RETURN QUERY
|
|
||||||
WITH ranked AS (
|
|
||||||
SELECT
|
|
||||||
tm.product_id,
|
|
||||||
tm.metric_value,
|
|
||||||
ROW_NUMBER() OVER (ORDER BY tm.metric_value DESC) as rank_pos,
|
|
||||||
SUM(tm.metric_value) OVER (ORDER BY tm.metric_value DESC) /
|
|
||||||
NULLIF(v_total_value, 0) * 100 as cum_pct
|
|
||||||
FROM tmp_abc_metrics tm
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
r.product_id,
|
|
||||||
CASE
|
|
||||||
WHEN r.cum_pct <= v_rule.threshold_a THEN 'A'::VARCHAR(1)
|
|
||||||
WHEN r.cum_pct <= v_rule.threshold_b THEN 'B'::VARCHAR(1)
|
|
||||||
ELSE 'C'::VARCHAR(1)
|
|
||||||
END as classification,
|
|
||||||
r.metric_value,
|
|
||||||
ROUND(r.cum_pct, 2),
|
|
||||||
r.rank_pos::INTEGER
|
|
||||||
FROM ranked r;
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS tmp_abc_metrics;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION inventory.calculate_abc_classification IS 'Calcula clasificación ABC para productos según regla';
|
|
||||||
|
|
||||||
-- Función: Obtener próximos conteos programados
|
|
||||||
CREATE OR REPLACE FUNCTION inventory.get_pending_counts(
|
|
||||||
p_days_ahead INTEGER DEFAULT 7
|
|
||||||
)
|
|
||||||
RETURNS TABLE (
|
|
||||||
location_id UUID,
|
|
||||||
location_name VARCHAR,
|
|
||||||
next_inventory_date DATE,
|
|
||||||
days_overdue INTEGER,
|
|
||||||
quant_count INTEGER,
|
|
||||||
total_value DECIMAL
|
|
||||||
) AS $$
|
|
||||||
BEGIN
|
|
||||||
RETURN QUERY
|
|
||||||
SELECT
|
|
||||||
l.id,
|
|
||||||
l.name,
|
|
||||||
(l.last_inventory_date + (l.cyclic_inventory_frequency || ' days')::INTERVAL)::DATE as next_inv_date,
|
|
||||||
(CURRENT_DATE - (l.last_inventory_date + (l.cyclic_inventory_frequency || ' days')::INTERVAL)::DATE)::INTEGER as days_over,
|
|
||||||
COUNT(q.id)::INTEGER as q_count,
|
|
||||||
COALESCE(SUM(q.quantity * COALESCE(p.standard_price, 0)), 0) as t_value
|
|
||||||
FROM inventory.locations l
|
|
||||||
LEFT JOIN inventory.quants q ON q.location_id = l.id
|
|
||||||
LEFT JOIN inventory.products p ON p.id = q.product_id
|
|
||||||
WHERE l.cyclic_inventory_frequency > 0
|
|
||||||
AND (l.last_inventory_date + (l.cyclic_inventory_frequency || ' days')::INTERVAL)::DATE <= CURRENT_DATE + p_days_ahead
|
|
||||||
AND l.location_type = 'internal'
|
|
||||||
GROUP BY l.id, l.name, l.last_inventory_date, l.cyclic_inventory_frequency
|
|
||||||
ORDER BY next_inv_date;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION inventory.get_pending_counts IS 'Obtiene ubicaciones con conteos cíclicos pendientes';
|
|
||||||
|
|
||||||
-- Función: Marcar quants como desactualizados cuando cambia cantidad
|
|
||||||
CREATE OR REPLACE FUNCTION inventory.mark_quants_outdated()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
IF OLD.quantity != NEW.quantity AND OLD.inventory_quantity_set = TRUE THEN
|
|
||||||
NEW.is_outdated := TRUE;
|
|
||||||
END IF;
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_quant_outdated
|
|
||||||
BEFORE UPDATE OF quantity ON inventory.quants
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION inventory.mark_quants_outdated();
|
|
||||||
|
|
||||||
-- Función: Calcular fechas de caducidad al crear lote
|
|
||||||
CREATE OR REPLACE FUNCTION inventory.compute_lot_expiration_dates()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
DECLARE
|
|
||||||
v_product RECORD;
|
|
||||||
BEGIN
|
|
||||||
-- Obtener configuración del producto
|
|
||||||
SELECT
|
|
||||||
use_expiration_date,
|
|
||||||
expiration_time,
|
|
||||||
use_time,
|
|
||||||
removal_time,
|
|
||||||
alert_time
|
|
||||||
INTO v_product
|
|
||||||
FROM inventory.products
|
|
||||||
WHERE id = NEW.product_id;
|
|
||||||
|
|
||||||
-- Si el producto usa fechas de caducidad y no se especificó expiration_date
|
|
||||||
IF v_product.use_expiration_date AND NEW.expiration_date IS NULL THEN
|
|
||||||
NEW.expiration_date := NOW() + (v_product.expiration_time || ' days')::INTERVAL;
|
|
||||||
|
|
||||||
IF v_product.use_time IS NOT NULL THEN
|
|
||||||
NEW.use_date := NEW.expiration_date - (v_product.use_time || ' days')::INTERVAL;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF v_product.removal_time IS NOT NULL THEN
|
|
||||||
NEW.removal_date := NEW.expiration_date - (v_product.removal_time || ' days')::INTERVAL;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF v_product.alert_time IS NOT NULL THEN
|
|
||||||
NEW.alert_date := NEW.expiration_date - (v_product.alert_time || ' days')::INTERVAL;
|
|
||||||
END IF;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_lot_expiration_dates
|
|
||||||
BEFORE INSERT ON inventory.lots
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION inventory.compute_lot_expiration_dates();
|
|
||||||
|
|
||||||
-- Función: Limpiar valor de la vista materializada
|
|
||||||
CREATE OR REPLACE FUNCTION inventory.refresh_product_valuation_summary()
|
|
||||||
RETURNS void AS $$
|
|
||||||
BEGIN
|
|
||||||
REFRESH MATERIALIZED VIEW CONCURRENTLY inventory.product_valuation_summary;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION inventory.refresh_product_valuation_summary IS 'Refresca la vista materializada de valoración de productos';
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- PARTE 5: TRIGGERS DE ACTUALIZACIÓN
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Trigger: Actualizar updated_at para lots
|
|
||||||
CREATE OR REPLACE FUNCTION inventory.update_lots_timestamp()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
NEW.updated_at = NOW();
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_lots_updated_at
|
|
||||||
BEFORE UPDATE ON inventory.lots
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION inventory.update_lots_timestamp();
|
|
||||||
|
|
||||||
-- Trigger: Actualizar updated_at para count_sessions
|
|
||||||
CREATE TRIGGER trg_count_sessions_updated_at
|
|
||||||
BEFORE UPDATE ON inventory.inventory_count_sessions
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION inventory.update_lots_timestamp();
|
|
||||||
|
|
||||||
-- Trigger: Actualizar estadísticas de sesión al modificar líneas
|
|
||||||
CREATE OR REPLACE FUNCTION inventory.update_session_stats()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
UPDATE inventory.inventory_count_sessions
|
|
||||||
SET
|
|
||||||
counted_quants = (
|
|
||||||
SELECT COUNT(*) FROM inventory.inventory_count_lines
|
|
||||||
WHERE session_id = COALESCE(NEW.session_id, OLD.session_id)
|
|
||||||
AND state IN ('counted', 'applied')
|
|
||||||
),
|
|
||||||
discrepancy_quants = (
|
|
||||||
SELECT COUNT(*) FROM inventory.inventory_count_lines
|
|
||||||
WHERE session_id = COALESCE(NEW.session_id, OLD.session_id)
|
|
||||||
AND state = 'counted'
|
|
||||||
AND (counted_qty - theoretical_qty) != 0
|
|
||||||
),
|
|
||||||
total_value_diff = (
|
|
||||||
SELECT COALESCE(SUM(ABS((counted_qty - theoretical_qty) * COALESCE(unit_cost, 0))), 0)
|
|
||||||
FROM inventory.inventory_count_lines
|
|
||||||
WHERE session_id = COALESCE(NEW.session_id, OLD.session_id)
|
|
||||||
AND counted_qty IS NOT NULL
|
|
||||||
),
|
|
||||||
updated_at = NOW()
|
|
||||||
WHERE id = COALESCE(NEW.session_id, OLD.session_id);
|
|
||||||
|
|
||||||
RETURN COALESCE(NEW, OLD);
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_update_session_stats
|
|
||||||
AFTER INSERT OR UPDATE OR DELETE ON inventory.inventory_count_lines
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION inventory.update_session_stats();
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- PARTE 6: VISTAS
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Vista: Lotes próximos a caducar
|
|
||||||
CREATE OR REPLACE VIEW inventory.expiring_lots_view AS
|
|
||||||
SELECT
|
|
||||||
l.id,
|
|
||||||
l.name as lot_name,
|
|
||||||
l.product_id,
|
|
||||||
p.name as product_name,
|
|
||||||
p.default_code as sku,
|
|
||||||
l.expiration_date,
|
|
||||||
l.removal_date,
|
|
||||||
EXTRACT(DAY FROM l.expiration_date - NOW()) as days_until_expiry,
|
|
||||||
COALESCE(SUM(q.quantity), 0) as stock_qty,
|
|
||||||
l.company_id,
|
|
||||||
l.tenant_id
|
|
||||||
FROM inventory.lots l
|
|
||||||
JOIN inventory.products p ON p.id = l.product_id
|
|
||||||
LEFT JOIN inventory.quants q ON q.lot_id = l.id
|
|
||||||
LEFT JOIN inventory.locations loc ON q.location_id = loc.id
|
|
||||||
WHERE l.expiration_date IS NOT NULL
|
|
||||||
AND l.expiration_date > NOW()
|
|
||||||
AND loc.location_type = 'internal'
|
|
||||||
GROUP BY l.id, p.id
|
|
||||||
HAVING COALESCE(SUM(q.quantity), 0) > 0;
|
|
||||||
|
|
||||||
COMMENT ON VIEW inventory.expiring_lots_view IS 'Vista de lotes con stock próximos a caducar';
|
|
||||||
|
|
||||||
-- Vista: Resumen de conteos por ubicación
|
|
||||||
CREATE OR REPLACE VIEW inventory.location_count_summary_view AS
|
|
||||||
SELECT
|
|
||||||
l.id as location_id,
|
|
||||||
l.name as location_name,
|
|
||||||
l.warehouse_id,
|
|
||||||
w.name as warehouse_name,
|
|
||||||
l.cyclic_inventory_frequency,
|
|
||||||
l.last_inventory_date,
|
|
||||||
(l.last_inventory_date + (l.cyclic_inventory_frequency || ' days')::INTERVAL)::DATE as next_inventory_date,
|
|
||||||
l.abc_classification,
|
|
||||||
COUNT(q.id) as quant_count,
|
|
||||||
COALESCE(SUM(q.quantity * COALESCE(p.standard_price, 0)), 0) as total_value
|
|
||||||
FROM inventory.locations l
|
|
||||||
LEFT JOIN inventory.warehouses w ON l.warehouse_id = w.id
|
|
||||||
LEFT JOIN inventory.quants q ON q.location_id = l.id AND q.quantity > 0
|
|
||||||
LEFT JOIN inventory.products p ON q.product_id = p.id
|
|
||||||
WHERE l.location_type = 'internal'
|
|
||||||
AND l.cyclic_inventory_frequency > 0
|
|
||||||
GROUP BY l.id, w.id;
|
|
||||||
|
|
||||||
COMMENT ON VIEW inventory.location_count_summary_view IS 'Resumen de configuración de conteo cíclico por ubicación';
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- COMENTARIOS EN TABLAS
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
COMMENT ON TABLE inventory.stock_valuation_layers IS 'Capas de valoración de inventario para costeo FIFO/AVCO';
|
|
||||||
COMMENT ON TABLE inventory.lots IS 'Lotes y números de serie para trazabilidad';
|
|
||||||
-- Nota: La tabla anterior se renombró a stock_move_consume_rel
|
|
||||||
COMMENT ON TABLE inventory.removal_strategies IS 'Estrategias de salida de inventario (FIFO/LIFO/FEFO)';
|
|
||||||
COMMENT ON TABLE inventory.inventory_count_sessions IS 'Sesiones de conteo cíclico de inventario';
|
|
||||||
COMMENT ON TABLE inventory.inventory_count_lines IS 'Líneas detalladas de conteo de inventario';
|
|
||||||
COMMENT ON TABLE inventory.abc_classification_rules IS 'Reglas de clasificación ABC para priorización';
|
|
||||||
COMMENT ON TABLE inventory.product_abc_classification IS 'Clasificación ABC calculada por producto';
|
|
||||||
COMMENT ON TABLE inventory.category_stock_accounts IS 'Cuentas contables de valoración por categoría';
|
|
||||||
COMMENT ON TABLE inventory.valuation_settings IS 'Configuración de valoración por tenant/empresa';
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- FIN DE EXTENSIONES INVENTORY
|
|
||||||
-- =====================================================
|
|
||||||
@ -1,772 +0,0 @@
|
|||||||
-- =====================================================
|
|
||||||
-- SCHEMA: inventory
|
|
||||||
-- PROPÓSITO: Gestión de inventarios, productos, almacenes, movimientos
|
|
||||||
-- MÓDULOS: MGN-005 (Inventario Básico)
|
|
||||||
-- FECHA: 2025-11-24
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Crear schema
|
|
||||||
CREATE SCHEMA IF NOT EXISTS inventory;
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- TYPES (ENUMs)
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
CREATE TYPE inventory.product_type AS ENUM (
|
|
||||||
'storable',
|
|
||||||
'consumable',
|
|
||||||
'service'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE inventory.tracking_type AS ENUM (
|
|
||||||
'none',
|
|
||||||
'lot',
|
|
||||||
'serial'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE inventory.location_type AS ENUM (
|
|
||||||
'internal',
|
|
||||||
'customer',
|
|
||||||
'supplier',
|
|
||||||
'inventory',
|
|
||||||
'production',
|
|
||||||
'transit'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE inventory.picking_type AS ENUM (
|
|
||||||
'incoming',
|
|
||||||
'outgoing',
|
|
||||||
'internal'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE inventory.move_status AS ENUM (
|
|
||||||
'draft',
|
|
||||||
'confirmed',
|
|
||||||
'assigned',
|
|
||||||
'done',
|
|
||||||
'cancelled'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE inventory.valuation_method AS ENUM (
|
|
||||||
'fifo',
|
|
||||||
'average',
|
|
||||||
'standard'
|
|
||||||
);
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- TABLES
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Tabla: products (Productos)
|
|
||||||
CREATE TABLE inventory.products (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
-- Identificación
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
code VARCHAR(100),
|
|
||||||
barcode VARCHAR(100),
|
|
||||||
description TEXT,
|
|
||||||
|
|
||||||
-- Tipo
|
|
||||||
product_type inventory.product_type NOT NULL DEFAULT 'storable',
|
|
||||||
tracking inventory.tracking_type NOT NULL DEFAULT 'none',
|
|
||||||
|
|
||||||
-- Categoría
|
|
||||||
category_id UUID REFERENCES core.product_categories(id),
|
|
||||||
|
|
||||||
-- Unidades de medida
|
|
||||||
uom_id UUID NOT NULL REFERENCES core.uom(id), -- UoM de venta/uso
|
|
||||||
purchase_uom_id UUID REFERENCES core.uom(id), -- UoM de compra
|
|
||||||
|
|
||||||
-- Precios
|
|
||||||
cost_price DECIMAL(15, 4) DEFAULT 0,
|
|
||||||
list_price DECIMAL(15, 4) DEFAULT 0,
|
|
||||||
|
|
||||||
-- Configuración de inventario
|
|
||||||
valuation_method inventory.valuation_method DEFAULT 'fifo',
|
|
||||||
is_storable BOOLEAN GENERATED ALWAYS AS (product_type = 'storable') STORED,
|
|
||||||
|
|
||||||
-- Pesos y dimensiones
|
|
||||||
weight DECIMAL(12, 4),
|
|
||||||
volume DECIMAL(12, 4),
|
|
||||||
|
|
||||||
-- Proveedores y clientes
|
|
||||||
can_be_sold BOOLEAN DEFAULT TRUE,
|
|
||||||
can_be_purchased BOOLEAN DEFAULT TRUE,
|
|
||||||
|
|
||||||
-- Imagen
|
|
||||||
image_url VARCHAR(500),
|
|
||||||
|
|
||||||
-- Control
|
|
||||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
deleted_at TIMESTAMP,
|
|
||||||
deleted_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT uq_products_code_tenant UNIQUE (tenant_id, code),
|
|
||||||
CONSTRAINT uq_products_barcode UNIQUE (barcode)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: product_variants (Variantes de producto)
|
|
||||||
CREATE TABLE inventory.product_variants (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
product_template_id UUID NOT NULL REFERENCES inventory.products(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
-- Atributos (JSON)
|
|
||||||
-- Ejemplo: {"color": "red", "size": "XL"}
|
|
||||||
attribute_values JSONB NOT NULL DEFAULT '{}',
|
|
||||||
|
|
||||||
-- Identificación
|
|
||||||
name VARCHAR(255),
|
|
||||||
code VARCHAR(100),
|
|
||||||
barcode VARCHAR(100),
|
|
||||||
|
|
||||||
-- Precio diferencial
|
|
||||||
price_extra DECIMAL(15, 4) DEFAULT 0,
|
|
||||||
|
|
||||||
-- Control
|
|
||||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT uq_product_variants_barcode UNIQUE (barcode)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: warehouses (Almacenes)
|
|
||||||
CREATE TABLE inventory.warehouses (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
company_id UUID NOT NULL REFERENCES auth.companies(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
code VARCHAR(20) NOT NULL,
|
|
||||||
|
|
||||||
-- Dirección
|
|
||||||
address_id UUID REFERENCES core.addresses(id),
|
|
||||||
|
|
||||||
-- Configuración
|
|
||||||
is_default BOOLEAN DEFAULT FALSE,
|
|
||||||
|
|
||||||
-- Control
|
|
||||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT uq_warehouses_code_company UNIQUE (company_id, code)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: locations (Ubicaciones de inventario)
|
|
||||||
CREATE TABLE inventory.locations (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
warehouse_id UUID REFERENCES inventory.warehouses(id),
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
complete_name TEXT, -- Generado: "Warehouse / Zone A / Shelf 1"
|
|
||||||
location_type inventory.location_type NOT NULL DEFAULT 'internal',
|
|
||||||
|
|
||||||
-- Jerarquía
|
|
||||||
parent_id UUID REFERENCES inventory.locations(id),
|
|
||||||
|
|
||||||
-- Configuración
|
|
||||||
is_scrap_location BOOLEAN DEFAULT FALSE,
|
|
||||||
is_return_location BOOLEAN DEFAULT FALSE,
|
|
||||||
|
|
||||||
-- Control
|
|
||||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT chk_locations_no_self_parent CHECK (id != parent_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: lots (Lotes/Series) - DEBE IR ANTES DE stock_quants por FK
|
|
||||||
CREATE TABLE inventory.lots (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
product_id UUID NOT NULL REFERENCES inventory.products(id),
|
|
||||||
name VARCHAR(100) NOT NULL,
|
|
||||||
ref VARCHAR(100), -- Referencia externa
|
|
||||||
|
|
||||||
-- Fechas
|
|
||||||
manufacture_date DATE,
|
|
||||||
expiration_date DATE,
|
|
||||||
removal_date DATE,
|
|
||||||
alert_date DATE,
|
|
||||||
|
|
||||||
-- Notas
|
|
||||||
notes TEXT,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT uq_lots_name_product UNIQUE (product_id, name),
|
|
||||||
CONSTRAINT chk_lots_expiration CHECK (expiration_date IS NULL OR expiration_date > manufacture_date)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: stock_quants (Cantidades en stock)
|
|
||||||
CREATE TABLE inventory.stock_quants (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
product_id UUID NOT NULL REFERENCES inventory.products(id),
|
|
||||||
location_id UUID NOT NULL REFERENCES inventory.locations(id),
|
|
||||||
lot_id UUID REFERENCES inventory.lots(id),
|
|
||||||
|
|
||||||
-- Cantidades
|
|
||||||
quantity DECIMAL(12, 4) NOT NULL DEFAULT 0,
|
|
||||||
reserved_quantity DECIMAL(12, 4) NOT NULL DEFAULT 0,
|
|
||||||
available_quantity DECIMAL(12, 4) GENERATED ALWAYS AS (quantity - reserved_quantity) STORED,
|
|
||||||
|
|
||||||
-- Valoración
|
|
||||||
cost DECIMAL(15, 4) DEFAULT 0,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT chk_stock_quants_reserved CHECK (reserved_quantity >= 0 AND reserved_quantity <= quantity)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Unique index for stock_quants (allows expressions unlike UNIQUE constraint)
|
|
||||||
CREATE UNIQUE INDEX uq_stock_quants_product_location_lot
|
|
||||||
ON inventory.stock_quants (tenant_id, product_id, location_id, COALESCE(lot_id, '00000000-0000-0000-0000-000000000000'::UUID));
|
|
||||||
|
|
||||||
-- Índices para stock_quants
|
|
||||||
CREATE INDEX idx_stock_quants_tenant_id ON inventory.stock_quants(tenant_id);
|
|
||||||
CREATE INDEX idx_stock_quants_product_location ON inventory.stock_quants(product_id, location_id);
|
|
||||||
|
|
||||||
-- RLS para stock_quants
|
|
||||||
ALTER TABLE inventory.stock_quants ENABLE ROW LEVEL SECURITY;
|
|
||||||
CREATE POLICY tenant_isolation_stock_quants ON inventory.stock_quants
|
|
||||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
|
||||||
|
|
||||||
-- Tabla: pickings (Albaranes/Transferencias)
|
|
||||||
CREATE TABLE inventory.pickings (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
company_id UUID NOT NULL REFERENCES auth.companies(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
name VARCHAR(100) NOT NULL,
|
|
||||||
picking_type inventory.picking_type NOT NULL,
|
|
||||||
|
|
||||||
-- Ubicaciones
|
|
||||||
location_id UUID NOT NULL REFERENCES inventory.locations(id), -- Origen
|
|
||||||
location_dest_id UUID NOT NULL REFERENCES inventory.locations(id), -- Destino
|
|
||||||
|
|
||||||
-- Partner (cliente/proveedor)
|
|
||||||
partner_id UUID REFERENCES core.partners(id),
|
|
||||||
|
|
||||||
-- Fechas
|
|
||||||
scheduled_date TIMESTAMP,
|
|
||||||
date_done TIMESTAMP,
|
|
||||||
|
|
||||||
-- Origen
|
|
||||||
origin VARCHAR(255), -- Referencia al documento origen (PO, SO, etc.)
|
|
||||||
|
|
||||||
-- Estado
|
|
||||||
status inventory.move_status NOT NULL DEFAULT 'draft',
|
|
||||||
|
|
||||||
-- Notas
|
|
||||||
notes TEXT,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
validated_at TIMESTAMP,
|
|
||||||
validated_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT uq_pickings_name_company UNIQUE (company_id, name)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: stock_moves (Movimientos de inventario)
|
|
||||||
CREATE TABLE inventory.stock_moves (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
product_id UUID NOT NULL REFERENCES inventory.products(id),
|
|
||||||
product_uom_id UUID NOT NULL REFERENCES core.uom(id),
|
|
||||||
|
|
||||||
-- Ubicaciones
|
|
||||||
location_id UUID NOT NULL REFERENCES inventory.locations(id), -- Origen
|
|
||||||
location_dest_id UUID NOT NULL REFERENCES inventory.locations(id), -- Destino
|
|
||||||
|
|
||||||
-- Cantidades
|
|
||||||
product_qty DECIMAL(12, 4) NOT NULL,
|
|
||||||
quantity_done DECIMAL(12, 4) DEFAULT 0,
|
|
||||||
|
|
||||||
-- Lote/Serie
|
|
||||||
lot_id UUID REFERENCES inventory.lots(id),
|
|
||||||
|
|
||||||
-- Relación con picking
|
|
||||||
picking_id UUID REFERENCES inventory.pickings(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
-- Origen del movimiento
|
|
||||||
origin VARCHAR(255),
|
|
||||||
ref VARCHAR(255),
|
|
||||||
|
|
||||||
-- Estado
|
|
||||||
status inventory.move_status NOT NULL DEFAULT 'draft',
|
|
||||||
|
|
||||||
-- Fechas
|
|
||||||
date_expected TIMESTAMP,
|
|
||||||
date TIMESTAMP,
|
|
||||||
|
|
||||||
-- Precio (para valoración)
|
|
||||||
price_unit DECIMAL(15, 4) DEFAULT 0,
|
|
||||||
|
|
||||||
-- Analítica
|
|
||||||
analytic_account_id UUID REFERENCES analytics.analytic_accounts(id), -- Distribución analítica
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT chk_stock_moves_quantity CHECK (product_qty > 0),
|
|
||||||
CONSTRAINT chk_stock_moves_quantity_done CHECK (quantity_done >= 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: inventory_adjustments (Ajustes de inventario)
|
|
||||||
CREATE TABLE inventory.inventory_adjustments (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
company_id UUID NOT NULL REFERENCES auth.companies(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
name VARCHAR(100) NOT NULL,
|
|
||||||
|
|
||||||
-- Ubicación a ajustar
|
|
||||||
location_id UUID NOT NULL REFERENCES inventory.locations(id),
|
|
||||||
|
|
||||||
-- Fecha de conteo
|
|
||||||
date DATE NOT NULL,
|
|
||||||
|
|
||||||
-- Estado
|
|
||||||
status inventory.move_status NOT NULL DEFAULT 'draft',
|
|
||||||
|
|
||||||
-- Notas
|
|
||||||
notes TEXT,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
validated_at TIMESTAMP,
|
|
||||||
validated_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT uq_inventory_adjustments_name_company UNIQUE (company_id, name)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: inventory_adjustment_lines (Líneas de ajuste)
|
|
||||||
CREATE TABLE inventory.inventory_adjustment_lines (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
adjustment_id UUID NOT NULL REFERENCES inventory.inventory_adjustments(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
product_id UUID NOT NULL REFERENCES inventory.products(id),
|
|
||||||
location_id UUID NOT NULL REFERENCES inventory.locations(id),
|
|
||||||
lot_id UUID REFERENCES inventory.lots(id),
|
|
||||||
|
|
||||||
-- Cantidades
|
|
||||||
theoretical_qty DECIMAL(12, 4) NOT NULL DEFAULT 0, -- Cantidad teórica del sistema
|
|
||||||
counted_qty DECIMAL(12, 4) NOT NULL, -- Cantidad contada físicamente
|
|
||||||
difference_qty DECIMAL(12, 4) GENERATED ALWAYS AS (counted_qty - theoretical_qty) STORED,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Índices para inventory_adjustment_lines
|
|
||||||
CREATE INDEX idx_inventory_adjustment_lines_tenant_id ON inventory.inventory_adjustment_lines(tenant_id);
|
|
||||||
|
|
||||||
-- RLS para inventory_adjustment_lines
|
|
||||||
ALTER TABLE inventory.inventory_adjustment_lines ENABLE ROW LEVEL SECURITY;
|
|
||||||
CREATE POLICY tenant_isolation_inventory_adjustment_lines ON inventory.inventory_adjustment_lines
|
|
||||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- INDICES
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Products
|
|
||||||
CREATE INDEX idx_products_tenant_id ON inventory.products(tenant_id);
|
|
||||||
CREATE INDEX idx_products_code ON inventory.products(code);
|
|
||||||
CREATE INDEX idx_products_barcode ON inventory.products(barcode);
|
|
||||||
CREATE INDEX idx_products_category_id ON inventory.products(category_id);
|
|
||||||
CREATE INDEX idx_products_type ON inventory.products(product_type);
|
|
||||||
CREATE INDEX idx_products_active ON inventory.products(active) WHERE active = TRUE;
|
|
||||||
|
|
||||||
-- Product Variants
|
|
||||||
CREATE INDEX idx_product_variants_template_id ON inventory.product_variants(product_template_id);
|
|
||||||
CREATE INDEX idx_product_variants_barcode ON inventory.product_variants(barcode);
|
|
||||||
|
|
||||||
-- Warehouses
|
|
||||||
CREATE INDEX idx_warehouses_tenant_id ON inventory.warehouses(tenant_id);
|
|
||||||
CREATE INDEX idx_warehouses_company_id ON inventory.warehouses(company_id);
|
|
||||||
CREATE INDEX idx_warehouses_code ON inventory.warehouses(code);
|
|
||||||
|
|
||||||
-- Locations
|
|
||||||
CREATE INDEX idx_locations_tenant_id ON inventory.locations(tenant_id);
|
|
||||||
CREATE INDEX idx_locations_warehouse_id ON inventory.locations(warehouse_id);
|
|
||||||
CREATE INDEX idx_locations_parent_id ON inventory.locations(parent_id);
|
|
||||||
CREATE INDEX idx_locations_type ON inventory.locations(location_type);
|
|
||||||
|
|
||||||
-- Stock Quants
|
|
||||||
CREATE INDEX idx_stock_quants_product_id ON inventory.stock_quants(product_id);
|
|
||||||
CREATE INDEX idx_stock_quants_location_id ON inventory.stock_quants(location_id);
|
|
||||||
CREATE INDEX idx_stock_quants_lot_id ON inventory.stock_quants(lot_id);
|
|
||||||
CREATE INDEX idx_stock_quants_available ON inventory.stock_quants(product_id, location_id)
|
|
||||||
WHERE available_quantity > 0;
|
|
||||||
|
|
||||||
-- Lots
|
|
||||||
CREATE INDEX idx_lots_tenant_id ON inventory.lots(tenant_id);
|
|
||||||
CREATE INDEX idx_lots_product_id ON inventory.lots(product_id);
|
|
||||||
CREATE INDEX idx_lots_name ON inventory.lots(name);
|
|
||||||
CREATE INDEX idx_lots_expiration_date ON inventory.lots(expiration_date);
|
|
||||||
|
|
||||||
-- Pickings
|
|
||||||
CREATE INDEX idx_pickings_tenant_id ON inventory.pickings(tenant_id);
|
|
||||||
CREATE INDEX idx_pickings_company_id ON inventory.pickings(company_id);
|
|
||||||
CREATE INDEX idx_pickings_name ON inventory.pickings(name);
|
|
||||||
CREATE INDEX idx_pickings_type ON inventory.pickings(picking_type);
|
|
||||||
CREATE INDEX idx_pickings_status ON inventory.pickings(status);
|
|
||||||
CREATE INDEX idx_pickings_partner_id ON inventory.pickings(partner_id);
|
|
||||||
CREATE INDEX idx_pickings_origin ON inventory.pickings(origin);
|
|
||||||
CREATE INDEX idx_pickings_scheduled_date ON inventory.pickings(scheduled_date);
|
|
||||||
|
|
||||||
-- Stock Moves
|
|
||||||
CREATE INDEX idx_stock_moves_tenant_id ON inventory.stock_moves(tenant_id);
|
|
||||||
CREATE INDEX idx_stock_moves_product_id ON inventory.stock_moves(product_id);
|
|
||||||
CREATE INDEX idx_stock_moves_picking_id ON inventory.stock_moves(picking_id);
|
|
||||||
CREATE INDEX idx_stock_moves_location_id ON inventory.stock_moves(location_id);
|
|
||||||
CREATE INDEX idx_stock_moves_location_dest_id ON inventory.stock_moves(location_dest_id);
|
|
||||||
CREATE INDEX idx_stock_moves_status ON inventory.stock_moves(status);
|
|
||||||
CREATE INDEX idx_stock_moves_lot_id ON inventory.stock_moves(lot_id);
|
|
||||||
CREATE INDEX idx_stock_moves_analytic_account_id ON inventory.stock_moves(analytic_account_id) WHERE analytic_account_id IS NOT NULL;
|
|
||||||
|
|
||||||
-- Inventory Adjustments
|
|
||||||
CREATE INDEX idx_inventory_adjustments_tenant_id ON inventory.inventory_adjustments(tenant_id);
|
|
||||||
CREATE INDEX idx_inventory_adjustments_company_id ON inventory.inventory_adjustments(company_id);
|
|
||||||
CREATE INDEX idx_inventory_adjustments_location_id ON inventory.inventory_adjustments(location_id);
|
|
||||||
CREATE INDEX idx_inventory_adjustments_status ON inventory.inventory_adjustments(status);
|
|
||||||
CREATE INDEX idx_inventory_adjustments_date ON inventory.inventory_adjustments(date);
|
|
||||||
|
|
||||||
-- Inventory Adjustment Lines
|
|
||||||
CREATE INDEX idx_inventory_adjustment_lines_adjustment_id ON inventory.inventory_adjustment_lines(adjustment_id);
|
|
||||||
CREATE INDEX idx_inventory_adjustment_lines_product_id ON inventory.inventory_adjustment_lines(product_id);
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- FUNCTIONS
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Función: update_stock_quant
|
|
||||||
-- Actualiza la cantidad en stock de un producto en una ubicación
|
|
||||||
CREATE OR REPLACE FUNCTION inventory.update_stock_quant(
|
|
||||||
p_product_id UUID,
|
|
||||||
p_location_id UUID,
|
|
||||||
p_lot_id UUID,
|
|
||||||
p_quantity DECIMAL
|
|
||||||
)
|
|
||||||
RETURNS VOID AS $$
|
|
||||||
BEGIN
|
|
||||||
INSERT INTO inventory.stock_quants (product_id, location_id, lot_id, quantity)
|
|
||||||
VALUES (p_product_id, p_location_id, p_lot_id, p_quantity)
|
|
||||||
ON CONFLICT (product_id, location_id, COALESCE(lot_id, '00000000-0000-0000-0000-000000000000'::UUID))
|
|
||||||
DO UPDATE SET
|
|
||||||
quantity = inventory.stock_quants.quantity + EXCLUDED.quantity,
|
|
||||||
updated_at = CURRENT_TIMESTAMP;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION inventory.update_stock_quant IS 'Actualiza la cantidad en stock de un producto en una ubicación';
|
|
||||||
|
|
||||||
-- Función: reserve_quantity
|
|
||||||
-- Reserva cantidad de un producto en una ubicación
|
|
||||||
CREATE OR REPLACE FUNCTION inventory.reserve_quantity(
|
|
||||||
p_product_id UUID,
|
|
||||||
p_location_id UUID,
|
|
||||||
p_lot_id UUID,
|
|
||||||
p_quantity DECIMAL
|
|
||||||
)
|
|
||||||
RETURNS BOOLEAN AS $$
|
|
||||||
DECLARE
|
|
||||||
v_available DECIMAL;
|
|
||||||
BEGIN
|
|
||||||
-- Verificar disponibilidad
|
|
||||||
SELECT available_quantity INTO v_available
|
|
||||||
FROM inventory.stock_quants
|
|
||||||
WHERE product_id = p_product_id
|
|
||||||
AND location_id = p_location_id
|
|
||||||
AND (lot_id = p_lot_id OR (lot_id IS NULL AND p_lot_id IS NULL));
|
|
||||||
|
|
||||||
IF v_available IS NULL OR v_available < p_quantity THEN
|
|
||||||
RETURN FALSE;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- Reservar
|
|
||||||
UPDATE inventory.stock_quants
|
|
||||||
SET reserved_quantity = reserved_quantity + p_quantity,
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE product_id = p_product_id
|
|
||||||
AND location_id = p_location_id
|
|
||||||
AND (lot_id = p_lot_id OR (lot_id IS NULL AND p_lot_id IS NULL));
|
|
||||||
|
|
||||||
RETURN TRUE;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION inventory.reserve_quantity IS 'Reserva cantidad de un producto en una ubicación';
|
|
||||||
|
|
||||||
-- Función: get_product_stock
|
|
||||||
-- Obtiene el stock disponible de un producto
|
|
||||||
CREATE OR REPLACE FUNCTION inventory.get_product_stock(
|
|
||||||
p_product_id UUID,
|
|
||||||
p_location_id UUID DEFAULT NULL
|
|
||||||
)
|
|
||||||
RETURNS TABLE(
|
|
||||||
location_id UUID,
|
|
||||||
location_name VARCHAR,
|
|
||||||
quantity DECIMAL,
|
|
||||||
reserved_quantity DECIMAL,
|
|
||||||
available_quantity DECIMAL
|
|
||||||
) AS $$
|
|
||||||
BEGIN
|
|
||||||
RETURN QUERY
|
|
||||||
SELECT
|
|
||||||
sq.location_id,
|
|
||||||
l.name AS location_name,
|
|
||||||
sq.quantity,
|
|
||||||
sq.reserved_quantity,
|
|
||||||
sq.available_quantity
|
|
||||||
FROM inventory.stock_quants sq
|
|
||||||
JOIN inventory.locations l ON sq.location_id = l.id
|
|
||||||
WHERE sq.product_id = p_product_id
|
|
||||||
AND (p_location_id IS NULL OR sq.location_id = p_location_id)
|
|
||||||
AND sq.quantity > 0;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql STABLE;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION inventory.get_product_stock IS 'Obtiene el stock disponible de un producto por ubicación';
|
|
||||||
|
|
||||||
-- Función: process_stock_move
|
|
||||||
-- Procesa un movimiento de inventario (actualiza quants)
|
|
||||||
CREATE OR REPLACE FUNCTION inventory.process_stock_move(p_move_id UUID)
|
|
||||||
RETURNS VOID AS $$
|
|
||||||
DECLARE
|
|
||||||
v_move RECORD;
|
|
||||||
BEGIN
|
|
||||||
-- Obtener datos del movimiento
|
|
||||||
SELECT * INTO v_move
|
|
||||||
FROM inventory.stock_moves
|
|
||||||
WHERE id = p_move_id;
|
|
||||||
|
|
||||||
IF NOT FOUND THEN
|
|
||||||
RAISE EXCEPTION 'Stock move % not found', p_move_id;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF v_move.status != 'confirmed' THEN
|
|
||||||
RAISE EXCEPTION 'Stock move % is not in confirmed status', p_move_id;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- Decrementar en ubicación origen
|
|
||||||
PERFORM inventory.update_stock_quant(
|
|
||||||
v_move.product_id,
|
|
||||||
v_move.location_id,
|
|
||||||
v_move.lot_id,
|
|
||||||
-v_move.quantity_done
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Incrementar en ubicación destino
|
|
||||||
PERFORM inventory.update_stock_quant(
|
|
||||||
v_move.product_id,
|
|
||||||
v_move.location_dest_id,
|
|
||||||
v_move.lot_id,
|
|
||||||
v_move.quantity_done
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Actualizar estado del movimiento
|
|
||||||
UPDATE inventory.stock_moves
|
|
||||||
SET status = 'done',
|
|
||||||
date = CURRENT_TIMESTAMP,
|
|
||||||
updated_at = CURRENT_TIMESTAMP,
|
|
||||||
updated_by = get_current_user_id()
|
|
||||||
WHERE id = p_move_id;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION inventory.process_stock_move IS 'Procesa un movimiento de inventario y actualiza los quants';
|
|
||||||
|
|
||||||
-- Función: update_location_complete_name
|
|
||||||
-- Actualiza el nombre completo de una ubicación
|
|
||||||
CREATE OR REPLACE FUNCTION inventory.update_location_complete_name()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
DECLARE
|
|
||||||
v_parent_name TEXT;
|
|
||||||
BEGIN
|
|
||||||
IF NEW.parent_id IS NULL THEN
|
|
||||||
NEW.complete_name := NEW.name;
|
|
||||||
ELSE
|
|
||||||
SELECT complete_name INTO v_parent_name
|
|
||||||
FROM inventory.locations
|
|
||||||
WHERE id = NEW.parent_id;
|
|
||||||
|
|
||||||
NEW.complete_name := v_parent_name || ' / ' || NEW.name;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION inventory.update_location_complete_name IS 'Actualiza el nombre completo de la ubicación';
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- TRIGGERS
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Trigger: Actualizar updated_at
|
|
||||||
CREATE TRIGGER trg_products_updated_at
|
|
||||||
BEFORE UPDATE ON inventory.products
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_warehouses_updated_at
|
|
||||||
BEFORE UPDATE ON inventory.warehouses
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_locations_updated_at
|
|
||||||
BEFORE UPDATE ON inventory.locations
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_pickings_updated_at
|
|
||||||
BEFORE UPDATE ON inventory.pickings
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_stock_moves_updated_at
|
|
||||||
BEFORE UPDATE ON inventory.stock_moves
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_inventory_adjustments_updated_at
|
|
||||||
BEFORE UPDATE ON inventory.inventory_adjustments
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
-- Trigger: Actualizar complete_name de ubicación
|
|
||||||
CREATE TRIGGER trg_locations_update_complete_name
|
|
||||||
BEFORE INSERT OR UPDATE OF name, parent_id ON inventory.locations
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION inventory.update_location_complete_name();
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- TRACKING AUTOMÁTICO (mail.thread pattern)
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Trigger: Tracking automático para movimientos de stock
|
|
||||||
CREATE TRIGGER track_stock_move_changes
|
|
||||||
AFTER INSERT OR UPDATE OR DELETE ON inventory.stock_moves
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION system.track_field_changes();
|
|
||||||
|
|
||||||
COMMENT ON TRIGGER track_stock_move_changes ON inventory.stock_moves IS
|
|
||||||
'Registra automáticamente cambios en movimientos de stock (estado, producto, cantidad, ubicaciones)';
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- ROW LEVEL SECURITY (RLS)
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
ALTER TABLE inventory.products ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE inventory.warehouses ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE inventory.locations ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE inventory.lots ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE inventory.pickings ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE inventory.stock_moves ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE inventory.inventory_adjustments ENABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_products ON inventory.products
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_warehouses ON inventory.warehouses
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_locations ON inventory.locations
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_lots ON inventory.lots
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_pickings ON inventory.pickings
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_stock_moves ON inventory.stock_moves
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_inventory_adjustments ON inventory.inventory_adjustments
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- COMENTARIOS
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
COMMENT ON SCHEMA inventory IS 'Schema de gestión de inventarios, productos, almacenes y movimientos';
|
|
||||||
COMMENT ON TABLE inventory.products IS 'Productos (almacenables, consumibles, servicios)';
|
|
||||||
COMMENT ON TABLE inventory.product_variants IS 'Variantes de productos (color, talla, etc.)';
|
|
||||||
COMMENT ON TABLE inventory.warehouses IS 'Almacenes físicos';
|
|
||||||
COMMENT ON TABLE inventory.locations IS 'Ubicaciones dentro de almacenes (estantes, zonas, etc.)';
|
|
||||||
COMMENT ON TABLE inventory.stock_quants IS 'Cantidades en stock por producto/ubicación/lote';
|
|
||||||
COMMENT ON TABLE inventory.lots IS 'Lotes de producción y números de serie';
|
|
||||||
COMMENT ON TABLE inventory.pickings IS 'Albaranes de entrada, salida y transferencia';
|
|
||||||
COMMENT ON TABLE inventory.stock_moves IS 'Movimientos individuales de inventario';
|
|
||||||
COMMENT ON TABLE inventory.inventory_adjustments IS 'Ajustes de inventario (conteos físicos)';
|
|
||||||
COMMENT ON TABLE inventory.inventory_adjustment_lines IS 'Líneas de ajuste de inventario';
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- VISTAS ÚTILES
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Vista: stock_by_product (Stock por producto)
|
|
||||||
CREATE OR REPLACE VIEW inventory.stock_by_product_view AS
|
|
||||||
SELECT
|
|
||||||
p.id AS product_id,
|
|
||||||
p.code AS product_code,
|
|
||||||
p.name AS product_name,
|
|
||||||
l.id AS location_id,
|
|
||||||
l.complete_name AS location_name,
|
|
||||||
COALESCE(SUM(sq.quantity), 0) AS quantity,
|
|
||||||
COALESCE(SUM(sq.reserved_quantity), 0) AS reserved_quantity,
|
|
||||||
COALESCE(SUM(sq.available_quantity), 0) AS available_quantity
|
|
||||||
FROM inventory.products p
|
|
||||||
CROSS JOIN inventory.locations l
|
|
||||||
LEFT JOIN inventory.stock_quants sq ON sq.product_id = p.id AND sq.location_id = l.id
|
|
||||||
WHERE p.product_type = 'storable'
|
|
||||||
AND l.location_type = 'internal'
|
|
||||||
GROUP BY p.id, p.code, p.name, l.id, l.complete_name;
|
|
||||||
|
|
||||||
COMMENT ON VIEW inventory.stock_by_product_view IS 'Vista de stock disponible por producto y ubicación';
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- FIN DEL SCHEMA INVENTORY
|
|
||||||
-- =====================================================
|
|
||||||
337
ddl/06-auth-extended.sql
Normal file
337
ddl/06-auth-extended.sql
Normal file
@ -0,0 +1,337 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- ARCHIVO: 06-auth-extended.sql
|
||||||
|
-- DESCRIPCION: Extensiones de autenticacion SaaS (JWT, OAuth, MFA)
|
||||||
|
-- VERSION: 1.0.0
|
||||||
|
-- PROYECTO: ERP-Core V2
|
||||||
|
-- FECHA: 2026-01-10
|
||||||
|
-- EPIC: SAAS-CORE-AUTH (EPIC-SAAS-001)
|
||||||
|
-- HISTORIAS: US-001, US-002, US-003
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- MODIFICACIONES A TABLAS EXISTENTES
|
||||||
|
-- =====================
|
||||||
|
|
||||||
|
-- Agregar columnas OAuth a auth.users (US-002)
|
||||||
|
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS oauth_provider VARCHAR(50);
|
||||||
|
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS oauth_provider_id VARCHAR(255);
|
||||||
|
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS avatar_url TEXT;
|
||||||
|
|
||||||
|
-- Agregar columnas MFA a auth.users (US-003)
|
||||||
|
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS mfa_enabled BOOLEAN DEFAULT FALSE;
|
||||||
|
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS mfa_secret_encrypted TEXT;
|
||||||
|
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS mfa_backup_codes TEXT[];
|
||||||
|
|
||||||
|
-- Agregar columna superadmin (EPIC-SAAS-006)
|
||||||
|
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS is_superadmin BOOLEAN DEFAULT FALSE;
|
||||||
|
|
||||||
|
-- Indices para nuevas columnas
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_oauth_provider ON auth.users(oauth_provider, oauth_provider_id) WHERE oauth_provider IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_mfa_enabled ON auth.users(mfa_enabled) WHERE mfa_enabled = TRUE;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: auth.sessions
|
||||||
|
-- Sesiones de usuario con refresh tokens (US-001)
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS auth.sessions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Token info
|
||||||
|
refresh_token_hash VARCHAR(255) NOT NULL,
|
||||||
|
jti VARCHAR(255) UNIQUE NOT NULL, -- JWT ID para blacklist
|
||||||
|
|
||||||
|
-- Device info
|
||||||
|
device_info JSONB DEFAULT '{}',
|
||||||
|
device_fingerprint VARCHAR(255),
|
||||||
|
user_agent TEXT,
|
||||||
|
ip_address INET,
|
||||||
|
|
||||||
|
-- Geo info
|
||||||
|
country_code VARCHAR(2),
|
||||||
|
city VARCHAR(100),
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
last_activity_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
revoked_at TIMESTAMPTZ,
|
||||||
|
revoked_reason VARCHAR(100),
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para sessions
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_user ON auth.sessions(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_tenant ON auth.sessions(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_jti ON auth.sessions(jti);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON auth.sessions(expires_at) WHERE revoked_at IS NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_active ON auth.sessions(user_id, revoked_at) WHERE revoked_at IS NULL;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: auth.token_blacklist
|
||||||
|
-- Tokens revocados/invalidados (US-001)
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS auth.token_blacklist (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
jti VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
token_type VARCHAR(20) NOT NULL CHECK (token_type IN ('access', 'refresh')),
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
reason VARCHAR(100),
|
||||||
|
revoked_by UUID REFERENCES auth.users(id),
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
revoked_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indice para limpieza de tokens expirados
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_token_blacklist_expires ON auth.token_blacklist(expires_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_token_blacklist_jti ON auth.token_blacklist(jti);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: auth.oauth_providers
|
||||||
|
-- Proveedores OAuth vinculados a usuarios (US-002)
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS auth.oauth_providers (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Provider info
|
||||||
|
provider VARCHAR(50) NOT NULL, -- google, github, microsoft, apple
|
||||||
|
provider_user_id VARCHAR(255) NOT NULL,
|
||||||
|
provider_email VARCHAR(255),
|
||||||
|
|
||||||
|
-- Tokens (encrypted)
|
||||||
|
access_token_encrypted TEXT,
|
||||||
|
refresh_token_encrypted TEXT,
|
||||||
|
token_expires_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Profile data from provider
|
||||||
|
profile_data JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
linked_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_used_at TIMESTAMPTZ,
|
||||||
|
unlinked_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
UNIQUE(provider, provider_user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para oauth_providers
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_oauth_providers_user ON auth.oauth_providers(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_oauth_providers_provider ON auth.oauth_providers(provider, provider_user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_oauth_providers_email ON auth.oauth_providers(provider_email);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: auth.mfa_devices
|
||||||
|
-- Dispositivos MFA registrados (US-003)
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS auth.mfa_devices (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Device info
|
||||||
|
device_type VARCHAR(50) NOT NULL, -- totp, sms, email, hardware_key
|
||||||
|
device_name VARCHAR(255),
|
||||||
|
|
||||||
|
-- TOTP specific
|
||||||
|
secret_encrypted TEXT,
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
is_primary BOOLEAN DEFAULT FALSE,
|
||||||
|
is_verified BOOLEAN DEFAULT FALSE,
|
||||||
|
verified_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Usage tracking
|
||||||
|
last_used_at TIMESTAMPTZ,
|
||||||
|
use_count INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
disabled_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para mfa_devices
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mfa_devices_user ON auth.mfa_devices(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mfa_devices_primary ON auth.mfa_devices(user_id, is_primary) WHERE is_primary = TRUE;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: auth.mfa_backup_codes
|
||||||
|
-- Codigos de respaldo MFA (US-003)
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS auth.mfa_backup_codes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Code (hashed)
|
||||||
|
code_hash VARCHAR(255) NOT NULL,
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
used_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expires_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para mfa_backup_codes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mfa_backup_codes_user ON auth.mfa_backup_codes(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mfa_backup_codes_unused ON auth.mfa_backup_codes(user_id, used_at) WHERE used_at IS NULL;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: auth.login_attempts
|
||||||
|
-- Intentos de login para rate limiting y seguridad
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS auth.login_attempts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
-- Identificacion
|
||||||
|
email VARCHAR(255),
|
||||||
|
ip_address INET NOT NULL,
|
||||||
|
user_agent TEXT,
|
||||||
|
|
||||||
|
-- Resultado
|
||||||
|
success BOOLEAN NOT NULL,
|
||||||
|
failure_reason VARCHAR(100),
|
||||||
|
|
||||||
|
-- MFA
|
||||||
|
mfa_required BOOLEAN DEFAULT FALSE,
|
||||||
|
mfa_passed BOOLEAN,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
tenant_id UUID REFERENCES auth.tenants(id),
|
||||||
|
user_id UUID REFERENCES auth.users(id),
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para login_attempts
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_login_attempts_email ON auth.login_attempts(email, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_login_attempts_ip ON auth.login_attempts(ip_address, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_login_attempts_cleanup ON auth.login_attempts(created_at);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- RLS POLICIES
|
||||||
|
-- =====================
|
||||||
|
ALTER TABLE auth.sessions ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_sessions ON auth.sessions
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
ALTER TABLE auth.oauth_providers ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_oauth ON auth.oauth_providers
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
ALTER TABLE auth.mfa_devices ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY user_isolation_mfa_devices ON auth.mfa_devices
|
||||||
|
USING (user_id = current_setting('app.current_user_id', true)::uuid);
|
||||||
|
|
||||||
|
ALTER TABLE auth.mfa_backup_codes ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY user_isolation_mfa_codes ON auth.mfa_backup_codes
|
||||||
|
USING (user_id = current_setting('app.current_user_id', true)::uuid);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- FUNCIONES
|
||||||
|
-- =====================
|
||||||
|
|
||||||
|
-- Funcion para limpiar sesiones expiradas
|
||||||
|
CREATE OR REPLACE FUNCTION auth.cleanup_expired_sessions()
|
||||||
|
RETURNS INTEGER AS $$
|
||||||
|
DECLARE
|
||||||
|
deleted_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
DELETE FROM auth.sessions
|
||||||
|
WHERE expires_at < CURRENT_TIMESTAMP
|
||||||
|
OR revoked_at IS NOT NULL;
|
||||||
|
|
||||||
|
GET DIAGNOSTICS deleted_count = ROW_COUNT;
|
||||||
|
RETURN deleted_count;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Funcion para limpiar tokens expirados del blacklist
|
||||||
|
CREATE OR REPLACE FUNCTION auth.cleanup_expired_tokens()
|
||||||
|
RETURNS INTEGER AS $$
|
||||||
|
DECLARE
|
||||||
|
deleted_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
DELETE FROM auth.token_blacklist
|
||||||
|
WHERE expires_at < CURRENT_TIMESTAMP;
|
||||||
|
|
||||||
|
GET DIAGNOSTICS deleted_count = ROW_COUNT;
|
||||||
|
RETURN deleted_count;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Funcion para limpiar intentos de login antiguos (mas de 30 dias)
|
||||||
|
CREATE OR REPLACE FUNCTION auth.cleanup_old_login_attempts()
|
||||||
|
RETURNS INTEGER AS $$
|
||||||
|
DECLARE
|
||||||
|
deleted_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
DELETE FROM auth.login_attempts
|
||||||
|
WHERE created_at < CURRENT_TIMESTAMP - INTERVAL '30 days';
|
||||||
|
|
||||||
|
GET DIAGNOSTICS deleted_count = ROW_COUNT;
|
||||||
|
RETURN deleted_count;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Funcion para verificar rate limit de login
|
||||||
|
CREATE OR REPLACE FUNCTION auth.check_login_rate_limit(
|
||||||
|
p_email VARCHAR(255),
|
||||||
|
p_ip_address INET,
|
||||||
|
p_max_attempts INTEGER DEFAULT 5,
|
||||||
|
p_window_minutes INTEGER DEFAULT 15
|
||||||
|
)
|
||||||
|
RETURNS BOOLEAN AS $$
|
||||||
|
DECLARE
|
||||||
|
attempt_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*) INTO attempt_count
|
||||||
|
FROM auth.login_attempts
|
||||||
|
WHERE (email = p_email OR ip_address = p_ip_address)
|
||||||
|
AND success = FALSE
|
||||||
|
AND created_at > CURRENT_TIMESTAMP - (p_window_minutes || ' minutes')::INTERVAL;
|
||||||
|
|
||||||
|
RETURN attempt_count < p_max_attempts;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Funcion para revocar todas las sesiones de un usuario
|
||||||
|
CREATE OR REPLACE FUNCTION auth.revoke_all_user_sessions(
|
||||||
|
p_user_id UUID,
|
||||||
|
p_reason VARCHAR(100) DEFAULT 'manual_revocation'
|
||||||
|
)
|
||||||
|
RETURNS INTEGER AS $$
|
||||||
|
DECLARE
|
||||||
|
revoked_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
UPDATE auth.sessions
|
||||||
|
SET revoked_at = CURRENT_TIMESTAMP,
|
||||||
|
revoked_reason = p_reason
|
||||||
|
WHERE user_id = p_user_id
|
||||||
|
AND revoked_at IS NULL;
|
||||||
|
|
||||||
|
GET DIAGNOSTICS revoked_count = ROW_COUNT;
|
||||||
|
RETURN revoked_count;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- COMENTARIOS
|
||||||
|
-- =====================
|
||||||
|
COMMENT ON TABLE auth.sessions IS 'Sesiones de usuario con refresh tokens para JWT';
|
||||||
|
COMMENT ON TABLE auth.token_blacklist IS 'Tokens JWT revocados antes de su expiracion';
|
||||||
|
COMMENT ON TABLE auth.oauth_providers IS 'Proveedores OAuth vinculados a cuentas de usuario';
|
||||||
|
COMMENT ON TABLE auth.mfa_devices IS 'Dispositivos MFA registrados por usuario';
|
||||||
|
COMMENT ON TABLE auth.mfa_backup_codes IS 'Codigos de respaldo para MFA';
|
||||||
|
COMMENT ON TABLE auth.login_attempts IS 'Registro de intentos de login para seguridad';
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION auth.cleanup_expired_sessions IS 'Limpia sesiones expiradas o revocadas';
|
||||||
|
COMMENT ON FUNCTION auth.cleanup_expired_tokens IS 'Limpia tokens del blacklist que ya expiraron';
|
||||||
|
COMMENT ON FUNCTION auth.check_login_rate_limit IS 'Verifica si un email/IP ha excedido intentos de login';
|
||||||
|
COMMENT ON FUNCTION auth.revoke_all_user_sessions IS 'Revoca todas las sesiones activas de un usuario';
|
||||||
@ -1,583 +0,0 @@
|
|||||||
-- =====================================================
|
|
||||||
-- SCHEMA: purchase
|
|
||||||
-- PROPÓSITO: Gestión de compras, proveedores, órdenes de compra
|
|
||||||
-- MÓDULOS: MGN-006 (Compras Básico)
|
|
||||||
-- FECHA: 2025-11-24
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Crear schema
|
|
||||||
CREATE SCHEMA IF NOT EXISTS purchase;
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- TYPES (ENUMs)
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
CREATE TYPE purchase.order_status AS ENUM (
|
|
||||||
'draft',
|
|
||||||
'sent',
|
|
||||||
'confirmed',
|
|
||||||
'received',
|
|
||||||
'billed',
|
|
||||||
'cancelled'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE purchase.rfq_status AS ENUM (
|
|
||||||
'draft',
|
|
||||||
'sent',
|
|
||||||
'responded',
|
|
||||||
'accepted',
|
|
||||||
'rejected',
|
|
||||||
'cancelled'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE purchase.agreement_type AS ENUM (
|
|
||||||
'price',
|
|
||||||
'discount',
|
|
||||||
'blanket'
|
|
||||||
);
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- TABLES
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Tabla: purchase_orders (Órdenes de compra)
|
|
||||||
CREATE TABLE purchase.purchase_orders (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
company_id UUID NOT NULL REFERENCES auth.companies(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
-- Numeración
|
|
||||||
name VARCHAR(100) NOT NULL,
|
|
||||||
ref VARCHAR(100), -- Referencia del proveedor
|
|
||||||
|
|
||||||
-- Proveedor
|
|
||||||
partner_id UUID NOT NULL REFERENCES core.partners(id),
|
|
||||||
|
|
||||||
-- Fechas
|
|
||||||
order_date DATE NOT NULL,
|
|
||||||
expected_date DATE,
|
|
||||||
effective_date DATE,
|
|
||||||
|
|
||||||
-- Configuración
|
|
||||||
currency_id UUID NOT NULL REFERENCES core.currencies(id),
|
|
||||||
payment_term_id UUID REFERENCES financial.payment_terms(id),
|
|
||||||
|
|
||||||
-- Montos
|
|
||||||
amount_untaxed DECIMAL(15, 2) NOT NULL DEFAULT 0,
|
|
||||||
amount_tax DECIMAL(15, 2) NOT NULL DEFAULT 0,
|
|
||||||
amount_total DECIMAL(15, 2) NOT NULL DEFAULT 0,
|
|
||||||
|
|
||||||
-- Estado
|
|
||||||
status purchase.order_status NOT NULL DEFAULT 'draft',
|
|
||||||
|
|
||||||
-- Recepciones y facturación
|
|
||||||
receipt_status VARCHAR(20) DEFAULT 'pending', -- pending, partial, received
|
|
||||||
invoice_status VARCHAR(20) DEFAULT 'pending', -- pending, partial, billed
|
|
||||||
|
|
||||||
-- Relaciones
|
|
||||||
picking_id UUID REFERENCES inventory.pickings(id), -- Recepción generada
|
|
||||||
invoice_id UUID REFERENCES financial.invoices(id), -- Factura generada
|
|
||||||
|
|
||||||
-- Notas
|
|
||||||
notes TEXT,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
confirmed_at TIMESTAMP,
|
|
||||||
confirmed_by UUID REFERENCES auth.users(id),
|
|
||||||
cancelled_at TIMESTAMP,
|
|
||||||
cancelled_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT uq_purchase_orders_name_company UNIQUE (company_id, name)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: purchase_order_lines (Líneas de orden de compra)
|
|
||||||
CREATE TABLE purchase.purchase_order_lines (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
order_id UUID NOT NULL REFERENCES purchase.purchase_orders(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
product_id UUID NOT NULL REFERENCES inventory.products(id),
|
|
||||||
description TEXT NOT NULL,
|
|
||||||
|
|
||||||
-- Cantidades
|
|
||||||
quantity DECIMAL(12, 4) NOT NULL,
|
|
||||||
qty_received DECIMAL(12, 4) DEFAULT 0,
|
|
||||||
qty_invoiced DECIMAL(12, 4) DEFAULT 0,
|
|
||||||
uom_id UUID NOT NULL REFERENCES core.uom(id),
|
|
||||||
|
|
||||||
-- Precios
|
|
||||||
price_unit DECIMAL(15, 4) NOT NULL,
|
|
||||||
discount DECIMAL(5, 2) DEFAULT 0, -- Porcentaje de descuento
|
|
||||||
|
|
||||||
-- Impuestos
|
|
||||||
tax_ids UUID[] DEFAULT '{}',
|
|
||||||
|
|
||||||
-- Montos
|
|
||||||
amount_untaxed DECIMAL(15, 2) NOT NULL,
|
|
||||||
amount_tax DECIMAL(15, 2) NOT NULL,
|
|
||||||
amount_total DECIMAL(15, 2) NOT NULL,
|
|
||||||
|
|
||||||
-- Fechas esperadas
|
|
||||||
expected_date DATE,
|
|
||||||
|
|
||||||
-- Analítica
|
|
||||||
analytic_account_id UUID REFERENCES analytics.analytic_accounts(id), -- Distribución analítica
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT chk_purchase_order_lines_quantity CHECK (quantity > 0),
|
|
||||||
CONSTRAINT chk_purchase_order_lines_discount CHECK (discount >= 0 AND discount <= 100)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Índices para purchase_order_lines
|
|
||||||
CREATE INDEX idx_purchase_order_lines_tenant_id ON purchase.purchase_order_lines(tenant_id);
|
|
||||||
CREATE INDEX idx_purchase_order_lines_order_id ON purchase.purchase_order_lines(order_id);
|
|
||||||
CREATE INDEX idx_purchase_order_lines_product_id ON purchase.purchase_order_lines(product_id);
|
|
||||||
|
|
||||||
-- RLS para purchase_order_lines
|
|
||||||
ALTER TABLE purchase.purchase_order_lines ENABLE ROW LEVEL SECURITY;
|
|
||||||
CREATE POLICY tenant_isolation_purchase_order_lines ON purchase.purchase_order_lines
|
|
||||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
|
||||||
|
|
||||||
-- Tabla: rfqs (Request for Quotation - Solicitudes de cotización)
|
|
||||||
CREATE TABLE purchase.rfqs (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
company_id UUID NOT NULL REFERENCES auth.companies(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
name VARCHAR(100) NOT NULL,
|
|
||||||
|
|
||||||
-- Proveedores (puede ser enviada a múltiples proveedores)
|
|
||||||
partner_ids UUID[] NOT NULL,
|
|
||||||
|
|
||||||
-- Fechas
|
|
||||||
request_date DATE NOT NULL,
|
|
||||||
deadline_date DATE,
|
|
||||||
response_date DATE,
|
|
||||||
|
|
||||||
-- Estado
|
|
||||||
status purchase.rfq_status NOT NULL DEFAULT 'draft',
|
|
||||||
|
|
||||||
-- Descripción
|
|
||||||
description TEXT,
|
|
||||||
notes TEXT,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT uq_rfqs_name_company UNIQUE (company_id, name)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: rfq_lines (Líneas de RFQ)
|
|
||||||
CREATE TABLE purchase.rfq_lines (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
rfq_id UUID NOT NULL REFERENCES purchase.rfqs(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
product_id UUID REFERENCES inventory.products(id),
|
|
||||||
description TEXT NOT NULL,
|
|
||||||
quantity DECIMAL(12, 4) NOT NULL,
|
|
||||||
uom_id UUID NOT NULL REFERENCES core.uom(id),
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT chk_rfq_lines_quantity CHECK (quantity > 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Índices para rfq_lines
|
|
||||||
CREATE INDEX idx_rfq_lines_tenant_id ON purchase.rfq_lines(tenant_id);
|
|
||||||
|
|
||||||
-- RLS para rfq_lines
|
|
||||||
ALTER TABLE purchase.rfq_lines ENABLE ROW LEVEL SECURITY;
|
|
||||||
CREATE POLICY tenant_isolation_rfq_lines ON purchase.rfq_lines
|
|
||||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
|
||||||
|
|
||||||
-- Tabla: vendor_pricelists (Listas de precios de proveedores)
|
|
||||||
CREATE TABLE purchase.vendor_pricelists (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
partner_id UUID NOT NULL REFERENCES core.partners(id),
|
|
||||||
product_id UUID NOT NULL REFERENCES inventory.products(id),
|
|
||||||
|
|
||||||
-- Precio
|
|
||||||
price DECIMAL(15, 4) NOT NULL,
|
|
||||||
currency_id UUID NOT NULL REFERENCES core.currencies(id),
|
|
||||||
|
|
||||||
-- Cantidad mínima
|
|
||||||
min_quantity DECIMAL(12, 4) DEFAULT 1,
|
|
||||||
|
|
||||||
-- Validez
|
|
||||||
valid_from DATE,
|
|
||||||
valid_to DATE,
|
|
||||||
|
|
||||||
-- Control
|
|
||||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT chk_vendor_pricelists_price CHECK (price >= 0),
|
|
||||||
CONSTRAINT chk_vendor_pricelists_min_qty CHECK (min_quantity > 0),
|
|
||||||
CONSTRAINT chk_vendor_pricelists_dates CHECK (valid_to IS NULL OR valid_to >= valid_from)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: purchase_agreements (Acuerdos de compra / Contratos)
|
|
||||||
CREATE TABLE purchase.purchase_agreements (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
company_id UUID NOT NULL REFERENCES auth.companies(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
code VARCHAR(50),
|
|
||||||
agreement_type purchase.agreement_type NOT NULL,
|
|
||||||
|
|
||||||
-- Proveedor
|
|
||||||
partner_id UUID NOT NULL REFERENCES core.partners(id),
|
|
||||||
|
|
||||||
-- Vigencia
|
|
||||||
start_date DATE NOT NULL,
|
|
||||||
end_date DATE NOT NULL,
|
|
||||||
|
|
||||||
-- Montos (para contratos blanket)
|
|
||||||
amount_max DECIMAL(15, 2),
|
|
||||||
currency_id UUID REFERENCES core.currencies(id),
|
|
||||||
|
|
||||||
-- Estado
|
|
||||||
is_active BOOLEAN DEFAULT TRUE,
|
|
||||||
|
|
||||||
-- Términos
|
|
||||||
terms TEXT,
|
|
||||||
notes TEXT,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT uq_purchase_agreements_code_company UNIQUE (company_id, code),
|
|
||||||
CONSTRAINT chk_purchase_agreements_dates CHECK (end_date > start_date)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: purchase_agreement_lines (Líneas de acuerdo)
|
|
||||||
CREATE TABLE purchase.purchase_agreement_lines (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
agreement_id UUID NOT NULL REFERENCES purchase.purchase_agreements(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
product_id UUID NOT NULL REFERENCES inventory.products(id),
|
|
||||||
|
|
||||||
-- Cantidades
|
|
||||||
quantity DECIMAL(12, 4),
|
|
||||||
qty_ordered DECIMAL(12, 4) DEFAULT 0,
|
|
||||||
|
|
||||||
-- Precio acordado
|
|
||||||
price_unit DECIMAL(15, 4),
|
|
||||||
discount DECIMAL(5, 2) DEFAULT 0,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Índices para purchase_agreement_lines
|
|
||||||
CREATE INDEX idx_purchase_agreement_lines_tenant_id ON purchase.purchase_agreement_lines(tenant_id);
|
|
||||||
|
|
||||||
-- RLS para purchase_agreement_lines
|
|
||||||
ALTER TABLE purchase.purchase_agreement_lines ENABLE ROW LEVEL SECURITY;
|
|
||||||
CREATE POLICY tenant_isolation_purchase_agreement_lines ON purchase.purchase_agreement_lines
|
|
||||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
|
||||||
|
|
||||||
-- Tabla: vendor_evaluations (Evaluaciones de proveedores)
|
|
||||||
CREATE TABLE purchase.vendor_evaluations (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
company_id UUID NOT NULL REFERENCES auth.companies(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
partner_id UUID NOT NULL REFERENCES core.partners(id),
|
|
||||||
|
|
||||||
-- Período de evaluación
|
|
||||||
evaluation_date DATE NOT NULL,
|
|
||||||
period_start DATE NOT NULL,
|
|
||||||
period_end DATE NOT NULL,
|
|
||||||
|
|
||||||
-- Calificaciones (1-5)
|
|
||||||
quality_rating INTEGER,
|
|
||||||
delivery_rating INTEGER,
|
|
||||||
service_rating INTEGER,
|
|
||||||
price_rating INTEGER,
|
|
||||||
overall_rating DECIMAL(3, 2),
|
|
||||||
|
|
||||||
-- Métricas
|
|
||||||
on_time_delivery_rate DECIMAL(5, 2), -- Porcentaje
|
|
||||||
defect_rate DECIMAL(5, 2), -- Porcentaje
|
|
||||||
|
|
||||||
-- Comentarios
|
|
||||||
comments TEXT,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT chk_vendor_evaluations_quality CHECK (quality_rating >= 1 AND quality_rating <= 5),
|
|
||||||
CONSTRAINT chk_vendor_evaluations_delivery CHECK (delivery_rating >= 1 AND delivery_rating <= 5),
|
|
||||||
CONSTRAINT chk_vendor_evaluations_service CHECK (service_rating >= 1 AND service_rating <= 5),
|
|
||||||
CONSTRAINT chk_vendor_evaluations_price CHECK (price_rating >= 1 AND price_rating <= 5),
|
|
||||||
CONSTRAINT chk_vendor_evaluations_overall CHECK (overall_rating >= 1 AND overall_rating <= 5),
|
|
||||||
CONSTRAINT chk_vendor_evaluations_dates CHECK (period_end >= period_start)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- INDICES
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Purchase Orders
|
|
||||||
CREATE INDEX idx_purchase_orders_tenant_id ON purchase.purchase_orders(tenant_id);
|
|
||||||
CREATE INDEX idx_purchase_orders_company_id ON purchase.purchase_orders(company_id);
|
|
||||||
CREATE INDEX idx_purchase_orders_partner_id ON purchase.purchase_orders(partner_id);
|
|
||||||
CREATE INDEX idx_purchase_orders_name ON purchase.purchase_orders(name);
|
|
||||||
CREATE INDEX idx_purchase_orders_status ON purchase.purchase_orders(status);
|
|
||||||
CREATE INDEX idx_purchase_orders_order_date ON purchase.purchase_orders(order_date);
|
|
||||||
CREATE INDEX idx_purchase_orders_expected_date ON purchase.purchase_orders(expected_date);
|
|
||||||
|
|
||||||
-- Purchase Order Lines
|
|
||||||
CREATE INDEX idx_purchase_order_lines_order_id ON purchase.purchase_order_lines(order_id);
|
|
||||||
CREATE INDEX idx_purchase_order_lines_product_id ON purchase.purchase_order_lines(product_id);
|
|
||||||
CREATE INDEX idx_purchase_order_lines_analytic_account_id ON purchase.purchase_order_lines(analytic_account_id) WHERE analytic_account_id IS NOT NULL;
|
|
||||||
|
|
||||||
-- RFQs
|
|
||||||
CREATE INDEX idx_rfqs_tenant_id ON purchase.rfqs(tenant_id);
|
|
||||||
CREATE INDEX idx_rfqs_company_id ON purchase.rfqs(company_id);
|
|
||||||
CREATE INDEX idx_rfqs_status ON purchase.rfqs(status);
|
|
||||||
CREATE INDEX idx_rfqs_request_date ON purchase.rfqs(request_date);
|
|
||||||
|
|
||||||
-- RFQ Lines
|
|
||||||
CREATE INDEX idx_rfq_lines_rfq_id ON purchase.rfq_lines(rfq_id);
|
|
||||||
CREATE INDEX idx_rfq_lines_product_id ON purchase.rfq_lines(product_id);
|
|
||||||
|
|
||||||
-- Vendor Pricelists
|
|
||||||
CREATE INDEX idx_vendor_pricelists_tenant_id ON purchase.vendor_pricelists(tenant_id);
|
|
||||||
CREATE INDEX idx_vendor_pricelists_partner_id ON purchase.vendor_pricelists(partner_id);
|
|
||||||
CREATE INDEX idx_vendor_pricelists_product_id ON purchase.vendor_pricelists(product_id);
|
|
||||||
CREATE INDEX idx_vendor_pricelists_active ON purchase.vendor_pricelists(active) WHERE active = TRUE;
|
|
||||||
|
|
||||||
-- Purchase Agreements
|
|
||||||
CREATE INDEX idx_purchase_agreements_tenant_id ON purchase.purchase_agreements(tenant_id);
|
|
||||||
CREATE INDEX idx_purchase_agreements_company_id ON purchase.purchase_agreements(company_id);
|
|
||||||
CREATE INDEX idx_purchase_agreements_partner_id ON purchase.purchase_agreements(partner_id);
|
|
||||||
CREATE INDEX idx_purchase_agreements_dates ON purchase.purchase_agreements(start_date, end_date);
|
|
||||||
CREATE INDEX idx_purchase_agreements_active ON purchase.purchase_agreements(is_active) WHERE is_active = TRUE;
|
|
||||||
|
|
||||||
-- Purchase Agreement Lines
|
|
||||||
CREATE INDEX idx_purchase_agreement_lines_agreement_id ON purchase.purchase_agreement_lines(agreement_id);
|
|
||||||
CREATE INDEX idx_purchase_agreement_lines_product_id ON purchase.purchase_agreement_lines(product_id);
|
|
||||||
|
|
||||||
-- Vendor Evaluations
|
|
||||||
CREATE INDEX idx_vendor_evaluations_tenant_id ON purchase.vendor_evaluations(tenant_id);
|
|
||||||
CREATE INDEX idx_vendor_evaluations_partner_id ON purchase.vendor_evaluations(partner_id);
|
|
||||||
CREATE INDEX idx_vendor_evaluations_date ON purchase.vendor_evaluations(evaluation_date);
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- FUNCTIONS
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Función: calculate_purchase_order_totals
|
|
||||||
CREATE OR REPLACE FUNCTION purchase.calculate_purchase_order_totals(p_order_id UUID)
|
|
||||||
RETURNS VOID AS $$
|
|
||||||
DECLARE
|
|
||||||
v_amount_untaxed DECIMAL;
|
|
||||||
v_amount_tax DECIMAL;
|
|
||||||
v_amount_total DECIMAL;
|
|
||||||
BEGIN
|
|
||||||
SELECT
|
|
||||||
COALESCE(SUM(amount_untaxed), 0),
|
|
||||||
COALESCE(SUM(amount_tax), 0),
|
|
||||||
COALESCE(SUM(amount_total), 0)
|
|
||||||
INTO v_amount_untaxed, v_amount_tax, v_amount_total
|
|
||||||
FROM purchase.purchase_order_lines
|
|
||||||
WHERE order_id = p_order_id;
|
|
||||||
|
|
||||||
UPDATE purchase.purchase_orders
|
|
||||||
SET amount_untaxed = v_amount_untaxed,
|
|
||||||
amount_tax = v_amount_tax,
|
|
||||||
amount_total = v_amount_total,
|
|
||||||
updated_at = CURRENT_TIMESTAMP,
|
|
||||||
updated_by = get_current_user_id()
|
|
||||||
WHERE id = p_order_id;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION purchase.calculate_purchase_order_totals IS 'Calcula los totales de una orden de compra';
|
|
||||||
|
|
||||||
-- Función: create_picking_from_po
|
|
||||||
CREATE OR REPLACE FUNCTION purchase.create_picking_from_po(p_order_id UUID)
|
|
||||||
RETURNS UUID AS $$
|
|
||||||
DECLARE
|
|
||||||
v_order RECORD;
|
|
||||||
v_picking_id UUID;
|
|
||||||
v_location_supplier UUID;
|
|
||||||
v_location_stock UUID;
|
|
||||||
BEGIN
|
|
||||||
-- Obtener datos de la orden
|
|
||||||
SELECT * INTO v_order
|
|
||||||
FROM purchase.purchase_orders
|
|
||||||
WHERE id = p_order_id;
|
|
||||||
|
|
||||||
IF NOT FOUND THEN
|
|
||||||
RAISE EXCEPTION 'Purchase order % not found', p_order_id;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- Obtener ubicaciones (simplificado - en producción obtener de configuración)
|
|
||||||
SELECT id INTO v_location_supplier
|
|
||||||
FROM inventory.locations
|
|
||||||
WHERE location_type = 'supplier'
|
|
||||||
LIMIT 1;
|
|
||||||
|
|
||||||
SELECT id INTO v_location_stock
|
|
||||||
FROM inventory.locations
|
|
||||||
WHERE location_type = 'internal'
|
|
||||||
LIMIT 1;
|
|
||||||
|
|
||||||
-- Crear picking
|
|
||||||
INSERT INTO inventory.pickings (
|
|
||||||
tenant_id,
|
|
||||||
company_id,
|
|
||||||
name,
|
|
||||||
picking_type,
|
|
||||||
location_id,
|
|
||||||
location_dest_id,
|
|
||||||
partner_id,
|
|
||||||
origin,
|
|
||||||
scheduled_date
|
|
||||||
) VALUES (
|
|
||||||
v_order.tenant_id,
|
|
||||||
v_order.company_id,
|
|
||||||
'IN/' || v_order.name,
|
|
||||||
'incoming',
|
|
||||||
v_location_supplier,
|
|
||||||
v_location_stock,
|
|
||||||
v_order.partner_id,
|
|
||||||
v_order.name,
|
|
||||||
v_order.expected_date
|
|
||||||
) RETURNING id INTO v_picking_id;
|
|
||||||
|
|
||||||
-- Actualizar la PO con el picking_id
|
|
||||||
UPDATE purchase.purchase_orders
|
|
||||||
SET picking_id = v_picking_id
|
|
||||||
WHERE id = p_order_id;
|
|
||||||
|
|
||||||
RETURN v_picking_id;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION purchase.create_picking_from_po IS 'Crea un picking de recepción a partir de una orden de compra';
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- TRIGGERS
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_purchase_orders_updated_at
|
|
||||||
BEFORE UPDATE ON purchase.purchase_orders
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_rfqs_updated_at
|
|
||||||
BEFORE UPDATE ON purchase.rfqs
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_vendor_pricelists_updated_at
|
|
||||||
BEFORE UPDATE ON purchase.vendor_pricelists
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_purchase_agreements_updated_at
|
|
||||||
BEFORE UPDATE ON purchase.purchase_agreements
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
-- Trigger: Actualizar totales de PO al cambiar líneas
|
|
||||||
CREATE OR REPLACE FUNCTION purchase.trg_update_po_totals()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
IF TG_OP = 'DELETE' THEN
|
|
||||||
PERFORM purchase.calculate_purchase_order_totals(OLD.order_id);
|
|
||||||
ELSE
|
|
||||||
PERFORM purchase.calculate_purchase_order_totals(NEW.order_id);
|
|
||||||
END IF;
|
|
||||||
RETURN NULL;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_purchase_order_lines_update_totals
|
|
||||||
AFTER INSERT OR UPDATE OR DELETE ON purchase.purchase_order_lines
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION purchase.trg_update_po_totals();
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- TRACKING AUTOMÁTICO (mail.thread pattern)
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Trigger: Tracking automático para órdenes de compra
|
|
||||||
CREATE TRIGGER track_purchase_order_changes
|
|
||||||
AFTER INSERT OR UPDATE OR DELETE ON purchase.purchase_orders
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION system.track_field_changes();
|
|
||||||
|
|
||||||
COMMENT ON TRIGGER track_purchase_order_changes ON purchase.purchase_orders IS
|
|
||||||
'Registra automáticamente cambios en órdenes de compra (estado, proveedor, monto, fecha)';
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- ROW LEVEL SECURITY (RLS)
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
ALTER TABLE purchase.purchase_orders ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE purchase.rfqs ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE purchase.vendor_pricelists ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE purchase.purchase_agreements ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE purchase.vendor_evaluations ENABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_purchase_orders ON purchase.purchase_orders
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_rfqs ON purchase.rfqs
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_vendor_pricelists ON purchase.vendor_pricelists
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_purchase_agreements ON purchase.purchase_agreements
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_vendor_evaluations ON purchase.vendor_evaluations
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- COMENTARIOS
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
COMMENT ON SCHEMA purchase IS 'Schema de gestión de compras y proveedores';
|
|
||||||
COMMENT ON TABLE purchase.purchase_orders IS 'Órdenes de compra a proveedores';
|
|
||||||
COMMENT ON TABLE purchase.purchase_order_lines IS 'Líneas de órdenes de compra';
|
|
||||||
COMMENT ON TABLE purchase.rfqs IS 'Solicitudes de cotización (RFQ)';
|
|
||||||
COMMENT ON TABLE purchase.rfq_lines IS 'Líneas de solicitudes de cotización';
|
|
||||||
COMMENT ON TABLE purchase.vendor_pricelists IS 'Listas de precios de proveedores';
|
|
||||||
COMMENT ON TABLE purchase.purchase_agreements IS 'Acuerdos/contratos de compra con proveedores';
|
|
||||||
COMMENT ON TABLE purchase.purchase_agreement_lines IS 'Líneas de acuerdos de compra';
|
|
||||||
COMMENT ON TABLE purchase.vendor_evaluations IS 'Evaluaciones de desempeño de proveedores';
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- FIN DEL SCHEMA PURCHASE
|
|
||||||
-- =====================================================
|
|
||||||
705
ddl/07-sales.sql
705
ddl/07-sales.sql
@ -1,705 +0,0 @@
|
|||||||
-- =====================================================
|
|
||||||
-- SCHEMA: sales
|
|
||||||
-- PROPÓSITO: Gestión de ventas, cotizaciones, clientes
|
|
||||||
-- MÓDULOS: MGN-007 (Ventas Básico)
|
|
||||||
-- FECHA: 2025-11-24
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Crear schema
|
|
||||||
CREATE SCHEMA IF NOT EXISTS sales;
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- TYPES (ENUMs)
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
CREATE TYPE sales.order_status AS ENUM (
|
|
||||||
'draft',
|
|
||||||
'sent',
|
|
||||||
'sale',
|
|
||||||
'done',
|
|
||||||
'cancelled'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE sales.quotation_status AS ENUM (
|
|
||||||
'draft',
|
|
||||||
'sent',
|
|
||||||
'approved',
|
|
||||||
'rejected',
|
|
||||||
'converted',
|
|
||||||
'expired'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE sales.invoice_policy AS ENUM (
|
|
||||||
'order',
|
|
||||||
'delivery'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE sales.delivery_status AS ENUM (
|
|
||||||
'pending',
|
|
||||||
'partial',
|
|
||||||
'delivered'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE sales.invoice_status AS ENUM (
|
|
||||||
'pending',
|
|
||||||
'partial',
|
|
||||||
'invoiced'
|
|
||||||
);
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- TABLES
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Tabla: sales_orders (Órdenes de venta)
|
|
||||||
CREATE TABLE sales.sales_orders (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
company_id UUID NOT NULL REFERENCES auth.companies(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
-- Numeración
|
|
||||||
name VARCHAR(100) NOT NULL,
|
|
||||||
client_order_ref VARCHAR(100), -- Referencia del cliente
|
|
||||||
|
|
||||||
-- Cliente
|
|
||||||
partner_id UUID NOT NULL REFERENCES core.partners(id),
|
|
||||||
|
|
||||||
-- Fechas
|
|
||||||
order_date DATE NOT NULL,
|
|
||||||
validity_date DATE,
|
|
||||||
commitment_date DATE,
|
|
||||||
|
|
||||||
-- Configuración
|
|
||||||
currency_id UUID NOT NULL REFERENCES core.currencies(id),
|
|
||||||
pricelist_id UUID REFERENCES sales.pricelists(id),
|
|
||||||
payment_term_id UUID REFERENCES financial.payment_terms(id),
|
|
||||||
|
|
||||||
-- Usuario
|
|
||||||
user_id UUID REFERENCES auth.users(id),
|
|
||||||
sales_team_id UUID REFERENCES sales.sales_teams(id),
|
|
||||||
|
|
||||||
-- Montos
|
|
||||||
amount_untaxed DECIMAL(15, 2) NOT NULL DEFAULT 0,
|
|
||||||
amount_tax DECIMAL(15, 2) NOT NULL DEFAULT 0,
|
|
||||||
amount_total DECIMAL(15, 2) NOT NULL DEFAULT 0,
|
|
||||||
|
|
||||||
-- Estado
|
|
||||||
status sales.order_status NOT NULL DEFAULT 'draft',
|
|
||||||
invoice_status sales.invoice_status NOT NULL DEFAULT 'pending',
|
|
||||||
delivery_status sales.delivery_status NOT NULL DEFAULT 'pending',
|
|
||||||
|
|
||||||
-- Facturación
|
|
||||||
invoice_policy sales.invoice_policy DEFAULT 'order',
|
|
||||||
|
|
||||||
-- Relaciones generadas
|
|
||||||
picking_id UUID REFERENCES inventory.pickings(id),
|
|
||||||
|
|
||||||
-- Notas
|
|
||||||
notes TEXT,
|
|
||||||
terms_conditions TEXT,
|
|
||||||
|
|
||||||
-- Firma electrónica
|
|
||||||
signature TEXT, -- base64
|
|
||||||
signature_date TIMESTAMP,
|
|
||||||
signature_ip INET,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
confirmed_at TIMESTAMP,
|
|
||||||
confirmed_by UUID REFERENCES auth.users(id),
|
|
||||||
cancelled_at TIMESTAMP,
|
|
||||||
cancelled_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT uq_sales_orders_name_company UNIQUE (company_id, name),
|
|
||||||
CONSTRAINT chk_sales_orders_validity CHECK (validity_date IS NULL OR validity_date >= order_date)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: sales_order_lines (Líneas de orden de venta)
|
|
||||||
CREATE TABLE sales.sales_order_lines (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
order_id UUID NOT NULL REFERENCES sales.sales_orders(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
product_id UUID NOT NULL REFERENCES inventory.products(id),
|
|
||||||
description TEXT NOT NULL,
|
|
||||||
|
|
||||||
-- Cantidades
|
|
||||||
quantity DECIMAL(12, 4) NOT NULL,
|
|
||||||
qty_delivered DECIMAL(12, 4) DEFAULT 0,
|
|
||||||
qty_invoiced DECIMAL(12, 4) DEFAULT 0,
|
|
||||||
uom_id UUID NOT NULL REFERENCES core.uom(id),
|
|
||||||
|
|
||||||
-- Precios
|
|
||||||
price_unit DECIMAL(15, 4) NOT NULL,
|
|
||||||
discount DECIMAL(5, 2) DEFAULT 0, -- Porcentaje de descuento
|
|
||||||
|
|
||||||
-- Impuestos
|
|
||||||
tax_ids UUID[] DEFAULT '{}',
|
|
||||||
|
|
||||||
-- Montos
|
|
||||||
amount_untaxed DECIMAL(15, 2) NOT NULL,
|
|
||||||
amount_tax DECIMAL(15, 2) NOT NULL,
|
|
||||||
amount_total DECIMAL(15, 2) NOT NULL,
|
|
||||||
|
|
||||||
-- Analítica
|
|
||||||
analytic_account_id UUID REFERENCES analytics.analytic_accounts(id), -- Distribución analítica
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT chk_sales_order_lines_quantity CHECK (quantity > 0),
|
|
||||||
CONSTRAINT chk_sales_order_lines_discount CHECK (discount >= 0 AND discount <= 100),
|
|
||||||
CONSTRAINT chk_sales_order_lines_qty_delivered CHECK (qty_delivered >= 0 AND qty_delivered <= quantity),
|
|
||||||
CONSTRAINT chk_sales_order_lines_qty_invoiced CHECK (qty_invoiced >= 0 AND qty_invoiced <= quantity)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Índices para sales_order_lines
|
|
||||||
CREATE INDEX idx_sales_order_lines_tenant_id ON sales.sales_order_lines(tenant_id);
|
|
||||||
CREATE INDEX idx_sales_order_lines_order_id ON sales.sales_order_lines(order_id);
|
|
||||||
CREATE INDEX idx_sales_order_lines_product_id ON sales.sales_order_lines(product_id);
|
|
||||||
|
|
||||||
-- RLS para sales_order_lines
|
|
||||||
ALTER TABLE sales.sales_order_lines ENABLE ROW LEVEL SECURITY;
|
|
||||||
CREATE POLICY tenant_isolation_sales_order_lines ON sales.sales_order_lines
|
|
||||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
|
||||||
|
|
||||||
-- Tabla: quotations (Cotizaciones)
|
|
||||||
CREATE TABLE sales.quotations (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
company_id UUID NOT NULL REFERENCES auth.companies(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
-- Numeración
|
|
||||||
name VARCHAR(100) NOT NULL,
|
|
||||||
|
|
||||||
-- Cliente potencial
|
|
||||||
partner_id UUID NOT NULL REFERENCES core.partners(id),
|
|
||||||
|
|
||||||
-- Fechas
|
|
||||||
quotation_date DATE NOT NULL,
|
|
||||||
validity_date DATE NOT NULL,
|
|
||||||
|
|
||||||
-- Configuración
|
|
||||||
currency_id UUID NOT NULL REFERENCES core.currencies(id),
|
|
||||||
pricelist_id UUID REFERENCES sales.pricelists(id),
|
|
||||||
|
|
||||||
-- Usuario
|
|
||||||
user_id UUID REFERENCES auth.users(id),
|
|
||||||
sales_team_id UUID REFERENCES sales.sales_teams(id),
|
|
||||||
|
|
||||||
-- Montos
|
|
||||||
amount_untaxed DECIMAL(15, 2) NOT NULL DEFAULT 0,
|
|
||||||
amount_tax DECIMAL(15, 2) NOT NULL DEFAULT 0,
|
|
||||||
amount_total DECIMAL(15, 2) NOT NULL DEFAULT 0,
|
|
||||||
|
|
||||||
-- Estado
|
|
||||||
status sales.quotation_status NOT NULL DEFAULT 'draft',
|
|
||||||
|
|
||||||
-- Conversión
|
|
||||||
sale_order_id UUID REFERENCES sales.sales_orders(id), -- Orden generada
|
|
||||||
|
|
||||||
-- Notas
|
|
||||||
notes TEXT,
|
|
||||||
terms_conditions TEXT,
|
|
||||||
|
|
||||||
-- Firma electrónica
|
|
||||||
signature TEXT, -- base64
|
|
||||||
signature_date TIMESTAMP,
|
|
||||||
signature_ip INET,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT uq_quotations_name_company UNIQUE (company_id, name),
|
|
||||||
CONSTRAINT chk_quotations_validity CHECK (validity_date >= quotation_date)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: quotation_lines (Líneas de cotización)
|
|
||||||
CREATE TABLE sales.quotation_lines (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
quotation_id UUID NOT NULL REFERENCES sales.quotations(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
product_id UUID REFERENCES inventory.products(id),
|
|
||||||
description TEXT NOT NULL,
|
|
||||||
quantity DECIMAL(12, 4) NOT NULL,
|
|
||||||
uom_id UUID NOT NULL REFERENCES core.uom(id),
|
|
||||||
price_unit DECIMAL(15, 4) NOT NULL,
|
|
||||||
discount DECIMAL(5, 2) DEFAULT 0,
|
|
||||||
tax_ids UUID[] DEFAULT '{}',
|
|
||||||
amount_untaxed DECIMAL(15, 2) NOT NULL,
|
|
||||||
amount_tax DECIMAL(15, 2) NOT NULL,
|
|
||||||
amount_total DECIMAL(15, 2) NOT NULL,
|
|
||||||
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT chk_quotation_lines_quantity CHECK (quantity > 0),
|
|
||||||
CONSTRAINT chk_quotation_lines_discount CHECK (discount >= 0 AND discount <= 100)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Índices para quotation_lines
|
|
||||||
CREATE INDEX idx_quotation_lines_tenant_id ON sales.quotation_lines(tenant_id);
|
|
||||||
CREATE INDEX idx_quotation_lines_quotation_id ON sales.quotation_lines(quotation_id);
|
|
||||||
CREATE INDEX idx_quotation_lines_product_id ON sales.quotation_lines(product_id);
|
|
||||||
|
|
||||||
-- RLS para quotation_lines
|
|
||||||
ALTER TABLE sales.quotation_lines ENABLE ROW LEVEL SECURITY;
|
|
||||||
CREATE POLICY tenant_isolation_quotation_lines ON sales.quotation_lines
|
|
||||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
|
||||||
|
|
||||||
-- Tabla: pricelists (Listas de precios)
|
|
||||||
CREATE TABLE sales.pricelists (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
company_id UUID REFERENCES auth.companies(id),
|
|
||||||
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
currency_id UUID NOT NULL REFERENCES core.currencies(id),
|
|
||||||
|
|
||||||
-- Control
|
|
||||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT uq_pricelists_name_tenant UNIQUE (tenant_id, name)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: pricelist_items (Items de lista de precios)
|
|
||||||
CREATE TABLE sales.pricelist_items (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
pricelist_id UUID NOT NULL REFERENCES sales.pricelists(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
product_id UUID REFERENCES inventory.products(id),
|
|
||||||
product_category_id UUID REFERENCES core.product_categories(id),
|
|
||||||
|
|
||||||
-- Precio
|
|
||||||
price DECIMAL(15, 4) NOT NULL,
|
|
||||||
|
|
||||||
-- Cantidad mínima
|
|
||||||
min_quantity DECIMAL(12, 4) DEFAULT 1,
|
|
||||||
|
|
||||||
-- Validez
|
|
||||||
valid_from DATE,
|
|
||||||
valid_to DATE,
|
|
||||||
|
|
||||||
-- Control
|
|
||||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT chk_pricelist_items_price CHECK (price >= 0),
|
|
||||||
CONSTRAINT chk_pricelist_items_min_qty CHECK (min_quantity > 0),
|
|
||||||
CONSTRAINT chk_pricelist_items_dates CHECK (valid_to IS NULL OR valid_to >= valid_from),
|
|
||||||
CONSTRAINT chk_pricelist_items_product_or_category CHECK (
|
|
||||||
(product_id IS NOT NULL AND product_category_id IS NULL) OR
|
|
||||||
(product_id IS NULL AND product_category_id IS NOT NULL)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Índices para pricelist_items
|
|
||||||
CREATE INDEX idx_pricelist_items_tenant_id ON sales.pricelist_items(tenant_id);
|
|
||||||
CREATE INDEX idx_pricelist_items_pricelist_id ON sales.pricelist_items(pricelist_id);
|
|
||||||
CREATE INDEX idx_pricelist_items_product_id ON sales.pricelist_items(product_id);
|
|
||||||
|
|
||||||
-- RLS para pricelist_items
|
|
||||||
ALTER TABLE sales.pricelist_items ENABLE ROW LEVEL SECURITY;
|
|
||||||
CREATE POLICY tenant_isolation_pricelist_items ON sales.pricelist_items
|
|
||||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
|
||||||
|
|
||||||
-- Tabla: customer_groups (Grupos de clientes)
|
|
||||||
CREATE TABLE sales.customer_groups (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
discount_percentage DECIMAL(5, 2) DEFAULT 0,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT uq_customer_groups_name_tenant UNIQUE (tenant_id, name),
|
|
||||||
CONSTRAINT chk_customer_groups_discount CHECK (discount_percentage >= 0 AND discount_percentage <= 100)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: customer_group_members (Miembros de grupos)
|
|
||||||
CREATE TABLE sales.customer_group_members (
|
|
||||||
customer_group_id UUID NOT NULL REFERENCES sales.customer_groups(id) ON DELETE CASCADE,
|
|
||||||
partner_id UUID NOT NULL REFERENCES core.partners(id) ON DELETE CASCADE,
|
|
||||||
joined_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
PRIMARY KEY (customer_group_id, partner_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: sales_teams (Equipos de ventas)
|
|
||||||
CREATE TABLE sales.sales_teams (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
company_id UUID NOT NULL REFERENCES auth.companies(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
code VARCHAR(50),
|
|
||||||
team_leader_id UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
-- Objetivos
|
|
||||||
target_monthly DECIMAL(15, 2),
|
|
||||||
target_annual DECIMAL(15, 2),
|
|
||||||
|
|
||||||
-- Control
|
|
||||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT uq_sales_teams_code_company UNIQUE (company_id, code)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: sales_team_members (Miembros de equipos)
|
|
||||||
CREATE TABLE sales.sales_team_members (
|
|
||||||
sales_team_id UUID NOT NULL REFERENCES sales.sales_teams(id) ON DELETE CASCADE,
|
|
||||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
||||||
joined_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
PRIMARY KEY (sales_team_id, user_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- INDICES
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Sales Orders
|
|
||||||
CREATE INDEX idx_sales_orders_tenant_id ON sales.sales_orders(tenant_id);
|
|
||||||
CREATE INDEX idx_sales_orders_company_id ON sales.sales_orders(company_id);
|
|
||||||
CREATE INDEX idx_sales_orders_partner_id ON sales.sales_orders(partner_id);
|
|
||||||
CREATE INDEX idx_sales_orders_name ON sales.sales_orders(name);
|
|
||||||
CREATE INDEX idx_sales_orders_status ON sales.sales_orders(status);
|
|
||||||
CREATE INDEX idx_sales_orders_order_date ON sales.sales_orders(order_date);
|
|
||||||
CREATE INDEX idx_sales_orders_user_id ON sales.sales_orders(user_id);
|
|
||||||
CREATE INDEX idx_sales_orders_sales_team_id ON sales.sales_orders(sales_team_id);
|
|
||||||
|
|
||||||
-- Sales Order Lines
|
|
||||||
CREATE INDEX idx_sales_order_lines_order_id ON sales.sales_order_lines(order_id);
|
|
||||||
CREATE INDEX idx_sales_order_lines_product_id ON sales.sales_order_lines(product_id);
|
|
||||||
CREATE INDEX idx_sales_order_lines_analytic_account_id ON sales.sales_order_lines(analytic_account_id) WHERE analytic_account_id IS NOT NULL;
|
|
||||||
|
|
||||||
-- Quotations
|
|
||||||
CREATE INDEX idx_quotations_tenant_id ON sales.quotations(tenant_id);
|
|
||||||
CREATE INDEX idx_quotations_company_id ON sales.quotations(company_id);
|
|
||||||
CREATE INDEX idx_quotations_partner_id ON sales.quotations(partner_id);
|
|
||||||
CREATE INDEX idx_quotations_status ON sales.quotations(status);
|
|
||||||
CREATE INDEX idx_quotations_validity_date ON sales.quotations(validity_date);
|
|
||||||
|
|
||||||
-- Quotation Lines
|
|
||||||
CREATE INDEX idx_quotation_lines_quotation_id ON sales.quotation_lines(quotation_id);
|
|
||||||
CREATE INDEX idx_quotation_lines_product_id ON sales.quotation_lines(product_id);
|
|
||||||
|
|
||||||
-- Pricelists
|
|
||||||
CREATE INDEX idx_pricelists_tenant_id ON sales.pricelists(tenant_id);
|
|
||||||
CREATE INDEX idx_pricelists_active ON sales.pricelists(active) WHERE active = TRUE;
|
|
||||||
|
|
||||||
-- Pricelist Items
|
|
||||||
CREATE INDEX idx_pricelist_items_pricelist_id ON sales.pricelist_items(pricelist_id);
|
|
||||||
CREATE INDEX idx_pricelist_items_product_id ON sales.pricelist_items(product_id);
|
|
||||||
CREATE INDEX idx_pricelist_items_category_id ON sales.pricelist_items(product_category_id);
|
|
||||||
|
|
||||||
-- Customer Groups
|
|
||||||
CREATE INDEX idx_customer_groups_tenant_id ON sales.customer_groups(tenant_id);
|
|
||||||
|
|
||||||
-- Sales Teams
|
|
||||||
CREATE INDEX idx_sales_teams_tenant_id ON sales.sales_teams(tenant_id);
|
|
||||||
CREATE INDEX idx_sales_teams_company_id ON sales.sales_teams(company_id);
|
|
||||||
CREATE INDEX idx_sales_teams_leader_id ON sales.sales_teams(team_leader_id);
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- FUNCTIONS
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Función: calculate_sales_order_totals
|
|
||||||
CREATE OR REPLACE FUNCTION sales.calculate_sales_order_totals(p_order_id UUID)
|
|
||||||
RETURNS VOID AS $$
|
|
||||||
DECLARE
|
|
||||||
v_amount_untaxed DECIMAL;
|
|
||||||
v_amount_tax DECIMAL;
|
|
||||||
v_amount_total DECIMAL;
|
|
||||||
BEGIN
|
|
||||||
SELECT
|
|
||||||
COALESCE(SUM(amount_untaxed), 0),
|
|
||||||
COALESCE(SUM(amount_tax), 0),
|
|
||||||
COALESCE(SUM(amount_total), 0)
|
|
||||||
INTO v_amount_untaxed, v_amount_tax, v_amount_total
|
|
||||||
FROM sales.sales_order_lines
|
|
||||||
WHERE order_id = p_order_id;
|
|
||||||
|
|
||||||
UPDATE sales.sales_orders
|
|
||||||
SET amount_untaxed = v_amount_untaxed,
|
|
||||||
amount_tax = v_amount_tax,
|
|
||||||
amount_total = v_amount_total,
|
|
||||||
updated_at = CURRENT_TIMESTAMP,
|
|
||||||
updated_by = get_current_user_id()
|
|
||||||
WHERE id = p_order_id;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION sales.calculate_sales_order_totals IS 'Calcula los totales de una orden de venta';
|
|
||||||
|
|
||||||
-- Función: calculate_quotation_totals
|
|
||||||
CREATE OR REPLACE FUNCTION sales.calculate_quotation_totals(p_quotation_id UUID)
|
|
||||||
RETURNS VOID AS $$
|
|
||||||
DECLARE
|
|
||||||
v_amount_untaxed DECIMAL;
|
|
||||||
v_amount_tax DECIMAL;
|
|
||||||
v_amount_total DECIMAL;
|
|
||||||
BEGIN
|
|
||||||
SELECT
|
|
||||||
COALESCE(SUM(amount_untaxed), 0),
|
|
||||||
COALESCE(SUM(amount_tax), 0),
|
|
||||||
COALESCE(SUM(amount_total), 0)
|
|
||||||
INTO v_amount_untaxed, v_amount_tax, v_amount_total
|
|
||||||
FROM sales.quotation_lines
|
|
||||||
WHERE quotation_id = p_quotation_id;
|
|
||||||
|
|
||||||
UPDATE sales.quotations
|
|
||||||
SET amount_untaxed = v_amount_untaxed,
|
|
||||||
amount_tax = v_amount_tax,
|
|
||||||
amount_total = v_amount_total,
|
|
||||||
updated_at = CURRENT_TIMESTAMP,
|
|
||||||
updated_by = get_current_user_id()
|
|
||||||
WHERE id = p_quotation_id;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION sales.calculate_quotation_totals IS 'Calcula los totales de una cotización';
|
|
||||||
|
|
||||||
-- Función: convert_quotation_to_order
|
|
||||||
CREATE OR REPLACE FUNCTION sales.convert_quotation_to_order(p_quotation_id UUID)
|
|
||||||
RETURNS UUID AS $$
|
|
||||||
DECLARE
|
|
||||||
v_quotation RECORD;
|
|
||||||
v_order_id UUID;
|
|
||||||
BEGIN
|
|
||||||
-- Obtener cotización
|
|
||||||
SELECT * INTO v_quotation
|
|
||||||
FROM sales.quotations
|
|
||||||
WHERE id = p_quotation_id;
|
|
||||||
|
|
||||||
IF NOT FOUND THEN
|
|
||||||
RAISE EXCEPTION 'Quotation % not found', p_quotation_id;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF v_quotation.status != 'approved' THEN
|
|
||||||
RAISE EXCEPTION 'Quotation must be approved before conversion';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- Crear orden de venta
|
|
||||||
INSERT INTO sales.sales_orders (
|
|
||||||
tenant_id,
|
|
||||||
company_id,
|
|
||||||
name,
|
|
||||||
partner_id,
|
|
||||||
order_date,
|
|
||||||
currency_id,
|
|
||||||
pricelist_id,
|
|
||||||
user_id,
|
|
||||||
sales_team_id,
|
|
||||||
amount_untaxed,
|
|
||||||
amount_tax,
|
|
||||||
amount_total,
|
|
||||||
notes,
|
|
||||||
terms_conditions,
|
|
||||||
signature,
|
|
||||||
signature_date,
|
|
||||||
signature_ip
|
|
||||||
) VALUES (
|
|
||||||
v_quotation.tenant_id,
|
|
||||||
v_quotation.company_id,
|
|
||||||
REPLACE(v_quotation.name, 'QT', 'SO'),
|
|
||||||
v_quotation.partner_id,
|
|
||||||
CURRENT_DATE,
|
|
||||||
v_quotation.currency_id,
|
|
||||||
v_quotation.pricelist_id,
|
|
||||||
v_quotation.user_id,
|
|
||||||
v_quotation.sales_team_id,
|
|
||||||
v_quotation.amount_untaxed,
|
|
||||||
v_quotation.amount_tax,
|
|
||||||
v_quotation.amount_total,
|
|
||||||
v_quotation.notes,
|
|
||||||
v_quotation.terms_conditions,
|
|
||||||
v_quotation.signature,
|
|
||||||
v_quotation.signature_date,
|
|
||||||
v_quotation.signature_ip
|
|
||||||
) RETURNING id INTO v_order_id;
|
|
||||||
|
|
||||||
-- Copiar líneas
|
|
||||||
INSERT INTO sales.sales_order_lines (
|
|
||||||
order_id,
|
|
||||||
product_id,
|
|
||||||
description,
|
|
||||||
quantity,
|
|
||||||
uom_id,
|
|
||||||
price_unit,
|
|
||||||
discount,
|
|
||||||
tax_ids,
|
|
||||||
amount_untaxed,
|
|
||||||
amount_tax,
|
|
||||||
amount_total
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
v_order_id,
|
|
||||||
product_id,
|
|
||||||
description,
|
|
||||||
quantity,
|
|
||||||
uom_id,
|
|
||||||
price_unit,
|
|
||||||
discount,
|
|
||||||
tax_ids,
|
|
||||||
amount_untaxed,
|
|
||||||
amount_tax,
|
|
||||||
amount_total
|
|
||||||
FROM sales.quotation_lines
|
|
||||||
WHERE quotation_id = p_quotation_id;
|
|
||||||
|
|
||||||
-- Actualizar cotización
|
|
||||||
UPDATE sales.quotations
|
|
||||||
SET status = 'converted',
|
|
||||||
sale_order_id = v_order_id,
|
|
||||||
updated_at = CURRENT_TIMESTAMP,
|
|
||||||
updated_by = get_current_user_id()
|
|
||||||
WHERE id = p_quotation_id;
|
|
||||||
|
|
||||||
RETURN v_order_id;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION sales.convert_quotation_to_order IS 'Convierte una cotización aprobada en orden de venta';
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- TRIGGERS
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_sales_orders_updated_at
|
|
||||||
BEFORE UPDATE ON sales.sales_orders
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_quotations_updated_at
|
|
||||||
BEFORE UPDATE ON sales.quotations
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_pricelists_updated_at
|
|
||||||
BEFORE UPDATE ON sales.pricelists
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_sales_teams_updated_at
|
|
||||||
BEFORE UPDATE ON sales.sales_teams
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
-- Trigger: Actualizar totales de orden al cambiar líneas
|
|
||||||
CREATE OR REPLACE FUNCTION sales.trg_update_so_totals()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
IF TG_OP = 'DELETE' THEN
|
|
||||||
PERFORM sales.calculate_sales_order_totals(OLD.order_id);
|
|
||||||
ELSE
|
|
||||||
PERFORM sales.calculate_sales_order_totals(NEW.order_id);
|
|
||||||
END IF;
|
|
||||||
RETURN NULL;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_sales_order_lines_update_totals
|
|
||||||
AFTER INSERT OR UPDATE OR DELETE ON sales.sales_order_lines
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION sales.trg_update_so_totals();
|
|
||||||
|
|
||||||
-- Trigger: Actualizar totales de cotización al cambiar líneas
|
|
||||||
CREATE OR REPLACE FUNCTION sales.trg_update_quotation_totals()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
IF TG_OP = 'DELETE' THEN
|
|
||||||
PERFORM sales.calculate_quotation_totals(OLD.quotation_id);
|
|
||||||
ELSE
|
|
||||||
PERFORM sales.calculate_quotation_totals(NEW.quotation_id);
|
|
||||||
END IF;
|
|
||||||
RETURN NULL;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_quotation_lines_update_totals
|
|
||||||
AFTER INSERT OR UPDATE OR DELETE ON sales.quotation_lines
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION sales.trg_update_quotation_totals();
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- TRACKING AUTOMÁTICO (mail.thread pattern)
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Trigger: Tracking automático para órdenes de venta
|
|
||||||
CREATE TRIGGER track_sales_order_changes
|
|
||||||
AFTER INSERT OR UPDATE OR DELETE ON sales.sales_orders
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION system.track_field_changes();
|
|
||||||
|
|
||||||
COMMENT ON TRIGGER track_sales_order_changes ON sales.sales_orders IS
|
|
||||||
'Registra automáticamente cambios en órdenes de venta (estado, cliente, monto, fecha, facturación, entrega)';
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- ROW LEVEL SECURITY (RLS)
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
ALTER TABLE sales.sales_orders ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE sales.quotations ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE sales.pricelists ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE sales.customer_groups ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE sales.sales_teams ENABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_sales_orders ON sales.sales_orders
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_quotations ON sales.quotations
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_pricelists ON sales.pricelists
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_customer_groups ON sales.customer_groups
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_sales_teams ON sales.sales_teams
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- COMENTARIOS
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
COMMENT ON SCHEMA sales IS 'Schema de gestión de ventas, cotizaciones y clientes';
|
|
||||||
COMMENT ON TABLE sales.sales_orders IS 'Órdenes de venta confirmadas';
|
|
||||||
COMMENT ON TABLE sales.sales_order_lines IS 'Líneas de órdenes de venta';
|
|
||||||
COMMENT ON TABLE sales.quotations IS 'Cotizaciones enviadas a clientes';
|
|
||||||
COMMENT ON TABLE sales.quotation_lines IS 'Líneas de cotizaciones';
|
|
||||||
COMMENT ON TABLE sales.pricelists IS 'Listas de precios para clientes';
|
|
||||||
COMMENT ON TABLE sales.pricelist_items IS 'Items de listas de precios por producto/categoría';
|
|
||||||
COMMENT ON TABLE sales.customer_groups IS 'Grupos de clientes para descuentos y segmentación';
|
|
||||||
COMMENT ON TABLE sales.sales_teams IS 'Equipos de ventas con objetivos';
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- FIN DEL SCHEMA SALES
|
|
||||||
-- =====================================================
|
|
||||||
565
ddl/07-users-rbac.sql
Normal file
565
ddl/07-users-rbac.sql
Normal file
@ -0,0 +1,565 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- ARCHIVO: 07-users-rbac.sql
|
||||||
|
-- DESCRIPCION: Sistema RBAC (Roles, Permisos, Invitaciones)
|
||||||
|
-- VERSION: 1.0.0
|
||||||
|
-- PROYECTO: ERP-Core V2
|
||||||
|
-- FECHA: 2026-01-10
|
||||||
|
-- EPIC: SAAS-CORE-AUTH (EPIC-SAAS-001)
|
||||||
|
-- HISTORIAS: US-004, US-005, US-006, US-030
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- SCHEMA: users
|
||||||
|
-- =====================
|
||||||
|
CREATE SCHEMA IF NOT EXISTS users;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: users.roles
|
||||||
|
-- Roles del sistema con herencia (US-004)
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS users.roles (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Info basica
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
display_name VARCHAR(255),
|
||||||
|
description TEXT,
|
||||||
|
color VARCHAR(20),
|
||||||
|
icon VARCHAR(50),
|
||||||
|
|
||||||
|
-- Jerarquia
|
||||||
|
parent_role_id UUID REFERENCES users.roles(id) ON DELETE SET NULL,
|
||||||
|
hierarchy_level INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
-- Flags
|
||||||
|
is_system BOOLEAN DEFAULT FALSE, -- No editable por usuarios
|
||||||
|
is_default BOOLEAN DEFAULT FALSE, -- Asignado a nuevos usuarios
|
||||||
|
is_superadmin BOOLEAN DEFAULT FALSE, -- Acceso total
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by UUID REFERENCES auth.users(id),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_by UUID REFERENCES auth.users(id),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Constraint: nombre unico por tenant (o global si tenant_id es NULL)
|
||||||
|
UNIQUE NULLS NOT DISTINCT (tenant_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para roles
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_roles_tenant ON users.roles(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_roles_parent ON users.roles(parent_role_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_roles_system ON users.roles(is_system) WHERE is_system = TRUE;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_roles_default ON users.roles(tenant_id, is_default) WHERE is_default = TRUE;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: users.permissions
|
||||||
|
-- Permisos granulares del sistema (US-004)
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS users.permissions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
-- Identificacion
|
||||||
|
resource VARCHAR(100) NOT NULL, -- users, tenants, branches, invoices, etc.
|
||||||
|
action VARCHAR(50) NOT NULL, -- create, read, update, delete, export, etc.
|
||||||
|
scope VARCHAR(50) DEFAULT 'own', -- own, tenant, global
|
||||||
|
|
||||||
|
-- Info
|
||||||
|
display_name VARCHAR(255),
|
||||||
|
description TEXT,
|
||||||
|
category VARCHAR(100), -- auth, billing, inventory, sales, etc.
|
||||||
|
|
||||||
|
-- Flags
|
||||||
|
is_dangerous BOOLEAN DEFAULT FALSE, -- Requiere confirmacion adicional
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
UNIQUE(resource, action, scope)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para permissions
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_permissions_resource ON users.permissions(resource);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_permissions_category ON users.permissions(category);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: users.role_permissions
|
||||||
|
-- Asignacion de permisos a roles (US-004)
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS users.role_permissions (
|
||||||
|
role_id UUID NOT NULL REFERENCES users.roles(id) ON DELETE CASCADE,
|
||||||
|
permission_id UUID NOT NULL REFERENCES users.permissions(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Condiciones opcionales
|
||||||
|
conditions JSONB DEFAULT '{}', -- Condiciones adicionales (ej: solo ciertos estados)
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
granted_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
granted_by UUID REFERENCES auth.users(id),
|
||||||
|
|
||||||
|
PRIMARY KEY (role_id, permission_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para role_permissions
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_role_permissions_role ON users.role_permissions(role_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_role_permissions_permission ON users.role_permissions(permission_id);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: users.user_roles
|
||||||
|
-- Asignacion de roles a usuarios (US-004)
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS users.user_roles (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
role_id UUID NOT NULL REFERENCES users.roles(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Contexto
|
||||||
|
tenant_id UUID REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
branch_id UUID REFERENCES core.branches(id) ON DELETE CASCADE, -- Opcional: rol por sucursal
|
||||||
|
|
||||||
|
-- Flags
|
||||||
|
is_primary BOOLEAN DEFAULT FALSE,
|
||||||
|
|
||||||
|
-- Vigencia
|
||||||
|
valid_from TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
valid_until TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
assigned_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
assigned_by UUID REFERENCES auth.users(id),
|
||||||
|
revoked_at TIMESTAMPTZ,
|
||||||
|
revoked_by UUID REFERENCES auth.users(id),
|
||||||
|
|
||||||
|
-- Constraint: un usuario solo puede tener un rol una vez por branch (o global si branch es NULL)
|
||||||
|
UNIQUE (user_id, role_id, branch_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para user_roles
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_roles_user ON users.user_roles(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_roles_role ON users.user_roles(role_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_roles_tenant ON users.user_roles(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_roles_branch ON users.user_roles(branch_id) WHERE branch_id IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_roles_active ON users.user_roles(user_id, revoked_at) WHERE revoked_at IS NULL;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: users.invitations
|
||||||
|
-- Invitaciones de usuario (US-006)
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS users.invitations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Destinatario
|
||||||
|
email VARCHAR(255) NOT NULL,
|
||||||
|
first_name VARCHAR(100),
|
||||||
|
last_name VARCHAR(100),
|
||||||
|
|
||||||
|
-- Token de invitacion
|
||||||
|
token_hash VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
token_expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
|
||||||
|
-- Rol a asignar
|
||||||
|
role_id UUID REFERENCES users.roles(id) ON DELETE SET NULL,
|
||||||
|
branch_id UUID REFERENCES core.branches(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'expired', 'revoked')),
|
||||||
|
|
||||||
|
-- Mensaje personalizado
|
||||||
|
message TEXT,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
invited_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
invited_by UUID NOT NULL REFERENCES auth.users(id),
|
||||||
|
accepted_at TIMESTAMPTZ,
|
||||||
|
accepted_user_id UUID REFERENCES auth.users(id),
|
||||||
|
resent_at TIMESTAMPTZ,
|
||||||
|
resent_count INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
-- Constraint: email unico por tenant mientras este pendiente
|
||||||
|
CONSTRAINT unique_pending_invitation UNIQUE (tenant_id, email, status)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para invitations
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invitations_tenant ON users.invitations(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invitations_email ON users.invitations(email);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invitations_token ON users.invitations(token_hash);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invitations_status ON users.invitations(status) WHERE status = 'pending';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invitations_expires ON users.invitations(token_expires_at) WHERE status = 'pending';
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: users.tenant_settings
|
||||||
|
-- Configuraciones por tenant (US-005)
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS users.tenant_settings (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE UNIQUE,
|
||||||
|
|
||||||
|
-- Limites
|
||||||
|
max_users INTEGER DEFAULT 10,
|
||||||
|
max_branches INTEGER DEFAULT 5,
|
||||||
|
max_storage_mb INTEGER DEFAULT 1024,
|
||||||
|
|
||||||
|
-- Features habilitadas
|
||||||
|
features_enabled TEXT[] DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Configuracion de branding
|
||||||
|
branding JSONB DEFAULT '{
|
||||||
|
"logo_url": null,
|
||||||
|
"primary_color": "#2563eb",
|
||||||
|
"secondary_color": "#64748b"
|
||||||
|
}',
|
||||||
|
|
||||||
|
-- Configuracion regional
|
||||||
|
locale VARCHAR(10) DEFAULT 'es-MX',
|
||||||
|
timezone VARCHAR(50) DEFAULT 'America/Mexico_City',
|
||||||
|
currency VARCHAR(3) DEFAULT 'MXN',
|
||||||
|
date_format VARCHAR(20) DEFAULT 'DD/MM/YYYY',
|
||||||
|
|
||||||
|
-- Configuracion de seguridad
|
||||||
|
security_settings JSONB DEFAULT '{
|
||||||
|
"require_mfa": false,
|
||||||
|
"session_timeout_minutes": 480,
|
||||||
|
"password_min_length": 8,
|
||||||
|
"password_require_special": true
|
||||||
|
}',
|
||||||
|
|
||||||
|
-- Configuracion de notificaciones
|
||||||
|
notification_settings JSONB DEFAULT '{
|
||||||
|
"email_enabled": true,
|
||||||
|
"push_enabled": true,
|
||||||
|
"sms_enabled": false
|
||||||
|
}',
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: users.profile_role_mapping
|
||||||
|
-- Mapeo de perfiles ERP a roles RBAC (US-030)
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS users.profile_role_mapping (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
profile_code VARCHAR(10) NOT NULL, -- ADM, CNT, VNT, ALM, etc.
|
||||||
|
role_id UUID NOT NULL REFERENCES users.roles(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Flags
|
||||||
|
is_default BOOLEAN DEFAULT TRUE,
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
UNIQUE(profile_code, role_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indice para profile_role_mapping
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_profile_role_mapping_profile ON users.profile_role_mapping(profile_code);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- RLS POLICIES
|
||||||
|
-- =====================
|
||||||
|
ALTER TABLE users.roles ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_roles ON users.roles
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid OR tenant_id IS NULL);
|
||||||
|
|
||||||
|
ALTER TABLE users.role_permissions ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_role_permissions ON users.role_permissions
|
||||||
|
USING (role_id IN (
|
||||||
|
SELECT id FROM users.roles
|
||||||
|
WHERE tenant_id = current_setting('app.current_tenant_id', true)::uuid OR tenant_id IS NULL
|
||||||
|
));
|
||||||
|
|
||||||
|
ALTER TABLE users.user_roles ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_user_roles ON users.user_roles
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid OR tenant_id IS NULL);
|
||||||
|
|
||||||
|
ALTER TABLE users.invitations ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_invitations ON users.invitations
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
ALTER TABLE users.tenant_settings ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_settings ON users.tenant_settings
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- FUNCIONES
|
||||||
|
-- =====================
|
||||||
|
|
||||||
|
-- Funcion para verificar si un usuario tiene un permiso especifico
|
||||||
|
CREATE OR REPLACE FUNCTION users.has_permission(
|
||||||
|
p_user_id UUID,
|
||||||
|
p_resource VARCHAR(100),
|
||||||
|
p_action VARCHAR(50),
|
||||||
|
p_scope VARCHAR(50) DEFAULT 'own'
|
||||||
|
)
|
||||||
|
RETURNS BOOLEAN AS $$
|
||||||
|
DECLARE
|
||||||
|
has_perm BOOLEAN;
|
||||||
|
BEGIN
|
||||||
|
-- Verificar si es superadmin
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM auth.users
|
||||||
|
WHERE id = p_user_id AND is_superadmin = TRUE
|
||||||
|
) THEN
|
||||||
|
RETURN TRUE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Verificar permisos via roles
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM users.user_roles ur
|
||||||
|
JOIN users.role_permissions rp ON rp.role_id = ur.role_id
|
||||||
|
JOIN users.permissions p ON p.id = rp.permission_id
|
||||||
|
WHERE ur.user_id = p_user_id
|
||||||
|
AND ur.revoked_at IS NULL
|
||||||
|
AND (ur.valid_until IS NULL OR ur.valid_until > CURRENT_TIMESTAMP)
|
||||||
|
AND p.resource = p_resource
|
||||||
|
AND p.action = p_action
|
||||||
|
AND (p.scope = p_scope OR p.scope = 'global')
|
||||||
|
) INTO has_perm;
|
||||||
|
|
||||||
|
RETURN has_perm;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE;
|
||||||
|
|
||||||
|
-- Funcion para obtener todos los permisos de un usuario
|
||||||
|
CREATE OR REPLACE FUNCTION users.get_user_permissions(p_user_id UUID)
|
||||||
|
RETURNS TABLE (
|
||||||
|
resource VARCHAR(100),
|
||||||
|
action VARCHAR(50),
|
||||||
|
scope VARCHAR(50),
|
||||||
|
conditions JSONB
|
||||||
|
) AS $$
|
||||||
|
BEGIN
|
||||||
|
-- Si es superadmin, devolver wildcard
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM auth.users
|
||||||
|
WHERE id = p_user_id AND is_superadmin = TRUE
|
||||||
|
) THEN
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT '*'::VARCHAR(100), '*'::VARCHAR(50), 'global'::VARCHAR(50), '{}'::JSONB;
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT DISTINCT
|
||||||
|
p.resource,
|
||||||
|
p.action,
|
||||||
|
p.scope,
|
||||||
|
rp.conditions
|
||||||
|
FROM users.user_roles ur
|
||||||
|
JOIN users.role_permissions rp ON rp.role_id = ur.role_id
|
||||||
|
JOIN users.permissions p ON p.id = rp.permission_id
|
||||||
|
WHERE ur.user_id = p_user_id
|
||||||
|
AND ur.revoked_at IS NULL
|
||||||
|
AND (ur.valid_until IS NULL OR ur.valid_until > CURRENT_TIMESTAMP);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE;
|
||||||
|
|
||||||
|
-- Funcion para obtener permisos heredados de un rol
|
||||||
|
CREATE OR REPLACE FUNCTION users.get_role_permissions_with_inheritance(p_role_id UUID)
|
||||||
|
RETURNS TABLE (
|
||||||
|
permission_id UUID,
|
||||||
|
resource VARCHAR(100),
|
||||||
|
action VARCHAR(50),
|
||||||
|
scope VARCHAR(50),
|
||||||
|
inherited_from UUID
|
||||||
|
) AS $$
|
||||||
|
WITH RECURSIVE role_hierarchy AS (
|
||||||
|
-- Rol base
|
||||||
|
SELECT id, parent_role_id, 0 as level
|
||||||
|
FROM users.roles
|
||||||
|
WHERE id = p_role_id
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
-- Roles padre (herencia)
|
||||||
|
SELECT r.id, r.parent_role_id, rh.level + 1
|
||||||
|
FROM users.roles r
|
||||||
|
JOIN role_hierarchy rh ON r.id = rh.parent_role_id
|
||||||
|
WHERE rh.level < 10 -- Limite de profundidad
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
p.id as permission_id,
|
||||||
|
p.resource,
|
||||||
|
p.action,
|
||||||
|
p.scope,
|
||||||
|
rh.id as inherited_from
|
||||||
|
FROM role_hierarchy rh
|
||||||
|
JOIN users.role_permissions rp ON rp.role_id = rh.id
|
||||||
|
JOIN users.permissions p ON p.id = rp.permission_id;
|
||||||
|
$$ LANGUAGE sql STABLE;
|
||||||
|
|
||||||
|
-- Funcion para limpiar invitaciones expiradas
|
||||||
|
CREATE OR REPLACE FUNCTION users.cleanup_expired_invitations()
|
||||||
|
RETURNS INTEGER AS $$
|
||||||
|
DECLARE
|
||||||
|
updated_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
UPDATE users.invitations
|
||||||
|
SET status = 'expired'
|
||||||
|
WHERE status = 'pending'
|
||||||
|
AND token_expires_at < CURRENT_TIMESTAMP;
|
||||||
|
|
||||||
|
GET DIAGNOSTICS updated_count = ROW_COUNT;
|
||||||
|
RETURN updated_count;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TRIGGERS
|
||||||
|
-- =====================
|
||||||
|
|
||||||
|
-- Trigger para actualizar updated_at en roles
|
||||||
|
CREATE OR REPLACE FUNCTION users.update_role_timestamp()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_roles_updated_at
|
||||||
|
BEFORE UPDATE ON users.roles
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION users.update_role_timestamp();
|
||||||
|
|
||||||
|
-- Trigger para actualizar updated_at en tenant_settings
|
||||||
|
CREATE TRIGGER trg_tenant_settings_updated_at
|
||||||
|
BEFORE UPDATE ON users.tenant_settings
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION users.update_role_timestamp();
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- SEED DATA: Permisos Base
|
||||||
|
-- =====================
|
||||||
|
INSERT INTO users.permissions (resource, action, scope, display_name, description, category) VALUES
|
||||||
|
-- Auth
|
||||||
|
('users', 'create', 'tenant', 'Crear usuarios', 'Crear nuevos usuarios en el tenant', 'auth'),
|
||||||
|
('users', 'read', 'tenant', 'Ver usuarios', 'Ver lista de usuarios del tenant', 'auth'),
|
||||||
|
('users', 'read', 'own', 'Ver perfil propio', 'Ver su propio perfil', 'auth'),
|
||||||
|
('users', 'update', 'tenant', 'Editar usuarios', 'Editar cualquier usuario del tenant', 'auth'),
|
||||||
|
('users', 'update', 'own', 'Editar perfil propio', 'Editar su propio perfil', 'auth'),
|
||||||
|
('users', 'delete', 'tenant', 'Eliminar usuarios', 'Eliminar usuarios del tenant', 'auth'),
|
||||||
|
('roles', 'create', 'tenant', 'Crear roles', 'Crear nuevos roles', 'auth'),
|
||||||
|
('roles', 'read', 'tenant', 'Ver roles', 'Ver roles del tenant', 'auth'),
|
||||||
|
('roles', 'update', 'tenant', 'Editar roles', 'Editar roles existentes', 'auth'),
|
||||||
|
('roles', 'delete', 'tenant', 'Eliminar roles', 'Eliminar roles', 'auth'),
|
||||||
|
('invitations', 'create', 'tenant', 'Invitar usuarios', 'Enviar invitaciones', 'auth'),
|
||||||
|
('invitations', 'read', 'tenant', 'Ver invitaciones', 'Ver invitaciones pendientes', 'auth'),
|
||||||
|
('invitations', 'delete', 'tenant', 'Cancelar invitaciones', 'Revocar invitaciones', 'auth'),
|
||||||
|
|
||||||
|
-- Tenants
|
||||||
|
('tenants', 'read', 'own', 'Ver configuracion', 'Ver configuracion del tenant', 'tenants'),
|
||||||
|
('tenants', 'update', 'own', 'Editar configuracion', 'Editar configuracion del tenant', 'tenants'),
|
||||||
|
('tenant_settings', 'read', 'own', 'Ver ajustes', 'Ver ajustes del tenant', 'tenants'),
|
||||||
|
('tenant_settings', 'update', 'own', 'Editar ajustes', 'Editar ajustes del tenant', 'tenants'),
|
||||||
|
|
||||||
|
-- Branches
|
||||||
|
('branches', 'create', 'tenant', 'Crear sucursales', 'Crear nuevas sucursales', 'branches'),
|
||||||
|
('branches', 'read', 'tenant', 'Ver sucursales', 'Ver todas las sucursales', 'branches'),
|
||||||
|
('branches', 'read', 'own', 'Ver sucursal asignada', 'Ver solo su sucursal', 'branches'),
|
||||||
|
('branches', 'update', 'tenant', 'Editar sucursales', 'Editar cualquier sucursal', 'branches'),
|
||||||
|
('branches', 'delete', 'tenant', 'Eliminar sucursales', 'Eliminar sucursales', 'branches'),
|
||||||
|
|
||||||
|
-- Billing
|
||||||
|
('billing', 'read', 'tenant', 'Ver facturacion', 'Ver informacion de facturacion', 'billing'),
|
||||||
|
('billing', 'update', 'tenant', 'Gestionar facturacion', 'Cambiar plan, metodo de pago', 'billing'),
|
||||||
|
('invoices', 'read', 'tenant', 'Ver facturas', 'Ver historial de facturas', 'billing'),
|
||||||
|
('invoices', 'export', 'tenant', 'Exportar facturas', 'Descargar facturas', 'billing'),
|
||||||
|
|
||||||
|
-- Audit
|
||||||
|
('audit_logs', 'read', 'tenant', 'Ver auditoria', 'Ver logs de auditoria', 'audit'),
|
||||||
|
('audit_logs', 'export', 'tenant', 'Exportar auditoria', 'Exportar logs', 'audit'),
|
||||||
|
('activity', 'read', 'own', 'Ver mi actividad', 'Ver actividad propia', 'audit'),
|
||||||
|
|
||||||
|
-- Notifications
|
||||||
|
('notifications', 'read', 'own', 'Ver notificaciones', 'Ver notificaciones propias', 'notifications'),
|
||||||
|
('notifications', 'update', 'own', 'Gestionar notificaciones', 'Marcar como leidas', 'notifications'),
|
||||||
|
('notification_settings', 'read', 'own', 'Ver preferencias', 'Ver preferencias de notificacion', 'notifications'),
|
||||||
|
('notification_settings', 'update', 'own', 'Editar preferencias', 'Editar preferencias', 'notifications')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- SEED DATA: Roles Base del Sistema
|
||||||
|
-- =====================
|
||||||
|
INSERT INTO users.roles (id, tenant_id, name, display_name, description, is_system, is_superadmin, hierarchy_level, icon, color) VALUES
|
||||||
|
('10000000-0000-0000-0000-000000000001', NULL, 'superadmin', 'Super Administrador', 'Acceso total a la plataforma', TRUE, TRUE, 0, 'shield-check', '#dc2626'),
|
||||||
|
('10000000-0000-0000-0000-000000000002', NULL, 'admin', 'Administrador', 'Administrador del tenant', TRUE, FALSE, 1, 'shield', '#ea580c'),
|
||||||
|
('10000000-0000-0000-0000-000000000003', NULL, 'manager', 'Gerente', 'Gerente con acceso a reportes', TRUE, FALSE, 2, 'briefcase', '#0891b2'),
|
||||||
|
('10000000-0000-0000-0000-000000000004', NULL, 'user', 'Usuario', 'Usuario estandar', TRUE, FALSE, 3, 'user', '#64748b'),
|
||||||
|
('10000000-0000-0000-0000-000000000005', NULL, 'viewer', 'Visor', 'Solo lectura', TRUE, FALSE, 4, 'eye', '#94a3b8')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Asignar permisos al rol Admin
|
||||||
|
INSERT INTO users.role_permissions (role_id, permission_id)
|
||||||
|
SELECT '10000000-0000-0000-0000-000000000002', id
|
||||||
|
FROM users.permissions
|
||||||
|
WHERE resource NOT IN ('audit_logs') -- Admin no tiene acceso a audit
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Asignar permisos al rol Manager
|
||||||
|
INSERT INTO users.role_permissions (role_id, permission_id)
|
||||||
|
SELECT '10000000-0000-0000-0000-000000000003', id
|
||||||
|
FROM users.permissions
|
||||||
|
WHERE scope = 'own'
|
||||||
|
OR (resource IN ('branches', 'users', 'invoices') AND action = 'read')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Asignar permisos al rol User
|
||||||
|
INSERT INTO users.role_permissions (role_id, permission_id)
|
||||||
|
SELECT '10000000-0000-0000-0000-000000000004', id
|
||||||
|
FROM users.permissions
|
||||||
|
WHERE scope = 'own'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Asignar permisos al rol Viewer
|
||||||
|
INSERT INTO users.role_permissions (role_id, permission_id)
|
||||||
|
SELECT '10000000-0000-0000-0000-000000000005', id
|
||||||
|
FROM users.permissions
|
||||||
|
WHERE action = 'read' AND scope = 'own'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- SEED DATA: Mapeo de Perfiles ERP a Roles (US-030)
|
||||||
|
-- =====================
|
||||||
|
INSERT INTO users.profile_role_mapping (profile_code, role_id) VALUES
|
||||||
|
('ADM', '10000000-0000-0000-0000-000000000002'), -- Admin
|
||||||
|
('CNT', '10000000-0000-0000-0000-000000000003'), -- Manager
|
||||||
|
('VNT', '10000000-0000-0000-0000-000000000004'), -- User
|
||||||
|
('CMP', '10000000-0000-0000-0000-000000000004'), -- User
|
||||||
|
('ALM', '10000000-0000-0000-0000-000000000004'), -- User
|
||||||
|
('HRH', '10000000-0000-0000-0000-000000000003'), -- Manager
|
||||||
|
('PRD', '10000000-0000-0000-0000-000000000004'), -- User
|
||||||
|
('EMP', '10000000-0000-0000-0000-000000000005'), -- Viewer
|
||||||
|
('GER', '10000000-0000-0000-0000-000000000003'), -- Manager
|
||||||
|
('AUD', '10000000-0000-0000-0000-000000000005') -- Viewer (read-only)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- COMENTARIOS
|
||||||
|
-- =====================
|
||||||
|
COMMENT ON TABLE users.roles IS 'Roles del sistema con soporte para herencia';
|
||||||
|
COMMENT ON TABLE users.permissions IS 'Permisos granulares (resource.action.scope)';
|
||||||
|
COMMENT ON TABLE users.role_permissions IS 'Asignacion de permisos a roles';
|
||||||
|
COMMENT ON TABLE users.user_roles IS 'Asignacion de roles a usuarios';
|
||||||
|
COMMENT ON TABLE users.invitations IS 'Invitaciones para nuevos usuarios';
|
||||||
|
COMMENT ON TABLE users.tenant_settings IS 'Configuraciones personalizadas por tenant';
|
||||||
|
COMMENT ON TABLE users.profile_role_mapping IS 'Mapeo de perfiles ERP a roles RBAC';
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION users.has_permission IS 'Verifica si un usuario tiene un permiso especifico';
|
||||||
|
COMMENT ON FUNCTION users.get_user_permissions IS 'Obtiene todos los permisos de un usuario';
|
||||||
|
COMMENT ON FUNCTION users.get_role_permissions_with_inheritance IS 'Obtiene permisos de un rol incluyendo herencia';
|
||||||
544
ddl/08-plans.sql
Normal file
544
ddl/08-plans.sql
Normal file
@ -0,0 +1,544 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- ARCHIVO: 08-plans.sql
|
||||||
|
-- DESCRIPCION: Extensiones de planes SaaS (features, limits, Stripe)
|
||||||
|
-- VERSION: 1.0.0
|
||||||
|
-- PROYECTO: ERP-Core V2
|
||||||
|
-- FECHA: 2026-01-10
|
||||||
|
-- EPIC: SAAS-BILLING (EPIC-SAAS-002)
|
||||||
|
-- HISTORIAS: US-007, US-010, US-011, US-012
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- MODIFICACIONES A TABLAS EXISTENTES
|
||||||
|
-- =====================
|
||||||
|
|
||||||
|
-- Agregar columnas Stripe a tenant_subscriptions (US-007, US-008)
|
||||||
|
ALTER TABLE billing.tenant_subscriptions
|
||||||
|
ADD COLUMN IF NOT EXISTS stripe_customer_id VARCHAR(255),
|
||||||
|
ADD COLUMN IF NOT EXISTS stripe_subscription_id VARCHAR(255),
|
||||||
|
ADD COLUMN IF NOT EXISTS stripe_payment_method_id VARCHAR(255),
|
||||||
|
ADD COLUMN IF NOT EXISTS stripe_price_id VARCHAR(255),
|
||||||
|
ADD COLUMN IF NOT EXISTS trial_ends_at TIMESTAMPTZ,
|
||||||
|
ADD COLUMN IF NOT EXISTS canceled_at TIMESTAMPTZ,
|
||||||
|
ADD COLUMN IF NOT EXISTS cancel_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
-- Agregar columnas Stripe a subscription_plans
|
||||||
|
ALTER TABLE billing.subscription_plans
|
||||||
|
ADD COLUMN IF NOT EXISTS stripe_product_id VARCHAR(255),
|
||||||
|
ADD COLUMN IF NOT EXISTS stripe_price_id_monthly VARCHAR(255),
|
||||||
|
ADD COLUMN IF NOT EXISTS stripe_price_id_annual VARCHAR(255);
|
||||||
|
|
||||||
|
-- Agregar columnas Stripe a payment_methods
|
||||||
|
ALTER TABLE billing.payment_methods
|
||||||
|
ADD COLUMN IF NOT EXISTS stripe_payment_method_id VARCHAR(255);
|
||||||
|
|
||||||
|
-- Indices para campos Stripe
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_subscriptions_stripe_customer ON billing.tenant_subscriptions(stripe_customer_id) WHERE stripe_customer_id IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_subscriptions_stripe_sub ON billing.tenant_subscriptions(stripe_subscription_id) WHERE stripe_subscription_id IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_plans_stripe_product ON billing.subscription_plans(stripe_product_id) WHERE stripe_product_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: billing.plan_features
|
||||||
|
-- Features habilitadas por plan (US-010, US-011)
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS billing.plan_features (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
plan_id UUID NOT NULL REFERENCES billing.subscription_plans(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Identificacion
|
||||||
|
feature_key VARCHAR(100) NOT NULL,
|
||||||
|
feature_name VARCHAR(255) NOT NULL,
|
||||||
|
category VARCHAR(100),
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
enabled BOOLEAN DEFAULT TRUE,
|
||||||
|
|
||||||
|
-- Configuracion
|
||||||
|
configuration JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
description TEXT,
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
UNIQUE(plan_id, feature_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para plan_features
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_plan_features_plan ON billing.plan_features(plan_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_plan_features_key ON billing.plan_features(feature_key);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_plan_features_enabled ON billing.plan_features(plan_id, enabled) WHERE enabled = TRUE;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: billing.plan_limits
|
||||||
|
-- Limites cuantificables por plan (US-010, US-012)
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS billing.plan_limits (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
plan_id UUID NOT NULL REFERENCES billing.subscription_plans(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Identificacion
|
||||||
|
limit_key VARCHAR(100) NOT NULL,
|
||||||
|
limit_name VARCHAR(255) NOT NULL,
|
||||||
|
|
||||||
|
-- Valor
|
||||||
|
limit_value INTEGER NOT NULL,
|
||||||
|
limit_type VARCHAR(50) DEFAULT 'monthly', -- monthly, daily, total, per_user
|
||||||
|
|
||||||
|
-- Overage (si se permite exceder)
|
||||||
|
allow_overage BOOLEAN DEFAULT FALSE,
|
||||||
|
overage_unit_price DECIMAL(10,4) DEFAULT 0,
|
||||||
|
overage_currency VARCHAR(3) DEFAULT 'MXN',
|
||||||
|
|
||||||
|
-- Alertas
|
||||||
|
alert_threshold_percent INTEGER DEFAULT 80,
|
||||||
|
hard_limit BOOLEAN DEFAULT TRUE, -- Si true, bloquea; si false, solo alerta
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
description TEXT,
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
UNIQUE(plan_id, limit_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para plan_limits
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_plan_limits_plan ON billing.plan_limits(plan_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_plan_limits_key ON billing.plan_limits(limit_key);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: billing.coupons
|
||||||
|
-- Cupones de descuento
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS billing.coupons (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
-- Identificacion
|
||||||
|
code VARCHAR(50) NOT NULL UNIQUE,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
|
||||||
|
-- Descuento
|
||||||
|
discount_type VARCHAR(20) NOT NULL CHECK (discount_type IN ('percentage', 'fixed')),
|
||||||
|
discount_value DECIMAL(10,2) NOT NULL,
|
||||||
|
currency VARCHAR(3) DEFAULT 'MXN',
|
||||||
|
|
||||||
|
-- Aplicabilidad
|
||||||
|
applicable_plans UUID[] DEFAULT '{}', -- Vacio = todos
|
||||||
|
min_amount DECIMAL(10,2) DEFAULT 0,
|
||||||
|
|
||||||
|
-- Limites
|
||||||
|
max_redemptions INTEGER,
|
||||||
|
times_redeemed INTEGER DEFAULT 0,
|
||||||
|
max_redemptions_per_customer INTEGER DEFAULT 1,
|
||||||
|
|
||||||
|
-- Vigencia
|
||||||
|
valid_from TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
valid_until TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Duracion del descuento
|
||||||
|
duration VARCHAR(20) DEFAULT 'once', -- once, forever, repeating
|
||||||
|
duration_months INTEGER, -- Si duration = repeating
|
||||||
|
|
||||||
|
-- Stripe
|
||||||
|
stripe_coupon_id VARCHAR(255),
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para coupons
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_coupons_code ON billing.coupons(code);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_coupons_active ON billing.coupons(is_active, valid_until);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: billing.coupon_redemptions
|
||||||
|
-- Uso de cupones
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS billing.coupon_redemptions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
coupon_id UUID NOT NULL REFERENCES billing.coupons(id),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id),
|
||||||
|
subscription_id UUID REFERENCES billing.tenant_subscriptions(id),
|
||||||
|
|
||||||
|
-- Descuento aplicado
|
||||||
|
discount_amount DECIMAL(10,2) NOT NULL,
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
redeemed_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
UNIQUE(coupon_id, tenant_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para coupon_redemptions
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_coupon_redemptions_coupon ON billing.coupon_redemptions(coupon_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_coupon_redemptions_tenant ON billing.coupon_redemptions(tenant_id);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: billing.stripe_events
|
||||||
|
-- Log de eventos de Stripe (US-008)
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS billing.stripe_events (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
-- Evento de Stripe
|
||||||
|
stripe_event_id VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
event_type VARCHAR(100) NOT NULL,
|
||||||
|
api_version VARCHAR(20),
|
||||||
|
|
||||||
|
-- Datos
|
||||||
|
data JSONB NOT NULL,
|
||||||
|
|
||||||
|
-- Procesamiento
|
||||||
|
processed BOOLEAN DEFAULT FALSE,
|
||||||
|
processed_at TIMESTAMPTZ,
|
||||||
|
error_message TEXT,
|
||||||
|
retry_count INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
-- Tenant relacionado (si aplica)
|
||||||
|
tenant_id UUID REFERENCES auth.tenants(id),
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para stripe_events
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_stripe_events_type ON billing.stripe_events(event_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_stripe_events_processed ON billing.stripe_events(processed) WHERE processed = FALSE;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_stripe_events_tenant ON billing.stripe_events(tenant_id);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- RLS POLICIES
|
||||||
|
-- =====================
|
||||||
|
ALTER TABLE billing.plan_features ENABLE ROW LEVEL SECURITY;
|
||||||
|
-- Plan features son globales, no requieren isolation
|
||||||
|
CREATE POLICY public_read_plan_features ON billing.plan_features
|
||||||
|
FOR SELECT USING (true);
|
||||||
|
|
||||||
|
ALTER TABLE billing.plan_limits ENABLE ROW LEVEL SECURITY;
|
||||||
|
-- Plan limits son globales, no requieren isolation
|
||||||
|
CREATE POLICY public_read_plan_limits ON billing.plan_limits
|
||||||
|
FOR SELECT USING (true);
|
||||||
|
|
||||||
|
ALTER TABLE billing.coupons ENABLE ROW LEVEL SECURITY;
|
||||||
|
-- Cupones son globales pero solo admins pueden modificar
|
||||||
|
CREATE POLICY public_read_coupons ON billing.coupons
|
||||||
|
FOR SELECT USING (is_active = TRUE);
|
||||||
|
|
||||||
|
ALTER TABLE billing.coupon_redemptions ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_coupon_redemptions ON billing.coupon_redemptions
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
ALTER TABLE billing.stripe_events ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_stripe_events ON billing.stripe_events
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid OR tenant_id IS NULL);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- FUNCIONES
|
||||||
|
-- =====================
|
||||||
|
|
||||||
|
-- Funcion para verificar si un tenant tiene una feature habilitada
|
||||||
|
CREATE OR REPLACE FUNCTION billing.has_feature(
|
||||||
|
p_tenant_id UUID,
|
||||||
|
p_feature_key VARCHAR(100)
|
||||||
|
)
|
||||||
|
RETURNS BOOLEAN AS $$
|
||||||
|
DECLARE
|
||||||
|
v_enabled BOOLEAN;
|
||||||
|
BEGIN
|
||||||
|
SELECT pf.enabled INTO v_enabled
|
||||||
|
FROM billing.tenant_subscriptions ts
|
||||||
|
JOIN billing.plan_features pf ON pf.plan_id = ts.plan_id
|
||||||
|
WHERE ts.tenant_id = p_tenant_id
|
||||||
|
AND ts.status = 'active'
|
||||||
|
AND pf.feature_key = p_feature_key;
|
||||||
|
|
||||||
|
RETURN COALESCE(v_enabled, FALSE);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE;
|
||||||
|
|
||||||
|
-- Funcion para obtener limite de un tenant
|
||||||
|
CREATE OR REPLACE FUNCTION billing.get_limit(
|
||||||
|
p_tenant_id UUID,
|
||||||
|
p_limit_key VARCHAR(100)
|
||||||
|
)
|
||||||
|
RETURNS INTEGER AS $$
|
||||||
|
DECLARE
|
||||||
|
v_limit INTEGER;
|
||||||
|
BEGIN
|
||||||
|
SELECT pl.limit_value INTO v_limit
|
||||||
|
FROM billing.tenant_subscriptions ts
|
||||||
|
JOIN billing.plan_limits pl ON pl.plan_id = ts.plan_id
|
||||||
|
WHERE ts.tenant_id = p_tenant_id
|
||||||
|
AND ts.status = 'active'
|
||||||
|
AND pl.limit_key = p_limit_key;
|
||||||
|
|
||||||
|
RETURN COALESCE(v_limit, 0);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE;
|
||||||
|
|
||||||
|
-- Funcion para verificar si se puede usar una feature (con limite)
|
||||||
|
CREATE OR REPLACE FUNCTION billing.can_use_feature(
|
||||||
|
p_tenant_id UUID,
|
||||||
|
p_limit_key VARCHAR(100),
|
||||||
|
p_current_usage INTEGER DEFAULT 0
|
||||||
|
)
|
||||||
|
RETURNS TABLE (
|
||||||
|
allowed BOOLEAN,
|
||||||
|
limit_value INTEGER,
|
||||||
|
current_usage INTEGER,
|
||||||
|
remaining INTEGER,
|
||||||
|
allow_overage BOOLEAN,
|
||||||
|
overage_price DECIMAL
|
||||||
|
) AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN pl.hard_limit THEN p_current_usage < pl.limit_value
|
||||||
|
ELSE TRUE
|
||||||
|
END as allowed,
|
||||||
|
pl.limit_value,
|
||||||
|
p_current_usage as current_usage,
|
||||||
|
GREATEST(0, pl.limit_value - p_current_usage) as remaining,
|
||||||
|
pl.allow_overage,
|
||||||
|
pl.overage_unit_price
|
||||||
|
FROM billing.tenant_subscriptions ts
|
||||||
|
JOIN billing.plan_limits pl ON pl.plan_id = ts.plan_id
|
||||||
|
WHERE ts.tenant_id = p_tenant_id
|
||||||
|
AND ts.status = 'active'
|
||||||
|
AND pl.limit_key = p_limit_key;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE;
|
||||||
|
|
||||||
|
-- Funcion para obtener todas las features de un tenant
|
||||||
|
CREATE OR REPLACE FUNCTION billing.get_tenant_features(p_tenant_id UUID)
|
||||||
|
RETURNS TABLE (
|
||||||
|
feature_key VARCHAR(100),
|
||||||
|
feature_name VARCHAR(255),
|
||||||
|
enabled BOOLEAN,
|
||||||
|
configuration JSONB
|
||||||
|
) AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
pf.feature_key,
|
||||||
|
pf.feature_name,
|
||||||
|
pf.enabled,
|
||||||
|
pf.configuration
|
||||||
|
FROM billing.tenant_subscriptions ts
|
||||||
|
JOIN billing.plan_features pf ON pf.plan_id = ts.plan_id
|
||||||
|
WHERE ts.tenant_id = p_tenant_id
|
||||||
|
AND ts.status IN ('active', 'trial');
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE;
|
||||||
|
|
||||||
|
-- Funcion para obtener todos los limites de un tenant
|
||||||
|
CREATE OR REPLACE FUNCTION billing.get_tenant_limits(p_tenant_id UUID)
|
||||||
|
RETURNS TABLE (
|
||||||
|
limit_key VARCHAR(100),
|
||||||
|
limit_name VARCHAR(255),
|
||||||
|
limit_value INTEGER,
|
||||||
|
limit_type VARCHAR(50),
|
||||||
|
allow_overage BOOLEAN,
|
||||||
|
overage_unit_price DECIMAL
|
||||||
|
) AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
pl.limit_key,
|
||||||
|
pl.limit_name,
|
||||||
|
pl.limit_value,
|
||||||
|
pl.limit_type,
|
||||||
|
pl.allow_overage,
|
||||||
|
pl.overage_unit_price
|
||||||
|
FROM billing.tenant_subscriptions ts
|
||||||
|
JOIN billing.plan_limits pl ON pl.plan_id = ts.plan_id
|
||||||
|
WHERE ts.tenant_id = p_tenant_id
|
||||||
|
AND ts.status IN ('active', 'trial');
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE;
|
||||||
|
|
||||||
|
-- Funcion para aplicar cupon
|
||||||
|
CREATE OR REPLACE FUNCTION billing.apply_coupon(
|
||||||
|
p_tenant_id UUID,
|
||||||
|
p_coupon_code VARCHAR(50)
|
||||||
|
)
|
||||||
|
RETURNS TABLE (
|
||||||
|
success BOOLEAN,
|
||||||
|
message TEXT,
|
||||||
|
discount_amount DECIMAL
|
||||||
|
) AS $$
|
||||||
|
DECLARE
|
||||||
|
v_coupon RECORD;
|
||||||
|
v_subscription RECORD;
|
||||||
|
v_discount DECIMAL;
|
||||||
|
BEGIN
|
||||||
|
-- Obtener cupon
|
||||||
|
SELECT * INTO v_coupon
|
||||||
|
FROM billing.coupons
|
||||||
|
WHERE code = p_coupon_code
|
||||||
|
AND is_active = TRUE
|
||||||
|
AND (valid_until IS NULL OR valid_until > CURRENT_TIMESTAMP);
|
||||||
|
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RETURN QUERY SELECT FALSE, 'Cupon no valido o expirado'::TEXT, 0::DECIMAL;
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Verificar max redemptions
|
||||||
|
IF v_coupon.max_redemptions IS NOT NULL AND v_coupon.times_redeemed >= v_coupon.max_redemptions THEN
|
||||||
|
RETURN QUERY SELECT FALSE, 'Cupon agotado'::TEXT, 0::DECIMAL;
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Verificar si ya fue usado por este tenant
|
||||||
|
IF EXISTS (SELECT 1 FROM billing.coupon_redemptions WHERE coupon_id = v_coupon.id AND tenant_id = p_tenant_id) THEN
|
||||||
|
RETURN QUERY SELECT FALSE, 'Cupon ya utilizado'::TEXT, 0::DECIMAL;
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Obtener suscripcion
|
||||||
|
SELECT * INTO v_subscription
|
||||||
|
FROM billing.tenant_subscriptions
|
||||||
|
WHERE tenant_id = p_tenant_id
|
||||||
|
AND status = 'active';
|
||||||
|
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RETURN QUERY SELECT FALSE, 'No hay suscripcion activa'::TEXT, 0::DECIMAL;
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Calcular descuento
|
||||||
|
IF v_coupon.discount_type = 'percentage' THEN
|
||||||
|
v_discount := v_subscription.current_price * (v_coupon.discount_value / 100);
|
||||||
|
ELSE
|
||||||
|
v_discount := v_coupon.discount_value;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Registrar uso
|
||||||
|
INSERT INTO billing.coupon_redemptions (coupon_id, tenant_id, subscription_id, discount_amount)
|
||||||
|
VALUES (v_coupon.id, p_tenant_id, v_subscription.id, v_discount);
|
||||||
|
|
||||||
|
-- Actualizar contador
|
||||||
|
UPDATE billing.coupons SET times_redeemed = times_redeemed + 1 WHERE id = v_coupon.id;
|
||||||
|
|
||||||
|
RETURN QUERY SELECT TRUE, 'Cupon aplicado correctamente'::TEXT, v_discount;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TRIGGERS
|
||||||
|
-- =====================
|
||||||
|
|
||||||
|
-- Trigger para updated_at en plan_features
|
||||||
|
CREATE OR REPLACE FUNCTION billing.update_plan_features_timestamp()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_plan_features_updated_at
|
||||||
|
BEFORE UPDATE ON billing.plan_features
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION billing.update_plan_features_timestamp();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_plan_limits_updated_at
|
||||||
|
BEFORE UPDATE ON billing.plan_limits
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION billing.update_plan_features_timestamp();
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- SEED DATA: Features por Plan
|
||||||
|
-- =====================
|
||||||
|
|
||||||
|
-- Features para plan Starter
|
||||||
|
INSERT INTO billing.plan_features (plan_id, feature_key, feature_name, category, enabled) VALUES
|
||||||
|
((SELECT id FROM billing.subscription_plans WHERE code = 'starter'), 'pos', 'Punto de Venta', 'sales', TRUE),
|
||||||
|
((SELECT id FROM billing.subscription_plans WHERE code = 'starter'), 'inventory_basic', 'Inventario Basico', 'inventory', TRUE),
|
||||||
|
((SELECT id FROM billing.subscription_plans WHERE code = 'starter'), 'reports_basic', 'Reportes Basicos', 'reports', TRUE),
|
||||||
|
((SELECT id FROM billing.subscription_plans WHERE code = 'starter'), 'email_support', 'Soporte por Email', 'support', TRUE)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Features para plan Professional
|
||||||
|
INSERT INTO billing.plan_features (plan_id, feature_key, feature_name, category, enabled) VALUES
|
||||||
|
((SELECT id FROM billing.subscription_plans WHERE code = 'professional'), 'pos', 'Punto de Venta', 'sales', TRUE),
|
||||||
|
((SELECT id FROM billing.subscription_plans WHERE code = 'professional'), 'mobile_app', 'App Movil', 'platform', TRUE),
|
||||||
|
((SELECT id FROM billing.subscription_plans WHERE code = 'professional'), 'inventory_advanced', 'Inventario Avanzado', 'inventory', TRUE),
|
||||||
|
((SELECT id FROM billing.subscription_plans WHERE code = 'professional'), 'reports_advanced', 'Reportes Avanzados', 'reports', TRUE),
|
||||||
|
((SELECT id FROM billing.subscription_plans WHERE code = 'professional'), 'multi_branch', 'Multi-Sucursal', 'core', TRUE),
|
||||||
|
((SELECT id FROM billing.subscription_plans WHERE code = 'professional'), 'api_access', 'Acceso a API', 'integration', TRUE),
|
||||||
|
((SELECT id FROM billing.subscription_plans WHERE code = 'professional'), 'chat_support', 'Soporte por Chat', 'support', TRUE)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Features para plan Business
|
||||||
|
INSERT INTO billing.plan_features (plan_id, feature_key, feature_name, category, enabled) VALUES
|
||||||
|
((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'pos', 'Punto de Venta', 'sales', TRUE),
|
||||||
|
((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'mobile_app', 'App Movil', 'platform', TRUE),
|
||||||
|
((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'desktop_app', 'App Desktop', 'platform', TRUE),
|
||||||
|
((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'inventory_advanced', 'Inventario Avanzado', 'inventory', TRUE),
|
||||||
|
((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'manufacturing', 'Manufactura', 'production', TRUE),
|
||||||
|
((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'hr_module', 'Modulo RRHH', 'hr', TRUE),
|
||||||
|
((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'accounting', 'Contabilidad', 'financial', TRUE),
|
||||||
|
((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'ai_assistant', 'Asistente IA', 'ai', TRUE),
|
||||||
|
((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'webhooks', 'Webhooks', 'integration', TRUE),
|
||||||
|
((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'priority_support', 'Soporte Prioritario', 'support', TRUE),
|
||||||
|
((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'sla_99', 'SLA 99.9%', 'support', TRUE)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- SEED DATA: Limits por Plan
|
||||||
|
-- =====================
|
||||||
|
|
||||||
|
-- Limits para plan Starter
|
||||||
|
INSERT INTO billing.plan_limits (plan_id, limit_key, limit_name, limit_value, limit_type, allow_overage) VALUES
|
||||||
|
((SELECT id FROM billing.subscription_plans WHERE code = 'starter'), 'users', 'Usuarios', 3, 'total', FALSE),
|
||||||
|
((SELECT id FROM billing.subscription_plans WHERE code = 'starter'), 'branches', 'Sucursales', 1, 'total', FALSE),
|
||||||
|
((SELECT id FROM billing.subscription_plans WHERE code = 'starter'), 'storage_mb', 'Storage (MB)', 5120, 'total', TRUE),
|
||||||
|
((SELECT id FROM billing.subscription_plans WHERE code = 'starter'), 'api_calls', 'API Calls', 5000, 'monthly', FALSE),
|
||||||
|
((SELECT id FROM billing.subscription_plans WHERE code = 'starter'), 'invoices', 'Facturas', 100, 'monthly', TRUE)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Limits para plan Professional
|
||||||
|
INSERT INTO billing.plan_limits (plan_id, limit_key, limit_name, limit_value, limit_type, allow_overage, overage_unit_price) VALUES
|
||||||
|
((SELECT id FROM billing.subscription_plans WHERE code = 'professional'), 'users', 'Usuarios', 10, 'total', TRUE, 99),
|
||||||
|
((SELECT id FROM billing.subscription_plans WHERE code = 'professional'), 'branches', 'Sucursales', 3, 'total', TRUE, 199),
|
||||||
|
((SELECT id FROM billing.subscription_plans WHERE code = 'professional'), 'storage_mb', 'Storage (MB)', 20480, 'total', TRUE, 0.10),
|
||||||
|
((SELECT id FROM billing.subscription_plans WHERE code = 'professional'), 'api_calls', 'API Calls', 25000, 'monthly', TRUE, 0.001),
|
||||||
|
((SELECT id FROM billing.subscription_plans WHERE code = 'professional'), 'invoices', 'Facturas', 500, 'monthly', TRUE, 2)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Limits para plan Business
|
||||||
|
INSERT INTO billing.plan_limits (plan_id, limit_key, limit_name, limit_value, limit_type, allow_overage, overage_unit_price) VALUES
|
||||||
|
((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'users', 'Usuarios', 25, 'total', TRUE, 79),
|
||||||
|
((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'branches', 'Sucursales', 10, 'total', TRUE, 149),
|
||||||
|
((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'storage_mb', 'Storage (MB)', 102400, 'total', TRUE, 0.05),
|
||||||
|
((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'api_calls', 'API Calls', 100000, 'monthly', TRUE, 0.0005),
|
||||||
|
((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'invoices', 'Facturas', 0, 'monthly', FALSE, 0), -- Ilimitadas
|
||||||
|
((SELECT id FROM billing.subscription_plans WHERE code = 'business'), 'ai_tokens', 'Tokens IA', 100000, 'monthly', TRUE, 0.0001)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- COMENTARIOS
|
||||||
|
-- =====================
|
||||||
|
COMMENT ON TABLE billing.plan_features IS 'Features habilitadas por plan de suscripcion';
|
||||||
|
COMMENT ON TABLE billing.plan_limits IS 'Limites cuantificables por plan';
|
||||||
|
COMMENT ON TABLE billing.coupons IS 'Cupones de descuento';
|
||||||
|
COMMENT ON TABLE billing.coupon_redemptions IS 'Registro de uso de cupones';
|
||||||
|
COMMENT ON TABLE billing.stripe_events IS 'Log de eventos recibidos de Stripe';
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION billing.has_feature IS 'Verifica si un tenant tiene una feature habilitada';
|
||||||
|
COMMENT ON FUNCTION billing.get_limit IS 'Obtiene el valor de un limite para un tenant';
|
||||||
|
COMMENT ON FUNCTION billing.can_use_feature IS 'Verifica si un tenant puede usar una feature con limite';
|
||||||
|
COMMENT ON FUNCTION billing.get_tenant_features IS 'Obtiene todas las features habilitadas para un tenant';
|
||||||
|
COMMENT ON FUNCTION billing.get_tenant_limits IS 'Obtiene todos los limites de un tenant';
|
||||||
|
COMMENT ON FUNCTION billing.apply_coupon IS 'Aplica un cupon de descuento a un tenant';
|
||||||
@ -1,537 +0,0 @@
|
|||||||
-- =====================================================
|
|
||||||
-- SCHEMA: projects
|
|
||||||
-- PROPÓSITO: Gestión de proyectos, tareas, milestones
|
|
||||||
-- MÓDULOS: MGN-011 (Proyectos Genéricos)
|
|
||||||
-- FECHA: 2025-11-24
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Crear schema
|
|
||||||
CREATE SCHEMA IF NOT EXISTS projects;
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- TYPES (ENUMs)
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
CREATE TYPE projects.project_status AS ENUM (
|
|
||||||
'draft',
|
|
||||||
'active',
|
|
||||||
'completed',
|
|
||||||
'cancelled',
|
|
||||||
'on_hold'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE projects.privacy_type AS ENUM (
|
|
||||||
'public',
|
|
||||||
'private',
|
|
||||||
'followers'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE projects.task_status AS ENUM (
|
|
||||||
'todo',
|
|
||||||
'in_progress',
|
|
||||||
'review',
|
|
||||||
'done',
|
|
||||||
'cancelled'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE projects.task_priority AS ENUM (
|
|
||||||
'low',
|
|
||||||
'normal',
|
|
||||||
'high',
|
|
||||||
'urgent'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE projects.dependency_type AS ENUM (
|
|
||||||
'finish_to_start',
|
|
||||||
'start_to_start',
|
|
||||||
'finish_to_finish',
|
|
||||||
'start_to_finish'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE projects.milestone_status AS ENUM (
|
|
||||||
'pending',
|
|
||||||
'completed'
|
|
||||||
);
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- TABLES
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Tabla: projects (Proyectos)
|
|
||||||
CREATE TABLE projects.projects (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
company_id UUID NOT NULL REFERENCES auth.companies(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
-- Identificación
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
code VARCHAR(50),
|
|
||||||
description TEXT,
|
|
||||||
|
|
||||||
-- Responsables
|
|
||||||
manager_id UUID REFERENCES auth.users(id),
|
|
||||||
partner_id UUID REFERENCES core.partners(id), -- Cliente
|
|
||||||
|
|
||||||
-- Analítica (1-1)
|
|
||||||
analytic_account_id UUID REFERENCES analytics.analytic_accounts(id),
|
|
||||||
|
|
||||||
-- Fechas
|
|
||||||
date_start DATE,
|
|
||||||
date_end DATE,
|
|
||||||
|
|
||||||
-- Estado
|
|
||||||
status projects.project_status NOT NULL DEFAULT 'draft',
|
|
||||||
privacy projects.privacy_type NOT NULL DEFAULT 'public',
|
|
||||||
|
|
||||||
-- Configuración
|
|
||||||
allow_timesheets BOOLEAN DEFAULT TRUE,
|
|
||||||
color VARCHAR(20), -- Color para UI
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
deleted_at TIMESTAMP,
|
|
||||||
deleted_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT uq_projects_code_company UNIQUE (company_id, code),
|
|
||||||
CONSTRAINT chk_projects_dates CHECK (date_end IS NULL OR date_end >= date_start)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: project_stages (Etapas de tareas)
|
|
||||||
CREATE TABLE projects.project_stages (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
project_id UUID REFERENCES projects.projects(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
name VARCHAR(100) NOT NULL,
|
|
||||||
sequence INTEGER NOT NULL DEFAULT 1,
|
|
||||||
is_closed BOOLEAN DEFAULT FALSE, -- Etapa final
|
|
||||||
fold BOOLEAN DEFAULT FALSE, -- Plegada en kanban
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT chk_project_stages_sequence CHECK (sequence > 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: tasks (Tareas)
|
|
||||||
CREATE TABLE projects.tasks (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
project_id UUID NOT NULL REFERENCES projects.projects(id) ON DELETE CASCADE,
|
|
||||||
stage_id UUID REFERENCES projects.project_stages(id),
|
|
||||||
|
|
||||||
-- Identificación
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
|
|
||||||
-- Asignación
|
|
||||||
assigned_to UUID REFERENCES auth.users(id),
|
|
||||||
partner_id UUID REFERENCES core.partners(id),
|
|
||||||
|
|
||||||
-- Jerarquía
|
|
||||||
parent_id UUID REFERENCES projects.tasks(id),
|
|
||||||
|
|
||||||
-- Fechas
|
|
||||||
date_start DATE,
|
|
||||||
date_deadline DATE,
|
|
||||||
|
|
||||||
-- Esfuerzo
|
|
||||||
planned_hours DECIMAL(8, 2) DEFAULT 0,
|
|
||||||
actual_hours DECIMAL(8, 2) DEFAULT 0,
|
|
||||||
progress INTEGER DEFAULT 0, -- 0-100
|
|
||||||
|
|
||||||
-- Prioridad y estado
|
|
||||||
priority projects.task_priority NOT NULL DEFAULT 'normal',
|
|
||||||
status projects.task_status NOT NULL DEFAULT 'todo',
|
|
||||||
|
|
||||||
-- Milestone
|
|
||||||
milestone_id UUID REFERENCES projects.milestones(id),
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
deleted_at TIMESTAMP,
|
|
||||||
deleted_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT chk_tasks_no_self_parent CHECK (id != parent_id),
|
|
||||||
CONSTRAINT chk_tasks_dates CHECK (date_deadline IS NULL OR date_deadline >= date_start),
|
|
||||||
CONSTRAINT chk_tasks_planned_hours CHECK (planned_hours >= 0),
|
|
||||||
CONSTRAINT chk_tasks_actual_hours CHECK (actual_hours >= 0),
|
|
||||||
CONSTRAINT chk_tasks_progress CHECK (progress >= 0 AND progress <= 100)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: milestones (Hitos)
|
|
||||||
CREATE TABLE projects.milestones (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
project_id UUID NOT NULL REFERENCES projects.projects(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
target_date DATE NOT NULL,
|
|
||||||
status projects.milestone_status NOT NULL DEFAULT 'pending',
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
completed_at TIMESTAMP,
|
|
||||||
completed_by UUID REFERENCES auth.users(id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: task_dependencies (Dependencias entre tareas)
|
|
||||||
CREATE TABLE projects.task_dependencies (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
|
|
||||||
task_id UUID NOT NULL REFERENCES projects.tasks(id) ON DELETE CASCADE,
|
|
||||||
depends_on_id UUID NOT NULL REFERENCES projects.tasks(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
dependency_type projects.dependency_type NOT NULL DEFAULT 'finish_to_start',
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT uq_task_dependencies UNIQUE (task_id, depends_on_id),
|
|
||||||
CONSTRAINT chk_task_dependencies_no_self CHECK (task_id != depends_on_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: task_tags (Etiquetas de tareas)
|
|
||||||
CREATE TABLE projects.task_tags (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
name VARCHAR(100) NOT NULL,
|
|
||||||
color VARCHAR(20),
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT uq_task_tags_name_tenant UNIQUE (tenant_id, name)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: task_tag_assignments (Many-to-many)
|
|
||||||
CREATE TABLE projects.task_tag_assignments (
|
|
||||||
task_id UUID NOT NULL REFERENCES projects.tasks(id) ON DELETE CASCADE,
|
|
||||||
tag_id UUID NOT NULL REFERENCES projects.task_tags(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
PRIMARY KEY (task_id, tag_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: timesheets (Registro de horas)
|
|
||||||
CREATE TABLE projects.timesheets (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
company_id UUID NOT NULL REFERENCES auth.companies(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
task_id UUID REFERENCES projects.tasks(id) ON DELETE SET NULL,
|
|
||||||
project_id UUID NOT NULL REFERENCES projects.projects(id),
|
|
||||||
|
|
||||||
employee_id UUID, -- FK a hr.employees (se crea después)
|
|
||||||
user_id UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
-- Fecha y horas
|
|
||||||
date DATE NOT NULL,
|
|
||||||
hours DECIMAL(8, 2) NOT NULL,
|
|
||||||
|
|
||||||
-- Descripción
|
|
||||||
description TEXT,
|
|
||||||
|
|
||||||
-- Analítica
|
|
||||||
analytic_account_id UUID REFERENCES analytics.analytic_accounts(id), -- Distribución analítica
|
|
||||||
analytic_line_id UUID REFERENCES analytics.analytic_lines(id), -- Línea analítica generada
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT chk_timesheets_hours CHECK (hours > 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: task_checklists (Checklists dentro de tareas)
|
|
||||||
CREATE TABLE projects.task_checklists (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
|
|
||||||
task_id UUID NOT NULL REFERENCES projects.tasks(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
item_name VARCHAR(255) NOT NULL,
|
|
||||||
is_completed BOOLEAN DEFAULT FALSE,
|
|
||||||
sequence INTEGER DEFAULT 1,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
completed_at TIMESTAMP,
|
|
||||||
completed_by UUID REFERENCES auth.users(id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: project_templates (Plantillas de proyectos)
|
|
||||||
CREATE TABLE projects.project_templates (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
|
|
||||||
-- Template data (JSON con estructura de proyecto, tareas, etc.)
|
|
||||||
template_data JSONB DEFAULT '{}',
|
|
||||||
|
|
||||||
-- Control
|
|
||||||
active BOOLEAN DEFAULT TRUE,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT uq_project_templates_name_tenant UNIQUE (tenant_id, name)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- INDICES
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Projects
|
|
||||||
CREATE INDEX idx_projects_tenant_id ON projects.projects(tenant_id);
|
|
||||||
CREATE INDEX idx_projects_company_id ON projects.projects(company_id);
|
|
||||||
CREATE INDEX idx_projects_manager_id ON projects.projects(manager_id);
|
|
||||||
CREATE INDEX idx_projects_partner_id ON projects.projects(partner_id);
|
|
||||||
CREATE INDEX idx_projects_analytic_account_id ON projects.projects(analytic_account_id);
|
|
||||||
CREATE INDEX idx_projects_status ON projects.projects(status);
|
|
||||||
|
|
||||||
-- Project Stages
|
|
||||||
CREATE INDEX idx_project_stages_tenant_id ON projects.project_stages(tenant_id);
|
|
||||||
CREATE INDEX idx_project_stages_project_id ON projects.project_stages(project_id);
|
|
||||||
CREATE INDEX idx_project_stages_sequence ON projects.project_stages(sequence);
|
|
||||||
|
|
||||||
-- Tasks
|
|
||||||
CREATE INDEX idx_tasks_tenant_id ON projects.tasks(tenant_id);
|
|
||||||
CREATE INDEX idx_tasks_project_id ON projects.tasks(project_id);
|
|
||||||
CREATE INDEX idx_tasks_stage_id ON projects.tasks(stage_id);
|
|
||||||
CREATE INDEX idx_tasks_assigned_to ON projects.tasks(assigned_to);
|
|
||||||
CREATE INDEX idx_tasks_parent_id ON projects.tasks(parent_id);
|
|
||||||
CREATE INDEX idx_tasks_milestone_id ON projects.tasks(milestone_id);
|
|
||||||
CREATE INDEX idx_tasks_status ON projects.tasks(status);
|
|
||||||
CREATE INDEX idx_tasks_priority ON projects.tasks(priority);
|
|
||||||
CREATE INDEX idx_tasks_date_deadline ON projects.tasks(date_deadline);
|
|
||||||
|
|
||||||
-- Milestones
|
|
||||||
CREATE INDEX idx_milestones_tenant_id ON projects.milestones(tenant_id);
|
|
||||||
CREATE INDEX idx_milestones_project_id ON projects.milestones(project_id);
|
|
||||||
CREATE INDEX idx_milestones_status ON projects.milestones(status);
|
|
||||||
CREATE INDEX idx_milestones_target_date ON projects.milestones(target_date);
|
|
||||||
|
|
||||||
-- Task Dependencies
|
|
||||||
CREATE INDEX idx_task_dependencies_task_id ON projects.task_dependencies(task_id);
|
|
||||||
CREATE INDEX idx_task_dependencies_depends_on_id ON projects.task_dependencies(depends_on_id);
|
|
||||||
|
|
||||||
-- Timesheets
|
|
||||||
CREATE INDEX idx_timesheets_tenant_id ON projects.timesheets(tenant_id);
|
|
||||||
CREATE INDEX idx_timesheets_company_id ON projects.timesheets(company_id);
|
|
||||||
CREATE INDEX idx_timesheets_task_id ON projects.timesheets(task_id);
|
|
||||||
CREATE INDEX idx_timesheets_project_id ON projects.timesheets(project_id);
|
|
||||||
CREATE INDEX idx_timesheets_employee_id ON projects.timesheets(employee_id);
|
|
||||||
CREATE INDEX idx_timesheets_date ON projects.timesheets(date);
|
|
||||||
CREATE INDEX idx_timesheets_analytic_account_id ON projects.timesheets(analytic_account_id) WHERE analytic_account_id IS NOT NULL;
|
|
||||||
|
|
||||||
-- Task Checklists
|
|
||||||
CREATE INDEX idx_task_checklists_task_id ON projects.task_checklists(task_id);
|
|
||||||
|
|
||||||
-- Project Templates
|
|
||||||
CREATE INDEX idx_project_templates_tenant_id ON projects.project_templates(tenant_id);
|
|
||||||
CREATE INDEX idx_project_templates_active ON projects.project_templates(active) WHERE active = TRUE;
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- FUNCTIONS
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Función: update_task_actual_hours
|
|
||||||
CREATE OR REPLACE FUNCTION projects.update_task_actual_hours()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
IF TG_OP = 'DELETE' THEN
|
|
||||||
UPDATE projects.tasks
|
|
||||||
SET actual_hours = (
|
|
||||||
SELECT COALESCE(SUM(hours), 0)
|
|
||||||
FROM projects.timesheets
|
|
||||||
WHERE task_id = OLD.task_id
|
|
||||||
)
|
|
||||||
WHERE id = OLD.task_id;
|
|
||||||
ELSE
|
|
||||||
UPDATE projects.tasks
|
|
||||||
SET actual_hours = (
|
|
||||||
SELECT COALESCE(SUM(hours), 0)
|
|
||||||
FROM projects.timesheets
|
|
||||||
WHERE task_id = NEW.task_id
|
|
||||||
)
|
|
||||||
WHERE id = NEW.task_id;
|
|
||||||
END IF;
|
|
||||||
RETURN NULL;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION projects.update_task_actual_hours IS 'Actualiza las horas reales de una tarea al cambiar timesheets';
|
|
||||||
|
|
||||||
-- Función: check_task_dependencies
|
|
||||||
CREATE OR REPLACE FUNCTION projects.check_task_dependencies(p_task_id UUID)
|
|
||||||
RETURNS BOOLEAN AS $$
|
|
||||||
DECLARE
|
|
||||||
v_unfinished_count INTEGER;
|
|
||||||
BEGIN
|
|
||||||
SELECT COUNT(*)
|
|
||||||
INTO v_unfinished_count
|
|
||||||
FROM projects.task_dependencies td
|
|
||||||
JOIN projects.tasks t ON td.depends_on_id = t.id
|
|
||||||
WHERE td.task_id = p_task_id
|
|
||||||
AND t.status != 'done';
|
|
||||||
|
|
||||||
RETURN v_unfinished_count = 0;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql STABLE;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION projects.check_task_dependencies IS 'Verifica si todas las dependencias de una tarea están completadas';
|
|
||||||
|
|
||||||
-- Función: prevent_circular_dependencies
|
|
||||||
CREATE OR REPLACE FUNCTION projects.prevent_circular_dependencies()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
-- Verificar si crear esta dependencia crea un ciclo
|
|
||||||
IF EXISTS (
|
|
||||||
WITH RECURSIVE dep_chain AS (
|
|
||||||
SELECT task_id, depends_on_id
|
|
||||||
FROM projects.task_dependencies
|
|
||||||
WHERE task_id = NEW.depends_on_id
|
|
||||||
|
|
||||||
UNION ALL
|
|
||||||
|
|
||||||
SELECT td.task_id, td.depends_on_id
|
|
||||||
FROM projects.task_dependencies td
|
|
||||||
JOIN dep_chain dc ON td.task_id = dc.depends_on_id
|
|
||||||
)
|
|
||||||
SELECT 1 FROM dep_chain WHERE depends_on_id = NEW.task_id
|
|
||||||
) THEN
|
|
||||||
RAISE EXCEPTION 'Cannot create circular dependency';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION projects.prevent_circular_dependencies IS 'Previene la creación de dependencias circulares entre tareas';
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- TRIGGERS
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_projects_updated_at
|
|
||||||
BEFORE UPDATE ON projects.projects
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_tasks_updated_at
|
|
||||||
BEFORE UPDATE ON projects.tasks
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_milestones_updated_at
|
|
||||||
BEFORE UPDATE ON projects.milestones
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_timesheets_updated_at
|
|
||||||
BEFORE UPDATE ON projects.timesheets
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_project_templates_updated_at
|
|
||||||
BEFORE UPDATE ON projects.project_templates
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
-- Trigger: Actualizar horas reales de tarea al cambiar timesheet
|
|
||||||
CREATE TRIGGER trg_timesheets_update_task_hours
|
|
||||||
AFTER INSERT OR UPDATE OR DELETE ON projects.timesheets
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION projects.update_task_actual_hours();
|
|
||||||
|
|
||||||
-- Trigger: Prevenir dependencias circulares
|
|
||||||
CREATE TRIGGER trg_task_dependencies_prevent_circular
|
|
||||||
BEFORE INSERT ON projects.task_dependencies
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION projects.prevent_circular_dependencies();
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- TRACKING AUTOMÁTICO (mail.thread pattern)
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Trigger: Tracking automático para proyectos
|
|
||||||
CREATE TRIGGER track_project_changes
|
|
||||||
AFTER INSERT OR UPDATE OR DELETE ON projects.projects
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION system.track_field_changes();
|
|
||||||
|
|
||||||
COMMENT ON TRIGGER track_project_changes ON projects.projects IS
|
|
||||||
'Registra automáticamente cambios en proyectos (estado, nombre, responsable, fechas)';
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- ROW LEVEL SECURITY (RLS)
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
ALTER TABLE projects.projects ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE projects.project_stages ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE projects.tasks ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE projects.milestones ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE projects.task_tags ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE projects.timesheets ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE projects.project_templates ENABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_projects ON projects.projects
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_project_stages ON projects.project_stages
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_tasks ON projects.tasks
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_milestones ON projects.milestones
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_task_tags ON projects.task_tags
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_timesheets ON projects.timesheets
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_project_templates ON projects.project_templates
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- COMENTARIOS
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
COMMENT ON SCHEMA projects IS 'Schema de gestión de proyectos, tareas y timesheets';
|
|
||||||
COMMENT ON TABLE projects.projects IS 'Proyectos genéricos con tracking de tareas';
|
|
||||||
COMMENT ON TABLE projects.project_stages IS 'Etapas/columnas para tablero Kanban de tareas';
|
|
||||||
COMMENT ON TABLE projects.tasks IS 'Tareas dentro de proyectos con jerarquía y dependencias';
|
|
||||||
COMMENT ON TABLE projects.milestones IS 'Hitos importantes en proyectos';
|
|
||||||
COMMENT ON TABLE projects.task_dependencies IS 'Dependencias entre tareas (precedencia)';
|
|
||||||
COMMENT ON TABLE projects.task_tags IS 'Etiquetas para categorizar tareas';
|
|
||||||
COMMENT ON TABLE projects.timesheets IS 'Registro de horas trabajadas en tareas';
|
|
||||||
COMMENT ON TABLE projects.task_checklists IS 'Checklists dentro de tareas';
|
|
||||||
COMMENT ON TABLE projects.project_templates IS 'Plantillas de proyectos para reutilización';
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- FIN DEL SCHEMA PROJECTS
|
|
||||||
-- =====================================================
|
|
||||||
711
ddl/09-notifications.sql
Normal file
711
ddl/09-notifications.sql
Normal file
@ -0,0 +1,711 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- ARCHIVO: 09-notifications.sql
|
||||||
|
-- DESCRIPCION: Sistema de notificaciones, templates, preferencias
|
||||||
|
-- VERSION: 1.0.0
|
||||||
|
-- PROYECTO: ERP-Core V2
|
||||||
|
-- FECHA: 2026-01-10
|
||||||
|
-- EPIC: SAAS-NOTIFICATIONS (EPIC-SAAS-003)
|
||||||
|
-- HISTORIAS: US-040, US-041, US-042
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- SCHEMA: notifications
|
||||||
|
-- =====================
|
||||||
|
CREATE SCHEMA IF NOT EXISTS notifications;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: notifications.channels
|
||||||
|
-- Canales de notificacion disponibles
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications.channels (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
-- Identificacion
|
||||||
|
code VARCHAR(30) NOT NULL UNIQUE,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
|
||||||
|
-- Tipo
|
||||||
|
channel_type VARCHAR(30) NOT NULL, -- email, sms, push, whatsapp, in_app, webhook
|
||||||
|
|
||||||
|
-- Configuracion del proveedor
|
||||||
|
provider VARCHAR(50), -- sendgrid, twilio, firebase, meta, custom
|
||||||
|
provider_config JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Limites
|
||||||
|
rate_limit_per_minute INTEGER DEFAULT 60,
|
||||||
|
rate_limit_per_hour INTEGER DEFAULT 1000,
|
||||||
|
rate_limit_per_day INTEGER DEFAULT 10000,
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
is_default BOOLEAN DEFAULT FALSE,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: notifications.templates
|
||||||
|
-- Templates de notificaciones
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications.templates (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES auth.tenants(id) ON DELETE CASCADE, -- NULL = global template
|
||||||
|
|
||||||
|
-- Identificacion
|
||||||
|
code VARCHAR(100) NOT NULL,
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
category VARCHAR(50), -- system, marketing, transactional, alert
|
||||||
|
|
||||||
|
-- Canal objetivo
|
||||||
|
channel_type VARCHAR(30) NOT NULL, -- email, sms, push, whatsapp, in_app
|
||||||
|
|
||||||
|
-- Contenido
|
||||||
|
subject VARCHAR(500), -- Para email
|
||||||
|
body_template TEXT NOT NULL,
|
||||||
|
body_html TEXT, -- Para email HTML
|
||||||
|
|
||||||
|
-- Variables disponibles
|
||||||
|
available_variables JSONB DEFAULT '[]',
|
||||||
|
-- Ejemplo: ["user_name", "company_name", "action_url"]
|
||||||
|
|
||||||
|
-- Configuracion
|
||||||
|
default_locale VARCHAR(10) DEFAULT 'es-MX',
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
is_system BOOLEAN DEFAULT FALSE, -- Templates del sistema no editables
|
||||||
|
|
||||||
|
-- Versionamiento
|
||||||
|
version INTEGER DEFAULT 1,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by UUID REFERENCES auth.users(id),
|
||||||
|
updated_by UUID REFERENCES auth.users(id),
|
||||||
|
|
||||||
|
UNIQUE(tenant_id, code, channel_type)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: notifications.template_translations
|
||||||
|
-- Traducciones de templates
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications.template_translations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
template_id UUID NOT NULL REFERENCES notifications.templates(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Idioma
|
||||||
|
locale VARCHAR(10) NOT NULL, -- es-MX, en-US, etc.
|
||||||
|
|
||||||
|
-- Contenido traducido
|
||||||
|
subject VARCHAR(500),
|
||||||
|
body_template TEXT NOT NULL,
|
||||||
|
body_html TEXT,
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
UNIQUE(template_id, locale)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: notifications.preferences
|
||||||
|
-- Preferencias de notificacion por usuario
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications.preferences (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Preferencias globales
|
||||||
|
global_enabled BOOLEAN DEFAULT TRUE,
|
||||||
|
quiet_hours_start TIME,
|
||||||
|
quiet_hours_end TIME,
|
||||||
|
timezone VARCHAR(50) DEFAULT 'America/Mexico_City',
|
||||||
|
|
||||||
|
-- Preferencias por canal
|
||||||
|
email_enabled BOOLEAN DEFAULT TRUE,
|
||||||
|
sms_enabled BOOLEAN DEFAULT TRUE,
|
||||||
|
push_enabled BOOLEAN DEFAULT TRUE,
|
||||||
|
whatsapp_enabled BOOLEAN DEFAULT FALSE,
|
||||||
|
in_app_enabled BOOLEAN DEFAULT TRUE,
|
||||||
|
|
||||||
|
-- Preferencias por categoria
|
||||||
|
category_preferences JSONB DEFAULT '{}',
|
||||||
|
-- Ejemplo: {"marketing": false, "alerts": true, "reports": {"email": true, "push": false}}
|
||||||
|
|
||||||
|
-- Frecuencia de digest
|
||||||
|
digest_frequency VARCHAR(20) DEFAULT 'instant', -- instant, hourly, daily, weekly
|
||||||
|
digest_day INTEGER, -- 0-6 para weekly
|
||||||
|
digest_hour INTEGER DEFAULT 9, -- Hora del dia para daily/weekly
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
UNIQUE(user_id, tenant_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: notifications.notifications
|
||||||
|
-- Notificaciones enviadas/pendientes
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications.notifications (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Destinatario
|
||||||
|
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
recipient_email VARCHAR(255),
|
||||||
|
recipient_phone VARCHAR(20),
|
||||||
|
recipient_device_id UUID REFERENCES auth.devices(id),
|
||||||
|
|
||||||
|
-- Template usado
|
||||||
|
template_id UUID REFERENCES notifications.templates(id),
|
||||||
|
template_code VARCHAR(100),
|
||||||
|
|
||||||
|
-- Canal
|
||||||
|
channel_type VARCHAR(30) NOT NULL,
|
||||||
|
channel_id UUID REFERENCES notifications.channels(id),
|
||||||
|
|
||||||
|
-- Contenido renderizado
|
||||||
|
subject VARCHAR(500),
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
body_html TEXT,
|
||||||
|
|
||||||
|
-- Variables usadas
|
||||||
|
variables JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Contexto
|
||||||
|
context_type VARCHAR(50), -- sale, attendance, inventory, system
|
||||||
|
context_id UUID,
|
||||||
|
|
||||||
|
-- Prioridad
|
||||||
|
priority VARCHAR(20) DEFAULT 'normal', -- low, normal, high, urgent
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||||
|
-- pending, queued, sending, sent, delivered, read, failed, cancelled
|
||||||
|
|
||||||
|
-- Tracking
|
||||||
|
queued_at TIMESTAMPTZ,
|
||||||
|
sent_at TIMESTAMPTZ,
|
||||||
|
delivered_at TIMESTAMPTZ,
|
||||||
|
read_at TIMESTAMPTZ,
|
||||||
|
failed_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Errores
|
||||||
|
error_message TEXT,
|
||||||
|
retry_count INTEGER DEFAULT 0,
|
||||||
|
max_retries INTEGER DEFAULT 3,
|
||||||
|
next_retry_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Proveedor
|
||||||
|
provider_message_id VARCHAR(255),
|
||||||
|
provider_response JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Expiracion
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: notifications.notification_batches
|
||||||
|
-- Lotes de notificaciones masivas
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications.notification_batches (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Identificacion
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
|
||||||
|
-- Template
|
||||||
|
template_id UUID REFERENCES notifications.templates(id),
|
||||||
|
channel_type VARCHAR(30) NOT NULL,
|
||||||
|
|
||||||
|
-- Audiencia
|
||||||
|
audience_type VARCHAR(30) NOT NULL, -- all_users, segment, custom
|
||||||
|
audience_filter JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Contenido
|
||||||
|
variables JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Programacion
|
||||||
|
scheduled_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'draft',
|
||||||
|
-- draft, scheduled, processing, completed, failed, cancelled
|
||||||
|
|
||||||
|
-- Estadisticas
|
||||||
|
total_recipients INTEGER DEFAULT 0,
|
||||||
|
sent_count INTEGER DEFAULT 0,
|
||||||
|
delivered_count INTEGER DEFAULT 0,
|
||||||
|
failed_count INTEGER DEFAULT 0,
|
||||||
|
read_count INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
-- Tiempos
|
||||||
|
started_at TIMESTAMPTZ,
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by UUID REFERENCES auth.users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: notifications.in_app_notifications
|
||||||
|
-- Notificaciones in-app (centro de notificaciones)
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications.in_app_notifications (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Contenido
|
||||||
|
title VARCHAR(200) NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
icon VARCHAR(50),
|
||||||
|
color VARCHAR(20),
|
||||||
|
|
||||||
|
-- Accion
|
||||||
|
action_type VARCHAR(30), -- link, modal, function
|
||||||
|
action_url TEXT,
|
||||||
|
action_data JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Categoria
|
||||||
|
category VARCHAR(50), -- info, success, warning, error, task
|
||||||
|
|
||||||
|
-- Contexto
|
||||||
|
context_type VARCHAR(50),
|
||||||
|
context_id UUID,
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
is_read BOOLEAN DEFAULT FALSE,
|
||||||
|
read_at TIMESTAMPTZ,
|
||||||
|
is_archived BOOLEAN DEFAULT FALSE,
|
||||||
|
archived_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Prioridad y expiracion
|
||||||
|
priority VARCHAR(20) DEFAULT 'normal',
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- INDICES
|
||||||
|
-- =====================
|
||||||
|
|
||||||
|
-- Indices para channels
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_channels_type ON notifications.channels(channel_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_channels_active ON notifications.channels(is_active) WHERE is_active = TRUE;
|
||||||
|
|
||||||
|
-- Indices para templates
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_templates_tenant ON notifications.templates(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_templates_code ON notifications.templates(code);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_templates_channel ON notifications.templates(channel_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_templates_active ON notifications.templates(is_active) WHERE is_active = TRUE;
|
||||||
|
|
||||||
|
-- Indices para template_translations
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_template_trans_template ON notifications.template_translations(template_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_template_trans_locale ON notifications.template_translations(locale);
|
||||||
|
|
||||||
|
-- Indices para preferences
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_preferences_user ON notifications.preferences(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_preferences_tenant ON notifications.preferences(tenant_id);
|
||||||
|
|
||||||
|
-- Indices para notifications
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_tenant ON notifications.notifications(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications.notifications(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_status ON notifications.notifications(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_channel ON notifications.notifications(channel_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_context ON notifications.notifications(context_type, context_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_pending ON notifications.notifications(status, next_retry_at)
|
||||||
|
WHERE status IN ('pending', 'queued');
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_created ON notifications.notifications(created_at DESC);
|
||||||
|
|
||||||
|
-- Indices para notification_batches
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_batches_tenant ON notifications.notification_batches(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_batches_status ON notifications.notification_batches(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_batches_scheduled ON notifications.notification_batches(scheduled_at)
|
||||||
|
WHERE status = 'scheduled';
|
||||||
|
|
||||||
|
-- Indices para in_app_notifications
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_in_app_user ON notifications.in_app_notifications(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_in_app_tenant ON notifications.in_app_notifications(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_in_app_unread ON notifications.in_app_notifications(user_id, is_read)
|
||||||
|
WHERE is_read = FALSE;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_in_app_created ON notifications.in_app_notifications(created_at DESC);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- RLS POLICIES
|
||||||
|
-- =====================
|
||||||
|
|
||||||
|
-- Channels son globales (lectura publica, escritura admin)
|
||||||
|
ALTER TABLE notifications.channels ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY public_read_channels ON notifications.channels
|
||||||
|
FOR SELECT USING (true);
|
||||||
|
|
||||||
|
-- Templates: globales (tenant_id NULL) o por tenant
|
||||||
|
ALTER TABLE notifications.templates ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_or_global_templates ON notifications.templates
|
||||||
|
FOR SELECT USING (
|
||||||
|
tenant_id IS NULL
|
||||||
|
OR tenant_id = current_setting('app.current_tenant_id', true)::uuid
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE notifications.template_translations ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY template_trans_access ON notifications.template_translations
|
||||||
|
FOR SELECT USING (
|
||||||
|
template_id IN (
|
||||||
|
SELECT id FROM notifications.templates
|
||||||
|
WHERE tenant_id IS NULL
|
||||||
|
OR tenant_id = current_setting('app.current_tenant_id', true)::uuid
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Preferences por tenant
|
||||||
|
ALTER TABLE notifications.preferences ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_preferences ON notifications.preferences
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
-- Notifications por tenant
|
||||||
|
ALTER TABLE notifications.notifications ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_notifications ON notifications.notifications
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
-- Batches por tenant
|
||||||
|
ALTER TABLE notifications.notification_batches ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_batches ON notifications.notification_batches
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
-- In-app notifications por tenant
|
||||||
|
ALTER TABLE notifications.in_app_notifications ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_in_app ON notifications.in_app_notifications
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- FUNCIONES
|
||||||
|
-- =====================
|
||||||
|
|
||||||
|
-- Funcion para obtener template con fallback a global
|
||||||
|
CREATE OR REPLACE FUNCTION notifications.get_template(
|
||||||
|
p_tenant_id UUID,
|
||||||
|
p_code VARCHAR(100),
|
||||||
|
p_channel_type VARCHAR(30),
|
||||||
|
p_locale VARCHAR(10) DEFAULT 'es-MX'
|
||||||
|
)
|
||||||
|
RETURNS TABLE (
|
||||||
|
template_id UUID,
|
||||||
|
subject VARCHAR(500),
|
||||||
|
body_template TEXT,
|
||||||
|
body_html TEXT,
|
||||||
|
available_variables JSONB
|
||||||
|
) AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
t.id as template_id,
|
||||||
|
COALESCE(tt.subject, t.subject) as subject,
|
||||||
|
COALESCE(tt.body_template, t.body_template) as body_template,
|
||||||
|
COALESCE(tt.body_html, t.body_html) as body_html,
|
||||||
|
t.available_variables
|
||||||
|
FROM notifications.templates t
|
||||||
|
LEFT JOIN notifications.template_translations tt
|
||||||
|
ON tt.template_id = t.id AND tt.locale = p_locale AND tt.is_active = TRUE
|
||||||
|
WHERE t.code = p_code
|
||||||
|
AND t.channel_type = p_channel_type
|
||||||
|
AND t.is_active = TRUE
|
||||||
|
AND (t.tenant_id = p_tenant_id OR t.tenant_id IS NULL)
|
||||||
|
ORDER BY t.tenant_id NULLS LAST -- Priorizar template del tenant
|
||||||
|
LIMIT 1;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE;
|
||||||
|
|
||||||
|
-- Funcion para verificar preferencias de usuario
|
||||||
|
CREATE OR REPLACE FUNCTION notifications.should_send(
|
||||||
|
p_user_id UUID,
|
||||||
|
p_tenant_id UUID,
|
||||||
|
p_channel_type VARCHAR(30),
|
||||||
|
p_category VARCHAR(50) DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS BOOLEAN AS $$
|
||||||
|
DECLARE
|
||||||
|
v_prefs RECORD;
|
||||||
|
v_channel_enabled BOOLEAN;
|
||||||
|
v_category_enabled BOOLEAN;
|
||||||
|
v_in_quiet_hours BOOLEAN;
|
||||||
|
BEGIN
|
||||||
|
-- Obtener preferencias
|
||||||
|
SELECT * INTO v_prefs
|
||||||
|
FROM notifications.preferences
|
||||||
|
WHERE user_id = p_user_id AND tenant_id = p_tenant_id;
|
||||||
|
|
||||||
|
-- Si no hay preferencias, permitir por defecto
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RETURN TRUE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Verificar si las notificaciones estan habilitadas globalmente
|
||||||
|
IF NOT v_prefs.global_enabled THEN
|
||||||
|
RETURN FALSE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Verificar canal especifico
|
||||||
|
v_channel_enabled := CASE p_channel_type
|
||||||
|
WHEN 'email' THEN v_prefs.email_enabled
|
||||||
|
WHEN 'sms' THEN v_prefs.sms_enabled
|
||||||
|
WHEN 'push' THEN v_prefs.push_enabled
|
||||||
|
WHEN 'whatsapp' THEN v_prefs.whatsapp_enabled
|
||||||
|
WHEN 'in_app' THEN v_prefs.in_app_enabled
|
||||||
|
ELSE TRUE
|
||||||
|
END;
|
||||||
|
|
||||||
|
IF NOT v_channel_enabled THEN
|
||||||
|
RETURN FALSE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Verificar categoria si se proporciona
|
||||||
|
IF p_category IS NOT NULL AND v_prefs.category_preferences ? p_category THEN
|
||||||
|
v_category_enabled := (v_prefs.category_preferences->>p_category)::boolean;
|
||||||
|
IF NOT v_category_enabled THEN
|
||||||
|
RETURN FALSE;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Verificar horas de silencio
|
||||||
|
IF v_prefs.quiet_hours_start IS NOT NULL AND v_prefs.quiet_hours_end IS NOT NULL THEN
|
||||||
|
v_in_quiet_hours := CURRENT_TIME BETWEEN v_prefs.quiet_hours_start AND v_prefs.quiet_hours_end;
|
||||||
|
IF v_in_quiet_hours AND p_channel_type IN ('push', 'sms') THEN
|
||||||
|
RETURN FALSE;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN TRUE;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE;
|
||||||
|
|
||||||
|
-- Funcion para encolar notificacion
|
||||||
|
CREATE OR REPLACE FUNCTION notifications.enqueue_notification(
|
||||||
|
p_tenant_id UUID,
|
||||||
|
p_user_id UUID,
|
||||||
|
p_template_code VARCHAR(100),
|
||||||
|
p_channel_type VARCHAR(30),
|
||||||
|
p_variables JSONB DEFAULT '{}',
|
||||||
|
p_context_type VARCHAR(50) DEFAULT NULL,
|
||||||
|
p_context_id UUID DEFAULT NULL,
|
||||||
|
p_priority VARCHAR(20) DEFAULT 'normal'
|
||||||
|
)
|
||||||
|
RETURNS UUID AS $$
|
||||||
|
DECLARE
|
||||||
|
v_template RECORD;
|
||||||
|
v_notification_id UUID;
|
||||||
|
v_subject VARCHAR(500);
|
||||||
|
v_body TEXT;
|
||||||
|
v_body_html TEXT;
|
||||||
|
BEGIN
|
||||||
|
-- Verificar preferencias
|
||||||
|
IF NOT notifications.should_send(p_user_id, p_tenant_id, p_channel_type) THEN
|
||||||
|
RETURN NULL;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Obtener template
|
||||||
|
SELECT * INTO v_template
|
||||||
|
FROM notifications.get_template(p_tenant_id, p_template_code, p_channel_type);
|
||||||
|
|
||||||
|
IF v_template.template_id IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'Template not found: %', p_template_code;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- TODO: Renderizar template con variables (se hara en el backend)
|
||||||
|
v_subject := v_template.subject;
|
||||||
|
v_body := v_template.body_template;
|
||||||
|
v_body_html := v_template.body_html;
|
||||||
|
|
||||||
|
-- Crear notificacion
|
||||||
|
INSERT INTO notifications.notifications (
|
||||||
|
tenant_id, user_id, template_id, template_code,
|
||||||
|
channel_type, subject, body, body_html,
|
||||||
|
variables, context_type, context_id, priority,
|
||||||
|
status, queued_at
|
||||||
|
) VALUES (
|
||||||
|
p_tenant_id, p_user_id, v_template.template_id, p_template_code,
|
||||||
|
p_channel_type, v_subject, v_body, v_body_html,
|
||||||
|
p_variables, p_context_type, p_context_id, p_priority,
|
||||||
|
'queued', CURRENT_TIMESTAMP
|
||||||
|
) RETURNING id INTO v_notification_id;
|
||||||
|
|
||||||
|
RETURN v_notification_id;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Funcion para marcar notificacion como leida
|
||||||
|
CREATE OR REPLACE FUNCTION notifications.mark_as_read(p_notification_id UUID)
|
||||||
|
RETURNS BOOLEAN AS $$
|
||||||
|
BEGIN
|
||||||
|
UPDATE notifications.in_app_notifications
|
||||||
|
SET is_read = TRUE, read_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = p_notification_id AND is_read = FALSE;
|
||||||
|
|
||||||
|
RETURN FOUND;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Funcion para obtener conteo de no leidas
|
||||||
|
CREATE OR REPLACE FUNCTION notifications.get_unread_count(p_user_id UUID, p_tenant_id UUID)
|
||||||
|
RETURNS INTEGER AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN (
|
||||||
|
SELECT COUNT(*)::INTEGER
|
||||||
|
FROM notifications.in_app_notifications
|
||||||
|
WHERE user_id = p_user_id
|
||||||
|
AND tenant_id = p_tenant_id
|
||||||
|
AND is_read = FALSE
|
||||||
|
AND is_archived = FALSE
|
||||||
|
AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP)
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE;
|
||||||
|
|
||||||
|
-- Funcion para limpiar notificaciones antiguas
|
||||||
|
CREATE OR REPLACE FUNCTION notifications.cleanup_old_notifications(p_days INTEGER DEFAULT 90)
|
||||||
|
RETURNS INTEGER AS $$
|
||||||
|
DECLARE
|
||||||
|
deleted_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
-- Eliminar notificaciones enviadas antiguas
|
||||||
|
DELETE FROM notifications.notifications
|
||||||
|
WHERE created_at < CURRENT_TIMESTAMP - (p_days || ' days')::INTERVAL
|
||||||
|
AND status IN ('sent', 'delivered', 'read', 'failed', 'cancelled');
|
||||||
|
|
||||||
|
GET DIAGNOSTICS deleted_count = ROW_COUNT;
|
||||||
|
|
||||||
|
-- Eliminar in-app archivadas antiguas
|
||||||
|
DELETE FROM notifications.in_app_notifications
|
||||||
|
WHERE archived_at IS NOT NULL
|
||||||
|
AND archived_at < CURRENT_TIMESTAMP - (p_days || ' days')::INTERVAL;
|
||||||
|
|
||||||
|
RETURN deleted_count;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TRIGGERS
|
||||||
|
-- =====================
|
||||||
|
|
||||||
|
-- Trigger para updated_at
|
||||||
|
CREATE OR REPLACE FUNCTION notifications.update_timestamp()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_channels_updated_at
|
||||||
|
BEFORE UPDATE ON notifications.channels
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION notifications.update_timestamp();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_templates_updated_at
|
||||||
|
BEFORE UPDATE ON notifications.templates
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION notifications.update_timestamp();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_preferences_updated_at
|
||||||
|
BEFORE UPDATE ON notifications.preferences
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION notifications.update_timestamp();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_notifications_updated_at
|
||||||
|
BEFORE UPDATE ON notifications.notifications
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION notifications.update_timestamp();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_batches_updated_at
|
||||||
|
BEFORE UPDATE ON notifications.notification_batches
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION notifications.update_timestamp();
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- SEED DATA: Canales
|
||||||
|
-- =====================
|
||||||
|
INSERT INTO notifications.channels (code, name, channel_type, provider, is_active, is_default) VALUES
|
||||||
|
('email_sendgrid', 'Email (SendGrid)', 'email', 'sendgrid', TRUE, TRUE),
|
||||||
|
('email_smtp', 'Email (SMTP)', 'email', 'smtp', TRUE, FALSE),
|
||||||
|
('sms_twilio', 'SMS (Twilio)', 'sms', 'twilio', TRUE, TRUE),
|
||||||
|
('push_firebase', 'Push (Firebase)', 'push', 'firebase', TRUE, TRUE),
|
||||||
|
('whatsapp_meta', 'WhatsApp (Meta)', 'whatsapp', 'meta', FALSE, FALSE),
|
||||||
|
('in_app', 'In-App', 'in_app', 'internal', TRUE, TRUE)
|
||||||
|
ON CONFLICT (code) DO NOTHING;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- SEED DATA: Templates del Sistema
|
||||||
|
-- =====================
|
||||||
|
INSERT INTO notifications.templates (code, name, channel_type, subject, body_template, category, is_system, available_variables) VALUES
|
||||||
|
-- Email templates
|
||||||
|
('welcome', 'Bienvenida', 'email', 'Bienvenido a {{company_name}}',
|
||||||
|
'Hola {{user_name}},\n\nBienvenido a {{company_name}}. Tu cuenta ha sido creada exitosamente.\n\nPuedes acceder desde: {{login_url}}\n\nSaludos,\nEl equipo de {{company_name}}',
|
||||||
|
'system', TRUE, '["user_name", "company_name", "login_url"]'),
|
||||||
|
|
||||||
|
('password_reset', 'Recuperar Contraseña', 'email', 'Recupera tu contraseña - {{company_name}}',
|
||||||
|
'Hola {{user_name}},\n\nHemos recibido una solicitud para recuperar tu contraseña.\n\nHaz clic aquí para restablecerla: {{reset_url}}\n\nEste enlace expira en {{expiry_hours}} horas.\n\nSi no solicitaste esto, ignora este correo.',
|
||||||
|
'system', TRUE, '["user_name", "reset_url", "expiry_hours", "company_name"]'),
|
||||||
|
|
||||||
|
('invitation', 'Invitación', 'email', 'Has sido invitado a {{company_name}}',
|
||||||
|
'Hola,\n\n{{inviter_name}} te ha invitado a unirte a {{company_name}} con el rol de {{role_name}}.\n\nAcepta la invitación aquí: {{invitation_url}}\n\nEsta invitación expira el {{expiry_date}}.',
|
||||||
|
'system', TRUE, '["inviter_name", "company_name", "role_name", "invitation_url", "expiry_date"]'),
|
||||||
|
|
||||||
|
('mfa_code', 'Código de Verificación', 'email', 'Tu código de verificación: {{code}}',
|
||||||
|
'Tu código de verificación es: {{code}}\n\nEste código expira en {{expiry_minutes}} minutos.\n\nSi no solicitaste esto, cambia tu contraseña inmediatamente.',
|
||||||
|
'system', TRUE, '["code", "expiry_minutes"]'),
|
||||||
|
|
||||||
|
-- Push templates
|
||||||
|
('attendance_reminder', 'Recordatorio de Asistencia', 'push', NULL,
|
||||||
|
'{{user_name}}, no olvides registrar tu {{attendance_type}} de hoy.',
|
||||||
|
'transactional', TRUE, '["user_name", "attendance_type"]'),
|
||||||
|
|
||||||
|
('low_stock_alert', 'Alerta de Stock Bajo', 'push', NULL,
|
||||||
|
'Stock bajo: {{product_name}} tiene solo {{quantity}} unidades en {{branch_name}}.',
|
||||||
|
'alert', TRUE, '["product_name", "quantity", "branch_name"]'),
|
||||||
|
|
||||||
|
-- In-app templates
|
||||||
|
('task_assigned', 'Tarea Asignada', 'in_app', NULL,
|
||||||
|
'{{assigner_name}} te ha asignado una nueva tarea: {{task_title}}',
|
||||||
|
'transactional', TRUE, '["assigner_name", "task_title"]'),
|
||||||
|
|
||||||
|
('payment_received', 'Pago Recibido', 'in_app', NULL,
|
||||||
|
'Se ha recibido un pago de ${{amount}} {{currency}} de {{customer_name}}.',
|
||||||
|
'transactional', TRUE, '["amount", "currency", "customer_name"]')
|
||||||
|
|
||||||
|
ON CONFLICT (tenant_id, code, channel_type) DO NOTHING;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- COMENTARIOS
|
||||||
|
-- =====================
|
||||||
|
COMMENT ON TABLE notifications.channels IS 'Canales de notificacion disponibles (email, sms, push, etc.)';
|
||||||
|
COMMENT ON TABLE notifications.templates IS 'Templates de notificaciones con soporte multi-idioma';
|
||||||
|
COMMENT ON TABLE notifications.template_translations IS 'Traducciones de templates de notificaciones';
|
||||||
|
COMMENT ON TABLE notifications.preferences IS 'Preferencias de notificacion por usuario';
|
||||||
|
COMMENT ON TABLE notifications.notifications IS 'Cola y log de notificaciones';
|
||||||
|
COMMENT ON TABLE notifications.notification_batches IS 'Lotes de notificaciones masivas';
|
||||||
|
COMMENT ON TABLE notifications.in_app_notifications IS 'Notificaciones in-app para centro de notificaciones';
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION notifications.get_template IS 'Obtiene template con fallback a template global';
|
||||||
|
COMMENT ON FUNCTION notifications.should_send IS 'Verifica si se debe enviar notificacion segun preferencias';
|
||||||
|
COMMENT ON FUNCTION notifications.enqueue_notification IS 'Encola una notificacion para envio';
|
||||||
|
COMMENT ON FUNCTION notifications.get_unread_count IS 'Obtiene conteo de notificaciones no leidas';
|
||||||
@ -1,853 +0,0 @@
|
|||||||
-- =====================================================
|
|
||||||
-- SCHEMA: system
|
|
||||||
-- PROPÓSITO: Mensajería, notificaciones, logs, reportes
|
|
||||||
-- MÓDULOS: MGN-012 (Reportes), MGN-014 (Mensajería)
|
|
||||||
-- FECHA: 2025-11-24
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Crear schema
|
|
||||||
CREATE SCHEMA IF NOT EXISTS system;
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- TYPES (ENUMs)
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
CREATE TYPE system.message_type AS ENUM (
|
|
||||||
'comment',
|
|
||||||
'note',
|
|
||||||
'email',
|
|
||||||
'notification',
|
|
||||||
'system'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE system.notification_status AS ENUM (
|
|
||||||
'pending',
|
|
||||||
'sent',
|
|
||||||
'read',
|
|
||||||
'failed'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE system.activity_type AS ENUM (
|
|
||||||
'call',
|
|
||||||
'meeting',
|
|
||||||
'email',
|
|
||||||
'todo',
|
|
||||||
'follow_up',
|
|
||||||
'custom'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE system.activity_status AS ENUM (
|
|
||||||
'planned',
|
|
||||||
'done',
|
|
||||||
'cancelled',
|
|
||||||
'overdue'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE system.email_status AS ENUM (
|
|
||||||
'draft',
|
|
||||||
'queued',
|
|
||||||
'sending',
|
|
||||||
'sent',
|
|
||||||
'failed',
|
|
||||||
'bounced'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE system.log_level AS ENUM (
|
|
||||||
'debug',
|
|
||||||
'info',
|
|
||||||
'warning',
|
|
||||||
'error',
|
|
||||||
'critical'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE system.report_format AS ENUM (
|
|
||||||
'pdf',
|
|
||||||
'excel',
|
|
||||||
'csv',
|
|
||||||
'html'
|
|
||||||
);
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- TABLES
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Tabla: messages (Chatter - mensajes en registros)
|
|
||||||
CREATE TABLE system.messages (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
-- Referencia polimórfica (a qué registro pertenece)
|
|
||||||
model VARCHAR(100) NOT NULL, -- 'SaleOrder', 'Task', 'Invoice', etc.
|
|
||||||
record_id UUID NOT NULL,
|
|
||||||
|
|
||||||
-- Tipo y contenido
|
|
||||||
message_type system.message_type NOT NULL DEFAULT 'comment',
|
|
||||||
subject VARCHAR(255),
|
|
||||||
body TEXT NOT NULL,
|
|
||||||
|
|
||||||
-- Autor
|
|
||||||
author_id UUID REFERENCES auth.users(id),
|
|
||||||
author_name VARCHAR(255),
|
|
||||||
author_email VARCHAR(255),
|
|
||||||
|
|
||||||
-- Email tracking
|
|
||||||
email_from VARCHAR(255),
|
|
||||||
reply_to VARCHAR(255),
|
|
||||||
message_id VARCHAR(500), -- Message-ID para threading
|
|
||||||
|
|
||||||
-- Relación (respuesta a mensaje)
|
|
||||||
parent_id UUID REFERENCES system.messages(id),
|
|
||||||
|
|
||||||
-- Attachments
|
|
||||||
attachment_ids UUID[] DEFAULT '{}',
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: message_followers (Seguidores de registros)
|
|
||||||
CREATE TABLE system.message_followers (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
|
|
||||||
-- Referencia polimórfica
|
|
||||||
model VARCHAR(100) NOT NULL,
|
|
||||||
record_id UUID NOT NULL,
|
|
||||||
|
|
||||||
-- Seguidor
|
|
||||||
partner_id UUID REFERENCES core.partners(id),
|
|
||||||
user_id UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
-- Configuración
|
|
||||||
email_notifications BOOLEAN DEFAULT TRUE,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT uq_message_followers UNIQUE (model, record_id, COALESCE(user_id, partner_id)),
|
|
||||||
CONSTRAINT chk_message_followers_user_or_partner CHECK (
|
|
||||||
(user_id IS NOT NULL AND partner_id IS NULL) OR
|
|
||||||
(partner_id IS NOT NULL AND user_id IS NULL)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: notifications (Notificaciones a usuarios)
|
|
||||||
CREATE TABLE system.notifications (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
-- Contenido
|
|
||||||
title VARCHAR(255) NOT NULL,
|
|
||||||
message TEXT NOT NULL,
|
|
||||||
url VARCHAR(500), -- URL para acción (ej: /sales/orders/123)
|
|
||||||
|
|
||||||
-- Referencia (opcional)
|
|
||||||
model VARCHAR(100),
|
|
||||||
record_id UUID,
|
|
||||||
|
|
||||||
-- Estado
|
|
||||||
status system.notification_status NOT NULL DEFAULT 'pending',
|
|
||||||
read_at TIMESTAMP,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
sent_at TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: activities (Actividades programadas)
|
|
||||||
CREATE TABLE system.activities (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
-- Referencia polimórfica
|
|
||||||
model VARCHAR(100) NOT NULL,
|
|
||||||
record_id UUID NOT NULL,
|
|
||||||
|
|
||||||
-- Actividad
|
|
||||||
activity_type system.activity_type NOT NULL,
|
|
||||||
summary VARCHAR(255) NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
|
|
||||||
-- Asignación
|
|
||||||
assigned_to UUID REFERENCES auth.users(id),
|
|
||||||
assigned_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
-- Fechas
|
|
||||||
due_date DATE NOT NULL,
|
|
||||||
due_time TIME,
|
|
||||||
|
|
||||||
-- Estado
|
|
||||||
status system.activity_status NOT NULL DEFAULT 'planned',
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
completed_at TIMESTAMP,
|
|
||||||
completed_by UUID REFERENCES auth.users(id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: message_templates (Plantillas de mensajes/emails)
|
|
||||||
CREATE TABLE system.message_templates (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
model VARCHAR(100), -- Para qué modelo se usa
|
|
||||||
|
|
||||||
-- Contenido
|
|
||||||
subject VARCHAR(255),
|
|
||||||
body_html TEXT,
|
|
||||||
body_text TEXT,
|
|
||||||
|
|
||||||
-- Configuración email
|
|
||||||
email_from VARCHAR(255),
|
|
||||||
reply_to VARCHAR(255),
|
|
||||||
cc VARCHAR(255),
|
|
||||||
bcc VARCHAR(255),
|
|
||||||
|
|
||||||
-- Control
|
|
||||||
active BOOLEAN DEFAULT TRUE,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT uq_message_templates_name_tenant UNIQUE (tenant_id, name)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: email_queue (Cola de envío de emails)
|
|
||||||
CREATE TABLE system.email_queue (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID REFERENCES auth.tenants(id),
|
|
||||||
|
|
||||||
-- Destinatarios
|
|
||||||
email_to VARCHAR(255) NOT NULL,
|
|
||||||
email_cc VARCHAR(500),
|
|
||||||
email_bcc VARCHAR(500),
|
|
||||||
|
|
||||||
-- Contenido
|
|
||||||
subject VARCHAR(255) NOT NULL,
|
|
||||||
body_html TEXT,
|
|
||||||
body_text TEXT,
|
|
||||||
|
|
||||||
-- Remitente
|
|
||||||
email_from VARCHAR(255) NOT NULL,
|
|
||||||
reply_to VARCHAR(255),
|
|
||||||
|
|
||||||
-- Attachments
|
|
||||||
attachment_ids UUID[] DEFAULT '{}',
|
|
||||||
|
|
||||||
-- Estado
|
|
||||||
status system.email_status NOT NULL DEFAULT 'queued',
|
|
||||||
attempts INTEGER DEFAULT 0,
|
|
||||||
max_attempts INTEGER DEFAULT 3,
|
|
||||||
error_message TEXT,
|
|
||||||
|
|
||||||
-- Tracking
|
|
||||||
message_id VARCHAR(500),
|
|
||||||
opened_at TIMESTAMP,
|
|
||||||
clicked_at TIMESTAMP,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
scheduled_at TIMESTAMP,
|
|
||||||
sent_at TIMESTAMP,
|
|
||||||
failed_at TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: logs (Logs del sistema)
|
|
||||||
CREATE TABLE system.logs (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID REFERENCES auth.tenants(id),
|
|
||||||
|
|
||||||
-- Nivel y fuente
|
|
||||||
level system.log_level NOT NULL,
|
|
||||||
logger VARCHAR(100), -- Módulo que genera el log
|
|
||||||
|
|
||||||
-- Mensaje
|
|
||||||
message TEXT NOT NULL,
|
|
||||||
stack_trace TEXT,
|
|
||||||
|
|
||||||
-- Contexto
|
|
||||||
user_id UUID REFERENCES auth.users(id),
|
|
||||||
ip_address INET,
|
|
||||||
user_agent TEXT,
|
|
||||||
request_id UUID,
|
|
||||||
|
|
||||||
-- Referencia (opcional)
|
|
||||||
model VARCHAR(100),
|
|
||||||
record_id UUID,
|
|
||||||
|
|
||||||
-- Metadata adicional
|
|
||||||
metadata JSONB DEFAULT '{}',
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: reports (Definiciones de reportes)
|
|
||||||
CREATE TABLE system.reports (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
code VARCHAR(50) NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
|
|
||||||
-- Tipo
|
|
||||||
model VARCHAR(100), -- Para qué modelo es el reporte
|
|
||||||
report_type VARCHAR(50), -- 'standard', 'custom', 'dashboard'
|
|
||||||
|
|
||||||
-- Query/Template
|
|
||||||
query_template TEXT, -- SQL template o JSON query
|
|
||||||
template_file VARCHAR(255), -- Path al archivo de plantilla
|
|
||||||
|
|
||||||
-- Configuración
|
|
||||||
default_format system.report_format DEFAULT 'pdf',
|
|
||||||
is_public BOOLEAN DEFAULT FALSE,
|
|
||||||
|
|
||||||
-- Control
|
|
||||||
active BOOLEAN DEFAULT TRUE,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT uq_reports_code_tenant UNIQUE (tenant_id, code)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: report_executions (Ejecuciones de reportes)
|
|
||||||
CREATE TABLE system.report_executions (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
report_id UUID NOT NULL REFERENCES system.reports(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
-- Parámetros de ejecución
|
|
||||||
parameters JSONB DEFAULT '{}',
|
|
||||||
format system.report_format NOT NULL,
|
|
||||||
|
|
||||||
-- Resultado
|
|
||||||
file_url VARCHAR(500),
|
|
||||||
file_size BIGINT,
|
|
||||||
error_message TEXT,
|
|
||||||
|
|
||||||
-- Estado
|
|
||||||
status VARCHAR(20) DEFAULT 'pending', -- pending, running, completed, failed
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
started_at TIMESTAMP,
|
|
||||||
completed_at TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: dashboards (Dashboards configurables)
|
|
||||||
CREATE TABLE system.dashboards (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
|
|
||||||
-- Configuración
|
|
||||||
layout JSONB DEFAULT '{}', -- Grid layout configuration
|
|
||||||
is_default BOOLEAN DEFAULT FALSE,
|
|
||||||
|
|
||||||
-- Visibilidad
|
|
||||||
user_id UUID REFERENCES auth.users(id), -- NULL = compartido
|
|
||||||
is_public BOOLEAN DEFAULT FALSE,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
CONSTRAINT uq_dashboards_name_user UNIQUE (tenant_id, name, COALESCE(user_id, '00000000-0000-0000-0000-000000000000'::UUID))
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: dashboard_widgets (Widgets en dashboards)
|
|
||||||
CREATE TABLE system.dashboard_widgets (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
|
|
||||||
dashboard_id UUID NOT NULL REFERENCES system.dashboards(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
-- Tipo de widget
|
|
||||||
widget_type VARCHAR(50) NOT NULL, -- 'chart', 'kpi', 'table', 'calendar', etc.
|
|
||||||
title VARCHAR(255),
|
|
||||||
|
|
||||||
-- Configuración
|
|
||||||
config JSONB NOT NULL DEFAULT '{}', -- Widget-specific configuration
|
|
||||||
position JSONB DEFAULT '{}', -- {x, y, w, h} para grid
|
|
||||||
|
|
||||||
-- Data source
|
|
||||||
data_source VARCHAR(100), -- Model o query
|
|
||||||
query_params JSONB DEFAULT '{}',
|
|
||||||
|
|
||||||
-- Refresh
|
|
||||||
refresh_interval INTEGER, -- Segundos
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- INDICES
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Messages
|
|
||||||
CREATE INDEX idx_messages_tenant_id ON system.messages(tenant_id);
|
|
||||||
CREATE INDEX idx_messages_model_record ON system.messages(model, record_id);
|
|
||||||
CREATE INDEX idx_messages_author_id ON system.messages(author_id);
|
|
||||||
CREATE INDEX idx_messages_parent_id ON system.messages(parent_id);
|
|
||||||
CREATE INDEX idx_messages_created_at ON system.messages(created_at DESC);
|
|
||||||
|
|
||||||
-- Message Followers
|
|
||||||
CREATE INDEX idx_message_followers_model_record ON system.message_followers(model, record_id);
|
|
||||||
CREATE INDEX idx_message_followers_user_id ON system.message_followers(user_id);
|
|
||||||
CREATE INDEX idx_message_followers_partner_id ON system.message_followers(partner_id);
|
|
||||||
|
|
||||||
-- Notifications
|
|
||||||
CREATE INDEX idx_notifications_tenant_id ON system.notifications(tenant_id);
|
|
||||||
CREATE INDEX idx_notifications_user_id ON system.notifications(user_id);
|
|
||||||
CREATE INDEX idx_notifications_status ON system.notifications(status);
|
|
||||||
CREATE INDEX idx_notifications_model_record ON system.notifications(model, record_id);
|
|
||||||
CREATE INDEX idx_notifications_created_at ON system.notifications(created_at DESC);
|
|
||||||
|
|
||||||
-- Activities
|
|
||||||
CREATE INDEX idx_activities_tenant_id ON system.activities(tenant_id);
|
|
||||||
CREATE INDEX idx_activities_model_record ON system.activities(model, record_id);
|
|
||||||
CREATE INDEX idx_activities_assigned_to ON system.activities(assigned_to);
|
|
||||||
CREATE INDEX idx_activities_due_date ON system.activities(due_date);
|
|
||||||
CREATE INDEX idx_activities_status ON system.activities(status);
|
|
||||||
|
|
||||||
-- Message Templates
|
|
||||||
CREATE INDEX idx_message_templates_tenant_id ON system.message_templates(tenant_id);
|
|
||||||
CREATE INDEX idx_message_templates_model ON system.message_templates(model);
|
|
||||||
CREATE INDEX idx_message_templates_active ON system.message_templates(active) WHERE active = TRUE;
|
|
||||||
|
|
||||||
-- Email Queue
|
|
||||||
CREATE INDEX idx_email_queue_status ON system.email_queue(status);
|
|
||||||
CREATE INDEX idx_email_queue_scheduled_at ON system.email_queue(scheduled_at);
|
|
||||||
CREATE INDEX idx_email_queue_created_at ON system.email_queue(created_at);
|
|
||||||
|
|
||||||
-- Logs
|
|
||||||
CREATE INDEX idx_logs_tenant_id ON system.logs(tenant_id);
|
|
||||||
CREATE INDEX idx_logs_level ON system.logs(level);
|
|
||||||
CREATE INDEX idx_logs_logger ON system.logs(logger);
|
|
||||||
CREATE INDEX idx_logs_user_id ON system.logs(user_id);
|
|
||||||
CREATE INDEX idx_logs_created_at ON system.logs(created_at DESC);
|
|
||||||
CREATE INDEX idx_logs_model_record ON system.logs(model, record_id);
|
|
||||||
|
|
||||||
-- Reports
|
|
||||||
CREATE INDEX idx_reports_tenant_id ON system.reports(tenant_id);
|
|
||||||
CREATE INDEX idx_reports_code ON system.reports(code);
|
|
||||||
CREATE INDEX idx_reports_active ON system.reports(active) WHERE active = TRUE;
|
|
||||||
|
|
||||||
-- Report Executions
|
|
||||||
CREATE INDEX idx_report_executions_tenant_id ON system.report_executions(tenant_id);
|
|
||||||
CREATE INDEX idx_report_executions_report_id ON system.report_executions(report_id);
|
|
||||||
CREATE INDEX idx_report_executions_created_by ON system.report_executions(created_by);
|
|
||||||
CREATE INDEX idx_report_executions_created_at ON system.report_executions(created_at DESC);
|
|
||||||
|
|
||||||
-- Dashboards
|
|
||||||
CREATE INDEX idx_dashboards_tenant_id ON system.dashboards(tenant_id);
|
|
||||||
CREATE INDEX idx_dashboards_user_id ON system.dashboards(user_id);
|
|
||||||
CREATE INDEX idx_dashboards_is_public ON system.dashboards(is_public) WHERE is_public = TRUE;
|
|
||||||
|
|
||||||
-- Dashboard Widgets
|
|
||||||
CREATE INDEX idx_dashboard_widgets_dashboard_id ON system.dashboard_widgets(dashboard_id);
|
|
||||||
CREATE INDEX idx_dashboard_widgets_type ON system.dashboard_widgets(widget_type);
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- FUNCTIONS
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Función: notify_followers
|
|
||||||
CREATE OR REPLACE FUNCTION system.notify_followers(
|
|
||||||
p_model VARCHAR,
|
|
||||||
p_record_id UUID,
|
|
||||||
p_message_id UUID
|
|
||||||
)
|
|
||||||
RETURNS VOID AS $$
|
|
||||||
BEGIN
|
|
||||||
INSERT INTO system.notifications (tenant_id, user_id, title, message, model, record_id)
|
|
||||||
SELECT
|
|
||||||
get_current_tenant_id(),
|
|
||||||
mf.user_id,
|
|
||||||
'New message in ' || p_model,
|
|
||||||
m.body,
|
|
||||||
p_model,
|
|
||||||
p_record_id
|
|
||||||
FROM system.message_followers mf
|
|
||||||
JOIN system.messages m ON m.id = p_message_id
|
|
||||||
WHERE mf.model = p_model
|
|
||||||
AND mf.record_id = p_record_id
|
|
||||||
AND mf.user_id IS NOT NULL
|
|
||||||
AND mf.email_notifications = TRUE;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION system.notify_followers IS 'Notifica a los seguidores de un registro cuando hay un nuevo mensaje';
|
|
||||||
|
|
||||||
-- Función: mark_activity_as_overdue
|
|
||||||
CREATE OR REPLACE FUNCTION system.mark_activities_as_overdue()
|
|
||||||
RETURNS INTEGER AS $$
|
|
||||||
DECLARE
|
|
||||||
v_updated_count INTEGER;
|
|
||||||
BEGIN
|
|
||||||
WITH updated AS (
|
|
||||||
UPDATE system.activities
|
|
||||||
SET status = 'overdue'
|
|
||||||
WHERE status = 'planned'
|
|
||||||
AND due_date < CURRENT_DATE
|
|
||||||
RETURNING id
|
|
||||||
)
|
|
||||||
SELECT COUNT(*) INTO v_updated_count FROM updated;
|
|
||||||
|
|
||||||
RETURN v_updated_count;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION system.mark_activities_as_overdue IS 'Marca actividades vencidas como overdue (ejecutar diariamente)';
|
|
||||||
|
|
||||||
-- Función: clean_old_logs
|
|
||||||
CREATE OR REPLACE FUNCTION system.clean_old_logs(p_days_to_keep INTEGER DEFAULT 90)
|
|
||||||
RETURNS INTEGER AS $$
|
|
||||||
DECLARE
|
|
||||||
v_deleted_count INTEGER;
|
|
||||||
BEGIN
|
|
||||||
WITH deleted AS (
|
|
||||||
DELETE FROM system.logs
|
|
||||||
WHERE created_at < CURRENT_TIMESTAMP - (p_days_to_keep || ' days')::INTERVAL
|
|
||||||
AND level != 'critical'
|
|
||||||
RETURNING id
|
|
||||||
)
|
|
||||||
SELECT COUNT(*) INTO v_deleted_count FROM deleted;
|
|
||||||
|
|
||||||
RETURN v_deleted_count;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION system.clean_old_logs IS 'Limpia logs antiguos (mantener solo críticos)';
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- TRIGGERS
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_messages_updated_at
|
|
||||||
BEFORE UPDATE ON system.messages
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_message_templates_updated_at
|
|
||||||
BEFORE UPDATE ON system.message_templates
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_reports_updated_at
|
|
||||||
BEFORE UPDATE ON system.reports
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_dashboards_updated_at
|
|
||||||
BEFORE UPDATE ON system.dashboards
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_dashboard_widgets_updated_at
|
|
||||||
BEFORE UPDATE ON system.dashboard_widgets
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- ROW LEVEL SECURITY (RLS)
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
ALTER TABLE system.messages ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE system.notifications ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE system.activities ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE system.message_templates ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE system.logs ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE system.reports ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE system.report_executions ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE system.dashboards ENABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_messages ON system.messages
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_notifications ON system.notifications
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_activities ON system.activities
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_message_templates ON system.message_templates
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_logs ON system.logs
|
|
||||||
USING (tenant_id = get_current_tenant_id() OR tenant_id IS NULL);
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_reports ON system.reports
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_report_executions ON system.report_executions
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_dashboards ON system.dashboards
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- TRACKING AUTOMÁTICO (mail.thread pattern de Odoo)
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Tabla: field_tracking_config (Configuración de campos a trackear)
|
|
||||||
CREATE TABLE system.field_tracking_config (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
table_schema VARCHAR(50) NOT NULL,
|
|
||||||
table_name VARCHAR(100) NOT NULL,
|
|
||||||
field_name VARCHAR(100) NOT NULL,
|
|
||||||
track_changes BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
field_type VARCHAR(50) NOT NULL, -- 'text', 'integer', 'numeric', 'boolean', 'uuid', 'timestamp', 'json'
|
|
||||||
display_label VARCHAR(255) NOT NULL, -- Para mostrar en UI: "Estado", "Monto", "Cliente", etc.
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
CONSTRAINT uq_field_tracking UNIQUE (table_schema, table_name, field_name)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Índice para búsqueda rápida
|
|
||||||
CREATE INDEX idx_field_tracking_config_table
|
|
||||||
ON system.field_tracking_config(table_schema, table_name);
|
|
||||||
|
|
||||||
-- Tabla: change_log (Historial de cambios en registros)
|
|
||||||
CREATE TABLE system.change_log (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id),
|
|
||||||
|
|
||||||
-- Referencia al registro modificado
|
|
||||||
table_schema VARCHAR(50) NOT NULL,
|
|
||||||
table_name VARCHAR(100) NOT NULL,
|
|
||||||
record_id UUID NOT NULL,
|
|
||||||
|
|
||||||
-- Usuario que hizo el cambio
|
|
||||||
changed_by UUID NOT NULL REFERENCES auth.users(id),
|
|
||||||
changed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
-- Tipo de cambio
|
|
||||||
change_type VARCHAR(20) NOT NULL CHECK (change_type IN ('create', 'update', 'delete', 'state_change')),
|
|
||||||
|
|
||||||
-- Campo modificado (NULL para create/delete)
|
|
||||||
field_name VARCHAR(100),
|
|
||||||
field_label VARCHAR(255), -- Para UI: "Estado", "Monto Total", etc.
|
|
||||||
|
|
||||||
-- Valores anterior y nuevo
|
|
||||||
old_value TEXT,
|
|
||||||
new_value TEXT,
|
|
||||||
|
|
||||||
-- Metadata adicional
|
|
||||||
change_context JSONB, -- Info adicional: IP, user agent, módulo, etc.
|
|
||||||
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Índices para performance del change_log
|
|
||||||
CREATE INDEX idx_change_log_tenant_id ON system.change_log(tenant_id);
|
|
||||||
CREATE INDEX idx_change_log_record ON system.change_log(table_schema, table_name, record_id);
|
|
||||||
CREATE INDEX idx_change_log_changed_by ON system.change_log(changed_by);
|
|
||||||
CREATE INDEX idx_change_log_changed_at ON system.change_log(changed_at DESC);
|
|
||||||
CREATE INDEX idx_change_log_type ON system.change_log(change_type);
|
|
||||||
|
|
||||||
-- Índice compuesto para queries comunes
|
|
||||||
CREATE INDEX idx_change_log_record_date
|
|
||||||
ON system.change_log(table_schema, table_name, record_id, changed_at DESC);
|
|
||||||
|
|
||||||
-- RLS Policy para multi-tenancy
|
|
||||||
ALTER TABLE system.change_log ENABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_change_log ON system.change_log
|
|
||||||
USING (tenant_id = get_current_tenant_id());
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- FUNCIÓN DE TRACKING AUTOMÁTICO
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Función: track_field_changes
|
|
||||||
-- Función genérica para trackear cambios automáticamente
|
|
||||||
CREATE OR REPLACE FUNCTION system.track_field_changes()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
DECLARE
|
|
||||||
v_tenant_id UUID;
|
|
||||||
v_user_id UUID;
|
|
||||||
v_field_name TEXT;
|
|
||||||
v_field_label TEXT;
|
|
||||||
v_old_value TEXT;
|
|
||||||
v_new_value TEXT;
|
|
||||||
v_field_config RECORD;
|
|
||||||
BEGIN
|
|
||||||
-- Obtener tenant_id y user_id del registro
|
|
||||||
IF TG_OP = 'DELETE' THEN
|
|
||||||
v_tenant_id := OLD.tenant_id;
|
|
||||||
v_user_id := OLD.deleted_by;
|
|
||||||
ELSE
|
|
||||||
v_tenant_id := NEW.tenant_id;
|
|
||||||
v_user_id := NEW.updated_by;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- Registrar creación
|
|
||||||
IF TG_OP = 'INSERT' THEN
|
|
||||||
INSERT INTO system.change_log (
|
|
||||||
tenant_id, table_schema, table_name, record_id,
|
|
||||||
changed_by, change_type, change_context
|
|
||||||
) VALUES (
|
|
||||||
v_tenant_id, TG_TABLE_SCHEMA, TG_TABLE_NAME, NEW.id,
|
|
||||||
NEW.created_by, 'create',
|
|
||||||
jsonb_build_object('operation', 'INSERT')
|
|
||||||
);
|
|
||||||
RETURN NEW;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- Registrar eliminación (soft delete)
|
|
||||||
IF TG_OP = 'UPDATE' AND OLD.deleted_at IS NULL AND NEW.deleted_at IS NOT NULL THEN
|
|
||||||
INSERT INTO system.change_log (
|
|
||||||
tenant_id, table_schema, table_name, record_id,
|
|
||||||
changed_by, change_type, change_context
|
|
||||||
) VALUES (
|
|
||||||
v_tenant_id, TG_TABLE_SCHEMA, TG_TABLE_NAME, NEW.id,
|
|
||||||
NEW.deleted_by, 'delete',
|
|
||||||
jsonb_build_object('operation', 'SOFT_DELETE', 'deleted_at', NEW.deleted_at)
|
|
||||||
);
|
|
||||||
RETURN NEW;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- Registrar cambios en campos configurados
|
|
||||||
IF TG_OP = 'UPDATE' THEN
|
|
||||||
-- Iterar sobre campos configurados para esta tabla
|
|
||||||
FOR v_field_config IN
|
|
||||||
SELECT field_name, display_label, field_type
|
|
||||||
FROM system.field_tracking_config
|
|
||||||
WHERE table_schema = TG_TABLE_SCHEMA
|
|
||||||
AND table_name = TG_TABLE_NAME
|
|
||||||
AND track_changes = true
|
|
||||||
LOOP
|
|
||||||
v_field_name := v_field_config.field_name;
|
|
||||||
v_field_label := v_field_config.display_label;
|
|
||||||
|
|
||||||
-- Obtener valores antiguo y nuevo (usar EXECUTE para campos dinámicos)
|
|
||||||
EXECUTE format('SELECT ($1).%I::TEXT, ($2).%I::TEXT', v_field_name, v_field_name)
|
|
||||||
INTO v_old_value, v_new_value
|
|
||||||
USING OLD, NEW;
|
|
||||||
|
|
||||||
-- Si el valor cambió, registrarlo
|
|
||||||
IF v_old_value IS DISTINCT FROM v_new_value THEN
|
|
||||||
INSERT INTO system.change_log (
|
|
||||||
tenant_id, table_schema, table_name, record_id,
|
|
||||||
changed_by, change_type, field_name, field_label,
|
|
||||||
old_value, new_value, change_context
|
|
||||||
) VALUES (
|
|
||||||
v_tenant_id, TG_TABLE_SCHEMA, TG_TABLE_NAME, NEW.id,
|
|
||||||
v_user_id,
|
|
||||||
CASE
|
|
||||||
WHEN v_field_name = 'status' OR v_field_name = 'state' THEN 'state_change'
|
|
||||||
ELSE 'update'
|
|
||||||
END,
|
|
||||||
v_field_name, v_field_label,
|
|
||||||
v_old_value, v_new_value,
|
|
||||||
jsonb_build_object('operation', 'UPDATE', 'field_type', v_field_config.field_type)
|
|
||||||
);
|
|
||||||
END IF;
|
|
||||||
END LOOP;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION system.track_field_changes IS
|
|
||||||
'Función trigger para trackear cambios automáticamente según configuración en field_tracking_config (patrón mail.thread de Odoo)';
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- SEED DATA: Configuración de campos a trackear
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- FINANCIAL: Facturas
|
|
||||||
INSERT INTO system.field_tracking_config (table_schema, table_name, field_name, field_type, display_label) VALUES
|
|
||||||
('financial', 'invoices', 'status', 'text', 'Estado'),
|
|
||||||
('financial', 'invoices', 'partner_id', 'uuid', 'Cliente/Proveedor'),
|
|
||||||
('financial', 'invoices', 'invoice_date', 'timestamp', 'Fecha de Factura'),
|
|
||||||
('financial', 'invoices', 'amount_total', 'numeric', 'Monto Total'),
|
|
||||||
('financial', 'invoices', 'payment_term_id', 'uuid', 'Término de Pago')
|
|
||||||
ON CONFLICT (table_schema, table_name, field_name) DO NOTHING;
|
|
||||||
|
|
||||||
-- FINANCIAL: Asientos contables
|
|
||||||
INSERT INTO system.field_tracking_config (table_schema, table_name, field_name, field_type, display_label) VALUES
|
|
||||||
('financial', 'journal_entries', 'status', 'text', 'Estado'),
|
|
||||||
('financial', 'journal_entries', 'date', 'timestamp', 'Fecha del Asiento'),
|
|
||||||
('financial', 'journal_entries', 'journal_id', 'uuid', 'Diario Contable')
|
|
||||||
ON CONFLICT (table_schema, table_name, field_name) DO NOTHING;
|
|
||||||
|
|
||||||
-- PURCHASE: Órdenes de compra
|
|
||||||
INSERT INTO system.field_tracking_config (table_schema, table_name, field_name, field_type, display_label) VALUES
|
|
||||||
('purchase', 'purchase_orders', 'status', 'text', 'Estado'),
|
|
||||||
('purchase', 'purchase_orders', 'partner_id', 'uuid', 'Proveedor'),
|
|
||||||
('purchase', 'purchase_orders', 'order_date', 'timestamp', 'Fecha de Orden'),
|
|
||||||
('purchase', 'purchase_orders', 'amount_total', 'numeric', 'Monto Total'),
|
|
||||||
('purchase', 'purchase_orders', 'receipt_status', 'text', 'Estado de Recepción')
|
|
||||||
ON CONFLICT (table_schema, table_name, field_name) DO NOTHING;
|
|
||||||
|
|
||||||
-- SALES: Órdenes de venta
|
|
||||||
INSERT INTO system.field_tracking_config (table_schema, table_name, field_name, field_type, display_label) VALUES
|
|
||||||
('sales', 'sales_orders', 'status', 'text', 'Estado'),
|
|
||||||
('sales', 'sales_orders', 'partner_id', 'uuid', 'Cliente'),
|
|
||||||
('sales', 'sales_orders', 'order_date', 'timestamp', 'Fecha de Orden'),
|
|
||||||
('sales', 'sales_orders', 'amount_total', 'numeric', 'Monto Total'),
|
|
||||||
('sales', 'sales_orders', 'invoice_status', 'text', 'Estado de Facturación'),
|
|
||||||
('sales', 'sales_orders', 'delivery_status', 'text', 'Estado de Entrega')
|
|
||||||
ON CONFLICT (table_schema, table_name, field_name) DO NOTHING;
|
|
||||||
|
|
||||||
-- INVENTORY: Movimientos de stock
|
|
||||||
INSERT INTO system.field_tracking_config (table_schema, table_name, field_name, field_type, display_label) VALUES
|
|
||||||
('inventory', 'stock_moves', 'status', 'text', 'Estado'),
|
|
||||||
('inventory', 'stock_moves', 'product_id', 'uuid', 'Producto'),
|
|
||||||
('inventory', 'stock_moves', 'product_qty', 'numeric', 'Cantidad'),
|
|
||||||
('inventory', 'stock_moves', 'location_id', 'uuid', 'Ubicación Origen'),
|
|
||||||
('inventory', 'stock_moves', 'location_dest_id', 'uuid', 'Ubicación Destino')
|
|
||||||
ON CONFLICT (table_schema, table_name, field_name) DO NOTHING;
|
|
||||||
|
|
||||||
-- PROJECTS: Proyectos
|
|
||||||
INSERT INTO system.field_tracking_config (table_schema, table_name, field_name, field_type, display_label) VALUES
|
|
||||||
('projects', 'projects', 'status', 'text', 'Estado'),
|
|
||||||
('projects', 'projects', 'name', 'text', 'Nombre del Proyecto'),
|
|
||||||
('projects', 'projects', 'manager_id', 'uuid', 'Responsable'),
|
|
||||||
('projects', 'projects', 'date_start', 'timestamp', 'Fecha de Inicio'),
|
|
||||||
('projects', 'projects', 'date_end', 'timestamp', 'Fecha de Fin')
|
|
||||||
ON CONFLICT (table_schema, table_name, field_name) DO NOTHING;
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- COMENTARIOS
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
COMMENT ON SCHEMA system IS 'Schema de mensajería, notificaciones, logs, reportes y tracking automático';
|
|
||||||
COMMENT ON TABLE system.messages IS 'Mensajes del chatter (comentarios, notas, emails)';
|
|
||||||
COMMENT ON TABLE system.message_followers IS 'Seguidores de registros para notificaciones';
|
|
||||||
COMMENT ON TABLE system.notifications IS 'Notificaciones a usuarios';
|
|
||||||
COMMENT ON TABLE system.activities IS 'Actividades programadas (llamadas, reuniones, tareas)';
|
|
||||||
COMMENT ON TABLE system.message_templates IS 'Plantillas de mensajes y emails';
|
|
||||||
COMMENT ON TABLE system.email_queue IS 'Cola de envío de emails';
|
|
||||||
COMMENT ON TABLE system.logs IS 'Logs del sistema y auditoría';
|
|
||||||
COMMENT ON TABLE system.reports IS 'Definiciones de reportes';
|
|
||||||
COMMENT ON TABLE system.report_executions IS 'Ejecuciones de reportes con resultados';
|
|
||||||
COMMENT ON TABLE system.dashboards IS 'Dashboards configurables por usuario';
|
|
||||||
COMMENT ON TABLE system.dashboard_widgets IS 'Widgets dentro de dashboards';
|
|
||||||
COMMENT ON TABLE system.field_tracking_config IS 'Configuración de campos a trackear automáticamente por tabla (patrón mail.thread de Odoo)';
|
|
||||||
COMMENT ON TABLE system.change_log IS 'Historial de cambios en registros (mail.thread pattern de Odoo). Registra automáticamente cambios de estado y campos críticos.';
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- FIN DEL SCHEMA SYSTEM
|
|
||||||
-- =====================================================
|
|
||||||
793
ddl/10-audit.sql
Normal file
793
ddl/10-audit.sql
Normal file
@ -0,0 +1,793 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- ARCHIVO: 10-audit.sql
|
||||||
|
-- DESCRIPCION: Sistema de audit trail, cambios de entidades, logs
|
||||||
|
-- VERSION: 1.0.0
|
||||||
|
-- PROYECTO: ERP-Core V2
|
||||||
|
-- FECHA: 2026-01-10
|
||||||
|
-- EPIC: SAAS-AUDIT (EPIC-SAAS-004)
|
||||||
|
-- HISTORIAS: US-050, US-051, US-052
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- SCHEMA: audit
|
||||||
|
-- =====================
|
||||||
|
CREATE SCHEMA IF NOT EXISTS audit;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: audit.audit_logs
|
||||||
|
-- Log de auditoría general
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS audit.audit_logs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Actor
|
||||||
|
user_id UUID REFERENCES auth.users(id),
|
||||||
|
user_email VARCHAR(255),
|
||||||
|
user_name VARCHAR(200),
|
||||||
|
session_id UUID,
|
||||||
|
impersonator_id UUID REFERENCES auth.users(id), -- Si está siendo impersonado
|
||||||
|
|
||||||
|
-- Acción
|
||||||
|
action VARCHAR(50) NOT NULL, -- create, read, update, delete, login, logout, export, etc.
|
||||||
|
action_category VARCHAR(50), -- data, auth, system, config, billing
|
||||||
|
|
||||||
|
-- Recurso
|
||||||
|
resource_type VARCHAR(100) NOT NULL, -- user, product, sale, branch, etc.
|
||||||
|
resource_id UUID,
|
||||||
|
resource_name VARCHAR(255),
|
||||||
|
|
||||||
|
-- Cambios
|
||||||
|
old_values JSONB,
|
||||||
|
new_values JSONB,
|
||||||
|
changed_fields TEXT[],
|
||||||
|
|
||||||
|
-- Contexto
|
||||||
|
ip_address INET,
|
||||||
|
user_agent TEXT,
|
||||||
|
device_info JSONB DEFAULT '{}',
|
||||||
|
location JSONB DEFAULT '{}', -- {country, city, lat, lng}
|
||||||
|
|
||||||
|
-- Request info
|
||||||
|
request_id VARCHAR(100),
|
||||||
|
request_method VARCHAR(10),
|
||||||
|
request_path TEXT,
|
||||||
|
request_params JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Resultado
|
||||||
|
status VARCHAR(20) DEFAULT 'success', -- success, failure, partial
|
||||||
|
error_message TEXT,
|
||||||
|
duration_ms INTEGER,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
tags TEXT[] DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Timestamp
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Particionar por fecha para mejor rendimiento (recomendado en producción)
|
||||||
|
-- CREATE TABLE audit.audit_logs_y2026m01 PARTITION OF audit.audit_logs
|
||||||
|
-- FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: audit.entity_changes
|
||||||
|
-- Historial detallado de cambios por entidad
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS audit.entity_changes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Entidad
|
||||||
|
entity_type VARCHAR(100) NOT NULL,
|
||||||
|
entity_id UUID NOT NULL,
|
||||||
|
entity_name VARCHAR(255),
|
||||||
|
|
||||||
|
-- Versión
|
||||||
|
version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
previous_version INTEGER,
|
||||||
|
|
||||||
|
-- Snapshot completo de la entidad
|
||||||
|
data_snapshot JSONB NOT NULL,
|
||||||
|
|
||||||
|
-- Cambios específicos
|
||||||
|
changes JSONB DEFAULT '[]',
|
||||||
|
-- Ejemplo: [{"field": "price", "old": 100, "new": 150, "type": "update"}]
|
||||||
|
|
||||||
|
-- Actor
|
||||||
|
changed_by UUID REFERENCES auth.users(id),
|
||||||
|
change_reason TEXT,
|
||||||
|
|
||||||
|
-- Tipo de cambio
|
||||||
|
change_type VARCHAR(20) NOT NULL, -- create, update, delete, restore
|
||||||
|
|
||||||
|
-- Timestamp
|
||||||
|
changed_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Índice único para versiones
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_entity_version ON audit.entity_changes(entity_type, entity_id, version);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: audit.sensitive_data_access
|
||||||
|
-- Acceso a datos sensibles
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS audit.sensitive_data_access (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Actor
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id),
|
||||||
|
session_id UUID,
|
||||||
|
|
||||||
|
-- Datos accedidos
|
||||||
|
data_type VARCHAR(100) NOT NULL, -- pii, financial, medical, credentials
|
||||||
|
data_category VARCHAR(100), -- customer_data, employee_data, payment_info
|
||||||
|
entity_type VARCHAR(100),
|
||||||
|
entity_id UUID,
|
||||||
|
|
||||||
|
-- Acción
|
||||||
|
access_type VARCHAR(30) NOT NULL, -- view, export, modify, decrypt
|
||||||
|
access_reason TEXT,
|
||||||
|
|
||||||
|
-- Contexto
|
||||||
|
ip_address INET,
|
||||||
|
user_agent TEXT,
|
||||||
|
|
||||||
|
-- Resultado
|
||||||
|
was_authorized BOOLEAN DEFAULT TRUE,
|
||||||
|
denial_reason TEXT,
|
||||||
|
|
||||||
|
-- Timestamp
|
||||||
|
accessed_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: audit.data_exports
|
||||||
|
-- Log de exportaciones de datos
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS audit.data_exports (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Actor
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id),
|
||||||
|
|
||||||
|
-- Exportación
|
||||||
|
export_type VARCHAR(50) NOT NULL, -- report, backup, gdpr_request, bulk_export
|
||||||
|
export_format VARCHAR(20), -- csv, xlsx, pdf, json
|
||||||
|
entity_types TEXT[] NOT NULL,
|
||||||
|
|
||||||
|
-- Filtros aplicados
|
||||||
|
filters JSONB DEFAULT '{}',
|
||||||
|
date_range_start TIMESTAMPTZ,
|
||||||
|
date_range_end TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Resultado
|
||||||
|
record_count INTEGER,
|
||||||
|
file_size_bytes BIGINT,
|
||||||
|
file_hash VARCHAR(64), -- SHA-256 del archivo
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
status VARCHAR(20) DEFAULT 'pending', -- pending, processing, completed, failed, expired
|
||||||
|
|
||||||
|
-- Archivos
|
||||||
|
download_url TEXT,
|
||||||
|
download_expires_at TIMESTAMPTZ,
|
||||||
|
download_count INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
-- Contexto
|
||||||
|
ip_address INET,
|
||||||
|
user_agent TEXT,
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
requested_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
expires_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: audit.login_history
|
||||||
|
-- Historial de inicios de sesión
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS audit.login_history (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Identificación del usuario
|
||||||
|
email VARCHAR(255),
|
||||||
|
username VARCHAR(100),
|
||||||
|
|
||||||
|
-- Resultado
|
||||||
|
status VARCHAR(20) NOT NULL, -- success, failed, blocked, mfa_required, mfa_failed
|
||||||
|
|
||||||
|
-- Método de autenticación
|
||||||
|
auth_method VARCHAR(30), -- password, sso, oauth, mfa, magic_link, biometric
|
||||||
|
oauth_provider VARCHAR(30),
|
||||||
|
|
||||||
|
-- MFA
|
||||||
|
mfa_method VARCHAR(20), -- totp, sms, email, push
|
||||||
|
mfa_verified BOOLEAN,
|
||||||
|
|
||||||
|
-- Dispositivo
|
||||||
|
device_id UUID REFERENCES auth.devices(id),
|
||||||
|
device_fingerprint VARCHAR(255),
|
||||||
|
device_type VARCHAR(30), -- desktop, mobile, tablet
|
||||||
|
device_os VARCHAR(50),
|
||||||
|
device_browser VARCHAR(50),
|
||||||
|
|
||||||
|
-- Ubicación
|
||||||
|
ip_address INET,
|
||||||
|
user_agent TEXT,
|
||||||
|
country_code VARCHAR(2),
|
||||||
|
city VARCHAR(100),
|
||||||
|
latitude DECIMAL(10, 8),
|
||||||
|
longitude DECIMAL(11, 8),
|
||||||
|
|
||||||
|
-- Riesgo
|
||||||
|
risk_score INTEGER, -- 0-100
|
||||||
|
risk_factors JSONB DEFAULT '[]',
|
||||||
|
is_suspicious BOOLEAN DEFAULT FALSE,
|
||||||
|
is_new_device BOOLEAN DEFAULT FALSE,
|
||||||
|
is_new_location BOOLEAN DEFAULT FALSE,
|
||||||
|
|
||||||
|
-- Error info
|
||||||
|
failure_reason VARCHAR(100),
|
||||||
|
failure_count INTEGER,
|
||||||
|
|
||||||
|
-- Timestamp
|
||||||
|
attempted_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: audit.permission_changes
|
||||||
|
-- Cambios en permisos y roles
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS audit.permission_changes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Actor
|
||||||
|
changed_by UUID NOT NULL REFERENCES auth.users(id),
|
||||||
|
|
||||||
|
-- Usuario afectado
|
||||||
|
target_user_id UUID NOT NULL REFERENCES auth.users(id),
|
||||||
|
target_user_email VARCHAR(255),
|
||||||
|
|
||||||
|
-- Tipo de cambio
|
||||||
|
change_type VARCHAR(30) NOT NULL, -- role_assigned, role_revoked, permission_granted, permission_revoked
|
||||||
|
|
||||||
|
-- Rol/Permiso
|
||||||
|
role_id UUID,
|
||||||
|
role_code VARCHAR(50),
|
||||||
|
permission_id UUID,
|
||||||
|
permission_code VARCHAR(100),
|
||||||
|
|
||||||
|
-- Contexto
|
||||||
|
branch_id UUID REFERENCES core.branches(id),
|
||||||
|
scope VARCHAR(30), -- global, tenant, branch
|
||||||
|
|
||||||
|
-- Valores anteriores
|
||||||
|
previous_roles TEXT[],
|
||||||
|
previous_permissions TEXT[],
|
||||||
|
|
||||||
|
-- Razón
|
||||||
|
reason TEXT,
|
||||||
|
|
||||||
|
-- Timestamp
|
||||||
|
changed_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: audit.config_changes
|
||||||
|
-- Cambios en configuración del sistema
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS audit.config_changes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES auth.tenants(id) ON DELETE CASCADE, -- NULL = config global
|
||||||
|
|
||||||
|
-- Actor
|
||||||
|
changed_by UUID NOT NULL REFERENCES auth.users(id),
|
||||||
|
|
||||||
|
-- Configuración
|
||||||
|
config_type VARCHAR(50) NOT NULL, -- tenant_settings, user_settings, system_settings, feature_flags
|
||||||
|
config_key VARCHAR(100) NOT NULL,
|
||||||
|
config_path TEXT, -- Path jerárquico: billing.invoicing.prefix
|
||||||
|
|
||||||
|
-- Valores
|
||||||
|
old_value JSONB,
|
||||||
|
new_value JSONB,
|
||||||
|
|
||||||
|
-- Contexto
|
||||||
|
reason TEXT,
|
||||||
|
ticket_id VARCHAR(50), -- Referencia a ticket de soporte
|
||||||
|
|
||||||
|
-- Timestamp
|
||||||
|
changed_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- INDICES
|
||||||
|
-- =====================
|
||||||
|
|
||||||
|
-- Indices para audit_logs
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_logs_tenant ON audit.audit_logs(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_logs_user ON audit.audit_logs(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_logs_action ON audit.audit_logs(action);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_logs_resource ON audit.audit_logs(resource_type, resource_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit.audit_logs(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_logs_category ON audit.audit_logs(action_category);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_logs_status ON audit.audit_logs(status) WHERE status = 'failure';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_logs_tags ON audit.audit_logs USING GIN(tags);
|
||||||
|
|
||||||
|
-- Indices para entity_changes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_entity_changes_tenant ON audit.entity_changes(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_entity_changes_entity ON audit.entity_changes(entity_type, entity_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_entity_changes_changed_by ON audit.entity_changes(changed_by);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_entity_changes_date ON audit.entity_changes(changed_at DESC);
|
||||||
|
|
||||||
|
-- Indices para sensitive_data_access
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sensitive_access_tenant ON audit.sensitive_data_access(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sensitive_access_user ON audit.sensitive_data_access(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sensitive_access_type ON audit.sensitive_data_access(data_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sensitive_access_date ON audit.sensitive_data_access(accessed_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sensitive_unauthorized ON audit.sensitive_data_access(was_authorized)
|
||||||
|
WHERE was_authorized = FALSE;
|
||||||
|
|
||||||
|
-- Indices para data_exports
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_exports_tenant ON audit.data_exports(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_exports_user ON audit.data_exports(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_exports_status ON audit.data_exports(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_exports_date ON audit.data_exports(requested_at DESC);
|
||||||
|
|
||||||
|
-- Indices para login_history
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_login_tenant ON audit.login_history(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_login_user ON audit.login_history(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_login_status ON audit.login_history(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_login_date ON audit.login_history(attempted_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_login_ip ON audit.login_history(ip_address);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_login_suspicious ON audit.login_history(is_suspicious) WHERE is_suspicious = TRUE;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_login_failed ON audit.login_history(status, email) WHERE status = 'failed';
|
||||||
|
|
||||||
|
-- Indices para permission_changes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_perm_changes_tenant ON audit.permission_changes(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_perm_changes_target ON audit.permission_changes(target_user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_perm_changes_date ON audit.permission_changes(changed_at DESC);
|
||||||
|
|
||||||
|
-- Indices para config_changes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_config_changes_tenant ON audit.config_changes(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_config_changes_type ON audit.config_changes(config_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_config_changes_date ON audit.config_changes(changed_at DESC);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- RLS POLICIES
|
||||||
|
-- =====================
|
||||||
|
|
||||||
|
ALTER TABLE audit.audit_logs ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_audit_logs ON audit.audit_logs
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
ALTER TABLE audit.entity_changes ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_entity_changes ON audit.entity_changes
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
ALTER TABLE audit.sensitive_data_access ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_sensitive ON audit.sensitive_data_access
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
ALTER TABLE audit.data_exports ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_exports ON audit.data_exports
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
ALTER TABLE audit.login_history ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_login ON audit.login_history
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
ALTER TABLE audit.permission_changes ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_perm_changes ON audit.permission_changes
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
ALTER TABLE audit.config_changes ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_config_changes ON audit.config_changes
|
||||||
|
USING (
|
||||||
|
tenant_id IS NULL
|
||||||
|
OR tenant_id = current_setting('app.current_tenant_id', true)::uuid
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- FUNCIONES
|
||||||
|
-- =====================
|
||||||
|
|
||||||
|
-- Función para registrar log de auditoría
|
||||||
|
CREATE OR REPLACE FUNCTION audit.log(
|
||||||
|
p_tenant_id UUID,
|
||||||
|
p_user_id UUID,
|
||||||
|
p_action VARCHAR(50),
|
||||||
|
p_resource_type VARCHAR(100),
|
||||||
|
p_resource_id UUID DEFAULT NULL,
|
||||||
|
p_old_values JSONB DEFAULT NULL,
|
||||||
|
p_new_values JSONB DEFAULT NULL,
|
||||||
|
p_metadata JSONB DEFAULT '{}'
|
||||||
|
)
|
||||||
|
RETURNS UUID AS $$
|
||||||
|
DECLARE
|
||||||
|
v_log_id UUID;
|
||||||
|
v_changed_fields TEXT[];
|
||||||
|
BEGIN
|
||||||
|
-- Calcular campos cambiados
|
||||||
|
IF p_old_values IS NOT NULL AND p_new_values IS NOT NULL THEN
|
||||||
|
SELECT ARRAY_AGG(key)
|
||||||
|
INTO v_changed_fields
|
||||||
|
FROM (
|
||||||
|
SELECT key FROM jsonb_object_keys(p_old_values) AS key
|
||||||
|
WHERE p_old_values->key IS DISTINCT FROM p_new_values->key
|
||||||
|
UNION
|
||||||
|
SELECT key FROM jsonb_object_keys(p_new_values) AS key
|
||||||
|
WHERE NOT p_old_values ? key
|
||||||
|
) AS changed;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO audit.audit_logs (
|
||||||
|
tenant_id, user_id, action, resource_type, resource_id,
|
||||||
|
old_values, new_values, changed_fields, metadata
|
||||||
|
) VALUES (
|
||||||
|
p_tenant_id, p_user_id, p_action, p_resource_type, p_resource_id,
|
||||||
|
p_old_values, p_new_values, v_changed_fields, p_metadata
|
||||||
|
) RETURNING id INTO v_log_id;
|
||||||
|
|
||||||
|
RETURN v_log_id;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Función para registrar cambio de entidad con versionamiento
|
||||||
|
CREATE OR REPLACE FUNCTION audit.log_entity_change(
|
||||||
|
p_tenant_id UUID,
|
||||||
|
p_entity_type VARCHAR(100),
|
||||||
|
p_entity_id UUID,
|
||||||
|
p_data_snapshot JSONB,
|
||||||
|
p_changes JSONB DEFAULT '[]',
|
||||||
|
p_changed_by UUID DEFAULT NULL,
|
||||||
|
p_change_type VARCHAR(20) DEFAULT 'update',
|
||||||
|
p_change_reason TEXT DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS INTEGER AS $$
|
||||||
|
DECLARE
|
||||||
|
v_version INTEGER;
|
||||||
|
v_prev_version INTEGER;
|
||||||
|
BEGIN
|
||||||
|
-- Obtener versión actual
|
||||||
|
SELECT COALESCE(MAX(version), 0) INTO v_prev_version
|
||||||
|
FROM audit.entity_changes
|
||||||
|
WHERE entity_type = p_entity_type AND entity_id = p_entity_id;
|
||||||
|
|
||||||
|
v_version := v_prev_version + 1;
|
||||||
|
|
||||||
|
INSERT INTO audit.entity_changes (
|
||||||
|
tenant_id, entity_type, entity_id, version, previous_version,
|
||||||
|
data_snapshot, changes, changed_by, change_type, change_reason
|
||||||
|
) VALUES (
|
||||||
|
p_tenant_id, p_entity_type, p_entity_id, v_version, v_prev_version,
|
||||||
|
p_data_snapshot, p_changes, p_changed_by, p_change_type, p_change_reason
|
||||||
|
);
|
||||||
|
|
||||||
|
RETURN v_version;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Función para obtener historial de una entidad
|
||||||
|
CREATE OR REPLACE FUNCTION audit.get_entity_history(
|
||||||
|
p_entity_type VARCHAR(100),
|
||||||
|
p_entity_id UUID,
|
||||||
|
p_limit INTEGER DEFAULT 50
|
||||||
|
)
|
||||||
|
RETURNS TABLE (
|
||||||
|
version INTEGER,
|
||||||
|
change_type VARCHAR(20),
|
||||||
|
data_snapshot JSONB,
|
||||||
|
changes JSONB,
|
||||||
|
changed_by UUID,
|
||||||
|
change_reason TEXT,
|
||||||
|
changed_at TIMESTAMPTZ
|
||||||
|
) AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
ec.version,
|
||||||
|
ec.change_type,
|
||||||
|
ec.data_snapshot,
|
||||||
|
ec.changes,
|
||||||
|
ec.changed_by,
|
||||||
|
ec.change_reason,
|
||||||
|
ec.changed_at
|
||||||
|
FROM audit.entity_changes ec
|
||||||
|
WHERE ec.entity_type = p_entity_type
|
||||||
|
AND ec.entity_id = p_entity_id
|
||||||
|
ORDER BY ec.version DESC
|
||||||
|
LIMIT p_limit;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE;
|
||||||
|
|
||||||
|
-- Función para obtener snapshot de una entidad en un momento dado
|
||||||
|
CREATE OR REPLACE FUNCTION audit.get_entity_at_time(
|
||||||
|
p_entity_type VARCHAR(100),
|
||||||
|
p_entity_id UUID,
|
||||||
|
p_at_time TIMESTAMPTZ
|
||||||
|
)
|
||||||
|
RETURNS JSONB AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN (
|
||||||
|
SELECT data_snapshot
|
||||||
|
FROM audit.entity_changes
|
||||||
|
WHERE entity_type = p_entity_type
|
||||||
|
AND entity_id = p_entity_id
|
||||||
|
AND changed_at <= p_at_time
|
||||||
|
ORDER BY changed_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE;
|
||||||
|
|
||||||
|
-- Función para registrar acceso a datos sensibles
|
||||||
|
CREATE OR REPLACE FUNCTION audit.log_sensitive_access(
|
||||||
|
p_tenant_id UUID,
|
||||||
|
p_user_id UUID,
|
||||||
|
p_data_type VARCHAR(100),
|
||||||
|
p_access_type VARCHAR(30),
|
||||||
|
p_entity_type VARCHAR(100) DEFAULT NULL,
|
||||||
|
p_entity_id UUID DEFAULT NULL,
|
||||||
|
p_was_authorized BOOLEAN DEFAULT TRUE,
|
||||||
|
p_access_reason TEXT DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS UUID AS $$
|
||||||
|
DECLARE
|
||||||
|
v_access_id UUID;
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO audit.sensitive_data_access (
|
||||||
|
tenant_id, user_id, data_type, access_type,
|
||||||
|
entity_type, entity_id, was_authorized, access_reason
|
||||||
|
) VALUES (
|
||||||
|
p_tenant_id, p_user_id, p_data_type, p_access_type,
|
||||||
|
p_entity_type, p_entity_id, p_was_authorized, p_access_reason
|
||||||
|
) RETURNING id INTO v_access_id;
|
||||||
|
|
||||||
|
RETURN v_access_id;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Función para registrar login
|
||||||
|
CREATE OR REPLACE FUNCTION audit.log_login(
|
||||||
|
p_user_id UUID,
|
||||||
|
p_tenant_id UUID,
|
||||||
|
p_email VARCHAR(255),
|
||||||
|
p_status VARCHAR(20),
|
||||||
|
p_auth_method VARCHAR(30) DEFAULT 'password',
|
||||||
|
p_ip_address INET DEFAULT NULL,
|
||||||
|
p_user_agent TEXT DEFAULT NULL,
|
||||||
|
p_device_info JSONB DEFAULT '{}'
|
||||||
|
)
|
||||||
|
RETURNS UUID AS $$
|
||||||
|
DECLARE
|
||||||
|
v_login_id UUID;
|
||||||
|
v_is_new_device BOOLEAN := FALSE;
|
||||||
|
v_is_new_location BOOLEAN := FALSE;
|
||||||
|
v_failure_count INTEGER := 0;
|
||||||
|
BEGIN
|
||||||
|
-- Verificar si es dispositivo nuevo
|
||||||
|
IF p_device_info->>'fingerprint' IS NOT NULL THEN
|
||||||
|
SELECT NOT EXISTS (
|
||||||
|
SELECT 1 FROM audit.login_history
|
||||||
|
WHERE user_id = p_user_id
|
||||||
|
AND device_fingerprint = p_device_info->>'fingerprint'
|
||||||
|
AND status = 'success'
|
||||||
|
) INTO v_is_new_device;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Contar intentos fallidos recientes
|
||||||
|
IF p_status = 'failed' THEN
|
||||||
|
SELECT COUNT(*) INTO v_failure_count
|
||||||
|
FROM audit.login_history
|
||||||
|
WHERE email = p_email
|
||||||
|
AND status = 'failed'
|
||||||
|
AND attempted_at > CURRENT_TIMESTAMP - INTERVAL '1 hour';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO audit.login_history (
|
||||||
|
user_id, tenant_id, email, status, auth_method,
|
||||||
|
ip_address, user_agent,
|
||||||
|
device_fingerprint, device_type, device_os, device_browser,
|
||||||
|
is_new_device, failure_count
|
||||||
|
) VALUES (
|
||||||
|
p_user_id, p_tenant_id, p_email, p_status, p_auth_method,
|
||||||
|
p_ip_address, p_user_agent,
|
||||||
|
p_device_info->>'fingerprint',
|
||||||
|
p_device_info->>'type',
|
||||||
|
p_device_info->>'os',
|
||||||
|
p_device_info->>'browser',
|
||||||
|
v_is_new_device, v_failure_count
|
||||||
|
) RETURNING id INTO v_login_id;
|
||||||
|
|
||||||
|
RETURN v_login_id;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Función para obtener estadísticas de auditoría
|
||||||
|
CREATE OR REPLACE FUNCTION audit.get_stats(
|
||||||
|
p_tenant_id UUID,
|
||||||
|
p_days INTEGER DEFAULT 30
|
||||||
|
)
|
||||||
|
RETURNS TABLE (
|
||||||
|
total_actions BIGINT,
|
||||||
|
unique_users BIGINT,
|
||||||
|
actions_by_category JSONB,
|
||||||
|
actions_by_day JSONB,
|
||||||
|
top_resources JSONB,
|
||||||
|
failed_actions BIGINT
|
||||||
|
) AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_actions,
|
||||||
|
COUNT(DISTINCT al.user_id) as unique_users,
|
||||||
|
jsonb_object_agg(COALESCE(al.action_category, 'other'), cat_count) as actions_by_category,
|
||||||
|
jsonb_object_agg(day_date, day_count) as actions_by_day,
|
||||||
|
jsonb_agg(DISTINCT jsonb_build_object('type', al.resource_type, 'count', res_count)) as top_resources,
|
||||||
|
COUNT(*) FILTER (WHERE al.status = 'failure') as failed_actions
|
||||||
|
FROM audit.audit_logs al
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT action_category, COUNT(*) as cat_count
|
||||||
|
FROM audit.audit_logs
|
||||||
|
WHERE tenant_id = p_tenant_id
|
||||||
|
AND created_at > CURRENT_TIMESTAMP - (p_days || ' days')::INTERVAL
|
||||||
|
GROUP BY action_category
|
||||||
|
) cat ON cat.action_category = al.action_category
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT DATE(created_at) as day_date, COUNT(*) as day_count
|
||||||
|
FROM audit.audit_logs
|
||||||
|
WHERE tenant_id = p_tenant_id
|
||||||
|
AND created_at > CURRENT_TIMESTAMP - (p_days || ' days')::INTERVAL
|
||||||
|
GROUP BY DATE(created_at)
|
||||||
|
) days ON TRUE
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT resource_type, COUNT(*) as res_count
|
||||||
|
FROM audit.audit_logs
|
||||||
|
WHERE tenant_id = p_tenant_id
|
||||||
|
AND created_at > CURRENT_TIMESTAMP - (p_days || ' days')::INTERVAL
|
||||||
|
GROUP BY resource_type
|
||||||
|
ORDER BY res_count DESC
|
||||||
|
LIMIT 10
|
||||||
|
) res ON res.resource_type = al.resource_type
|
||||||
|
WHERE al.tenant_id = p_tenant_id
|
||||||
|
AND al.created_at > CURRENT_TIMESTAMP - (p_days || ' days')::INTERVAL
|
||||||
|
LIMIT 1;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE;
|
||||||
|
|
||||||
|
-- Función para limpiar logs antiguos
|
||||||
|
CREATE OR REPLACE FUNCTION audit.cleanup_old_logs(
|
||||||
|
p_audit_days INTEGER DEFAULT 365,
|
||||||
|
p_login_days INTEGER DEFAULT 90,
|
||||||
|
p_export_days INTEGER DEFAULT 30
|
||||||
|
)
|
||||||
|
RETURNS TABLE (
|
||||||
|
audit_deleted INTEGER,
|
||||||
|
login_deleted INTEGER,
|
||||||
|
export_deleted INTEGER
|
||||||
|
) AS $$
|
||||||
|
DECLARE
|
||||||
|
v_audit INTEGER;
|
||||||
|
v_login INTEGER;
|
||||||
|
v_export INTEGER;
|
||||||
|
BEGIN
|
||||||
|
-- Limpiar audit_logs
|
||||||
|
DELETE FROM audit.audit_logs
|
||||||
|
WHERE created_at < CURRENT_TIMESTAMP - (p_audit_days || ' days')::INTERVAL;
|
||||||
|
GET DIAGNOSTICS v_audit = ROW_COUNT;
|
||||||
|
|
||||||
|
-- Limpiar login_history
|
||||||
|
DELETE FROM audit.login_history
|
||||||
|
WHERE attempted_at < CURRENT_TIMESTAMP - (p_login_days || ' days')::INTERVAL;
|
||||||
|
GET DIAGNOSTICS v_login = ROW_COUNT;
|
||||||
|
|
||||||
|
-- Limpiar data_exports completados/expirados
|
||||||
|
DELETE FROM audit.data_exports
|
||||||
|
WHERE (status IN ('completed', 'expired', 'failed'))
|
||||||
|
AND requested_at < CURRENT_TIMESTAMP - (p_export_days || ' days')::INTERVAL;
|
||||||
|
GET DIAGNOSTICS v_export = ROW_COUNT;
|
||||||
|
|
||||||
|
RETURN QUERY SELECT v_audit, v_login, v_export;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TRIGGER GENÉRICO PARA AUDITORÍA
|
||||||
|
-- =====================
|
||||||
|
|
||||||
|
-- Función trigger para auditoría automática
|
||||||
|
CREATE OR REPLACE FUNCTION audit.audit_trigger_func()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
v_old_data JSONB;
|
||||||
|
v_new_data JSONB;
|
||||||
|
v_tenant_id UUID;
|
||||||
|
v_user_id UUID;
|
||||||
|
BEGIN
|
||||||
|
-- Obtener tenant_id y user_id del contexto
|
||||||
|
v_tenant_id := current_setting('app.current_tenant_id', true)::uuid;
|
||||||
|
v_user_id := current_setting('app.current_user_id', true)::uuid;
|
||||||
|
|
||||||
|
IF TG_OP = 'INSERT' THEN
|
||||||
|
v_new_data := to_jsonb(NEW);
|
||||||
|
|
||||||
|
PERFORM audit.log_entity_change(
|
||||||
|
v_tenant_id,
|
||||||
|
TG_TABLE_SCHEMA || '.' || TG_TABLE_NAME,
|
||||||
|
(v_new_data->>'id')::uuid,
|
||||||
|
v_new_data,
|
||||||
|
'[]'::jsonb,
|
||||||
|
v_user_id,
|
||||||
|
'create'
|
||||||
|
);
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
|
||||||
|
ELSIF TG_OP = 'UPDATE' THEN
|
||||||
|
v_old_data := to_jsonb(OLD);
|
||||||
|
v_new_data := to_jsonb(NEW);
|
||||||
|
|
||||||
|
-- Solo registrar si hay cambios reales
|
||||||
|
IF v_old_data IS DISTINCT FROM v_new_data THEN
|
||||||
|
PERFORM audit.log_entity_change(
|
||||||
|
v_tenant_id,
|
||||||
|
TG_TABLE_SCHEMA || '.' || TG_TABLE_NAME,
|
||||||
|
(v_new_data->>'id')::uuid,
|
||||||
|
v_new_data,
|
||||||
|
jsonb_build_array(jsonb_build_object(
|
||||||
|
'old', v_old_data,
|
||||||
|
'new', v_new_data
|
||||||
|
)),
|
||||||
|
v_user_id,
|
||||||
|
'update'
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
|
||||||
|
ELSIF TG_OP = 'DELETE' THEN
|
||||||
|
v_old_data := to_jsonb(OLD);
|
||||||
|
|
||||||
|
PERFORM audit.log_entity_change(
|
||||||
|
v_tenant_id,
|
||||||
|
TG_TABLE_SCHEMA || '.' || TG_TABLE_NAME,
|
||||||
|
(v_old_data->>'id')::uuid,
|
||||||
|
v_old_data,
|
||||||
|
'[]'::jsonb,
|
||||||
|
v_user_id,
|
||||||
|
'delete'
|
||||||
|
);
|
||||||
|
|
||||||
|
RETURN OLD;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- COMENTARIOS
|
||||||
|
-- =====================
|
||||||
|
COMMENT ON TABLE audit.audit_logs IS 'Log de auditoría general para todas las acciones';
|
||||||
|
COMMENT ON TABLE audit.entity_changes IS 'Historial de cambios con versionamiento por entidad';
|
||||||
|
COMMENT ON TABLE audit.sensitive_data_access IS 'Log de acceso a datos sensibles';
|
||||||
|
COMMENT ON TABLE audit.data_exports IS 'Registro de exportaciones de datos';
|
||||||
|
COMMENT ON TABLE audit.login_history IS 'Historial de intentos de inicio de sesión';
|
||||||
|
COMMENT ON TABLE audit.permission_changes IS 'Log de cambios en permisos y roles';
|
||||||
|
COMMENT ON TABLE audit.config_changes IS 'Log de cambios en configuración del sistema';
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION audit.log IS 'Registra una acción en el log de auditoría';
|
||||||
|
COMMENT ON FUNCTION audit.log_entity_change IS 'Registra un cambio versionado de una entidad';
|
||||||
|
COMMENT ON FUNCTION audit.get_entity_history IS 'Obtiene el historial de cambios de una entidad';
|
||||||
|
COMMENT ON FUNCTION audit.get_entity_at_time IS 'Obtiene el snapshot de una entidad en un momento específico';
|
||||||
|
COMMENT ON FUNCTION audit.log_login IS 'Registra un intento de inicio de sesión';
|
||||||
|
COMMENT ON FUNCTION audit.audit_trigger_func IS 'Función trigger para auditoría automática de tablas';
|
||||||
@ -1,638 +0,0 @@
|
|||||||
-- =====================================================
|
|
||||||
-- SCHEMA: billing
|
|
||||||
-- PROPÓSITO: Suscripciones SaaS, planes, pagos, facturación
|
|
||||||
-- MÓDULOS: MGN-015 (Billing y Suscripciones)
|
|
||||||
-- FECHA: 2025-11-24
|
|
||||||
-- =====================================================
|
|
||||||
-- NOTA: Este schema permite que el sistema opere como SaaS multi-tenant
|
|
||||||
-- o como instalación single-tenant (on-premise). En modo single-tenant,
|
|
||||||
-- las tablas de este schema pueden ignorarse o tener un único plan "unlimited".
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Crear schema
|
|
||||||
CREATE SCHEMA IF NOT EXISTS billing;
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- TYPES (ENUMs)
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
CREATE TYPE billing.subscription_status AS ENUM (
|
|
||||||
'trialing', -- En período de prueba
|
|
||||||
'active', -- Suscripción activa
|
|
||||||
'past_due', -- Pago atrasado
|
|
||||||
'paused', -- Suscripción pausada
|
|
||||||
'cancelled', -- Cancelada por usuario
|
|
||||||
'suspended', -- Suspendida por falta de pago
|
|
||||||
'expired' -- Expirada
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE billing.billing_cycle AS ENUM (
|
|
||||||
'monthly',
|
|
||||||
'quarterly',
|
|
||||||
'semi_annual',
|
|
||||||
'annual'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE billing.payment_method_type AS ENUM (
|
|
||||||
'card',
|
|
||||||
'bank_transfer',
|
|
||||||
'paypal',
|
|
||||||
'oxxo', -- México
|
|
||||||
'spei', -- México
|
|
||||||
'other'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE billing.invoice_status AS ENUM (
|
|
||||||
'draft',
|
|
||||||
'open',
|
|
||||||
'paid',
|
|
||||||
'void',
|
|
||||||
'uncollectible'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE billing.payment_status AS ENUM (
|
|
||||||
'pending',
|
|
||||||
'processing',
|
|
||||||
'succeeded',
|
|
||||||
'failed',
|
|
||||||
'cancelled',
|
|
||||||
'refunded'
|
|
||||||
);
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- TABLES
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Tabla: subscription_plans (Planes disponibles - global, no por tenant)
|
|
||||||
-- Esta tabla no tiene tenant_id porque los planes son globales del sistema SaaS
|
|
||||||
CREATE TABLE billing.subscription_plans (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
code VARCHAR(50) UNIQUE NOT NULL,
|
|
||||||
name VARCHAR(100) NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
|
|
||||||
-- Precios
|
|
||||||
price_monthly DECIMAL(12,2) NOT NULL DEFAULT 0,
|
|
||||||
price_yearly DECIMAL(12,2) NOT NULL DEFAULT 0,
|
|
||||||
currency_code VARCHAR(3) NOT NULL DEFAULT 'MXN',
|
|
||||||
|
|
||||||
-- Límites
|
|
||||||
max_users INTEGER DEFAULT 10,
|
|
||||||
max_companies INTEGER DEFAULT 1,
|
|
||||||
max_storage_gb INTEGER DEFAULT 5,
|
|
||||||
max_api_calls_month INTEGER DEFAULT 10000,
|
|
||||||
|
|
||||||
-- Características incluidas (JSON para flexibilidad)
|
|
||||||
features JSONB DEFAULT '{}'::jsonb,
|
|
||||||
-- Ejemplo: {"inventory": true, "sales": true, "financial": true, "purchase": true, "crm": false}
|
|
||||||
|
|
||||||
-- Metadata
|
|
||||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
is_public BOOLEAN NOT NULL DEFAULT true, -- Visible en página de precios
|
|
||||||
is_default BOOLEAN NOT NULL DEFAULT false, -- Plan por defecto para nuevos tenants
|
|
||||||
trial_days INTEGER DEFAULT 14,
|
|
||||||
sort_order INTEGER DEFAULT 0,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID,
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by UUID,
|
|
||||||
|
|
||||||
CONSTRAINT chk_plans_price_monthly CHECK (price_monthly >= 0),
|
|
||||||
CONSTRAINT chk_plans_price_yearly CHECK (price_yearly >= 0),
|
|
||||||
CONSTRAINT chk_plans_max_users CHECK (max_users > 0 OR max_users IS NULL),
|
|
||||||
CONSTRAINT chk_plans_trial_days CHECK (trial_days >= 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: tenant_owners (Propietarios/Contratantes de tenant)
|
|
||||||
-- Usuario(s) que contratan y pagan por el tenant
|
|
||||||
CREATE TABLE billing.tenant_owners (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
||||||
ownership_type VARCHAR(20) NOT NULL DEFAULT 'owner',
|
|
||||||
-- owner: Propietario principal (puede haber solo 1)
|
|
||||||
-- billing_admin: Puede gestionar facturación
|
|
||||||
|
|
||||||
-- Contacto de facturación (puede diferir del usuario)
|
|
||||||
billing_email VARCHAR(255),
|
|
||||||
billing_phone VARCHAR(50),
|
|
||||||
billing_name VARCHAR(255),
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID,
|
|
||||||
|
|
||||||
CONSTRAINT uq_tenant_owners UNIQUE (tenant_id, user_id),
|
|
||||||
CONSTRAINT chk_ownership_type CHECK (ownership_type IN ('owner', 'billing_admin'))
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: subscriptions (Suscripciones activas de cada tenant)
|
|
||||||
CREATE TABLE billing.subscriptions (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
plan_id UUID NOT NULL REFERENCES billing.subscription_plans(id),
|
|
||||||
|
|
||||||
-- Estado
|
|
||||||
status billing.subscription_status NOT NULL DEFAULT 'trialing',
|
|
||||||
billing_cycle billing.billing_cycle NOT NULL DEFAULT 'monthly',
|
|
||||||
|
|
||||||
-- Fechas importantes
|
|
||||||
trial_start_at TIMESTAMP,
|
|
||||||
trial_end_at TIMESTAMP,
|
|
||||||
current_period_start TIMESTAMP NOT NULL,
|
|
||||||
current_period_end TIMESTAMP NOT NULL,
|
|
||||||
cancelled_at TIMESTAMP,
|
|
||||||
cancel_at_period_end BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
paused_at TIMESTAMP,
|
|
||||||
|
|
||||||
-- Descuentos/Cupones
|
|
||||||
discount_percent DECIMAL(5,2) DEFAULT 0,
|
|
||||||
coupon_code VARCHAR(50),
|
|
||||||
|
|
||||||
-- Integración pasarela de pago
|
|
||||||
stripe_subscription_id VARCHAR(255),
|
|
||||||
stripe_customer_id VARCHAR(255),
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID,
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
updated_by UUID,
|
|
||||||
|
|
||||||
CONSTRAINT uq_subscriptions_tenant UNIQUE (tenant_id), -- Solo 1 suscripción activa por tenant
|
|
||||||
CONSTRAINT chk_subscriptions_discount CHECK (discount_percent >= 0 AND discount_percent <= 100),
|
|
||||||
CONSTRAINT chk_subscriptions_period CHECK (current_period_end > current_period_start)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: payment_methods (Métodos de pago por tenant)
|
|
||||||
CREATE TABLE billing.payment_methods (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
type billing.payment_method_type NOT NULL,
|
|
||||||
is_default BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
|
|
||||||
-- Información de tarjeta (solo últimos 4 dígitos por seguridad)
|
|
||||||
card_last_four VARCHAR(4),
|
|
||||||
card_brand VARCHAR(20), -- visa, mastercard, amex
|
|
||||||
card_exp_month INTEGER,
|
|
||||||
card_exp_year INTEGER,
|
|
||||||
|
|
||||||
-- Dirección de facturación
|
|
||||||
billing_name VARCHAR(255),
|
|
||||||
billing_email VARCHAR(255),
|
|
||||||
billing_address_line1 VARCHAR(255),
|
|
||||||
billing_address_line2 VARCHAR(255),
|
|
||||||
billing_city VARCHAR(100),
|
|
||||||
billing_state VARCHAR(100),
|
|
||||||
billing_postal_code VARCHAR(20),
|
|
||||||
billing_country VARCHAR(2), -- ISO 3166-1 alpha-2
|
|
||||||
|
|
||||||
-- Integración pasarela
|
|
||||||
stripe_payment_method_id VARCHAR(255),
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID,
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
deleted_at TIMESTAMP, -- Soft delete
|
|
||||||
|
|
||||||
CONSTRAINT chk_payment_methods_card_exp CHECK (
|
|
||||||
(type != 'card') OR
|
|
||||||
(card_exp_month BETWEEN 1 AND 12 AND card_exp_year >= EXTRACT(YEAR FROM CURRENT_DATE))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: billing_invoices (Facturas de suscripción)
|
|
||||||
CREATE TABLE billing.invoices (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
subscription_id UUID REFERENCES billing.subscriptions(id),
|
|
||||||
|
|
||||||
-- Número de factura
|
|
||||||
invoice_number VARCHAR(50) NOT NULL,
|
|
||||||
|
|
||||||
-- Estado y fechas
|
|
||||||
status billing.invoice_status NOT NULL DEFAULT 'draft',
|
|
||||||
period_start TIMESTAMP,
|
|
||||||
period_end TIMESTAMP,
|
|
||||||
due_date DATE NOT NULL,
|
|
||||||
paid_at TIMESTAMP,
|
|
||||||
voided_at TIMESTAMP,
|
|
||||||
|
|
||||||
-- Montos
|
|
||||||
subtotal DECIMAL(12,2) NOT NULL DEFAULT 0,
|
|
||||||
tax_amount DECIMAL(12,2) NOT NULL DEFAULT 0,
|
|
||||||
discount_amount DECIMAL(12,2) NOT NULL DEFAULT 0,
|
|
||||||
total DECIMAL(12,2) NOT NULL DEFAULT 0,
|
|
||||||
amount_paid DECIMAL(12,2) NOT NULL DEFAULT 0,
|
|
||||||
amount_due DECIMAL(12,2) NOT NULL DEFAULT 0,
|
|
||||||
currency_code VARCHAR(3) NOT NULL DEFAULT 'MXN',
|
|
||||||
|
|
||||||
-- Datos fiscales del cliente
|
|
||||||
customer_name VARCHAR(255),
|
|
||||||
customer_tax_id VARCHAR(50),
|
|
||||||
customer_email VARCHAR(255),
|
|
||||||
customer_address TEXT,
|
|
||||||
|
|
||||||
-- PDF y CFDI (México)
|
|
||||||
pdf_url VARCHAR(500),
|
|
||||||
cfdi_uuid VARCHAR(36), -- UUID del CFDI si aplica
|
|
||||||
cfdi_xml_url VARCHAR(500),
|
|
||||||
|
|
||||||
-- Integración pasarela
|
|
||||||
stripe_invoice_id VARCHAR(255),
|
|
||||||
|
|
||||||
-- Notas
|
|
||||||
notes TEXT,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID,
|
|
||||||
updated_at TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT uq_invoices_number UNIQUE (invoice_number),
|
|
||||||
CONSTRAINT chk_invoices_amounts CHECK (total >= 0 AND subtotal >= 0 AND amount_due >= 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: invoice_lines (Líneas de detalle de factura)
|
|
||||||
CREATE TABLE billing.invoice_lines (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
invoice_id UUID NOT NULL REFERENCES billing.invoices(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
description VARCHAR(255) NOT NULL,
|
|
||||||
quantity DECIMAL(12,4) NOT NULL DEFAULT 1,
|
|
||||||
unit_price DECIMAL(12,2) NOT NULL,
|
|
||||||
amount DECIMAL(12,2) NOT NULL,
|
|
||||||
|
|
||||||
-- Para facturación por uso
|
|
||||||
period_start TIMESTAMP,
|
|
||||||
period_end TIMESTAMP,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT chk_invoice_lines_qty CHECK (quantity > 0),
|
|
||||||
CONSTRAINT chk_invoice_lines_price CHECK (unit_price >= 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: payments (Pagos recibidos)
|
|
||||||
CREATE TABLE billing.payments (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
invoice_id UUID REFERENCES billing.invoices(id),
|
|
||||||
payment_method_id UUID REFERENCES billing.payment_methods(id),
|
|
||||||
|
|
||||||
-- Monto y moneda
|
|
||||||
amount DECIMAL(12,2) NOT NULL,
|
|
||||||
currency_code VARCHAR(3) NOT NULL DEFAULT 'MXN',
|
|
||||||
|
|
||||||
-- Estado
|
|
||||||
status billing.payment_status NOT NULL DEFAULT 'pending',
|
|
||||||
|
|
||||||
-- Fechas
|
|
||||||
paid_at TIMESTAMP,
|
|
||||||
failed_at TIMESTAMP,
|
|
||||||
refunded_at TIMESTAMP,
|
|
||||||
|
|
||||||
-- Detalles del error (si falló)
|
|
||||||
failure_reason VARCHAR(255),
|
|
||||||
failure_code VARCHAR(50),
|
|
||||||
|
|
||||||
-- Referencia de transacción
|
|
||||||
transaction_id VARCHAR(255),
|
|
||||||
stripe_payment_intent_id VARCHAR(255),
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT chk_payments_amount CHECK (amount > 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: usage_records (Registros de uso para billing por consumo)
|
|
||||||
CREATE TABLE billing.usage_records (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
subscription_id UUID REFERENCES billing.subscriptions(id),
|
|
||||||
|
|
||||||
-- Tipo de métrica
|
|
||||||
metric_type VARCHAR(50) NOT NULL,
|
|
||||||
-- Ejemplos: 'users', 'storage_gb', 'api_calls', 'invoices_sent', 'emails_sent'
|
|
||||||
|
|
||||||
quantity DECIMAL(12,4) NOT NULL,
|
|
||||||
billing_period DATE NOT NULL, -- Mes de facturación (YYYY-MM-01)
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
recorded_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT chk_usage_quantity CHECK (quantity >= 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: coupons (Cupones de descuento)
|
|
||||||
CREATE TABLE billing.coupons (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
code VARCHAR(50) UNIQUE NOT NULL,
|
|
||||||
name VARCHAR(100) NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
|
|
||||||
-- Tipo de descuento
|
|
||||||
discount_type VARCHAR(20) NOT NULL DEFAULT 'percent',
|
|
||||||
-- 'percent': Porcentaje de descuento
|
|
||||||
-- 'fixed': Monto fijo de descuento
|
|
||||||
|
|
||||||
discount_value DECIMAL(12,2) NOT NULL,
|
|
||||||
currency_code VARCHAR(3) DEFAULT 'MXN', -- Solo para tipo 'fixed'
|
|
||||||
|
|
||||||
-- Restricciones
|
|
||||||
max_redemptions INTEGER, -- Máximo de usos totales
|
|
||||||
max_redemptions_per_tenant INTEGER DEFAULT 1, -- Máximo por tenant
|
|
||||||
redemptions_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
|
|
||||||
-- Vigencia
|
|
||||||
valid_from TIMESTAMP,
|
|
||||||
valid_until TIMESTAMP,
|
|
||||||
|
|
||||||
-- Aplicable a
|
|
||||||
applicable_plans UUID[], -- Array de plan_ids, NULL = todos
|
|
||||||
|
|
||||||
-- Estado
|
|
||||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID,
|
|
||||||
|
|
||||||
CONSTRAINT chk_coupons_discount CHECK (
|
|
||||||
(discount_type = 'percent' AND discount_value > 0 AND discount_value <= 100) OR
|
|
||||||
(discount_type = 'fixed' AND discount_value > 0)
|
|
||||||
),
|
|
||||||
CONSTRAINT chk_coupons_dates CHECK (valid_until IS NULL OR valid_until > valid_from)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: coupon_redemptions (Uso de cupones)
|
|
||||||
CREATE TABLE billing.coupon_redemptions (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
coupon_id UUID NOT NULL REFERENCES billing.coupons(id),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
subscription_id UUID REFERENCES billing.subscriptions(id),
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
redeemed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
redeemed_by UUID,
|
|
||||||
|
|
||||||
CONSTRAINT uq_coupon_redemptions UNIQUE (coupon_id, tenant_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: subscription_history (Historial de cambios de suscripción)
|
|
||||||
CREATE TABLE billing.subscription_history (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
subscription_id UUID NOT NULL REFERENCES billing.subscriptions(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
event_type VARCHAR(50) NOT NULL,
|
|
||||||
-- 'created', 'upgraded', 'downgraded', 'renewed', 'cancelled',
|
|
||||||
-- 'paused', 'resumed', 'payment_failed', 'payment_succeeded'
|
|
||||||
|
|
||||||
previous_plan_id UUID REFERENCES billing.subscription_plans(id),
|
|
||||||
new_plan_id UUID REFERENCES billing.subscription_plans(id),
|
|
||||||
previous_status billing.subscription_status,
|
|
||||||
new_status billing.subscription_status,
|
|
||||||
|
|
||||||
-- Metadata adicional
|
|
||||||
metadata JSONB DEFAULT '{}'::jsonb,
|
|
||||||
notes TEXT,
|
|
||||||
|
|
||||||
-- Auditoría
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_by UUID
|
|
||||||
);
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- ÍNDICES
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- subscription_plans
|
|
||||||
CREATE INDEX idx_plans_is_active ON billing.subscription_plans(is_active) WHERE is_active = true;
|
|
||||||
CREATE INDEX idx_plans_is_public ON billing.subscription_plans(is_public) WHERE is_public = true;
|
|
||||||
|
|
||||||
-- tenant_owners
|
|
||||||
CREATE INDEX idx_tenant_owners_tenant_id ON billing.tenant_owners(tenant_id);
|
|
||||||
CREATE INDEX idx_tenant_owners_user_id ON billing.tenant_owners(user_id);
|
|
||||||
|
|
||||||
-- subscriptions
|
|
||||||
CREATE INDEX idx_subscriptions_tenant_id ON billing.subscriptions(tenant_id);
|
|
||||||
CREATE INDEX idx_subscriptions_status ON billing.subscriptions(status);
|
|
||||||
CREATE INDEX idx_subscriptions_period_end ON billing.subscriptions(current_period_end);
|
|
||||||
|
|
||||||
-- payment_methods
|
|
||||||
CREATE INDEX idx_payment_methods_tenant_id ON billing.payment_methods(tenant_id);
|
|
||||||
CREATE INDEX idx_payment_methods_default ON billing.payment_methods(tenant_id, is_default) WHERE is_default = true;
|
|
||||||
|
|
||||||
-- invoices
|
|
||||||
CREATE INDEX idx_invoices_tenant_id ON billing.invoices(tenant_id);
|
|
||||||
CREATE INDEX idx_invoices_status ON billing.invoices(status);
|
|
||||||
CREATE INDEX idx_invoices_due_date ON billing.invoices(due_date);
|
|
||||||
CREATE INDEX idx_invoices_stripe_id ON billing.invoices(stripe_invoice_id);
|
|
||||||
|
|
||||||
-- payments
|
|
||||||
CREATE INDEX idx_payments_tenant_id ON billing.payments(tenant_id);
|
|
||||||
CREATE INDEX idx_payments_status ON billing.payments(status);
|
|
||||||
CREATE INDEX idx_payments_invoice_id ON billing.payments(invoice_id);
|
|
||||||
|
|
||||||
-- usage_records
|
|
||||||
CREATE INDEX idx_usage_records_tenant_id ON billing.usage_records(tenant_id);
|
|
||||||
CREATE INDEX idx_usage_records_period ON billing.usage_records(billing_period);
|
|
||||||
CREATE INDEX idx_usage_records_metric ON billing.usage_records(metric_type, billing_period);
|
|
||||||
|
|
||||||
-- coupons
|
|
||||||
CREATE INDEX idx_coupons_code ON billing.coupons(code);
|
|
||||||
CREATE INDEX idx_coupons_active ON billing.coupons(is_active) WHERE is_active = true;
|
|
||||||
|
|
||||||
-- subscription_history
|
|
||||||
CREATE INDEX idx_subscription_history_subscription ON billing.subscription_history(subscription_id);
|
|
||||||
CREATE INDEX idx_subscription_history_created ON billing.subscription_history(created_at);
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- TRIGGERS
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Trigger updated_at para subscriptions
|
|
||||||
CREATE TRIGGER trg_subscriptions_updated_at
|
|
||||||
BEFORE UPDATE ON billing.subscriptions
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
-- Trigger updated_at para payment_methods
|
|
||||||
CREATE TRIGGER trg_payment_methods_updated_at
|
|
||||||
BEFORE UPDATE ON billing.payment_methods
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
-- Trigger updated_at para invoices
|
|
||||||
CREATE TRIGGER trg_invoices_updated_at
|
|
||||||
BEFORE UPDATE ON billing.invoices
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
-- Trigger updated_at para subscription_plans
|
|
||||||
CREATE TRIGGER trg_plans_updated_at
|
|
||||||
BEFORE UPDATE ON billing.subscription_plans
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION auth.update_updated_at_column();
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- FUNCIONES
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Función para obtener el plan actual de un tenant
|
|
||||||
CREATE OR REPLACE FUNCTION billing.get_tenant_plan(p_tenant_id UUID)
|
|
||||||
RETURNS TABLE(
|
|
||||||
plan_code VARCHAR,
|
|
||||||
plan_name VARCHAR,
|
|
||||||
max_users INTEGER,
|
|
||||||
max_companies INTEGER,
|
|
||||||
features JSONB,
|
|
||||||
subscription_status billing.subscription_status,
|
|
||||||
days_until_renewal INTEGER
|
|
||||||
) AS $$
|
|
||||||
BEGIN
|
|
||||||
RETURN QUERY
|
|
||||||
SELECT
|
|
||||||
sp.code,
|
|
||||||
sp.name,
|
|
||||||
sp.max_users,
|
|
||||||
sp.max_companies,
|
|
||||||
sp.features,
|
|
||||||
s.status,
|
|
||||||
EXTRACT(DAY FROM s.current_period_end - CURRENT_TIMESTAMP)::INTEGER
|
|
||||||
FROM billing.subscriptions s
|
|
||||||
JOIN billing.subscription_plans sp ON s.plan_id = sp.id
|
|
||||||
WHERE s.tenant_id = p_tenant_id;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
-- Función para verificar si tenant puede agregar más usuarios
|
|
||||||
CREATE OR REPLACE FUNCTION billing.can_add_user(p_tenant_id UUID)
|
|
||||||
RETURNS BOOLEAN AS $$
|
|
||||||
DECLARE
|
|
||||||
v_max_users INTEGER;
|
|
||||||
v_current_users INTEGER;
|
|
||||||
BEGIN
|
|
||||||
-- Obtener límite del plan
|
|
||||||
SELECT sp.max_users INTO v_max_users
|
|
||||||
FROM billing.subscriptions s
|
|
||||||
JOIN billing.subscription_plans sp ON s.plan_id = sp.id
|
|
||||||
WHERE s.tenant_id = p_tenant_id AND s.status IN ('active', 'trialing');
|
|
||||||
|
|
||||||
-- Si no hay límite (NULL), permitir
|
|
||||||
IF v_max_users IS NULL THEN
|
|
||||||
RETURN true;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- Contar usuarios actuales
|
|
||||||
SELECT COUNT(*) INTO v_current_users
|
|
||||||
FROM auth.users
|
|
||||||
WHERE tenant_id = p_tenant_id AND deleted_at IS NULL;
|
|
||||||
|
|
||||||
RETURN v_current_users < v_max_users;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
-- Función para verificar si una feature está habilitada para el tenant
|
|
||||||
CREATE OR REPLACE FUNCTION billing.has_feature(p_tenant_id UUID, p_feature VARCHAR)
|
|
||||||
RETURNS BOOLEAN AS $$
|
|
||||||
DECLARE
|
|
||||||
v_features JSONB;
|
|
||||||
BEGIN
|
|
||||||
SELECT sp.features INTO v_features
|
|
||||||
FROM billing.subscriptions s
|
|
||||||
JOIN billing.subscription_plans sp ON s.plan_id = sp.id
|
|
||||||
WHERE s.tenant_id = p_tenant_id AND s.status IN ('active', 'trialing');
|
|
||||||
|
|
||||||
-- Si no hay plan o features, denegar
|
|
||||||
IF v_features IS NULL THEN
|
|
||||||
RETURN false;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- Verificar feature
|
|
||||||
RETURN COALESCE((v_features ->> p_feature)::boolean, false);
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- DATOS INICIALES (Plans por defecto)
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Plan Free/Trial
|
|
||||||
INSERT INTO billing.subscription_plans (code, name, description, price_monthly, price_yearly, max_users, max_companies, max_storage_gb, trial_days, is_default, sort_order, features)
|
|
||||||
VALUES (
|
|
||||||
'free',
|
|
||||||
'Free / Trial',
|
|
||||||
'Plan gratuito para probar el sistema',
|
|
||||||
0, 0,
|
|
||||||
3, 1, 1, 14, true, 1,
|
|
||||||
'{"inventory": true, "sales": true, "financial": false, "purchase": false, "crm": false, "projects": false, "reports_basic": true, "reports_advanced": false, "api_access": false}'::jsonb
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Plan Básico
|
|
||||||
INSERT INTO billing.subscription_plans (code, name, description, price_monthly, price_yearly, max_users, max_companies, max_storage_gb, trial_days, sort_order, features)
|
|
||||||
VALUES (
|
|
||||||
'basic',
|
|
||||||
'Básico',
|
|
||||||
'Ideal para pequeños negocios',
|
|
||||||
499, 4990,
|
|
||||||
5, 1, 5, 14, 2,
|
|
||||||
'{"inventory": true, "sales": true, "financial": true, "purchase": true, "crm": false, "projects": false, "reports_basic": true, "reports_advanced": false, "api_access": false}'::jsonb
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Plan Profesional
|
|
||||||
INSERT INTO billing.subscription_plans (code, name, description, price_monthly, price_yearly, max_users, max_companies, max_storage_gb, trial_days, sort_order, features)
|
|
||||||
VALUES (
|
|
||||||
'professional',
|
|
||||||
'Profesional',
|
|
||||||
'Para empresas en crecimiento',
|
|
||||||
999, 9990,
|
|
||||||
15, 3, 20, 14, 3,
|
|
||||||
'{"inventory": true, "sales": true, "financial": true, "purchase": true, "crm": true, "projects": true, "reports_basic": true, "reports_advanced": true, "api_access": true}'::jsonb
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Plan Enterprise
|
|
||||||
INSERT INTO billing.subscription_plans (code, name, description, price_monthly, price_yearly, max_users, max_companies, max_storage_gb, trial_days, sort_order, features)
|
|
||||||
VALUES (
|
|
||||||
'enterprise',
|
|
||||||
'Enterprise',
|
|
||||||
'Solución completa para grandes empresas',
|
|
||||||
2499, 24990,
|
|
||||||
NULL, NULL, 100, 30, 4, -- NULL = ilimitado
|
|
||||||
'{"inventory": true, "sales": true, "financial": true, "purchase": true, "crm": true, "projects": true, "reports_basic": true, "reports_advanced": true, "api_access": true, "white_label": true, "priority_support": true, "custom_integrations": true}'::jsonb
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Plan Single-Tenant (para instalaciones on-premise)
|
|
||||||
INSERT INTO billing.subscription_plans (code, name, description, price_monthly, price_yearly, max_users, max_companies, max_storage_gb, trial_days, is_public, sort_order, features)
|
|
||||||
VALUES (
|
|
||||||
'single_tenant',
|
|
||||||
'Single Tenant / On-Premise',
|
|
||||||
'Instalación dedicada sin restricciones',
|
|
||||||
0, 0,
|
|
||||||
NULL, NULL, NULL, 0, false, 99, -- No público, solo asignación manual
|
|
||||||
'{"inventory": true, "sales": true, "financial": true, "purchase": true, "crm": true, "projects": true, "reports_basic": true, "reports_advanced": true, "api_access": true, "white_label": true, "priority_support": true, "custom_integrations": true, "unlimited": true}'::jsonb
|
|
||||||
);
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- COMENTARIOS
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
COMMENT ON SCHEMA billing IS 'Schema para gestión de suscripciones SaaS, planes, pagos y facturación';
|
|
||||||
|
|
||||||
COMMENT ON TABLE billing.subscription_plans IS 'Planes de suscripción disponibles (global, no por tenant)';
|
|
||||||
COMMENT ON TABLE billing.tenant_owners IS 'Propietarios/administradores de facturación de cada tenant';
|
|
||||||
COMMENT ON TABLE billing.subscriptions IS 'Suscripciones activas de cada tenant';
|
|
||||||
COMMENT ON TABLE billing.payment_methods IS 'Métodos de pago registrados por tenant';
|
|
||||||
COMMENT ON TABLE billing.invoices IS 'Facturas de suscripción';
|
|
||||||
COMMENT ON TABLE billing.invoice_lines IS 'Líneas de detalle de facturas';
|
|
||||||
COMMENT ON TABLE billing.payments IS 'Pagos recibidos';
|
|
||||||
COMMENT ON TABLE billing.usage_records IS 'Registros de uso para billing por consumo';
|
|
||||||
COMMENT ON TABLE billing.coupons IS 'Cupones de descuento';
|
|
||||||
COMMENT ON TABLE billing.coupon_redemptions IS 'Registro de cupones usados';
|
|
||||||
COMMENT ON TABLE billing.subscription_history IS 'Historial de cambios de suscripción';
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION billing.get_tenant_plan IS 'Obtiene información del plan actual de un tenant';
|
|
||||||
COMMENT ON FUNCTION billing.can_add_user IS 'Verifica si el tenant puede agregar más usuarios según su plan';
|
|
||||||
COMMENT ON FUNCTION billing.has_feature IS 'Verifica si una feature está habilitada para el tenant';
|
|
||||||
366
ddl/11-crm.sql
366
ddl/11-crm.sql
@ -1,366 +0,0 @@
|
|||||||
-- =====================================================
|
|
||||||
-- SCHEMA: crm
|
|
||||||
-- PROPOSITO: Customer Relationship Management
|
|
||||||
-- MODULOS: MGN-CRM (CRM)
|
|
||||||
-- FECHA: 2025-11-24
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Crear schema
|
|
||||||
CREATE SCHEMA IF NOT EXISTS crm;
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- TYPES (ENUMs)
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
CREATE TYPE crm.lead_status AS ENUM (
|
|
||||||
'new',
|
|
||||||
'contacted',
|
|
||||||
'qualified',
|
|
||||||
'converted',
|
|
||||||
'lost'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE crm.opportunity_status AS ENUM (
|
|
||||||
'open',
|
|
||||||
'won',
|
|
||||||
'lost'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE crm.activity_type AS ENUM (
|
|
||||||
'call',
|
|
||||||
'email',
|
|
||||||
'meeting',
|
|
||||||
'task',
|
|
||||||
'note'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE crm.lead_source AS ENUM (
|
|
||||||
'website',
|
|
||||||
'phone',
|
|
||||||
'email',
|
|
||||||
'referral',
|
|
||||||
'social_media',
|
|
||||||
'advertising',
|
|
||||||
'event',
|
|
||||||
'other'
|
|
||||||
);
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- TABLES
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Tabla: lead_stages (Etapas del pipeline de leads)
|
|
||||||
CREATE TABLE crm.lead_stages (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
name VARCHAR(100) NOT NULL,
|
|
||||||
sequence INTEGER NOT NULL DEFAULT 10,
|
|
||||||
is_won BOOLEAN DEFAULT FALSE,
|
|
||||||
probability DECIMAL(5, 2) DEFAULT 0,
|
|
||||||
requirements TEXT,
|
|
||||||
|
|
||||||
active BOOLEAN DEFAULT TRUE,
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
UNIQUE(tenant_id, name)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: opportunity_stages (Etapas del pipeline de oportunidades)
|
|
||||||
CREATE TABLE crm.opportunity_stages (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
name VARCHAR(100) NOT NULL,
|
|
||||||
sequence INTEGER NOT NULL DEFAULT 10,
|
|
||||||
is_won BOOLEAN DEFAULT FALSE,
|
|
||||||
probability DECIMAL(5, 2) DEFAULT 0,
|
|
||||||
requirements TEXT,
|
|
||||||
|
|
||||||
active BOOLEAN DEFAULT TRUE,
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
UNIQUE(tenant_id, name)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: lost_reasons (Razones de perdida)
|
|
||||||
CREATE TABLE crm.lost_reasons (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
name VARCHAR(100) NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
|
|
||||||
active BOOLEAN DEFAULT TRUE,
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
UNIQUE(tenant_id, name)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: leads (Prospectos/Leads)
|
|
||||||
CREATE TABLE crm.leads (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
company_id UUID NOT NULL REFERENCES auth.companies(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
-- Numeracion
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
ref VARCHAR(100),
|
|
||||||
|
|
||||||
-- Contacto
|
|
||||||
contact_name VARCHAR(255),
|
|
||||||
email VARCHAR(255),
|
|
||||||
phone VARCHAR(50),
|
|
||||||
mobile VARCHAR(50),
|
|
||||||
website VARCHAR(255),
|
|
||||||
|
|
||||||
-- Empresa del prospecto
|
|
||||||
company_name VARCHAR(255),
|
|
||||||
job_position VARCHAR(100),
|
|
||||||
industry VARCHAR(100),
|
|
||||||
employee_count VARCHAR(50),
|
|
||||||
annual_revenue DECIMAL(15, 2),
|
|
||||||
|
|
||||||
-- Direccion
|
|
||||||
street VARCHAR(255),
|
|
||||||
city VARCHAR(100),
|
|
||||||
state VARCHAR(100),
|
|
||||||
zip VARCHAR(20),
|
|
||||||
country VARCHAR(100),
|
|
||||||
|
|
||||||
-- Pipeline
|
|
||||||
stage_id UUID REFERENCES crm.lead_stages(id),
|
|
||||||
status crm.lead_status NOT NULL DEFAULT 'new',
|
|
||||||
|
|
||||||
-- Asignacion
|
|
||||||
user_id UUID REFERENCES auth.users(id),
|
|
||||||
sales_team_id UUID REFERENCES sales.sales_teams(id),
|
|
||||||
|
|
||||||
-- Origen
|
|
||||||
source crm.lead_source,
|
|
||||||
campaign_id UUID, -- Para futuro modulo marketing
|
|
||||||
medium VARCHAR(100),
|
|
||||||
|
|
||||||
-- Valoracion
|
|
||||||
priority INTEGER DEFAULT 0 CHECK (priority >= 0 AND priority <= 3),
|
|
||||||
probability DECIMAL(5, 2) DEFAULT 0,
|
|
||||||
expected_revenue DECIMAL(15, 2),
|
|
||||||
|
|
||||||
-- Fechas
|
|
||||||
date_open TIMESTAMP WITH TIME ZONE,
|
|
||||||
date_closed TIMESTAMP WITH TIME ZONE,
|
|
||||||
date_deadline DATE,
|
|
||||||
date_last_activity TIMESTAMP WITH TIME ZONE,
|
|
||||||
|
|
||||||
-- Conversion
|
|
||||||
partner_id UUID REFERENCES core.partners(id),
|
|
||||||
opportunity_id UUID, -- Se llena al convertir
|
|
||||||
|
|
||||||
-- Perdida
|
|
||||||
lost_reason_id UUID REFERENCES crm.lost_reasons(id),
|
|
||||||
lost_notes TEXT,
|
|
||||||
|
|
||||||
-- Notas
|
|
||||||
description TEXT,
|
|
||||||
notes TEXT,
|
|
||||||
tags VARCHAR(255)[],
|
|
||||||
|
|
||||||
-- Auditoria
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: opportunities (Oportunidades de venta)
|
|
||||||
CREATE TABLE crm.opportunities (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
company_id UUID NOT NULL REFERENCES auth.companies(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
-- Numeracion
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
ref VARCHAR(100),
|
|
||||||
|
|
||||||
-- Cliente
|
|
||||||
partner_id UUID NOT NULL REFERENCES core.partners(id),
|
|
||||||
contact_name VARCHAR(255),
|
|
||||||
email VARCHAR(255),
|
|
||||||
phone VARCHAR(50),
|
|
||||||
|
|
||||||
-- Pipeline
|
|
||||||
stage_id UUID REFERENCES crm.opportunity_stages(id),
|
|
||||||
status crm.opportunity_status NOT NULL DEFAULT 'open',
|
|
||||||
|
|
||||||
-- Asignacion
|
|
||||||
user_id UUID REFERENCES auth.users(id),
|
|
||||||
sales_team_id UUID REFERENCES sales.sales_teams(id),
|
|
||||||
|
|
||||||
-- Valoracion
|
|
||||||
priority INTEGER DEFAULT 0 CHECK (priority >= 0 AND priority <= 3),
|
|
||||||
probability DECIMAL(5, 2) DEFAULT 0,
|
|
||||||
expected_revenue DECIMAL(15, 2),
|
|
||||||
recurring_revenue DECIMAL(15, 2),
|
|
||||||
recurring_plan VARCHAR(50),
|
|
||||||
|
|
||||||
-- Fechas
|
|
||||||
date_deadline DATE,
|
|
||||||
date_closed TIMESTAMP WITH TIME ZONE,
|
|
||||||
date_last_activity TIMESTAMP WITH TIME ZONE,
|
|
||||||
|
|
||||||
-- Origen (si viene de lead)
|
|
||||||
lead_id UUID REFERENCES crm.leads(id),
|
|
||||||
source crm.lead_source,
|
|
||||||
campaign_id UUID,
|
|
||||||
medium VARCHAR(100),
|
|
||||||
|
|
||||||
-- Cierre
|
|
||||||
lost_reason_id UUID REFERENCES crm.lost_reasons(id),
|
|
||||||
lost_notes TEXT,
|
|
||||||
|
|
||||||
-- Relaciones
|
|
||||||
quotation_id UUID REFERENCES sales.quotations(id),
|
|
||||||
order_id UUID REFERENCES sales.sales_orders(id),
|
|
||||||
|
|
||||||
-- Notas
|
|
||||||
description TEXT,
|
|
||||||
notes TEXT,
|
|
||||||
tags VARCHAR(255)[],
|
|
||||||
|
|
||||||
-- Auditoria
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Actualizar referencia circular en leads
|
|
||||||
ALTER TABLE crm.leads ADD CONSTRAINT fk_leads_opportunity
|
|
||||||
FOREIGN KEY (opportunity_id) REFERENCES crm.opportunities(id);
|
|
||||||
|
|
||||||
-- Tabla: crm_activities (Actividades CRM)
|
|
||||||
CREATE TABLE crm.activities (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
-- Referencia polimorfica
|
|
||||||
res_model VARCHAR(100) NOT NULL,
|
|
||||||
res_id UUID NOT NULL,
|
|
||||||
|
|
||||||
-- Actividad
|
|
||||||
activity_type crm.activity_type NOT NULL,
|
|
||||||
summary VARCHAR(255),
|
|
||||||
description TEXT,
|
|
||||||
|
|
||||||
-- Fechas
|
|
||||||
date_deadline DATE,
|
|
||||||
date_done TIMESTAMP WITH TIME ZONE,
|
|
||||||
|
|
||||||
-- Asignacion
|
|
||||||
user_id UUID REFERENCES auth.users(id),
|
|
||||||
assigned_to UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
-- Estado
|
|
||||||
done BOOLEAN DEFAULT FALSE,
|
|
||||||
|
|
||||||
-- Auditoria
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- INDEXES
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
CREATE INDEX idx_lead_stages_tenant ON crm.lead_stages(tenant_id);
|
|
||||||
CREATE INDEX idx_opportunity_stages_tenant ON crm.opportunity_stages(tenant_id);
|
|
||||||
CREATE INDEX idx_lost_reasons_tenant ON crm.lost_reasons(tenant_id);
|
|
||||||
|
|
||||||
CREATE INDEX idx_leads_tenant ON crm.leads(tenant_id);
|
|
||||||
CREATE INDEX idx_leads_company ON crm.leads(company_id);
|
|
||||||
CREATE INDEX idx_leads_status ON crm.leads(status);
|
|
||||||
CREATE INDEX idx_leads_stage ON crm.leads(stage_id);
|
|
||||||
CREATE INDEX idx_leads_user ON crm.leads(user_id);
|
|
||||||
CREATE INDEX idx_leads_partner ON crm.leads(partner_id);
|
|
||||||
CREATE INDEX idx_leads_email ON crm.leads(email);
|
|
||||||
|
|
||||||
CREATE INDEX idx_opportunities_tenant ON crm.opportunities(tenant_id);
|
|
||||||
CREATE INDEX idx_opportunities_company ON crm.opportunities(company_id);
|
|
||||||
CREATE INDEX idx_opportunities_status ON crm.opportunities(status);
|
|
||||||
CREATE INDEX idx_opportunities_stage ON crm.opportunities(stage_id);
|
|
||||||
CREATE INDEX idx_opportunities_user ON crm.opportunities(user_id);
|
|
||||||
CREATE INDEX idx_opportunities_partner ON crm.opportunities(partner_id);
|
|
||||||
|
|
||||||
CREATE INDEX idx_crm_activities_tenant ON crm.activities(tenant_id);
|
|
||||||
CREATE INDEX idx_crm_activities_model ON crm.activities(res_model, res_id);
|
|
||||||
CREATE INDEX idx_crm_activities_user ON crm.activities(assigned_to);
|
|
||||||
CREATE INDEX idx_crm_activities_deadline ON crm.activities(date_deadline);
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- TRIGGERS
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
CREATE TRIGGER update_lead_stages_timestamp
|
|
||||||
BEFORE UPDATE ON crm.lead_stages
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION core.update_timestamp();
|
|
||||||
|
|
||||||
CREATE TRIGGER update_opportunity_stages_timestamp
|
|
||||||
BEFORE UPDATE ON crm.opportunity_stages
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION core.update_timestamp();
|
|
||||||
|
|
||||||
CREATE TRIGGER update_leads_timestamp
|
|
||||||
BEFORE UPDATE ON crm.leads
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION core.update_timestamp();
|
|
||||||
|
|
||||||
CREATE TRIGGER update_opportunities_timestamp
|
|
||||||
BEFORE UPDATE ON crm.opportunities
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION core.update_timestamp();
|
|
||||||
|
|
||||||
CREATE TRIGGER update_crm_activities_timestamp
|
|
||||||
BEFORE UPDATE ON crm.activities
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION core.update_timestamp();
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- ROW LEVEL SECURITY
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Habilitar RLS
|
|
||||||
ALTER TABLE crm.lead_stages ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE crm.opportunity_stages ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE crm.lost_reasons ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE crm.leads ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE crm.opportunities ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE crm.activities ENABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
-- Políticas de aislamiento por tenant
|
|
||||||
CREATE POLICY tenant_isolation_lead_stages ON crm.lead_stages
|
|
||||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_opportunity_stages ON crm.opportunity_stages
|
|
||||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_lost_reasons ON crm.lost_reasons
|
|
||||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_leads ON crm.leads
|
|
||||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_opportunities ON crm.opportunities
|
|
||||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_crm_activities ON crm.activities
|
|
||||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- COMMENTS
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
COMMENT ON TABLE crm.lead_stages IS 'Etapas del pipeline de leads';
|
|
||||||
COMMENT ON TABLE crm.opportunity_stages IS 'Etapas del pipeline de oportunidades';
|
|
||||||
COMMENT ON TABLE crm.lost_reasons IS 'Razones de perdida de leads/oportunidades';
|
|
||||||
COMMENT ON TABLE crm.leads IS 'Prospectos/leads de ventas';
|
|
||||||
COMMENT ON TABLE crm.opportunities IS 'Oportunidades de venta';
|
|
||||||
COMMENT ON TABLE crm.activities IS 'Actividades CRM (llamadas, reuniones, etc.)';
|
|
||||||
424
ddl/11-feature-flags.sql
Normal file
424
ddl/11-feature-flags.sql
Normal file
@ -0,0 +1,424 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- ARCHIVO: 11-feature-flags.sql
|
||||||
|
-- DESCRIPCION: Sistema de Feature Flags para rollout gradual
|
||||||
|
-- VERSION: 1.0.0
|
||||||
|
-- PROYECTO: ERP-Core V2
|
||||||
|
-- FECHA: 2026-01-10
|
||||||
|
-- EPIC: SAAS-BILLING (EPIC-SAAS-002)
|
||||||
|
-- HISTORIAS: US-022
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- SCHEMA: flags
|
||||||
|
-- =====================
|
||||||
|
CREATE SCHEMA IF NOT EXISTS flags;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: flags.flags
|
||||||
|
-- Definicion de feature flags globales (US-022)
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS flags.flags (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
-- Identificacion
|
||||||
|
key VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
category VARCHAR(100),
|
||||||
|
|
||||||
|
-- Estado global
|
||||||
|
enabled BOOLEAN DEFAULT FALSE,
|
||||||
|
|
||||||
|
-- Rollout gradual
|
||||||
|
rollout_percentage INTEGER DEFAULT 0 CHECK (rollout_percentage BETWEEN 0 AND 100),
|
||||||
|
|
||||||
|
-- Targeting
|
||||||
|
targeting_rules JSONB DEFAULT '[]',
|
||||||
|
-- Ejemplo: [{"type": "tenant", "operator": "in", "values": ["uuid1", "uuid2"]}]
|
||||||
|
|
||||||
|
-- Variantes (para A/B testing)
|
||||||
|
variants JSONB DEFAULT '[]',
|
||||||
|
-- Ejemplo: [{"key": "control", "weight": 50}, {"key": "variant_a", "weight": 50}]
|
||||||
|
default_variant VARCHAR(100) DEFAULT 'control',
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
tags TEXT[] DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Lifecycle
|
||||||
|
starts_at TIMESTAMPTZ,
|
||||||
|
ends_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Audit
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by UUID REFERENCES auth.users(id),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_by UUID REFERENCES auth.users(id),
|
||||||
|
archived_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para flags
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_flags_key ON flags.flags(key);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_flags_enabled ON flags.flags(enabled) WHERE enabled = TRUE;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_flags_category ON flags.flags(category);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_flags_tags ON flags.flags USING GIN(tags);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_flags_active ON flags.flags(starts_at, ends_at)
|
||||||
|
WHERE archived_at IS NULL;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: flags.flag_overrides
|
||||||
|
-- Overrides por tenant para feature flags (US-022)
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS flags.flag_overrides (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
flag_id UUID NOT NULL REFERENCES flags.flags(id) ON DELETE CASCADE,
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Override
|
||||||
|
enabled BOOLEAN NOT NULL,
|
||||||
|
variant VARCHAR(100), -- Variante especifica para este tenant
|
||||||
|
|
||||||
|
-- Razon
|
||||||
|
reason TEXT,
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Audit
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by UUID REFERENCES auth.users(id),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
UNIQUE(flag_id, tenant_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para flag_overrides
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_flag_overrides_flag ON flags.flag_overrides(flag_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_flag_overrides_tenant ON flags.flag_overrides(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_flag_overrides_active ON flags.flag_overrides(expires_at)
|
||||||
|
WHERE expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: flags.flag_evaluations
|
||||||
|
-- Log de evaluaciones de flags (para analytics)
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS flags.flag_evaluations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
flag_id UUID NOT NULL REFERENCES flags.flags(id) ON DELETE CASCADE,
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID REFERENCES auth.users(id),
|
||||||
|
|
||||||
|
-- Resultado
|
||||||
|
result BOOLEAN NOT NULL,
|
||||||
|
variant VARCHAR(100),
|
||||||
|
|
||||||
|
-- Contexto de evaluacion
|
||||||
|
evaluation_context JSONB DEFAULT '{}',
|
||||||
|
evaluation_reason VARCHAR(100), -- 'override', 'targeting', 'rollout', 'default'
|
||||||
|
|
||||||
|
evaluated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para flag_evaluations (particionado por fecha recomendado en produccion)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_flag_evaluations_flag ON flags.flag_evaluations(flag_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_flag_evaluations_tenant ON flags.flag_evaluations(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_flag_evaluations_date ON flags.flag_evaluations(evaluated_at DESC);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: flags.flag_segments
|
||||||
|
-- Segmentos de usuarios para targeting avanzado
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS flags.flag_segments (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
-- Identificacion
|
||||||
|
key VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
|
||||||
|
-- Reglas del segmento
|
||||||
|
rules JSONB NOT NULL DEFAULT '[]',
|
||||||
|
-- Ejemplo: [{"attribute": "plan", "operator": "eq", "value": "business"}]
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
|
||||||
|
-- Audit
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by UUID REFERENCES auth.users(id),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para flag_segments
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_flag_segments_key ON flags.flag_segments(key);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_flag_segments_active ON flags.flag_segments(is_active) WHERE is_active = TRUE;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- RLS POLICIES
|
||||||
|
-- =====================
|
||||||
|
|
||||||
|
-- Flags son globales, lectura publica
|
||||||
|
ALTER TABLE flags.flags ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY public_read_flags ON flags.flags
|
||||||
|
FOR SELECT USING (true);
|
||||||
|
|
||||||
|
-- Overrides son por tenant
|
||||||
|
ALTER TABLE flags.flag_overrides ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_overrides ON flags.flag_overrides
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
-- Evaluations son por tenant
|
||||||
|
ALTER TABLE flags.flag_evaluations ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_evaluations ON flags.flag_evaluations
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
-- Segments son globales
|
||||||
|
ALTER TABLE flags.flag_segments ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY public_read_segments ON flags.flag_segments
|
||||||
|
FOR SELECT USING (true);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- FUNCIONES
|
||||||
|
-- =====================
|
||||||
|
|
||||||
|
-- Funcion principal para evaluar un flag
|
||||||
|
CREATE OR REPLACE FUNCTION flags.evaluate_flag(
|
||||||
|
p_flag_key VARCHAR(100),
|
||||||
|
p_tenant_id UUID,
|
||||||
|
p_user_id UUID DEFAULT NULL,
|
||||||
|
p_context JSONB DEFAULT '{}'
|
||||||
|
)
|
||||||
|
RETURNS TABLE (
|
||||||
|
enabled BOOLEAN,
|
||||||
|
variant VARCHAR(100),
|
||||||
|
reason VARCHAR(100)
|
||||||
|
) AS $$
|
||||||
|
DECLARE
|
||||||
|
v_flag RECORD;
|
||||||
|
v_override RECORD;
|
||||||
|
v_result BOOLEAN;
|
||||||
|
v_variant VARCHAR(100);
|
||||||
|
v_reason VARCHAR(100);
|
||||||
|
BEGIN
|
||||||
|
-- Obtener flag
|
||||||
|
SELECT * INTO v_flag
|
||||||
|
FROM flags.flags
|
||||||
|
WHERE key = p_flag_key
|
||||||
|
AND archived_at IS NULL
|
||||||
|
AND (starts_at IS NULL OR starts_at <= CURRENT_TIMESTAMP)
|
||||||
|
AND (ends_at IS NULL OR ends_at > CURRENT_TIMESTAMP);
|
||||||
|
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RETURN QUERY SELECT FALSE, 'control'::VARCHAR(100), 'flag_not_found'::VARCHAR(100);
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Verificar override para el tenant
|
||||||
|
SELECT * INTO v_override
|
||||||
|
FROM flags.flag_overrides
|
||||||
|
WHERE flag_id = v_flag.id
|
||||||
|
AND tenant_id = p_tenant_id
|
||||||
|
AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP);
|
||||||
|
|
||||||
|
IF FOUND THEN
|
||||||
|
v_result := v_override.enabled;
|
||||||
|
v_variant := COALESCE(v_override.variant, v_flag.default_variant);
|
||||||
|
v_reason := 'override';
|
||||||
|
ELSE
|
||||||
|
-- Evaluar targeting rules (simplificado)
|
||||||
|
IF v_flag.targeting_rules IS NOT NULL AND jsonb_array_length(v_flag.targeting_rules) > 0 THEN
|
||||||
|
-- Por ahora, si hay targeting rules y el tenant esta en la lista, habilitar
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM jsonb_array_elements(v_flag.targeting_rules) AS rule
|
||||||
|
WHERE rule->>'type' = 'tenant'
|
||||||
|
AND p_tenant_id::text = ANY(
|
||||||
|
SELECT jsonb_array_elements_text(rule->'values')
|
||||||
|
)
|
||||||
|
) THEN
|
||||||
|
v_result := TRUE;
|
||||||
|
v_reason := 'targeting';
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Si no paso targeting, evaluar rollout
|
||||||
|
IF v_reason IS NULL THEN
|
||||||
|
IF v_flag.rollout_percentage > 0 THEN
|
||||||
|
-- Usar hash del tenant_id para consistencia
|
||||||
|
IF (abs(hashtext(p_tenant_id::text)) % 100) < v_flag.rollout_percentage THEN
|
||||||
|
v_result := TRUE;
|
||||||
|
v_reason := 'rollout';
|
||||||
|
ELSE
|
||||||
|
v_result := v_flag.enabled;
|
||||||
|
v_reason := 'default';
|
||||||
|
END IF;
|
||||||
|
ELSE
|
||||||
|
v_result := v_flag.enabled;
|
||||||
|
v_reason := 'default';
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
v_variant := v_flag.default_variant;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Registrar evaluacion (async en produccion)
|
||||||
|
INSERT INTO flags.flag_evaluations (flag_id, tenant_id, user_id, result, variant, evaluation_context, evaluation_reason)
|
||||||
|
VALUES (v_flag.id, p_tenant_id, p_user_id, v_result, v_variant, p_context, v_reason);
|
||||||
|
|
||||||
|
RETURN QUERY SELECT v_result, v_variant, v_reason;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Funcion simplificada para solo verificar si esta habilitado
|
||||||
|
CREATE OR REPLACE FUNCTION flags.is_enabled(
|
||||||
|
p_flag_key VARCHAR(100),
|
||||||
|
p_tenant_id UUID
|
||||||
|
)
|
||||||
|
RETURNS BOOLEAN AS $$
|
||||||
|
DECLARE
|
||||||
|
v_enabled BOOLEAN;
|
||||||
|
BEGIN
|
||||||
|
SELECT enabled INTO v_enabled
|
||||||
|
FROM flags.evaluate_flag(p_flag_key, p_tenant_id);
|
||||||
|
|
||||||
|
RETURN COALESCE(v_enabled, FALSE);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Funcion para obtener todos los flags de un tenant
|
||||||
|
CREATE OR REPLACE FUNCTION flags.get_all_flags_for_tenant(p_tenant_id UUID)
|
||||||
|
RETURNS TABLE (
|
||||||
|
flag_key VARCHAR(100),
|
||||||
|
flag_name VARCHAR(255),
|
||||||
|
enabled BOOLEAN,
|
||||||
|
variant VARCHAR(100)
|
||||||
|
) AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
f.key as flag_key,
|
||||||
|
f.name as flag_name,
|
||||||
|
COALESCE(fo.enabled, f.enabled) as enabled,
|
||||||
|
COALESCE(fo.variant, f.default_variant) as variant
|
||||||
|
FROM flags.flags f
|
||||||
|
LEFT JOIN flags.flag_overrides fo ON fo.flag_id = f.id AND fo.tenant_id = p_tenant_id
|
||||||
|
WHERE f.archived_at IS NULL
|
||||||
|
AND (f.starts_at IS NULL OR f.starts_at <= CURRENT_TIMESTAMP)
|
||||||
|
AND (f.ends_at IS NULL OR f.ends_at > CURRENT_TIMESTAMP);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE;
|
||||||
|
|
||||||
|
-- Funcion para obtener estadisticas de un flag
|
||||||
|
CREATE OR REPLACE FUNCTION flags.get_flag_stats(
|
||||||
|
p_flag_key VARCHAR(100),
|
||||||
|
p_days INTEGER DEFAULT 7
|
||||||
|
)
|
||||||
|
RETURNS TABLE (
|
||||||
|
total_evaluations BIGINT,
|
||||||
|
enabled_count BIGINT,
|
||||||
|
disabled_count BIGINT,
|
||||||
|
enabled_percentage DECIMAL,
|
||||||
|
unique_tenants BIGINT
|
||||||
|
) AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_evaluations,
|
||||||
|
COUNT(*) FILTER (WHERE fe.result = TRUE) as enabled_count,
|
||||||
|
COUNT(*) FILTER (WHERE fe.result = FALSE) as disabled_count,
|
||||||
|
ROUND(COUNT(*) FILTER (WHERE fe.result = TRUE)::DECIMAL / NULLIF(COUNT(*), 0) * 100, 2) as enabled_percentage,
|
||||||
|
COUNT(DISTINCT fe.tenant_id) as unique_tenants
|
||||||
|
FROM flags.flag_evaluations fe
|
||||||
|
JOIN flags.flags f ON f.id = fe.flag_id
|
||||||
|
WHERE f.key = p_flag_key
|
||||||
|
AND fe.evaluated_at >= CURRENT_TIMESTAMP - (p_days || ' days')::INTERVAL;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE;
|
||||||
|
|
||||||
|
-- Funcion para limpiar evaluaciones antiguas
|
||||||
|
CREATE OR REPLACE FUNCTION flags.cleanup_old_evaluations(p_days INTEGER DEFAULT 30)
|
||||||
|
RETURNS INTEGER AS $$
|
||||||
|
DECLARE
|
||||||
|
deleted_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
DELETE FROM flags.flag_evaluations
|
||||||
|
WHERE evaluated_at < CURRENT_TIMESTAMP - (p_days || ' days')::INTERVAL;
|
||||||
|
|
||||||
|
GET DIAGNOSTICS deleted_count = ROW_COUNT;
|
||||||
|
RETURN deleted_count;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TRIGGERS
|
||||||
|
-- =====================
|
||||||
|
|
||||||
|
-- Trigger para updated_at en flags
|
||||||
|
CREATE OR REPLACE FUNCTION flags.update_timestamp()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_flags_updated_at
|
||||||
|
BEFORE UPDATE ON flags.flags
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION flags.update_timestamp();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_flag_overrides_updated_at
|
||||||
|
BEFORE UPDATE ON flags.flag_overrides
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION flags.update_timestamp();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_flag_segments_updated_at
|
||||||
|
BEFORE UPDATE ON flags.flag_segments
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION flags.update_timestamp();
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- SEED DATA: Feature Flags Base
|
||||||
|
-- =====================
|
||||||
|
INSERT INTO flags.flags (key, name, description, category, enabled, rollout_percentage, tags) VALUES
|
||||||
|
-- Features nuevas (deshabilitadas por default)
|
||||||
|
('new_dashboard', 'Nuevo Dashboard', 'Dashboard rediseñado con metricas en tiempo real', 'ui', FALSE, 0, '{ui,beta}'),
|
||||||
|
('ai_assistant', 'Asistente IA', 'Chat con asistente de inteligencia artificial', 'ai', FALSE, 0, '{ai,premium}'),
|
||||||
|
('whatsapp_notifications', 'Notificaciones WhatsApp', 'Enviar notificaciones via WhatsApp', 'notifications', FALSE, 0, '{notifications,beta}'),
|
||||||
|
('offline_mode', 'Modo Offline Mejorado', 'Sincronizacion offline avanzada', 'mobile', FALSE, 0, '{mobile,beta}'),
|
||||||
|
('multi_currency', 'Multi-Moneda', 'Soporte para multiples monedas', 'billing', FALSE, 0, '{billing,premium}'),
|
||||||
|
|
||||||
|
-- Features de rollout gradual
|
||||||
|
('new_checkout', 'Nuevo Flujo de Checkout', 'Checkout optimizado con menos pasos', 'billing', FALSE, 25, '{billing,ab_test}'),
|
||||||
|
('smart_inventory', 'Inventario Inteligente', 'Predicciones de stock con ML', 'inventory', FALSE, 10, '{inventory,ai,beta}'),
|
||||||
|
|
||||||
|
-- Features habilitadas globalmente
|
||||||
|
('dark_mode', 'Modo Oscuro', 'Tema oscuro para la interfaz', 'ui', TRUE, 100, '{ui}'),
|
||||||
|
('export_csv', 'Exportar a CSV', 'Exportar datos a formato CSV', 'reports', TRUE, 100, '{reports}'),
|
||||||
|
('notifications_center', 'Centro de Notificaciones', 'Panel centralizado de notificaciones', 'notifications', TRUE, 100, '{notifications}'),
|
||||||
|
|
||||||
|
-- Kill switches (para emergencias)
|
||||||
|
('maintenance_mode', 'Modo Mantenimiento', 'Mostrar pagina de mantenimiento', 'system', FALSE, 0, '{system,kill_switch}'),
|
||||||
|
('disable_signups', 'Deshabilitar Registros', 'Pausar nuevos registros', 'system', FALSE, 0, '{system,kill_switch}'),
|
||||||
|
('read_only_mode', 'Modo Solo Lectura', 'Deshabilitar escrituras en DB', 'system', FALSE, 0, '{system,kill_switch}')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- SEED DATA: Segmentos
|
||||||
|
-- =====================
|
||||||
|
INSERT INTO flags.flag_segments (key, name, description, rules) VALUES
|
||||||
|
('beta_testers', 'Beta Testers', 'Usuarios en programa beta', '[{"attribute": "tags", "operator": "contains", "value": "beta"}]'),
|
||||||
|
('enterprise_customers', 'Clientes Enterprise', 'Tenants con plan enterprise', '[{"attribute": "plan", "operator": "eq", "value": "enterprise"}]'),
|
||||||
|
('high_usage', 'Alto Uso', 'Tenants con alto volumen de uso', '[{"attribute": "monthly_transactions", "operator": "gt", "value": 1000}]'),
|
||||||
|
('mexico_only', 'Solo Mexico', 'Tenants en Mexico', '[{"attribute": "country", "operator": "eq", "value": "MX"}]')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- COMENTARIOS
|
||||||
|
-- =====================
|
||||||
|
COMMENT ON TABLE flags.flags IS 'Feature flags globales para control de funcionalidades';
|
||||||
|
COMMENT ON TABLE flags.flag_overrides IS 'Overrides de flags por tenant';
|
||||||
|
COMMENT ON TABLE flags.flag_evaluations IS 'Log de evaluaciones de flags para analytics';
|
||||||
|
COMMENT ON TABLE flags.flag_segments IS 'Segmentos de usuarios para targeting';
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION flags.evaluate_flag IS 'Evalua un flag para un tenant, retorna enabled, variant y reason';
|
||||||
|
COMMENT ON FUNCTION flags.is_enabled IS 'Verifica si un flag esta habilitado para un tenant';
|
||||||
|
COMMENT ON FUNCTION flags.get_all_flags_for_tenant IS 'Obtiene todos los flags con su estado para un tenant';
|
||||||
|
COMMENT ON FUNCTION flags.get_flag_stats IS 'Obtiene estadisticas de uso de un flag';
|
||||||
379
ddl/12-hr.sql
379
ddl/12-hr.sql
@ -1,379 +0,0 @@
|
|||||||
-- =====================================================
|
|
||||||
-- SCHEMA: hr
|
|
||||||
-- PROPOSITO: Human Resources Management
|
|
||||||
-- MODULOS: MGN-HR (Recursos Humanos)
|
|
||||||
-- FECHA: 2025-11-24
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Crear schema
|
|
||||||
CREATE SCHEMA IF NOT EXISTS hr;
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- TYPES (ENUMs)
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
CREATE TYPE hr.contract_status AS ENUM (
|
|
||||||
'draft',
|
|
||||||
'active',
|
|
||||||
'expired',
|
|
||||||
'terminated',
|
|
||||||
'cancelled'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE hr.contract_type AS ENUM (
|
|
||||||
'permanent',
|
|
||||||
'temporary',
|
|
||||||
'contractor',
|
|
||||||
'internship',
|
|
||||||
'part_time'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE hr.leave_status AS ENUM (
|
|
||||||
'draft',
|
|
||||||
'submitted',
|
|
||||||
'approved',
|
|
||||||
'rejected',
|
|
||||||
'cancelled'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE hr.leave_type AS ENUM (
|
|
||||||
'vacation',
|
|
||||||
'sick',
|
|
||||||
'personal',
|
|
||||||
'maternity',
|
|
||||||
'paternity',
|
|
||||||
'bereavement',
|
|
||||||
'unpaid',
|
|
||||||
'other'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TYPE hr.employee_status AS ENUM (
|
|
||||||
'active',
|
|
||||||
'inactive',
|
|
||||||
'on_leave',
|
|
||||||
'terminated'
|
|
||||||
);
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- TABLES
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Tabla: departments (Departamentos)
|
|
||||||
CREATE TABLE hr.departments (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
company_id UUID NOT NULL REFERENCES auth.companies(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
name VARCHAR(100) NOT NULL,
|
|
||||||
code VARCHAR(20),
|
|
||||||
parent_id UUID REFERENCES hr.departments(id),
|
|
||||||
manager_id UUID, -- References employees, set after table creation
|
|
||||||
|
|
||||||
description TEXT,
|
|
||||||
color VARCHAR(20),
|
|
||||||
|
|
||||||
active BOOLEAN DEFAULT TRUE,
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
UNIQUE(tenant_id, company_id, name)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: job_positions (Puestos de trabajo)
|
|
||||||
CREATE TABLE hr.job_positions (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
name VARCHAR(100) NOT NULL,
|
|
||||||
department_id UUID REFERENCES hr.departments(id),
|
|
||||||
|
|
||||||
description TEXT,
|
|
||||||
requirements TEXT,
|
|
||||||
responsibilities TEXT,
|
|
||||||
|
|
||||||
min_salary DECIMAL(15, 2),
|
|
||||||
max_salary DECIMAL(15, 2),
|
|
||||||
|
|
||||||
active BOOLEAN DEFAULT TRUE,
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
UNIQUE(tenant_id, name)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: employees (Empleados)
|
|
||||||
CREATE TABLE hr.employees (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
company_id UUID NOT NULL REFERENCES auth.companies(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
-- Identificacion
|
|
||||||
employee_number VARCHAR(50) NOT NULL,
|
|
||||||
first_name VARCHAR(100) NOT NULL,
|
|
||||||
last_name VARCHAR(100) NOT NULL,
|
|
||||||
middle_name VARCHAR(100),
|
|
||||||
|
|
||||||
-- Usuario vinculado (opcional)
|
|
||||||
user_id UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
-- Informacion personal
|
|
||||||
birth_date DATE,
|
|
||||||
gender VARCHAR(20),
|
|
||||||
marital_status VARCHAR(20),
|
|
||||||
nationality VARCHAR(100),
|
|
||||||
identification_id VARCHAR(50),
|
|
||||||
identification_type VARCHAR(50),
|
|
||||||
social_security_number VARCHAR(50),
|
|
||||||
tax_id VARCHAR(50),
|
|
||||||
|
|
||||||
-- Contacto
|
|
||||||
email VARCHAR(255),
|
|
||||||
work_email VARCHAR(255),
|
|
||||||
phone VARCHAR(50),
|
|
||||||
work_phone VARCHAR(50),
|
|
||||||
mobile VARCHAR(50),
|
|
||||||
emergency_contact VARCHAR(255),
|
|
||||||
emergency_phone VARCHAR(50),
|
|
||||||
|
|
||||||
-- Direccion
|
|
||||||
street VARCHAR(255),
|
|
||||||
city VARCHAR(100),
|
|
||||||
state VARCHAR(100),
|
|
||||||
zip VARCHAR(20),
|
|
||||||
country VARCHAR(100),
|
|
||||||
|
|
||||||
-- Trabajo
|
|
||||||
department_id UUID REFERENCES hr.departments(id),
|
|
||||||
job_position_id UUID REFERENCES hr.job_positions(id),
|
|
||||||
manager_id UUID REFERENCES hr.employees(id),
|
|
||||||
|
|
||||||
hire_date DATE NOT NULL,
|
|
||||||
termination_date DATE,
|
|
||||||
status hr.employee_status NOT NULL DEFAULT 'active',
|
|
||||||
|
|
||||||
-- Datos bancarios
|
|
||||||
bank_name VARCHAR(100),
|
|
||||||
bank_account VARCHAR(50),
|
|
||||||
bank_clabe VARCHAR(20),
|
|
||||||
|
|
||||||
-- Foto
|
|
||||||
photo_url VARCHAR(500),
|
|
||||||
|
|
||||||
-- Notas
|
|
||||||
notes TEXT,
|
|
||||||
|
|
||||||
-- Auditoria
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
UNIQUE(tenant_id, employee_number)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Add manager_id reference to departments
|
|
||||||
ALTER TABLE hr.departments ADD CONSTRAINT fk_departments_manager
|
|
||||||
FOREIGN KEY (manager_id) REFERENCES hr.employees(id);
|
|
||||||
|
|
||||||
-- Tabla: contracts (Contratos laborales)
|
|
||||||
CREATE TABLE hr.contracts (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
company_id UUID NOT NULL REFERENCES auth.companies(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
employee_id UUID NOT NULL REFERENCES hr.employees(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
-- Identificacion
|
|
||||||
name VARCHAR(100) NOT NULL,
|
|
||||||
reference VARCHAR(100),
|
|
||||||
|
|
||||||
-- Tipo y estado
|
|
||||||
contract_type hr.contract_type NOT NULL,
|
|
||||||
status hr.contract_status NOT NULL DEFAULT 'draft',
|
|
||||||
|
|
||||||
-- Puesto
|
|
||||||
job_position_id UUID REFERENCES hr.job_positions(id),
|
|
||||||
department_id UUID REFERENCES hr.departments(id),
|
|
||||||
|
|
||||||
-- Vigencia
|
|
||||||
date_start DATE NOT NULL,
|
|
||||||
date_end DATE,
|
|
||||||
trial_date_end DATE,
|
|
||||||
|
|
||||||
-- Compensacion
|
|
||||||
wage DECIMAL(15, 2) NOT NULL,
|
|
||||||
wage_type VARCHAR(20) DEFAULT 'monthly', -- hourly, daily, weekly, monthly, yearly
|
|
||||||
currency_id UUID REFERENCES core.currencies(id),
|
|
||||||
|
|
||||||
-- Horas
|
|
||||||
resource_calendar_id UUID, -- For future scheduling module
|
|
||||||
hours_per_week DECIMAL(5, 2) DEFAULT 40,
|
|
||||||
|
|
||||||
-- Beneficios y deducciones
|
|
||||||
vacation_days INTEGER DEFAULT 6,
|
|
||||||
christmas_bonus_days INTEGER DEFAULT 15,
|
|
||||||
|
|
||||||
-- Documentos
|
|
||||||
document_url VARCHAR(500),
|
|
||||||
|
|
||||||
-- Notas
|
|
||||||
notes TEXT,
|
|
||||||
|
|
||||||
-- Auditoria
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: leave_types (Tipos de ausencia configurables)
|
|
||||||
CREATE TABLE hr.leave_types (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
name VARCHAR(100) NOT NULL,
|
|
||||||
code VARCHAR(20),
|
|
||||||
leave_type hr.leave_type NOT NULL,
|
|
||||||
|
|
||||||
requires_approval BOOLEAN DEFAULT TRUE,
|
|
||||||
max_days INTEGER,
|
|
||||||
is_paid BOOLEAN DEFAULT TRUE,
|
|
||||||
|
|
||||||
color VARCHAR(20),
|
|
||||||
|
|
||||||
active BOOLEAN DEFAULT TRUE,
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
UNIQUE(tenant_id, name)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Tabla: leaves (Ausencias/Permisos)
|
|
||||||
CREATE TABLE hr.leaves (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
company_id UUID NOT NULL REFERENCES auth.companies(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
employee_id UUID NOT NULL REFERENCES hr.employees(id) ON DELETE CASCADE,
|
|
||||||
leave_type_id UUID NOT NULL REFERENCES hr.leave_types(id),
|
|
||||||
|
|
||||||
-- Solicitud
|
|
||||||
name VARCHAR(255),
|
|
||||||
date_from DATE NOT NULL,
|
|
||||||
date_to DATE NOT NULL,
|
|
||||||
number_of_days DECIMAL(5, 2) NOT NULL,
|
|
||||||
|
|
||||||
-- Estado
|
|
||||||
status hr.leave_status NOT NULL DEFAULT 'draft',
|
|
||||||
|
|
||||||
-- Descripcion
|
|
||||||
description TEXT,
|
|
||||||
|
|
||||||
-- Aprobacion
|
|
||||||
approved_by UUID REFERENCES auth.users(id),
|
|
||||||
approved_at TIMESTAMP WITH TIME ZONE,
|
|
||||||
rejection_reason TEXT,
|
|
||||||
|
|
||||||
-- Auditoria
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- INDEXES
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
CREATE INDEX idx_departments_tenant ON hr.departments(tenant_id);
|
|
||||||
CREATE INDEX idx_departments_company ON hr.departments(company_id);
|
|
||||||
CREATE INDEX idx_departments_parent ON hr.departments(parent_id);
|
|
||||||
|
|
||||||
CREATE INDEX idx_job_positions_tenant ON hr.job_positions(tenant_id);
|
|
||||||
CREATE INDEX idx_job_positions_department ON hr.job_positions(department_id);
|
|
||||||
|
|
||||||
CREATE INDEX idx_employees_tenant ON hr.employees(tenant_id);
|
|
||||||
CREATE INDEX idx_employees_company ON hr.employees(company_id);
|
|
||||||
CREATE INDEX idx_employees_department ON hr.employees(department_id);
|
|
||||||
CREATE INDEX idx_employees_manager ON hr.employees(manager_id);
|
|
||||||
CREATE INDEX idx_employees_user ON hr.employees(user_id);
|
|
||||||
CREATE INDEX idx_employees_status ON hr.employees(status);
|
|
||||||
CREATE INDEX idx_employees_number ON hr.employees(employee_number);
|
|
||||||
|
|
||||||
CREATE INDEX idx_contracts_tenant ON hr.contracts(tenant_id);
|
|
||||||
CREATE INDEX idx_contracts_employee ON hr.contracts(employee_id);
|
|
||||||
CREATE INDEX idx_contracts_status ON hr.contracts(status);
|
|
||||||
CREATE INDEX idx_contracts_dates ON hr.contracts(date_start, date_end);
|
|
||||||
|
|
||||||
CREATE INDEX idx_leave_types_tenant ON hr.leave_types(tenant_id);
|
|
||||||
|
|
||||||
CREATE INDEX idx_leaves_tenant ON hr.leaves(tenant_id);
|
|
||||||
CREATE INDEX idx_leaves_employee ON hr.leaves(employee_id);
|
|
||||||
CREATE INDEX idx_leaves_status ON hr.leaves(status);
|
|
||||||
CREATE INDEX idx_leaves_dates ON hr.leaves(date_from, date_to);
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- TRIGGERS
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
CREATE TRIGGER update_departments_timestamp
|
|
||||||
BEFORE UPDATE ON hr.departments
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION core.update_timestamp();
|
|
||||||
|
|
||||||
CREATE TRIGGER update_job_positions_timestamp
|
|
||||||
BEFORE UPDATE ON hr.job_positions
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION core.update_timestamp();
|
|
||||||
|
|
||||||
CREATE TRIGGER update_employees_timestamp
|
|
||||||
BEFORE UPDATE ON hr.employees
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION core.update_timestamp();
|
|
||||||
|
|
||||||
CREATE TRIGGER update_contracts_timestamp
|
|
||||||
BEFORE UPDATE ON hr.contracts
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION core.update_timestamp();
|
|
||||||
|
|
||||||
CREATE TRIGGER update_leaves_timestamp
|
|
||||||
BEFORE UPDATE ON hr.leaves
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION core.update_timestamp();
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- ROW LEVEL SECURITY
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- Habilitar RLS
|
|
||||||
ALTER TABLE hr.departments ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE hr.job_positions ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE hr.employees ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE hr.contracts ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE hr.leave_types ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE hr.leaves ENABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
-- Políticas de aislamiento por tenant
|
|
||||||
CREATE POLICY tenant_isolation_departments ON hr.departments
|
|
||||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_job_positions ON hr.job_positions
|
|
||||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_employees ON hr.employees
|
|
||||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_contracts ON hr.contracts
|
|
||||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_leave_types ON hr.leave_types
|
|
||||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
|
||||||
|
|
||||||
CREATE POLICY tenant_isolation_leaves ON hr.leaves
|
|
||||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- COMMENTS
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
COMMENT ON TABLE hr.departments IS 'Departamentos de la organizacion';
|
|
||||||
COMMENT ON TABLE hr.job_positions IS 'Puestos de trabajo/posiciones';
|
|
||||||
COMMENT ON TABLE hr.employees IS 'Empleados de la organizacion';
|
|
||||||
COMMENT ON TABLE hr.contracts IS 'Contratos laborales';
|
|
||||||
COMMENT ON TABLE hr.leave_types IS 'Tipos de ausencia configurables';
|
|
||||||
COMMENT ON TABLE hr.leaves IS 'Solicitudes de ausencias/permisos';
|
|
||||||
724
ddl/12-webhooks.sql
Normal file
724
ddl/12-webhooks.sql
Normal file
@ -0,0 +1,724 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- ARCHIVO: 12-webhooks.sql
|
||||||
|
-- DESCRIPCION: Sistema de webhooks, endpoints, entregas
|
||||||
|
-- VERSION: 1.0.0
|
||||||
|
-- PROYECTO: ERP-Core V2
|
||||||
|
-- FECHA: 2026-01-10
|
||||||
|
-- EPIC: SAAS-INTEGRATIONS (EPIC-SAAS-005)
|
||||||
|
-- HISTORIAS: US-060, US-061
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- SCHEMA: webhooks
|
||||||
|
-- =====================
|
||||||
|
CREATE SCHEMA IF NOT EXISTS webhooks;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: webhooks.event_types
|
||||||
|
-- Tipos de eventos disponibles para webhooks
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS webhooks.event_types (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
-- Identificación
|
||||||
|
code VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
category VARCHAR(50), -- sales, inventory, auth, billing, system
|
||||||
|
|
||||||
|
-- Schema del payload
|
||||||
|
payload_schema JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
is_internal BOOLEAN DEFAULT FALSE, -- Eventos internos no expuestos
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: webhooks.endpoints
|
||||||
|
-- Endpoints configurados por tenant
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS webhooks.endpoints (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Identificación
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
|
||||||
|
-- URL destino
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
http_method VARCHAR(10) DEFAULT 'POST',
|
||||||
|
|
||||||
|
-- Autenticación
|
||||||
|
auth_type VARCHAR(30) DEFAULT 'none', -- none, basic, bearer, hmac, oauth2
|
||||||
|
auth_config JSONB DEFAULT '{}',
|
||||||
|
-- basic: {username, password}
|
||||||
|
-- bearer: {token}
|
||||||
|
-- hmac: {secret, header_name, algorithm}
|
||||||
|
-- oauth2: {client_id, client_secret, token_url}
|
||||||
|
|
||||||
|
-- Headers personalizados
|
||||||
|
custom_headers JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Eventos suscritos
|
||||||
|
subscribed_events TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Filtros
|
||||||
|
filters JSONB DEFAULT '{}',
|
||||||
|
-- Ejemplo: {"branch_id": ["uuid1", "uuid2"], "amount_gte": 1000}
|
||||||
|
|
||||||
|
-- Configuración de reintentos
|
||||||
|
retry_enabled BOOLEAN DEFAULT TRUE,
|
||||||
|
max_retries INTEGER DEFAULT 5,
|
||||||
|
retry_delay_seconds INTEGER DEFAULT 60,
|
||||||
|
retry_backoff_multiplier DECIMAL(3,1) DEFAULT 2.0,
|
||||||
|
|
||||||
|
-- Timeouts
|
||||||
|
timeout_seconds INTEGER DEFAULT 30,
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
is_verified BOOLEAN DEFAULT FALSE,
|
||||||
|
verified_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Secreto para firma
|
||||||
|
signing_secret VARCHAR(255),
|
||||||
|
|
||||||
|
-- Estadísticas
|
||||||
|
total_deliveries INTEGER DEFAULT 0,
|
||||||
|
successful_deliveries INTEGER DEFAULT 0,
|
||||||
|
failed_deliveries INTEGER DEFAULT 0,
|
||||||
|
last_delivery_at TIMESTAMPTZ,
|
||||||
|
last_success_at TIMESTAMPTZ,
|
||||||
|
last_failure_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Rate limiting
|
||||||
|
rate_limit_per_minute INTEGER DEFAULT 60,
|
||||||
|
rate_limit_per_hour INTEGER DEFAULT 1000,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by UUID REFERENCES auth.users(id),
|
||||||
|
|
||||||
|
UNIQUE(tenant_id, url)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: webhooks.deliveries
|
||||||
|
-- Log de entregas de webhooks
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS webhooks.deliveries (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
endpoint_id UUID NOT NULL REFERENCES webhooks.endpoints(id) ON DELETE CASCADE,
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Evento
|
||||||
|
event_type VARCHAR(100) NOT NULL,
|
||||||
|
event_id UUID NOT NULL,
|
||||||
|
|
||||||
|
-- Payload enviado
|
||||||
|
payload JSONB NOT NULL,
|
||||||
|
payload_hash VARCHAR(64), -- SHA-256 para deduplicación
|
||||||
|
|
||||||
|
-- Request
|
||||||
|
request_url TEXT NOT NULL,
|
||||||
|
request_method VARCHAR(10) NOT NULL,
|
||||||
|
request_headers JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Response
|
||||||
|
response_status INTEGER,
|
||||||
|
response_headers JSONB DEFAULT '{}',
|
||||||
|
response_body TEXT,
|
||||||
|
response_time_ms INTEGER,
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||||
|
-- pending, sending, delivered, failed, retrying, cancelled
|
||||||
|
|
||||||
|
-- Reintentos
|
||||||
|
attempt_number INTEGER DEFAULT 1,
|
||||||
|
max_attempts INTEGER DEFAULT 5,
|
||||||
|
next_retry_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Error info
|
||||||
|
error_message TEXT,
|
||||||
|
error_code VARCHAR(50),
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
scheduled_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
started_at TIMESTAMPTZ,
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: webhooks.events
|
||||||
|
-- Cola de eventos pendientes de envío
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS webhooks.events (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Tipo de evento
|
||||||
|
event_type VARCHAR(100) NOT NULL,
|
||||||
|
|
||||||
|
-- Payload del evento
|
||||||
|
payload JSONB NOT NULL,
|
||||||
|
|
||||||
|
-- Contexto
|
||||||
|
resource_type VARCHAR(100),
|
||||||
|
resource_id UUID,
|
||||||
|
triggered_by UUID REFERENCES auth.users(id),
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending, processing, dispatched, failed
|
||||||
|
|
||||||
|
-- Procesamiento
|
||||||
|
processed_at TIMESTAMPTZ,
|
||||||
|
dispatched_endpoints INTEGER DEFAULT 0,
|
||||||
|
failed_endpoints INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
-- Deduplicación
|
||||||
|
idempotency_key VARCHAR(255),
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expires_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: webhooks.subscriptions
|
||||||
|
-- Suscripciones individuales evento-endpoint
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS webhooks.subscriptions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
endpoint_id UUID NOT NULL REFERENCES webhooks.endpoints(id) ON DELETE CASCADE,
|
||||||
|
event_type_id UUID NOT NULL REFERENCES webhooks.event_types(id) ON DELETE CASCADE,
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Filtros específicos para esta suscripción
|
||||||
|
filters JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Transformación del payload
|
||||||
|
payload_template JSONB, -- Template para transformar el payload
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
UNIQUE(endpoint_id, event_type_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: webhooks.endpoint_logs
|
||||||
|
-- Logs de actividad de endpoints
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS webhooks.endpoint_logs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
endpoint_id UUID NOT NULL REFERENCES webhooks.endpoints(id) ON DELETE CASCADE,
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Tipo de log
|
||||||
|
log_type VARCHAR(30) NOT NULL, -- config_changed, activated, deactivated, verified, error, rate_limited
|
||||||
|
|
||||||
|
-- Detalles
|
||||||
|
message TEXT,
|
||||||
|
details JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Actor
|
||||||
|
actor_id UUID REFERENCES auth.users(id),
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- INDICES
|
||||||
|
-- =====================
|
||||||
|
|
||||||
|
-- Indices para event_types
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_event_types_code ON webhooks.event_types(code);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_event_types_category ON webhooks.event_types(category);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_event_types_active ON webhooks.event_types(is_active) WHERE is_active = TRUE;
|
||||||
|
|
||||||
|
-- Indices para endpoints
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_endpoints_tenant ON webhooks.endpoints(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_endpoints_active ON webhooks.endpoints(is_active) WHERE is_active = TRUE;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_endpoints_events ON webhooks.endpoints USING GIN(subscribed_events);
|
||||||
|
|
||||||
|
-- Indices para deliveries
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_deliveries_endpoint ON webhooks.deliveries(endpoint_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_deliveries_tenant ON webhooks.deliveries(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_deliveries_event ON webhooks.deliveries(event_type, event_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_deliveries_status ON webhooks.deliveries(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_deliveries_pending ON webhooks.deliveries(status, next_retry_at)
|
||||||
|
WHERE status IN ('pending', 'retrying');
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_deliveries_created ON webhooks.deliveries(created_at DESC);
|
||||||
|
|
||||||
|
-- Indices para events
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_tenant ON webhooks.events(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_type ON webhooks.events(event_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_status ON webhooks.events(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_pending ON webhooks.events(status, created_at)
|
||||||
|
WHERE status = 'pending';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_idempotency ON webhooks.events(idempotency_key)
|
||||||
|
WHERE idempotency_key IS NOT NULL;
|
||||||
|
|
||||||
|
-- Indices para subscriptions
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_subs_endpoint ON webhooks.subscriptions(endpoint_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_subs_event_type ON webhooks.subscriptions(event_type_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_subs_tenant ON webhooks.subscriptions(tenant_id);
|
||||||
|
|
||||||
|
-- Indices para endpoint_logs
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_endpoint_logs_endpoint ON webhooks.endpoint_logs(endpoint_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_endpoint_logs_created ON webhooks.endpoint_logs(created_at DESC);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- RLS POLICIES
|
||||||
|
-- =====================
|
||||||
|
|
||||||
|
-- Event types son globales (lectura pública)
|
||||||
|
ALTER TABLE webhooks.event_types ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY public_read_event_types ON webhooks.event_types
|
||||||
|
FOR SELECT USING (is_active = TRUE AND is_internal = FALSE);
|
||||||
|
|
||||||
|
-- Endpoints por tenant
|
||||||
|
ALTER TABLE webhooks.endpoints ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_endpoints ON webhooks.endpoints
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
-- Deliveries por tenant
|
||||||
|
ALTER TABLE webhooks.deliveries ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_deliveries ON webhooks.deliveries
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
-- Events por tenant
|
||||||
|
ALTER TABLE webhooks.events ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_events ON webhooks.events
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
-- Subscriptions por tenant
|
||||||
|
ALTER TABLE webhooks.subscriptions ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_subscriptions ON webhooks.subscriptions
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
-- Endpoint logs por tenant
|
||||||
|
ALTER TABLE webhooks.endpoint_logs ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_endpoint_logs ON webhooks.endpoint_logs
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- FUNCIONES
|
||||||
|
-- =====================
|
||||||
|
|
||||||
|
-- Función para generar signing secret
|
||||||
|
CREATE OR REPLACE FUNCTION webhooks.generate_signing_secret()
|
||||||
|
RETURNS VARCHAR(255) AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN 'whsec_' || encode(gen_random_bytes(32), 'hex');
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Función para crear un endpoint con secreto
|
||||||
|
CREATE OR REPLACE FUNCTION webhooks.create_endpoint(
|
||||||
|
p_tenant_id UUID,
|
||||||
|
p_name VARCHAR(200),
|
||||||
|
p_url TEXT,
|
||||||
|
p_subscribed_events TEXT[],
|
||||||
|
p_auth_type VARCHAR(30) DEFAULT 'none',
|
||||||
|
p_auth_config JSONB DEFAULT '{}',
|
||||||
|
p_created_by UUID DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS UUID AS $$
|
||||||
|
DECLARE
|
||||||
|
v_endpoint_id UUID;
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO webhooks.endpoints (
|
||||||
|
tenant_id, name, url, subscribed_events,
|
||||||
|
auth_type, auth_config, signing_secret, created_by
|
||||||
|
) VALUES (
|
||||||
|
p_tenant_id, p_name, p_url, p_subscribed_events,
|
||||||
|
p_auth_type, p_auth_config, webhooks.generate_signing_secret(), p_created_by
|
||||||
|
) RETURNING id INTO v_endpoint_id;
|
||||||
|
|
||||||
|
-- Log de creación
|
||||||
|
INSERT INTO webhooks.endpoint_logs (endpoint_id, tenant_id, log_type, message, actor_id)
|
||||||
|
VALUES (v_endpoint_id, p_tenant_id, 'created', 'Endpoint created', p_created_by);
|
||||||
|
|
||||||
|
RETURN v_endpoint_id;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Función para emitir un evento
|
||||||
|
CREATE OR REPLACE FUNCTION webhooks.emit_event(
|
||||||
|
p_tenant_id UUID,
|
||||||
|
p_event_type VARCHAR(100),
|
||||||
|
p_payload JSONB,
|
||||||
|
p_resource_type VARCHAR(100) DEFAULT NULL,
|
||||||
|
p_resource_id UUID DEFAULT NULL,
|
||||||
|
p_triggered_by UUID DEFAULT NULL,
|
||||||
|
p_idempotency_key VARCHAR(255) DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS UUID AS $$
|
||||||
|
DECLARE
|
||||||
|
v_event_id UUID;
|
||||||
|
BEGIN
|
||||||
|
-- Verificar deduplicación
|
||||||
|
IF p_idempotency_key IS NOT NULL THEN
|
||||||
|
SELECT id INTO v_event_id
|
||||||
|
FROM webhooks.events
|
||||||
|
WHERE tenant_id = p_tenant_id
|
||||||
|
AND idempotency_key = p_idempotency_key
|
||||||
|
AND created_at > CURRENT_TIMESTAMP - INTERVAL '24 hours';
|
||||||
|
|
||||||
|
IF FOUND THEN
|
||||||
|
RETURN v_event_id;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Crear evento
|
||||||
|
INSERT INTO webhooks.events (
|
||||||
|
tenant_id, event_type, payload,
|
||||||
|
resource_type, resource_id, triggered_by,
|
||||||
|
idempotency_key, expires_at
|
||||||
|
) VALUES (
|
||||||
|
p_tenant_id, p_event_type, p_payload,
|
||||||
|
p_resource_type, p_resource_id, p_triggered_by,
|
||||||
|
p_idempotency_key, CURRENT_TIMESTAMP + INTERVAL '7 days'
|
||||||
|
) RETURNING id INTO v_event_id;
|
||||||
|
|
||||||
|
RETURN v_event_id;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Función para obtener endpoints suscritos a un evento
|
||||||
|
CREATE OR REPLACE FUNCTION webhooks.get_subscribed_endpoints(
|
||||||
|
p_tenant_id UUID,
|
||||||
|
p_event_type VARCHAR(100)
|
||||||
|
)
|
||||||
|
RETURNS TABLE (
|
||||||
|
endpoint_id UUID,
|
||||||
|
url TEXT,
|
||||||
|
auth_type VARCHAR(30),
|
||||||
|
auth_config JSONB,
|
||||||
|
custom_headers JSONB,
|
||||||
|
signing_secret VARCHAR(255),
|
||||||
|
timeout_seconds INTEGER,
|
||||||
|
filters JSONB
|
||||||
|
) AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
e.id as endpoint_id,
|
||||||
|
e.url,
|
||||||
|
e.auth_type,
|
||||||
|
e.auth_config,
|
||||||
|
e.custom_headers,
|
||||||
|
e.signing_secret,
|
||||||
|
e.timeout_seconds,
|
||||||
|
e.filters
|
||||||
|
FROM webhooks.endpoints e
|
||||||
|
WHERE e.tenant_id = p_tenant_id
|
||||||
|
AND e.is_active = TRUE
|
||||||
|
AND p_event_type = ANY(e.subscribed_events);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE;
|
||||||
|
|
||||||
|
-- Función para encolar entrega
|
||||||
|
CREATE OR REPLACE FUNCTION webhooks.queue_delivery(
|
||||||
|
p_endpoint_id UUID,
|
||||||
|
p_event_type VARCHAR(100),
|
||||||
|
p_event_id UUID,
|
||||||
|
p_payload JSONB
|
||||||
|
)
|
||||||
|
RETURNS UUID AS $$
|
||||||
|
DECLARE
|
||||||
|
v_endpoint RECORD;
|
||||||
|
v_delivery_id UUID;
|
||||||
|
BEGIN
|
||||||
|
-- Obtener endpoint
|
||||||
|
SELECT * INTO v_endpoint
|
||||||
|
FROM webhooks.endpoints
|
||||||
|
WHERE id = p_endpoint_id;
|
||||||
|
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RAISE EXCEPTION 'Endpoint not found: %', p_endpoint_id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Crear delivery
|
||||||
|
INSERT INTO webhooks.deliveries (
|
||||||
|
endpoint_id, tenant_id, event_type, event_id,
|
||||||
|
payload, payload_hash,
|
||||||
|
request_url, request_method, request_headers,
|
||||||
|
max_attempts, status, scheduled_at
|
||||||
|
) VALUES (
|
||||||
|
p_endpoint_id, v_endpoint.tenant_id, p_event_type, p_event_id,
|
||||||
|
p_payload, encode(sha256(p_payload::text::bytea), 'hex'),
|
||||||
|
v_endpoint.url, v_endpoint.http_method, v_endpoint.custom_headers,
|
||||||
|
v_endpoint.max_retries + 1, 'pending', CURRENT_TIMESTAMP
|
||||||
|
) RETURNING id INTO v_delivery_id;
|
||||||
|
|
||||||
|
RETURN v_delivery_id;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Función para marcar entrega como completada
|
||||||
|
CREATE OR REPLACE FUNCTION webhooks.mark_delivery_completed(
|
||||||
|
p_delivery_id UUID,
|
||||||
|
p_response_status INTEGER,
|
||||||
|
p_response_headers JSONB,
|
||||||
|
p_response_body TEXT,
|
||||||
|
p_response_time_ms INTEGER
|
||||||
|
)
|
||||||
|
RETURNS BOOLEAN AS $$
|
||||||
|
DECLARE
|
||||||
|
v_delivery RECORD;
|
||||||
|
v_is_success BOOLEAN;
|
||||||
|
BEGIN
|
||||||
|
SELECT * INTO v_delivery
|
||||||
|
FROM webhooks.deliveries
|
||||||
|
WHERE id = p_delivery_id;
|
||||||
|
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RETURN FALSE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
v_is_success := p_response_status >= 200 AND p_response_status < 300;
|
||||||
|
|
||||||
|
UPDATE webhooks.deliveries
|
||||||
|
SET
|
||||||
|
status = CASE WHEN v_is_success THEN 'delivered' ELSE 'failed' END,
|
||||||
|
response_status = p_response_status,
|
||||||
|
response_headers = p_response_headers,
|
||||||
|
response_body = LEFT(p_response_body, 10000), -- Truncar respuesta larga
|
||||||
|
response_time_ms = p_response_time_ms,
|
||||||
|
completed_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = p_delivery_id;
|
||||||
|
|
||||||
|
-- Actualizar estadísticas del endpoint
|
||||||
|
UPDATE webhooks.endpoints
|
||||||
|
SET
|
||||||
|
total_deliveries = total_deliveries + 1,
|
||||||
|
successful_deliveries = successful_deliveries + CASE WHEN v_is_success THEN 1 ELSE 0 END,
|
||||||
|
failed_deliveries = failed_deliveries + CASE WHEN v_is_success THEN 0 ELSE 1 END,
|
||||||
|
last_delivery_at = CURRENT_TIMESTAMP,
|
||||||
|
last_success_at = CASE WHEN v_is_success THEN CURRENT_TIMESTAMP ELSE last_success_at END,
|
||||||
|
last_failure_at = CASE WHEN v_is_success THEN last_failure_at ELSE CURRENT_TIMESTAMP END,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = v_delivery.endpoint_id;
|
||||||
|
|
||||||
|
RETURN v_is_success;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Función para programar reintento
|
||||||
|
CREATE OR REPLACE FUNCTION webhooks.schedule_retry(
|
||||||
|
p_delivery_id UUID,
|
||||||
|
p_error_message TEXT DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS BOOLEAN AS $$
|
||||||
|
DECLARE
|
||||||
|
v_delivery RECORD;
|
||||||
|
v_endpoint RECORD;
|
||||||
|
v_delay_seconds INTEGER;
|
||||||
|
BEGIN
|
||||||
|
SELECT d.*, e.retry_delay_seconds, e.retry_backoff_multiplier
|
||||||
|
INTO v_delivery
|
||||||
|
FROM webhooks.deliveries d
|
||||||
|
JOIN webhooks.endpoints e ON e.id = d.endpoint_id
|
||||||
|
WHERE d.id = p_delivery_id;
|
||||||
|
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RETURN FALSE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Verificar si quedan reintentos
|
||||||
|
IF v_delivery.attempt_number >= v_delivery.max_attempts THEN
|
||||||
|
UPDATE webhooks.deliveries
|
||||||
|
SET status = 'failed', error_message = p_error_message, completed_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = p_delivery_id;
|
||||||
|
RETURN FALSE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Calcular delay con backoff exponencial
|
||||||
|
v_delay_seconds := v_delivery.retry_delay_seconds *
|
||||||
|
POWER(v_delivery.retry_backoff_multiplier, v_delivery.attempt_number - 1);
|
||||||
|
|
||||||
|
-- Programar reintento
|
||||||
|
UPDATE webhooks.deliveries
|
||||||
|
SET
|
||||||
|
status = 'retrying',
|
||||||
|
attempt_number = attempt_number + 1,
|
||||||
|
next_retry_at = CURRENT_TIMESTAMP + (v_delay_seconds || ' seconds')::INTERVAL,
|
||||||
|
error_message = p_error_message
|
||||||
|
WHERE id = p_delivery_id;
|
||||||
|
|
||||||
|
RETURN TRUE;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Función para obtener estadísticas de un endpoint
|
||||||
|
CREATE OR REPLACE FUNCTION webhooks.get_endpoint_stats(
|
||||||
|
p_endpoint_id UUID,
|
||||||
|
p_days INTEGER DEFAULT 7
|
||||||
|
)
|
||||||
|
RETURNS TABLE (
|
||||||
|
total_deliveries BIGINT,
|
||||||
|
successful BIGINT,
|
||||||
|
failed BIGINT,
|
||||||
|
success_rate DECIMAL,
|
||||||
|
avg_response_time_ms DECIMAL,
|
||||||
|
deliveries_by_day JSONB,
|
||||||
|
errors_by_type JSONB
|
||||||
|
) AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_deliveries,
|
||||||
|
COUNT(*) FILTER (WHERE d.status = 'delivered') as successful,
|
||||||
|
COUNT(*) FILTER (WHERE d.status = 'failed') as failed,
|
||||||
|
ROUND(
|
||||||
|
COUNT(*) FILTER (WHERE d.status = 'delivered')::DECIMAL /
|
||||||
|
NULLIF(COUNT(*), 0) * 100, 2
|
||||||
|
) as success_rate,
|
||||||
|
ROUND(AVG(d.response_time_ms)::DECIMAL, 2) as avg_response_time_ms,
|
||||||
|
jsonb_object_agg(
|
||||||
|
COALESCE(DATE(d.created_at)::TEXT, 'unknown'),
|
||||||
|
day_count
|
||||||
|
) as deliveries_by_day,
|
||||||
|
jsonb_object_agg(
|
||||||
|
COALESCE(d.error_code, 'unknown'),
|
||||||
|
error_count
|
||||||
|
) FILTER (WHERE d.error_code IS NOT NULL) as errors_by_type
|
||||||
|
FROM webhooks.deliveries d
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT DATE(created_at) as day, COUNT(*) as day_count
|
||||||
|
FROM webhooks.deliveries
|
||||||
|
WHERE endpoint_id = p_endpoint_id
|
||||||
|
AND created_at > CURRENT_TIMESTAMP - (p_days || ' days')::INTERVAL
|
||||||
|
GROUP BY DATE(created_at)
|
||||||
|
) days ON TRUE
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT error_code, COUNT(*) as error_count
|
||||||
|
FROM webhooks.deliveries
|
||||||
|
WHERE endpoint_id = p_endpoint_id
|
||||||
|
AND error_code IS NOT NULL
|
||||||
|
AND created_at > CURRENT_TIMESTAMP - (p_days || ' days')::INTERVAL
|
||||||
|
GROUP BY error_code
|
||||||
|
) errors ON TRUE
|
||||||
|
WHERE d.endpoint_id = p_endpoint_id
|
||||||
|
AND d.created_at > CURRENT_TIMESTAMP - (p_days || ' days')::INTERVAL;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE;
|
||||||
|
|
||||||
|
-- Función para limpiar entregas antiguas
|
||||||
|
CREATE OR REPLACE FUNCTION webhooks.cleanup_old_deliveries(p_days INTEGER DEFAULT 30)
|
||||||
|
RETURNS INTEGER AS $$
|
||||||
|
DECLARE
|
||||||
|
deleted_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
DELETE FROM webhooks.deliveries
|
||||||
|
WHERE created_at < CURRENT_TIMESTAMP - (p_days || ' days')::INTERVAL
|
||||||
|
AND status IN ('delivered', 'failed', 'cancelled');
|
||||||
|
|
||||||
|
GET DIAGNOSTICS deleted_count = ROW_COUNT;
|
||||||
|
|
||||||
|
-- También limpiar eventos procesados
|
||||||
|
DELETE FROM webhooks.events
|
||||||
|
WHERE created_at < CURRENT_TIMESTAMP - (p_days || ' days')::INTERVAL
|
||||||
|
AND status = 'dispatched';
|
||||||
|
|
||||||
|
RETURN deleted_count;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TRIGGERS
|
||||||
|
-- =====================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION webhooks.update_timestamp()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_event_types_updated_at
|
||||||
|
BEFORE UPDATE ON webhooks.event_types
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION webhooks.update_timestamp();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_endpoints_updated_at
|
||||||
|
BEFORE UPDATE ON webhooks.endpoints
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION webhooks.update_timestamp();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_subscriptions_updated_at
|
||||||
|
BEFORE UPDATE ON webhooks.subscriptions
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION webhooks.update_timestamp();
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- SEED DATA: Event Types
|
||||||
|
-- =====================
|
||||||
|
INSERT INTO webhooks.event_types (code, name, category, description, payload_schema) VALUES
|
||||||
|
-- Sales events
|
||||||
|
('sale.created', 'Venta Creada', 'sales', 'Se creó una nueva venta', '{"type": "object", "properties": {"sale_id": {"type": "string"}, "total": {"type": "number"}}}'),
|
||||||
|
('sale.completed', 'Venta Completada', 'sales', 'Una venta fue completada y pagada', '{}'),
|
||||||
|
('sale.cancelled', 'Venta Cancelada', 'sales', 'Una venta fue cancelada', '{}'),
|
||||||
|
('sale.refunded', 'Venta Reembolsada', 'sales', 'Se procesó un reembolso', '{}'),
|
||||||
|
|
||||||
|
-- Inventory events
|
||||||
|
('inventory.low_stock', 'Stock Bajo', 'inventory', 'Un producto alcanzó el nivel mínimo de stock', '{}'),
|
||||||
|
('inventory.out_of_stock', 'Sin Stock', 'inventory', 'Un producto se quedó sin stock', '{}'),
|
||||||
|
('inventory.adjusted', 'Inventario Ajustado', 'inventory', 'Se realizó un ajuste de inventario', '{}'),
|
||||||
|
('inventory.received', 'Mercancía Recibida', 'inventory', 'Se recibió mercancía en el almacén', '{}'),
|
||||||
|
|
||||||
|
-- Customer events
|
||||||
|
('customer.created', 'Cliente Creado', 'customers', 'Se registró un nuevo cliente', '{}'),
|
||||||
|
('customer.updated', 'Cliente Actualizado', 'customers', 'Se actualizó información del cliente', '{}'),
|
||||||
|
|
||||||
|
-- Auth events
|
||||||
|
('user.created', 'Usuario Creado', 'auth', 'Se creó un nuevo usuario', '{}'),
|
||||||
|
('user.login', 'Inicio de Sesión', 'auth', 'Un usuario inició sesión', '{}'),
|
||||||
|
('user.password_reset', 'Contraseña Restablecida', 'auth', 'Un usuario restableció su contraseña', '{}'),
|
||||||
|
|
||||||
|
-- Billing events
|
||||||
|
('subscription.created', 'Suscripción Creada', 'billing', 'Se creó una nueva suscripción', '{}'),
|
||||||
|
('subscription.renewed', 'Suscripción Renovada', 'billing', 'Se renovó una suscripción', '{}'),
|
||||||
|
('subscription.cancelled', 'Suscripción Cancelada', 'billing', 'Se canceló una suscripción', '{}'),
|
||||||
|
('invoice.created', 'Factura Creada', 'billing', 'Se generó una nueva factura', '{}'),
|
||||||
|
('invoice.paid', 'Factura Pagada', 'billing', 'Se pagó una factura', '{}'),
|
||||||
|
('payment.received', 'Pago Recibido', 'billing', 'Se recibió un pago', '{}'),
|
||||||
|
('payment.failed', 'Pago Fallido', 'billing', 'Un pago falló', '{}'),
|
||||||
|
|
||||||
|
-- System events
|
||||||
|
('system.maintenance', 'Mantenimiento Programado', 'system', 'Se programó mantenimiento del sistema', '{}'),
|
||||||
|
('system.alert', 'Alerta del Sistema', 'system', 'Se generó una alerta del sistema', '{}')
|
||||||
|
|
||||||
|
ON CONFLICT (code) DO NOTHING;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- COMENTARIOS
|
||||||
|
-- =====================
|
||||||
|
COMMENT ON TABLE webhooks.event_types IS 'Tipos de eventos disponibles para webhooks';
|
||||||
|
COMMENT ON TABLE webhooks.endpoints IS 'Endpoints configurados por tenant para recibir webhooks';
|
||||||
|
COMMENT ON TABLE webhooks.deliveries IS 'Log de entregas de webhooks con estado y reintentos';
|
||||||
|
COMMENT ON TABLE webhooks.events IS 'Cola de eventos pendientes de despacho';
|
||||||
|
COMMENT ON TABLE webhooks.subscriptions IS 'Suscripciones individuales evento-endpoint';
|
||||||
|
COMMENT ON TABLE webhooks.endpoint_logs IS 'Logs de actividad de endpoints';
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION webhooks.emit_event IS 'Emite un evento a la cola de webhooks';
|
||||||
|
COMMENT ON FUNCTION webhooks.queue_delivery IS 'Encola una entrega de webhook';
|
||||||
|
COMMENT ON FUNCTION webhooks.mark_delivery_completed IS 'Marca una entrega como completada';
|
||||||
|
COMMENT ON FUNCTION webhooks.schedule_retry IS 'Programa un reintento de entrega';
|
||||||
|
COMMENT ON FUNCTION webhooks.get_endpoint_stats IS 'Obtiene estadísticas de un endpoint';
|
||||||
736
ddl/13-storage.sql
Normal file
736
ddl/13-storage.sql
Normal file
@ -0,0 +1,736 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- ARCHIVO: 13-storage.sql
|
||||||
|
-- DESCRIPCION: Sistema de almacenamiento de archivos, carpetas, uploads
|
||||||
|
-- VERSION: 1.0.0
|
||||||
|
-- PROYECTO: ERP-Core V2
|
||||||
|
-- FECHA: 2026-01-10
|
||||||
|
-- EPIC: SAAS-STORAGE (EPIC-SAAS-006)
|
||||||
|
-- HISTORIAS: US-070, US-071, US-072
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- SCHEMA: storage
|
||||||
|
-- =====================
|
||||||
|
CREATE SCHEMA IF NOT EXISTS storage;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: storage.buckets
|
||||||
|
-- Contenedores de almacenamiento
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS storage.buckets (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
-- Identificación
|
||||||
|
name VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
description TEXT,
|
||||||
|
|
||||||
|
-- Tipo
|
||||||
|
bucket_type VARCHAR(30) NOT NULL DEFAULT 'private',
|
||||||
|
-- public: acceso público sin autenticación
|
||||||
|
-- private: requiere autenticación
|
||||||
|
-- protected: requiere token temporal
|
||||||
|
|
||||||
|
-- Configuración
|
||||||
|
max_file_size_mb INTEGER DEFAULT 50,
|
||||||
|
allowed_mime_types TEXT[] DEFAULT '{}', -- Vacío = todos permitidos
|
||||||
|
allowed_extensions TEXT[] DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Políticas
|
||||||
|
auto_delete_days INTEGER, -- NULL = no auto-eliminar
|
||||||
|
versioning_enabled BOOLEAN DEFAULT FALSE,
|
||||||
|
max_versions INTEGER DEFAULT 5,
|
||||||
|
|
||||||
|
-- Storage backend
|
||||||
|
storage_provider VARCHAR(30) DEFAULT 'local', -- local, s3, gcs, azure
|
||||||
|
storage_config JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Límites por tenant
|
||||||
|
quota_per_tenant_gb INTEGER,
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
is_system BOOLEAN DEFAULT FALSE,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: storage.folders
|
||||||
|
-- Estructura de carpetas virtuales
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS storage.folders (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
bucket_id UUID NOT NULL REFERENCES storage.buckets(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Jerarquía
|
||||||
|
parent_id UUID REFERENCES storage.folders(id) ON DELETE CASCADE,
|
||||||
|
path TEXT NOT NULL, -- /documents/invoices/2026/
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
depth INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
description TEXT,
|
||||||
|
color VARCHAR(7), -- Color hex para UI
|
||||||
|
icon VARCHAR(50),
|
||||||
|
|
||||||
|
-- Permisos
|
||||||
|
is_private BOOLEAN DEFAULT FALSE,
|
||||||
|
owner_id UUID REFERENCES auth.users(id),
|
||||||
|
|
||||||
|
-- Estadísticas (actualizadas async)
|
||||||
|
file_count INTEGER DEFAULT 0,
|
||||||
|
total_size_bytes BIGINT DEFAULT 0,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by UUID REFERENCES auth.users(id),
|
||||||
|
|
||||||
|
UNIQUE(tenant_id, bucket_id, path)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: storage.files
|
||||||
|
-- Archivos almacenados
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS storage.files (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
bucket_id UUID NOT NULL REFERENCES storage.buckets(id) ON DELETE CASCADE,
|
||||||
|
folder_id UUID REFERENCES storage.folders(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
-- Identificación
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
original_name VARCHAR(255) NOT NULL,
|
||||||
|
path TEXT NOT NULL, -- Ruta completa en storage
|
||||||
|
|
||||||
|
-- Tipo de archivo
|
||||||
|
mime_type VARCHAR(100) NOT NULL,
|
||||||
|
extension VARCHAR(20),
|
||||||
|
category VARCHAR(30), -- image, document, video, audio, archive, other
|
||||||
|
|
||||||
|
-- Tamaño
|
||||||
|
size_bytes BIGINT NOT NULL,
|
||||||
|
|
||||||
|
-- Hashes para integridad y deduplicación
|
||||||
|
checksum_md5 VARCHAR(32),
|
||||||
|
checksum_sha256 VARCHAR(64),
|
||||||
|
|
||||||
|
-- Almacenamiento
|
||||||
|
storage_key TEXT NOT NULL, -- Key en el backend de storage
|
||||||
|
storage_url TEXT, -- URL directa (si aplica)
|
||||||
|
cdn_url TEXT, -- URL de CDN (si aplica)
|
||||||
|
|
||||||
|
-- Imagen (si aplica)
|
||||||
|
width INTEGER,
|
||||||
|
height INTEGER,
|
||||||
|
thumbnail_url TEXT,
|
||||||
|
thumbnails JSONB DEFAULT '{}', -- {small: url, medium: url, large: url}
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
tags TEXT[] DEFAULT '{}',
|
||||||
|
alt_text TEXT,
|
||||||
|
|
||||||
|
-- Versionamiento
|
||||||
|
version INTEGER DEFAULT 1,
|
||||||
|
parent_version_id UUID REFERENCES storage.files(id),
|
||||||
|
is_latest BOOLEAN DEFAULT TRUE,
|
||||||
|
|
||||||
|
-- Asociación con entidades
|
||||||
|
entity_type VARCHAR(100), -- product, user, invoice, etc.
|
||||||
|
entity_id UUID,
|
||||||
|
|
||||||
|
-- Acceso
|
||||||
|
is_public BOOLEAN DEFAULT FALSE,
|
||||||
|
access_count INTEGER DEFAULT 0,
|
||||||
|
last_accessed_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
status VARCHAR(20) DEFAULT 'active', -- active, processing, archived, deleted
|
||||||
|
archived_at TIMESTAMPTZ,
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Procesamiento
|
||||||
|
processing_status VARCHAR(20), -- pending, processing, completed, failed
|
||||||
|
processing_error TEXT,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
uploaded_by UUID REFERENCES auth.users(id),
|
||||||
|
|
||||||
|
UNIQUE(tenant_id, bucket_id, path, version)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: storage.file_access_tokens
|
||||||
|
-- Tokens de acceso temporal a archivos
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS storage.file_access_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
file_id UUID NOT NULL REFERENCES storage.files(id) ON DELETE CASCADE,
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Token
|
||||||
|
token VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
|
||||||
|
-- Permisos
|
||||||
|
permissions TEXT[] DEFAULT '{read}', -- read, download, write
|
||||||
|
|
||||||
|
-- Restricciones
|
||||||
|
allowed_ips INET[],
|
||||||
|
max_downloads INTEGER,
|
||||||
|
download_count INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
-- Validez
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
revoked_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_for VARCHAR(255), -- Email o nombre para quien se creó
|
||||||
|
purpose TEXT,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by UUID REFERENCES auth.users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: storage.uploads
|
||||||
|
-- Uploads en progreso (multipart, resumable)
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS storage.uploads (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
bucket_id UUID NOT NULL REFERENCES storage.buckets(id) ON DELETE CASCADE,
|
||||||
|
folder_id UUID REFERENCES storage.folders(id),
|
||||||
|
|
||||||
|
-- Archivo destino
|
||||||
|
file_name VARCHAR(255) NOT NULL,
|
||||||
|
mime_type VARCHAR(100),
|
||||||
|
total_size_bytes BIGINT,
|
||||||
|
|
||||||
|
-- Estado del upload
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||||
|
-- pending, uploading, processing, completed, failed, cancelled
|
||||||
|
|
||||||
|
-- Progreso
|
||||||
|
uploaded_bytes BIGINT DEFAULT 0,
|
||||||
|
upload_progress DECIMAL(5,2) DEFAULT 0,
|
||||||
|
|
||||||
|
-- Chunks (para multipart)
|
||||||
|
total_chunks INTEGER,
|
||||||
|
completed_chunks INTEGER DEFAULT 0,
|
||||||
|
chunk_size_bytes INTEGER,
|
||||||
|
chunks_status JSONB DEFAULT '{}', -- {0: 'completed', 1: 'pending', ...}
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Resultado
|
||||||
|
file_id UUID REFERENCES storage.files(id),
|
||||||
|
error_message TEXT,
|
||||||
|
|
||||||
|
-- Tiempos
|
||||||
|
started_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_chunk_at TIMESTAMPTZ,
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by UUID REFERENCES auth.users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: storage.file_shares
|
||||||
|
-- Archivos compartidos
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS storage.file_shares (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
file_id UUID NOT NULL REFERENCES storage.files(id) ON DELETE CASCADE,
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Compartido con
|
||||||
|
shared_with_user_id UUID REFERENCES auth.users(id),
|
||||||
|
shared_with_email VARCHAR(255),
|
||||||
|
shared_with_role VARCHAR(50),
|
||||||
|
|
||||||
|
-- Permisos
|
||||||
|
can_view BOOLEAN DEFAULT TRUE,
|
||||||
|
can_download BOOLEAN DEFAULT TRUE,
|
||||||
|
can_edit BOOLEAN DEFAULT FALSE,
|
||||||
|
can_delete BOOLEAN DEFAULT FALSE,
|
||||||
|
can_share BOOLEAN DEFAULT FALSE,
|
||||||
|
|
||||||
|
-- Link público
|
||||||
|
public_link VARCHAR(255) UNIQUE,
|
||||||
|
public_link_password VARCHAR(255),
|
||||||
|
|
||||||
|
-- Validez
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
revoked_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Estadísticas
|
||||||
|
view_count INTEGER DEFAULT 0,
|
||||||
|
download_count INTEGER DEFAULT 0,
|
||||||
|
last_accessed_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Notificaciones
|
||||||
|
notify_on_access BOOLEAN DEFAULT FALSE,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by UUID REFERENCES auth.users(id),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: storage.tenant_usage
|
||||||
|
-- Uso de storage por tenant
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS storage.tenant_usage (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
bucket_id UUID NOT NULL REFERENCES storage.buckets(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Uso actual
|
||||||
|
file_count INTEGER DEFAULT 0,
|
||||||
|
total_size_bytes BIGINT DEFAULT 0,
|
||||||
|
|
||||||
|
-- Límites
|
||||||
|
quota_bytes BIGINT,
|
||||||
|
quota_file_count INTEGER,
|
||||||
|
|
||||||
|
-- Uso por categoría
|
||||||
|
usage_by_category JSONB DEFAULT '{}',
|
||||||
|
-- {image: 1024000, document: 2048000, ...}
|
||||||
|
|
||||||
|
-- Histórico mensual
|
||||||
|
monthly_upload_bytes BIGINT DEFAULT 0,
|
||||||
|
monthly_download_bytes BIGINT DEFAULT 0,
|
||||||
|
month_year VARCHAR(7), -- 2026-01
|
||||||
|
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
UNIQUE(tenant_id, bucket_id, month_year)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- INDICES
|
||||||
|
-- =====================
|
||||||
|
|
||||||
|
-- Indices para buckets
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_buckets_name ON storage.buckets(name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_buckets_active ON storage.buckets(is_active) WHERE is_active = TRUE;
|
||||||
|
|
||||||
|
-- Indices para folders
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_folders_tenant ON storage.folders(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_folders_bucket ON storage.folders(bucket_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_folders_parent ON storage.folders(parent_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_folders_path ON storage.folders(path);
|
||||||
|
|
||||||
|
-- Indices para files
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_files_tenant ON storage.files(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_files_bucket ON storage.files(bucket_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_files_folder ON storage.files(folder_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_files_entity ON storage.files(entity_type, entity_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_files_mime ON storage.files(mime_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_files_category ON storage.files(category);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_files_status ON storage.files(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_files_checksum ON storage.files(checksum_sha256);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_files_created ON storage.files(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_files_tags ON storage.files USING GIN(tags);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_files_latest ON storage.files(parent_version_id) WHERE is_latest = TRUE;
|
||||||
|
|
||||||
|
-- Indices para file_access_tokens
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_access_tokens_file ON storage.file_access_tokens(file_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_access_tokens_token ON storage.file_access_tokens(token);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_access_tokens_valid ON storage.file_access_tokens(expires_at)
|
||||||
|
WHERE revoked_at IS NULL;
|
||||||
|
|
||||||
|
-- Indices para uploads
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_uploads_tenant ON storage.uploads(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_uploads_status ON storage.uploads(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_uploads_expires ON storage.uploads(expires_at) WHERE status = 'uploading';
|
||||||
|
|
||||||
|
-- Indices para file_shares
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_shares_file ON storage.file_shares(file_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_shares_user ON storage.file_shares(shared_with_user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_shares_link ON storage.file_shares(public_link);
|
||||||
|
|
||||||
|
-- Indices para tenant_usage
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_usage_tenant ON storage.tenant_usage(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_usage_bucket ON storage.tenant_usage(bucket_id);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- RLS POLICIES
|
||||||
|
-- =====================
|
||||||
|
|
||||||
|
-- Buckets son globales (lectura pública)
|
||||||
|
ALTER TABLE storage.buckets ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY public_read_buckets ON storage.buckets
|
||||||
|
FOR SELECT USING (is_active = TRUE);
|
||||||
|
|
||||||
|
-- Folders por tenant
|
||||||
|
ALTER TABLE storage.folders ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_folders ON storage.folders
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
-- Files por tenant
|
||||||
|
ALTER TABLE storage.files ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_files ON storage.files
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
-- Access tokens por tenant
|
||||||
|
ALTER TABLE storage.file_access_tokens ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_tokens ON storage.file_access_tokens
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
-- Uploads por tenant
|
||||||
|
ALTER TABLE storage.uploads ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_uploads ON storage.uploads
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
-- File shares por tenant
|
||||||
|
ALTER TABLE storage.file_shares ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_shares ON storage.file_shares
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
-- Tenant usage por tenant
|
||||||
|
ALTER TABLE storage.tenant_usage ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_usage ON storage.tenant_usage
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- FUNCIONES
|
||||||
|
-- =====================
|
||||||
|
|
||||||
|
-- Función para generar storage key único
|
||||||
|
CREATE OR REPLACE FUNCTION storage.generate_storage_key(
|
||||||
|
p_tenant_id UUID,
|
||||||
|
p_bucket_name VARCHAR(100),
|
||||||
|
p_file_name VARCHAR(255)
|
||||||
|
)
|
||||||
|
RETURNS TEXT AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN p_bucket_name || '/' ||
|
||||||
|
p_tenant_id::TEXT || '/' ||
|
||||||
|
TO_CHAR(CURRENT_DATE, 'YYYY/MM/DD') || '/' ||
|
||||||
|
gen_random_uuid()::TEXT || '/' ||
|
||||||
|
p_file_name;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Función para determinar categoría por mime type
|
||||||
|
CREATE OR REPLACE FUNCTION storage.get_file_category(p_mime_type VARCHAR(100))
|
||||||
|
RETURNS VARCHAR(30) AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN CASE
|
||||||
|
WHEN p_mime_type LIKE 'image/%' THEN 'image'
|
||||||
|
WHEN p_mime_type LIKE 'video/%' THEN 'video'
|
||||||
|
WHEN p_mime_type LIKE 'audio/%' THEN 'audio'
|
||||||
|
WHEN p_mime_type IN ('application/pdf', 'application/msword',
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'application/vnd.ms-excel',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'text/plain', 'text/csv') THEN 'document'
|
||||||
|
WHEN p_mime_type IN ('application/zip', 'application/x-rar-compressed',
|
||||||
|
'application/x-7z-compressed', 'application/gzip') THEN 'archive'
|
||||||
|
ELSE 'other'
|
||||||
|
END;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql IMMUTABLE;
|
||||||
|
|
||||||
|
-- Función para crear archivo
|
||||||
|
CREATE OR REPLACE FUNCTION storage.create_file(
|
||||||
|
p_tenant_id UUID,
|
||||||
|
p_bucket_id UUID,
|
||||||
|
p_folder_id UUID,
|
||||||
|
p_name VARCHAR(255),
|
||||||
|
p_original_name VARCHAR(255),
|
||||||
|
p_mime_type VARCHAR(100),
|
||||||
|
p_size_bytes BIGINT,
|
||||||
|
p_storage_key TEXT,
|
||||||
|
p_uploaded_by UUID DEFAULT NULL,
|
||||||
|
p_metadata JSONB DEFAULT '{}'
|
||||||
|
)
|
||||||
|
RETURNS UUID AS $$
|
||||||
|
DECLARE
|
||||||
|
v_file_id UUID;
|
||||||
|
v_bucket RECORD;
|
||||||
|
v_path TEXT;
|
||||||
|
v_category VARCHAR(30);
|
||||||
|
BEGIN
|
||||||
|
-- Verificar bucket
|
||||||
|
SELECT * INTO v_bucket FROM storage.buckets WHERE id = p_bucket_id;
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RAISE EXCEPTION 'Bucket not found';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Verificar tamaño
|
||||||
|
IF v_bucket.max_file_size_mb IS NOT NULL AND
|
||||||
|
p_size_bytes > v_bucket.max_file_size_mb * 1024 * 1024 THEN
|
||||||
|
RAISE EXCEPTION 'File size exceeds bucket limit';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Obtener path de la carpeta
|
||||||
|
IF p_folder_id IS NOT NULL THEN
|
||||||
|
SELECT path INTO v_path FROM storage.folders WHERE id = p_folder_id;
|
||||||
|
v_path := v_path || p_name;
|
||||||
|
ELSE
|
||||||
|
v_path := '/' || p_name;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Determinar categoría
|
||||||
|
v_category := storage.get_file_category(p_mime_type);
|
||||||
|
|
||||||
|
-- Crear archivo
|
||||||
|
INSERT INTO storage.files (
|
||||||
|
tenant_id, bucket_id, folder_id,
|
||||||
|
name, original_name, path,
|
||||||
|
mime_type, extension, category,
|
||||||
|
size_bytes, storage_key,
|
||||||
|
metadata, uploaded_by
|
||||||
|
) VALUES (
|
||||||
|
p_tenant_id, p_bucket_id, p_folder_id,
|
||||||
|
p_name, p_original_name, v_path,
|
||||||
|
p_mime_type, LOWER(SPLIT_PART(p_name, '.', -1)), v_category,
|
||||||
|
p_size_bytes, p_storage_key,
|
||||||
|
p_metadata, p_uploaded_by
|
||||||
|
) RETURNING id INTO v_file_id;
|
||||||
|
|
||||||
|
-- Actualizar estadísticas de carpeta
|
||||||
|
IF p_folder_id IS NOT NULL THEN
|
||||||
|
UPDATE storage.folders
|
||||||
|
SET file_count = file_count + 1,
|
||||||
|
total_size_bytes = total_size_bytes + p_size_bytes,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = p_folder_id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Actualizar uso del tenant
|
||||||
|
INSERT INTO storage.tenant_usage (tenant_id, bucket_id, file_count, total_size_bytes, month_year)
|
||||||
|
VALUES (p_tenant_id, p_bucket_id, 1, p_size_bytes, TO_CHAR(CURRENT_DATE, 'YYYY-MM'))
|
||||||
|
ON CONFLICT (tenant_id, bucket_id, month_year)
|
||||||
|
DO UPDATE SET
|
||||||
|
file_count = storage.tenant_usage.file_count + 1,
|
||||||
|
total_size_bytes = storage.tenant_usage.total_size_bytes + p_size_bytes,
|
||||||
|
updated_at = CURRENT_TIMESTAMP;
|
||||||
|
|
||||||
|
RETURN v_file_id;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Función para crear token de acceso
|
||||||
|
CREATE OR REPLACE FUNCTION storage.create_access_token(
|
||||||
|
p_file_id UUID,
|
||||||
|
p_expires_in_hours INTEGER DEFAULT 24,
|
||||||
|
p_permissions TEXT[] DEFAULT '{read}',
|
||||||
|
p_max_downloads INTEGER DEFAULT NULL,
|
||||||
|
p_created_by UUID DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS TEXT AS $$
|
||||||
|
DECLARE
|
||||||
|
v_token TEXT;
|
||||||
|
v_tenant_id UUID;
|
||||||
|
BEGIN
|
||||||
|
-- Obtener tenant del archivo
|
||||||
|
SELECT tenant_id INTO v_tenant_id FROM storage.files WHERE id = p_file_id;
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RAISE EXCEPTION 'File not found';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Generar token
|
||||||
|
v_token := 'sat_' || encode(gen_random_bytes(32), 'hex');
|
||||||
|
|
||||||
|
-- Crear registro
|
||||||
|
INSERT INTO storage.file_access_tokens (
|
||||||
|
file_id, tenant_id, token, permissions,
|
||||||
|
max_downloads, expires_at, created_by
|
||||||
|
) VALUES (
|
||||||
|
p_file_id, v_tenant_id, v_token, p_permissions,
|
||||||
|
p_max_downloads,
|
||||||
|
CURRENT_TIMESTAMP + (p_expires_in_hours || ' hours')::INTERVAL,
|
||||||
|
p_created_by
|
||||||
|
);
|
||||||
|
|
||||||
|
RETURN v_token;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Función para validar token de acceso
|
||||||
|
CREATE OR REPLACE FUNCTION storage.validate_access_token(
|
||||||
|
p_token VARCHAR(255),
|
||||||
|
p_permission VARCHAR(20) DEFAULT 'read'
|
||||||
|
)
|
||||||
|
RETURNS TABLE (
|
||||||
|
is_valid BOOLEAN,
|
||||||
|
file_id UUID,
|
||||||
|
tenant_id UUID,
|
||||||
|
error_message TEXT
|
||||||
|
) AS $$
|
||||||
|
DECLARE
|
||||||
|
v_token RECORD;
|
||||||
|
BEGIN
|
||||||
|
SELECT * INTO v_token
|
||||||
|
FROM storage.file_access_tokens
|
||||||
|
WHERE token = p_token;
|
||||||
|
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RETURN QUERY SELECT FALSE, NULL::UUID, NULL::UUID, 'Token not found'::TEXT;
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF v_token.revoked_at IS NOT NULL THEN
|
||||||
|
RETURN QUERY SELECT FALSE, NULL::UUID, NULL::UUID, 'Token revoked'::TEXT;
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF v_token.expires_at < CURRENT_TIMESTAMP THEN
|
||||||
|
RETURN QUERY SELECT FALSE, NULL::UUID, NULL::UUID, 'Token expired'::TEXT;
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT (p_permission = ANY(v_token.permissions)) THEN
|
||||||
|
RETURN QUERY SELECT FALSE, NULL::UUID, NULL::UUID, 'Permission denied'::TEXT;
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF v_token.max_downloads IS NOT NULL AND
|
||||||
|
p_permission = 'download' AND
|
||||||
|
v_token.download_count >= v_token.max_downloads THEN
|
||||||
|
RETURN QUERY SELECT FALSE, NULL::UUID, NULL::UUID, 'Download limit reached'::TEXT;
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Incrementar contador si es download
|
||||||
|
IF p_permission = 'download' THEN
|
||||||
|
UPDATE storage.file_access_tokens
|
||||||
|
SET download_count = download_count + 1
|
||||||
|
WHERE id = v_token.id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN QUERY SELECT TRUE, v_token.file_id, v_token.tenant_id, NULL::TEXT;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Función para obtener uso del tenant
|
||||||
|
CREATE OR REPLACE FUNCTION storage.get_tenant_usage(p_tenant_id UUID)
|
||||||
|
RETURNS TABLE (
|
||||||
|
total_files BIGINT,
|
||||||
|
total_size_bytes BIGINT,
|
||||||
|
total_size_mb DECIMAL,
|
||||||
|
usage_by_bucket JSONB,
|
||||||
|
usage_by_category JSONB
|
||||||
|
) AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(tu.file_count), 0)::BIGINT as total_files,
|
||||||
|
COALESCE(SUM(tu.total_size_bytes), 0)::BIGINT as total_size_bytes,
|
||||||
|
ROUND(COALESCE(SUM(tu.total_size_bytes), 0)::DECIMAL / 1024 / 1024, 2) as total_size_mb,
|
||||||
|
jsonb_object_agg(b.name, tu.total_size_bytes) as usage_by_bucket,
|
||||||
|
COALESCE(
|
||||||
|
(SELECT jsonb_object_agg(category, cat_size)
|
||||||
|
FROM (
|
||||||
|
SELECT f.category, SUM(f.size_bytes) as cat_size
|
||||||
|
FROM storage.files f
|
||||||
|
WHERE f.tenant_id = p_tenant_id AND f.status = 'active'
|
||||||
|
GROUP BY f.category
|
||||||
|
) cats),
|
||||||
|
'{}'::JSONB
|
||||||
|
) as usage_by_category
|
||||||
|
FROM storage.tenant_usage tu
|
||||||
|
JOIN storage.buckets b ON b.id = tu.bucket_id
|
||||||
|
WHERE tu.tenant_id = p_tenant_id
|
||||||
|
AND tu.month_year = TO_CHAR(CURRENT_DATE, 'YYYY-MM');
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE;
|
||||||
|
|
||||||
|
-- Función para limpiar archivos expirados
|
||||||
|
CREATE OR REPLACE FUNCTION storage.cleanup_expired_files()
|
||||||
|
RETURNS INTEGER AS $$
|
||||||
|
DECLARE
|
||||||
|
deleted_count INTEGER := 0;
|
||||||
|
v_bucket RECORD;
|
||||||
|
BEGIN
|
||||||
|
-- Procesar cada bucket con auto_delete_days
|
||||||
|
FOR v_bucket IN SELECT * FROM storage.buckets WHERE auto_delete_days IS NOT NULL LOOP
|
||||||
|
UPDATE storage.files
|
||||||
|
SET status = 'deleted', deleted_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE bucket_id = v_bucket.id
|
||||||
|
AND status = 'active'
|
||||||
|
AND created_at < CURRENT_TIMESTAMP - (v_bucket.auto_delete_days || ' days')::INTERVAL;
|
||||||
|
|
||||||
|
deleted_count := deleted_count + ROW_COUNT;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
-- Limpiar tokens expirados
|
||||||
|
DELETE FROM storage.file_access_tokens
|
||||||
|
WHERE expires_at < CURRENT_TIMESTAMP - INTERVAL '7 days';
|
||||||
|
|
||||||
|
-- Limpiar uploads abandonados
|
||||||
|
DELETE FROM storage.uploads
|
||||||
|
WHERE status IN ('pending', 'uploading')
|
||||||
|
AND expires_at < CURRENT_TIMESTAMP;
|
||||||
|
|
||||||
|
RETURN deleted_count;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TRIGGERS
|
||||||
|
-- =====================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION storage.update_timestamp()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_buckets_updated_at
|
||||||
|
BEFORE UPDATE ON storage.buckets
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION storage.update_timestamp();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_folders_updated_at
|
||||||
|
BEFORE UPDATE ON storage.folders
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION storage.update_timestamp();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_files_updated_at
|
||||||
|
BEFORE UPDATE ON storage.files
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION storage.update_timestamp();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_shares_updated_at
|
||||||
|
BEFORE UPDATE ON storage.file_shares
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION storage.update_timestamp();
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- SEED DATA: Buckets del Sistema
|
||||||
|
-- =====================
|
||||||
|
INSERT INTO storage.buckets (name, description, bucket_type, max_file_size_mb, allowed_mime_types, is_system) VALUES
|
||||||
|
('avatars', 'Avatares de usuarios', 'public', 5, '{image/jpeg,image/png,image/gif,image/webp}', TRUE),
|
||||||
|
('logos', 'Logos de empresas', 'public', 10, '{image/jpeg,image/png,image/svg+xml,image/webp}', TRUE),
|
||||||
|
('documents', 'Documentos generales', 'private', 50, '{}', TRUE),
|
||||||
|
('invoices', 'Facturas y comprobantes', 'private', 20, '{application/pdf,image/jpeg,image/png}', TRUE),
|
||||||
|
('products', 'Imágenes de productos', 'public', 10, '{image/jpeg,image/png,image/webp}', TRUE),
|
||||||
|
('attachments', 'Archivos adjuntos', 'private', 25, '{}', TRUE),
|
||||||
|
('exports', 'Exportaciones de datos', 'protected', 500, '{application/zip,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet}', TRUE),
|
||||||
|
('backups', 'Respaldos', 'private', 1000, '{application/zip,application/gzip}', TRUE)
|
||||||
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- COMENTARIOS
|
||||||
|
-- =====================
|
||||||
|
COMMENT ON TABLE storage.buckets IS 'Contenedores de almacenamiento configurables';
|
||||||
|
COMMENT ON TABLE storage.folders IS 'Estructura de carpetas virtuales por tenant';
|
||||||
|
COMMENT ON TABLE storage.files IS 'Archivos almacenados con metadata y versionamiento';
|
||||||
|
COMMENT ON TABLE storage.file_access_tokens IS 'Tokens de acceso temporal a archivos';
|
||||||
|
COMMENT ON TABLE storage.uploads IS 'Uploads en progreso (multipart/resumable)';
|
||||||
|
COMMENT ON TABLE storage.file_shares IS 'Configuración de archivos compartidos';
|
||||||
|
COMMENT ON TABLE storage.tenant_usage IS 'Uso de storage por tenant y bucket';
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION storage.create_file IS 'Crea un registro de archivo con validaciones';
|
||||||
|
COMMENT ON FUNCTION storage.create_access_token IS 'Genera un token de acceso temporal';
|
||||||
|
COMMENT ON FUNCTION storage.validate_access_token IS 'Valida un token de acceso';
|
||||||
|
COMMENT ON FUNCTION storage.get_tenant_usage IS 'Obtiene estadísticas de uso de storage';
|
||||||
852
ddl/14-ai.sql
Normal file
852
ddl/14-ai.sql
Normal file
@ -0,0 +1,852 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- ARCHIVO: 14-ai.sql
|
||||||
|
-- DESCRIPCION: Sistema de AI/ML, prompts, completions, embeddings
|
||||||
|
-- VERSION: 1.0.0
|
||||||
|
-- PROYECTO: ERP-Core V2
|
||||||
|
-- FECHA: 2026-01-10
|
||||||
|
-- EPIC: SAAS-AI (EPIC-SAAS-007)
|
||||||
|
-- HISTORIAS: US-080, US-081, US-082
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- SCHEMA: ai
|
||||||
|
-- =====================
|
||||||
|
CREATE SCHEMA IF NOT EXISTS ai;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- EXTENSIÓN: pgvector para embeddings
|
||||||
|
-- =====================
|
||||||
|
-- CREATE EXTENSION IF NOT EXISTS vector;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: ai.models
|
||||||
|
-- Modelos de AI disponibles
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS ai.models (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
-- Identificación
|
||||||
|
code VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
|
||||||
|
-- Proveedor
|
||||||
|
provider VARCHAR(50) NOT NULL, -- openai, anthropic, google, azure, local
|
||||||
|
model_id VARCHAR(100) NOT NULL, -- gpt-4, claude-3, etc.
|
||||||
|
|
||||||
|
-- Tipo
|
||||||
|
model_type VARCHAR(30) NOT NULL, -- chat, completion, embedding, image, audio
|
||||||
|
|
||||||
|
-- Capacidades
|
||||||
|
max_tokens INTEGER,
|
||||||
|
supports_functions BOOLEAN DEFAULT FALSE,
|
||||||
|
supports_vision BOOLEAN DEFAULT FALSE,
|
||||||
|
supports_streaming BOOLEAN DEFAULT TRUE,
|
||||||
|
|
||||||
|
-- Costos (por 1K tokens)
|
||||||
|
input_cost_per_1k DECIMAL(10,6),
|
||||||
|
output_cost_per_1k DECIMAL(10,6),
|
||||||
|
|
||||||
|
-- Límites
|
||||||
|
rate_limit_rpm INTEGER, -- Requests per minute
|
||||||
|
rate_limit_tpm INTEGER, -- Tokens per minute
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
is_default BOOLEAN DEFAULT FALSE,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: ai.prompts
|
||||||
|
-- Biblioteca de prompts del sistema
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS ai.prompts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES auth.tenants(id) ON DELETE CASCADE, -- NULL = global
|
||||||
|
|
||||||
|
-- Identificación
|
||||||
|
code VARCHAR(100) NOT NULL,
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
category VARCHAR(50), -- assistant, analysis, generation, extraction
|
||||||
|
|
||||||
|
-- Contenido
|
||||||
|
system_prompt TEXT,
|
||||||
|
user_prompt_template TEXT NOT NULL,
|
||||||
|
-- Variables: {{variable_name}}
|
||||||
|
|
||||||
|
-- Configuración del modelo
|
||||||
|
model_id UUID REFERENCES ai.models(id),
|
||||||
|
temperature DECIMAL(3,2) DEFAULT 0.7,
|
||||||
|
max_tokens INTEGER,
|
||||||
|
top_p DECIMAL(3,2),
|
||||||
|
frequency_penalty DECIMAL(3,2),
|
||||||
|
presence_penalty DECIMAL(3,2),
|
||||||
|
|
||||||
|
-- Variables requeridas
|
||||||
|
required_variables TEXT[] DEFAULT '{}',
|
||||||
|
variable_schema JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Funciones (para function calling)
|
||||||
|
functions JSONB DEFAULT '[]',
|
||||||
|
|
||||||
|
-- Versionamiento
|
||||||
|
version INTEGER DEFAULT 1,
|
||||||
|
is_latest BOOLEAN DEFAULT TRUE,
|
||||||
|
parent_version_id UUID REFERENCES ai.prompts(id),
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
is_system BOOLEAN DEFAULT FALSE,
|
||||||
|
|
||||||
|
-- Estadísticas
|
||||||
|
usage_count INTEGER DEFAULT 0,
|
||||||
|
avg_tokens_used INTEGER,
|
||||||
|
avg_latency_ms INTEGER,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by UUID REFERENCES auth.users(id),
|
||||||
|
|
||||||
|
UNIQUE(tenant_id, code, version)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: ai.conversations
|
||||||
|
-- Conversaciones con el asistente AI
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS ai.conversations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Identificación
|
||||||
|
title VARCHAR(255),
|
||||||
|
summary TEXT,
|
||||||
|
|
||||||
|
-- Contexto
|
||||||
|
context_type VARCHAR(50), -- general, sales, inventory, support
|
||||||
|
context_data JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Modelo usado
|
||||||
|
model_id UUID REFERENCES ai.models(id),
|
||||||
|
prompt_id UUID REFERENCES ai.prompts(id),
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
status VARCHAR(20) DEFAULT 'active', -- active, archived, deleted
|
||||||
|
is_pinned BOOLEAN DEFAULT FALSE,
|
||||||
|
|
||||||
|
-- Estadísticas
|
||||||
|
message_count INTEGER DEFAULT 0,
|
||||||
|
total_tokens INTEGER DEFAULT 0,
|
||||||
|
total_cost DECIMAL(10,4) DEFAULT 0,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
tags TEXT[] DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Tiempos
|
||||||
|
last_message_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: ai.messages
|
||||||
|
-- Mensajes en conversaciones
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS ai.messages (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
conversation_id UUID NOT NULL REFERENCES ai.conversations(id) ON DELETE CASCADE,
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Mensaje
|
||||||
|
role VARCHAR(20) NOT NULL, -- system, user, assistant, function
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
|
||||||
|
-- Función (si aplica)
|
||||||
|
function_name VARCHAR(100),
|
||||||
|
function_arguments JSONB,
|
||||||
|
function_result JSONB,
|
||||||
|
|
||||||
|
-- Modelo usado
|
||||||
|
model_id UUID REFERENCES ai.models(id),
|
||||||
|
model_response_id VARCHAR(255), -- ID de respuesta del proveedor
|
||||||
|
|
||||||
|
-- Tokens y costos
|
||||||
|
prompt_tokens INTEGER,
|
||||||
|
completion_tokens INTEGER,
|
||||||
|
total_tokens INTEGER,
|
||||||
|
cost DECIMAL(10,6),
|
||||||
|
|
||||||
|
-- Performance
|
||||||
|
latency_ms INTEGER,
|
||||||
|
finish_reason VARCHAR(30), -- stop, length, function_call, content_filter
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Feedback
|
||||||
|
feedback_rating INTEGER, -- 1-5
|
||||||
|
feedback_text TEXT,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: ai.completions
|
||||||
|
-- Completaciones individuales (no conversacionales)
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS ai.completions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID REFERENCES auth.users(id),
|
||||||
|
|
||||||
|
-- Prompt usado
|
||||||
|
prompt_id UUID REFERENCES ai.prompts(id),
|
||||||
|
prompt_code VARCHAR(100),
|
||||||
|
|
||||||
|
-- Modelo
|
||||||
|
model_id UUID REFERENCES ai.models(id),
|
||||||
|
|
||||||
|
-- Input/Output
|
||||||
|
input_text TEXT NOT NULL,
|
||||||
|
input_variables JSONB DEFAULT '{}',
|
||||||
|
output_text TEXT,
|
||||||
|
|
||||||
|
-- Tokens y costos
|
||||||
|
prompt_tokens INTEGER,
|
||||||
|
completion_tokens INTEGER,
|
||||||
|
total_tokens INTEGER,
|
||||||
|
cost DECIMAL(10,6),
|
||||||
|
|
||||||
|
-- Performance
|
||||||
|
latency_ms INTEGER,
|
||||||
|
finish_reason VARCHAR(30),
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
status VARCHAR(20) DEFAULT 'pending', -- pending, processing, completed, failed
|
||||||
|
error_message TEXT,
|
||||||
|
|
||||||
|
-- Contexto
|
||||||
|
context_type VARCHAR(50),
|
||||||
|
context_id UUID,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: ai.embeddings
|
||||||
|
-- Embeddings vectoriales
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS ai.embeddings (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Contenido original
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
content_hash VARCHAR(64), -- SHA-256 para deduplicación
|
||||||
|
|
||||||
|
-- Vector
|
||||||
|
-- embedding vector(1536), -- Para OpenAI ada-002
|
||||||
|
embedding_json JSONB, -- Alternativa si no hay pgvector
|
||||||
|
|
||||||
|
-- Modelo usado
|
||||||
|
model_id UUID REFERENCES ai.models(id),
|
||||||
|
model_name VARCHAR(100),
|
||||||
|
dimensions INTEGER,
|
||||||
|
|
||||||
|
-- Asociación
|
||||||
|
entity_type VARCHAR(100), -- product, document, faq
|
||||||
|
entity_id UUID,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
tags TEXT[] DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Chunks (si es parte de un documento grande)
|
||||||
|
chunk_index INTEGER,
|
||||||
|
chunk_total INTEGER,
|
||||||
|
parent_embedding_id UUID REFERENCES ai.embeddings(id),
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: ai.usage_logs
|
||||||
|
-- Log de uso de AI para billing
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS ai.usage_logs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID REFERENCES auth.users(id),
|
||||||
|
|
||||||
|
-- Modelo
|
||||||
|
model_id UUID REFERENCES ai.models(id),
|
||||||
|
model_name VARCHAR(100),
|
||||||
|
provider VARCHAR(50),
|
||||||
|
|
||||||
|
-- Tipo de uso
|
||||||
|
usage_type VARCHAR(30) NOT NULL, -- chat, completion, embedding, image
|
||||||
|
|
||||||
|
-- Tokens
|
||||||
|
prompt_tokens INTEGER DEFAULT 0,
|
||||||
|
completion_tokens INTEGER DEFAULT 0,
|
||||||
|
total_tokens INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
-- Costos
|
||||||
|
cost DECIMAL(10,6) DEFAULT 0,
|
||||||
|
|
||||||
|
-- Contexto
|
||||||
|
conversation_id UUID,
|
||||||
|
completion_id UUID,
|
||||||
|
request_id VARCHAR(255),
|
||||||
|
|
||||||
|
-- Periodo
|
||||||
|
usage_date DATE DEFAULT CURRENT_DATE,
|
||||||
|
usage_month VARCHAR(7), -- 2026-01
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: ai.tenant_quotas
|
||||||
|
-- Cuotas de AI por tenant
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS ai.tenant_quotas (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Límites mensuales
|
||||||
|
monthly_token_limit INTEGER,
|
||||||
|
monthly_request_limit INTEGER,
|
||||||
|
monthly_cost_limit DECIMAL(10,2),
|
||||||
|
|
||||||
|
-- Uso actual del mes
|
||||||
|
current_tokens INTEGER DEFAULT 0,
|
||||||
|
current_requests INTEGER DEFAULT 0,
|
||||||
|
current_cost DECIMAL(10,4) DEFAULT 0,
|
||||||
|
|
||||||
|
-- Periodo
|
||||||
|
quota_month VARCHAR(7) NOT NULL, -- 2026-01
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
is_exceeded BOOLEAN DEFAULT FALSE,
|
||||||
|
exceeded_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Alertas
|
||||||
|
alert_threshold_percent INTEGER DEFAULT 80,
|
||||||
|
alert_sent_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
UNIQUE(tenant_id, quota_month)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: ai.knowledge_base
|
||||||
|
-- Base de conocimiento para RAG
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS ai.knowledge_base (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES auth.tenants(id) ON DELETE CASCADE, -- NULL = global
|
||||||
|
|
||||||
|
-- Identificación
|
||||||
|
code VARCHAR(100) NOT NULL,
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
|
||||||
|
-- Fuente
|
||||||
|
source_type VARCHAR(30), -- manual, document, website, api
|
||||||
|
source_url TEXT,
|
||||||
|
source_file_id UUID,
|
||||||
|
|
||||||
|
-- Contenido
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
content_type VARCHAR(50), -- faq, documentation, policy, procedure
|
||||||
|
|
||||||
|
-- Categorización
|
||||||
|
category VARCHAR(100),
|
||||||
|
subcategory VARCHAR(100),
|
||||||
|
tags TEXT[] DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Embedding
|
||||||
|
embedding_id UUID REFERENCES ai.embeddings(id),
|
||||||
|
|
||||||
|
-- Relevancia
|
||||||
|
priority INTEGER DEFAULT 0,
|
||||||
|
relevance_score DECIMAL(5,4),
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
is_verified BOOLEAN DEFAULT FALSE,
|
||||||
|
verified_by UUID REFERENCES auth.users(id),
|
||||||
|
verified_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by UUID REFERENCES auth.users(id),
|
||||||
|
|
||||||
|
UNIQUE(tenant_id, code)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- INDICES
|
||||||
|
-- =====================
|
||||||
|
|
||||||
|
-- Indices para models
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_models_provider ON ai.models(provider);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_models_type ON ai.models(model_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_models_active ON ai.models(is_active) WHERE is_active = TRUE;
|
||||||
|
|
||||||
|
-- Indices para prompts
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_prompts_tenant ON ai.prompts(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_prompts_code ON ai.prompts(code);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_prompts_category ON ai.prompts(category);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_prompts_active ON ai.prompts(is_active) WHERE is_active = TRUE;
|
||||||
|
|
||||||
|
-- Indices para conversations
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_conversations_tenant ON ai.conversations(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_conversations_user ON ai.conversations(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_conversations_status ON ai.conversations(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_conversations_created ON ai.conversations(created_at DESC);
|
||||||
|
|
||||||
|
-- Indices para messages
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_conversation ON ai.messages(conversation_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_tenant ON ai.messages(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_created ON ai.messages(created_at);
|
||||||
|
|
||||||
|
-- Indices para completions
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_completions_tenant ON ai.completions(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_completions_user ON ai.completions(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_completions_prompt ON ai.completions(prompt_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_completions_context ON ai.completions(context_type, context_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_completions_created ON ai.completions(created_at DESC);
|
||||||
|
|
||||||
|
-- Indices para embeddings
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_embeddings_tenant ON ai.embeddings(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_embeddings_entity ON ai.embeddings(entity_type, entity_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_embeddings_hash ON ai.embeddings(content_hash);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_embeddings_tags ON ai.embeddings USING GIN(tags);
|
||||||
|
|
||||||
|
-- Indices para usage_logs
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_usage_tenant ON ai.usage_logs(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_usage_date ON ai.usage_logs(usage_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_usage_month ON ai.usage_logs(usage_month);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_usage_model ON ai.usage_logs(model_id);
|
||||||
|
|
||||||
|
-- Indices para tenant_quotas
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_quotas_tenant ON ai.tenant_quotas(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_quotas_month ON ai.tenant_quotas(quota_month);
|
||||||
|
|
||||||
|
-- Indices para knowledge_base
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_kb_tenant ON ai.knowledge_base(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_kb_category ON ai.knowledge_base(category);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_kb_tags ON ai.knowledge_base USING GIN(tags);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_kb_active ON ai.knowledge_base(is_active) WHERE is_active = TRUE;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- RLS POLICIES
|
||||||
|
-- =====================
|
||||||
|
|
||||||
|
-- Models son globales
|
||||||
|
ALTER TABLE ai.models ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY public_read_models ON ai.models
|
||||||
|
FOR SELECT USING (is_active = TRUE);
|
||||||
|
|
||||||
|
-- Prompts: globales o por tenant
|
||||||
|
ALTER TABLE ai.prompts ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_or_global_prompts ON ai.prompts
|
||||||
|
FOR SELECT USING (
|
||||||
|
tenant_id IS NULL
|
||||||
|
OR tenant_id = current_setting('app.current_tenant_id', true)::uuid
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Conversations por tenant
|
||||||
|
ALTER TABLE ai.conversations ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_conversations ON ai.conversations
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
-- Messages por tenant
|
||||||
|
ALTER TABLE ai.messages ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_messages ON ai.messages
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
-- Completions por tenant
|
||||||
|
ALTER TABLE ai.completions ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_completions ON ai.completions
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
-- Embeddings por tenant
|
||||||
|
ALTER TABLE ai.embeddings ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_embeddings ON ai.embeddings
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
-- Usage logs por tenant
|
||||||
|
ALTER TABLE ai.usage_logs ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_usage ON ai.usage_logs
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
-- Quotas por tenant
|
||||||
|
ALTER TABLE ai.tenant_quotas ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_quotas ON ai.tenant_quotas
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
||||||
|
|
||||||
|
-- Knowledge base: global o por tenant
|
||||||
|
ALTER TABLE ai.knowledge_base ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_or_global_kb ON ai.knowledge_base
|
||||||
|
FOR SELECT USING (
|
||||||
|
tenant_id IS NULL
|
||||||
|
OR tenant_id = current_setting('app.current_tenant_id', true)::uuid
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- FUNCIONES
|
||||||
|
-- =====================
|
||||||
|
|
||||||
|
-- Función para crear conversación
|
||||||
|
CREATE OR REPLACE FUNCTION ai.create_conversation(
|
||||||
|
p_tenant_id UUID,
|
||||||
|
p_user_id UUID,
|
||||||
|
p_title VARCHAR(255) DEFAULT NULL,
|
||||||
|
p_context_type VARCHAR(50) DEFAULT 'general',
|
||||||
|
p_model_id UUID DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS UUID AS $$
|
||||||
|
DECLARE
|
||||||
|
v_conversation_id UUID;
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO ai.conversations (
|
||||||
|
tenant_id, user_id, title, context_type, model_id
|
||||||
|
) VALUES (
|
||||||
|
p_tenant_id, p_user_id, p_title, p_context_type, p_model_id
|
||||||
|
) RETURNING id INTO v_conversation_id;
|
||||||
|
|
||||||
|
RETURN v_conversation_id;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Función para agregar mensaje a conversación
|
||||||
|
CREATE OR REPLACE FUNCTION ai.add_message(
|
||||||
|
p_conversation_id UUID,
|
||||||
|
p_role VARCHAR(20),
|
||||||
|
p_content TEXT,
|
||||||
|
p_model_id UUID DEFAULT NULL,
|
||||||
|
p_tokens JSONB DEFAULT NULL,
|
||||||
|
p_latency_ms INTEGER DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS UUID AS $$
|
||||||
|
DECLARE
|
||||||
|
v_message_id UUID;
|
||||||
|
v_tenant_id UUID;
|
||||||
|
v_prompt_tokens INTEGER;
|
||||||
|
v_completion_tokens INTEGER;
|
||||||
|
v_total_tokens INTEGER;
|
||||||
|
v_cost DECIMAL(10,6);
|
||||||
|
BEGIN
|
||||||
|
-- Obtener tenant de la conversación
|
||||||
|
SELECT tenant_id INTO v_tenant_id
|
||||||
|
FROM ai.conversations WHERE id = p_conversation_id;
|
||||||
|
|
||||||
|
-- Extraer tokens
|
||||||
|
v_prompt_tokens := (p_tokens->>'prompt_tokens')::INTEGER;
|
||||||
|
v_completion_tokens := (p_tokens->>'completion_tokens')::INTEGER;
|
||||||
|
v_total_tokens := COALESCE(v_prompt_tokens, 0) + COALESCE(v_completion_tokens, 0);
|
||||||
|
|
||||||
|
-- Calcular costo (si hay modelo)
|
||||||
|
IF p_model_id IS NOT NULL THEN
|
||||||
|
SELECT
|
||||||
|
(COALESCE(v_prompt_tokens, 0) * m.input_cost_per_1k / 1000) +
|
||||||
|
(COALESCE(v_completion_tokens, 0) * m.output_cost_per_1k / 1000)
|
||||||
|
INTO v_cost
|
||||||
|
FROM ai.models m WHERE m.id = p_model_id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Crear mensaje
|
||||||
|
INSERT INTO ai.messages (
|
||||||
|
conversation_id, tenant_id, role, content, model_id,
|
||||||
|
prompt_tokens, completion_tokens, total_tokens, cost, latency_ms
|
||||||
|
) VALUES (
|
||||||
|
p_conversation_id, v_tenant_id, p_role, p_content, p_model_id,
|
||||||
|
v_prompt_tokens, v_completion_tokens, v_total_tokens, v_cost, p_latency_ms
|
||||||
|
) RETURNING id INTO v_message_id;
|
||||||
|
|
||||||
|
-- Actualizar estadísticas de conversación
|
||||||
|
UPDATE ai.conversations
|
||||||
|
SET
|
||||||
|
message_count = message_count + 1,
|
||||||
|
total_tokens = total_tokens + COALESCE(v_total_tokens, 0),
|
||||||
|
total_cost = total_cost + COALESCE(v_cost, 0),
|
||||||
|
last_message_at = CURRENT_TIMESTAMP,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = p_conversation_id;
|
||||||
|
|
||||||
|
-- Registrar uso
|
||||||
|
IF v_total_tokens > 0 THEN
|
||||||
|
PERFORM ai.log_usage(
|
||||||
|
v_tenant_id, NULL, p_model_id, 'chat',
|
||||||
|
v_prompt_tokens, v_completion_tokens, v_cost
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN v_message_id;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Función para registrar uso
|
||||||
|
CREATE OR REPLACE FUNCTION ai.log_usage(
|
||||||
|
p_tenant_id UUID,
|
||||||
|
p_user_id UUID,
|
||||||
|
p_model_id UUID,
|
||||||
|
p_usage_type VARCHAR(30),
|
||||||
|
p_prompt_tokens INTEGER DEFAULT 0,
|
||||||
|
p_completion_tokens INTEGER DEFAULT 0,
|
||||||
|
p_cost DECIMAL(10,6) DEFAULT 0
|
||||||
|
)
|
||||||
|
RETURNS UUID AS $$
|
||||||
|
DECLARE
|
||||||
|
v_log_id UUID;
|
||||||
|
v_model_name VARCHAR(100);
|
||||||
|
v_provider VARCHAR(50);
|
||||||
|
v_current_month VARCHAR(7);
|
||||||
|
BEGIN
|
||||||
|
-- Obtener info del modelo
|
||||||
|
SELECT name, provider INTO v_model_name, v_provider
|
||||||
|
FROM ai.models WHERE id = p_model_id;
|
||||||
|
|
||||||
|
v_current_month := TO_CHAR(CURRENT_DATE, 'YYYY-MM');
|
||||||
|
|
||||||
|
-- Registrar uso
|
||||||
|
INSERT INTO ai.usage_logs (
|
||||||
|
tenant_id, user_id, model_id, model_name, provider,
|
||||||
|
usage_type, prompt_tokens, completion_tokens,
|
||||||
|
total_tokens, cost, usage_month
|
||||||
|
) VALUES (
|
||||||
|
p_tenant_id, p_user_id, p_model_id, v_model_name, v_provider,
|
||||||
|
p_usage_type, p_prompt_tokens, p_completion_tokens,
|
||||||
|
p_prompt_tokens + p_completion_tokens, p_cost, v_current_month
|
||||||
|
) RETURNING id INTO v_log_id;
|
||||||
|
|
||||||
|
-- Actualizar cuota del tenant
|
||||||
|
INSERT INTO ai.tenant_quotas (tenant_id, quota_month, current_tokens, current_requests, current_cost)
|
||||||
|
VALUES (p_tenant_id, v_current_month, p_prompt_tokens + p_completion_tokens, 1, p_cost)
|
||||||
|
ON CONFLICT (tenant_id, quota_month)
|
||||||
|
DO UPDATE SET
|
||||||
|
current_tokens = ai.tenant_quotas.current_tokens + p_prompt_tokens + p_completion_tokens,
|
||||||
|
current_requests = ai.tenant_quotas.current_requests + 1,
|
||||||
|
current_cost = ai.tenant_quotas.current_cost + p_cost,
|
||||||
|
updated_at = CURRENT_TIMESTAMP;
|
||||||
|
|
||||||
|
RETURN v_log_id;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Función para verificar cuota
|
||||||
|
CREATE OR REPLACE FUNCTION ai.check_quota(p_tenant_id UUID)
|
||||||
|
RETURNS TABLE (
|
||||||
|
has_quota BOOLEAN,
|
||||||
|
tokens_remaining INTEGER,
|
||||||
|
requests_remaining INTEGER,
|
||||||
|
cost_remaining DECIMAL,
|
||||||
|
percent_used INTEGER
|
||||||
|
) AS $$
|
||||||
|
DECLARE
|
||||||
|
v_quota RECORD;
|
||||||
|
BEGIN
|
||||||
|
SELECT * INTO v_quota
|
||||||
|
FROM ai.tenant_quotas
|
||||||
|
WHERE tenant_id = p_tenant_id
|
||||||
|
AND quota_month = TO_CHAR(CURRENT_DATE, 'YYYY-MM');
|
||||||
|
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
-- Sin límites configurados
|
||||||
|
RETURN QUERY SELECT TRUE, NULL::INTEGER, NULL::INTEGER, NULL::DECIMAL, 0;
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN QUERY SELECT
|
||||||
|
NOT v_quota.is_exceeded AND
|
||||||
|
(v_quota.monthly_token_limit IS NULL OR v_quota.current_tokens < v_quota.monthly_token_limit) AND
|
||||||
|
(v_quota.monthly_request_limit IS NULL OR v_quota.current_requests < v_quota.monthly_request_limit) AND
|
||||||
|
(v_quota.monthly_cost_limit IS NULL OR v_quota.current_cost < v_quota.monthly_cost_limit),
|
||||||
|
|
||||||
|
CASE WHEN v_quota.monthly_token_limit IS NOT NULL
|
||||||
|
THEN v_quota.monthly_token_limit - v_quota.current_tokens
|
||||||
|
ELSE NULL END,
|
||||||
|
|
||||||
|
CASE WHEN v_quota.monthly_request_limit IS NOT NULL
|
||||||
|
THEN v_quota.monthly_request_limit - v_quota.current_requests
|
||||||
|
ELSE NULL END,
|
||||||
|
|
||||||
|
CASE WHEN v_quota.monthly_cost_limit IS NOT NULL
|
||||||
|
THEN v_quota.monthly_cost_limit - v_quota.current_cost
|
||||||
|
ELSE NULL END,
|
||||||
|
|
||||||
|
CASE WHEN v_quota.monthly_token_limit IS NOT NULL
|
||||||
|
THEN (v_quota.current_tokens * 100 / v_quota.monthly_token_limit)::INTEGER
|
||||||
|
ELSE 0 END;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE;
|
||||||
|
|
||||||
|
-- Función para obtener uso del tenant
|
||||||
|
CREATE OR REPLACE FUNCTION ai.get_tenant_usage(
|
||||||
|
p_tenant_id UUID,
|
||||||
|
p_month VARCHAR(7) DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS TABLE (
|
||||||
|
total_tokens BIGINT,
|
||||||
|
total_requests BIGINT,
|
||||||
|
total_cost DECIMAL,
|
||||||
|
usage_by_model JSONB,
|
||||||
|
usage_by_type JSONB,
|
||||||
|
daily_usage JSONB
|
||||||
|
) AS $$
|
||||||
|
DECLARE
|
||||||
|
v_month VARCHAR(7);
|
||||||
|
BEGIN
|
||||||
|
v_month := COALESCE(p_month, TO_CHAR(CURRENT_DATE, 'YYYY-MM'));
|
||||||
|
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(ul.total_tokens), 0)::BIGINT as total_tokens,
|
||||||
|
COUNT(*)::BIGINT as total_requests,
|
||||||
|
COALESCE(SUM(ul.cost), 0)::DECIMAL as total_cost,
|
||||||
|
jsonb_object_agg(
|
||||||
|
COALESCE(ul.model_name, 'unknown'),
|
||||||
|
model_tokens
|
||||||
|
) as usage_by_model,
|
||||||
|
jsonb_object_agg(ul.usage_type, type_tokens) as usage_by_type,
|
||||||
|
jsonb_object_agg(ul.usage_date::TEXT, day_tokens) as daily_usage
|
||||||
|
FROM ai.usage_logs ul
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT model_name, SUM(total_tokens) as model_tokens
|
||||||
|
FROM ai.usage_logs
|
||||||
|
WHERE tenant_id = p_tenant_id AND usage_month = v_month
|
||||||
|
GROUP BY model_name
|
||||||
|
) models ON models.model_name = ul.model_name
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT usage_type, SUM(total_tokens) as type_tokens
|
||||||
|
FROM ai.usage_logs
|
||||||
|
WHERE tenant_id = p_tenant_id AND usage_month = v_month
|
||||||
|
GROUP BY usage_type
|
||||||
|
) types ON types.usage_type = ul.usage_type
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT usage_date, SUM(total_tokens) as day_tokens
|
||||||
|
FROM ai.usage_logs
|
||||||
|
WHERE tenant_id = p_tenant_id AND usage_month = v_month
|
||||||
|
GROUP BY usage_date
|
||||||
|
) days ON days.usage_date = ul.usage_date
|
||||||
|
WHERE ul.tenant_id = p_tenant_id
|
||||||
|
AND ul.usage_month = v_month;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TRIGGERS
|
||||||
|
-- =====================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION ai.update_timestamp()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_models_updated_at
|
||||||
|
BEFORE UPDATE ON ai.models
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION ai.update_timestamp();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_prompts_updated_at
|
||||||
|
BEFORE UPDATE ON ai.prompts
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION ai.update_timestamp();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_conversations_updated_at
|
||||||
|
BEFORE UPDATE ON ai.conversations
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION ai.update_timestamp();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_embeddings_updated_at
|
||||||
|
BEFORE UPDATE ON ai.embeddings
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION ai.update_timestamp();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_kb_updated_at
|
||||||
|
BEFORE UPDATE ON ai.knowledge_base
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION ai.update_timestamp();
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- SEED DATA: Modelos
|
||||||
|
-- =====================
|
||||||
|
INSERT INTO ai.models (code, name, provider, model_id, model_type, max_tokens, supports_functions, supports_vision, input_cost_per_1k, output_cost_per_1k) VALUES
|
||||||
|
('gpt-4o', 'GPT-4o', 'openai', 'gpt-4o', 'chat', 128000, TRUE, TRUE, 0.005, 0.015),
|
||||||
|
('gpt-4o-mini', 'GPT-4o Mini', 'openai', 'gpt-4o-mini', 'chat', 128000, TRUE, TRUE, 0.00015, 0.0006),
|
||||||
|
('gpt-4-turbo', 'GPT-4 Turbo', 'openai', 'gpt-4-turbo', 'chat', 128000, TRUE, TRUE, 0.01, 0.03),
|
||||||
|
('claude-3-opus', 'Claude 3 Opus', 'anthropic', 'claude-3-opus-20240229', 'chat', 200000, TRUE, TRUE, 0.015, 0.075),
|
||||||
|
('claude-3-sonnet', 'Claude 3 Sonnet', 'anthropic', 'claude-3-sonnet-20240229', 'chat', 200000, TRUE, TRUE, 0.003, 0.015),
|
||||||
|
('claude-3-haiku', 'Claude 3 Haiku', 'anthropic', 'claude-3-haiku-20240307', 'chat', 200000, TRUE, TRUE, 0.00025, 0.00125),
|
||||||
|
('text-embedding-3-small', 'Text Embedding 3 Small', 'openai', 'text-embedding-3-small', 'embedding', 8191, FALSE, FALSE, 0.00002, 0),
|
||||||
|
('text-embedding-3-large', 'Text Embedding 3 Large', 'openai', 'text-embedding-3-large', 'embedding', 8191, FALSE, FALSE, 0.00013, 0)
|
||||||
|
ON CONFLICT (code) DO NOTHING;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- SEED DATA: Prompts del Sistema
|
||||||
|
-- =====================
|
||||||
|
INSERT INTO ai.prompts (code, name, category, system_prompt, user_prompt_template, required_variables, is_system) VALUES
|
||||||
|
('assistant_general', 'Asistente General', 'assistant',
|
||||||
|
'Eres un asistente virtual para un sistema ERP. Ayudas a los usuarios con consultas sobre ventas, inventario, facturación y gestión empresarial. Responde de forma clara y concisa en español.',
|
||||||
|
'{{user_message}}',
|
||||||
|
'{user_message}', TRUE),
|
||||||
|
|
||||||
|
('sales_analysis', 'Análisis de Ventas', 'analysis',
|
||||||
|
'Eres un analista de ventas experto. Analiza los datos proporcionados y genera insights accionables.',
|
||||||
|
'Analiza los siguientes datos de ventas:\n\n{{sales_data}}\n\nGenera un resumen ejecutivo con los principales hallazgos.',
|
||||||
|
'{sales_data}', TRUE),
|
||||||
|
|
||||||
|
('product_description', 'Generador de Descripción', 'generation',
|
||||||
|
'Eres un copywriter experto en productos. Genera descripciones atractivas y persuasivas.',
|
||||||
|
'Genera una descripción de producto para:\n\nNombre: {{product_name}}\nCategoría: {{category}}\nCaracterísticas: {{features}}\n\nLa descripción debe ser de {{word_count}} palabras aproximadamente.',
|
||||||
|
'{product_name,category,features,word_count}', TRUE),
|
||||||
|
|
||||||
|
('invoice_data_extraction', 'Extracción de Facturas', 'extraction',
|
||||||
|
'Eres un experto en extracción de datos de documentos fiscales mexicanos. Extrae la información estructurada de facturas.',
|
||||||
|
'Extrae los datos de la siguiente factura:\n\n{{invoice_text}}\n\nDevuelve los datos en formato JSON con los campos: rfc_emisor, rfc_receptor, fecha, total, conceptos.',
|
||||||
|
'{invoice_text}', TRUE),
|
||||||
|
|
||||||
|
('support_response', 'Respuesta de Soporte', 'assistant',
|
||||||
|
'Eres un agente de soporte técnico. Responde de forma amable y profesional, proporcionando soluciones claras.',
|
||||||
|
'El cliente tiene el siguiente problema:\n\n{{issue_description}}\n\nContexto adicional:\n{{context}}\n\nGenera una respuesta de soporte apropiada.',
|
||||||
|
'{issue_description,context}', TRUE)
|
||||||
|
|
||||||
|
ON CONFLICT (tenant_id, code, version) DO NOTHING;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- COMENTARIOS
|
||||||
|
-- =====================
|
||||||
|
COMMENT ON TABLE ai.models IS 'Modelos de AI disponibles (OpenAI, Anthropic, etc.)';
|
||||||
|
COMMENT ON TABLE ai.prompts IS 'Biblioteca de prompts del sistema y personalizados';
|
||||||
|
COMMENT ON TABLE ai.conversations IS 'Conversaciones con el asistente AI';
|
||||||
|
COMMENT ON TABLE ai.messages IS 'Mensajes individuales en conversaciones';
|
||||||
|
COMMENT ON TABLE ai.completions IS 'Completaciones individuales (no conversacionales)';
|
||||||
|
COMMENT ON TABLE ai.embeddings IS 'Embeddings vectoriales para búsqueda semántica';
|
||||||
|
COMMENT ON TABLE ai.usage_logs IS 'Log de uso de AI para billing y analytics';
|
||||||
|
COMMENT ON TABLE ai.tenant_quotas IS 'Cuotas de uso de AI por tenant';
|
||||||
|
COMMENT ON TABLE ai.knowledge_base IS 'Base de conocimiento para RAG';
|
||||||
|
|
||||||
|
COMMENT ON FUNCTION ai.create_conversation IS 'Crea una nueva conversación con el asistente';
|
||||||
|
COMMENT ON FUNCTION ai.add_message IS 'Agrega un mensaje a una conversación';
|
||||||
|
COMMENT ON FUNCTION ai.log_usage IS 'Registra uso de AI para billing';
|
||||||
|
COMMENT ON FUNCTION ai.check_quota IS 'Verifica si el tenant tiene cuota disponible';
|
||||||
1018
ddl/15-whatsapp.sql
Normal file
1018
ddl/15-whatsapp.sql
Normal file
File diff suppressed because it is too large
Load Diff
215
ddl/16-partners.sql
Normal file
215
ddl/16-partners.sql
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- ARCHIVO: 16-partners.sql
|
||||||
|
-- DESCRIPCION: Partners (clientes, proveedores, contactos)
|
||||||
|
-- VERSION: 1.0.0
|
||||||
|
-- PROYECTO: ERP-Core V2
|
||||||
|
-- FECHA: 2026-01-13
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- SCHEMA: partners
|
||||||
|
-- =====================
|
||||||
|
CREATE SCHEMA IF NOT EXISTS partners;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: partners
|
||||||
|
-- Clientes, proveedores, y otros socios comerciales
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS partners.partners (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Identificacion
|
||||||
|
code VARCHAR(30) NOT NULL,
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
display_name VARCHAR(200),
|
||||||
|
|
||||||
|
-- Tipo de partner
|
||||||
|
partner_type VARCHAR(20) NOT NULL DEFAULT 'customer', -- customer, supplier, both, contact
|
||||||
|
|
||||||
|
-- Datos fiscales
|
||||||
|
tax_id VARCHAR(50), -- RFC en Mexico
|
||||||
|
tax_id_type VARCHAR(20), -- rfc_moral, rfc_fisica, extranjero
|
||||||
|
legal_name VARCHAR(200),
|
||||||
|
|
||||||
|
-- Contacto principal
|
||||||
|
email VARCHAR(255),
|
||||||
|
phone VARCHAR(30),
|
||||||
|
mobile VARCHAR(30),
|
||||||
|
website VARCHAR(255),
|
||||||
|
|
||||||
|
-- Credito y pagos
|
||||||
|
credit_limit DECIMAL(15, 2) DEFAULT 0,
|
||||||
|
payment_term_days INTEGER DEFAULT 0,
|
||||||
|
payment_method VARCHAR(50), -- cash, transfer, credit_card, check
|
||||||
|
|
||||||
|
-- Clasificacion
|
||||||
|
category VARCHAR(50), -- retail, wholesale, government, etc.
|
||||||
|
tags TEXT[] DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
is_verified BOOLEAN DEFAULT FALSE,
|
||||||
|
verified_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Configuracion
|
||||||
|
settings JSONB DEFAULT '{}',
|
||||||
|
-- Ejemplo: {"send_reminders": true, "preferred_contact": "email"}
|
||||||
|
|
||||||
|
-- Notas
|
||||||
|
notes TEXT,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by UUID REFERENCES auth.users(id),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_by UUID REFERENCES auth.users(id),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
UNIQUE(tenant_id, code)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para partners
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_partners_tenant ON partners.partners(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_partners_code ON partners.partners(code);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_partners_type ON partners.partners(partner_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_partners_tax_id ON partners.partners(tax_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_partners_active ON partners.partners(is_active) WHERE is_active = TRUE;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_partners_email ON partners.partners(email);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_partners_name ON partners.partners USING gin(to_tsvector('spanish', name));
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: partner_addresses
|
||||||
|
-- Direcciones de partners (facturacion, envio, etc.)
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS partners.partner_addresses (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
partner_id UUID NOT NULL REFERENCES partners.partners(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Tipo de direccion
|
||||||
|
address_type VARCHAR(20) NOT NULL DEFAULT 'billing', -- billing, shipping, main, other
|
||||||
|
|
||||||
|
-- Direccion
|
||||||
|
address_line1 VARCHAR(200) NOT NULL,
|
||||||
|
address_line2 VARCHAR(200),
|
||||||
|
city VARCHAR(100) NOT NULL,
|
||||||
|
state VARCHAR(100),
|
||||||
|
postal_code VARCHAR(20),
|
||||||
|
country VARCHAR(3) DEFAULT 'MEX',
|
||||||
|
|
||||||
|
-- Contacto en esta direccion
|
||||||
|
contact_name VARCHAR(100),
|
||||||
|
contact_phone VARCHAR(30),
|
||||||
|
contact_email VARCHAR(255),
|
||||||
|
|
||||||
|
-- Referencia
|
||||||
|
reference TEXT,
|
||||||
|
|
||||||
|
-- Geolocalizacion
|
||||||
|
latitude DECIMAL(10, 8),
|
||||||
|
longitude DECIMAL(11, 8),
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
is_default BOOLEAN DEFAULT FALSE,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para partner_addresses
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_partner_addresses_partner ON partners.partner_addresses(partner_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_partner_addresses_type ON partners.partner_addresses(address_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_partner_addresses_default ON partners.partner_addresses(partner_id, is_default) WHERE is_default = TRUE;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: partner_contacts
|
||||||
|
-- Contactos individuales de un partner
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS partners.partner_contacts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
partner_id UUID NOT NULL REFERENCES partners.partners(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Datos personales
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
job_title VARCHAR(100),
|
||||||
|
department VARCHAR(100),
|
||||||
|
|
||||||
|
-- Contacto
|
||||||
|
email VARCHAR(255),
|
||||||
|
phone VARCHAR(30),
|
||||||
|
mobile VARCHAR(30),
|
||||||
|
|
||||||
|
-- Rol
|
||||||
|
contact_type VARCHAR(20) DEFAULT 'general', -- general, billing, purchasing, sales, technical
|
||||||
|
is_primary BOOLEAN DEFAULT FALSE,
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
|
||||||
|
-- Notas
|
||||||
|
notes TEXT,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para partner_contacts
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_partner_contacts_partner ON partners.partner_contacts(partner_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_partner_contacts_type ON partners.partner_contacts(contact_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_partner_contacts_primary ON partners.partner_contacts(partner_id, is_primary) WHERE is_primary = TRUE;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_partner_contacts_email ON partners.partner_contacts(email);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: partner_bank_accounts
|
||||||
|
-- Cuentas bancarias de partners
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS partners.partner_bank_accounts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
partner_id UUID NOT NULL REFERENCES partners.partners(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Datos bancarios
|
||||||
|
bank_name VARCHAR(100) NOT NULL,
|
||||||
|
account_number VARCHAR(50),
|
||||||
|
clabe VARCHAR(20), -- CLABE para Mexico
|
||||||
|
swift_code VARCHAR(20),
|
||||||
|
iban VARCHAR(50),
|
||||||
|
|
||||||
|
-- Titular
|
||||||
|
account_holder VARCHAR(200),
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
is_default BOOLEAN DEFAULT FALSE,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
is_verified BOOLEAN DEFAULT FALSE,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para partner_bank_accounts
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_partner_bank_accounts_partner ON partners.partner_bank_accounts(partner_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_partner_bank_accounts_default ON partners.partner_bank_accounts(partner_id, is_default) WHERE is_default = TRUE;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- COMENTARIOS
|
||||||
|
-- =====================
|
||||||
|
COMMENT ON TABLE partners.partners IS 'Clientes, proveedores y otros socios comerciales del negocio';
|
||||||
|
COMMENT ON COLUMN partners.partners.partner_type IS 'Tipo: customer (cliente), supplier (proveedor), both (ambos), contact (contacto)';
|
||||||
|
COMMENT ON COLUMN partners.partners.tax_id IS 'Identificacion fiscal (RFC en Mexico)';
|
||||||
|
COMMENT ON COLUMN partners.partners.credit_limit IS 'Limite de credito en moneda local';
|
||||||
|
COMMENT ON COLUMN partners.partners.payment_term_days IS 'Dias de plazo para pago';
|
||||||
|
|
||||||
|
COMMENT ON TABLE partners.partner_addresses IS 'Direcciones asociadas a un partner (facturacion, envio, etc.)';
|
||||||
|
COMMENT ON COLUMN partners.partner_addresses.address_type IS 'Tipo: billing, shipping, main, other';
|
||||||
|
|
||||||
|
COMMENT ON TABLE partners.partner_contacts IS 'Personas de contacto individuales de un partner';
|
||||||
|
COMMENT ON COLUMN partners.partner_contacts.contact_type IS 'Rol: general, billing, purchasing, sales, technical';
|
||||||
|
|
||||||
|
COMMENT ON TABLE partners.partner_bank_accounts IS 'Cuentas bancarias de partners para pagos/cobros';
|
||||||
230
ddl/17-products.sql
Normal file
230
ddl/17-products.sql
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- ARCHIVO: 17-products.sql
|
||||||
|
-- DESCRIPCION: Productos, categorias y precios
|
||||||
|
-- VERSION: 1.0.0
|
||||||
|
-- PROYECTO: ERP-Core V2
|
||||||
|
-- FECHA: 2026-01-13
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- SCHEMA: products
|
||||||
|
-- =====================
|
||||||
|
CREATE SCHEMA IF NOT EXISTS products;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: product_categories
|
||||||
|
-- Categorias jerarquicas de productos
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS products.product_categories (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
parent_id UUID REFERENCES products.product_categories(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
-- Identificacion
|
||||||
|
code VARCHAR(30) NOT NULL,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
|
||||||
|
-- Jerarquia
|
||||||
|
hierarchy_path TEXT, -- /root/electronics/phones
|
||||||
|
hierarchy_level INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
-- Imagen/icono
|
||||||
|
image_url VARCHAR(500),
|
||||||
|
icon VARCHAR(50),
|
||||||
|
color VARCHAR(20),
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
display_order INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by UUID REFERENCES auth.users(id),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_by UUID REFERENCES auth.users(id),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
UNIQUE(tenant_id, code)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para product_categories
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_product_categories_tenant ON products.product_categories(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_product_categories_parent ON products.product_categories(parent_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_product_categories_code ON products.product_categories(code);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_product_categories_hierarchy ON products.product_categories(hierarchy_path);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_product_categories_active ON products.product_categories(is_active) WHERE is_active = TRUE;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: products
|
||||||
|
-- Productos y servicios
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS products.products (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
category_id UUID REFERENCES products.product_categories(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
-- Identificacion
|
||||||
|
sku VARCHAR(50) NOT NULL, -- Stock Keeping Unit
|
||||||
|
barcode VARCHAR(50), -- EAN, UPC, etc.
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
short_description VARCHAR(500),
|
||||||
|
|
||||||
|
-- Tipo
|
||||||
|
product_type VARCHAR(20) NOT NULL DEFAULT 'product', -- product, service, consumable, kit
|
||||||
|
|
||||||
|
-- Precios
|
||||||
|
price DECIMAL(15, 4) NOT NULL DEFAULT 0,
|
||||||
|
cost DECIMAL(15, 4) DEFAULT 0,
|
||||||
|
currency VARCHAR(3) DEFAULT 'MXN',
|
||||||
|
tax_included BOOLEAN DEFAULT TRUE,
|
||||||
|
|
||||||
|
-- Impuestos
|
||||||
|
tax_rate DECIMAL(5, 2) DEFAULT 16.00, -- IVA en Mexico
|
||||||
|
tax_code VARCHAR(20), -- Codigo SAT para facturacion
|
||||||
|
|
||||||
|
-- Unidad de medida
|
||||||
|
uom VARCHAR(20) DEFAULT 'PZA', -- Unidad de medida principal
|
||||||
|
uom_purchase VARCHAR(20), -- Unidad de medida para compras
|
||||||
|
uom_conversion DECIMAL(10, 4) DEFAULT 1, -- Factor de conversion
|
||||||
|
|
||||||
|
-- Inventario
|
||||||
|
track_inventory BOOLEAN DEFAULT TRUE,
|
||||||
|
min_stock DECIMAL(15, 4) DEFAULT 0,
|
||||||
|
max_stock DECIMAL(15, 4),
|
||||||
|
reorder_point DECIMAL(15, 4),
|
||||||
|
lead_time_days INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
-- Caracteristicas fisicas
|
||||||
|
weight DECIMAL(10, 4), -- Peso en kg
|
||||||
|
length DECIMAL(10, 4), -- Dimensiones en cm
|
||||||
|
width DECIMAL(10, 4),
|
||||||
|
height DECIMAL(10, 4),
|
||||||
|
volume DECIMAL(10, 4), -- Volumen en m3
|
||||||
|
|
||||||
|
-- Imagenes
|
||||||
|
image_url VARCHAR(500),
|
||||||
|
images JSONB DEFAULT '[]', -- Array de URLs de imagenes
|
||||||
|
|
||||||
|
-- Atributos
|
||||||
|
attributes JSONB DEFAULT '{}',
|
||||||
|
-- Ejemplo: {"color": "red", "size": "XL", "material": "cotton"}
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
is_sellable BOOLEAN DEFAULT TRUE,
|
||||||
|
is_purchasable BOOLEAN DEFAULT TRUE,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by UUID REFERENCES auth.users(id),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_by UUID REFERENCES auth.users(id),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
UNIQUE(tenant_id, sku)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para products
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_products_tenant ON products.products(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_products_category ON products.products(category_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_products_sku ON products.products(sku);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_products_barcode ON products.products(barcode);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_products_type ON products.products(product_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_products_active ON products.products(is_active) WHERE is_active = TRUE;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_products_name ON products.products USING gin(to_tsvector('spanish', name));
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_products_sellable ON products.products(is_sellable) WHERE is_sellable = TRUE;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_products_purchasable ON products.products(is_purchasable) WHERE is_purchasable = TRUE;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: product_prices
|
||||||
|
-- Listas de precios y precios especiales
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS products.product_prices (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
product_id UUID NOT NULL REFERENCES products.products(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Tipo de precio
|
||||||
|
price_type VARCHAR(30) NOT NULL DEFAULT 'standard', -- standard, wholesale, retail, promo
|
||||||
|
price_list_name VARCHAR(100),
|
||||||
|
|
||||||
|
-- Precio
|
||||||
|
price DECIMAL(15, 4) NOT NULL,
|
||||||
|
currency VARCHAR(3) DEFAULT 'MXN',
|
||||||
|
|
||||||
|
-- Cantidad minima para este precio
|
||||||
|
min_quantity DECIMAL(15, 4) DEFAULT 1,
|
||||||
|
|
||||||
|
-- Vigencia
|
||||||
|
valid_from TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
valid_to TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para product_prices
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_product_prices_product ON products.product_prices(product_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_product_prices_type ON products.product_prices(price_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_product_prices_active ON products.product_prices(is_active) WHERE is_active = TRUE;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_product_prices_validity ON products.product_prices(valid_from, valid_to);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: product_suppliers
|
||||||
|
-- Proveedores de productos
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS products.product_suppliers (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
product_id UUID NOT NULL REFERENCES products.products(id) ON DELETE CASCADE,
|
||||||
|
supplier_id UUID NOT NULL REFERENCES partners.partners(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Datos del proveedor
|
||||||
|
supplier_sku VARCHAR(50), -- SKU del proveedor
|
||||||
|
supplier_name VARCHAR(200), -- Nombre del producto del proveedor
|
||||||
|
|
||||||
|
-- Precios de compra
|
||||||
|
purchase_price DECIMAL(15, 4),
|
||||||
|
currency VARCHAR(3) DEFAULT 'MXN',
|
||||||
|
min_order_qty DECIMAL(15, 4) DEFAULT 1,
|
||||||
|
|
||||||
|
-- Tiempos
|
||||||
|
lead_time_days INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
is_preferred BOOLEAN DEFAULT FALSE,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
UNIQUE(product_id, supplier_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para product_suppliers
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_product_suppliers_product ON products.product_suppliers(product_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_product_suppliers_supplier ON products.product_suppliers(supplier_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_product_suppliers_preferred ON products.product_suppliers(product_id, is_preferred) WHERE is_preferred = TRUE;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- COMENTARIOS
|
||||||
|
-- =====================
|
||||||
|
COMMENT ON TABLE products.product_categories IS 'Categorias jerarquicas para organizar productos';
|
||||||
|
COMMENT ON COLUMN products.product_categories.hierarchy_path IS 'Path materializado para consultas eficientes de jerarquia';
|
||||||
|
|
||||||
|
COMMENT ON TABLE products.products IS 'Catalogo de productos y servicios';
|
||||||
|
COMMENT ON COLUMN products.products.product_type IS 'Tipo: product (fisico), service (servicio), consumable (consumible), kit (combo)';
|
||||||
|
COMMENT ON COLUMN products.products.sku IS 'Stock Keeping Unit - identificador unico del producto';
|
||||||
|
COMMENT ON COLUMN products.products.tax_code IS 'Codigo SAT para facturacion electronica en Mexico';
|
||||||
|
COMMENT ON COLUMN products.products.track_inventory IS 'Si se debe llevar control de inventario';
|
||||||
|
|
||||||
|
COMMENT ON TABLE products.product_prices IS 'Listas de precios y precios especiales por cantidad';
|
||||||
|
COMMENT ON COLUMN products.product_prices.price_type IS 'Tipo: standard, wholesale (mayoreo), retail (menudeo), promo (promocional)';
|
||||||
|
|
||||||
|
COMMENT ON TABLE products.product_suppliers IS 'Relacion de productos con sus proveedores';
|
||||||
|
COMMENT ON COLUMN products.product_suppliers.is_preferred IS 'Proveedor preferido para este producto';
|
||||||
182
ddl/18-warehouses.sql
Normal file
182
ddl/18-warehouses.sql
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- ARCHIVO: 18-warehouses.sql
|
||||||
|
-- DESCRIPCION: Almacenes y ubicaciones
|
||||||
|
-- VERSION: 1.0.0
|
||||||
|
-- PROYECTO: ERP-Core V2
|
||||||
|
-- FECHA: 2026-01-13
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- SCHEMA: inventory (compartido con 19-inventory.sql)
|
||||||
|
-- =====================
|
||||||
|
CREATE SCHEMA IF NOT EXISTS inventory;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: warehouses
|
||||||
|
-- Almacenes/bodegas para inventario
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS inventory.warehouses (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
branch_id UUID REFERENCES core.branches(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
-- Identificacion
|
||||||
|
code VARCHAR(20) NOT NULL,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
|
||||||
|
-- Tipo
|
||||||
|
warehouse_type VARCHAR(20) DEFAULT 'standard', -- standard, transit, returns, quarantine, virtual
|
||||||
|
|
||||||
|
-- Direccion
|
||||||
|
address_line1 VARCHAR(200),
|
||||||
|
address_line2 VARCHAR(200),
|
||||||
|
city VARCHAR(100),
|
||||||
|
state VARCHAR(100),
|
||||||
|
postal_code VARCHAR(20),
|
||||||
|
country VARCHAR(3) DEFAULT 'MEX',
|
||||||
|
|
||||||
|
-- Contacto
|
||||||
|
manager_name VARCHAR(100),
|
||||||
|
phone VARCHAR(30),
|
||||||
|
email VARCHAR(255),
|
||||||
|
|
||||||
|
-- Geolocalizacion
|
||||||
|
latitude DECIMAL(10, 8),
|
||||||
|
longitude DECIMAL(11, 8),
|
||||||
|
|
||||||
|
-- Capacidad
|
||||||
|
capacity_units INTEGER, -- Capacidad en unidades
|
||||||
|
capacity_volume DECIMAL(10, 4), -- Capacidad en m3
|
||||||
|
capacity_weight DECIMAL(10, 4), -- Capacidad en kg
|
||||||
|
|
||||||
|
-- Configuracion
|
||||||
|
settings JSONB DEFAULT '{}',
|
||||||
|
-- Ejemplo: {"allow_negative": false, "auto_reorder": true}
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
is_default BOOLEAN DEFAULT FALSE,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by UUID REFERENCES auth.users(id),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_by UUID REFERENCES auth.users(id),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
UNIQUE(tenant_id, code)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para warehouses
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_warehouses_tenant ON inventory.warehouses(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_warehouses_branch ON inventory.warehouses(branch_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_warehouses_code ON inventory.warehouses(code);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_warehouses_type ON inventory.warehouses(warehouse_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_warehouses_active ON inventory.warehouses(is_active) WHERE is_active = TRUE;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_warehouses_default ON inventory.warehouses(tenant_id, is_default) WHERE is_default = TRUE;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: warehouse_locations
|
||||||
|
-- Ubicaciones dentro de almacenes (pasillos, racks, estantes)
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS inventory.warehouse_locations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
warehouse_id UUID NOT NULL REFERENCES inventory.warehouses(id) ON DELETE CASCADE,
|
||||||
|
parent_id UUID REFERENCES inventory.warehouse_locations(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
-- Identificacion
|
||||||
|
code VARCHAR(30) NOT NULL,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
barcode VARCHAR(50),
|
||||||
|
|
||||||
|
-- Tipo de ubicacion
|
||||||
|
location_type VARCHAR(20) DEFAULT 'shelf', -- zone, aisle, rack, shelf, bin
|
||||||
|
|
||||||
|
-- Jerarquia
|
||||||
|
hierarchy_path TEXT, -- /warehouse-01/zone-a/rack-1/shelf-2
|
||||||
|
hierarchy_level INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
-- Coordenadas dentro del almacen
|
||||||
|
aisle VARCHAR(10),
|
||||||
|
rack VARCHAR(10),
|
||||||
|
shelf VARCHAR(10),
|
||||||
|
bin VARCHAR(10),
|
||||||
|
|
||||||
|
-- Capacidad
|
||||||
|
capacity_units INTEGER,
|
||||||
|
capacity_volume DECIMAL(10, 4),
|
||||||
|
capacity_weight DECIMAL(10, 4),
|
||||||
|
|
||||||
|
-- Restricciones
|
||||||
|
allowed_product_types TEXT[] DEFAULT '{}', -- Tipos de producto permitidos
|
||||||
|
temperature_range JSONB, -- {"min": -20, "max": 4} para productos refrigerados
|
||||||
|
humidity_range JSONB, -- {"min": 30, "max": 50}
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
is_pickable BOOLEAN DEFAULT TRUE, -- Se puede tomar inventario
|
||||||
|
is_receivable BOOLEAN DEFAULT TRUE, -- Se puede recibir inventario
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
UNIQUE(warehouse_id, code)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para warehouse_locations
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_warehouse_locations_warehouse ON inventory.warehouse_locations(warehouse_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_warehouse_locations_parent ON inventory.warehouse_locations(parent_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_warehouse_locations_code ON inventory.warehouse_locations(code);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_warehouse_locations_type ON inventory.warehouse_locations(location_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_warehouse_locations_hierarchy ON inventory.warehouse_locations(hierarchy_path);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_warehouse_locations_active ON inventory.warehouse_locations(is_active) WHERE is_active = TRUE;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_warehouse_locations_barcode ON inventory.warehouse_locations(barcode);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: warehouse_zones
|
||||||
|
-- Zonas logicas de almacen (para organizacion)
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS inventory.warehouse_zones (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
warehouse_id UUID NOT NULL REFERENCES inventory.warehouses(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Identificacion
|
||||||
|
code VARCHAR(20) NOT NULL,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
color VARCHAR(20),
|
||||||
|
|
||||||
|
-- Tipo de zona
|
||||||
|
zone_type VARCHAR(20) DEFAULT 'storage', -- storage, picking, packing, shipping, receiving, quarantine
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
UNIQUE(warehouse_id, code)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para warehouse_zones
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_warehouse_zones_warehouse ON inventory.warehouse_zones(warehouse_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_warehouse_zones_type ON inventory.warehouse_zones(zone_type);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- COMENTARIOS
|
||||||
|
-- =====================
|
||||||
|
COMMENT ON TABLE inventory.warehouses IS 'Almacenes y bodegas para gestion de inventario';
|
||||||
|
COMMENT ON COLUMN inventory.warehouses.warehouse_type IS 'Tipo: standard, transit (en transito), returns (devoluciones), quarantine (cuarentena), virtual';
|
||||||
|
COMMENT ON COLUMN inventory.warehouses.is_default IS 'Almacen por defecto para operaciones';
|
||||||
|
|
||||||
|
COMMENT ON TABLE inventory.warehouse_locations IS 'Ubicaciones fisicas dentro de almacenes (racks, estantes, bins)';
|
||||||
|
COMMENT ON COLUMN inventory.warehouse_locations.location_type IS 'Tipo: zone, aisle, rack, shelf, bin';
|
||||||
|
COMMENT ON COLUMN inventory.warehouse_locations.is_pickable IS 'Se puede hacer picking desde esta ubicacion';
|
||||||
|
COMMENT ON COLUMN inventory.warehouse_locations.is_receivable IS 'Se puede recibir inventario en esta ubicacion';
|
||||||
|
|
||||||
|
COMMENT ON TABLE inventory.warehouse_zones IS 'Zonas logicas para organizar el almacen';
|
||||||
|
COMMENT ON COLUMN inventory.warehouse_zones.zone_type IS 'Tipo: storage, picking, packing, shipping, receiving, quarantine';
|
||||||
303
ddl/21-inventory.sql
Normal file
303
ddl/21-inventory.sql
Normal file
@ -0,0 +1,303 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- ARCHIVO: 21-inventory.sql
|
||||||
|
-- DESCRIPCION: Niveles de stock y movimientos de inventario
|
||||||
|
-- VERSION: 1.0.0
|
||||||
|
-- PROYECTO: ERP-Core V2
|
||||||
|
-- FECHA: 2026-01-13
|
||||||
|
-- DEPENDE DE: 17-products.sql, 18-warehouses.sql
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- SCHEMA: inventory (ya creado en 18-warehouses.sql)
|
||||||
|
-- =====================
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: stock_levels
|
||||||
|
-- Niveles de inventario por producto/almacen/ubicacion
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS inventory.stock_levels (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
product_id UUID NOT NULL REFERENCES products.products(id) ON DELETE CASCADE,
|
||||||
|
warehouse_id UUID NOT NULL REFERENCES inventory.warehouses(id) ON DELETE CASCADE,
|
||||||
|
location_id UUID REFERENCES inventory.warehouse_locations(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
-- Cantidades
|
||||||
|
quantity_on_hand DECIMAL(15, 4) NOT NULL DEFAULT 0, -- Cantidad fisica disponible
|
||||||
|
quantity_reserved DECIMAL(15, 4) NOT NULL DEFAULT 0, -- Reservada para ordenes
|
||||||
|
quantity_available DECIMAL(15, 4) GENERATED ALWAYS AS (quantity_on_hand - quantity_reserved) STORED,
|
||||||
|
quantity_incoming DECIMAL(15, 4) NOT NULL DEFAULT 0, -- En transito/por recibir
|
||||||
|
quantity_outgoing DECIMAL(15, 4) NOT NULL DEFAULT 0, -- Por enviar
|
||||||
|
|
||||||
|
-- Lote y serie
|
||||||
|
lot_number VARCHAR(50),
|
||||||
|
serial_number VARCHAR(50),
|
||||||
|
expiry_date DATE,
|
||||||
|
|
||||||
|
-- Costo
|
||||||
|
unit_cost DECIMAL(15, 4),
|
||||||
|
total_cost DECIMAL(15, 4),
|
||||||
|
|
||||||
|
-- Ultima actividad
|
||||||
|
last_movement_at TIMESTAMPTZ,
|
||||||
|
last_count_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
UNIQUE(product_id, warehouse_id, COALESCE(location_id, '00000000-0000-0000-0000-000000000000'::UUID), COALESCE(lot_number, ''), COALESCE(serial_number, ''))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para stock_levels
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_stock_levels_tenant ON inventory.stock_levels(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_stock_levels_product ON inventory.stock_levels(product_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_stock_levels_warehouse ON inventory.stock_levels(warehouse_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_stock_levels_location ON inventory.stock_levels(location_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_stock_levels_lot ON inventory.stock_levels(lot_number);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_stock_levels_serial ON inventory.stock_levels(serial_number);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_stock_levels_expiry ON inventory.stock_levels(expiry_date) WHERE expiry_date IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_stock_levels_low_stock ON inventory.stock_levels(quantity_on_hand) WHERE quantity_on_hand <= 0;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_stock_levels_available ON inventory.stock_levels(quantity_available);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: stock_movements
|
||||||
|
-- Movimientos de inventario (entradas, salidas, transferencias, ajustes)
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS inventory.stock_movements (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Tipo de movimiento
|
||||||
|
movement_type VARCHAR(20) NOT NULL, -- receipt, shipment, transfer, adjustment, return, production, consumption
|
||||||
|
movement_number VARCHAR(30) NOT NULL, -- Numero secuencial
|
||||||
|
|
||||||
|
-- Producto
|
||||||
|
product_id UUID NOT NULL REFERENCES products.products(id) ON DELETE RESTRICT,
|
||||||
|
|
||||||
|
-- Origen y destino
|
||||||
|
source_warehouse_id UUID REFERENCES inventory.warehouses(id),
|
||||||
|
source_location_id UUID REFERENCES inventory.warehouse_locations(id),
|
||||||
|
dest_warehouse_id UUID REFERENCES inventory.warehouses(id),
|
||||||
|
dest_location_id UUID REFERENCES inventory.warehouse_locations(id),
|
||||||
|
|
||||||
|
-- Cantidad
|
||||||
|
quantity DECIMAL(15, 4) NOT NULL,
|
||||||
|
uom VARCHAR(20) DEFAULT 'PZA',
|
||||||
|
|
||||||
|
-- Lote y serie
|
||||||
|
lot_number VARCHAR(50),
|
||||||
|
serial_number VARCHAR(50),
|
||||||
|
expiry_date DATE,
|
||||||
|
|
||||||
|
-- Costo
|
||||||
|
unit_cost DECIMAL(15, 4),
|
||||||
|
total_cost DECIMAL(15, 4),
|
||||||
|
|
||||||
|
-- Referencia
|
||||||
|
reference_type VARCHAR(30), -- sales_order, purchase_order, transfer_order, adjustment, return
|
||||||
|
reference_id UUID,
|
||||||
|
reference_number VARCHAR(50),
|
||||||
|
|
||||||
|
-- Razon (para ajustes)
|
||||||
|
reason VARCHAR(100),
|
||||||
|
notes TEXT,
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'draft', -- draft, confirmed, cancelled
|
||||||
|
confirmed_at TIMESTAMPTZ,
|
||||||
|
confirmed_by UUID REFERENCES auth.users(id),
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by UUID REFERENCES auth.users(id),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para stock_movements
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_stock_movements_tenant ON inventory.stock_movements(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_stock_movements_type ON inventory.stock_movements(movement_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_stock_movements_number ON inventory.stock_movements(movement_number);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_stock_movements_product ON inventory.stock_movements(product_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_stock_movements_source ON inventory.stock_movements(source_warehouse_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_stock_movements_dest ON inventory.stock_movements(dest_warehouse_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_stock_movements_status ON inventory.stock_movements(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_stock_movements_reference ON inventory.stock_movements(reference_type, reference_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_stock_movements_date ON inventory.stock_movements(created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_stock_movements_lot ON inventory.stock_movements(lot_number) WHERE lot_number IS NOT NULL;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: inventory_counts
|
||||||
|
-- Conteos fisicos de inventario
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS inventory.inventory_counts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
warehouse_id UUID NOT NULL REFERENCES inventory.warehouses(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Identificacion
|
||||||
|
count_number VARCHAR(30) NOT NULL,
|
||||||
|
name VARCHAR(100),
|
||||||
|
|
||||||
|
-- Tipo de conteo
|
||||||
|
count_type VARCHAR(20) DEFAULT 'full', -- full, partial, cycle, spot
|
||||||
|
|
||||||
|
-- Fecha programada
|
||||||
|
scheduled_date DATE,
|
||||||
|
started_at TIMESTAMPTZ,
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'draft', -- draft, in_progress, completed, cancelled
|
||||||
|
|
||||||
|
-- Responsable
|
||||||
|
assigned_to UUID REFERENCES auth.users(id),
|
||||||
|
|
||||||
|
-- Notas
|
||||||
|
notes TEXT,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by UUID REFERENCES auth.users(id),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para inventory_counts
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_inventory_counts_tenant ON inventory.inventory_counts(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_inventory_counts_warehouse ON inventory.inventory_counts(warehouse_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_inventory_counts_status ON inventory.inventory_counts(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_inventory_counts_date ON inventory.inventory_counts(scheduled_date);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: inventory_count_lines
|
||||||
|
-- Lineas de conteo de inventario
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS inventory.inventory_count_lines (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
count_id UUID NOT NULL REFERENCES inventory.inventory_counts(id) ON DELETE CASCADE,
|
||||||
|
product_id UUID NOT NULL REFERENCES products.products(id) ON DELETE RESTRICT,
|
||||||
|
location_id UUID REFERENCES inventory.warehouse_locations(id),
|
||||||
|
|
||||||
|
-- Cantidades
|
||||||
|
system_quantity DECIMAL(15, 4), -- Cantidad segun sistema
|
||||||
|
counted_quantity DECIMAL(15, 4), -- Cantidad contada
|
||||||
|
difference DECIMAL(15, 4) GENERATED ALWAYS AS (COALESCE(counted_quantity, 0) - COALESCE(system_quantity, 0)) STORED,
|
||||||
|
|
||||||
|
-- Lote y serie
|
||||||
|
lot_number VARCHAR(50),
|
||||||
|
serial_number VARCHAR(50),
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
is_counted BOOLEAN DEFAULT FALSE,
|
||||||
|
counted_at TIMESTAMPTZ,
|
||||||
|
counted_by UUID REFERENCES auth.users(id),
|
||||||
|
|
||||||
|
-- Notas
|
||||||
|
notes TEXT,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para inventory_count_lines
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_inventory_count_lines_count ON inventory.inventory_count_lines(count_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_inventory_count_lines_product ON inventory.inventory_count_lines(product_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_inventory_count_lines_location ON inventory.inventory_count_lines(location_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_inventory_count_lines_counted ON inventory.inventory_count_lines(is_counted);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: transfer_orders
|
||||||
|
-- Ordenes de transferencia entre almacenes
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS inventory.transfer_orders (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Identificacion
|
||||||
|
transfer_number VARCHAR(30) NOT NULL,
|
||||||
|
|
||||||
|
-- Origen y destino
|
||||||
|
source_warehouse_id UUID NOT NULL REFERENCES inventory.warehouses(id),
|
||||||
|
dest_warehouse_id UUID NOT NULL REFERENCES inventory.warehouses(id),
|
||||||
|
|
||||||
|
-- Fechas
|
||||||
|
scheduled_date DATE,
|
||||||
|
shipped_at TIMESTAMPTZ,
|
||||||
|
received_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'draft', -- draft, confirmed, shipped, in_transit, received, cancelled
|
||||||
|
|
||||||
|
-- Notas
|
||||||
|
notes TEXT,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by UUID REFERENCES auth.users(id),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
UNIQUE(tenant_id, transfer_number)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para transfer_orders
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_transfer_orders_tenant ON inventory.transfer_orders(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_transfer_orders_source ON inventory.transfer_orders(source_warehouse_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_transfer_orders_dest ON inventory.transfer_orders(dest_warehouse_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_transfer_orders_status ON inventory.transfer_orders(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_transfer_orders_date ON inventory.transfer_orders(scheduled_date);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: transfer_order_lines
|
||||||
|
-- Lineas de orden de transferencia
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS inventory.transfer_order_lines (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
transfer_id UUID NOT NULL REFERENCES inventory.transfer_orders(id) ON DELETE CASCADE,
|
||||||
|
product_id UUID NOT NULL REFERENCES products.products(id) ON DELETE RESTRICT,
|
||||||
|
|
||||||
|
-- Ubicaciones especificas
|
||||||
|
source_location_id UUID REFERENCES inventory.warehouse_locations(id),
|
||||||
|
dest_location_id UUID REFERENCES inventory.warehouse_locations(id),
|
||||||
|
|
||||||
|
-- Cantidades
|
||||||
|
quantity_requested DECIMAL(15, 4) NOT NULL,
|
||||||
|
quantity_shipped DECIMAL(15, 4) DEFAULT 0,
|
||||||
|
quantity_received DECIMAL(15, 4) DEFAULT 0,
|
||||||
|
|
||||||
|
-- Lote y serie
|
||||||
|
lot_number VARCHAR(50),
|
||||||
|
serial_number VARCHAR(50),
|
||||||
|
|
||||||
|
-- Notas
|
||||||
|
notes TEXT,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para transfer_order_lines
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_transfer_order_lines_transfer ON inventory.transfer_order_lines(transfer_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_transfer_order_lines_product ON inventory.transfer_order_lines(product_id);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- COMENTARIOS
|
||||||
|
-- =====================
|
||||||
|
COMMENT ON TABLE inventory.stock_levels IS 'Niveles actuales de inventario por producto/almacen/ubicacion';
|
||||||
|
COMMENT ON COLUMN inventory.stock_levels.quantity_on_hand IS 'Cantidad fisica disponible en el almacen';
|
||||||
|
COMMENT ON COLUMN inventory.stock_levels.quantity_reserved IS 'Cantidad reservada para ordenes pendientes';
|
||||||
|
COMMENT ON COLUMN inventory.stock_levels.quantity_available IS 'Cantidad disponible para venta (on_hand - reserved)';
|
||||||
|
COMMENT ON COLUMN inventory.stock_levels.quantity_incoming IS 'Cantidad en transito o por recibir';
|
||||||
|
|
||||||
|
COMMENT ON TABLE inventory.stock_movements IS 'Historial de movimientos de inventario';
|
||||||
|
COMMENT ON COLUMN inventory.stock_movements.movement_type IS 'Tipo: receipt (entrada), shipment (salida), transfer, adjustment, return, production, consumption';
|
||||||
|
COMMENT ON COLUMN inventory.stock_movements.status IS 'Estado: draft, confirmed, cancelled';
|
||||||
|
|
||||||
|
COMMENT ON TABLE inventory.inventory_counts IS 'Conteos fisicos de inventario para reconciliacion';
|
||||||
|
COMMENT ON COLUMN inventory.inventory_counts.count_type IS 'Tipo: full (completo), partial, cycle (ciclico), spot (aleatorio)';
|
||||||
|
|
||||||
|
COMMENT ON TABLE inventory.transfer_orders IS 'Ordenes de transferencia entre almacenes';
|
||||||
|
COMMENT ON COLUMN inventory.transfer_orders.status IS 'Estado: draft, confirmed, shipped, in_transit, received, cancelled';
|
||||||
285
ddl/22-sales.sql
Normal file
285
ddl/22-sales.sql
Normal file
@ -0,0 +1,285 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- ARCHIVO: 22-sales.sql
|
||||||
|
-- DESCRIPCION: Cotizaciones y ordenes de venta
|
||||||
|
-- VERSION: 1.0.0
|
||||||
|
-- PROYECTO: ERP-Core V2
|
||||||
|
-- FECHA: 2026-01-13
|
||||||
|
-- DEPENDE DE: 16-partners.sql, 17-products.sql
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- SCHEMA: sales
|
||||||
|
-- =====================
|
||||||
|
CREATE SCHEMA IF NOT EXISTS sales;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: quotations
|
||||||
|
-- Cotizaciones de venta
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS sales.quotations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Identificacion
|
||||||
|
quotation_number VARCHAR(30) NOT NULL,
|
||||||
|
|
||||||
|
-- Cliente
|
||||||
|
partner_id UUID NOT NULL REFERENCES partners.partners(id) ON DELETE RESTRICT,
|
||||||
|
partner_name VARCHAR(200), -- Snapshot del nombre
|
||||||
|
partner_email VARCHAR(255),
|
||||||
|
|
||||||
|
-- Direcciones
|
||||||
|
billing_address JSONB,
|
||||||
|
shipping_address JSONB,
|
||||||
|
|
||||||
|
-- Fechas
|
||||||
|
quotation_date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||||
|
valid_until DATE,
|
||||||
|
expected_close_date DATE,
|
||||||
|
|
||||||
|
-- Vendedor
|
||||||
|
sales_rep_id UUID REFERENCES auth.users(id),
|
||||||
|
|
||||||
|
-- Totales
|
||||||
|
currency VARCHAR(3) DEFAULT 'MXN',
|
||||||
|
subtotal DECIMAL(15, 2) NOT NULL DEFAULT 0,
|
||||||
|
tax_amount DECIMAL(15, 2) NOT NULL DEFAULT 0,
|
||||||
|
discount_amount DECIMAL(15, 2) NOT NULL DEFAULT 0,
|
||||||
|
total DECIMAL(15, 2) NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
-- Terminos
|
||||||
|
payment_term_days INTEGER DEFAULT 0,
|
||||||
|
payment_method VARCHAR(50),
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'draft', -- draft, sent, accepted, rejected, expired, converted
|
||||||
|
|
||||||
|
-- Conversion
|
||||||
|
converted_to_order BOOLEAN DEFAULT FALSE,
|
||||||
|
order_id UUID,
|
||||||
|
converted_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Notas
|
||||||
|
notes TEXT,
|
||||||
|
internal_notes TEXT,
|
||||||
|
terms_and_conditions TEXT,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by UUID REFERENCES auth.users(id),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_by UUID REFERENCES auth.users(id),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
UNIQUE(tenant_id, quotation_number)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para quotations
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_quotations_tenant ON sales.quotations(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_quotations_number ON sales.quotations(quotation_number);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_quotations_partner ON sales.quotations(partner_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_quotations_status ON sales.quotations(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_quotations_date ON sales.quotations(quotation_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_quotations_valid_until ON sales.quotations(valid_until);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_quotations_sales_rep ON sales.quotations(sales_rep_id);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: quotation_items
|
||||||
|
-- Lineas de cotizacion
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS sales.quotation_items (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
quotation_id UUID NOT NULL REFERENCES sales.quotations(id) ON DELETE CASCADE,
|
||||||
|
product_id UUID REFERENCES products.products(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
-- Linea
|
||||||
|
line_number INTEGER NOT NULL DEFAULT 1,
|
||||||
|
|
||||||
|
-- Producto
|
||||||
|
product_sku VARCHAR(50),
|
||||||
|
product_name VARCHAR(200) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
|
||||||
|
-- Cantidad y precio
|
||||||
|
quantity DECIMAL(15, 4) NOT NULL DEFAULT 1,
|
||||||
|
uom VARCHAR(20) DEFAULT 'PZA',
|
||||||
|
unit_price DECIMAL(15, 4) NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
-- Descuentos
|
||||||
|
discount_percent DECIMAL(5, 2) DEFAULT 0,
|
||||||
|
discount_amount DECIMAL(15, 2) DEFAULT 0,
|
||||||
|
|
||||||
|
-- Impuestos
|
||||||
|
tax_rate DECIMAL(5, 2) DEFAULT 16.00,
|
||||||
|
tax_amount DECIMAL(15, 2) DEFAULT 0,
|
||||||
|
|
||||||
|
-- Totales
|
||||||
|
subtotal DECIMAL(15, 2) NOT NULL DEFAULT 0,
|
||||||
|
total DECIMAL(15, 2) NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para quotation_items
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_quotation_items_quotation ON sales.quotation_items(quotation_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_quotation_items_product ON sales.quotation_items(product_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_quotation_items_line ON sales.quotation_items(quotation_id, line_number);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: sales_orders
|
||||||
|
-- Ordenes de venta
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS sales.sales_orders (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Identificacion
|
||||||
|
order_number VARCHAR(30) NOT NULL,
|
||||||
|
|
||||||
|
-- Origen
|
||||||
|
quotation_id UUID REFERENCES sales.quotations(id),
|
||||||
|
|
||||||
|
-- Cliente
|
||||||
|
partner_id UUID NOT NULL REFERENCES partners.partners(id) ON DELETE RESTRICT,
|
||||||
|
partner_name VARCHAR(200),
|
||||||
|
partner_email VARCHAR(255),
|
||||||
|
|
||||||
|
-- Direcciones
|
||||||
|
billing_address JSONB,
|
||||||
|
shipping_address JSONB,
|
||||||
|
|
||||||
|
-- Fechas
|
||||||
|
order_date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||||
|
requested_date DATE, -- Fecha solicitada por cliente
|
||||||
|
promised_date DATE, -- Fecha prometida
|
||||||
|
shipped_date DATE,
|
||||||
|
delivered_date DATE,
|
||||||
|
|
||||||
|
-- Vendedor
|
||||||
|
sales_rep_id UUID REFERENCES auth.users(id),
|
||||||
|
|
||||||
|
-- Almacen
|
||||||
|
warehouse_id UUID REFERENCES inventory.warehouses(id),
|
||||||
|
|
||||||
|
-- Totales
|
||||||
|
currency VARCHAR(3) DEFAULT 'MXN',
|
||||||
|
subtotal DECIMAL(15, 2) NOT NULL DEFAULT 0,
|
||||||
|
tax_amount DECIMAL(15, 2) NOT NULL DEFAULT 0,
|
||||||
|
discount_amount DECIMAL(15, 2) NOT NULL DEFAULT 0,
|
||||||
|
shipping_amount DECIMAL(15, 2) DEFAULT 0,
|
||||||
|
total DECIMAL(15, 2) NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
-- Terminos
|
||||||
|
payment_term_days INTEGER DEFAULT 0,
|
||||||
|
payment_method VARCHAR(50),
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'draft', -- draft, confirmed, processing, shipped, delivered, cancelled
|
||||||
|
|
||||||
|
-- Envio
|
||||||
|
shipping_method VARCHAR(50),
|
||||||
|
tracking_number VARCHAR(100),
|
||||||
|
carrier VARCHAR(100),
|
||||||
|
|
||||||
|
-- Notas
|
||||||
|
notes TEXT,
|
||||||
|
internal_notes TEXT,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by UUID REFERENCES auth.users(id),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_by UUID REFERENCES auth.users(id),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
UNIQUE(tenant_id, order_number)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para sales_orders
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sales_orders_tenant ON sales.sales_orders(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sales_orders_number ON sales.sales_orders(order_number);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sales_orders_quotation ON sales.sales_orders(quotation_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sales_orders_partner ON sales.sales_orders(partner_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sales_orders_status ON sales.sales_orders(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sales_orders_date ON sales.sales_orders(order_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sales_orders_warehouse ON sales.sales_orders(warehouse_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sales_orders_sales_rep ON sales.sales_orders(sales_rep_id);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: sales_order_items
|
||||||
|
-- Lineas de orden de venta
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS sales.sales_order_items (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
order_id UUID NOT NULL REFERENCES sales.sales_orders(id) ON DELETE CASCADE,
|
||||||
|
product_id UUID REFERENCES products.products(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
-- Linea
|
||||||
|
line_number INTEGER NOT NULL DEFAULT 1,
|
||||||
|
|
||||||
|
-- Producto
|
||||||
|
product_sku VARCHAR(50),
|
||||||
|
product_name VARCHAR(200) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
|
||||||
|
-- Cantidad
|
||||||
|
quantity DECIMAL(15, 4) NOT NULL DEFAULT 1,
|
||||||
|
quantity_reserved DECIMAL(15, 4) DEFAULT 0,
|
||||||
|
quantity_shipped DECIMAL(15, 4) DEFAULT 0,
|
||||||
|
quantity_delivered DECIMAL(15, 4) DEFAULT 0,
|
||||||
|
quantity_returned DECIMAL(15, 4) DEFAULT 0,
|
||||||
|
uom VARCHAR(20) DEFAULT 'PZA',
|
||||||
|
|
||||||
|
-- Precio
|
||||||
|
unit_price DECIMAL(15, 4) NOT NULL DEFAULT 0,
|
||||||
|
unit_cost DECIMAL(15, 4) DEFAULT 0,
|
||||||
|
|
||||||
|
-- Descuentos
|
||||||
|
discount_percent DECIMAL(5, 2) DEFAULT 0,
|
||||||
|
discount_amount DECIMAL(15, 2) DEFAULT 0,
|
||||||
|
|
||||||
|
-- Impuestos
|
||||||
|
tax_rate DECIMAL(5, 2) DEFAULT 16.00,
|
||||||
|
tax_amount DECIMAL(15, 2) DEFAULT 0,
|
||||||
|
|
||||||
|
-- Totales
|
||||||
|
subtotal DECIMAL(15, 2) NOT NULL DEFAULT 0,
|
||||||
|
total DECIMAL(15, 2) NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
-- Lote/Serie
|
||||||
|
lot_number VARCHAR(50),
|
||||||
|
serial_number VARCHAR(50),
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
status VARCHAR(20) DEFAULT 'pending', -- pending, reserved, shipped, delivered, cancelled
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para sales_order_items
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sales_order_items_order ON sales.sales_order_items(order_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sales_order_items_product ON sales.sales_order_items(product_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sales_order_items_line ON sales.sales_order_items(order_id, line_number);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sales_order_items_status ON sales.sales_order_items(status);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- COMENTARIOS
|
||||||
|
-- =====================
|
||||||
|
COMMENT ON TABLE sales.quotations IS 'Cotizaciones de venta a clientes';
|
||||||
|
COMMENT ON COLUMN sales.quotations.status IS 'Estado: draft, sent, accepted, rejected, expired, converted';
|
||||||
|
COMMENT ON COLUMN sales.quotations.converted_to_order IS 'Indica si la cotizacion fue convertida a orden de venta';
|
||||||
|
|
||||||
|
COMMENT ON TABLE sales.quotation_items IS 'Lineas de detalle de cotizaciones';
|
||||||
|
|
||||||
|
COMMENT ON TABLE sales.sales_orders IS 'Ordenes de venta confirmadas';
|
||||||
|
COMMENT ON COLUMN sales.sales_orders.status IS 'Estado: draft, confirmed, processing, shipped, delivered, cancelled';
|
||||||
|
COMMENT ON COLUMN sales.sales_orders.quotation_id IS 'Referencia a la cotizacion origen (si aplica)';
|
||||||
|
|
||||||
|
COMMENT ON TABLE sales.sales_order_items IS 'Lineas de detalle de ordenes de venta';
|
||||||
|
COMMENT ON COLUMN sales.sales_order_items.quantity_reserved IS 'Cantidad reservada en inventario';
|
||||||
|
COMMENT ON COLUMN sales.sales_order_items.quantity_shipped IS 'Cantidad enviada';
|
||||||
|
COMMENT ON COLUMN sales.sales_order_items.quantity_delivered IS 'Cantidad entregada al cliente';
|
||||||
243
ddl/23-purchases.sql
Normal file
243
ddl/23-purchases.sql
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- ARCHIVO: 23-purchases.sql
|
||||||
|
-- DESCRIPCION: Ordenes de compra
|
||||||
|
-- VERSION: 1.0.0
|
||||||
|
-- PROYECTO: ERP-Core V2
|
||||||
|
-- FECHA: 2026-01-13
|
||||||
|
-- DEPENDE DE: 16-partners.sql, 17-products.sql, 18-warehouses.sql
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- SCHEMA: purchases
|
||||||
|
-- =====================
|
||||||
|
CREATE SCHEMA IF NOT EXISTS purchases;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: purchase_orders
|
||||||
|
-- Ordenes de compra a proveedores
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS purchases.purchase_orders (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Identificacion
|
||||||
|
order_number VARCHAR(30) NOT NULL,
|
||||||
|
|
||||||
|
-- Proveedor
|
||||||
|
supplier_id UUID NOT NULL REFERENCES partners.partners(id) ON DELETE RESTRICT,
|
||||||
|
supplier_name VARCHAR(200),
|
||||||
|
supplier_email VARCHAR(255),
|
||||||
|
|
||||||
|
-- Direcciones
|
||||||
|
shipping_address JSONB, -- Direccion de recepcion
|
||||||
|
|
||||||
|
-- Fechas
|
||||||
|
order_date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||||
|
expected_date DATE, -- Fecha esperada de recepcion
|
||||||
|
received_date DATE,
|
||||||
|
|
||||||
|
-- Comprador
|
||||||
|
buyer_id UUID REFERENCES auth.users(id),
|
||||||
|
|
||||||
|
-- Almacen destino
|
||||||
|
warehouse_id UUID REFERENCES inventory.warehouses(id),
|
||||||
|
|
||||||
|
-- Totales
|
||||||
|
currency VARCHAR(3) DEFAULT 'MXN',
|
||||||
|
subtotal DECIMAL(15, 2) NOT NULL DEFAULT 0,
|
||||||
|
tax_amount DECIMAL(15, 2) NOT NULL DEFAULT 0,
|
||||||
|
discount_amount DECIMAL(15, 2) NOT NULL DEFAULT 0,
|
||||||
|
shipping_amount DECIMAL(15, 2) DEFAULT 0,
|
||||||
|
total DECIMAL(15, 2) NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
-- Terminos
|
||||||
|
payment_term_days INTEGER DEFAULT 0,
|
||||||
|
payment_method VARCHAR(50),
|
||||||
|
incoterm VARCHAR(10), -- FOB, CIF, EXW, etc.
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'draft', -- draft, sent, confirmed, partial, received, cancelled
|
||||||
|
|
||||||
|
-- Referencia del proveedor
|
||||||
|
supplier_reference VARCHAR(100),
|
||||||
|
|
||||||
|
-- Notas
|
||||||
|
notes TEXT,
|
||||||
|
internal_notes TEXT,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by UUID REFERENCES auth.users(id),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_by UUID REFERENCES auth.users(id),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
UNIQUE(tenant_id, order_number)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para purchase_orders
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchase_orders_tenant ON purchases.purchase_orders(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchase_orders_number ON purchases.purchase_orders(order_number);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchase_orders_supplier ON purchases.purchase_orders(supplier_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchase_orders_status ON purchases.purchase_orders(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchase_orders_date ON purchases.purchase_orders(order_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchase_orders_expected ON purchases.purchase_orders(expected_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchase_orders_warehouse ON purchases.purchase_orders(warehouse_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchase_orders_buyer ON purchases.purchase_orders(buyer_id);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: purchase_order_items
|
||||||
|
-- Lineas de orden de compra
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS purchases.purchase_order_items (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
order_id UUID NOT NULL REFERENCES purchases.purchase_orders(id) ON DELETE CASCADE,
|
||||||
|
product_id UUID REFERENCES products.products(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
-- Linea
|
||||||
|
line_number INTEGER NOT NULL DEFAULT 1,
|
||||||
|
|
||||||
|
-- Producto
|
||||||
|
product_sku VARCHAR(50),
|
||||||
|
product_name VARCHAR(200) NOT NULL,
|
||||||
|
supplier_sku VARCHAR(50), -- SKU del proveedor
|
||||||
|
description TEXT,
|
||||||
|
|
||||||
|
-- Cantidad
|
||||||
|
quantity DECIMAL(15, 4) NOT NULL DEFAULT 1,
|
||||||
|
quantity_received DECIMAL(15, 4) DEFAULT 0,
|
||||||
|
quantity_returned DECIMAL(15, 4) DEFAULT 0,
|
||||||
|
uom VARCHAR(20) DEFAULT 'PZA',
|
||||||
|
|
||||||
|
-- Precio
|
||||||
|
unit_price DECIMAL(15, 4) NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
-- Descuentos
|
||||||
|
discount_percent DECIMAL(5, 2) DEFAULT 0,
|
||||||
|
discount_amount DECIMAL(15, 2) DEFAULT 0,
|
||||||
|
|
||||||
|
-- Impuestos
|
||||||
|
tax_rate DECIMAL(5, 2) DEFAULT 16.00,
|
||||||
|
tax_amount DECIMAL(15, 2) DEFAULT 0,
|
||||||
|
|
||||||
|
-- Totales
|
||||||
|
subtotal DECIMAL(15, 2) NOT NULL DEFAULT 0,
|
||||||
|
total DECIMAL(15, 2) NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
-- Lote/Serie
|
||||||
|
lot_number VARCHAR(50),
|
||||||
|
expiry_date DATE,
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
status VARCHAR(20) DEFAULT 'pending', -- pending, partial, received, cancelled
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para purchase_order_items
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchase_order_items_order ON purchases.purchase_order_items(order_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchase_order_items_product ON purchases.purchase_order_items(product_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchase_order_items_line ON purchases.purchase_order_items(order_id, line_number);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchase_order_items_status ON purchases.purchase_order_items(status);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: purchase_receipts
|
||||||
|
-- Recepciones de mercancia
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS purchases.purchase_receipts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
order_id UUID NOT NULL REFERENCES purchases.purchase_orders(id) ON DELETE RESTRICT,
|
||||||
|
|
||||||
|
-- Identificacion
|
||||||
|
receipt_number VARCHAR(30) NOT NULL,
|
||||||
|
|
||||||
|
-- Recepcion
|
||||||
|
receipt_date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||||
|
received_by UUID REFERENCES auth.users(id),
|
||||||
|
|
||||||
|
-- Almacen
|
||||||
|
warehouse_id UUID REFERENCES inventory.warehouses(id),
|
||||||
|
location_id UUID REFERENCES inventory.warehouse_locations(id),
|
||||||
|
|
||||||
|
-- Documentos del proveedor
|
||||||
|
supplier_delivery_note VARCHAR(100),
|
||||||
|
supplier_invoice_number VARCHAR(100),
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'draft', -- draft, confirmed, cancelled
|
||||||
|
|
||||||
|
-- Notas
|
||||||
|
notes TEXT,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by UUID REFERENCES auth.users(id),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
UNIQUE(tenant_id, receipt_number)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para purchase_receipts
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchase_receipts_tenant ON purchases.purchase_receipts(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchase_receipts_order ON purchases.purchase_receipts(order_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchase_receipts_number ON purchases.purchase_receipts(receipt_number);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchase_receipts_date ON purchases.purchase_receipts(receipt_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchase_receipts_status ON purchases.purchase_receipts(status);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: purchase_receipt_items
|
||||||
|
-- Lineas de recepcion
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS purchases.purchase_receipt_items (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
receipt_id UUID NOT NULL REFERENCES purchases.purchase_receipts(id) ON DELETE CASCADE,
|
||||||
|
order_item_id UUID REFERENCES purchases.purchase_order_items(id),
|
||||||
|
product_id UUID REFERENCES products.products(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
-- Cantidad
|
||||||
|
quantity_expected DECIMAL(15, 4),
|
||||||
|
quantity_received DECIMAL(15, 4) NOT NULL,
|
||||||
|
quantity_rejected DECIMAL(15, 4) DEFAULT 0,
|
||||||
|
|
||||||
|
-- Lote/Serie
|
||||||
|
lot_number VARCHAR(50),
|
||||||
|
serial_number VARCHAR(50),
|
||||||
|
expiry_date DATE,
|
||||||
|
|
||||||
|
-- Ubicacion de almacenamiento
|
||||||
|
location_id UUID REFERENCES inventory.warehouse_locations(id),
|
||||||
|
|
||||||
|
-- Control de calidad
|
||||||
|
quality_status VARCHAR(20) DEFAULT 'pending', -- pending, approved, rejected, quarantine
|
||||||
|
quality_notes TEXT,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para purchase_receipt_items
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchase_receipt_items_receipt ON purchases.purchase_receipt_items(receipt_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchase_receipt_items_order_item ON purchases.purchase_receipt_items(order_item_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchase_receipt_items_product ON purchases.purchase_receipt_items(product_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchase_receipt_items_lot ON purchases.purchase_receipt_items(lot_number);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- COMENTARIOS
|
||||||
|
-- =====================
|
||||||
|
COMMENT ON TABLE purchases.purchase_orders IS 'Ordenes de compra a proveedores';
|
||||||
|
COMMENT ON COLUMN purchases.purchase_orders.status IS 'Estado: draft, sent, confirmed, partial (parcialmente recibido), received, cancelled';
|
||||||
|
COMMENT ON COLUMN purchases.purchase_orders.incoterm IS 'Termino de comercio internacional: FOB, CIF, EXW, etc.';
|
||||||
|
|
||||||
|
COMMENT ON TABLE purchases.purchase_order_items IS 'Lineas de detalle de ordenes de compra';
|
||||||
|
COMMENT ON COLUMN purchases.purchase_order_items.supplier_sku IS 'Codigo del producto segun el proveedor';
|
||||||
|
COMMENT ON COLUMN purchases.purchase_order_items.quantity_received IS 'Cantidad ya recibida de esta linea';
|
||||||
|
|
||||||
|
COMMENT ON TABLE purchases.purchase_receipts IS 'Documentos de recepcion de mercancia';
|
||||||
|
COMMENT ON COLUMN purchases.purchase_receipts.status IS 'Estado: draft, confirmed, cancelled';
|
||||||
|
|
||||||
|
COMMENT ON TABLE purchases.purchase_receipt_items IS 'Lineas de detalle de recepciones';
|
||||||
|
COMMENT ON COLUMN purchases.purchase_receipt_items.quality_status IS 'Estado QC: pending, approved, rejected, quarantine';
|
||||||
250
ddl/24-invoices.sql
Normal file
250
ddl/24-invoices.sql
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- ARCHIVO: 24-invoices.sql
|
||||||
|
-- DESCRIPCION: Facturas de venta/compra y pagos
|
||||||
|
-- VERSION: 1.0.0
|
||||||
|
-- PROYECTO: ERP-Core V2
|
||||||
|
-- FECHA: 2026-01-13
|
||||||
|
-- DEPENDE DE: 16-partners.sql, 22-sales.sql, 23-purchases.sql
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- SCHEMA: billing
|
||||||
|
-- =====================
|
||||||
|
CREATE SCHEMA IF NOT EXISTS billing;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: invoices
|
||||||
|
-- Facturas de venta y compra
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS billing.invoices (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Identificacion
|
||||||
|
invoice_number VARCHAR(30) NOT NULL,
|
||||||
|
invoice_type VARCHAR(20) NOT NULL DEFAULT 'sale', -- sale (venta), purchase (compra), credit_note, debit_note
|
||||||
|
|
||||||
|
-- Referencia a origen
|
||||||
|
sales_order_id UUID REFERENCES sales.sales_orders(id),
|
||||||
|
purchase_order_id UUID REFERENCES purchases.purchase_orders(id),
|
||||||
|
|
||||||
|
-- Partner
|
||||||
|
partner_id UUID NOT NULL REFERENCES partners.partners(id) ON DELETE RESTRICT,
|
||||||
|
partner_name VARCHAR(200),
|
||||||
|
partner_tax_id VARCHAR(50),
|
||||||
|
|
||||||
|
-- Direcciones
|
||||||
|
billing_address JSONB,
|
||||||
|
|
||||||
|
-- Fechas
|
||||||
|
invoice_date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||||
|
due_date DATE,
|
||||||
|
payment_date DATE, -- Fecha real de pago
|
||||||
|
|
||||||
|
-- Totales
|
||||||
|
currency VARCHAR(3) DEFAULT 'MXN',
|
||||||
|
exchange_rate DECIMAL(10, 6) DEFAULT 1,
|
||||||
|
subtotal DECIMAL(15, 2) NOT NULL DEFAULT 0,
|
||||||
|
tax_amount DECIMAL(15, 2) NOT NULL DEFAULT 0,
|
||||||
|
withholding_tax DECIMAL(15, 2) DEFAULT 0, -- Retenciones
|
||||||
|
discount_amount DECIMAL(15, 2) NOT NULL DEFAULT 0,
|
||||||
|
total DECIMAL(15, 2) NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
-- Pagos
|
||||||
|
amount_paid DECIMAL(15, 2) DEFAULT 0,
|
||||||
|
amount_due DECIMAL(15, 2) GENERATED ALWAYS AS (total - COALESCE(amount_paid, 0)) STORED,
|
||||||
|
|
||||||
|
-- Terminos
|
||||||
|
payment_term_days INTEGER DEFAULT 0,
|
||||||
|
payment_method VARCHAR(50),
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'draft', -- draft, validated, sent, partial, paid, cancelled, voided
|
||||||
|
|
||||||
|
-- CFDI (Facturacion electronica Mexico)
|
||||||
|
cfdi_uuid VARCHAR(40), -- UUID del CFDI
|
||||||
|
cfdi_status VARCHAR(20), -- pending, stamped, cancelled
|
||||||
|
cfdi_xml TEXT, -- XML del CFDI
|
||||||
|
cfdi_pdf_url VARCHAR(500),
|
||||||
|
|
||||||
|
-- Notas
|
||||||
|
notes TEXT,
|
||||||
|
internal_notes TEXT,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by UUID REFERENCES auth.users(id),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_by UUID REFERENCES auth.users(id),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
UNIQUE(tenant_id, invoice_number)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para invoices
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoices_tenant ON billing.invoices(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoices_number ON billing.invoices(invoice_number);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoices_type ON billing.invoices(invoice_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoices_partner ON billing.invoices(partner_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoices_sales_order ON billing.invoices(sales_order_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoices_purchase_order ON billing.invoices(purchase_order_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoices_status ON billing.invoices(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoices_date ON billing.invoices(invoice_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoices_due_date ON billing.invoices(due_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoices_cfdi ON billing.invoices(cfdi_uuid);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoices_unpaid ON billing.invoices(status) WHERE status IN ('validated', 'sent', 'partial');
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: invoice_items
|
||||||
|
-- Lineas de factura
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS billing.invoice_items (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
invoice_id UUID NOT NULL REFERENCES billing.invoices(id) ON DELETE CASCADE,
|
||||||
|
product_id UUID REFERENCES products.products(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
-- Linea
|
||||||
|
line_number INTEGER NOT NULL DEFAULT 1,
|
||||||
|
|
||||||
|
-- Producto
|
||||||
|
product_sku VARCHAR(50),
|
||||||
|
product_name VARCHAR(200) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
|
||||||
|
-- SAT (Mexico)
|
||||||
|
sat_product_code VARCHAR(20), -- Clave de producto SAT
|
||||||
|
sat_unit_code VARCHAR(10), -- Clave de unidad SAT
|
||||||
|
|
||||||
|
-- Cantidad y precio
|
||||||
|
quantity DECIMAL(15, 4) NOT NULL DEFAULT 1,
|
||||||
|
uom VARCHAR(20) DEFAULT 'PZA',
|
||||||
|
unit_price DECIMAL(15, 4) NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
-- Descuentos
|
||||||
|
discount_percent DECIMAL(5, 2) DEFAULT 0,
|
||||||
|
discount_amount DECIMAL(15, 2) DEFAULT 0,
|
||||||
|
|
||||||
|
-- Impuestos
|
||||||
|
tax_rate DECIMAL(5, 2) DEFAULT 16.00,
|
||||||
|
tax_amount DECIMAL(15, 2) DEFAULT 0,
|
||||||
|
withholding_rate DECIMAL(5, 2) DEFAULT 0,
|
||||||
|
withholding_amount DECIMAL(15, 2) DEFAULT 0,
|
||||||
|
|
||||||
|
-- Totales
|
||||||
|
subtotal DECIMAL(15, 2) NOT NULL DEFAULT 0,
|
||||||
|
total DECIMAL(15, 2) NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para invoice_items
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoice_items_invoice ON billing.invoice_items(invoice_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoice_items_product ON billing.invoice_items(product_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoice_items_line ON billing.invoice_items(invoice_id, line_number);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: payments
|
||||||
|
-- Pagos recibidos y realizados
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS billing.payments (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Identificacion
|
||||||
|
payment_number VARCHAR(30) NOT NULL,
|
||||||
|
payment_type VARCHAR(20) NOT NULL DEFAULT 'received', -- received (cobro), made (pago)
|
||||||
|
|
||||||
|
-- Partner
|
||||||
|
partner_id UUID NOT NULL REFERENCES partners.partners(id) ON DELETE RESTRICT,
|
||||||
|
partner_name VARCHAR(200),
|
||||||
|
|
||||||
|
-- Monto
|
||||||
|
currency VARCHAR(3) DEFAULT 'MXN',
|
||||||
|
amount DECIMAL(15, 2) NOT NULL,
|
||||||
|
exchange_rate DECIMAL(10, 6) DEFAULT 1,
|
||||||
|
|
||||||
|
-- Fecha
|
||||||
|
payment_date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||||
|
|
||||||
|
-- Metodo de pago
|
||||||
|
payment_method VARCHAR(50) NOT NULL, -- cash, transfer, check, credit_card, debit_card
|
||||||
|
reference VARCHAR(100), -- Numero de referencia, cheque, etc.
|
||||||
|
|
||||||
|
-- Cuenta bancaria
|
||||||
|
bank_account_id UUID REFERENCES partners.partner_bank_accounts(id),
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'draft', -- draft, confirmed, reconciled, cancelled
|
||||||
|
|
||||||
|
-- Notas
|
||||||
|
notes TEXT,
|
||||||
|
|
||||||
|
-- CFDI de pago (Mexico)
|
||||||
|
cfdi_uuid VARCHAR(40),
|
||||||
|
cfdi_status VARCHAR(20),
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by UUID REFERENCES auth.users(id),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
UNIQUE(tenant_id, payment_number)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para payments
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_payments_tenant ON billing.payments(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_payments_number ON billing.payments(payment_number);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_payments_type ON billing.payments(payment_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_payments_partner ON billing.payments(partner_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_payments_status ON billing.payments(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_payments_date ON billing.payments(payment_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_payments_method ON billing.payments(payment_method);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- TABLA: payment_allocations
|
||||||
|
-- Aplicacion de pagos a facturas
|
||||||
|
-- =====================
|
||||||
|
CREATE TABLE IF NOT EXISTS billing.payment_allocations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
payment_id UUID NOT NULL REFERENCES billing.payments(id) ON DELETE CASCADE,
|
||||||
|
invoice_id UUID NOT NULL REFERENCES billing.invoices(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Monto aplicado
|
||||||
|
amount DECIMAL(15, 2) NOT NULL,
|
||||||
|
|
||||||
|
-- Fecha de aplicacion
|
||||||
|
allocation_date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by UUID REFERENCES auth.users(id),
|
||||||
|
|
||||||
|
UNIQUE(payment_id, invoice_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices para payment_allocations
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_payment_allocations_payment ON billing.payment_allocations(payment_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_payment_allocations_invoice ON billing.payment_allocations(invoice_id);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- COMENTARIOS
|
||||||
|
-- =====================
|
||||||
|
COMMENT ON TABLE billing.invoices IS 'Facturas de venta y compra';
|
||||||
|
COMMENT ON COLUMN billing.invoices.invoice_type IS 'Tipo: sale (venta), purchase (compra), credit_note (nota credito), debit_note (nota debito)';
|
||||||
|
COMMENT ON COLUMN billing.invoices.status IS 'Estado: draft, validated, sent, partial (pago parcial), paid, cancelled, voided';
|
||||||
|
COMMENT ON COLUMN billing.invoices.cfdi_uuid IS 'UUID del CFDI para facturacion electronica en Mexico';
|
||||||
|
COMMENT ON COLUMN billing.invoices.amount_due IS 'Saldo pendiente de pago (calculado)';
|
||||||
|
|
||||||
|
COMMENT ON TABLE billing.invoice_items IS 'Lineas de detalle de facturas';
|
||||||
|
COMMENT ON COLUMN billing.invoice_items.sat_product_code IS 'Clave de producto del catalogo SAT (Mexico)';
|
||||||
|
COMMENT ON COLUMN billing.invoice_items.sat_unit_code IS 'Clave de unidad del catalogo SAT (Mexico)';
|
||||||
|
|
||||||
|
COMMENT ON TABLE billing.payments IS 'Registro de pagos recibidos y realizados';
|
||||||
|
COMMENT ON COLUMN billing.payments.payment_type IS 'Tipo: received (cobro a cliente), made (pago a proveedor)';
|
||||||
|
COMMENT ON COLUMN billing.payments.status IS 'Estado: draft, confirmed, reconciled, cancelled';
|
||||||
|
|
||||||
|
COMMENT ON TABLE billing.payment_allocations IS 'Aplicacion de pagos a facturas especificas';
|
||||||
|
COMMENT ON COLUMN billing.payment_allocations.amount IS 'Monto del pago aplicado a esta factura';
|
||||||
@ -1,159 +0,0 @@
|
|||||||
-- ============================================================================
|
|
||||||
-- Schema: core_shared
|
|
||||||
-- Descripcion: Funciones y tipos compartidos entre todos los modulos
|
|
||||||
-- Proyecto: ERP Core
|
|
||||||
-- Autor: Database-Agent
|
|
||||||
-- Fecha: 2025-12-06
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- Crear schema
|
|
||||||
CREATE SCHEMA IF NOT EXISTS core_shared;
|
|
||||||
|
|
||||||
COMMENT ON SCHEMA core_shared IS 'Funciones, tipos y utilidades compartidas entre modulos';
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- FUNCIONES DE AUDITORIA
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- Funcion para actualizar updated_at automaticamente
|
|
||||||
CREATE OR REPLACE FUNCTION core_shared.set_updated_at()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
NEW.updated_at = NOW();
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION core_shared.set_updated_at() IS
|
|
||||||
'Trigger function para actualizar automaticamente el campo updated_at en cada UPDATE';
|
|
||||||
|
|
||||||
-- Funcion para establecer tenant_id desde contexto
|
|
||||||
CREATE OR REPLACE FUNCTION core_shared.set_tenant_id()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
IF NEW.tenant_id IS NULL THEN
|
|
||||||
NEW.tenant_id = current_setting('app.current_tenant_id', true)::uuid;
|
|
||||||
END IF;
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION core_shared.set_tenant_id() IS
|
|
||||||
'Trigger function para establecer tenant_id automaticamente desde el contexto de sesion';
|
|
||||||
|
|
||||||
-- Funcion para establecer created_by desde contexto
|
|
||||||
CREATE OR REPLACE FUNCTION core_shared.set_created_by()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
IF NEW.created_by IS NULL THEN
|
|
||||||
NEW.created_by = current_setting('app.current_user_id', true)::uuid;
|
|
||||||
END IF;
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION core_shared.set_created_by() IS
|
|
||||||
'Trigger function para establecer created_by automaticamente desde el contexto de sesion';
|
|
||||||
|
|
||||||
-- Funcion para establecer updated_by desde contexto
|
|
||||||
CREATE OR REPLACE FUNCTION core_shared.set_updated_by()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
NEW.updated_by = current_setting('app.current_user_id', true)::uuid;
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION core_shared.set_updated_by() IS
|
|
||||||
'Trigger function para establecer updated_by automaticamente desde el contexto de sesion';
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- FUNCIONES DE CONTEXTO
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- Obtener tenant_id actual del contexto
|
|
||||||
CREATE OR REPLACE FUNCTION core_shared.get_current_tenant_id()
|
|
||||||
RETURNS UUID AS $$
|
|
||||||
BEGIN
|
|
||||||
RETURN NULLIF(current_setting('app.current_tenant_id', true), '')::UUID;
|
|
||||||
EXCEPTION
|
|
||||||
WHEN OTHERS THEN
|
|
||||||
RETURN NULL;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql STABLE;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION core_shared.get_current_tenant_id() IS
|
|
||||||
'Obtiene el ID del tenant actual desde el contexto de sesion';
|
|
||||||
|
|
||||||
-- Obtener user_id actual del contexto
|
|
||||||
CREATE OR REPLACE FUNCTION core_shared.get_current_user_id()
|
|
||||||
RETURNS UUID AS $$
|
|
||||||
BEGIN
|
|
||||||
RETURN NULLIF(current_setting('app.current_user_id', true), '')::UUID;
|
|
||||||
EXCEPTION
|
|
||||||
WHEN OTHERS THEN
|
|
||||||
RETURN NULL;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql STABLE;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION core_shared.get_current_user_id() IS
|
|
||||||
'Obtiene el ID del usuario actual desde el contexto de sesion';
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- FUNCIONES DE UTILIDAD
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- Generar slug desde texto
|
|
||||||
CREATE OR REPLACE FUNCTION core_shared.generate_slug(input_text TEXT)
|
|
||||||
RETURNS TEXT AS $$
|
|
||||||
BEGIN
|
|
||||||
RETURN LOWER(
|
|
||||||
REGEXP_REPLACE(
|
|
||||||
REGEXP_REPLACE(
|
|
||||||
TRIM(input_text),
|
|
||||||
'[^a-zA-Z0-9\s-]', '', 'g'
|
|
||||||
),
|
|
||||||
'\s+', '-', 'g'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql IMMUTABLE;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION core_shared.generate_slug(TEXT) IS
|
|
||||||
'Genera un slug URL-friendly desde un texto';
|
|
||||||
|
|
||||||
-- Validar formato de email
|
|
||||||
CREATE OR REPLACE FUNCTION core_shared.is_valid_email(email TEXT)
|
|
||||||
RETURNS BOOLEAN AS $$
|
|
||||||
BEGIN
|
|
||||||
RETURN email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$';
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql IMMUTABLE;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION core_shared.is_valid_email(TEXT) IS
|
|
||||||
'Valida si un texto tiene formato de email valido';
|
|
||||||
|
|
||||||
-- Validar formato de RFC mexicano
|
|
||||||
CREATE OR REPLACE FUNCTION core_shared.is_valid_rfc(rfc TEXT)
|
|
||||||
RETURNS BOOLEAN AS $$
|
|
||||||
BEGIN
|
|
||||||
-- RFC persona moral: 3 letras + 6 digitos + 3 caracteres
|
|
||||||
-- RFC persona fisica: 4 letras + 6 digitos + 3 caracteres
|
|
||||||
RETURN rfc ~* '^[A-Z&Ñ]{3,4}[0-9]{6}[A-Z0-9]{3}$';
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql IMMUTABLE;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION core_shared.is_valid_rfc(TEXT) IS
|
|
||||||
'Valida si un texto tiene formato de RFC mexicano valido';
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- GRANT PERMISOS
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- Permitir uso del schema a todos los roles de la aplicacion
|
|
||||||
GRANT USAGE ON SCHEMA core_shared TO PUBLIC;
|
|
||||||
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA core_shared TO PUBLIC;
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- FIN
|
|
||||||
-- ============================================================================
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: postgres:15-alpine
|
|
||||||
container_name: erp-generic-db
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-erp_generic}
|
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-erp_admin}
|
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-erp_secret_2024}
|
|
||||||
PGDATA: /var/lib/postgresql/data/pgdata
|
|
||||||
ports:
|
|
||||||
- "${POSTGRES_PORT:-5432}:5432"
|
|
||||||
volumes:
|
|
||||||
- postgres_data:/var/lib/postgresql/data
|
|
||||||
- ./ddl:/docker-entrypoint-initdb.d:ro
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-erp_admin} -d ${POSTGRES_DB:-erp_generic}"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
|
|
||||||
# Optional: pgAdmin for database management
|
|
||||||
pgadmin:
|
|
||||||
image: dpage/pgadmin4:latest
|
|
||||||
container_name: erp-generic-pgadmin
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL:-admin@erp-generic.local}
|
|
||||||
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD:-admin123}
|
|
||||||
PGADMIN_CONFIG_SERVER_MODE: 'False'
|
|
||||||
ports:
|
|
||||||
- "${PGADMIN_PORT:-5050}:80"
|
|
||||||
volumes:
|
|
||||||
- pgadmin_data:/var/lib/pgadmin
|
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
profiles:
|
|
||||||
- tools
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
postgres_data:
|
|
||||||
driver: local
|
|
||||||
pgadmin_data:
|
|
||||||
driver: local
|
|
||||||
|
|
||||||
networks:
|
|
||||||
default:
|
|
||||||
name: erp-generic-network
|
|
||||||
@ -1,207 +0,0 @@
|
|||||||
-- ============================================================================
|
|
||||||
-- MIGRACIÓN: Validación de Período Fiscal Cerrado
|
|
||||||
-- Fecha: 2025-12-12
|
|
||||||
-- Descripción: Agrega trigger para prevenir asientos en períodos cerrados
|
|
||||||
-- Impacto: Todas las verticales que usan el módulo financiero
|
|
||||||
-- Rollback: DROP TRIGGER y DROP FUNCTION incluidos al final
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 1. FUNCIÓN DE VALIDACIÓN
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION financial.validate_period_not_closed()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
DECLARE
|
|
||||||
v_period_status TEXT;
|
|
||||||
v_period_name TEXT;
|
|
||||||
BEGIN
|
|
||||||
-- Solo validar si hay un fiscal_period_id
|
|
||||||
IF NEW.fiscal_period_id IS NULL THEN
|
|
||||||
RETURN NEW;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- Obtener el estado del período
|
|
||||||
SELECT fp.status, fp.name INTO v_period_status, v_period_name
|
|
||||||
FROM financial.fiscal_periods fp
|
|
||||||
WHERE fp.id = NEW.fiscal_period_id;
|
|
||||||
|
|
||||||
-- Validar que el período no esté cerrado
|
|
||||||
IF v_period_status = 'closed' THEN
|
|
||||||
RAISE EXCEPTION 'ERR_PERIOD_CLOSED: No se pueden crear o modificar asientos en el período cerrado: %', v_period_name
|
|
||||||
USING ERRCODE = 'P0001';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION financial.validate_period_not_closed() IS
|
|
||||||
'Valida que no se creen asientos contables en períodos fiscales cerrados.
|
|
||||||
Lanza excepción ERR_PERIOD_CLOSED si el período está cerrado.';
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 2. TRIGGER EN JOURNAL_ENTRIES
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- Eliminar trigger si existe (idempotente)
|
|
||||||
DROP TRIGGER IF EXISTS trg_validate_period_before_entry ON financial.journal_entries;
|
|
||||||
|
|
||||||
-- Crear trigger BEFORE INSERT OR UPDATE
|
|
||||||
CREATE TRIGGER trg_validate_period_before_entry
|
|
||||||
BEFORE INSERT OR UPDATE ON financial.journal_entries
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION financial.validate_period_not_closed();
|
|
||||||
|
|
||||||
COMMENT ON TRIGGER trg_validate_period_before_entry ON financial.journal_entries IS
|
|
||||||
'Previene la creación o modificación de asientos en períodos fiscales cerrados';
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 3. FUNCIÓN PARA CERRAR PERÍODO
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION financial.close_fiscal_period(
|
|
||||||
p_period_id UUID,
|
|
||||||
p_user_id UUID
|
|
||||||
)
|
|
||||||
RETURNS financial.fiscal_periods AS $$
|
|
||||||
DECLARE
|
|
||||||
v_period financial.fiscal_periods;
|
|
||||||
v_unposted_count INTEGER;
|
|
||||||
BEGIN
|
|
||||||
-- Obtener período
|
|
||||||
SELECT * INTO v_period
|
|
||||||
FROM financial.fiscal_periods
|
|
||||||
WHERE id = p_period_id
|
|
||||||
FOR UPDATE;
|
|
||||||
|
|
||||||
IF NOT FOUND THEN
|
|
||||||
RAISE EXCEPTION 'Período fiscal no encontrado' USING ERRCODE = 'P0002';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF v_period.status = 'closed' THEN
|
|
||||||
RAISE EXCEPTION 'El período ya está cerrado' USING ERRCODE = 'P0003';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- Verificar que no haya asientos sin postear
|
|
||||||
SELECT COUNT(*) INTO v_unposted_count
|
|
||||||
FROM financial.journal_entries je
|
|
||||||
WHERE je.fiscal_period_id = p_period_id
|
|
||||||
AND je.status = 'draft';
|
|
||||||
|
|
||||||
IF v_unposted_count > 0 THEN
|
|
||||||
RAISE EXCEPTION 'Existen % asientos sin postear en este período. Postéelos antes de cerrar.',
|
|
||||||
v_unposted_count USING ERRCODE = 'P0004';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- Cerrar el período
|
|
||||||
UPDATE financial.fiscal_periods
|
|
||||||
SET status = 'closed',
|
|
||||||
closed_at = NOW(),
|
|
||||||
closed_by = p_user_id,
|
|
||||||
updated_at = NOW()
|
|
||||||
WHERE id = p_period_id
|
|
||||||
RETURNING * INTO v_period;
|
|
||||||
|
|
||||||
RETURN v_period;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION financial.close_fiscal_period(UUID, UUID) IS
|
|
||||||
'Cierra un período fiscal. Valida que todos los asientos estén posteados.';
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 4. FUNCIÓN PARA REABRIR PERÍODO (Solo admins)
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION financial.reopen_fiscal_period(
|
|
||||||
p_period_id UUID,
|
|
||||||
p_user_id UUID,
|
|
||||||
p_reason TEXT DEFAULT NULL
|
|
||||||
)
|
|
||||||
RETURNS financial.fiscal_periods AS $$
|
|
||||||
DECLARE
|
|
||||||
v_period financial.fiscal_periods;
|
|
||||||
BEGIN
|
|
||||||
-- Obtener período
|
|
||||||
SELECT * INTO v_period
|
|
||||||
FROM financial.fiscal_periods
|
|
||||||
WHERE id = p_period_id
|
|
||||||
FOR UPDATE;
|
|
||||||
|
|
||||||
IF NOT FOUND THEN
|
|
||||||
RAISE EXCEPTION 'Período fiscal no encontrado' USING ERRCODE = 'P0002';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF v_period.status = 'open' THEN
|
|
||||||
RAISE EXCEPTION 'El período ya está abierto' USING ERRCODE = 'P0005';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- Reabrir el período
|
|
||||||
UPDATE financial.fiscal_periods
|
|
||||||
SET status = 'open',
|
|
||||||
closed_at = NULL,
|
|
||||||
closed_by = NULL,
|
|
||||||
updated_at = NOW()
|
|
||||||
WHERE id = p_period_id
|
|
||||||
RETURNING * INTO v_period;
|
|
||||||
|
|
||||||
-- Registrar en log de auditoría
|
|
||||||
INSERT INTO system.logs (
|
|
||||||
tenant_id, level, module, message, context, user_id
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
v_period.tenant_id,
|
|
||||||
'warning',
|
|
||||||
'financial',
|
|
||||||
'Período fiscal reabierto',
|
|
||||||
jsonb_build_object(
|
|
||||||
'period_id', p_period_id,
|
|
||||||
'period_name', v_period.name,
|
|
||||||
'reason', p_reason,
|
|
||||||
'reopened_by', p_user_id
|
|
||||||
),
|
|
||||||
p_user_id;
|
|
||||||
|
|
||||||
RETURN v_period;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION financial.reopen_fiscal_period(UUID, UUID, TEXT) IS
|
|
||||||
'Reabre un período fiscal cerrado. Registra en auditoría. Solo para administradores.';
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 5. ÍNDICE PARA PERFORMANCE
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_journal_entries_fiscal_period
|
|
||||||
ON financial.journal_entries(fiscal_period_id)
|
|
||||||
WHERE fiscal_period_id IS NOT NULL;
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- ROLLBACK SCRIPT (ejecutar si es necesario revertir)
|
|
||||||
-- ============================================================================
|
|
||||||
/*
|
|
||||||
DROP TRIGGER IF EXISTS trg_validate_period_before_entry ON financial.journal_entries;
|
|
||||||
DROP FUNCTION IF EXISTS financial.validate_period_not_closed();
|
|
||||||
DROP FUNCTION IF EXISTS financial.close_fiscal_period(UUID, UUID);
|
|
||||||
DROP FUNCTION IF EXISTS financial.reopen_fiscal_period(UUID, UUID, TEXT);
|
|
||||||
DROP INDEX IF EXISTS financial.idx_journal_entries_fiscal_period;
|
|
||||||
*/
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- VERIFICACIÓN
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
-- Verificar que el trigger existe
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1 FROM pg_trigger
|
|
||||||
WHERE tgname = 'trg_validate_period_before_entry'
|
|
||||||
) THEN
|
|
||||||
RAISE EXCEPTION 'Error: Trigger no fue creado correctamente';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
RAISE NOTICE 'Migración completada exitosamente: Validación de período fiscal';
|
|
||||||
END $$;
|
|
||||||
@ -1,391 +0,0 @@
|
|||||||
-- ============================================================================
|
|
||||||
-- MIGRACIÓN: Sistema de Ranking de Partners (Clientes/Proveedores)
|
|
||||||
-- Fecha: 2025-12-12
|
|
||||||
-- Descripción: Crea tablas y funciones para clasificación ABC de partners
|
|
||||||
-- Impacto: Verticales que usan módulo de partners/ventas/compras
|
|
||||||
-- Rollback: DROP TABLE y DROP FUNCTION incluidos al final
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 1. TABLA DE RANKINGS POR PERÍODO
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS core.partner_rankings (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
partner_id UUID NOT NULL REFERENCES core.partners(id) ON DELETE CASCADE,
|
|
||||||
company_id UUID REFERENCES auth.companies(id) ON DELETE SET NULL,
|
|
||||||
|
|
||||||
-- Período de análisis
|
|
||||||
period_start DATE NOT NULL,
|
|
||||||
period_end DATE NOT NULL,
|
|
||||||
|
|
||||||
-- Métricas de Cliente
|
|
||||||
total_sales DECIMAL(16,2) DEFAULT 0,
|
|
||||||
sales_order_count INTEGER DEFAULT 0,
|
|
||||||
avg_order_value DECIMAL(16,2) DEFAULT 0,
|
|
||||||
|
|
||||||
-- Métricas de Proveedor
|
|
||||||
total_purchases DECIMAL(16,2) DEFAULT 0,
|
|
||||||
purchase_order_count INTEGER DEFAULT 0,
|
|
||||||
avg_purchase_value DECIMAL(16,2) DEFAULT 0,
|
|
||||||
|
|
||||||
-- Métricas de Pago
|
|
||||||
avg_payment_days INTEGER,
|
|
||||||
on_time_payment_rate DECIMAL(5,2), -- Porcentaje 0-100
|
|
||||||
|
|
||||||
-- Rankings (posición relativa dentro del período)
|
|
||||||
sales_rank INTEGER,
|
|
||||||
purchase_rank INTEGER,
|
|
||||||
|
|
||||||
-- Clasificación ABC
|
|
||||||
customer_abc CHAR(1) CHECK (customer_abc IN ('A', 'B', 'C', NULL)),
|
|
||||||
supplier_abc CHAR(1) CHECK (supplier_abc IN ('A', 'B', 'C', NULL)),
|
|
||||||
|
|
||||||
-- Scores calculados (0-100)
|
|
||||||
customer_score DECIMAL(5,2) CHECK (customer_score IS NULL OR customer_score BETWEEN 0 AND 100),
|
|
||||||
supplier_score DECIMAL(5,2) CHECK (supplier_score IS NULL OR supplier_score BETWEEN 0 AND 100),
|
|
||||||
overall_score DECIMAL(5,2) CHECK (overall_score IS NULL OR overall_score BETWEEN 0 AND 100),
|
|
||||||
|
|
||||||
-- Tendencia vs período anterior
|
|
||||||
sales_trend DECIMAL(5,2), -- % cambio
|
|
||||||
purchase_trend DECIMAL(5,2),
|
|
||||||
|
|
||||||
-- Metadatos
|
|
||||||
calculated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
|
|
||||||
-- Constraints
|
|
||||||
UNIQUE(tenant_id, partner_id, company_id, period_start, period_end),
|
|
||||||
CHECK (period_end >= period_start)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 2. CAMPOS DESNORMALIZADOS EN PARTNERS (para consultas rápidas)
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
-- Agregar columnas si no existen
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'core' AND table_name = 'partners'
|
|
||||||
AND column_name = 'customer_rank') THEN
|
|
||||||
ALTER TABLE core.partners ADD COLUMN customer_rank INTEGER;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'core' AND table_name = 'partners'
|
|
||||||
AND column_name = 'supplier_rank') THEN
|
|
||||||
ALTER TABLE core.partners ADD COLUMN supplier_rank INTEGER;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'core' AND table_name = 'partners'
|
|
||||||
AND column_name = 'customer_abc') THEN
|
|
||||||
ALTER TABLE core.partners ADD COLUMN customer_abc CHAR(1);
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'core' AND table_name = 'partners'
|
|
||||||
AND column_name = 'supplier_abc') THEN
|
|
||||||
ALTER TABLE core.partners ADD COLUMN supplier_abc CHAR(1);
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'core' AND table_name = 'partners'
|
|
||||||
AND column_name = 'last_ranking_date') THEN
|
|
||||||
ALTER TABLE core.partners ADD COLUMN last_ranking_date DATE;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'core' AND table_name = 'partners'
|
|
||||||
AND column_name = 'total_sales_ytd') THEN
|
|
||||||
ALTER TABLE core.partners ADD COLUMN total_sales_ytd DECIMAL(16,2) DEFAULT 0;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'core' AND table_name = 'partners'
|
|
||||||
AND column_name = 'total_purchases_ytd') THEN
|
|
||||||
ALTER TABLE core.partners ADD COLUMN total_purchases_ytd DECIMAL(16,2) DEFAULT 0;
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 3. ÍNDICES
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_partner_rankings_tenant_period
|
|
||||||
ON core.partner_rankings(tenant_id, period_start, period_end);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_partner_rankings_partner
|
|
||||||
ON core.partner_rankings(partner_id);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_partner_rankings_abc
|
|
||||||
ON core.partner_rankings(tenant_id, customer_abc)
|
|
||||||
WHERE customer_abc IS NOT NULL;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_partners_customer_rank
|
|
||||||
ON core.partners(tenant_id, customer_rank)
|
|
||||||
WHERE customer_rank IS NOT NULL;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_partners_supplier_rank
|
|
||||||
ON core.partners(tenant_id, supplier_rank)
|
|
||||||
WHERE supplier_rank IS NOT NULL;
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 4. RLS (Row Level Security)
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
ALTER TABLE core.partner_rankings ENABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS partner_rankings_tenant_isolation ON core.partner_rankings;
|
|
||||||
CREATE POLICY partner_rankings_tenant_isolation ON core.partner_rankings
|
|
||||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 5. FUNCIÓN: Calcular rankings de partners
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION core.calculate_partner_rankings(
|
|
||||||
p_tenant_id UUID,
|
|
||||||
p_company_id UUID DEFAULT NULL,
|
|
||||||
p_period_start DATE DEFAULT (CURRENT_DATE - INTERVAL '1 year')::date,
|
|
||||||
p_period_end DATE DEFAULT CURRENT_DATE
|
|
||||||
)
|
|
||||||
RETURNS TABLE (
|
|
||||||
partners_processed INTEGER,
|
|
||||||
customers_ranked INTEGER,
|
|
||||||
suppliers_ranked INTEGER
|
|
||||||
) AS $$
|
|
||||||
DECLARE
|
|
||||||
v_partners_processed INTEGER := 0;
|
|
||||||
v_customers_ranked INTEGER := 0;
|
|
||||||
v_suppliers_ranked INTEGER := 0;
|
|
||||||
BEGIN
|
|
||||||
-- 1. Calcular métricas de ventas por partner
|
|
||||||
INSERT INTO core.partner_rankings (
|
|
||||||
tenant_id, partner_id, company_id, period_start, period_end,
|
|
||||||
total_sales, sales_order_count, avg_order_value
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
p_tenant_id,
|
|
||||||
so.partner_id,
|
|
||||||
COALESCE(p_company_id, so.company_id),
|
|
||||||
p_period_start,
|
|
||||||
p_period_end,
|
|
||||||
COALESCE(SUM(so.amount_total), 0),
|
|
||||||
COUNT(*),
|
|
||||||
COALESCE(AVG(so.amount_total), 0)
|
|
||||||
FROM sales.sales_orders so
|
|
||||||
WHERE so.tenant_id = p_tenant_id
|
|
||||||
AND so.status IN ('sale', 'done')
|
|
||||||
AND so.order_date BETWEEN p_period_start AND p_period_end
|
|
||||||
AND (p_company_id IS NULL OR so.company_id = p_company_id)
|
|
||||||
GROUP BY so.partner_id, so.company_id
|
|
||||||
ON CONFLICT (tenant_id, partner_id, company_id, period_start, period_end)
|
|
||||||
DO UPDATE SET
|
|
||||||
total_sales = EXCLUDED.total_sales,
|
|
||||||
sales_order_count = EXCLUDED.sales_order_count,
|
|
||||||
avg_order_value = EXCLUDED.avg_order_value,
|
|
||||||
calculated_at = NOW();
|
|
||||||
|
|
||||||
GET DIAGNOSTICS v_customers_ranked = ROW_COUNT;
|
|
||||||
|
|
||||||
-- 2. Calcular métricas de compras por partner
|
|
||||||
INSERT INTO core.partner_rankings (
|
|
||||||
tenant_id, partner_id, company_id, period_start, period_end,
|
|
||||||
total_purchases, purchase_order_count, avg_purchase_value
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
p_tenant_id,
|
|
||||||
po.partner_id,
|
|
||||||
COALESCE(p_company_id, po.company_id),
|
|
||||||
p_period_start,
|
|
||||||
p_period_end,
|
|
||||||
COALESCE(SUM(po.amount_total), 0),
|
|
||||||
COUNT(*),
|
|
||||||
COALESCE(AVG(po.amount_total), 0)
|
|
||||||
FROM purchase.purchase_orders po
|
|
||||||
WHERE po.tenant_id = p_tenant_id
|
|
||||||
AND po.status IN ('confirmed', 'done')
|
|
||||||
AND po.order_date BETWEEN p_period_start AND p_period_end
|
|
||||||
AND (p_company_id IS NULL OR po.company_id = p_company_id)
|
|
||||||
GROUP BY po.partner_id, po.company_id
|
|
||||||
ON CONFLICT (tenant_id, partner_id, company_id, period_start, period_end)
|
|
||||||
DO UPDATE SET
|
|
||||||
total_purchases = EXCLUDED.total_purchases,
|
|
||||||
purchase_order_count = EXCLUDED.purchase_order_count,
|
|
||||||
avg_purchase_value = EXCLUDED.avg_purchase_value,
|
|
||||||
calculated_at = NOW();
|
|
||||||
|
|
||||||
GET DIAGNOSTICS v_suppliers_ranked = ROW_COUNT;
|
|
||||||
|
|
||||||
-- 3. Calcular rankings de clientes (por total de ventas)
|
|
||||||
WITH ranked AS (
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
ROW_NUMBER() OVER (ORDER BY total_sales DESC) as rank,
|
|
||||||
total_sales,
|
|
||||||
SUM(total_sales) OVER () as grand_total,
|
|
||||||
SUM(total_sales) OVER (ORDER BY total_sales DESC) as cumulative_total
|
|
||||||
FROM core.partner_rankings
|
|
||||||
WHERE tenant_id = p_tenant_id
|
|
||||||
AND period_start = p_period_start
|
|
||||||
AND period_end = p_period_end
|
|
||||||
AND total_sales > 0
|
|
||||||
)
|
|
||||||
UPDATE core.partner_rankings pr
|
|
||||||
SET
|
|
||||||
sales_rank = r.rank,
|
|
||||||
customer_abc = CASE
|
|
||||||
WHEN r.cumulative_total / NULLIF(r.grand_total, 0) <= 0.80 THEN 'A'
|
|
||||||
WHEN r.cumulative_total / NULLIF(r.grand_total, 0) <= 0.95 THEN 'B'
|
|
||||||
ELSE 'C'
|
|
||||||
END,
|
|
||||||
customer_score = CASE
|
|
||||||
WHEN r.rank = 1 THEN 100
|
|
||||||
ELSE GREATEST(0, 100 - (r.rank - 1) * 5)
|
|
||||||
END
|
|
||||||
FROM ranked r
|
|
||||||
WHERE pr.id = r.id;
|
|
||||||
|
|
||||||
-- 4. Calcular rankings de proveedores (por total de compras)
|
|
||||||
WITH ranked AS (
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
ROW_NUMBER() OVER (ORDER BY total_purchases DESC) as rank,
|
|
||||||
total_purchases,
|
|
||||||
SUM(total_purchases) OVER () as grand_total,
|
|
||||||
SUM(total_purchases) OVER (ORDER BY total_purchases DESC) as cumulative_total
|
|
||||||
FROM core.partner_rankings
|
|
||||||
WHERE tenant_id = p_tenant_id
|
|
||||||
AND period_start = p_period_start
|
|
||||||
AND period_end = p_period_end
|
|
||||||
AND total_purchases > 0
|
|
||||||
)
|
|
||||||
UPDATE core.partner_rankings pr
|
|
||||||
SET
|
|
||||||
purchase_rank = r.rank,
|
|
||||||
supplier_abc = CASE
|
|
||||||
WHEN r.cumulative_total / NULLIF(r.grand_total, 0) <= 0.80 THEN 'A'
|
|
||||||
WHEN r.cumulative_total / NULLIF(r.grand_total, 0) <= 0.95 THEN 'B'
|
|
||||||
ELSE 'C'
|
|
||||||
END,
|
|
||||||
supplier_score = CASE
|
|
||||||
WHEN r.rank = 1 THEN 100
|
|
||||||
ELSE GREATEST(0, 100 - (r.rank - 1) * 5)
|
|
||||||
END
|
|
||||||
FROM ranked r
|
|
||||||
WHERE pr.id = r.id;
|
|
||||||
|
|
||||||
-- 5. Calcular score overall
|
|
||||||
UPDATE core.partner_rankings
|
|
||||||
SET overall_score = COALESCE(
|
|
||||||
(COALESCE(customer_score, 0) + COALESCE(supplier_score, 0)) /
|
|
||||||
NULLIF(
|
|
||||||
CASE WHEN customer_score IS NOT NULL THEN 1 ELSE 0 END +
|
|
||||||
CASE WHEN supplier_score IS NOT NULL THEN 1 ELSE 0 END,
|
|
||||||
0
|
|
||||||
),
|
|
||||||
0
|
|
||||||
)
|
|
||||||
WHERE tenant_id = p_tenant_id
|
|
||||||
AND period_start = p_period_start
|
|
||||||
AND period_end = p_period_end;
|
|
||||||
|
|
||||||
-- 6. Actualizar campos desnormalizados en partners
|
|
||||||
UPDATE core.partners p
|
|
||||||
SET
|
|
||||||
customer_rank = pr.sales_rank,
|
|
||||||
supplier_rank = pr.purchase_rank,
|
|
||||||
customer_abc = pr.customer_abc,
|
|
||||||
supplier_abc = pr.supplier_abc,
|
|
||||||
total_sales_ytd = pr.total_sales,
|
|
||||||
total_purchases_ytd = pr.total_purchases,
|
|
||||||
last_ranking_date = CURRENT_DATE
|
|
||||||
FROM core.partner_rankings pr
|
|
||||||
WHERE p.id = pr.partner_id
|
|
||||||
AND p.tenant_id = p_tenant_id
|
|
||||||
AND pr.tenant_id = p_tenant_id
|
|
||||||
AND pr.period_start = p_period_start
|
|
||||||
AND pr.period_end = p_period_end;
|
|
||||||
|
|
||||||
GET DIAGNOSTICS v_partners_processed = ROW_COUNT;
|
|
||||||
|
|
||||||
RETURN QUERY SELECT v_partners_processed, v_customers_ranked, v_suppliers_ranked;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION core.calculate_partner_rankings IS
|
|
||||||
'Calcula rankings ABC de partners basado en ventas/compras.
|
|
||||||
Parámetros:
|
|
||||||
- p_tenant_id: Tenant obligatorio
|
|
||||||
- p_company_id: Opcional, filtrar por empresa
|
|
||||||
- p_period_start: Inicio del período (default: hace 1 año)
|
|
||||||
- p_period_end: Fin del período (default: hoy)';
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 6. VISTA: Top Partners
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
CREATE OR REPLACE VIEW core.top_partners_view AS
|
|
||||||
SELECT
|
|
||||||
p.id,
|
|
||||||
p.tenant_id,
|
|
||||||
p.name,
|
|
||||||
p.email,
|
|
||||||
p.is_customer,
|
|
||||||
p.is_supplier,
|
|
||||||
p.customer_rank,
|
|
||||||
p.supplier_rank,
|
|
||||||
p.customer_abc,
|
|
||||||
p.supplier_abc,
|
|
||||||
p.total_sales_ytd,
|
|
||||||
p.total_purchases_ytd,
|
|
||||||
p.last_ranking_date,
|
|
||||||
CASE
|
|
||||||
WHEN p.customer_abc = 'A' THEN 'Cliente VIP'
|
|
||||||
WHEN p.customer_abc = 'B' THEN 'Cliente Regular'
|
|
||||||
WHEN p.customer_abc = 'C' THEN 'Cliente Ocasional'
|
|
||||||
ELSE NULL
|
|
||||||
END as customer_category,
|
|
||||||
CASE
|
|
||||||
WHEN p.supplier_abc = 'A' THEN 'Proveedor Estratégico'
|
|
||||||
WHEN p.supplier_abc = 'B' THEN 'Proveedor Regular'
|
|
||||||
WHEN p.supplier_abc = 'C' THEN 'Proveedor Ocasional'
|
|
||||||
ELSE NULL
|
|
||||||
END as supplier_category
|
|
||||||
FROM core.partners p
|
|
||||||
WHERE p.deleted_at IS NULL
|
|
||||||
AND (p.customer_rank IS NOT NULL OR p.supplier_rank IS NOT NULL);
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- ROLLBACK SCRIPT
|
|
||||||
-- ============================================================================
|
|
||||||
/*
|
|
||||||
DROP VIEW IF EXISTS core.top_partners_view;
|
|
||||||
DROP FUNCTION IF EXISTS core.calculate_partner_rankings(UUID, UUID, DATE, DATE);
|
|
||||||
DROP TABLE IF EXISTS core.partner_rankings;
|
|
||||||
|
|
||||||
ALTER TABLE core.partners
|
|
||||||
DROP COLUMN IF EXISTS customer_rank,
|
|
||||||
DROP COLUMN IF EXISTS supplier_rank,
|
|
||||||
DROP COLUMN IF EXISTS customer_abc,
|
|
||||||
DROP COLUMN IF EXISTS supplier_abc,
|
|
||||||
DROP COLUMN IF EXISTS last_ranking_date,
|
|
||||||
DROP COLUMN IF EXISTS total_sales_ytd,
|
|
||||||
DROP COLUMN IF EXISTS total_purchases_ytd;
|
|
||||||
*/
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- VERIFICACIÓN
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'core' AND tablename = 'partner_rankings') THEN
|
|
||||||
RAISE EXCEPTION 'Error: Tabla partner_rankings no fue creada';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
RAISE NOTICE 'Migración completada exitosamente: Partner Rankings';
|
|
||||||
END $$;
|
|
||||||
@ -1,464 +0,0 @@
|
|||||||
-- ============================================================================
|
|
||||||
-- MIGRACIÓN: Sistema de Reportes Financieros
|
|
||||||
-- Fecha: 2025-12-12
|
|
||||||
-- Descripción: Crea tablas para definición, ejecución y programación de reportes
|
|
||||||
-- Impacto: Módulo financiero y verticales que requieren reportes contables
|
|
||||||
-- Rollback: DROP TABLE y DROP FUNCTION incluidos al final
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 1. TABLA DE DEFINICIONES DE REPORTES
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS reports.report_definitions (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
-- Identificación
|
|
||||||
code VARCHAR(50) NOT NULL,
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
|
|
||||||
-- Clasificación
|
|
||||||
report_type VARCHAR(50) NOT NULL DEFAULT 'financial',
|
|
||||||
-- financial, accounting, tax, management, custom
|
|
||||||
category VARCHAR(100),
|
|
||||||
-- balance_sheet, income_statement, cash_flow, trial_balance, ledger, etc.
|
|
||||||
|
|
||||||
-- Configuración de consulta
|
|
||||||
base_query TEXT, -- SQL base o referencia a función
|
|
||||||
query_function VARCHAR(255), -- Nombre de función PostgreSQL si usa función
|
|
||||||
|
|
||||||
-- Parámetros requeridos (JSON Schema)
|
|
||||||
parameters_schema JSONB DEFAULT '{}',
|
|
||||||
-- Ejemplo: {"date_from": {"type": "date", "required": true}, "company_id": {"type": "uuid"}}
|
|
||||||
|
|
||||||
-- Configuración de columnas
|
|
||||||
columns_config JSONB DEFAULT '[]',
|
|
||||||
-- Ejemplo: [{"name": "account", "label": "Cuenta", "type": "string"}, {"name": "debit", "label": "Debe", "type": "currency"}]
|
|
||||||
|
|
||||||
-- Agrupaciones disponibles
|
|
||||||
grouping_options JSONB DEFAULT '[]',
|
|
||||||
-- Ejemplo: ["account_type", "company", "period"]
|
|
||||||
|
|
||||||
-- Configuración de totales
|
|
||||||
totals_config JSONB DEFAULT '{}',
|
|
||||||
-- Ejemplo: {"show_totals": true, "total_columns": ["debit", "credit", "balance"]}
|
|
||||||
|
|
||||||
-- Plantillas de exportación
|
|
||||||
export_formats JSONB DEFAULT '["pdf", "xlsx", "csv"]',
|
|
||||||
pdf_template VARCHAR(255), -- Referencia a plantilla PDF
|
|
||||||
xlsx_template VARCHAR(255),
|
|
||||||
|
|
||||||
-- Estado y visibilidad
|
|
||||||
is_system BOOLEAN DEFAULT false, -- Reportes del sistema vs personalizados
|
|
||||||
is_active BOOLEAN DEFAULT true,
|
|
||||||
|
|
||||||
-- Permisos requeridos
|
|
||||||
required_permissions JSONB DEFAULT '[]',
|
|
||||||
-- Ejemplo: ["financial.reports.view", "financial.reports.balance_sheet"]
|
|
||||||
|
|
||||||
-- Metadata
|
|
||||||
version INTEGER DEFAULT 1,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_by UUID REFERENCES auth.users(id),
|
|
||||||
|
|
||||||
-- Constraints
|
|
||||||
UNIQUE(tenant_id, code)
|
|
||||||
);
|
|
||||||
|
|
||||||
COMMENT ON TABLE reports.report_definitions IS
|
|
||||||
'Definiciones de reportes disponibles en el sistema. Incluye reportes predefinidos y personalizados.';
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 2. TABLA DE EJECUCIONES DE REPORTES
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS reports.report_executions (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
definition_id UUID NOT NULL REFERENCES reports.report_definitions(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
-- Parámetros de ejecución
|
|
||||||
parameters JSONB NOT NULL DEFAULT '{}',
|
|
||||||
-- Los valores específicos usados para esta ejecución
|
|
||||||
|
|
||||||
-- Estado de ejecución
|
|
||||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
|
||||||
-- pending, running, completed, failed, cancelled
|
|
||||||
|
|
||||||
-- Tiempos
|
|
||||||
started_at TIMESTAMPTZ,
|
|
||||||
completed_at TIMESTAMPTZ,
|
|
||||||
execution_time_ms INTEGER,
|
|
||||||
|
|
||||||
-- Resultados
|
|
||||||
row_count INTEGER,
|
|
||||||
result_data JSONB, -- Datos del reporte (puede ser grande)
|
|
||||||
result_summary JSONB, -- Resumen/totales
|
|
||||||
|
|
||||||
-- Archivos generados
|
|
||||||
output_files JSONB DEFAULT '[]',
|
|
||||||
-- Ejemplo: [{"format": "pdf", "path": "/reports/...", "size": 12345}]
|
|
||||||
|
|
||||||
-- Errores
|
|
||||||
error_message TEXT,
|
|
||||||
error_details JSONB,
|
|
||||||
|
|
||||||
-- Metadata
|
|
||||||
requested_by UUID NOT NULL REFERENCES auth.users(id),
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
COMMENT ON TABLE reports.report_executions IS
|
|
||||||
'Historial de ejecuciones de reportes con sus resultados y archivos generados.';
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 3. TABLA DE PROGRAMACIÓN DE REPORTES
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS reports.report_schedules (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
definition_id UUID NOT NULL REFERENCES reports.report_definitions(id) ON DELETE CASCADE,
|
|
||||||
company_id UUID REFERENCES auth.companies(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
-- Nombre del schedule
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
|
|
||||||
-- Parámetros predeterminados
|
|
||||||
default_parameters JSONB DEFAULT '{}',
|
|
||||||
|
|
||||||
-- Programación (cron expression)
|
|
||||||
cron_expression VARCHAR(100) NOT NULL,
|
|
||||||
-- Ejemplo: "0 8 1 * *" (primer día del mes a las 8am)
|
|
||||||
|
|
||||||
timezone VARCHAR(50) DEFAULT 'America/Mexico_City',
|
|
||||||
|
|
||||||
-- Estado
|
|
||||||
is_active BOOLEAN DEFAULT true,
|
|
||||||
|
|
||||||
-- Última ejecución
|
|
||||||
last_execution_id UUID REFERENCES reports.report_executions(id),
|
|
||||||
last_run_at TIMESTAMPTZ,
|
|
||||||
next_run_at TIMESTAMPTZ,
|
|
||||||
|
|
||||||
-- Destino de entrega
|
|
||||||
delivery_method VARCHAR(50) DEFAULT 'none',
|
|
||||||
-- none, email, storage, webhook
|
|
||||||
delivery_config JSONB DEFAULT '{}',
|
|
||||||
-- Para email: {"recipients": ["a@b.com"], "subject": "...", "format": "pdf"}
|
|
||||||
-- Para storage: {"path": "/reports/scheduled/", "retention_days": 30}
|
|
||||||
|
|
||||||
-- Metadata
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_by UUID REFERENCES auth.users(id)
|
|
||||||
);
|
|
||||||
|
|
||||||
COMMENT ON TABLE reports.report_schedules IS
|
|
||||||
'Programación automática de reportes con opciones de entrega.';
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 4. TABLA DE PLANTILLAS DE REPORTES
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS reports.report_templates (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
||||||
|
|
||||||
-- Identificación
|
|
||||||
code VARCHAR(50) NOT NULL,
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
|
|
||||||
-- Tipo de plantilla
|
|
||||||
template_type VARCHAR(20) NOT NULL,
|
|
||||||
-- pdf, xlsx, html
|
|
||||||
|
|
||||||
-- Contenido de la plantilla
|
|
||||||
template_content BYTEA, -- Para plantillas binarias (XLSX)
|
|
||||||
template_html TEXT, -- Para plantillas HTML/PDF
|
|
||||||
|
|
||||||
-- Estilos CSS (para PDF/HTML)
|
|
||||||
styles TEXT,
|
|
||||||
|
|
||||||
-- Variables disponibles
|
|
||||||
available_variables JSONB DEFAULT '[]',
|
|
||||||
|
|
||||||
-- Estado
|
|
||||||
is_active BOOLEAN DEFAULT true,
|
|
||||||
|
|
||||||
-- Metadata
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
created_by UUID REFERENCES auth.users(id),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
|
|
||||||
UNIQUE(tenant_id, code)
|
|
||||||
);
|
|
||||||
|
|
||||||
COMMENT ON TABLE reports.report_templates IS
|
|
||||||
'Plantillas personalizables para la generación de reportes en diferentes formatos.';
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 5. ÍNDICES
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_report_definitions_tenant_type
|
|
||||||
ON reports.report_definitions(tenant_id, report_type);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_report_definitions_tenant_category
|
|
||||||
ON reports.report_definitions(tenant_id, category);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_report_executions_tenant_status
|
|
||||||
ON reports.report_executions(tenant_id, status);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_report_executions_definition
|
|
||||||
ON reports.report_executions(definition_id);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_report_executions_created
|
|
||||||
ON reports.report_executions(tenant_id, created_at DESC);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_report_schedules_next_run
|
|
||||||
ON reports.report_schedules(next_run_at)
|
|
||||||
WHERE is_active = true;
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 6. RLS (Row Level Security)
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
ALTER TABLE reports.report_definitions ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE reports.report_executions ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE reports.report_schedules ENABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE reports.report_templates ENABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
-- Políticas para report_definitions
|
|
||||||
DROP POLICY IF EXISTS report_definitions_tenant_isolation ON reports.report_definitions;
|
|
||||||
CREATE POLICY report_definitions_tenant_isolation ON reports.report_definitions
|
|
||||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
|
||||||
|
|
||||||
-- Políticas para report_executions
|
|
||||||
DROP POLICY IF EXISTS report_executions_tenant_isolation ON reports.report_executions;
|
|
||||||
CREATE POLICY report_executions_tenant_isolation ON reports.report_executions
|
|
||||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
|
||||||
|
|
||||||
-- Políticas para report_schedules
|
|
||||||
DROP POLICY IF EXISTS report_schedules_tenant_isolation ON reports.report_schedules;
|
|
||||||
CREATE POLICY report_schedules_tenant_isolation ON reports.report_schedules
|
|
||||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
|
||||||
|
|
||||||
-- Políticas para report_templates
|
|
||||||
DROP POLICY IF EXISTS report_templates_tenant_isolation ON reports.report_templates;
|
|
||||||
CREATE POLICY report_templates_tenant_isolation ON reports.report_templates
|
|
||||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 7. FUNCIONES DE REPORTES PREDEFINIDOS
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- Balance de Comprobación
|
|
||||||
CREATE OR REPLACE FUNCTION reports.generate_trial_balance(
|
|
||||||
p_tenant_id UUID,
|
|
||||||
p_company_id UUID,
|
|
||||||
p_date_from DATE,
|
|
||||||
p_date_to DATE,
|
|
||||||
p_include_zero_balance BOOLEAN DEFAULT false
|
|
||||||
)
|
|
||||||
RETURNS TABLE (
|
|
||||||
account_id UUID,
|
|
||||||
account_code VARCHAR(20),
|
|
||||||
account_name VARCHAR(255),
|
|
||||||
account_type VARCHAR(50),
|
|
||||||
initial_debit DECIMAL(16,2),
|
|
||||||
initial_credit DECIMAL(16,2),
|
|
||||||
period_debit DECIMAL(16,2),
|
|
||||||
period_credit DECIMAL(16,2),
|
|
||||||
final_debit DECIMAL(16,2),
|
|
||||||
final_credit DECIMAL(16,2)
|
|
||||||
) AS $$
|
|
||||||
BEGIN
|
|
||||||
RETURN QUERY
|
|
||||||
WITH account_balances AS (
|
|
||||||
-- Saldos iniciales (antes del período)
|
|
||||||
SELECT
|
|
||||||
a.id as account_id,
|
|
||||||
a.code as account_code,
|
|
||||||
a.name as account_name,
|
|
||||||
a.account_type,
|
|
||||||
COALESCE(SUM(CASE WHEN jel.transaction_date < p_date_from THEN jel.debit ELSE 0 END), 0) as initial_debit,
|
|
||||||
COALESCE(SUM(CASE WHEN jel.transaction_date < p_date_from THEN jel.credit ELSE 0 END), 0) as initial_credit,
|
|
||||||
COALESCE(SUM(CASE WHEN jel.transaction_date BETWEEN p_date_from AND p_date_to THEN jel.debit ELSE 0 END), 0) as period_debit,
|
|
||||||
COALESCE(SUM(CASE WHEN jel.transaction_date BETWEEN p_date_from AND p_date_to THEN jel.credit ELSE 0 END), 0) as period_credit
|
|
||||||
FROM financial.accounts a
|
|
||||||
LEFT JOIN financial.journal_entry_lines jel ON a.id = jel.account_id
|
|
||||||
LEFT JOIN financial.journal_entries je ON jel.journal_entry_id = je.id AND je.status = 'posted'
|
|
||||||
WHERE a.tenant_id = p_tenant_id
|
|
||||||
AND (p_company_id IS NULL OR a.company_id = p_company_id)
|
|
||||||
AND a.is_active = true
|
|
||||||
GROUP BY a.id, a.code, a.name, a.account_type
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
ab.account_id,
|
|
||||||
ab.account_code,
|
|
||||||
ab.account_name,
|
|
||||||
ab.account_type,
|
|
||||||
ab.initial_debit,
|
|
||||||
ab.initial_credit,
|
|
||||||
ab.period_debit,
|
|
||||||
ab.period_credit,
|
|
||||||
ab.initial_debit + ab.period_debit as final_debit,
|
|
||||||
ab.initial_credit + ab.period_credit as final_credit
|
|
||||||
FROM account_balances ab
|
|
||||||
WHERE p_include_zero_balance = true
|
|
||||||
OR (ab.initial_debit + ab.period_debit) != 0
|
|
||||||
OR (ab.initial_credit + ab.period_credit) != 0
|
|
||||||
ORDER BY ab.account_code;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION reports.generate_trial_balance IS
|
|
||||||
'Genera el balance de comprobación para un período específico.';
|
|
||||||
|
|
||||||
-- Libro Mayor
|
|
||||||
CREATE OR REPLACE FUNCTION reports.generate_general_ledger(
|
|
||||||
p_tenant_id UUID,
|
|
||||||
p_company_id UUID,
|
|
||||||
p_account_id UUID,
|
|
||||||
p_date_from DATE,
|
|
||||||
p_date_to DATE
|
|
||||||
)
|
|
||||||
RETURNS TABLE (
|
|
||||||
entry_date DATE,
|
|
||||||
journal_entry_id UUID,
|
|
||||||
entry_number VARCHAR(50),
|
|
||||||
description TEXT,
|
|
||||||
partner_name VARCHAR(255),
|
|
||||||
debit DECIMAL(16,2),
|
|
||||||
credit DECIMAL(16,2),
|
|
||||||
running_balance DECIMAL(16,2)
|
|
||||||
) AS $$
|
|
||||||
BEGIN
|
|
||||||
RETURN QUERY
|
|
||||||
WITH movements AS (
|
|
||||||
SELECT
|
|
||||||
je.entry_date,
|
|
||||||
je.id as journal_entry_id,
|
|
||||||
je.entry_number,
|
|
||||||
je.description,
|
|
||||||
p.name as partner_name,
|
|
||||||
jel.debit,
|
|
||||||
jel.credit,
|
|
||||||
ROW_NUMBER() OVER (ORDER BY je.entry_date, je.id) as rn
|
|
||||||
FROM financial.journal_entry_lines jel
|
|
||||||
JOIN financial.journal_entries je ON jel.journal_entry_id = je.id
|
|
||||||
LEFT JOIN core.partners p ON je.partner_id = p.id
|
|
||||||
WHERE jel.account_id = p_account_id
|
|
||||||
AND jel.tenant_id = p_tenant_id
|
|
||||||
AND je.status = 'posted'
|
|
||||||
AND je.entry_date BETWEEN p_date_from AND p_date_to
|
|
||||||
AND (p_company_id IS NULL OR je.company_id = p_company_id)
|
|
||||||
ORDER BY je.entry_date, je.id
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
m.entry_date,
|
|
||||||
m.journal_entry_id,
|
|
||||||
m.entry_number,
|
|
||||||
m.description,
|
|
||||||
m.partner_name,
|
|
||||||
m.debit,
|
|
||||||
m.credit,
|
|
||||||
SUM(m.debit - m.credit) OVER (ORDER BY m.rn) as running_balance
|
|
||||||
FROM movements m;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
COMMENT ON FUNCTION reports.generate_general_ledger IS
|
|
||||||
'Genera el libro mayor para una cuenta específica.';
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 8. DATOS SEMILLA: REPORTES PREDEFINIDOS DEL SISTEMA
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- Nota: Los reportes del sistema se insertan con is_system = true
|
|
||||||
-- y se insertan solo si no existen (usando ON CONFLICT)
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
DECLARE
|
|
||||||
v_system_tenant_id UUID;
|
|
||||||
BEGIN
|
|
||||||
-- Obtener el tenant del sistema (si existe)
|
|
||||||
SELECT id INTO v_system_tenant_id
|
|
||||||
FROM auth.tenants
|
|
||||||
WHERE code = 'system' OR is_system = true
|
|
||||||
LIMIT 1;
|
|
||||||
|
|
||||||
-- Solo insertar si hay un tenant sistema
|
|
||||||
IF v_system_tenant_id IS NOT NULL THEN
|
|
||||||
-- Balance de Comprobación
|
|
||||||
INSERT INTO reports.report_definitions (
|
|
||||||
tenant_id, code, name, description, report_type, category,
|
|
||||||
query_function, parameters_schema, columns_config, is_system
|
|
||||||
) VALUES (
|
|
||||||
v_system_tenant_id,
|
|
||||||
'TRIAL_BALANCE',
|
|
||||||
'Balance de Comprobación',
|
|
||||||
'Reporte de balance de comprobación con saldos iniciales, movimientos y saldos finales',
|
|
||||||
'financial',
|
|
||||||
'trial_balance',
|
|
||||||
'reports.generate_trial_balance',
|
|
||||||
'{"company_id": {"type": "uuid", "required": false}, "date_from": {"type": "date", "required": true}, "date_to": {"type": "date", "required": true}, "include_zero": {"type": "boolean", "default": false}}',
|
|
||||||
'[{"name": "account_code", "label": "Código", "type": "string"}, {"name": "account_name", "label": "Cuenta", "type": "string"}, {"name": "initial_debit", "label": "Debe Inicial", "type": "currency"}, {"name": "initial_credit", "label": "Haber Inicial", "type": "currency"}, {"name": "period_debit", "label": "Debe Período", "type": "currency"}, {"name": "period_credit", "label": "Haber Período", "type": "currency"}, {"name": "final_debit", "label": "Debe Final", "type": "currency"}, {"name": "final_credit", "label": "Haber Final", "type": "currency"}]',
|
|
||||||
true
|
|
||||||
) ON CONFLICT (tenant_id, code) DO NOTHING;
|
|
||||||
|
|
||||||
-- Libro Mayor
|
|
||||||
INSERT INTO reports.report_definitions (
|
|
||||||
tenant_id, code, name, description, report_type, category,
|
|
||||||
query_function, parameters_schema, columns_config, is_system
|
|
||||||
) VALUES (
|
|
||||||
v_system_tenant_id,
|
|
||||||
'GENERAL_LEDGER',
|
|
||||||
'Libro Mayor',
|
|
||||||
'Detalle de movimientos por cuenta con saldo acumulado',
|
|
||||||
'financial',
|
|
||||||
'ledger',
|
|
||||||
'reports.generate_general_ledger',
|
|
||||||
'{"company_id": {"type": "uuid", "required": false}, "account_id": {"type": "uuid", "required": true}, "date_from": {"type": "date", "required": true}, "date_to": {"type": "date", "required": true}}',
|
|
||||||
'[{"name": "entry_date", "label": "Fecha", "type": "date"}, {"name": "entry_number", "label": "Número", "type": "string"}, {"name": "description", "label": "Descripción", "type": "string"}, {"name": "partner_name", "label": "Tercero", "type": "string"}, {"name": "debit", "label": "Debe", "type": "currency"}, {"name": "credit", "label": "Haber", "type": "currency"}, {"name": "running_balance", "label": "Saldo", "type": "currency"}]',
|
|
||||||
true
|
|
||||||
) ON CONFLICT (tenant_id, code) DO NOTHING;
|
|
||||||
|
|
||||||
RAISE NOTICE 'Reportes del sistema insertados correctamente';
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- ROLLBACK SCRIPT
|
|
||||||
-- ============================================================================
|
|
||||||
/*
|
|
||||||
DROP FUNCTION IF EXISTS reports.generate_general_ledger(UUID, UUID, UUID, DATE, DATE);
|
|
||||||
DROP FUNCTION IF EXISTS reports.generate_trial_balance(UUID, UUID, DATE, DATE, BOOLEAN);
|
|
||||||
DROP TABLE IF EXISTS reports.report_templates;
|
|
||||||
DROP TABLE IF EXISTS reports.report_schedules;
|
|
||||||
DROP TABLE IF EXISTS reports.report_executions;
|
|
||||||
DROP TABLE IF EXISTS reports.report_definitions;
|
|
||||||
*/
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- VERIFICACIÓN
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'reports' AND tablename = 'report_definitions') THEN
|
|
||||||
RAISE EXCEPTION 'Error: Tabla report_definitions no fue creada';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'reports' AND tablename = 'report_executions') THEN
|
|
||||||
RAISE EXCEPTION 'Error: Tabla report_executions no fue creada';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
RAISE NOTICE 'Migración completada exitosamente: Reportes Financieros';
|
|
||||||
END $$;
|
|
||||||
238
migrations/20260110_001_add_tenant_user_fields.sql
Normal file
238
migrations/20260110_001_add_tenant_user_fields.sql
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- MIGRACION: 20260110_001_add_tenant_user_fields.sql
|
||||||
|
-- DESCRIPCION: Agregar campos nuevos a tenants y users para soportar
|
||||||
|
-- persona moral/fisica, sucursales, perfiles y mobile
|
||||||
|
-- VERSION: 1.0.0
|
||||||
|
-- PROYECTO: ERP-Core V2
|
||||||
|
-- FECHA: 2026-01-10
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- UP MIGRATION
|
||||||
|
-- =====================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- MODIFICACIONES A auth.tenants
|
||||||
|
-- =====================
|
||||||
|
|
||||||
|
-- Agregar columna client_type (Persona Moral o Fisica)
|
||||||
|
ALTER TABLE auth.tenants
|
||||||
|
ADD COLUMN IF NOT EXISTS client_type VARCHAR(20) DEFAULT 'persona_moral';
|
||||||
|
|
||||||
|
-- Agregar restriccion para client_type
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'chk_tenant_client_type'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE auth.tenants
|
||||||
|
ADD CONSTRAINT chk_tenant_client_type
|
||||||
|
CHECK (client_type IN ('persona_fisica', 'persona_moral'));
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Agregar persona responsable (representante legal o titular)
|
||||||
|
ALTER TABLE auth.tenants
|
||||||
|
ADD COLUMN IF NOT EXISTS responsible_person_id UUID REFERENCES auth.persons(id);
|
||||||
|
|
||||||
|
-- Agregar tipo de despliegue
|
||||||
|
ALTER TABLE auth.tenants
|
||||||
|
ADD COLUMN IF NOT EXISTS deployment_type VARCHAR(20) DEFAULT 'saas';
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'chk_tenant_deployment_type'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE auth.tenants
|
||||||
|
ADD CONSTRAINT chk_tenant_deployment_type
|
||||||
|
CHECK (deployment_type IN ('saas', 'on_premise', 'hybrid'));
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Agregar configuracion de facturacion
|
||||||
|
ALTER TABLE auth.tenants
|
||||||
|
ADD COLUMN IF NOT EXISTS billing_config JSONB DEFAULT '{}';
|
||||||
|
|
||||||
|
-- Agregar sucursal matriz
|
||||||
|
ALTER TABLE auth.tenants
|
||||||
|
ADD COLUMN IF NOT EXISTS main_branch_id UUID REFERENCES core.branches(id);
|
||||||
|
|
||||||
|
-- Agregar configuracion de perfiles permitidos
|
||||||
|
ALTER TABLE auth.tenants
|
||||||
|
ADD COLUMN IF NOT EXISTS allowed_profiles TEXT[] DEFAULT '{}';
|
||||||
|
|
||||||
|
-- Agregar configuracion de plataformas permitidas
|
||||||
|
ALTER TABLE auth.tenants
|
||||||
|
ADD COLUMN IF NOT EXISTS allowed_platforms TEXT[] DEFAULT '{web}';
|
||||||
|
|
||||||
|
-- Agregar limite de usuarios
|
||||||
|
ALTER TABLE auth.tenants
|
||||||
|
ADD COLUMN IF NOT EXISTS max_users INTEGER DEFAULT 5;
|
||||||
|
|
||||||
|
-- Agregar limite de sucursales
|
||||||
|
ALTER TABLE auth.tenants
|
||||||
|
ADD COLUMN IF NOT EXISTS max_branches INTEGER DEFAULT 1;
|
||||||
|
|
||||||
|
-- Agregar datos fiscales
|
||||||
|
ALTER TABLE auth.tenants
|
||||||
|
ADD COLUMN IF NOT EXISTS fiscal_data JSONB DEFAULT '{}';
|
||||||
|
-- Ejemplo: {"rfc": "ABC123456XYZ", "regimen_fiscal": "601", "uso_cfdi": "G03"}
|
||||||
|
|
||||||
|
-- Agregar logo
|
||||||
|
ALTER TABLE auth.tenants
|
||||||
|
ADD COLUMN IF NOT EXISTS logo_url TEXT;
|
||||||
|
|
||||||
|
-- Agregar configuracion general
|
||||||
|
ALTER TABLE auth.tenants
|
||||||
|
ADD COLUMN IF NOT EXISTS settings JSONB DEFAULT '{}';
|
||||||
|
|
||||||
|
-- Indices nuevos para tenants
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tenants_client_type ON auth.tenants(client_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tenants_deployment ON auth.tenants(deployment_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tenants_responsible ON auth.tenants(responsible_person_id);
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- MODIFICACIONES A auth.users
|
||||||
|
-- =====================
|
||||||
|
|
||||||
|
-- Agregar perfil principal
|
||||||
|
ALTER TABLE auth.users
|
||||||
|
ADD COLUMN IF NOT EXISTS primary_profile_id UUID REFERENCES auth.user_profiles(id);
|
||||||
|
|
||||||
|
-- Agregar perfiles adicionales (array de UUIDs)
|
||||||
|
ALTER TABLE auth.users
|
||||||
|
ADD COLUMN IF NOT EXISTS additional_profile_ids UUID[] DEFAULT '{}';
|
||||||
|
|
||||||
|
-- Agregar sucursal principal
|
||||||
|
ALTER TABLE auth.users
|
||||||
|
ADD COLUMN IF NOT EXISTS primary_branch_id UUID REFERENCES core.branches(id);
|
||||||
|
|
||||||
|
-- Agregar sucursales adicionales (array de UUIDs)
|
||||||
|
ALTER TABLE auth.users
|
||||||
|
ADD COLUMN IF NOT EXISTS additional_branch_ids UUID[] DEFAULT '{}';
|
||||||
|
|
||||||
|
-- Agregar plataformas permitidas
|
||||||
|
ALTER TABLE auth.users
|
||||||
|
ADD COLUMN IF NOT EXISTS allowed_platforms TEXT[] DEFAULT '{web}';
|
||||||
|
|
||||||
|
-- Agregar configuracion de biometricos
|
||||||
|
ALTER TABLE auth.users
|
||||||
|
ADD COLUMN IF NOT EXISTS biometric_enabled BOOLEAN DEFAULT FALSE;
|
||||||
|
|
||||||
|
-- Agregar persona asociada (para empleados)
|
||||||
|
ALTER TABLE auth.users
|
||||||
|
ADD COLUMN IF NOT EXISTS person_id UUID REFERENCES auth.persons(id);
|
||||||
|
|
||||||
|
-- Agregar preferencias de usuario
|
||||||
|
ALTER TABLE auth.users
|
||||||
|
ADD COLUMN IF NOT EXISTS preferences JSONB DEFAULT '{}';
|
||||||
|
-- Ejemplo: {"theme": "light", "language": "es", "notifications": true}
|
||||||
|
|
||||||
|
-- Agregar configuracion de notificaciones
|
||||||
|
ALTER TABLE auth.users
|
||||||
|
ADD COLUMN IF NOT EXISTS notification_settings JSONB DEFAULT '{}';
|
||||||
|
-- Ejemplo: {"push": true, "email": true, "sms": false, "categories": ["sales", "inventory"]}
|
||||||
|
|
||||||
|
-- Agregar ultimo dispositivo usado
|
||||||
|
ALTER TABLE auth.users
|
||||||
|
ADD COLUMN IF NOT EXISTS last_device_id UUID REFERENCES auth.devices(id);
|
||||||
|
|
||||||
|
-- Agregar ultima ubicacion conocida
|
||||||
|
ALTER TABLE auth.users
|
||||||
|
ADD COLUMN IF NOT EXISTS last_latitude DECIMAL(10, 8);
|
||||||
|
|
||||||
|
ALTER TABLE auth.users
|
||||||
|
ADD COLUMN IF NOT EXISTS last_longitude DECIMAL(11, 8);
|
||||||
|
|
||||||
|
ALTER TABLE auth.users
|
||||||
|
ADD COLUMN IF NOT EXISTS last_location_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
-- Agregar contador de dispositivos
|
||||||
|
ALTER TABLE auth.users
|
||||||
|
ADD COLUMN IF NOT EXISTS device_count INTEGER DEFAULT 0;
|
||||||
|
|
||||||
|
-- Agregar flag de usuario movil
|
||||||
|
ALTER TABLE auth.users
|
||||||
|
ADD COLUMN IF NOT EXISTS is_mobile_user BOOLEAN DEFAULT FALSE;
|
||||||
|
|
||||||
|
-- Indices nuevos para users
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_primary_profile ON auth.users(primary_profile_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_primary_branch ON auth.users(primary_branch_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_person ON auth.users(person_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_mobile ON auth.users(is_mobile_user) WHERE is_mobile_user = TRUE;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- COMENTARIOS
|
||||||
|
-- =====================
|
||||||
|
COMMENT ON COLUMN auth.tenants.client_type IS 'Tipo de cliente: persona_fisica o persona_moral';
|
||||||
|
COMMENT ON COLUMN auth.tenants.responsible_person_id IS 'Persona fisica responsable de la cuenta (representante legal o titular)';
|
||||||
|
COMMENT ON COLUMN auth.tenants.deployment_type IS 'Tipo de despliegue: saas, on_premise, hybrid';
|
||||||
|
COMMENT ON COLUMN auth.tenants.billing_config IS 'Configuracion de facturacion en formato JSON';
|
||||||
|
COMMENT ON COLUMN auth.tenants.fiscal_data IS 'Datos fiscales: RFC, regimen fiscal, uso CFDI';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN auth.users.primary_profile_id IS 'Perfil principal del usuario';
|
||||||
|
COMMENT ON COLUMN auth.users.primary_branch_id IS 'Sucursal principal asignada';
|
||||||
|
COMMENT ON COLUMN auth.users.biometric_enabled IS 'Indica si el usuario tiene biometricos habilitados';
|
||||||
|
COMMENT ON COLUMN auth.users.is_mobile_user IS 'Indica si el usuario usa la app movil';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
-- =====================
|
||||||
|
-- DOWN MIGRATION (para rollback)
|
||||||
|
-- =====================
|
||||||
|
|
||||||
|
-- Para ejecutar rollback, descomenta y ejecuta lo siguiente:
|
||||||
|
/*
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- Quitar columnas de tenants
|
||||||
|
ALTER TABLE auth.tenants
|
||||||
|
DROP COLUMN IF EXISTS client_type,
|
||||||
|
DROP COLUMN IF EXISTS responsible_person_id,
|
||||||
|
DROP COLUMN IF EXISTS deployment_type,
|
||||||
|
DROP COLUMN IF EXISTS billing_config,
|
||||||
|
DROP COLUMN IF EXISTS main_branch_id,
|
||||||
|
DROP COLUMN IF EXISTS allowed_profiles,
|
||||||
|
DROP COLUMN IF EXISTS allowed_platforms,
|
||||||
|
DROP COLUMN IF EXISTS max_users,
|
||||||
|
DROP COLUMN IF EXISTS max_branches,
|
||||||
|
DROP COLUMN IF EXISTS fiscal_data,
|
||||||
|
DROP COLUMN IF EXISTS logo_url,
|
||||||
|
DROP COLUMN IF EXISTS settings;
|
||||||
|
|
||||||
|
-- Quitar columnas de users
|
||||||
|
ALTER TABLE auth.users
|
||||||
|
DROP COLUMN IF EXISTS primary_profile_id,
|
||||||
|
DROP COLUMN IF EXISTS additional_profile_ids,
|
||||||
|
DROP COLUMN IF EXISTS primary_branch_id,
|
||||||
|
DROP COLUMN IF EXISTS additional_branch_ids,
|
||||||
|
DROP COLUMN IF EXISTS allowed_platforms,
|
||||||
|
DROP COLUMN IF EXISTS biometric_enabled,
|
||||||
|
DROP COLUMN IF EXISTS person_id,
|
||||||
|
DROP COLUMN IF EXISTS preferences,
|
||||||
|
DROP COLUMN IF EXISTS notification_settings,
|
||||||
|
DROP COLUMN IF EXISTS last_device_id,
|
||||||
|
DROP COLUMN IF EXISTS last_latitude,
|
||||||
|
DROP COLUMN IF EXISTS last_longitude,
|
||||||
|
DROP COLUMN IF EXISTS last_location_at,
|
||||||
|
DROP COLUMN IF EXISTS device_count,
|
||||||
|
DROP COLUMN IF EXISTS is_mobile_user;
|
||||||
|
|
||||||
|
-- Quitar constraints
|
||||||
|
ALTER TABLE auth.tenants DROP CONSTRAINT IF EXISTS chk_tenant_client_type;
|
||||||
|
ALTER TABLE auth.tenants DROP CONSTRAINT IF EXISTS chk_tenant_deployment_type;
|
||||||
|
|
||||||
|
-- Quitar indices
|
||||||
|
DROP INDEX IF EXISTS auth.idx_tenants_client_type;
|
||||||
|
DROP INDEX IF EXISTS auth.idx_tenants_deployment;
|
||||||
|
DROP INDEX IF EXISTS auth.idx_tenants_responsible;
|
||||||
|
DROP INDEX IF EXISTS auth.idx_users_primary_profile;
|
||||||
|
DROP INDEX IF EXISTS auth.idx_users_primary_branch;
|
||||||
|
DROP INDEX IF EXISTS auth.idx_users_person;
|
||||||
|
DROP INDEX IF EXISTS auth.idx_users_mobile;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
*/
|
||||||
@ -77,7 +77,9 @@ DDL_FILES=(
|
|||||||
"00-prerequisites.sql"
|
"00-prerequisites.sql"
|
||||||
"01-auth.sql"
|
"01-auth.sql"
|
||||||
"01-auth-extensions.sql"
|
"01-auth-extensions.sql"
|
||||||
|
"01-auth-mfa-email-verification.sql"
|
||||||
"02-core.sql"
|
"02-core.sql"
|
||||||
|
"02-core-extensions.sql"
|
||||||
"03-analytics.sql"
|
"03-analytics.sql"
|
||||||
"04-financial.sql"
|
"04-financial.sql"
|
||||||
"05-inventory.sql"
|
"05-inventory.sql"
|
||||||
@ -86,9 +88,19 @@ DDL_FILES=(
|
|||||||
"07-sales.sql"
|
"07-sales.sql"
|
||||||
"08-projects.sql"
|
"08-projects.sql"
|
||||||
"09-system.sql"
|
"09-system.sql"
|
||||||
|
"09-system-extensions.sql"
|
||||||
"10-billing.sql"
|
"10-billing.sql"
|
||||||
"11-crm.sql"
|
"11-crm.sql"
|
||||||
"12-hr.sql"
|
"12-hr.sql"
|
||||||
|
"13-audit.sql"
|
||||||
|
"14-reports.sql"
|
||||||
|
# MGN-020, MGN-021, MGN-022 - AI Agents, Messaging, Integrations
|
||||||
|
"15-ai-agents.sql"
|
||||||
|
"16-messaging.sql"
|
||||||
|
"17-integrations.sql"
|
||||||
|
# MGN-018, MGN-019 - Webhooks, Feature Flags (2026-01-13)
|
||||||
|
"19-webhooks.sql"
|
||||||
|
"20-feature-flags.sql"
|
||||||
)
|
)
|
||||||
|
|
||||||
TOTAL=${#DDL_FILES[@]}
|
TOTAL=${#DDL_FILES[@]}
|
||||||
|
|||||||
161
scripts/create-test-database.sh
Executable file
161
scripts/create-test-database.sh
Executable file
@ -0,0 +1,161 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ============================================================================
|
||||||
|
# ERP GENERIC - CREATE TEST DATABASE SCRIPT
|
||||||
|
# ============================================================================
|
||||||
|
# Description: Creates a test database for integration tests
|
||||||
|
# Usage: ./scripts/create-test-database.sh
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Script directory
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
DATABASE_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
DDL_DIR="$DATABASE_DIR/ddl"
|
||||||
|
|
||||||
|
# Load environment variables
|
||||||
|
if [ -f "$DATABASE_DIR/.env" ]; then
|
||||||
|
source "$DATABASE_DIR/.env"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test database configuration (separate from main DB)
|
||||||
|
POSTGRES_HOST="${POSTGRES_HOST:-localhost}"
|
||||||
|
POSTGRES_PORT="${POSTGRES_PORT:-5432}"
|
||||||
|
POSTGRES_DB="${TEST_DB_NAME:-erp_generic_test}"
|
||||||
|
POSTGRES_USER="${POSTGRES_USER:-erp_admin}"
|
||||||
|
POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-erp_secret_2024}"
|
||||||
|
|
||||||
|
# Connection string
|
||||||
|
export PGPASSWORD="$POSTGRES_PASSWORD"
|
||||||
|
PSQL_CMD="psql -h $POSTGRES_HOST -p $POSTGRES_PORT -U $POSTGRES_USER"
|
||||||
|
|
||||||
|
echo -e "${BLUE}============================================${NC}"
|
||||||
|
echo -e "${BLUE} ERP GENERIC - TEST DATABASE CREATION${NC}"
|
||||||
|
echo -e "${BLUE}============================================${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "Host: ${GREEN}$POSTGRES_HOST:$POSTGRES_PORT${NC}"
|
||||||
|
echo -e "Database: ${GREEN}$POSTGRES_DB${NC}"
|
||||||
|
echo -e "User: ${GREEN}$POSTGRES_USER${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check if PostgreSQL is reachable
|
||||||
|
echo -e "${BLUE}[1/5] Checking PostgreSQL connection...${NC}"
|
||||||
|
if ! $PSQL_CMD -d postgres -c "SELECT 1" > /dev/null 2>&1; then
|
||||||
|
echo -e "${RED}Error: Cannot connect to PostgreSQL${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo -e "${GREEN}PostgreSQL is reachable!${NC}"
|
||||||
|
|
||||||
|
# Drop test database if exists
|
||||||
|
echo -e "${BLUE}[2/5] Dropping existing test database if exists...${NC}"
|
||||||
|
$PSQL_CMD -d postgres -c "DROP DATABASE IF EXISTS $POSTGRES_DB;" 2>/dev/null || true
|
||||||
|
echo -e "${GREEN}Old test database dropped (if existed)${NC}"
|
||||||
|
|
||||||
|
# Create test database
|
||||||
|
echo -e "${BLUE}[3/5] Creating test database...${NC}"
|
||||||
|
$PSQL_CMD -d postgres -c "CREATE DATABASE $POSTGRES_DB WITH ENCODING='UTF8' LC_COLLATE='en_US.UTF-8' LC_CTYPE='en_US.UTF-8' TEMPLATE=template0;" 2>/dev/null || \
|
||||||
|
$PSQL_CMD -d postgres -c "CREATE DATABASE $POSTGRES_DB;"
|
||||||
|
echo -e "${GREEN}Test database '$POSTGRES_DB' created!${NC}"
|
||||||
|
|
||||||
|
# Execute DDL files in order
|
||||||
|
echo -e "${BLUE}[4/5] Executing DDL files...${NC}"
|
||||||
|
|
||||||
|
DDL_FILES=(
|
||||||
|
"00-prerequisites.sql"
|
||||||
|
"01-auth.sql"
|
||||||
|
"01-auth-extensions.sql"
|
||||||
|
"01-auth-mfa-email-verification.sql"
|
||||||
|
"02-core.sql"
|
||||||
|
"02-core-extensions.sql"
|
||||||
|
"03-analytics.sql"
|
||||||
|
"04-financial.sql"
|
||||||
|
"05-inventory.sql"
|
||||||
|
"05-inventory-extensions.sql"
|
||||||
|
"06-purchase.sql"
|
||||||
|
"07-sales.sql"
|
||||||
|
"08-projects.sql"
|
||||||
|
"09-system.sql"
|
||||||
|
"09-system-extensions.sql"
|
||||||
|
"10-billing.sql"
|
||||||
|
"11-crm.sql"
|
||||||
|
"12-hr.sql"
|
||||||
|
"13-audit.sql"
|
||||||
|
"14-reports.sql"
|
||||||
|
# MGN-020, MGN-021, MGN-022 - AI Agents, Messaging, Integrations
|
||||||
|
"15-ai-agents.sql"
|
||||||
|
"16-messaging.sql"
|
||||||
|
"17-integrations.sql"
|
||||||
|
# MGN-018, MGN-019 - Webhooks, Feature Flags (2026-01-13)
|
||||||
|
"19-webhooks.sql"
|
||||||
|
"20-feature-flags.sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
TOTAL=${#DDL_FILES[@]}
|
||||||
|
CURRENT=0
|
||||||
|
|
||||||
|
for ddl_file in "${DDL_FILES[@]}"; do
|
||||||
|
CURRENT=$((CURRENT + 1))
|
||||||
|
filepath="$DDL_DIR/$ddl_file"
|
||||||
|
|
||||||
|
if [ -f "$filepath" ]; then
|
||||||
|
if $PSQL_CMD -d $POSTGRES_DB -f "$filepath" > /dev/null 2>&1; then
|
||||||
|
echo -e " [${CURRENT}/${TOTAL}] ${GREEN}✓${NC} $ddl_file"
|
||||||
|
else
|
||||||
|
echo -e " [${CURRENT}/${TOTAL}] ${RED}✗${NC} $ddl_file"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e " [${CURRENT}/${TOTAL}] ${YELLOW}⊘${NC} $ddl_file (not found, skipping)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Load test fixtures
|
||||||
|
echo -e "${BLUE}[5/5] Loading test fixtures...${NC}"
|
||||||
|
FIXTURES_FILE="$DATABASE_DIR/seeds/test/fixtures.sql"
|
||||||
|
if [ -f "$FIXTURES_FILE" ]; then
|
||||||
|
if $PSQL_CMD -d $POSTGRES_DB -f "$FIXTURES_FILE" > /dev/null 2>&1; then
|
||||||
|
echo -e " ${GREEN}✓${NC} Test fixtures loaded"
|
||||||
|
else
|
||||||
|
echo -e " ${RED}✗${NC} Error loading fixtures"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e " ${YELLOW}⊘${NC} No fixtures file found (seeds/test/fixtures.sql)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}============================================${NC}"
|
||||||
|
echo -e "${GREEN} TEST DATABASE CREATED SUCCESSFULLY!${NC}"
|
||||||
|
echo -e "${GREEN}============================================${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "Connection string:"
|
||||||
|
echo -e "${BLUE}postgresql://$POSTGRES_USER:****@$POSTGRES_HOST:$POSTGRES_PORT/$POSTGRES_DB${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Show statistics
|
||||||
|
echo -e "${BLUE}Database Statistics:${NC}"
|
||||||
|
$PSQL_CMD -d $POSTGRES_DB -c "
|
||||||
|
SELECT
|
||||||
|
schemaname AS schema,
|
||||||
|
COUNT(*) AS tables
|
||||||
|
FROM pg_tables
|
||||||
|
WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
|
||||||
|
GROUP BY schemaname
|
||||||
|
ORDER BY schemaname;
|
||||||
|
"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}Environment variables for tests:${NC}"
|
||||||
|
echo " export TEST_DB_HOST=$POSTGRES_HOST"
|
||||||
|
echo " export TEST_DB_PORT=$POSTGRES_PORT"
|
||||||
|
echo " export TEST_DB_NAME=$POSTGRES_DB"
|
||||||
|
echo " export TEST_DB_USER=$POSTGRES_USER"
|
||||||
|
echo " export TEST_DB_PASSWORD=<your_password>"
|
||||||
|
echo ""
|
||||||
@ -1,75 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# ============================================================================
|
|
||||||
# ERP GENERIC - DROP DATABASE SCRIPT
|
|
||||||
# ============================================================================
|
|
||||||
# Description: Drops the ERP Generic database
|
|
||||||
# Usage: ./scripts/drop-database.sh [--force]
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Colors for output
|
|
||||||
RED='\033[0;31m'
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
BLUE='\033[0;34m'
|
|
||||||
NC='\033[0m'
|
|
||||||
|
|
||||||
# Script directory
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
DATABASE_DIR="$(dirname "$SCRIPT_DIR")"
|
|
||||||
|
|
||||||
# Load environment variables
|
|
||||||
if [ -f "$DATABASE_DIR/.env" ]; then
|
|
||||||
source "$DATABASE_DIR/.env"
|
|
||||||
elif [ -f "$DATABASE_DIR/.env.example" ]; then
|
|
||||||
source "$DATABASE_DIR/.env.example"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Default values
|
|
||||||
POSTGRES_HOST="${POSTGRES_HOST:-localhost}"
|
|
||||||
POSTGRES_PORT="${POSTGRES_PORT:-5432}"
|
|
||||||
POSTGRES_DB="${POSTGRES_DB:-erp_generic}"
|
|
||||||
POSTGRES_USER="${POSTGRES_USER:-erp_admin}"
|
|
||||||
POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-erp_secret_2024}"
|
|
||||||
|
|
||||||
# Connection string
|
|
||||||
export PGPASSWORD="$POSTGRES_PASSWORD"
|
|
||||||
PSQL_CMD="psql -h $POSTGRES_HOST -p $POSTGRES_PORT -U $POSTGRES_USER"
|
|
||||||
|
|
||||||
# Check for --force flag
|
|
||||||
FORCE=false
|
|
||||||
if [ "$1" == "--force" ]; then
|
|
||||||
FORCE=true
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo -e "${RED}============================================${NC}"
|
|
||||||
echo -e "${RED} ERP GENERIC - DROP DATABASE${NC}"
|
|
||||||
echo -e "${RED}============================================${NC}"
|
|
||||||
echo ""
|
|
||||||
echo -e "Database: ${YELLOW}$POSTGRES_DB${NC}"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
if [ "$FORCE" != true ]; then
|
|
||||||
echo -e "${RED}WARNING: This will permanently delete all data!${NC}"
|
|
||||||
read -p "Are you sure you want to drop the database? (y/N): " confirm
|
|
||||||
if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then
|
|
||||||
echo "Aborted."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo -e "${BLUE}Terminating active connections...${NC}"
|
|
||||||
$PSQL_CMD -d postgres -c "
|
|
||||||
SELECT pg_terminate_backend(pg_stat_activity.pid)
|
|
||||||
FROM pg_stat_activity
|
|
||||||
WHERE pg_stat_activity.datname = '$POSTGRES_DB'
|
|
||||||
AND pid <> pg_backend_pid();
|
|
||||||
" 2>/dev/null || true
|
|
||||||
|
|
||||||
echo -e "${BLUE}Dropping database...${NC}"
|
|
||||||
$PSQL_CMD -d postgres -c "DROP DATABASE IF EXISTS $POSTGRES_DB;"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo -e "${GREEN}Database '$POSTGRES_DB' has been dropped.${NC}"
|
|
||||||
echo ""
|
|
||||||
@ -1,101 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# ============================================================================
|
|
||||||
# ERP GENERIC - LOAD SEEDS SCRIPT
|
|
||||||
# ============================================================================
|
|
||||||
# Description: Loads seed data for the specified environment
|
|
||||||
# Usage: ./scripts/load-seeds.sh [dev|prod]
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Colors for output
|
|
||||||
RED='\033[0;31m'
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
BLUE='\033[0;34m'
|
|
||||||
NC='\033[0m'
|
|
||||||
|
|
||||||
# Script directory
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
DATABASE_DIR="$(dirname "$SCRIPT_DIR")"
|
|
||||||
SEEDS_DIR="$DATABASE_DIR/seeds"
|
|
||||||
|
|
||||||
# Environment (default: dev)
|
|
||||||
ENV="${1:-dev}"
|
|
||||||
|
|
||||||
# Load environment variables
|
|
||||||
if [ -f "$DATABASE_DIR/.env" ]; then
|
|
||||||
source "$DATABASE_DIR/.env"
|
|
||||||
elif [ -f "$DATABASE_DIR/.env.example" ]; then
|
|
||||||
source "$DATABASE_DIR/.env.example"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Default values
|
|
||||||
POSTGRES_HOST="${POSTGRES_HOST:-localhost}"
|
|
||||||
POSTGRES_PORT="${POSTGRES_PORT:-5432}"
|
|
||||||
POSTGRES_DB="${POSTGRES_DB:-erp_generic}"
|
|
||||||
POSTGRES_USER="${POSTGRES_USER:-erp_admin}"
|
|
||||||
POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-erp_secret_2024}"
|
|
||||||
|
|
||||||
# Connection string
|
|
||||||
export PGPASSWORD="$POSTGRES_PASSWORD"
|
|
||||||
PSQL_CMD="psql -h $POSTGRES_HOST -p $POSTGRES_PORT -U $POSTGRES_USER -d $POSTGRES_DB"
|
|
||||||
|
|
||||||
echo -e "${BLUE}============================================${NC}"
|
|
||||||
echo -e "${BLUE} ERP GENERIC - LOAD SEED DATA${NC}"
|
|
||||||
echo -e "${BLUE}============================================${NC}"
|
|
||||||
echo ""
|
|
||||||
echo -e "Environment: ${GREEN}$ENV${NC}"
|
|
||||||
echo -e "Database: ${GREEN}$POSTGRES_DB${NC}"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Check if seeds directory exists
|
|
||||||
SEED_ENV_DIR="$SEEDS_DIR/$ENV"
|
|
||||||
if [ ! -d "$SEED_ENV_DIR" ]; then
|
|
||||||
echo -e "${RED}Error: Seeds directory not found: $SEED_ENV_DIR${NC}"
|
|
||||||
echo "Available environments:"
|
|
||||||
ls -1 "$SEEDS_DIR" 2>/dev/null || echo " (none)"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if there are SQL files
|
|
||||||
SEED_FILES=($(find "$SEED_ENV_DIR" -name "*.sql" -type f | sort))
|
|
||||||
|
|
||||||
if [ ${#SEED_FILES[@]} -eq 0 ]; then
|
|
||||||
echo -e "${YELLOW}No seed files found in $SEED_ENV_DIR${NC}"
|
|
||||||
echo "Create seed files with format: XX-description.sql"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo -e "${BLUE}Loading ${#SEED_FILES[@]} seed file(s)...${NC}"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
TOTAL=${#SEED_FILES[@]}
|
|
||||||
CURRENT=0
|
|
||||||
FAILED=0
|
|
||||||
|
|
||||||
for seed_file in "${SEED_FILES[@]}"; do
|
|
||||||
CURRENT=$((CURRENT + 1))
|
|
||||||
filename=$(basename "$seed_file")
|
|
||||||
|
|
||||||
echo -e " [${CURRENT}/${TOTAL}] Loading ${YELLOW}$filename${NC}..."
|
|
||||||
|
|
||||||
if $PSQL_CMD -f "$seed_file" > /dev/null 2>&1; then
|
|
||||||
echo -e " [${CURRENT}/${TOTAL}] ${GREEN}$filename loaded successfully${NC}"
|
|
||||||
else
|
|
||||||
echo -e " [${CURRENT}/${TOTAL}] ${RED}Error loading $filename${NC}"
|
|
||||||
FAILED=$((FAILED + 1))
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
if [ $FAILED -eq 0 ]; then
|
|
||||||
echo -e "${GREEN}============================================${NC}"
|
|
||||||
echo -e "${GREEN} ALL SEEDS LOADED SUCCESSFULLY!${NC}"
|
|
||||||
echo -e "${GREEN}============================================${NC}"
|
|
||||||
else
|
|
||||||
echo -e "${YELLOW}============================================${NC}"
|
|
||||||
echo -e "${YELLOW} SEEDS LOADED WITH $FAILED ERRORS${NC}"
|
|
||||||
echo -e "${YELLOW}============================================${NC}"
|
|
||||||
fi
|
|
||||||
echo ""
|
|
||||||
481
scripts/recreate-database.sh
Executable file
481
scripts/recreate-database.sh
Executable file
@ -0,0 +1,481 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# =============================================================
|
||||||
|
# SCRIPT: recreate-database.sh
|
||||||
|
# DESCRIPCION: Script de recreacion completa de base de datos ERP-Core
|
||||||
|
# VERSION: 1.0.0
|
||||||
|
# PROYECTO: ERP-Core V2
|
||||||
|
# FECHA: 2026-01-10
|
||||||
|
# =============================================================
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Colores para output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Configuracion por defecto
|
||||||
|
DB_HOST="${DB_HOST:-localhost}"
|
||||||
|
DB_PORT="${DB_PORT:-5432}"
|
||||||
|
DB_NAME="${DB_NAME:-erp_core}"
|
||||||
|
DB_USER="${DB_USER:-postgres}"
|
||||||
|
DB_PASSWORD="${DB_PASSWORD:-}"
|
||||||
|
|
||||||
|
# Directorios
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
DATABASE_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
DDL_DIR="$DATABASE_DIR/ddl"
|
||||||
|
MIGRATIONS_DIR="$DATABASE_DIR/migrations"
|
||||||
|
SEEDS_DIR="$DATABASE_DIR/seeds"
|
||||||
|
|
||||||
|
# Flags
|
||||||
|
DROP_DB=false
|
||||||
|
LOAD_SEEDS=false
|
||||||
|
VERBOSE=false
|
||||||
|
DRY_RUN=false
|
||||||
|
|
||||||
|
# Funciones de utilidad
|
||||||
|
log_info() {
|
||||||
|
echo -e "${BLUE}[INFO]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_success() {
|
||||||
|
echo -e "${GREEN}[OK]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_warning() {
|
||||||
|
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_error() {
|
||||||
|
echo -e "${RED}[ERROR]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
show_help() {
|
||||||
|
echo "Uso: $0 [opciones]"
|
||||||
|
echo ""
|
||||||
|
echo "Script de recreacion de base de datos ERP-Core"
|
||||||
|
echo ""
|
||||||
|
echo "Opciones:"
|
||||||
|
echo " -h, --help Mostrar esta ayuda"
|
||||||
|
echo " -d, --drop Eliminar y recrear la base de datos completa"
|
||||||
|
echo " -s, --seeds Cargar seeds de desarrollo"
|
||||||
|
echo " -v, --verbose Modo verbose"
|
||||||
|
echo " --dry-run Mostrar comandos sin ejecutar"
|
||||||
|
echo ""
|
||||||
|
echo "Variables de entorno:"
|
||||||
|
echo " DB_HOST Host de la base de datos (default: localhost)"
|
||||||
|
echo " DB_PORT Puerto de la base de datos (default: 5432)"
|
||||||
|
echo " DB_NAME Nombre de la base de datos (default: erp_core)"
|
||||||
|
echo " DB_USER Usuario de la base de datos (default: postgres)"
|
||||||
|
echo " DB_PASSWORD Password de la base de datos"
|
||||||
|
echo ""
|
||||||
|
echo "Ejemplos:"
|
||||||
|
echo " $0 Ejecutar DDL y migraciones sin eliminar DB"
|
||||||
|
echo " $0 -d Eliminar y recrear DB completa"
|
||||||
|
echo " $0 -d -s Eliminar, recrear DB y cargar seeds"
|
||||||
|
echo " DB_HOST=db.example.com $0 -d Usar host remoto"
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_args() {
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case $1 in
|
||||||
|
-h|--help)
|
||||||
|
show_help
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
-d|--drop)
|
||||||
|
DROP_DB=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-s|--seeds)
|
||||||
|
LOAD_SEEDS=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-v|--verbose)
|
||||||
|
VERBOSE=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--dry-run)
|
||||||
|
DRY_RUN=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
log_error "Opcion desconocida: $1"
|
||||||
|
show_help
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Construir connection string
|
||||||
|
get_psql_cmd() {
|
||||||
|
local cmd="psql -h $DB_HOST -p $DB_PORT -U $DB_USER"
|
||||||
|
if [ -n "$DB_PASSWORD" ]; then
|
||||||
|
cmd="PGPASSWORD=$DB_PASSWORD $cmd"
|
||||||
|
fi
|
||||||
|
echo "$cmd"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_sql() {
|
||||||
|
local db="$1"
|
||||||
|
local sql="$2"
|
||||||
|
local psql_cmd=$(get_psql_cmd)
|
||||||
|
|
||||||
|
if [ "$DRY_RUN" = true ]; then
|
||||||
|
log_info "[DRY-RUN] Ejecutaria en $db: $sql"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$VERBOSE" = true ]; then
|
||||||
|
log_info "Ejecutando: $sql"
|
||||||
|
fi
|
||||||
|
|
||||||
|
eval "$psql_cmd -d $db -c \"$sql\""
|
||||||
|
}
|
||||||
|
|
||||||
|
run_sql_file() {
|
||||||
|
local db="$1"
|
||||||
|
local file="$2"
|
||||||
|
local psql_cmd=$(get_psql_cmd)
|
||||||
|
|
||||||
|
if [ ! -f "$file" ]; then
|
||||||
|
log_error "Archivo no encontrado: $file"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$DRY_RUN" = true ]; then
|
||||||
|
log_info "[DRY-RUN] Ejecutaria archivo: $file"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$VERBOSE" = true ]; then
|
||||||
|
log_info "Ejecutando archivo: $file"
|
||||||
|
fi
|
||||||
|
|
||||||
|
eval "$psql_cmd -d $db -f \"$file\""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Drop y recrear base de datos
|
||||||
|
drop_and_create_db() {
|
||||||
|
local psql_cmd=$(get_psql_cmd)
|
||||||
|
|
||||||
|
log_info "Eliminando base de datos existente: $DB_NAME"
|
||||||
|
|
||||||
|
if [ "$DRY_RUN" = true ]; then
|
||||||
|
log_info "[DRY-RUN] DROP DATABASE IF EXISTS $DB_NAME"
|
||||||
|
log_info "[DRY-RUN] CREATE DATABASE $DB_NAME"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Terminar conexiones activas
|
||||||
|
eval "$psql_cmd -d postgres -c \"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$DB_NAME' AND pid <> pg_backend_pid();\"" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Eliminar y crear base de datos
|
||||||
|
eval "$psql_cmd -d postgres -c \"DROP DATABASE IF EXISTS $DB_NAME;\""
|
||||||
|
eval "$psql_cmd -d postgres -c \"CREATE DATABASE $DB_NAME WITH ENCODING 'UTF8' LC_COLLATE 'en_US.UTF-8' LC_CTYPE 'en_US.UTF-8' TEMPLATE template0;\""
|
||||||
|
|
||||||
|
log_success "Base de datos $DB_NAME creada"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Crear schemas base
|
||||||
|
create_base_schemas() {
|
||||||
|
log_info "Creando schemas base..."
|
||||||
|
|
||||||
|
run_sql "$DB_NAME" "CREATE SCHEMA IF NOT EXISTS auth;"
|
||||||
|
run_sql "$DB_NAME" "CREATE SCHEMA IF NOT EXISTS core;"
|
||||||
|
run_sql "$DB_NAME" "CREATE SCHEMA IF NOT EXISTS mobile;"
|
||||||
|
run_sql "$DB_NAME" "CREATE SCHEMA IF NOT EXISTS billing;"
|
||||||
|
run_sql "$DB_NAME" "CREATE SCHEMA IF NOT EXISTS users;"
|
||||||
|
run_sql "$DB_NAME" "CREATE SCHEMA IF NOT EXISTS flags;"
|
||||||
|
# Sprint 3+ schemas
|
||||||
|
run_sql "$DB_NAME" "CREATE SCHEMA IF NOT EXISTS notifications;"
|
||||||
|
run_sql "$DB_NAME" "CREATE SCHEMA IF NOT EXISTS audit;"
|
||||||
|
run_sql "$DB_NAME" "CREATE SCHEMA IF NOT EXISTS webhooks;"
|
||||||
|
run_sql "$DB_NAME" "CREATE SCHEMA IF NOT EXISTS storage;"
|
||||||
|
run_sql "$DB_NAME" "CREATE SCHEMA IF NOT EXISTS ai;"
|
||||||
|
run_sql "$DB_NAME" "CREATE SCHEMA IF NOT EXISTS whatsapp;"
|
||||||
|
# Business modules schemas
|
||||||
|
run_sql "$DB_NAME" "CREATE SCHEMA IF NOT EXISTS partners;"
|
||||||
|
run_sql "$DB_NAME" "CREATE SCHEMA IF NOT EXISTS products;"
|
||||||
|
run_sql "$DB_NAME" "CREATE SCHEMA IF NOT EXISTS inventory;"
|
||||||
|
run_sql "$DB_NAME" "CREATE SCHEMA IF NOT EXISTS sales;"
|
||||||
|
run_sql "$DB_NAME" "CREATE SCHEMA IF NOT EXISTS purchases;"
|
||||||
|
|
||||||
|
log_success "Schemas base creados"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Crear extensiones requeridas
|
||||||
|
create_extensions() {
|
||||||
|
log_info "Creando extensiones requeridas..."
|
||||||
|
|
||||||
|
run_sql "$DB_NAME" "CREATE EXTENSION IF NOT EXISTS cube;"
|
||||||
|
run_sql "$DB_NAME" "CREATE EXTENSION IF NOT EXISTS earthdistance;"
|
||||||
|
|
||||||
|
log_success "Extensiones creadas"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Verificar si existen tablas base
|
||||||
|
check_base_tables() {
|
||||||
|
local psql_cmd=$(get_psql_cmd)
|
||||||
|
local result
|
||||||
|
|
||||||
|
result=$(eval "$psql_cmd -d $DB_NAME -t -c \"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'auth' AND table_name IN ('tenants', 'users');\"" 2>/dev/null | tr -d ' ')
|
||||||
|
|
||||||
|
if [ "$result" -eq "2" ]; then
|
||||||
|
return 0 # Tablas existen
|
||||||
|
else
|
||||||
|
return 1 # Tablas no existen
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Crear tablas base (si no existen y es una recreacion)
|
||||||
|
create_base_tables() {
|
||||||
|
log_info "Creando tablas base (auth.tenants, auth.users)..."
|
||||||
|
|
||||||
|
# Solo crear si estamos en modo drop o si no existen
|
||||||
|
run_sql "$DB_NAME" "
|
||||||
|
CREATE TABLE IF NOT EXISTS auth.tenants (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
slug VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS auth.users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
||||||
|
email VARCHAR(255) NOT NULL,
|
||||||
|
password_hash TEXT,
|
||||||
|
first_name VARCHAR(100),
|
||||||
|
last_name VARCHAR(100),
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
email_verified BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
UNIQUE(tenant_id, email)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_tenant ON auth.users(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_email ON auth.users(email);
|
||||||
|
"
|
||||||
|
|
||||||
|
log_success "Tablas base creadas"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutar archivos DDL en orden
|
||||||
|
run_ddl_files() {
|
||||||
|
log_info "Ejecutando archivos DDL..."
|
||||||
|
|
||||||
|
# Orden especifico de ejecucion
|
||||||
|
# Nota: El orden es importante por las dependencias entre tablas
|
||||||
|
local ddl_files=(
|
||||||
|
# Core existente
|
||||||
|
"01-auth-profiles.sql"
|
||||||
|
"02-auth-devices.sql"
|
||||||
|
"03-core-branches.sql"
|
||||||
|
"04-mobile.sql"
|
||||||
|
"05-billing-usage.sql"
|
||||||
|
# SaaS Extensions - Sprint 1-2 (EPIC-SAAS-001, EPIC-SAAS-002)
|
||||||
|
"06-auth-extended.sql"
|
||||||
|
"07-users-rbac.sql"
|
||||||
|
"08-plans.sql"
|
||||||
|
"11-feature-flags.sql"
|
||||||
|
# SaaS Extensions - Sprint 3+ (EPIC-SAAS-003 - EPIC-SAAS-008)
|
||||||
|
"09-notifications.sql"
|
||||||
|
"10-audit.sql"
|
||||||
|
"12-webhooks.sql"
|
||||||
|
"13-storage.sql"
|
||||||
|
"14-ai.sql"
|
||||||
|
"15-whatsapp.sql"
|
||||||
|
# Business Modules - ERP Core
|
||||||
|
"16-partners.sql"
|
||||||
|
"17-products.sql"
|
||||||
|
"18-warehouses.sql"
|
||||||
|
"21-inventory.sql"
|
||||||
|
"22-sales.sql"
|
||||||
|
"23-purchases.sql"
|
||||||
|
"24-invoices.sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
for ddl_file in "${ddl_files[@]}"; do
|
||||||
|
local file_path="$DDL_DIR/$ddl_file"
|
||||||
|
if [ -f "$file_path" ]; then
|
||||||
|
log_info "Ejecutando: $ddl_file"
|
||||||
|
run_sql_file "$DB_NAME" "$file_path"
|
||||||
|
log_success "Completado: $ddl_file"
|
||||||
|
else
|
||||||
|
log_warning "Archivo no encontrado: $ddl_file"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ejecutar migraciones
|
||||||
|
run_migrations() {
|
||||||
|
log_info "Ejecutando migraciones..."
|
||||||
|
|
||||||
|
if [ ! -d "$MIGRATIONS_DIR" ]; then
|
||||||
|
log_warning "Directorio de migraciones no encontrado: $MIGRATIONS_DIR"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ordenar migraciones por nombre (fecha)
|
||||||
|
local migration_files=$(ls -1 "$MIGRATIONS_DIR"/*.sql 2>/dev/null | sort)
|
||||||
|
|
||||||
|
if [ -z "$migration_files" ]; then
|
||||||
|
log_info "No hay migraciones pendientes"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
for migration_file in $migration_files; do
|
||||||
|
local filename=$(basename "$migration_file")
|
||||||
|
log_info "Ejecutando migracion: $filename"
|
||||||
|
run_sql_file "$DB_NAME" "$migration_file"
|
||||||
|
log_success "Migracion completada: $filename"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cargar seeds de desarrollo
|
||||||
|
load_seeds() {
|
||||||
|
log_info "Cargando seeds de desarrollo..."
|
||||||
|
|
||||||
|
local seeds_dev_dir="$SEEDS_DIR/dev"
|
||||||
|
|
||||||
|
if [ ! -d "$seeds_dev_dir" ]; then
|
||||||
|
log_warning "Directorio de seeds no encontrado: $seeds_dev_dir"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ordenar seeds por nombre
|
||||||
|
local seed_files=$(ls -1 "$seeds_dev_dir"/*.sql 2>/dev/null | sort)
|
||||||
|
|
||||||
|
if [ -z "$seed_files" ]; then
|
||||||
|
log_info "No hay seeds para cargar"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
for seed_file in $seed_files; do
|
||||||
|
local filename=$(basename "$seed_file")
|
||||||
|
log_info "Cargando seed: $filename"
|
||||||
|
run_sql_file "$DB_NAME" "$seed_file"
|
||||||
|
log_success "Seed cargado: $filename"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Validar creacion de tablas
|
||||||
|
validate_database() {
|
||||||
|
log_info "Validando base de datos..."
|
||||||
|
|
||||||
|
local psql_cmd=$(get_psql_cmd)
|
||||||
|
|
||||||
|
if [ "$DRY_RUN" = true ]; then
|
||||||
|
log_info "[DRY-RUN] Validaria tablas creadas"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Contar tablas por schema
|
||||||
|
echo ""
|
||||||
|
echo "=== Resumen de tablas por schema ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
local schemas=("auth" "core" "mobile" "billing" "users" "flags" "notifications" "audit" "webhooks" "storage" "ai" "whatsapp" "partners" "products" "inventory" "sales" "purchases")
|
||||||
|
local total_tables=0
|
||||||
|
|
||||||
|
for schema in "${schemas[@]}"; do
|
||||||
|
local count=$(eval "$psql_cmd -d $DB_NAME -t -c \"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = '$schema';\"" | tr -d ' ')
|
||||||
|
echo " $schema: $count tablas"
|
||||||
|
total_tables=$((total_tables + count))
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo " Total: $total_tables tablas"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Listar tablas principales
|
||||||
|
echo "=== Tablas principales creadas ==="
|
||||||
|
echo ""
|
||||||
|
eval "$psql_cmd -d $DB_NAME -c \"
|
||||||
|
SELECT table_schema, table_name
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema IN ('auth', 'core', 'mobile', 'billing', 'users', 'flags', 'notifications', 'audit', 'webhooks', 'storage', 'ai', 'whatsapp', 'partners', 'products', 'inventory', 'sales', 'purchases')
|
||||||
|
AND table_type = 'BASE TABLE'
|
||||||
|
ORDER BY table_schema, table_name;
|
||||||
|
\""
|
||||||
|
|
||||||
|
log_success "Validacion completada"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mostrar configuracion actual
|
||||||
|
show_config() {
|
||||||
|
echo ""
|
||||||
|
echo "=== Configuracion ==="
|
||||||
|
echo " Host: $DB_HOST"
|
||||||
|
echo " Puerto: $DB_PORT"
|
||||||
|
echo " Base de datos: $DB_NAME"
|
||||||
|
echo " Usuario: $DB_USER"
|
||||||
|
echo " Drop DB: $DROP_DB"
|
||||||
|
echo " Cargar seeds: $LOAD_SEEDS"
|
||||||
|
echo " Dry run: $DRY_RUN"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main
|
||||||
|
main() {
|
||||||
|
parse_args "$@"
|
||||||
|
|
||||||
|
echo "=================================================="
|
||||||
|
echo " ERP-Core Database Recreation Script v1.0.0"
|
||||||
|
echo "=================================================="
|
||||||
|
|
||||||
|
show_config
|
||||||
|
|
||||||
|
# Verificar que psql esta disponible
|
||||||
|
if ! command -v psql &> /dev/null; then
|
||||||
|
log_error "psql no encontrado. Instalar PostgreSQL client."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verificar conexion
|
||||||
|
log_info "Verificando conexion a PostgreSQL..."
|
||||||
|
local psql_cmd=$(get_psql_cmd)
|
||||||
|
if ! eval "$psql_cmd -d postgres -c 'SELECT 1;'" &> /dev/null; then
|
||||||
|
log_error "No se puede conectar a PostgreSQL. Verificar credenciales."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
log_success "Conexion exitosa"
|
||||||
|
|
||||||
|
# Ejecutar pasos
|
||||||
|
if [ "$DROP_DB" = true ]; then
|
||||||
|
drop_and_create_db
|
||||||
|
create_base_schemas
|
||||||
|
create_extensions
|
||||||
|
create_base_tables
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ejecutar DDL
|
||||||
|
run_ddl_files
|
||||||
|
|
||||||
|
# Ejecutar migraciones
|
||||||
|
run_migrations
|
||||||
|
|
||||||
|
# Cargar seeds si se solicito
|
||||||
|
if [ "$LOAD_SEEDS" = true ]; then
|
||||||
|
load_seeds
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Validar
|
||||||
|
validate_database
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
log_success "Recreacion de base de datos completada exitosamente"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@ -1,102 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# ============================================================================
|
|
||||||
# ERP GENERIC - RESET DATABASE SCRIPT
|
|
||||||
# ============================================================================
|
|
||||||
# Description: Drops and recreates the database with fresh data
|
|
||||||
# Usage: ./scripts/reset-database.sh [--no-seeds] [--env dev|prod] [--force]
|
|
||||||
#
|
|
||||||
# Por defecto:
|
|
||||||
# - Carga DDL completo
|
|
||||||
# - Carga seeds de desarrollo (dev)
|
|
||||||
# - Pide confirmación
|
|
||||||
#
|
|
||||||
# Opciones:
|
|
||||||
# --no-seeds No cargar seeds después del DDL
|
|
||||||
# --env ENV Ambiente de seeds: dev (default) o prod
|
|
||||||
# --force No pedir confirmación (para CI/CD)
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Colors for output
|
|
||||||
RED='\033[0;31m'
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
BLUE='\033[0;34m'
|
|
||||||
NC='\033[0m'
|
|
||||||
|
|
||||||
# Script directory
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
|
|
||||||
# Defaults - Seeds activados por defecto
|
|
||||||
WITH_SEEDS=true
|
|
||||||
ENV="dev"
|
|
||||||
FORCE=false
|
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case $1 in
|
|
||||||
--no-seeds)
|
|
||||||
WITH_SEEDS=false
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
--env)
|
|
||||||
ENV="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--force)
|
|
||||||
FORCE=true
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
echo -e "${YELLOW}============================================${NC}"
|
|
||||||
echo -e "${YELLOW} ERP GENERIC - RESET DATABASE${NC}"
|
|
||||||
echo -e "${YELLOW}============================================${NC}"
|
|
||||||
echo ""
|
|
||||||
echo -e "Ambiente: ${GREEN}$ENV${NC}"
|
|
||||||
echo -e "Seeds: ${GREEN}$WITH_SEEDS${NC}"
|
|
||||||
echo ""
|
|
||||||
echo -e "${RED}WARNING: This will DELETE all data and recreate the database!${NC}"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
if [ "$FORCE" = false ]; then
|
|
||||||
read -p "Are you sure you want to reset? (y/N): " confirm
|
|
||||||
if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then
|
|
||||||
echo "Aborted."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Drop database
|
|
||||||
echo ""
|
|
||||||
echo -e "${BLUE}Step 1: Dropping database...${NC}"
|
|
||||||
"$SCRIPT_DIR/drop-database.sh" --force
|
|
||||||
|
|
||||||
# Create database (DDL)
|
|
||||||
echo ""
|
|
||||||
echo -e "${BLUE}Step 2: Creating database (DDL)...${NC}"
|
|
||||||
"$SCRIPT_DIR/create-database.sh"
|
|
||||||
|
|
||||||
# Load seeds (por defecto)
|
|
||||||
if [ "$WITH_SEEDS" = true ]; then
|
|
||||||
echo ""
|
|
||||||
echo -e "${BLUE}Step 3: Loading seed data ($ENV)...${NC}"
|
|
||||||
"$SCRIPT_DIR/load-seeds.sh" "$ENV"
|
|
||||||
else
|
|
||||||
echo ""
|
|
||||||
echo -e "${YELLOW}Step 3: Skipping seeds (--no-seeds)${NC}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo -e "${GREEN}============================================${NC}"
|
|
||||||
echo -e "${GREEN} DATABASE RESET COMPLETE!${NC}"
|
|
||||||
echo -e "${GREEN}============================================${NC}"
|
|
||||||
echo ""
|
|
||||||
echo -e "Resumen:"
|
|
||||||
echo -e " - DDL ejecutados: ${GREEN}15 archivos${NC}"
|
|
||||||
echo -e " - Seeds cargados: ${GREEN}$WITH_SEEDS ($ENV)${NC}"
|
|
||||||
echo ""
|
|
||||||
@ -1,81 +0,0 @@
|
|||||||
-- ============================================================================
|
|
||||||
-- ERP GENERIC - SEED DATA: CATALOGS (Development)
|
|
||||||
-- ============================================================================
|
|
||||||
-- Description: Base catalogs needed before other seeds (currencies, countries, UOMs)
|
|
||||||
-- Order: Must be loaded FIRST (before tenants, companies, etc.)
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- ===========================================
|
|
||||||
-- CURRENCIES (ISO 4217)
|
|
||||||
-- ===========================================
|
|
||||||
|
|
||||||
INSERT INTO core.currencies (id, code, name, symbol, decimals, rounding, active)
|
|
||||||
VALUES
|
|
||||||
('00000000-0000-0000-0000-000000000001', 'MXN', 'Peso Mexicano', '$', 2, 0.01, true),
|
|
||||||
('00000000-0000-0000-0000-000000000002', 'USD', 'US Dollar', '$', 2, 0.01, true),
|
|
||||||
('00000000-0000-0000-0000-000000000003', 'EUR', 'Euro', '€', 2, 0.01, true)
|
|
||||||
ON CONFLICT (code) DO NOTHING;
|
|
||||||
|
|
||||||
-- ===========================================
|
|
||||||
-- COUNTRIES (ISO 3166-1 alpha-2)
|
|
||||||
-- ===========================================
|
|
||||||
|
|
||||||
INSERT INTO core.countries (id, code, name, phone_code, currency_code)
|
|
||||||
VALUES
|
|
||||||
('00000000-0000-0000-0001-000000000001', 'MX', 'México', '+52', 'MXN'),
|
|
||||||
('00000000-0000-0000-0001-000000000002', 'US', 'United States', '+1', 'USD'),
|
|
||||||
('00000000-0000-0000-0001-000000000003', 'CA', 'Canada', '+1', 'CAD'),
|
|
||||||
('00000000-0000-0000-0001-000000000004', 'ES', 'España', '+34', 'EUR'),
|
|
||||||
('00000000-0000-0000-0001-000000000005', 'DE', 'Alemania', '+49', 'EUR')
|
|
||||||
ON CONFLICT (code) DO NOTHING;
|
|
||||||
|
|
||||||
-- ===========================================
|
|
||||||
-- UOM CATEGORIES
|
|
||||||
-- ===========================================
|
|
||||||
|
|
||||||
INSERT INTO core.uom_categories (id, name, description)
|
|
||||||
VALUES
|
|
||||||
('00000000-0000-0000-0002-000000000001', 'Unit', 'Unidades individuales'),
|
|
||||||
('00000000-0000-0000-0002-000000000002', 'Weight', 'Unidades de peso'),
|
|
||||||
('00000000-0000-0000-0002-000000000003', 'Volume', 'Unidades de volumen'),
|
|
||||||
('00000000-0000-0000-0002-000000000004', 'Length', 'Unidades de longitud'),
|
|
||||||
('00000000-0000-0000-0002-000000000005', 'Time', 'Unidades de tiempo')
|
|
||||||
ON CONFLICT (name) DO NOTHING;
|
|
||||||
|
|
||||||
-- ===========================================
|
|
||||||
-- UNITS OF MEASURE
|
|
||||||
-- ===========================================
|
|
||||||
|
|
||||||
INSERT INTO core.uom (id, category_id, name, code, uom_type, factor, rounding, active)
|
|
||||||
VALUES
|
|
||||||
-- Units
|
|
||||||
('00000000-0000-0000-0003-000000000001', '00000000-0000-0000-0002-000000000001', 'Unit', 'UNIT', 'reference', 1.0, 1, true),
|
|
||||||
('00000000-0000-0000-0003-000000000002', '00000000-0000-0000-0002-000000000001', 'Dozen', 'DOZ', 'bigger', 12.0, 1, true),
|
|
||||||
('00000000-0000-0000-0003-000000000003', '00000000-0000-0000-0002-000000000001', 'Sheet', 'SHEET', 'reference', 1.0, 1, true),
|
|
||||||
-- Weight
|
|
||||||
('00000000-0000-0000-0003-000000000010', '00000000-0000-0000-0002-000000000002', 'Kilogram', 'KG', 'reference', 1.0, 0.001, true),
|
|
||||||
('00000000-0000-0000-0003-000000000011', '00000000-0000-0000-0002-000000000002', 'Gram', 'G', 'smaller', 0.001, 0.01, true),
|
|
||||||
('00000000-0000-0000-0003-000000000012', '00000000-0000-0000-0002-000000000002', 'Pound', 'LB', 'bigger', 0.453592, 0.01, true),
|
|
||||||
-- Volume
|
|
||||||
('00000000-0000-0000-0003-000000000020', '00000000-0000-0000-0002-000000000003', 'Liter', 'L', 'reference', 1.0, 0.001, true),
|
|
||||||
('00000000-0000-0000-0003-000000000021', '00000000-0000-0000-0002-000000000003', 'Milliliter', 'ML', 'smaller', 0.001, 1, true),
|
|
||||||
('00000000-0000-0000-0003-000000000022', '00000000-0000-0000-0002-000000000003', 'Gallon', 'GAL', 'bigger', 3.78541, 0.01, true),
|
|
||||||
-- Length
|
|
||||||
('00000000-0000-0000-0003-000000000030', '00000000-0000-0000-0002-000000000004', 'Meter', 'M', 'reference', 1.0, 0.001, true),
|
|
||||||
('00000000-0000-0000-0003-000000000031', '00000000-0000-0000-0002-000000000004', 'Centimeter', 'CM', 'smaller', 0.01, 0.1, true),
|
|
||||||
('00000000-0000-0000-0003-000000000032', '00000000-0000-0000-0002-000000000004', 'Inch', 'IN', 'smaller', 0.0254, 0.1, true),
|
|
||||||
-- Time
|
|
||||||
('00000000-0000-0000-0003-000000000040', '00000000-0000-0000-0002-000000000005', 'Hour', 'HOUR', 'reference', 1.0, 0.01, true),
|
|
||||||
('00000000-0000-0000-0003-000000000041', '00000000-0000-0000-0002-000000000005', 'Day', 'DAY', 'bigger', 8.0, 0.01, true),
|
|
||||||
('00000000-0000-0000-0003-000000000042', '00000000-0000-0000-0002-000000000005', 'Minute', 'MIN', 'smaller', 0.016667, 1, true)
|
|
||||||
ON CONFLICT (id) DO NOTHING;
|
|
||||||
|
|
||||||
-- Output confirmation
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
RAISE NOTICE 'Catalogs seed data loaded:';
|
|
||||||
RAISE NOTICE ' - 3 currencies (MXN, USD, EUR)';
|
|
||||||
RAISE NOTICE ' - 5 countries';
|
|
||||||
RAISE NOTICE ' - 5 UOM categories';
|
|
||||||
RAISE NOTICE ' - 15 units of measure';
|
|
||||||
END $$;
|
|
||||||
38
seeds/dev/01-seed-tenants.sql
Normal file
38
seeds/dev/01-seed-tenants.sql
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- SEED: 01-seed-tenants.sql
|
||||||
|
-- DESCRIPCION: Datos de desarrollo para tenants y usuarios
|
||||||
|
-- VERSION: 1.0.0
|
||||||
|
-- PROYECTO: ERP-Core V2
|
||||||
|
-- FECHA: 2026-01-10
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
-- Insertar tenant de desarrollo
|
||||||
|
INSERT INTO auth.tenants (id, name, slug, is_active)
|
||||||
|
VALUES
|
||||||
|
('11111111-1111-1111-1111-111111111111', 'Empresa Demo SA de CV', 'demo', TRUE),
|
||||||
|
('22222222-2222-2222-2222-222222222222', 'Tienda Pruebas', 'test-store', TRUE),
|
||||||
|
('33333333-3333-3333-3333-333333333333', 'Sucursal Norte SA', 'norte', TRUE)
|
||||||
|
ON CONFLICT (slug) DO NOTHING;
|
||||||
|
|
||||||
|
-- Insertar usuarios de desarrollo
|
||||||
|
-- Password: password123 (hash bcrypt)
|
||||||
|
INSERT INTO auth.users (id, tenant_id, email, password_hash, first_name, last_name, is_active, email_verified)
|
||||||
|
VALUES
|
||||||
|
-- Usuarios tenant Demo
|
||||||
|
('aaaa1111-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111', 'admin@demo.com', '$2b$10$EIX2KxQcTcDhxXQ8EfYWzuZs8KxMqrq7y8E8fLJ3VDLqvAF3WDK5K', 'Admin', 'Demo', TRUE, TRUE),
|
||||||
|
('aaaa2222-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111', 'vendedor@demo.com', '$2b$10$EIX2KxQcTcDhxXQ8EfYWzuZs8KxMqrq7y8E8fLJ3VDLqvAF3WDK5K', 'Juan', 'Vendedor', TRUE, TRUE),
|
||||||
|
('aaaa3333-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111', 'cajero@demo.com', '$2b$10$EIX2KxQcTcDhxXQ8EfYWzuZs8KxMqrq7y8E8fLJ3VDLqvAF3WDK5K', 'Maria', 'Cajera', TRUE, TRUE),
|
||||||
|
('aaaa4444-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111', 'almacenista@demo.com', '$2b$10$EIX2KxQcTcDhxXQ8EfYWzuZs8KxMqrq7y8E8fLJ3VDLqvAF3WDK5K', 'Pedro', 'Almacen', TRUE, TRUE),
|
||||||
|
-- Usuarios tenant Test Store
|
||||||
|
('bbbb1111-2222-2222-2222-222222222222', '22222222-2222-2222-2222-222222222222', 'admin@test.com', '$2b$10$EIX2KxQcTcDhxXQ8EfYWzuZs8KxMqrq7y8E8fLJ3VDLqvAF3WDK5K', 'Admin', 'Test', TRUE, TRUE)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Insertar personas responsables
|
||||||
|
INSERT INTO auth.persons (id, full_name, first_name, last_name, email, phone, identification_type, identification_number, is_verified, is_responsible_for_tenant)
|
||||||
|
VALUES
|
||||||
|
('eeee1111-1111-1111-1111-111111111111', 'Carlos Rodriguez Martinez', 'Carlos', 'Rodriguez', 'carlos@demo.com', '+52 55 1234 5678', 'INE', 'ROMC850101HDFRRL09', TRUE, TRUE),
|
||||||
|
('eeee2222-2222-2222-2222-222222222222', 'Ana Garcia Lopez', 'Ana', 'Garcia', 'ana@test.com', '+52 55 8765 4321', 'INE', 'GALA900515MDFRRN02', TRUE, TRUE)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Comentario
|
||||||
|
COMMENT ON TABLE auth.tenants IS 'Seeds de desarrollo cargados';
|
||||||
@ -1,49 +0,0 @@
|
|||||||
-- ============================================================================
|
|
||||||
-- ERP GENERIC - SEED DATA: TENANTS (Development)
|
|
||||||
-- ============================================================================
|
|
||||||
-- Description: Initial tenants for development environment
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- Default tenant for development
|
|
||||||
INSERT INTO auth.tenants (id, name, subdomain, schema_name, status, settings, plan, max_users, created_at)
|
|
||||||
VALUES (
|
|
||||||
'1c7dfbb0-19b8-4e87-a225-a74da6f26dbf',
|
|
||||||
'Demo Company',
|
|
||||||
'demo',
|
|
||||||
'tenant_demo',
|
|
||||||
'active',
|
|
||||||
jsonb_build_object(
|
|
||||||
'locale', 'es_MX',
|
|
||||||
'timezone', 'America/Mexico_City',
|
|
||||||
'currency', 'MXN',
|
|
||||||
'date_format', 'DD/MM/YYYY'
|
|
||||||
),
|
|
||||||
'pro',
|
|
||||||
50,
|
|
||||||
CURRENT_TIMESTAMP
|
|
||||||
) ON CONFLICT (id) DO NOTHING;
|
|
||||||
|
|
||||||
-- Second tenant for multi-tenancy testing
|
|
||||||
INSERT INTO auth.tenants (id, name, subdomain, schema_name, status, settings, plan, max_users, created_at)
|
|
||||||
VALUES (
|
|
||||||
'204c4748-09b2-4a98-bb5a-183ec263f205',
|
|
||||||
'Test Corporation',
|
|
||||||
'test-corp',
|
|
||||||
'tenant_test_corp',
|
|
||||||
'active',
|
|
||||||
jsonb_build_object(
|
|
||||||
'locale', 'en_US',
|
|
||||||
'timezone', 'America/New_York',
|
|
||||||
'currency', 'USD',
|
|
||||||
'date_format', 'MM/DD/YYYY'
|
|
||||||
),
|
|
||||||
'basic',
|
|
||||||
10,
|
|
||||||
CURRENT_TIMESTAMP
|
|
||||||
) ON CONFLICT (id) DO NOTHING;
|
|
||||||
|
|
||||||
-- Output confirmation
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
RAISE NOTICE 'Tenants seed data loaded: 2 tenants created';
|
|
||||||
END $$;
|
|
||||||
@ -1,64 +0,0 @@
|
|||||||
-- ============================================================================
|
|
||||||
-- ERP GENERIC - SEED DATA: COMPANIES (Development)
|
|
||||||
-- ============================================================================
|
|
||||||
-- Description: Initial companies for development environment
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- Default company for Demo tenant
|
|
||||||
INSERT INTO auth.companies (id, tenant_id, name, legal_name, tax_id, currency_id, settings, created_at)
|
|
||||||
VALUES (
|
|
||||||
'50fa9b29-504f-4c45-8f8a-3d129cfc6095',
|
|
||||||
'1c7dfbb0-19b8-4e87-a225-a74da6f26dbf',
|
|
||||||
'Demo Company S.A. de C.V.',
|
|
||||||
'Demo Company Sociedad Anónima de Capital Variable',
|
|
||||||
'DCO123456ABC',
|
|
||||||
(SELECT id FROM core.currencies WHERE code = 'MXN' LIMIT 1),
|
|
||||||
jsonb_build_object(
|
|
||||||
'fiscal_position', 'general',
|
|
||||||
'tax_regime', '601',
|
|
||||||
'email', 'contacto@demo-company.mx',
|
|
||||||
'phone', '+52 55 1234 5678',
|
|
||||||
'website', 'https://demo-company.mx'
|
|
||||||
),
|
|
||||||
CURRENT_TIMESTAMP
|
|
||||||
) ON CONFLICT (id) DO NOTHING;
|
|
||||||
|
|
||||||
-- Second company (subsidiary) for Demo tenant
|
|
||||||
INSERT INTO auth.companies (id, tenant_id, parent_company_id, name, legal_name, tax_id, currency_id, settings, created_at)
|
|
||||||
VALUES (
|
|
||||||
'e347be2e-483e-4ab5-8d73-5ed454e304c6',
|
|
||||||
'1c7dfbb0-19b8-4e87-a225-a74da6f26dbf',
|
|
||||||
'50fa9b29-504f-4c45-8f8a-3d129cfc6095',
|
|
||||||
'Demo Subsidiary',
|
|
||||||
'Demo Subsidiary S. de R.L.',
|
|
||||||
'DSU789012DEF',
|
|
||||||
(SELECT id FROM core.currencies WHERE code = 'MXN' LIMIT 1),
|
|
||||||
jsonb_build_object(
|
|
||||||
'email', 'subsidiary@demo-company.mx',
|
|
||||||
'phone', '+52 55 8765 4321'
|
|
||||||
),
|
|
||||||
CURRENT_TIMESTAMP
|
|
||||||
) ON CONFLICT (id) DO NOTHING;
|
|
||||||
|
|
||||||
-- Company for Test Corp tenant
|
|
||||||
INSERT INTO auth.companies (id, tenant_id, name, legal_name, tax_id, currency_id, settings, created_at)
|
|
||||||
VALUES (
|
|
||||||
'2f24ea46-7828-4125-add2-3f12644d796f',
|
|
||||||
'204c4748-09b2-4a98-bb5a-183ec263f205',
|
|
||||||
'Test Corporation Inc.',
|
|
||||||
'Test Corporation Incorporated',
|
|
||||||
'12-3456789',
|
|
||||||
(SELECT id FROM core.currencies WHERE code = 'USD' LIMIT 1),
|
|
||||||
jsonb_build_object(
|
|
||||||
'email', 'info@test-corp.com',
|
|
||||||
'phone', '+1 555 123 4567',
|
|
||||||
'website', 'https://test-corp.com'
|
|
||||||
),
|
|
||||||
CURRENT_TIMESTAMP
|
|
||||||
) ON CONFLICT (id) DO NOTHING;
|
|
||||||
|
|
||||||
-- Output confirmation
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
RAISE NOTICE 'Companies seed data loaded: 3 companies created';
|
|
||||||
END $$;
|
|
||||||
78
seeds/dev/02-seed-branches.sql
Normal file
78
seeds/dev/02-seed-branches.sql
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- SEED: 02-seed-branches.sql
|
||||||
|
-- DESCRIPCION: Datos de desarrollo para sucursales
|
||||||
|
-- VERSION: 1.0.1
|
||||||
|
-- PROYECTO: ERP-Core V2
|
||||||
|
-- FECHA: 2026-01-10
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
-- Sucursales para tenant Demo
|
||||||
|
INSERT INTO core.branches (id, tenant_id, code, name, branch_type, is_main, phone, email,
|
||||||
|
address_line1, city, state, postal_code, country, latitude, longitude, geofence_radius, is_active)
|
||||||
|
VALUES
|
||||||
|
-- Sucursales Demo
|
||||||
|
('bbbb1111-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111',
|
||||||
|
'MTZ-001', 'Matriz Centro', 'matriz', TRUE, '+52 55 1234 0001', 'matriz@demo.com',
|
||||||
|
'Av. Reforma 123', 'Ciudad de Mexico', 'CDMX', '06600', 'MEX', 19.4284, -99.1677, 100, TRUE),
|
||||||
|
|
||||||
|
('bbbb2222-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111',
|
||||||
|
'SUC-001', 'Sucursal Polanco', 'store', FALSE, '+52 55 1234 0002', 'polanco@demo.com',
|
||||||
|
'Av. Presidente Masaryk 456', 'Ciudad de Mexico', 'CDMX', '11560', 'MEX', 19.4341, -99.1918, 100, TRUE),
|
||||||
|
|
||||||
|
('bbbb3333-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111',
|
||||||
|
'SUC-002', 'Sucursal Santa Fe', 'store', FALSE, '+52 55 1234 0003', 'santafe@demo.com',
|
||||||
|
'Centro Comercial Santa Fe', 'Ciudad de Mexico', 'CDMX', '01210', 'MEX', 19.3573, -99.2611, 150, TRUE),
|
||||||
|
|
||||||
|
('bbbb4444-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111',
|
||||||
|
'ALM-001', 'Almacen Central', 'warehouse', FALSE, '+52 55 1234 0004', 'almacen@demo.com',
|
||||||
|
'Parque Industrial Vallejo', 'Ciudad de Mexico', 'CDMX', '02300', 'MEX', 19.4895, -99.1456, 200, TRUE),
|
||||||
|
|
||||||
|
-- Sucursales Test Store
|
||||||
|
('cccc1111-2222-2222-2222-222222222222', '22222222-2222-2222-2222-222222222222',
|
||||||
|
'MTZ-001', 'Tienda Principal', 'matriz', TRUE, '+52 33 1234 0001', 'principal@test.com',
|
||||||
|
'Av. Vallarta 1234', 'Guadalajara', 'Jalisco', '44100', 'MEX', 20.6769, -103.3653, 100, TRUE)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Horarios de sucursales
|
||||||
|
INSERT INTO core.branch_schedules (id, branch_id, name, schedule_type, day_of_week, open_time, close_time, is_active)
|
||||||
|
VALUES
|
||||||
|
-- Matriz Centro - Lunes a Viernes 9:00-19:00, Sabado 9:00-14:00
|
||||||
|
(gen_random_uuid(), 'bbbb1111-1111-1111-1111-111111111111', 'Lunes', 'regular', 0, '09:00', '19:00', TRUE),
|
||||||
|
(gen_random_uuid(), 'bbbb1111-1111-1111-1111-111111111111', 'Martes', 'regular', 1, '09:00', '19:00', TRUE),
|
||||||
|
(gen_random_uuid(), 'bbbb1111-1111-1111-1111-111111111111', 'Miercoles', 'regular', 2, '09:00', '19:00', TRUE),
|
||||||
|
(gen_random_uuid(), 'bbbb1111-1111-1111-1111-111111111111', 'Jueves', 'regular', 3, '09:00', '19:00', TRUE),
|
||||||
|
(gen_random_uuid(), 'bbbb1111-1111-1111-1111-111111111111', 'Viernes', 'regular', 4, '09:00', '19:00', TRUE),
|
||||||
|
(gen_random_uuid(), 'bbbb1111-1111-1111-1111-111111111111', 'Sabado', 'regular', 5, '09:00', '14:00', TRUE),
|
||||||
|
(gen_random_uuid(), 'bbbb1111-1111-1111-1111-111111111111', 'Domingo', 'regular', 6, '00:00', '00:00', FALSE),
|
||||||
|
|
||||||
|
-- Sucursal Polanco - Lunes a Sabado 10:00-20:00
|
||||||
|
(gen_random_uuid(), 'bbbb2222-1111-1111-1111-111111111111', 'Lunes', 'regular', 0, '10:00', '20:00', TRUE),
|
||||||
|
(gen_random_uuid(), 'bbbb2222-1111-1111-1111-111111111111', 'Martes', 'regular', 1, '10:00', '20:00', TRUE),
|
||||||
|
(gen_random_uuid(), 'bbbb2222-1111-1111-1111-111111111111', 'Miercoles', 'regular', 2, '10:00', '20:00', TRUE),
|
||||||
|
(gen_random_uuid(), 'bbbb2222-1111-1111-1111-111111111111', 'Jueves', 'regular', 3, '10:00', '20:00', TRUE),
|
||||||
|
(gen_random_uuid(), 'bbbb2222-1111-1111-1111-111111111111', 'Viernes', 'regular', 4, '10:00', '20:00', TRUE),
|
||||||
|
(gen_random_uuid(), 'bbbb2222-1111-1111-1111-111111111111', 'Sabado', 'regular', 5, '10:00', '20:00', TRUE),
|
||||||
|
(gen_random_uuid(), 'bbbb2222-1111-1111-1111-111111111111', 'Domingo', 'regular', 6, '11:00', '18:00', TRUE)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Asignaciones de usuarios a sucursales
|
||||||
|
INSERT INTO core.user_branch_assignments (id, user_id, branch_id, tenant_id, assignment_type, branch_role, is_active)
|
||||||
|
VALUES
|
||||||
|
-- Admin puede acceder a todas las sucursales
|
||||||
|
(gen_random_uuid(), 'aaaa1111-1111-1111-1111-111111111111', 'bbbb1111-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111', 'primary', 'admin', TRUE),
|
||||||
|
(gen_random_uuid(), 'aaaa1111-1111-1111-1111-111111111111', 'bbbb2222-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111', 'secondary', 'admin', TRUE),
|
||||||
|
(gen_random_uuid(), 'aaaa1111-1111-1111-1111-111111111111', 'bbbb3333-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111', 'secondary', 'admin', TRUE),
|
||||||
|
(gen_random_uuid(), 'aaaa1111-1111-1111-1111-111111111111', 'bbbb4444-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111', 'secondary', 'admin', TRUE),
|
||||||
|
|
||||||
|
-- Vendedor solo Polanco
|
||||||
|
(gen_random_uuid(), 'aaaa2222-1111-1111-1111-111111111111', 'bbbb2222-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111', 'primary', 'sales', TRUE),
|
||||||
|
|
||||||
|
-- Cajero en Matriz y Polanco
|
||||||
|
(gen_random_uuid(), 'aaaa3333-1111-1111-1111-111111111111', 'bbbb1111-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111', 'primary', 'cashier', TRUE),
|
||||||
|
(gen_random_uuid(), 'aaaa3333-1111-1111-1111-111111111111', 'bbbb2222-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111', 'secondary', 'cashier', TRUE),
|
||||||
|
|
||||||
|
-- Almacenista en Almacen Central
|
||||||
|
(gen_random_uuid(), 'aaaa4444-1111-1111-1111-111111111111', 'bbbb4444-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111', 'primary', 'warehouse', TRUE)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
COMMENT ON TABLE core.branches IS 'Seeds de desarrollo cargados';
|
||||||
@ -1,246 +0,0 @@
|
|||||||
-- ============================================================================
|
|
||||||
-- ERP GENERIC - SEED DATA: ROLES (Development)
|
|
||||||
-- ============================================================================
|
|
||||||
-- Description: Default roles and permissions for development
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- ===========================================
|
|
||||||
-- TENANT-SPECIFIC ROLES (Demo Company)
|
|
||||||
-- ===========================================
|
|
||||||
|
|
||||||
-- Super Admin for Demo tenant
|
|
||||||
INSERT INTO auth.roles (id, tenant_id, name, code, description, is_system, color, created_at)
|
|
||||||
VALUES (
|
|
||||||
'5e29aadd-1d9f-4280-a38b-fefe7cdece5a',
|
|
||||||
'1c7dfbb0-19b8-4e87-a225-a74da6f26dbf',
|
|
||||||
'Super Administrator',
|
|
||||||
'super_admin',
|
|
||||||
'Full system access. Reserved for system administrators.',
|
|
||||||
true,
|
|
||||||
'#FF0000',
|
|
||||||
CURRENT_TIMESTAMP
|
|
||||||
) ON CONFLICT (id) DO NOTHING;
|
|
||||||
|
|
||||||
-- Admin
|
|
||||||
INSERT INTO auth.roles (id, tenant_id, name, code, description, is_system, color, created_at)
|
|
||||||
VALUES (
|
|
||||||
'fed1cfa2-8ea1-4d86-bfef-b3dcc08801c2',
|
|
||||||
'1c7dfbb0-19b8-4e87-a225-a74da6f26dbf',
|
|
||||||
'Administrator',
|
|
||||||
'admin',
|
|
||||||
'Full access within the tenant. Can manage users, settings, and all modules.',
|
|
||||||
true,
|
|
||||||
'#4CAF50',
|
|
||||||
CURRENT_TIMESTAMP
|
|
||||||
) ON CONFLICT (id) DO NOTHING;
|
|
||||||
|
|
||||||
-- Manager
|
|
||||||
INSERT INTO auth.roles (id, tenant_id, name, code, description, is_system, color, created_at)
|
|
||||||
VALUES (
|
|
||||||
'1a35fbf0-a282-487d-95ef-13b3f702e8d6',
|
|
||||||
'1c7dfbb0-19b8-4e87-a225-a74da6f26dbf',
|
|
||||||
'Manager',
|
|
||||||
'manager',
|
|
||||||
'Can manage operations, approve documents, and view reports.',
|
|
||||||
false,
|
|
||||||
'#2196F3',
|
|
||||||
CURRENT_TIMESTAMP
|
|
||||||
) ON CONFLICT (id) DO NOTHING;
|
|
||||||
|
|
||||||
-- Accountant
|
|
||||||
INSERT INTO auth.roles (id, tenant_id, name, code, description, is_system, color, created_at)
|
|
||||||
VALUES (
|
|
||||||
'c91f1a60-bd0d-40d3-91b8-36c226ce3d29',
|
|
||||||
'1c7dfbb0-19b8-4e87-a225-a74da6f26dbf',
|
|
||||||
'Accountant',
|
|
||||||
'accountant',
|
|
||||||
'Access to financial module: journals, invoices, payments, reports.',
|
|
||||||
false,
|
|
||||||
'#9C27B0',
|
|
||||||
CURRENT_TIMESTAMP
|
|
||||||
) ON CONFLICT (id) DO NOTHING;
|
|
||||||
|
|
||||||
-- Sales
|
|
||||||
INSERT INTO auth.roles (id, tenant_id, name, code, description, is_system, color, created_at)
|
|
||||||
VALUES (
|
|
||||||
'493568ed-972f-472f-9ac1-236a32438936',
|
|
||||||
'1c7dfbb0-19b8-4e87-a225-a74da6f26dbf',
|
|
||||||
'Sales Representative',
|
|
||||||
'sales',
|
|
||||||
'Access to sales module: quotations, orders, customers.',
|
|
||||||
false,
|
|
||||||
'#FF9800',
|
|
||||||
CURRENT_TIMESTAMP
|
|
||||||
) ON CONFLICT (id) DO NOTHING;
|
|
||||||
|
|
||||||
-- Purchasing
|
|
||||||
INSERT INTO auth.roles (id, tenant_id, name, code, description, is_system, color, created_at)
|
|
||||||
VALUES (
|
|
||||||
'80515d77-fc15-4a5a-a213-7b9f869db15a',
|
|
||||||
'1c7dfbb0-19b8-4e87-a225-a74da6f26dbf',
|
|
||||||
'Purchasing Agent',
|
|
||||||
'purchasing',
|
|
||||||
'Access to purchase module: RFQs, purchase orders, vendors.',
|
|
||||||
false,
|
|
||||||
'#00BCD4',
|
|
||||||
CURRENT_TIMESTAMP
|
|
||||||
) ON CONFLICT (id) DO NOTHING;
|
|
||||||
|
|
||||||
-- Warehouse
|
|
||||||
INSERT INTO auth.roles (id, tenant_id, name, code, description, is_system, color, created_at)
|
|
||||||
VALUES (
|
|
||||||
'0a86a34a-7fd6-47e2-9e0c-4c547c6af9f1',
|
|
||||||
'1c7dfbb0-19b8-4e87-a225-a74da6f26dbf',
|
|
||||||
'Warehouse Operator',
|
|
||||||
'warehouse',
|
|
||||||
'Access to inventory module: stock moves, pickings, adjustments.',
|
|
||||||
false,
|
|
||||||
'#795548',
|
|
||||||
CURRENT_TIMESTAMP
|
|
||||||
) ON CONFLICT (id) DO NOTHING;
|
|
||||||
|
|
||||||
-- Employee (basic)
|
|
||||||
INSERT INTO auth.roles (id, tenant_id, name, code, description, is_system, color, created_at)
|
|
||||||
VALUES (
|
|
||||||
'88e299e6-8cda-4fd1-a32f-afc2aa7b8975',
|
|
||||||
'1c7dfbb0-19b8-4e87-a225-a74da6f26dbf',
|
|
||||||
'Employee',
|
|
||||||
'employee',
|
|
||||||
'Basic access: timesheets, expenses, personal information.',
|
|
||||||
false,
|
|
||||||
'#607D8B',
|
|
||||||
CURRENT_TIMESTAMP
|
|
||||||
) ON CONFLICT (id) DO NOTHING;
|
|
||||||
|
|
||||||
-- ===========================================
|
|
||||||
-- PERMISSIONS (using resource + action pattern)
|
|
||||||
-- ===========================================
|
|
||||||
|
|
||||||
INSERT INTO auth.permissions (id, resource, action, description, module, created_at)
|
|
||||||
VALUES
|
|
||||||
-- Users
|
|
||||||
('26389d69-6b88-48a5-9ca9-118394d32cd6', 'users', 'read', 'View user list and details', 'auth', CURRENT_TIMESTAMP),
|
|
||||||
('be0f398a-7c7f-4bd0-a9b7-fd74cde7e5a0', 'users', 'create', 'Create new users', 'auth', CURRENT_TIMESTAMP),
|
|
||||||
('4a584c2f-0485-453c-a93d-8c6df33e18d4', 'users', 'update', 'Edit existing users', 'auth', CURRENT_TIMESTAMP),
|
|
||||||
('4650549e-b016-438a-bf4b-5cfcb0e9d3bb', 'users', 'delete', 'Delete users', 'auth', CURRENT_TIMESTAMP),
|
|
||||||
-- Companies
|
|
||||||
('22f7d6c6-c65f-4aa4-b15c-dc6c3efd9baa', 'companies', 'read', 'View companies', 'core', CURRENT_TIMESTAMP),
|
|
||||||
('11b94a84-65f2-40f6-b468-748fbc56a30a', 'companies', 'create', 'Create companies', 'core', CURRENT_TIMESTAMP),
|
|
||||||
('3f1858a5-4381-4763-b23e-dee57e7cb3cf', 'companies', 'update', 'Edit companies', 'core', CURRENT_TIMESTAMP),
|
|
||||||
-- Partners
|
|
||||||
('abc6a21a-1674-4acf-8155-3a0d5b130586', 'partners', 'read', 'View customers/vendors', 'core', CURRENT_TIMESTAMP),
|
|
||||||
('a52fab21-24e0-446e-820f-9288b1468a36', 'partners', 'create', 'Create partners', 'core', CURRENT_TIMESTAMP),
|
|
||||||
('bd453537-ba4c-4497-a982-1c923009a399', 'partners', 'update', 'Edit partners', 'core', CURRENT_TIMESTAMP),
|
|
||||||
-- Financial - Accounting
|
|
||||||
('7a22be70-b5f7-446f-a9b9-8d6ba50615cc', 'journal_entries', 'read', 'View journal entries', 'financial', CURRENT_TIMESTAMP),
|
|
||||||
('41eb796e-952f-4e34-8811-5adc4967d8ce', 'journal_entries', 'create', 'Create journal entries', 'financial', CURRENT_TIMESTAMP),
|
|
||||||
('f5a77c95-f771-4854-8bc3-d1922f63deb7', 'journal_entries', 'approve', 'Approve/post journal entries', 'financial', CURRENT_TIMESTAMP),
|
|
||||||
-- Financial - Invoices
|
|
||||||
('546ce323-7f80-49b1-a11f-76939d2b4289', 'invoices', 'read', 'View invoices', 'financial', CURRENT_TIMESTAMP),
|
|
||||||
('139b4ed3-59e7-44d7-b4d9-7a2d02529152', 'invoices', 'create', 'Create invoices', 'financial', CURRENT_TIMESTAMP),
|
|
||||||
('dacf3592-a892-4374-82e5-7f10603c107a', 'invoices', 'approve', 'Validate invoices', 'financial', CURRENT_TIMESTAMP),
|
|
||||||
-- Inventory
|
|
||||||
('04481809-1d01-4516-afa2-dcaae8a1b331', 'products', 'read', 'View products', 'inventory', CURRENT_TIMESTAMP),
|
|
||||||
('3df9671e-db5a-4a22-b570-9210d3c0a2e3', 'products', 'create', 'Create products', 'inventory', CURRENT_TIMESTAMP),
|
|
||||||
('101f7d9f-f50f-4673-94da-d2002e65348b', 'stock_moves', 'read', 'View stock movements', 'inventory', CURRENT_TIMESTAMP),
|
|
||||||
('5e5de64d-68b6-46bc-9ec4-d34ca145b1cc', 'stock_moves', 'create', 'Create stock movements', 'inventory', CURRENT_TIMESTAMP),
|
|
||||||
-- Purchase
|
|
||||||
('7c602d68-d1d2-4ba1-b0fd-9d7b70d3f12a', 'purchase_orders', 'read', 'View purchase orders', 'purchase', CURRENT_TIMESTAMP),
|
|
||||||
('38cf2a54-60db-4ba5-8a95-fd34d2cba6cf', 'purchase_orders', 'create', 'Create purchase orders', 'purchase', CURRENT_TIMESTAMP),
|
|
||||||
('3356eb5b-538e-4bde-a12c-3b7d35ebd657', 'purchase_orders', 'approve', 'Approve purchase orders', 'purchase', CURRENT_TIMESTAMP),
|
|
||||||
-- Sales
|
|
||||||
('ffc586d2-3928-4fc7-bf72-47d52ec5e692', 'sales_orders', 'read', 'View sales orders', 'sales', CURRENT_TIMESTAMP),
|
|
||||||
('5d3a2eee-98e7-429f-b907-07452de3fb0e', 'sales_orders', 'create', 'Create sales orders', 'sales', CURRENT_TIMESTAMP),
|
|
||||||
('00481e6e-571c-475d-a4a2-81620866ff1a', 'sales_orders', 'approve', 'Confirm sales orders', 'sales', CURRENT_TIMESTAMP),
|
|
||||||
-- Reports
|
|
||||||
('c699419a-e99c-4808-abd6-c6352e2eeb67', 'reports', 'read', 'View reports', 'system', CURRENT_TIMESTAMP),
|
|
||||||
('c648cac1-d3cc-4e9b-a84a-533f28132768', 'reports', 'export', 'Export reports', 'system', CURRENT_TIMESTAMP)
|
|
||||||
ON CONFLICT (resource, action) DO NOTHING;
|
|
||||||
|
|
||||||
-- ===========================================
|
|
||||||
-- ROLE-PERMISSION ASSIGNMENTS
|
|
||||||
-- ===========================================
|
|
||||||
|
|
||||||
-- Admin role gets all permissions
|
|
||||||
INSERT INTO auth.role_permissions (role_id, permission_id, granted_at)
|
|
||||||
SELECT
|
|
||||||
'fed1cfa2-8ea1-4d86-bfef-b3dcc08801c2',
|
|
||||||
id,
|
|
||||||
CURRENT_TIMESTAMP
|
|
||||||
FROM auth.permissions
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
|
|
||||||
-- Manager role (most permissions except user management)
|
|
||||||
INSERT INTO auth.role_permissions (role_id, permission_id, granted_at)
|
|
||||||
SELECT
|
|
||||||
'1a35fbf0-a282-487d-95ef-13b3f702e8d6',
|
|
||||||
id,
|
|
||||||
CURRENT_TIMESTAMP
|
|
||||||
FROM auth.permissions
|
|
||||||
WHERE resource NOT IN ('users')
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
|
|
||||||
-- Accountant role (financial MGN-004 + read partners + reports)
|
|
||||||
INSERT INTO auth.role_permissions (role_id, permission_id, granted_at)
|
|
||||||
SELECT
|
|
||||||
'c91f1a60-bd0d-40d3-91b8-36c226ce3d29',
|
|
||||||
id,
|
|
||||||
CURRENT_TIMESTAMP
|
|
||||||
FROM auth.permissions
|
|
||||||
WHERE module = 'MGN-004'
|
|
||||||
OR (resource = 'partners' AND action = 'read')
|
|
||||||
OR (resource = 'reports')
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
|
|
||||||
-- Sales role (MGN-007 + sales + partners + read invoices/products/reports)
|
|
||||||
INSERT INTO auth.role_permissions (role_id, permission_id, granted_at)
|
|
||||||
SELECT
|
|
||||||
'493568ed-972f-472f-9ac1-236a32438936',
|
|
||||||
id,
|
|
||||||
CURRENT_TIMESTAMP
|
|
||||||
FROM auth.permissions
|
|
||||||
WHERE module IN ('sales', 'MGN-007')
|
|
||||||
OR (resource = 'partners')
|
|
||||||
OR (resource = 'invoices' AND action = 'read')
|
|
||||||
OR (resource = 'products' AND action = 'read')
|
|
||||||
OR (resource = 'reports' AND action = 'read')
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
|
|
||||||
-- Purchasing role (MGN-006 + partners + products read)
|
|
||||||
INSERT INTO auth.role_permissions (role_id, permission_id, granted_at)
|
|
||||||
SELECT
|
|
||||||
'80515d77-fc15-4a5a-a213-7b9f869db15a',
|
|
||||||
id,
|
|
||||||
CURRENT_TIMESTAMP
|
|
||||||
FROM auth.permissions
|
|
||||||
WHERE module = 'MGN-006'
|
|
||||||
OR (resource = 'partners')
|
|
||||||
OR (resource = 'products' AND action = 'read')
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
|
|
||||||
-- Warehouse role (MGN-005 inventory + products)
|
|
||||||
INSERT INTO auth.role_permissions (role_id, permission_id, granted_at)
|
|
||||||
SELECT
|
|
||||||
'0a86a34a-7fd6-47e2-9e0c-4c547c6af9f1',
|
|
||||||
id,
|
|
||||||
CURRENT_TIMESTAMP
|
|
||||||
FROM auth.permissions
|
|
||||||
WHERE module = 'MGN-005'
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
|
|
||||||
-- Employee role (basic read permissions)
|
|
||||||
INSERT INTO auth.role_permissions (role_id, permission_id, granted_at)
|
|
||||||
SELECT
|
|
||||||
'88e299e6-8cda-4fd1-a32f-afc2aa7b8975',
|
|
||||||
id,
|
|
||||||
CURRENT_TIMESTAMP
|
|
||||||
FROM auth.permissions
|
|
||||||
WHERE action = 'read'
|
|
||||||
AND resource IN ('companies', 'partners', 'products', 'reports')
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
|
|
||||||
-- Output confirmation
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
RAISE NOTICE 'Roles seed data loaded: 8 roles, 28 permissions';
|
|
||||||
END $$;
|
|
||||||
131
seeds/dev/03-seed-subscriptions.sql
Normal file
131
seeds/dev/03-seed-subscriptions.sql
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- SEED: 03-seed-subscriptions.sql
|
||||||
|
-- DESCRIPCION: Datos de desarrollo para suscripciones
|
||||||
|
-- VERSION: 1.0.0
|
||||||
|
-- PROYECTO: ERP-Core V2
|
||||||
|
-- FECHA: 2026-01-10
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
-- Obtener IDs de planes (los planes ya deben existir desde el DDL)
|
||||||
|
-- Los planes fueron insertados en 05-billing-usage.sql
|
||||||
|
|
||||||
|
-- Suscripciones para tenants de desarrollo
|
||||||
|
INSERT INTO billing.tenant_subscriptions (
|
||||||
|
id, tenant_id, plan_id, billing_cycle,
|
||||||
|
current_period_start, current_period_end, status,
|
||||||
|
billing_email, billing_name, tax_id,
|
||||||
|
current_price, contracted_users, contracted_branches,
|
||||||
|
auto_renew
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'dddd1111-1111-1111-1111-111111111111'::uuid,
|
||||||
|
'11111111-1111-1111-1111-111111111111'::uuid,
|
||||||
|
sp.id,
|
||||||
|
'monthly',
|
||||||
|
CURRENT_DATE - INTERVAL '15 days',
|
||||||
|
CURRENT_DATE + INTERVAL '15 days',
|
||||||
|
'active',
|
||||||
|
'billing@demo.com',
|
||||||
|
'Empresa Demo SA de CV',
|
||||||
|
'ABC123456XYZ',
|
||||||
|
sp.base_monthly_price,
|
||||||
|
10,
|
||||||
|
5,
|
||||||
|
TRUE
|
||||||
|
FROM billing.subscription_plans sp
|
||||||
|
WHERE sp.code = 'professional'
|
||||||
|
ON CONFLICT (tenant_id) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO billing.tenant_subscriptions (
|
||||||
|
id, tenant_id, plan_id, billing_cycle,
|
||||||
|
current_period_start, current_period_end, status,
|
||||||
|
trial_start, trial_end,
|
||||||
|
billing_email, billing_name, tax_id,
|
||||||
|
current_price, contracted_users, contracted_branches,
|
||||||
|
auto_renew
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'dddd2222-2222-2222-2222-222222222222'::uuid,
|
||||||
|
'22222222-2222-2222-2222-222222222222'::uuid,
|
||||||
|
sp.id,
|
||||||
|
'monthly',
|
||||||
|
CURRENT_DATE - INTERVAL '10 days',
|
||||||
|
CURRENT_DATE + INTERVAL '20 days',
|
||||||
|
'trial',
|
||||||
|
CURRENT_DATE - INTERVAL '10 days',
|
||||||
|
CURRENT_DATE + INTERVAL '4 days',
|
||||||
|
'billing@test.com',
|
||||||
|
'Tienda Pruebas',
|
||||||
|
'XYZ987654ABC',
|
||||||
|
0, -- Gratis durante trial
|
||||||
|
5,
|
||||||
|
1,
|
||||||
|
TRUE
|
||||||
|
FROM billing.subscription_plans sp
|
||||||
|
WHERE sp.code = 'starter'
|
||||||
|
ON CONFLICT (tenant_id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Usage Tracking para el periodo actual
|
||||||
|
INSERT INTO billing.usage_tracking (
|
||||||
|
id, tenant_id, period_start, period_end,
|
||||||
|
active_users, peak_concurrent_users, active_branches,
|
||||||
|
storage_used_gb, api_calls, sales_count, sales_amount,
|
||||||
|
mobile_sessions, payment_transactions
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
-- Uso de Demo (tenant activo)
|
||||||
|
('eeee3333-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111',
|
||||||
|
date_trunc('month', CURRENT_DATE), date_trunc('month', CURRENT_DATE) + INTERVAL '1 month' - INTERVAL '1 day',
|
||||||
|
4, 3, 4, 2.5, 15000, 250, 125000.00, 150, 75),
|
||||||
|
|
||||||
|
-- Uso de Test Store (trial)
|
||||||
|
('eeee4444-2222-2222-2222-222222222222', '22222222-2222-2222-2222-222222222222',
|
||||||
|
date_trunc('month', CURRENT_DATE), date_trunc('month', CURRENT_DATE) + INTERVAL '1 month' - INTERVAL '1 day',
|
||||||
|
1, 1, 1, 0.1, 500, 15, 3500.00, 10, 5)
|
||||||
|
ON CONFLICT (tenant_id, period_start) DO NOTHING;
|
||||||
|
|
||||||
|
-- Factura de ejemplo (pagada)
|
||||||
|
INSERT INTO billing.invoices (
|
||||||
|
id, tenant_id, subscription_id, invoice_number, invoice_date,
|
||||||
|
period_start, period_end,
|
||||||
|
billing_name, billing_email, tax_id,
|
||||||
|
subtotal, tax_amount, total, currency, status,
|
||||||
|
due_date, paid_at, paid_amount, payment_method, payment_reference
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
('ffff1111-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111',
|
||||||
|
'dddd1111-1111-1111-1111-111111111111',
|
||||||
|
'INV-2026-000001', CURRENT_DATE - INTERVAL '1 month',
|
||||||
|
CURRENT_DATE - INTERVAL '2 months', CURRENT_DATE - INTERVAL '1 month',
|
||||||
|
'Empresa Demo SA de CV', 'billing@demo.com', 'ABC123456XYZ',
|
||||||
|
999.00, 159.84, 1158.84, 'MXN', 'paid',
|
||||||
|
CURRENT_DATE - INTERVAL '25 days', CURRENT_DATE - INTERVAL '26 days', 1158.84,
|
||||||
|
'card', 'ch_1234567890')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Items de factura
|
||||||
|
INSERT INTO billing.invoice_items (
|
||||||
|
id, invoice_id, description, item_type, quantity, unit_price, subtotal
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
(gen_random_uuid(), 'ffff1111-1111-1111-1111-111111111111',
|
||||||
|
'Suscripcion Professional - Diciembre 2025', 'subscription', 1, 999.00, 999.00)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Alertas de billing de ejemplo
|
||||||
|
INSERT INTO billing.billing_alerts (
|
||||||
|
id, tenant_id, alert_type, title, message, severity, status
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
-- Alerta resuelta
|
||||||
|
(gen_random_uuid(), '11111111-1111-1111-1111-111111111111',
|
||||||
|
'payment_due', 'Pago pendiente procesado',
|
||||||
|
'Su pago del periodo anterior ha sido procesado exitosamente.',
|
||||||
|
'info', 'resolved'),
|
||||||
|
|
||||||
|
-- Alerta de trial ending para tenant test
|
||||||
|
(gen_random_uuid(), '22222222-2222-2222-2222-222222222222',
|
||||||
|
'trial_ending', 'Su periodo de prueba termina pronto',
|
||||||
|
'Su periodo de prueba terminara en 4 dias. Seleccione un plan para continuar usando el servicio.',
|
||||||
|
'warning', 'active')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
@ -1,148 +0,0 @@
|
|||||||
-- ============================================================================
|
|
||||||
-- ERP GENERIC - SEED DATA: USERS (Development)
|
|
||||||
-- ============================================================================
|
|
||||||
-- Description: Development users for testing
|
|
||||||
-- Password for all users: Test1234 (bcrypt hash)
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- Password hash for "Test1234" using bcrypt (generated with bcryptjs, 10 rounds)
|
|
||||||
-- Hash: $2a$10$EbJ0czCHXi8LatF2e7NfkeA/RLMn2R8w1ShDnHVUtQRnosEokense
|
|
||||||
-- Note: You should regenerate this in production
|
|
||||||
|
|
||||||
-- Super Admin (is_superuser=true, assigned to Demo tenant)
|
|
||||||
INSERT INTO auth.users (id, tenant_id, email, password_hash, full_name, status, is_superuser, email_verified_at, created_at)
|
|
||||||
VALUES (
|
|
||||||
'0bb44df3-ec99-4306-85e9-50c34dd7d27a',
|
|
||||||
'1c7dfbb0-19b8-4e87-a225-a74da6f26dbf',
|
|
||||||
'superadmin@erp-generic.local',
|
|
||||||
'$2a$10$EbJ0czCHXi8LatF2e7NfkeA/RLMn2R8w1ShDnHVUtQRnosEokense',
|
|
||||||
'Super Admin',
|
|
||||||
'active',
|
|
||||||
true,
|
|
||||||
CURRENT_TIMESTAMP,
|
|
||||||
CURRENT_TIMESTAMP
|
|
||||||
) ON CONFLICT (id) DO NOTHING;
|
|
||||||
|
|
||||||
-- Assign super_admin role
|
|
||||||
INSERT INTO auth.user_roles (user_id, role_id, assigned_at)
|
|
||||||
VALUES (
|
|
||||||
'0bb44df3-ec99-4306-85e9-50c34dd7d27a',
|
|
||||||
'5e29aadd-1d9f-4280-a38b-fefe7cdece5a',
|
|
||||||
CURRENT_TIMESTAMP
|
|
||||||
) ON CONFLICT DO NOTHING;
|
|
||||||
|
|
||||||
-- ===========================================
|
|
||||||
-- DEMO COMPANY USERS
|
|
||||||
-- ===========================================
|
|
||||||
|
|
||||||
-- Admin user
|
|
||||||
INSERT INTO auth.users (id, tenant_id, email, password_hash, full_name, status, email_verified_at, created_at)
|
|
||||||
VALUES (
|
|
||||||
'e6f9a1fd-2a56-496c-9dc5-f603e1a910dd',
|
|
||||||
'1c7dfbb0-19b8-4e87-a225-a74da6f26dbf',
|
|
||||||
'admin@demo-company.mx',
|
|
||||||
'$2a$10$EbJ0czCHXi8LatF2e7NfkeA/RLMn2R8w1ShDnHVUtQRnosEokense',
|
|
||||||
'Carlos Administrador',
|
|
||||||
'active',
|
|
||||||
CURRENT_TIMESTAMP,
|
|
||||||
CURRENT_TIMESTAMP
|
|
||||||
) ON CONFLICT (id) DO NOTHING;
|
|
||||||
|
|
||||||
INSERT INTO auth.user_roles (user_id, role_id, assigned_at)
|
|
||||||
VALUES ('e6f9a1fd-2a56-496c-9dc5-f603e1a910dd', 'fed1cfa2-8ea1-4d86-bfef-b3dcc08801c2', CURRENT_TIMESTAMP)
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
|
|
||||||
INSERT INTO auth.user_companies (user_id, company_id, is_default, assigned_at)
|
|
||||||
VALUES ('e6f9a1fd-2a56-496c-9dc5-f603e1a910dd', '50fa9b29-504f-4c45-8f8a-3d129cfc6095', true, CURRENT_TIMESTAMP)
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
|
|
||||||
-- Manager user
|
|
||||||
INSERT INTO auth.users (id, tenant_id, email, password_hash, full_name, status, email_verified_at, created_at)
|
|
||||||
VALUES (
|
|
||||||
'c8013936-53ad-4c6a-8f50-d7c7be1da9de',
|
|
||||||
'1c7dfbb0-19b8-4e87-a225-a74da6f26dbf',
|
|
||||||
'manager@demo-company.mx',
|
|
||||||
'$2a$10$EbJ0czCHXi8LatF2e7NfkeA/RLMn2R8w1ShDnHVUtQRnosEokense',
|
|
||||||
'María Gerente',
|
|
||||||
'active',
|
|
||||||
CURRENT_TIMESTAMP,
|
|
||||||
CURRENT_TIMESTAMP
|
|
||||||
) ON CONFLICT (id) DO NOTHING;
|
|
||||||
|
|
||||||
INSERT INTO auth.user_roles (user_id, role_id, assigned_at)
|
|
||||||
VALUES ('c8013936-53ad-4c6a-8f50-d7c7be1da9de', '1a35fbf0-a282-487d-95ef-13b3f702e8d6', CURRENT_TIMESTAMP)
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
|
|
||||||
INSERT INTO auth.user_companies (user_id, company_id, is_default, assigned_at)
|
|
||||||
VALUES ('c8013936-53ad-4c6a-8f50-d7c7be1da9de', '50fa9b29-504f-4c45-8f8a-3d129cfc6095', true, CURRENT_TIMESTAMP)
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
|
|
||||||
-- Accountant user
|
|
||||||
INSERT INTO auth.users (id, tenant_id, email, password_hash, full_name, status, email_verified_at, created_at)
|
|
||||||
VALUES (
|
|
||||||
'1110b920-a7ab-4303-aa9e-4b2fafe44f84',
|
|
||||||
'1c7dfbb0-19b8-4e87-a225-a74da6f26dbf',
|
|
||||||
'contador@demo-company.mx',
|
|
||||||
'$2a$10$EbJ0czCHXi8LatF2e7NfkeA/RLMn2R8w1ShDnHVUtQRnosEokense',
|
|
||||||
'Juan Contador',
|
|
||||||
'active',
|
|
||||||
CURRENT_TIMESTAMP,
|
|
||||||
CURRENT_TIMESTAMP
|
|
||||||
) ON CONFLICT (id) DO NOTHING;
|
|
||||||
|
|
||||||
INSERT INTO auth.user_roles (user_id, role_id, assigned_at)
|
|
||||||
VALUES ('1110b920-a7ab-4303-aa9e-4b2fafe44f84', 'c91f1a60-bd0d-40d3-91b8-36c226ce3d29', CURRENT_TIMESTAMP)
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
|
|
||||||
INSERT INTO auth.user_companies (user_id, company_id, is_default, assigned_at)
|
|
||||||
VALUES ('1110b920-a7ab-4303-aa9e-4b2fafe44f84', '50fa9b29-504f-4c45-8f8a-3d129cfc6095', true, CURRENT_TIMESTAMP)
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
|
|
||||||
-- Sales user
|
|
||||||
INSERT INTO auth.users (id, tenant_id, email, password_hash, full_name, status, email_verified_at, created_at)
|
|
||||||
VALUES (
|
|
||||||
'607fc4d8-374c-4693-b601-81f522a857ab',
|
|
||||||
'1c7dfbb0-19b8-4e87-a225-a74da6f26dbf',
|
|
||||||
'ventas@demo-company.mx',
|
|
||||||
'$2a$10$EbJ0czCHXi8LatF2e7NfkeA/RLMn2R8w1ShDnHVUtQRnosEokense',
|
|
||||||
'Ana Ventas',
|
|
||||||
'active',
|
|
||||||
CURRENT_TIMESTAMP,
|
|
||||||
CURRENT_TIMESTAMP
|
|
||||||
) ON CONFLICT (id) DO NOTHING;
|
|
||||||
|
|
||||||
INSERT INTO auth.user_roles (user_id, role_id, assigned_at)
|
|
||||||
VALUES ('607fc4d8-374c-4693-b601-81f522a857ab', '493568ed-972f-472f-9ac1-236a32438936', CURRENT_TIMESTAMP)
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
|
|
||||||
INSERT INTO auth.user_companies (user_id, company_id, is_default, assigned_at)
|
|
||||||
VALUES ('607fc4d8-374c-4693-b601-81f522a857ab', '50fa9b29-504f-4c45-8f8a-3d129cfc6095', true, CURRENT_TIMESTAMP)
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
|
|
||||||
-- Warehouse user
|
|
||||||
INSERT INTO auth.users (id, tenant_id, email, password_hash, full_name, status, email_verified_at, created_at)
|
|
||||||
VALUES (
|
|
||||||
'7c7f132b-4551-4864-bafd-36147e626bb7',
|
|
||||||
'1c7dfbb0-19b8-4e87-a225-a74da6f26dbf',
|
|
||||||
'almacen@demo-company.mx',
|
|
||||||
'$2a$10$EbJ0czCHXi8LatF2e7NfkeA/RLMn2R8w1ShDnHVUtQRnosEokense',
|
|
||||||
'Pedro Almacén',
|
|
||||||
'active',
|
|
||||||
CURRENT_TIMESTAMP,
|
|
||||||
CURRENT_TIMESTAMP
|
|
||||||
) ON CONFLICT (id) DO NOTHING;
|
|
||||||
|
|
||||||
INSERT INTO auth.user_roles (user_id, role_id, assigned_at)
|
|
||||||
VALUES ('7c7f132b-4551-4864-bafd-36147e626bb7', '0a86a34a-7fd6-47e2-9e0c-4c547c6af9f1', CURRENT_TIMESTAMP)
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
|
|
||||||
INSERT INTO auth.user_companies (user_id, company_id, is_default, assigned_at)
|
|
||||||
VALUES ('7c7f132b-4551-4864-bafd-36147e626bb7', '50fa9b29-504f-4c45-8f8a-3d129cfc6095', true, CURRENT_TIMESTAMP)
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
|
|
||||||
-- Output confirmation
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
RAISE NOTICE 'Users seed data loaded: 6 users created';
|
|
||||||
RAISE NOTICE 'Default password for all users: Test1234';
|
|
||||||
END $$;
|
|
||||||
@ -1,228 +0,0 @@
|
|||||||
-- ============================================================================
|
|
||||||
-- ERP GENERIC - SEED DATA: SAMPLE DATA (Development)
|
|
||||||
-- ============================================================================
|
|
||||||
-- Description: Sample partners, products, and transactions for testing
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- ===========================================
|
|
||||||
-- UUID REFERENCE (from previous seeds)
|
|
||||||
-- ===========================================
|
|
||||||
-- TENANT_DEMO: 1c7dfbb0-19b8-4e87-a225-a74da6f26dbf
|
|
||||||
-- COMPANY_DEMO: 50fa9b29-504f-4c45-8f8a-3d129cfc6095
|
|
||||||
|
|
||||||
-- ===========================================
|
|
||||||
-- SAMPLE PARTNERS (Customers & Vendors)
|
|
||||||
-- ===========================================
|
|
||||||
|
|
||||||
-- Customer 1 - Acme Corporation
|
|
||||||
INSERT INTO core.partners (id, tenant_id, name, legal_name, partner_type, is_customer, is_supplier, is_company, email, phone, tax_id, created_at)
|
|
||||||
VALUES (
|
|
||||||
'dda3e76c-0f92-49ea-b647-62fde7d6e1d1',
|
|
||||||
'1c7dfbb0-19b8-4e87-a225-a74da6f26dbf',
|
|
||||||
'Acme Corporation',
|
|
||||||
'Acme Corporation S.A. de C.V.',
|
|
||||||
'company',
|
|
||||||
true,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
'ventas@acme.mx',
|
|
||||||
'+52 55 1111 2222',
|
|
||||||
'ACM123456ABC',
|
|
||||||
CURRENT_TIMESTAMP
|
|
||||||
) ON CONFLICT (id) DO NOTHING;
|
|
||||||
|
|
||||||
-- Customer 2 - Tech Solutions
|
|
||||||
INSERT INTO core.partners (id, tenant_id, name, legal_name, partner_type, is_customer, is_supplier, is_company, email, phone, tax_id, created_at)
|
|
||||||
VALUES (
|
|
||||||
'78291258-da01-4560-a49e-5047d92cf11f',
|
|
||||||
'1c7dfbb0-19b8-4e87-a225-a74da6f26dbf',
|
|
||||||
'Tech Solutions',
|
|
||||||
'Tech Solutions de México S.A.',
|
|
||||||
'company',
|
|
||||||
true,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
'contacto@techsolutions.mx',
|
|
||||||
'+52 55 3333 4444',
|
|
||||||
'TSM987654XYZ',
|
|
||||||
CURRENT_TIMESTAMP
|
|
||||||
) ON CONFLICT (id) DO NOTHING;
|
|
||||||
|
|
||||||
-- Vendor 1 - Materiales del Centro
|
|
||||||
INSERT INTO core.partners (id, tenant_id, name, legal_name, partner_type, is_customer, is_supplier, is_company, email, phone, tax_id, created_at)
|
|
||||||
VALUES (
|
|
||||||
'643c97e3-bf44-40ed-bd01-ae1f5f0d861b',
|
|
||||||
'1c7dfbb0-19b8-4e87-a225-a74da6f26dbf',
|
|
||||||
'Materiales del Centro',
|
|
||||||
'Materiales del Centro S. de R.L.',
|
|
||||||
'company',
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
'ventas@materialescentro.mx',
|
|
||||||
'+52 55 5555 6666',
|
|
||||||
'MDC456789DEF',
|
|
||||||
CURRENT_TIMESTAMP
|
|
||||||
) ON CONFLICT (id) DO NOTHING;
|
|
||||||
|
|
||||||
-- Vendor 2 - Distribuidora Nacional
|
|
||||||
INSERT INTO core.partners (id, tenant_id, name, legal_name, partner_type, is_customer, is_supplier, is_company, email, phone, tax_id, created_at)
|
|
||||||
VALUES (
|
|
||||||
'79f3d083-375e-4e50-920b-a3630f74d4b1',
|
|
||||||
'1c7dfbb0-19b8-4e87-a225-a74da6f26dbf',
|
|
||||||
'Distribuidora Nacional',
|
|
||||||
'Distribuidora Nacional de Productos S.A.',
|
|
||||||
'company',
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
'pedidos@distnacional.mx',
|
|
||||||
'+52 55 7777 8888',
|
|
||||||
'DNP321654GHI',
|
|
||||||
CURRENT_TIMESTAMP
|
|
||||||
) ON CONFLICT (id) DO NOTHING;
|
|
||||||
|
|
||||||
-- ===========================================
|
|
||||||
-- SAMPLE PRODUCT CATEGORIES
|
|
||||||
-- ===========================================
|
|
||||||
|
|
||||||
INSERT INTO core.product_categories (id, tenant_id, name, code, parent_id, full_path, active, created_at)
|
|
||||||
VALUES
|
|
||||||
('f10ee8c4-e52e-41f5-93b3-a140d09dd807', '1c7dfbb0-19b8-4e87-a225-a74da6f26dbf', 'All Products', 'ALL', NULL, 'All Products', true, CURRENT_TIMESTAMP),
|
|
||||||
('b1517141-470a-4835-98ff-9250ffd18121', '1c7dfbb0-19b8-4e87-a225-a74da6f26dbf', 'Raw Materials', 'RAW', 'f10ee8c4-e52e-41f5-93b3-a140d09dd807', 'All Products / Raw Materials', true, CURRENT_TIMESTAMP),
|
|
||||||
('0b55e26b-ec64-4a80-aab3-be5a55b0ca88', '1c7dfbb0-19b8-4e87-a225-a74da6f26dbf', 'Finished Goods', 'FIN', 'f10ee8c4-e52e-41f5-93b3-a140d09dd807', 'All Products / Finished Goods', true, CURRENT_TIMESTAMP),
|
|
||||||
('e92fbdc8-998f-4bf2-8a00-c7efd3e8eb64', '1c7dfbb0-19b8-4e87-a225-a74da6f26dbf', 'Services', 'SRV', 'f10ee8c4-e52e-41f5-93b3-a140d09dd807', 'All Products / Services', true, CURRENT_TIMESTAMP)
|
|
||||||
ON CONFLICT (id) DO NOTHING;
|
|
||||||
|
|
||||||
-- ===========================================
|
|
||||||
-- SAMPLE PRODUCTS
|
|
||||||
-- ===========================================
|
|
||||||
|
|
||||||
INSERT INTO inventory.products (id, tenant_id, name, code, barcode, category_id, product_type, uom_id, cost_price, list_price, created_at)
|
|
||||||
VALUES
|
|
||||||
-- Product 1: Raw material - Steel Sheet
|
|
||||||
(
|
|
||||||
'ccbc64d7-06f9-47a1-9ad7-6dbfbbf82955',
|
|
||||||
'1c7dfbb0-19b8-4e87-a225-a74da6f26dbf',
|
|
||||||
'Steel Sheet 4x8',
|
|
||||||
'MAT-001',
|
|
||||||
'7501234567890',
|
|
||||||
'b1517141-470a-4835-98ff-9250ffd18121',
|
|
||||||
'storable',
|
|
||||||
(SELECT id FROM core.uom WHERE code = 'unit' LIMIT 1),
|
|
||||||
350.00,
|
|
||||||
500.00,
|
|
||||||
CURRENT_TIMESTAMP
|
|
||||||
),
|
|
||||||
-- Product 2: Finished good - Metal Cabinet
|
|
||||||
(
|
|
||||||
'1d4bbccb-1d83-4b15-a85d-687e378fff96',
|
|
||||||
'1c7dfbb0-19b8-4e87-a225-a74da6f26dbf',
|
|
||||||
'Metal Cabinet Large',
|
|
||||||
'PROD-001',
|
|
||||||
'7501234567891',
|
|
||||||
'0b55e26b-ec64-4a80-aab3-be5a55b0ca88',
|
|
||||||
'storable',
|
|
||||||
(SELECT id FROM core.uom WHERE code = 'unit' LIMIT 1),
|
|
||||||
1800.00,
|
|
||||||
2500.00,
|
|
||||||
CURRENT_TIMESTAMP
|
|
||||||
),
|
|
||||||
-- Product 3: Service - Installation
|
|
||||||
(
|
|
||||||
'aae17b73-5bd2-433e-bb99-d9187df398b8',
|
|
||||||
'1c7dfbb0-19b8-4e87-a225-a74da6f26dbf',
|
|
||||||
'Installation Service',
|
|
||||||
'SRV-001',
|
|
||||||
NULL,
|
|
||||||
'e92fbdc8-998f-4bf2-8a00-c7efd3e8eb64',
|
|
||||||
'service',
|
|
||||||
(SELECT id FROM core.uom WHERE code = 'h' LIMIT 1),
|
|
||||||
300.00,
|
|
||||||
500.00,
|
|
||||||
CURRENT_TIMESTAMP
|
|
||||||
)
|
|
||||||
ON CONFLICT (id) DO NOTHING;
|
|
||||||
|
|
||||||
-- ===========================================
|
|
||||||
-- SAMPLE WAREHOUSE & LOCATIONS
|
|
||||||
-- ===========================================
|
|
||||||
|
|
||||||
INSERT INTO inventory.warehouses (id, tenant_id, company_id, name, code, is_default, active, created_at)
|
|
||||||
VALUES (
|
|
||||||
'40ea2e44-31aa-4e4c-856d-e5c3dd0b942f',
|
|
||||||
'1c7dfbb0-19b8-4e87-a225-a74da6f26dbf',
|
|
||||||
'50fa9b29-504f-4c45-8f8a-3d129cfc6095',
|
|
||||||
'Main Warehouse',
|
|
||||||
'WH-MAIN',
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
CURRENT_TIMESTAMP
|
|
||||||
) ON CONFLICT (id) DO NOTHING;
|
|
||||||
|
|
||||||
INSERT INTO inventory.locations (id, tenant_id, warehouse_id, name, complete_name, location_type, active, created_at)
|
|
||||||
VALUES
|
|
||||||
('7a57d418-4ea6-47d7-a3e0-2ade4c95e240', '1c7dfbb0-19b8-4e87-a225-a74da6f26dbf', '40ea2e44-31aa-4e4c-856d-e5c3dd0b942f', 'Stock', 'WH-MAIN/Stock', 'internal', true, CURRENT_TIMESTAMP),
|
|
||||||
('3bea067b-5023-474b-88cf-97bb0461538b', '1c7dfbb0-19b8-4e87-a225-a74da6f26dbf', '40ea2e44-31aa-4e4c-856d-e5c3dd0b942f', 'Input', 'WH-MAIN/Input', 'internal', true, CURRENT_TIMESTAMP),
|
|
||||||
('8f97bcf7-a34f-406e-8292-bfb04502a4f8', '1c7dfbb0-19b8-4e87-a225-a74da6f26dbf', '40ea2e44-31aa-4e4c-856d-e5c3dd0b942f', 'Output', 'WH-MAIN/Output', 'internal', true, CURRENT_TIMESTAMP)
|
|
||||||
ON CONFLICT (id) DO NOTHING;
|
|
||||||
|
|
||||||
-- ===========================================
|
|
||||||
-- SAMPLE STOCK QUANTITIES
|
|
||||||
-- ===========================================
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'inventory' AND table_name = 'stock_quants') THEN
|
|
||||||
-- Steel Sheet 4x8 - 100 units in Stock location
|
|
||||||
PERFORM inventory.update_stock_quant(
|
|
||||||
'ccbc64d7-06f9-47a1-9ad7-6dbfbbf82955'::uuid,
|
|
||||||
'7a57d418-4ea6-47d7-a3e0-2ade4c95e240'::uuid,
|
|
||||||
NULL,
|
|
||||||
100.00
|
|
||||||
);
|
|
||||||
-- Metal Cabinet Large - 25 units in Stock location
|
|
||||||
PERFORM inventory.update_stock_quant(
|
|
||||||
'1d4bbccb-1d83-4b15-a85d-687e378fff96'::uuid,
|
|
||||||
'7a57d418-4ea6-47d7-a3e0-2ade4c95e240'::uuid,
|
|
||||||
NULL,
|
|
||||||
25.00
|
|
||||||
);
|
|
||||||
RAISE NOTICE 'Stock quantities added via update_stock_quant function';
|
|
||||||
ELSE
|
|
||||||
RAISE NOTICE 'inventory.stock_quants table does not exist, skipping stock initialization';
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
-- ===========================================
|
|
||||||
-- SAMPLE ANALYTIC ACCOUNTS
|
|
||||||
-- ===========================================
|
|
||||||
|
|
||||||
INSERT INTO analytics.analytic_plans (id, tenant_id, company_id, name, description, active, created_at)
|
|
||||||
VALUES (
|
|
||||||
'f6085dcd-dcd7-4ef5-affc-0fa3b037b1d2',
|
|
||||||
'1c7dfbb0-19b8-4e87-a225-a74da6f26dbf',
|
|
||||||
'50fa9b29-504f-4c45-8f8a-3d129cfc6095',
|
|
||||||
'Projects',
|
|
||||||
'Plan for project-based analytics',
|
|
||||||
true,
|
|
||||||
CURRENT_TIMESTAMP
|
|
||||||
) ON CONFLICT (id) DO NOTHING;
|
|
||||||
|
|
||||||
INSERT INTO analytics.analytic_accounts (id, tenant_id, company_id, plan_id, name, code, account_type, status, created_at)
|
|
||||||
VALUES
|
|
||||||
('858e16c0-773d-4cec-ac94-0241ab0c90e3', '1c7dfbb0-19b8-4e87-a225-a74da6f26dbf', '50fa9b29-504f-4c45-8f8a-3d129cfc6095', 'f6085dcd-dcd7-4ef5-affc-0fa3b037b1d2', 'Project Alpha', 'PROJ-ALPHA', 'project', 'active', CURRENT_TIMESTAMP),
|
|
||||||
('41b6a320-021d-473d-b643-038b1bb86055', '1c7dfbb0-19b8-4e87-a225-a74da6f26dbf', '50fa9b29-504f-4c45-8f8a-3d129cfc6095', 'f6085dcd-dcd7-4ef5-affc-0fa3b037b1d2', 'Project Beta', 'PROJ-BETA', 'project', 'active', CURRENT_TIMESTAMP),
|
|
||||||
('b950ada5-2f11-4dd7-a91b-5696dbb8fabc', '1c7dfbb0-19b8-4e87-a225-a74da6f26dbf', '50fa9b29-504f-4c45-8f8a-3d129cfc6095', 'f6085dcd-dcd7-4ef5-affc-0fa3b037b1d2', 'Operations', 'OPS', 'department', 'active', CURRENT_TIMESTAMP)
|
|
||||||
ON CONFLICT (id) DO NOTHING;
|
|
||||||
|
|
||||||
-- Output confirmation
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
RAISE NOTICE 'Sample data loaded:';
|
|
||||||
RAISE NOTICE ' - 4 partners (2 customers, 2 vendors)';
|
|
||||||
RAISE NOTICE ' - 4 product categories';
|
|
||||||
RAISE NOTICE ' - 3 products';
|
|
||||||
RAISE NOTICE ' - 1 warehouse with 3 locations';
|
|
||||||
RAISE NOTICE ' - 3 analytic accounts';
|
|
||||||
END $$;
|
|
||||||
Loading…
Reference in New Issue
Block a user