💻 Galería de Código
Exploración interactiva del código desarrollado durante la migración. 269 funciones PostgreSQL y 1,215 tests unitarios que implementan el modelo cliente/servidor con precisión matemática.
Funciones PostgreSQL
Validaciones, cálculos y generación automática de códigos
Tests Exhaustivos
Cobertura 100% con pgTAP y tests comparativos
Documentación Completa
Código comentado y patrones reutilizables documentados
💻 Resumen del Código Desarrollado
📋 Categorías de Funciones
Funciones organizadas por tipo de funcionalidad implementada.
Validación
Triggers BEFORE INSERT/UPDATE que validan datos de entrada, formatos de email, códigos únicos y reglas de negocio.
Cálculos
Triggers AFTER que realizan cálculos automáticos de totales, precios con IVA, multiprecios y saldos bancarios.
Generación
Funciones que generan automáticamente códigos únicos, referencias y elementos calculados según reglas de negocio.
Auditoría
Funciones de tracking, logging y auditoría que mantienen históricos automáticos y trazabilidad completa.
🔧 Galería de Funciones PostgreSQL
Exploración interactiva de las funciones implementadas organizadas por módulo.
👥 Módulo Societe
llx_societe_before_insert()
ValidaciónCREATE OR REPLACE FUNCTION llx_societe_before_insert()
RETURNS trigger AS $$
BEGIN
-- Validar nombre obligatorio
IF NEW.nom IS NULL OR trim(NEW.nom) = '' THEN
RAISE EXCEPTION 'ErrorFieldRequired: nom';
END IF;
-- Validar email
IF NEW.email IS NOT NULL AND NEW.email != '' THEN
NEW.email = trim(lower(NEW.email));
IF NOT (NEW.email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$') THEN
RAISE EXCEPTION 'ErrorBadEMail: %', NEW.email;
END IF;
END IF;
-- Generar código cliente automáticamente
IF NEW.client = 1 AND (NEW.code_client IS NULL OR trim(NEW.code_client) = '') THEN
NEW.code_client := llx_societe_get_next_code('C', NEW.entity);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
llx_societe_get_next_code()
GeneraciónCREATE OR REPLACE FUNCTION llx_societe_get_next_code(
p_type varchar DEFAULT 'C',
p_entity integer DEFAULT 1
) RETURNS varchar AS $$
DECLARE
v_current_num integer;
v_new_code varchar(50);
BEGIN
-- Buscar el siguiente número según el tipo
CASE p_type
WHEN 'C' THEN
SELECT COALESCE(MAX(
CAST(substring(code_client from '[0-9]+$') AS integer)
), 0) + 1 INTO v_current_num
FROM llx_societe
WHERE code_client ~ '^CU[0-9]+$' AND entity = p_entity;
v_new_code := 'CU' || lpad(v_current_num::text, 4, '0');
WHEN 'F' THEN
SELECT COALESCE(MAX(
CAST(substring(code_fournisseur from '[0-9]+$') AS integer)
), 0) + 1 INTO v_current_num
FROM llx_societe
WHERE code_fournisseur ~ '^FO[0-9]+$' AND entity = p_entity;
v_new_code := 'FO' || lpad(v_current_num::text, 4, '0');
END CASE;
RETURN v_new_code;
END;
$$ LANGUAGE plpgsql;
llx_societe_id_prof_exists()
ValidaciónCREATE OR REPLACE FUNCTION llx_societe_id_prof_exists(
p_field varchar,
p_value varchar,
p_id integer DEFAULT 0
) RETURNS boolean AS $$
DECLARE
v_count integer;
BEGIN
IF p_value IS NULL OR trim(p_value) = '' THEN
RETURN false;
END IF;
EXECUTE format('SELECT COUNT(*) FROM llx_societe WHERE %I = $1 AND rowid != $2', p_field)
INTO v_count
USING p_value, p_id;
RETURN v_count > 0;
END;
$$ LANGUAGE plpgsql;
📦 Módulo Product
llx_product_before_insert()
CálculosCREATE OR REPLACE FUNCTION llx_product_before_insert()
RETURNS trigger AS $$
BEGIN
-- Validar referencia obligatoria
IF NEW.ref IS NULL OR trim(NEW.ref) = '' THEN
RAISE EXCEPTION 'ErrorFieldRequired: ref';
END IF;
-- Validar unicidad de referencia
IF EXISTS (SELECT 1 FROM llx_product WHERE ref = NEW.ref AND entity = NEW.entity) THEN
RAISE EXCEPTION 'ErrorRefAlreadyExists: %', NEW.ref;
END IF;
-- Calcular precio TTC automáticamente
IF NEW.price IS NOT NULL AND NEW.tva_tx IS NOT NULL THEN
NEW.price_ttc := NEW.price * (1 + NEW.tva_tx / 100);
END IF;
-- Valores por defecto
NEW.entity := COALESCE(NEW.entity, 1);
NEW.tosell := COALESCE(NEW.tosell, 1);
NEW.tobuy := COALESCE(NEW.tobuy, 1);
NEW.datec := COALESCE(NEW.datec, NOW());
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
llx_product_manage_price_history()
AuditoríaCREATE OR REPLACE FUNCTION llx_product_manage_price_history()
RETURNS trigger AS $$
BEGIN
-- Solo crear histórico si cambió el precio o IVA
IF OLD.price IS DISTINCT FROM NEW.price OR
OLD.price_ttc IS DISTINCT FROM NEW.price_ttc OR
OLD.tva_tx IS DISTINCT FROM NEW.tva_tx THEN
INSERT INTO llx_product_price (
fk_product, date_price, price, price_ttc, tva_tx,
fk_user_author, entity
) VALUES (
NEW.rowid, NOW(), NEW.price, NEW.price_ttc, NEW.tva_tx,
COALESCE(NEW.fk_user_modif, NEW.fk_user_author, 1), NEW.entity
);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
📋 Módulo Propale
llx_propal_get_next_ref()
GeneraciónCREATE OR REPLACE FUNCTION llx_propal_get_next_ref(p_entity integer DEFAULT 1)
RETURNS varchar AS $$
DECLARE
v_current_year varchar(2);
v_current_month varchar(2);
v_current_num integer;
v_new_ref varchar(30);
BEGIN
-- Obtener año y mes actual
v_current_year := to_char(CURRENT_DATE, 'YY');
v_current_month := to_char(CURRENT_DATE, 'MM');
-- Buscar el siguiente número para este año-mes
SELECT COALESCE(MAX(
CAST(
CASE
WHEN ref ~ '^PR[0-9]{2}[0-9]{2}-[0-9]{4}$'
THEN substring(ref from 8 for 4)
ELSE '0'
END AS integer
)
), 0) + 1 INTO v_current_num
FROM llx_propal
WHERE ref ~ ('^PR' || v_current_year || v_current_month || '-[0-9]{4}$')
AND entity = p_entity;
-- Generar nueva referencia con formato PR-YYMM-NNNN
v_new_ref := 'PR' || v_current_year || v_current_month || '-' ||
lpad(v_current_num::text, 4, '0');
RETURN v_new_ref;
END;
$$ LANGUAGE plpgsql;
llx_propal_update_totals()
CálculosCREATE OR REPLACE FUNCTION llx_propal_update_totals(p_propal_id integer)
RETURNS void AS $$
DECLARE
v_total_ht numeric(24,8) := 0;
v_total_tva numeric(24,8) := 0;
v_total_ttc numeric(24,8) := 0;
BEGIN
-- Calcular totales desde las líneas de detalle
SELECT
COALESCE(SUM(total_ht), 0),
COALESCE(SUM(total_tva), 0),
COALESCE(SUM(total_ttc), 0)
INTO v_total_ht, v_total_tva, v_total_ttc
FROM llx_propaldet
WHERE fk_propal = p_propal_id;
-- Actualizar la cabecera del presupuesto
UPDATE llx_propal
SET
total_ht = v_total_ht,
total_tva = v_total_tva,
total_ttc = v_total_ttc,
tms = NOW()
WHERE rowid = p_propal_id;
END;
$$ LANGUAGE plpgsql;
📦 Módulo Commande
llx_commande_before_update()
ValidaciónCREATE OR REPLACE FUNCTION llx_commande_before_update()
RETURNS trigger AS $$
BEGIN
-- Control de transiciones de estado
IF OLD.fk_statut != NEW.fk_statut THEN
-- No permitir volver a borrador una vez validado
IF OLD.fk_statut >= 1 AND NEW.fk_statut = 0 THEN
RAISE EXCEPTION 'No se puede volver a borrador un pedido validado';
END IF;
-- No permitir modificar pedidos cerrados
IF OLD.fk_statut = 3 THEN
RAISE EXCEPTION 'No se puede modificar un pedido cerrado';
END IF;
-- Establecer fecha de validación al validar
IF NEW.fk_statut = 1 AND OLD.fk_statut = 0 THEN
NEW.date_valid := COALESCE(NEW.date_valid, NOW());
-- Generar referencia definitiva al validar
IF NEW.ref LIKE '(PROV%' THEN
NEW.ref := llx_commande_get_next_ref(NEW.entity, NEW.fk_soc);
END IF;
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
🧪 Galería de Tests pgTAP
Tests unitarios exhaustivos que garantizan el comportamiento correcto de todas las funciones.
🔍 Tests de Existencia
Verifican que todas las funciones y triggers están creados correctamente.
-- Verificar que las funciones existen
SELECT has_function('llx_societe_before_insert', 'Función before_insert debe existir');
SELECT has_function('llx_societe_get_next_code', ARRAY['varchar', 'integer'], 'Función get_next_code debe existir');
-- Verificar que los triggers existen
SELECT has_trigger('llx_societe', 'trg_societe_before_insert', 'Trigger before insert debe existir');
SELECT has_trigger('llx_societe', 'trg_societe_before_update', 'Trigger before update debe existir');
✅ Tests de Validación
Comprueban que las validaciones funcionan correctamente, tanto en casos exitosos como fallidos.
-- Test: Campo obligatorio vacío debe fallar
PREPARE test_nom_vacio AS
INSERT INTO llx_societe (nom, entity) VALUES ('', 1);
SELECT throws_ok(
'test_nom_vacio',
'P0001',
'ErrorFieldRequired: nom',
'Debe fallar con nombre vacío'
);
-- Test: Email inválido debe fallar
PREPARE test_email_invalido AS
INSERT INTO llx_societe (nom, email, entity) VALUES ('Test', 'email_malo', 1);
SELECT throws_ok(
'test_email_invalido',
'P0001',
'ErrorBadEMail: email_malo',
'Debe fallar con email inválido'
);
🧮 Tests de Cálculos
Validan que todos los cálculos automáticos producen resultados correctos.
-- Test: Cálculo automático de precio TTC
INSERT INTO llx_product (ref, label, price, tva_tx, entity, fk_user_author)
VALUES ('TEST-CALC', 'Producto Test Cálculo', 100.00, 21.00, 1, 1);
SELECT is(
(SELECT price_ttc FROM llx_product WHERE ref = 'TEST-CALC'),
121.00::double precision,
'price_ttc debe calcularse automáticamente: 100 * 1.21 = 121'
);
-- Test: Actualización de totales en presupuesto
SELECT is(
(SELECT total_ht FROM llx_propal WHERE rowid = v_propal_id),
200.00::numeric(24,8),
'total_ht debe ser la suma de líneas'
);
🏷️ Tests de Generación
Verifican que la generación automática de códigos produce referencias únicas y consecutivas.
-- Test: Generación de código único
SELECT matches(
llx_societe_get_next_code('C', 1),
'^CU[0-9]{4}$',
'Código cliente debe seguir formato CU0000'
);
-- Test: Referencias consecutivas
DO $$
DECLARE
v_ref1 varchar(30);
v_ref2 varchar(30);
v_num1 integer;
v_num2 integer;
BEGIN
v_ref1 := llx_propal_get_next_ref(1);
v_ref2 := llx_propal_get_next_ref(1);
v_num1 := substring(v_ref1 from 8 for 4)::integer;
v_num2 := substring(v_ref2 from 8 for 4)::integer;
PERFORM is(v_num2, v_num1 + 1, 'Referencias deben ser consecutivas');
END $$;
📁 Archivos de Código
Estructura completa de archivos implementados en el proyecto.