-- ============================================================= -- 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 -- =====================