erp-core/database/ddl/02-core.sql
rckrdmrd 4c4e27d9ba feat: Documentation and orchestration updates
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 05:35:20 -06:00

1053 lines
35 KiB
PL/PgSQL

-- =====================================================
-- SCHEMA: core
-- PROPÓSITO: Catálogos maestros y entidades fundamentales
-- MÓDULOS: MGN-002 (Empresas), MGN-003 (Catálogos Maestros)
-- FECHA: 2025-11-24
-- =====================================================
-- Crear schema
CREATE SCHEMA IF NOT EXISTS core;
-- =====================================================
-- TYPES (ENUMs)
-- =====================================================
CREATE TYPE core.partner_type AS ENUM (
'person',
'company'
);
CREATE TYPE core.partner_category AS ENUM (
'customer',
'supplier',
'employee',
'contact',
'other'
);
CREATE TYPE core.address_type AS ENUM (
'billing',
'shipping',
'contact',
'other'
);
CREATE TYPE core.uom_type AS ENUM (
'reference',
'bigger',
'smaller'
);
-- =====================================================
-- TABLES
-- =====================================================
-- Tabla: countries (Países - ISO 3166-1)
CREATE TABLE core.countries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(2) NOT NULL UNIQUE, -- ISO 3166-1 alpha-2
name VARCHAR(255) NOT NULL,
phone_code VARCHAR(10),
currency_code VARCHAR(3), -- ISO 4217
-- Sin tenant_id: catálogo global
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Tabla: currencies (Monedas - ISO 4217)
CREATE TABLE core.currencies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(3) NOT NULL UNIQUE, -- ISO 4217
name VARCHAR(100) NOT NULL,
symbol VARCHAR(10) NOT NULL,
decimals INTEGER NOT NULL DEFAULT 2,
rounding DECIMAL(12, 6) DEFAULT 0.01,
active BOOLEAN NOT NULL DEFAULT TRUE,
-- Sin tenant_id: catálogo global
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Tabla: exchange_rates (Tasas de cambio)
CREATE TABLE core.exchange_rates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
from_currency_id UUID NOT NULL REFERENCES core.currencies(id),
to_currency_id UUID NOT NULL REFERENCES core.currencies(id),
rate DECIMAL(12, 6) NOT NULL,
date DATE NOT NULL,
-- Sin tenant_id: catálogo global
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_exchange_rates_currencies_date UNIQUE (from_currency_id, to_currency_id, date),
CONSTRAINT chk_exchange_rates_rate CHECK (rate > 0),
CONSTRAINT chk_exchange_rates_different_currencies CHECK (from_currency_id != to_currency_id)
);
-- Tabla: uom_categories (Categorías de unidades de medida)
CREATE TABLE core.uom_categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL UNIQUE,
description TEXT,
-- Sin tenant_id: catálogo global
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Tabla: uom (Unidades de medida)
CREATE TABLE core.uom (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
category_id UUID NOT NULL REFERENCES core.uom_categories(id),
name VARCHAR(100) NOT NULL,
code VARCHAR(20),
uom_type core.uom_type NOT NULL DEFAULT 'reference',
factor DECIMAL(12, 6) NOT NULL DEFAULT 1.0,
rounding DECIMAL(12, 6) DEFAULT 0.01,
active BOOLEAN NOT NULL DEFAULT TRUE,
-- Sin tenant_id: catálogo global
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_uom_name_category UNIQUE (category_id, name),
CONSTRAINT chk_uom_factor CHECK (factor > 0)
);
-- Tabla: partners (Partners universales - patrón Odoo)
CREATE TABLE core.partners (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Datos básicos
name VARCHAR(255) NOT NULL,
legal_name VARCHAR(255),
partner_type core.partner_type NOT NULL DEFAULT 'person',
-- Categorización (multiple flags como Odoo)
is_customer BOOLEAN DEFAULT FALSE,
is_supplier BOOLEAN DEFAULT FALSE,
is_employee BOOLEAN DEFAULT FALSE,
is_company BOOLEAN DEFAULT FALSE,
-- Contacto
email VARCHAR(255),
phone VARCHAR(50),
mobile VARCHAR(50),
website VARCHAR(255),
-- Fiscal
tax_id VARCHAR(50), -- RFC en México
-- Referencias
company_id UUID REFERENCES auth.companies(id),
parent_id UUID REFERENCES core.partners(id), -- Para jerarquía de contactos
user_id UUID REFERENCES auth.users(id), -- Usuario vinculado (si aplica)
-- Comercial
payment_term_id UUID, -- FK a financial.payment_terms (se crea después)
pricelist_id UUID, -- FK a sales.pricelists (se crea después)
-- Configuración
language VARCHAR(10) DEFAULT 'es',
currency_id UUID REFERENCES core.currencies(id),
-- Notas
notes TEXT,
internal_notes TEXT,
-- Control
active BOOLEAN NOT NULL DEFAULT TRUE,
-- Auditoría
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMP,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMP,
deleted_by UUID REFERENCES auth.users(id),
CONSTRAINT chk_partners_email_format CHECK (
email IS NULL OR email ~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}$'
),
CONSTRAINT chk_partners_no_self_parent CHECK (id != parent_id)
);
-- Tabla: addresses (Direcciones de partners)
CREATE TABLE core.addresses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
partner_id UUID NOT NULL REFERENCES core.partners(id) ON DELETE CASCADE,
-- Tipo de dirección
address_type core.address_type NOT NULL DEFAULT 'contact',
-- Dirección
street VARCHAR(255),
street2 VARCHAR(255),
city VARCHAR(100),
state VARCHAR(100),
zip_code VARCHAR(20),
country_id UUID REFERENCES core.countries(id),
-- Control
is_default BOOLEAN DEFAULT FALSE,
active BOOLEAN NOT NULL DEFAULT TRUE,
-- Auditoría
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMP,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMP,
deleted_by UUID REFERENCES auth.users(id)
);
-- Tabla: product_categories (Categorías de productos)
CREATE TABLE core.product_categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
code VARCHAR(50),
parent_id UUID REFERENCES core.product_categories(id),
full_path TEXT, -- Generado automáticamente: "Electrónica / Computadoras / Laptops"
-- Configuración
notes TEXT,
active BOOLEAN NOT NULL DEFAULT TRUE,
-- Auditoría
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMP,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMP,
deleted_by UUID REFERENCES auth.users(id),
CONSTRAINT uq_product_categories_code_tenant UNIQUE (tenant_id, code),
CONSTRAINT chk_product_categories_no_self_parent CHECK (id != parent_id)
);
-- Tabla: tags (Etiquetas genéricas)
CREATE TABLE core.tags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
color VARCHAR(20), -- Color hex: #FF5733
model VARCHAR(100), -- Para qué se usa: 'products', 'partners', 'tasks', etc.
description TEXT,
-- Auditoría
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by UUID REFERENCES auth.users(id),
CONSTRAINT uq_tags_name_model_tenant UNIQUE (tenant_id, name, model)
);
-- Tabla: sequences (Generación de números secuenciales)
CREATE TABLE core.sequences (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
company_id UUID REFERENCES auth.companies(id),
code VARCHAR(100) NOT NULL, -- Código único: 'sale.order', 'purchase.order', etc.
name VARCHAR(255) NOT NULL,
prefix VARCHAR(50), -- Prefijo: "SO-", "PO-", etc.
suffix VARCHAR(50), -- Sufijo: "/2025"
next_number INTEGER NOT NULL DEFAULT 1,
padding INTEGER NOT NULL DEFAULT 4, -- 0001, 0002, etc.
-- Auditoría
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMP,
updated_by UUID REFERENCES auth.users(id),
CONSTRAINT uq_sequences_code_tenant UNIQUE (tenant_id, code),
CONSTRAINT chk_sequences_next_number CHECK (next_number > 0),
CONSTRAINT chk_sequences_padding CHECK (padding >= 0)
);
-- Tabla: attachments (Archivos adjuntos genéricos)
CREATE TABLE core.attachments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Referencia polimórfica (a qué tabla/registro pertenece)
model VARCHAR(100) NOT NULL, -- 'partners', 'invoices', 'tasks', etc.
record_id UUID NOT NULL,
-- Archivo
filename VARCHAR(255) NOT NULL,
mimetype VARCHAR(100),
size_bytes BIGINT,
url VARCHAR(1000), -- URL en S3, local storage, etc.
-- Metadatos
description TEXT,
is_public BOOLEAN DEFAULT FALSE,
-- Auditoría
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMP,
deleted_by UUID REFERENCES auth.users(id),
CONSTRAINT chk_attachments_size CHECK (size_bytes >= 0)
);
-- Tabla: notes (Notas genéricas)
CREATE TABLE core.notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Referencia polimórfica
model VARCHAR(100) NOT NULL,
record_id UUID NOT NULL,
-- Nota
subject VARCHAR(255),
content TEXT NOT NULL,
-- Control
is_pinned BOOLEAN DEFAULT FALSE,
-- Auditoría
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMP,
updated_by UUID REFERENCES auth.users(id),
deleted_at TIMESTAMP,
deleted_by UUID REFERENCES auth.users(id)
);
-- =====================================================
-- INDICES
-- =====================================================
-- Countries
CREATE INDEX idx_countries_code ON core.countries(code);
CREATE INDEX idx_countries_name ON core.countries(name);
-- Currencies
CREATE INDEX idx_currencies_code ON core.currencies(code);
CREATE INDEX idx_currencies_active ON core.currencies(active) WHERE active = TRUE;
-- Exchange Rates
CREATE INDEX idx_exchange_rates_from_currency ON core.exchange_rates(from_currency_id);
CREATE INDEX idx_exchange_rates_to_currency ON core.exchange_rates(to_currency_id);
CREATE INDEX idx_exchange_rates_date ON core.exchange_rates(date DESC);
-- UoM Categories
CREATE INDEX idx_uom_categories_name ON core.uom_categories(name);
-- UoM
CREATE INDEX idx_uom_category_id ON core.uom(category_id);
CREATE INDEX idx_uom_active ON core.uom(active) WHERE active = TRUE;
-- Partners
CREATE INDEX idx_partners_tenant_id ON core.partners(tenant_id);
CREATE INDEX idx_partners_name ON core.partners(name);
CREATE INDEX idx_partners_email ON core.partners(email);
CREATE INDEX idx_partners_tax_id ON core.partners(tax_id);
CREATE INDEX idx_partners_parent_id ON core.partners(parent_id);
CREATE INDEX idx_partners_user_id ON core.partners(user_id);
CREATE INDEX idx_partners_company_id ON core.partners(company_id);
CREATE INDEX idx_partners_currency_id ON core.partners(currency_id) WHERE currency_id IS NOT NULL;
CREATE INDEX idx_partners_payment_term_id ON core.partners(payment_term_id) WHERE payment_term_id IS NOT NULL;
CREATE INDEX idx_partners_pricelist_id ON core.partners(pricelist_id) WHERE pricelist_id IS NOT NULL;
CREATE INDEX idx_partners_is_customer ON core.partners(tenant_id, is_customer) WHERE is_customer = TRUE;
CREATE INDEX idx_partners_is_supplier ON core.partners(tenant_id, is_supplier) WHERE is_supplier = TRUE;
CREATE INDEX idx_partners_is_employee ON core.partners(tenant_id, is_employee) WHERE is_employee = TRUE;
CREATE INDEX idx_partners_active ON core.partners(tenant_id, active) WHERE active = TRUE;
-- Addresses
CREATE INDEX idx_addresses_partner_id ON core.addresses(partner_id);
CREATE INDEX idx_addresses_country_id ON core.addresses(country_id);
CREATE INDEX idx_addresses_is_default ON core.addresses(partner_id, is_default) WHERE is_default = TRUE;
-- Product Categories
CREATE INDEX idx_product_categories_tenant_id ON core.product_categories(tenant_id);
CREATE INDEX idx_product_categories_parent_id ON core.product_categories(parent_id);
CREATE INDEX idx_product_categories_code ON core.product_categories(code);
-- Tags
CREATE INDEX idx_tags_tenant_id ON core.tags(tenant_id);
CREATE INDEX idx_tags_model ON core.tags(model);
CREATE INDEX idx_tags_name ON core.tags(name);
-- Sequences
CREATE INDEX idx_sequences_tenant_id ON core.sequences(tenant_id);
CREATE INDEX idx_sequences_code ON core.sequences(code);
-- Attachments
CREATE INDEX idx_attachments_tenant_id ON core.attachments(tenant_id);
CREATE INDEX idx_attachments_model_record ON core.attachments(model, record_id);
CREATE INDEX idx_attachments_created_by ON core.attachments(created_by);
-- Notes
CREATE INDEX idx_notes_tenant_id ON core.notes(tenant_id);
CREATE INDEX idx_notes_model_record ON core.notes(model, record_id);
CREATE INDEX idx_notes_created_by ON core.notes(created_by);
CREATE INDEX idx_notes_is_pinned ON core.notes(is_pinned) WHERE is_pinned = TRUE;
-- =====================================================
-- FUNCTIONS
-- =====================================================
-- Función: generate_next_sequence
-- Genera el siguiente número de secuencia
CREATE OR REPLACE FUNCTION core.generate_next_sequence(p_sequence_code VARCHAR)
RETURNS VARCHAR AS $$
DECLARE
v_sequence RECORD;
v_next_number INTEGER;
v_result VARCHAR;
BEGIN
-- Obtener secuencia y bloquear fila (SELECT FOR UPDATE)
SELECT * INTO v_sequence
FROM core.sequences
WHERE code = p_sequence_code
AND tenant_id = get_current_tenant_id()
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'Sequence % not found', p_sequence_code;
END IF;
-- Generar número
v_next_number := v_sequence.next_number;
-- Formatear resultado
v_result := COALESCE(v_sequence.prefix, '') ||
LPAD(v_next_number::TEXT, v_sequence.padding, '0') ||
COALESCE(v_sequence.suffix, '');
-- Incrementar contador
UPDATE core.sequences
SET next_number = next_number + 1,
updated_at = CURRENT_TIMESTAMP,
updated_by = get_current_user_id()
WHERE id = v_sequence.id;
RETURN v_result;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION core.generate_next_sequence IS 'Genera el siguiente número de secuencia para un código dado';
-- Función: update_product_category_path
-- Actualiza el full_path de una categoría de producto
CREATE OR REPLACE FUNCTION core.update_product_category_path()
RETURNS TRIGGER AS $$
DECLARE
v_parent_path TEXT;
BEGIN
IF NEW.parent_id IS NULL THEN
NEW.full_path := NEW.name;
ELSE
SELECT full_path INTO v_parent_path
FROM core.product_categories
WHERE id = NEW.parent_id;
NEW.full_path := v_parent_path || ' / ' || NEW.name;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION core.update_product_category_path IS 'Actualiza el path completo de la categoría al crear/actualizar';
-- Función: get_exchange_rate
-- Obtiene la tasa de cambio entre dos monedas en una fecha
CREATE OR REPLACE FUNCTION core.get_exchange_rate(
p_from_currency_id UUID,
p_to_currency_id UUID,
p_date DATE DEFAULT CURRENT_DATE
)
RETURNS DECIMAL AS $$
DECLARE
v_rate DECIMAL;
BEGIN
-- Si son la misma moneda, tasa = 1
IF p_from_currency_id = p_to_currency_id THEN
RETURN 1.0;
END IF;
-- Buscar tasa directa
SELECT rate INTO v_rate
FROM core.exchange_rates
WHERE from_currency_id = p_from_currency_id
AND to_currency_id = p_to_currency_id
AND date <= p_date
ORDER BY date DESC
LIMIT 1;
IF FOUND THEN
RETURN v_rate;
END IF;
-- Buscar tasa inversa
SELECT 1.0 / rate INTO v_rate
FROM core.exchange_rates
WHERE from_currency_id = p_to_currency_id
AND to_currency_id = p_from_currency_id
AND date <= p_date
ORDER BY date DESC
LIMIT 1;
IF FOUND THEN
RETURN v_rate;
END IF;
-- No se encontró tasa
RAISE EXCEPTION 'Exchange rate not found for currencies % to % on date %',
p_from_currency_id, p_to_currency_id, p_date;
END;
$$ LANGUAGE plpgsql STABLE;
COMMENT ON FUNCTION core.get_exchange_rate IS 'Obtiene la tasa de cambio entre dos monedas en una fecha específica';
-- =====================================================
-- TRIGGERS
-- =====================================================
-- Trigger: Actualizar updated_at en partners
CREATE TRIGGER trg_partners_updated_at
BEFORE UPDATE ON core.partners
FOR EACH ROW
EXECUTE FUNCTION auth.update_updated_at_column();
-- Trigger: Actualizar updated_at en addresses
CREATE TRIGGER trg_addresses_updated_at
BEFORE UPDATE ON core.addresses
FOR EACH ROW
EXECUTE FUNCTION auth.update_updated_at_column();
-- Trigger: Actualizar updated_at en product_categories
CREATE TRIGGER trg_product_categories_updated_at
BEFORE UPDATE ON core.product_categories
FOR EACH ROW
EXECUTE FUNCTION auth.update_updated_at_column();
-- Trigger: Actualizar updated_at en notes
CREATE TRIGGER trg_notes_updated_at
BEFORE UPDATE ON core.notes
FOR EACH ROW
EXECUTE FUNCTION auth.update_updated_at_column();
-- Trigger: Actualizar full_path en product_categories
CREATE TRIGGER trg_product_categories_update_path
BEFORE INSERT OR UPDATE OF name, parent_id ON core.product_categories
FOR EACH ROW
EXECUTE FUNCTION core.update_product_category_path();
-- =====================================================
-- ROW LEVEL SECURITY (RLS)
-- =====================================================
-- Habilitar RLS en tablas con tenant_id
ALTER TABLE core.partners ENABLE ROW LEVEL SECURITY;
ALTER TABLE core.product_categories ENABLE ROW LEVEL SECURITY;
ALTER TABLE core.tags ENABLE ROW LEVEL SECURITY;
ALTER TABLE core.sequences ENABLE ROW LEVEL SECURITY;
ALTER TABLE core.attachments ENABLE ROW LEVEL SECURITY;
ALTER TABLE core.notes ENABLE ROW LEVEL SECURITY;
-- Policy: Tenant Isolation - Partners
CREATE POLICY tenant_isolation_partners
ON core.partners
USING (tenant_id = get_current_tenant_id());
-- Policy: Tenant Isolation - Product Categories
CREATE POLICY tenant_isolation_product_categories
ON core.product_categories
USING (tenant_id = get_current_tenant_id());
-- Policy: Tenant Isolation - Tags
CREATE POLICY tenant_isolation_tags
ON core.tags
USING (tenant_id = get_current_tenant_id());
-- Policy: Tenant Isolation - Sequences
CREATE POLICY tenant_isolation_sequences
ON core.sequences
USING (tenant_id = get_current_tenant_id());
-- Policy: Tenant Isolation - Attachments
CREATE POLICY tenant_isolation_attachments
ON core.attachments
USING (tenant_id = get_current_tenant_id());
-- Policy: Tenant Isolation - Notes
CREATE POLICY tenant_isolation_notes
ON core.notes
USING (tenant_id = get_current_tenant_id());
-- =====================================================
-- SEED DATA
-- =====================================================
-- Monedas principales (ISO 4217)
INSERT INTO core.currencies (code, name, symbol, decimals) VALUES
('USD', 'US Dollar', '$', 2),
('MXN', 'Peso Mexicano', '$', 2),
('EUR', 'Euro', '', 2),
('GBP', 'British Pound', '£', 2),
('CAD', 'Canadian Dollar', '$', 2),
('JPY', 'Japanese Yen', '¥', 0),
('CNY', 'Chinese Yuan', '¥', 2),
('BRL', 'Brazilian Real', 'R$', 2),
('ARS', 'Argentine Peso', '$', 2),
('COP', 'Colombian Peso', '$', 2)
ON CONFLICT (code) DO NOTHING;
-- Países principales (ISO 3166-1)
INSERT INTO core.countries (code, name, phone_code, currency_code) VALUES
('MX', 'México', '52', 'MXN'),
('US', 'United States', '1', 'USD'),
('CA', 'Canada', '1', 'CAD'),
('GB', 'United Kingdom', '44', 'GBP'),
('FR', 'France', '33', 'EUR'),
('DE', 'Germany', '49', 'EUR'),
('ES', 'Spain', '34', 'EUR'),
('IT', 'Italy', '39', 'EUR'),
('BR', 'Brazil', '55', 'BRL'),
('AR', 'Argentina', '54', 'ARS'),
('CO', 'Colombia', '57', 'COP'),
('CL', 'Chile', '56', 'CLP'),
('PE', 'Peru', '51', 'PEN'),
('CN', 'China', '86', 'CNY'),
('JP', 'Japan', '81', 'JPY'),
('IN', 'India', '91', 'INR')
ON CONFLICT (code) DO NOTHING;
-- Categorías de UoM
INSERT INTO core.uom_categories (name, description) VALUES
('Weight', 'Unidades de peso'),
('Volume', 'Unidades de volumen'),
('Length', 'Unidades de longitud'),
('Time', 'Unidades de tiempo'),
('Unit', 'Unidades (piezas, docenas, etc.)')
ON CONFLICT (name) DO NOTHING;
-- Unidades de medida estándar
INSERT INTO core.uom (category_id, name, code, uom_type, factor)
SELECT
cat.id,
uom.name,
uom.code,
uom.uom_type::core.uom_type,
uom.factor
FROM (
-- Weight
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Weight'), 'Kilogram', 'kg', 'reference', 1.0 UNION ALL
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Weight'), 'Gram', 'g', 'smaller', 0.001 UNION ALL
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Weight'), 'Ton', 't', 'bigger', 1000.0 UNION ALL
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Weight'), 'Pound', 'lb', 'smaller', 0.453592 UNION ALL
-- Volume
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Volume'), 'Liter', 'L', 'reference', 1.0 UNION ALL
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Volume'), 'Milliliter', 'mL', 'smaller', 0.001 UNION ALL
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Volume'), 'Cubic Meter', '', 'bigger', 1000.0 UNION ALL
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Volume'), 'Gallon', 'gal', 'bigger', 3.78541 UNION ALL
-- Length
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Length'), 'Meter', 'm', 'reference', 1.0 UNION ALL
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Length'), 'Centimeter', 'cm', 'smaller', 0.01 UNION ALL
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Length'), 'Millimeter', 'mm', 'smaller', 0.001 UNION ALL
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Length'), 'Kilometer', 'km', 'bigger', 1000.0 UNION ALL
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Length'), 'Inch', 'in', 'smaller', 0.0254 UNION ALL
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Length'), 'Foot', 'ft', 'smaller', 0.3048 UNION ALL
-- Time
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Time'), 'Hour', 'h', 'reference', 1.0 UNION ALL
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Time'), 'Day', 'd', 'bigger', 24.0 UNION ALL
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Time'), 'Week', 'wk', 'bigger', 168.0 UNION ALL
-- Unit
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Unit'), 'Unit', 'unit', 'reference', 1.0 UNION ALL
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Unit'), 'Dozen', 'doz', 'bigger', 12.0 UNION ALL
SELECT (SELECT id FROM core.uom_categories WHERE name = 'Unit'), 'Pack', 'pack', 'bigger', 1.0
) AS uom(category_id, name, code, uom_type, factor)
JOIN core.uom_categories cat ON cat.id = uom.category_id
ON CONFLICT DO NOTHING;
-- =====================================================
-- COMENTARIOS EN TABLAS
-- =====================================================
COMMENT ON SCHEMA core IS 'Schema de catálogos maestros y entidades fundamentales';
COMMENT ON TABLE core.countries IS 'Catálogo de países (ISO 3166-1)';
COMMENT ON TABLE core.currencies IS 'Catálogo de monedas (ISO 4217)';
COMMENT ON TABLE core.exchange_rates IS 'Tasas de cambio históricas entre monedas';
COMMENT ON TABLE core.uom_categories IS 'Categorías de unidades de medida';
COMMENT ON TABLE core.uom IS 'Unidades de medida (peso, volumen, longitud, etc.)';
COMMENT ON TABLE core.partners IS 'Partners universales (clientes, proveedores, empleados, contactos) - patrón Odoo';
COMMENT ON TABLE core.addresses IS 'Direcciones de partners (facturación, envío, contacto)';
COMMENT ON TABLE core.product_categories IS 'Categorías jerárquicas de productos';
COMMENT ON TABLE core.tags IS 'Etiquetas genéricas para clasificar registros';
COMMENT ON TABLE core.sequences IS 'Generadores de números secuenciales automáticos';
COMMENT ON TABLE core.attachments IS 'Archivos adjuntos polimórficos (cualquier tabla/registro)';
COMMENT ON TABLE core.notes IS 'Notas polimórficas (cualquier tabla/registro)';
-- =====================================================
-- VISTAS ÚTILES
-- =====================================================
-- Vista: customers (solo partners que son clientes)
CREATE OR REPLACE VIEW core.customers_view AS
SELECT
id,
tenant_id,
name,
legal_name,
email,
phone,
mobile,
tax_id,
company_id,
active
FROM core.partners
WHERE is_customer = TRUE
AND deleted_at IS NULL;
COMMENT ON VIEW core.customers_view IS 'Vista de partners que son clientes';
-- Vista: suppliers (solo partners que son proveedores)
CREATE OR REPLACE VIEW core.suppliers_view AS
SELECT
id,
tenant_id,
name,
legal_name,
email,
phone,
tax_id,
company_id,
active
FROM core.partners
WHERE is_supplier = TRUE
AND deleted_at IS NULL;
COMMENT ON VIEW core.suppliers_view IS 'Vista de partners que son proveedores';
-- Vista: employees (solo partners que son empleados)
CREATE OR REPLACE VIEW core.employees_view AS
SELECT
p.id,
p.tenant_id,
p.name,
p.email,
p.phone,
p.user_id,
u.full_name as user_name,
p.active
FROM core.partners p
LEFT JOIN auth.users u ON p.user_id = u.id
WHERE p.is_employee = TRUE
AND p.deleted_at IS NULL;
COMMENT ON VIEW core.employees_view IS 'Vista de partners que son empleados';
-- =====================================================
-- COR-020: Duplicate Detection (Partners)
-- Sistema de deteccion de duplicados
-- =====================================================
-- Tabla: partner_duplicates (Posibles duplicados detectados)
CREATE TABLE core.partner_duplicates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Partners involucrados
partner1_id UUID NOT NULL REFERENCES core.partners(id) ON DELETE CASCADE,
partner2_id UUID NOT NULL REFERENCES core.partners(id) ON DELETE CASCADE,
-- Puntuacion de similitud (0-100)
similarity_score INTEGER NOT NULL,
-- Campos que coinciden
matching_fields JSONB DEFAULT '{}', -- {"email": true, "phone": true, "name_similarity": 0.85}
-- Estado
status VARCHAR(20) DEFAULT 'pending', -- pending, merged, ignored, false_positive
-- Resolucion
resolved_at TIMESTAMP,
resolved_by UUID REFERENCES auth.users(id),
resolution_notes TEXT,
-- Auditoria
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_partner_duplicates UNIQUE (tenant_id, partner1_id, partner2_id),
CONSTRAINT chk_partner_duplicates_different CHECK (partner1_id != partner2_id),
CONSTRAINT chk_partner_duplicates_score CHECK (similarity_score >= 0 AND similarity_score <= 100),
CONSTRAINT chk_partner_duplicates_status CHECK (status IN ('pending', 'merged', 'ignored', 'false_positive'))
);
-- Indices para partner_duplicates
CREATE INDEX idx_partner_duplicates_tenant ON core.partner_duplicates(tenant_id);
CREATE INDEX idx_partner_duplicates_partner1 ON core.partner_duplicates(partner1_id);
CREATE INDEX idx_partner_duplicates_partner2 ON core.partner_duplicates(partner2_id);
CREATE INDEX idx_partner_duplicates_status ON core.partner_duplicates(status);
CREATE INDEX idx_partner_duplicates_score ON core.partner_duplicates(similarity_score DESC);
-- RLS para partner_duplicates
ALTER TABLE core.partner_duplicates ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_partner_duplicates ON core.partner_duplicates
USING (tenant_id = get_current_tenant_id());
-- Funcion: calculate_partner_similarity
CREATE OR REPLACE FUNCTION core.calculate_partner_similarity(
p_partner1_id UUID,
p_partner2_id UUID
)
RETURNS TABLE(
similarity_score INTEGER,
matching_fields JSONB
) AS $$
DECLARE
v_p1 RECORD;
v_p2 RECORD;
v_score INTEGER := 0;
v_matches JSONB := '{}';
v_name_similarity DECIMAL;
BEGIN
-- Obtener partners
SELECT * INTO v_p1 FROM core.partners WHERE id = p_partner1_id;
SELECT * INTO v_p2 FROM core.partners WHERE id = p_partner2_id;
IF NOT FOUND THEN
RETURN QUERY SELECT 0::INTEGER, '{}'::JSONB;
RETURN;
END IF;
-- Verificar email (40 puntos)
IF v_p1.email IS NOT NULL AND v_p2.email IS NOT NULL THEN
IF LOWER(v_p1.email) = LOWER(v_p2.email) THEN
v_score := v_score + 40;
v_matches := v_matches || '{"email": true}';
END IF;
END IF;
-- Verificar telefono (20 puntos)
IF v_p1.phone IS NOT NULL AND v_p2.phone IS NOT NULL THEN
IF regexp_replace(v_p1.phone, '[^0-9]', '', 'g') = regexp_replace(v_p2.phone, '[^0-9]', '', 'g') THEN
v_score := v_score + 20;
v_matches := v_matches || '{"phone": true}';
END IF;
END IF;
-- Verificar tax_id (30 puntos)
IF v_p1.tax_id IS NOT NULL AND v_p2.tax_id IS NOT NULL THEN
IF UPPER(v_p1.tax_id) = UPPER(v_p2.tax_id) THEN
v_score := v_score + 30;
v_matches := v_matches || '{"tax_id": true}';
END IF;
END IF;
-- Verificar nombre (similarity usando trigramas - hasta 30 puntos)
-- Usamos una comparacion simple si no hay pg_trgm
IF v_p1.name IS NOT NULL AND v_p2.name IS NOT NULL THEN
IF LOWER(v_p1.name) = LOWER(v_p2.name) THEN
v_score := v_score + 30;
v_matches := v_matches || '{"name_exact": true}';
ELSIF LOWER(v_p1.name) LIKE '%' || LOWER(v_p2.name) || '%'
OR LOWER(v_p2.name) LIKE '%' || LOWER(v_p1.name) || '%' THEN
v_score := v_score + 15;
v_matches := v_matches || '{"name_partial": true}';
END IF;
END IF;
-- Normalizar score a 100 maximo
v_score := LEAST(v_score, 100);
RETURN QUERY SELECT v_score, v_matches;
END;
$$ LANGUAGE plpgsql STABLE;
COMMENT ON FUNCTION core.calculate_partner_similarity IS
'COR-020: Calcula la similitud entre dos partners para deteccion de duplicados';
-- Funcion: find_partner_duplicates
CREATE OR REPLACE FUNCTION core.find_partner_duplicates(
p_partner_id UUID,
p_min_score INTEGER DEFAULT 50
)
RETURNS TABLE(
partner_id UUID,
partner_name VARCHAR,
similarity_score INTEGER,
matching_fields JSONB
) AS $$
DECLARE
v_partner RECORD;
v_candidate RECORD;
v_result RECORD;
BEGIN
-- Obtener partner
SELECT * INTO v_partner FROM core.partners WHERE id = p_partner_id;
IF NOT FOUND THEN
RETURN;
END IF;
-- Buscar candidatos
FOR v_candidate IN
SELECT * FROM core.partners
WHERE id != p_partner_id
AND tenant_id = v_partner.tenant_id
AND deleted_at IS NULL
AND active = TRUE
-- Pre-filtro para eficiencia
AND (
email = v_partner.email
OR phone = v_partner.phone
OR tax_id = v_partner.tax_id
OR name ILIKE '%' || v_partner.name || '%'
OR v_partner.name ILIKE '%' || name || '%'
)
LOOP
SELECT * INTO v_result
FROM core.calculate_partner_similarity(p_partner_id, v_candidate.id);
IF v_result.similarity_score >= p_min_score THEN
RETURN QUERY SELECT
v_candidate.id,
v_candidate.name,
v_result.similarity_score,
v_result.matching_fields;
END IF;
END LOOP;
END;
$$ LANGUAGE plpgsql STABLE;
COMMENT ON FUNCTION core.find_partner_duplicates IS
'COR-020: Busca posibles duplicados de un partner con score minimo configurable';
-- Funcion: auto_detect_duplicates_on_create
CREATE OR REPLACE FUNCTION core.auto_detect_duplicates_on_create()
RETURNS TRIGGER AS $$
DECLARE
v_duplicate RECORD;
BEGIN
-- Solo buscar duplicados si hay datos suficientes
IF NEW.email IS NOT NULL OR NEW.phone IS NOT NULL OR NEW.tax_id IS NOT NULL THEN
FOR v_duplicate IN
SELECT * FROM core.find_partner_duplicates(NEW.id, 60)
LOOP
-- Insertar en tabla de duplicados (si no existe)
INSERT INTO core.partner_duplicates (
tenant_id, partner1_id, partner2_id,
similarity_score, matching_fields
) VALUES (
NEW.tenant_id,
LEAST(NEW.id, v_duplicate.partner_id),
GREATEST(NEW.id, v_duplicate.partner_id),
v_duplicate.similarity_score,
v_duplicate.matching_fields
) ON CONFLICT (tenant_id, partner1_id, partner2_id) DO UPDATE
SET similarity_score = EXCLUDED.similarity_score,
matching_fields = EXCLUDED.matching_fields;
END LOOP;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION core.auto_detect_duplicates_on_create IS
'COR-020: Trigger para detectar duplicados automaticamente al crear partner';
-- Trigger: Detectar duplicados al crear partner
CREATE TRIGGER trg_partners_detect_duplicates
AFTER INSERT ON core.partners
FOR EACH ROW
EXECUTE FUNCTION core.auto_detect_duplicates_on_create();
COMMENT ON TABLE core.partner_duplicates IS 'COR-020: Posibles duplicados de partners detectados';
-- =====================================================
-- COR-021: States/Provinces
-- Equivalente a res.country.state de Odoo
-- =====================================================
CREATE TABLE core.states (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
country_id UUID NOT NULL REFERENCES core.countries(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
code VARCHAR(10) NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(country_id, code)
);
CREATE INDEX idx_states_country ON core.states(country_id);
CREATE INDEX idx_states_name ON core.states(name);
COMMENT ON TABLE core.states IS 'COR-021: States/Provinces - Equivalent to res.country.state';
-- Agregar state_id a partners y addresses
ALTER TABLE core.partners ADD COLUMN IF NOT EXISTS state_id UUID REFERENCES core.states(id);
ALTER TABLE core.addresses ADD COLUMN IF NOT EXISTS state_id UUID REFERENCES core.states(id);
-- =====================================================
-- COR-022: Banks and Partner Bank Accounts
-- Equivalente a res.bank y res.partner.bank de Odoo
-- =====================================================
-- Tabla: banks (Catalogo de bancos)
CREATE TABLE core.banks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
bic VARCHAR(11), -- SWIFT/BIC code
country_id UUID REFERENCES core.countries(id),
street VARCHAR(255),
city VARCHAR(100),
zip VARCHAR(20),
phone VARCHAR(50),
email VARCHAR(255),
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_banks_country ON core.banks(country_id);
CREATE UNIQUE INDEX idx_banks_bic ON core.banks(bic) WHERE bic IS NOT NULL;
COMMENT ON TABLE core.banks IS 'COR-022: Banks catalog - Equivalent to res.bank';
-- Tabla: partner_banks (Cuentas bancarias de partners)
CREATE TABLE core.partner_banks (
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) ON DELETE CASCADE,
bank_id UUID REFERENCES core.banks(id),
acc_number VARCHAR(64) NOT NULL,
acc_holder_name VARCHAR(255),
sequence INTEGER DEFAULT 10,
currency_id UUID REFERENCES core.currencies(id),
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_partner_banks_tenant ON core.partner_banks(tenant_id);
CREATE INDEX idx_partner_banks_partner ON core.partner_banks(partner_id);
CREATE INDEX idx_partner_banks_bank ON core.partner_banks(bank_id);
-- RLS para partner_banks
ALTER TABLE core.partner_banks ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_partner_banks ON core.partner_banks
USING (tenant_id = get_current_tenant_id());
COMMENT ON TABLE core.partner_banks IS 'COR-022: Partner bank accounts - Equivalent to res.partner.bank';
-- =====================================================
-- FIN DEL SCHEMA CORE
-- =====================================================