erp-core-database-v2/ddl/20-core-catalogs.sql
rckrdmrd 49c64e74a8 feat(catalogs): Add core catalogs DDL and seed data (MGN-005)
DDL (20-core-catalogs.sql):
- Countries, States tables with ISO codes
- Currencies with ISO 4217
- Currency rates with historical tracking
- UoM categories and units with conversion factors
- Product categories hierarchy
- Sequences, Payment terms, Discount rules

Seed data (04-seed-catalogs.sql):
- 33 countries (Americas, Europe, Asia)
- 32 Mexican states with timezones
- 16 currencies (MXN, USD, EUR, etc.)
- Initial exchange rates
- 6 UoM categories with 30+ units

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 08:57:41 -06:00

468 lines
16 KiB
PL/PgSQL

-- =============================================================
-- ARCHIVO: 20-core-catalogs.sql
-- DESCRIPCION: Catalogos maestros - paises, estados, monedas, UoM
-- VERSION: 1.0.0
-- PROYECTO: ERP-Core V2
-- FECHA: 2026-01-18
-- =============================================================
-- =====================
-- SCHEMA: core (si no existe)
-- =====================
CREATE SCHEMA IF NOT EXISTS core;
-- =====================
-- TABLA: countries
-- Paises ISO 3166-1
-- =====================
CREATE TABLE IF NOT EXISTS core.countries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(2) NOT NULL UNIQUE, -- ISO 3166-1 alpha-2
code_alpha3 VARCHAR(3), -- ISO 3166-1 alpha-3
name VARCHAR(255) NOT NULL,
phone_code VARCHAR(10),
currency_code VARCHAR(3), -- Default currency
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_countries_code ON core.countries(code);
CREATE INDEX IF NOT EXISTS idx_countries_name ON core.countries(name);
COMMENT ON TABLE core.countries IS 'Catalogo de paises ISO 3166-1';
-- =====================
-- TABLA: states
-- Estados/Provincias/Regiones
-- =====================
CREATE TABLE IF NOT EXISTS core.states (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
country_id UUID NOT NULL REFERENCES core.countries(id) ON DELETE CASCADE,
code VARCHAR(10) NOT NULL, -- Codigo del estado (ej: JAL, NL, QRO)
name VARCHAR(255) NOT NULL,
-- Datos adicionales
timezone VARCHAR(50), -- Zona horaria predominante
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE(country_id, code)
);
CREATE INDEX IF NOT EXISTS idx_states_country ON core.states(country_id);
CREATE INDEX IF NOT EXISTS idx_states_code ON core.states(code);
CREATE INDEX IF NOT EXISTS idx_states_active ON core.states(is_active) WHERE is_active = TRUE;
COMMENT ON TABLE core.states IS 'Catalogo de estados/provincias/regiones por pais';
-- =====================
-- TABLA: currencies
-- Monedas ISO 4217
-- =====================
CREATE TABLE IF NOT EXISTS 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 DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_currencies_code ON core.currencies(code);
CREATE INDEX IF NOT EXISTS idx_currencies_active ON core.currencies(active) WHERE active = TRUE;
COMMENT ON TABLE core.currencies IS 'Catalogo de monedas ISO 4217';
-- =====================
-- TABLA: currency_rates
-- Tipos de cambio historicos
-- =====================
CREATE TABLE IF NOT EXISTS core.currency_rates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES auth.tenants(id) ON DELETE CASCADE, -- NULL = global
from_currency_id UUID NOT NULL REFERENCES core.currencies(id),
to_currency_id UUID NOT NULL REFERENCES core.currencies(id),
rate DECIMAL(18, 8) NOT NULL, -- Tipo de cambio
rate_date DATE NOT NULL, -- Fecha del tipo de cambio
-- Fuente del tipo de cambio
source VARCHAR(50) DEFAULT 'manual', -- manual, banxico, xe, openexchange
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
created_by UUID REFERENCES auth.users(id),
UNIQUE(tenant_id, from_currency_id, to_currency_id, rate_date)
);
CREATE INDEX IF NOT EXISTS idx_currency_rates_tenant ON core.currency_rates(tenant_id);
CREATE INDEX IF NOT EXISTS idx_currency_rates_from ON core.currency_rates(from_currency_id);
CREATE INDEX IF NOT EXISTS idx_currency_rates_to ON core.currency_rates(to_currency_id);
CREATE INDEX IF NOT EXISTS idx_currency_rates_date ON core.currency_rates(rate_date DESC);
CREATE INDEX IF NOT EXISTS idx_currency_rates_lookup ON core.currency_rates(from_currency_id, to_currency_id, rate_date DESC);
COMMENT ON TABLE core.currency_rates IS 'Historico de tipos de cambio entre monedas';
-- =====================
-- TABLA: uom_categories
-- Categorias de unidades de medida
-- =====================
CREATE TABLE IF NOT EXISTS core.uom_categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES auth.tenants(id) ON DELETE CASCADE, -- NULL = global
name VARCHAR(100) NOT NULL,
description TEXT,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE(tenant_id, name)
);
CREATE INDEX IF NOT EXISTS idx_uom_categories_tenant ON core.uom_categories(tenant_id);
COMMENT ON TABLE core.uom_categories IS 'Categorias de unidades de medida (peso, volumen, longitud, etc.)';
-- =====================
-- TABLA: uom
-- Unidades de medida
-- =====================
CREATE TABLE IF NOT EXISTS core.uom (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES auth.tenants(id) ON DELETE CASCADE, -- NULL = global
category_id UUID NOT NULL REFERENCES core.uom_categories(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
symbol VARCHAR(20) NOT NULL,
-- Tipo: reference = unidad base de la categoria
uom_type VARCHAR(20) NOT NULL DEFAULT 'reference', -- reference, bigger, smaller
-- Factor de conversion respecto a la unidad de referencia de la categoria
factor DECIMAL(18, 8) NOT NULL DEFAULT 1,
rounding DECIMAL(12, 6) DEFAULT 0.01,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE(tenant_id, category_id, name)
);
CREATE INDEX IF NOT EXISTS idx_uom_tenant ON core.uom(tenant_id);
CREATE INDEX IF NOT EXISTS idx_uom_category ON core.uom(category_id);
CREATE INDEX IF NOT EXISTS idx_uom_active ON core.uom(is_active) WHERE is_active = TRUE;
COMMENT ON TABLE core.uom IS 'Unidades de medida con factores de conversion';
-- =====================
-- TABLA: product_categories
-- Categorias jerarquicas de productos
-- =====================
CREATE TABLE IF NOT EXISTS core.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 core.product_categories(id) ON DELETE SET NULL,
code VARCHAR(50),
name VARCHAR(255) NOT NULL,
description TEXT,
-- Jerarquia
hierarchy_path TEXT,
hierarchy_level INTEGER DEFAULT 0,
-- Configuracion
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMPTZ,
UNIQUE(tenant_id, code)
);
CREATE INDEX IF NOT EXISTS idx_product_categories_tenant ON core.product_categories(tenant_id);
CREATE INDEX IF NOT EXISTS idx_product_categories_parent ON core.product_categories(parent_id);
CREATE INDEX IF NOT EXISTS idx_product_categories_path ON core.product_categories(hierarchy_path);
CREATE INDEX IF NOT EXISTS idx_product_categories_active ON core.product_categories(is_active) WHERE is_active = TRUE;
COMMENT ON TABLE core.product_categories IS 'Categorias jerarquicas de productos';
-- =====================
-- TABLA: sequences
-- Secuencias para numeracion automatica
-- =====================
CREATE TABLE IF NOT EXISTS core.sequences (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
code VARCHAR(50) NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
-- Formato
prefix VARCHAR(20),
suffix VARCHAR(20),
padding INTEGER DEFAULT 5,
-- Contador
next_number BIGINT DEFAULT 1,
-- Reset
reset_period VARCHAR(20) DEFAULT 'never', -- never, daily, monthly, yearly
last_reset_at TIMESTAMPTZ,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE(tenant_id, code)
);
CREATE INDEX IF NOT EXISTS idx_sequences_tenant ON core.sequences(tenant_id);
CREATE INDEX IF NOT EXISTS idx_sequences_code ON core.sequences(code);
COMMENT ON TABLE core.sequences IS 'Secuencias para numeracion automatica de documentos';
-- =====================
-- TABLA: payment_terms
-- Condiciones de pago
-- =====================
CREATE TABLE IF NOT EXISTS core.payment_terms (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
code VARCHAR(50) NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
-- Configuracion
is_immediate BOOLEAN DEFAULT FALSE, -- Pago inmediato/contado
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMPTZ,
UNIQUE(tenant_id, code)
);
CREATE INDEX IF NOT EXISTS idx_payment_terms_tenant ON core.payment_terms(tenant_id);
CREATE INDEX IF NOT EXISTS idx_payment_terms_code ON core.payment_terms(code);
-- =====================
-- TABLA: payment_term_lines
-- Lineas de condiciones de pago
-- =====================
CREATE TABLE IF NOT EXISTS core.payment_term_lines (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
payment_term_id UUID NOT NULL REFERENCES core.payment_terms(id) ON DELETE CASCADE,
sequence INTEGER DEFAULT 0,
line_type VARCHAR(20) NOT NULL DEFAULT 'balance', -- percent, fixed, balance
value_percent DECIMAL(5, 2), -- Para type=percent
value_fixed DECIMAL(12, 2), -- Para type=fixed
days INTEGER DEFAULT 0, -- Dias para vencimiento
day_of_month INTEGER, -- Dia especifico del mes (1-31)
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_payment_term_lines_term ON core.payment_term_lines(payment_term_id);
COMMENT ON TABLE core.payment_term_lines IS 'Lineas que componen una condicion de pago';
-- =====================
-- TABLA: discount_rules
-- Reglas de descuento
-- =====================
CREATE TABLE IF NOT EXISTS core.discount_rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
code VARCHAR(50) NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
-- Tipo de descuento
discount_type VARCHAR(20) NOT NULL DEFAULT 'percent', -- percent, fixed
value DECIMAL(12, 2) NOT NULL,
-- A que aplica
applies_to VARCHAR(30) DEFAULT 'all', -- all, product, category, customer, order
-- Condiciones (JSONB para flexibilidad)
conditions JSONB DEFAULT '{}',
-- Ejemplo: {"min_qty": 10, "min_amount": 1000, "product_ids": [...]}
-- Vigencia
valid_from TIMESTAMPTZ,
valid_until TIMESTAMPTZ,
-- Prioridad (para resolver conflictos)
priority INTEGER DEFAULT 0,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMPTZ,
UNIQUE(tenant_id, code)
);
CREATE INDEX IF NOT EXISTS idx_discount_rules_tenant ON core.discount_rules(tenant_id);
CREATE INDEX IF NOT EXISTS idx_discount_rules_code ON core.discount_rules(code);
CREATE INDEX IF NOT EXISTS idx_discount_rules_active ON core.discount_rules(is_active) WHERE is_active = TRUE;
CREATE INDEX IF NOT EXISTS idx_discount_rules_validity ON core.discount_rules(valid_from, valid_until);
COMMENT ON TABLE core.discount_rules IS 'Reglas de descuento configurables';
-- =====================
-- RLS POLICIES
-- =====================
-- Currency rates: tenant isolation (NULL tenant = global, available to all)
ALTER TABLE core.currency_rates ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_currency_rates ON core.currency_rates
USING (
tenant_id IS NULL OR
tenant_id = current_setting('app.current_tenant_id', true)::uuid
);
-- UoM Categories: tenant isolation
ALTER TABLE core.uom_categories ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_uom_categories ON core.uom_categories
USING (
tenant_id IS NULL OR
tenant_id = current_setting('app.current_tenant_id', true)::uuid
);
-- UoM: tenant isolation
ALTER TABLE core.uom ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_uom ON core.uom
USING (
tenant_id IS NULL OR
tenant_id = current_setting('app.current_tenant_id', true)::uuid
);
-- Product Categories: tenant isolation
ALTER TABLE core.product_categories ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_product_categories ON core.product_categories
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- Sequences: tenant isolation
ALTER TABLE core.sequences ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_sequences ON core.sequences
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- Payment Terms: tenant isolation
ALTER TABLE core.payment_terms ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_payment_terms ON core.payment_terms
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- Discount Rules: tenant isolation
ALTER TABLE core.discount_rules ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_discount_rules ON core.discount_rules
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- =====================
-- FUNCIONES DE UTILIDAD
-- =====================
-- Funcion para obtener tipo de cambio mas reciente
CREATE OR REPLACE FUNCTION core.get_currency_rate(
p_from_currency VARCHAR(3),
p_to_currency VARCHAR(3),
p_date DATE DEFAULT CURRENT_DATE,
p_tenant_id UUID DEFAULT NULL
)
RETURNS DECIMAL(18, 8) AS $$
DECLARE
v_rate DECIMAL(18, 8);
BEGIN
-- Si son la misma moneda, retornar 1
IF p_from_currency = p_to_currency THEN
RETURN 1.0;
END IF;
-- Buscar tipo de cambio directo (mas reciente hasta la fecha dada)
SELECT cr.rate INTO v_rate
FROM core.currency_rates cr
JOIN core.currencies fc ON fc.id = cr.from_currency_id AND fc.code = p_from_currency
JOIN core.currencies tc ON tc.id = cr.to_currency_id AND tc.code = p_to_currency
WHERE cr.rate_date <= p_date
AND (cr.tenant_id IS NULL OR cr.tenant_id = p_tenant_id)
ORDER BY cr.rate_date DESC, cr.tenant_id DESC NULLS LAST
LIMIT 1;
IF v_rate IS NOT NULL THEN
RETURN v_rate;
END IF;
-- Buscar tipo de cambio inverso
SELECT 1.0 / cr.rate INTO v_rate
FROM core.currency_rates cr
JOIN core.currencies fc ON fc.id = cr.from_currency_id AND fc.code = p_to_currency
JOIN core.currencies tc ON tc.id = cr.to_currency_id AND tc.code = p_from_currency
WHERE cr.rate_date <= p_date
AND (cr.tenant_id IS NULL OR cr.tenant_id = p_tenant_id)
ORDER BY cr.rate_date DESC, cr.tenant_id DESC NULLS LAST
LIMIT 1;
RETURN v_rate; -- NULL si no se encuentra
END;
$$ LANGUAGE plpgsql;
-- Funcion para convertir cantidad entre UoM
CREATE OR REPLACE FUNCTION core.convert_uom(
p_quantity DECIMAL,
p_from_uom_id UUID,
p_to_uom_id UUID
)
RETURNS DECIMAL(18, 8) AS $$
DECLARE
v_from_factor DECIMAL(18, 8);
v_to_factor DECIMAL(18, 8);
v_from_category UUID;
v_to_category UUID;
BEGIN
-- Si son la misma UoM, retornar la cantidad
IF p_from_uom_id = p_to_uom_id THEN
RETURN p_quantity;
END IF;
-- Obtener factores y categorias
SELECT factor, category_id INTO v_from_factor, v_from_category
FROM core.uom WHERE id = p_from_uom_id;
SELECT factor, category_id INTO v_to_factor, v_to_category
FROM core.uom WHERE id = p_to_uom_id;
-- Validar que sean de la misma categoria
IF v_from_category != v_to_category THEN
RAISE EXCEPTION 'Cannot convert between different UoM categories';
END IF;
-- Convertir: primero a unidad de referencia, luego a destino
RETURN p_quantity * v_from_factor / v_to_factor;
END;
$$ LANGUAGE plpgsql;
-- =====================
-- FIN DEL ARCHIVO
-- =====================