🎯 Patrones y Lecciones
Biblioteca completa de patrones reutilizables y lecciones críticas destiladas durante la migración de 17 módulos + 5 migraciones avanzadas. Conocimiento práctico para acelerar futuras implementaciones.
Patrones Probados
35 patrones PostgreSQL validados en producción
Errores Documentados
Problemas comunes identificados y solucionados
Soluciones Listas
Código copy-paste para implementaciones futuras
🎯 Patrones PostgreSQL Reutilizables
Colección de patrones probados y optimizados desarrollados durante la migración de 17 módulos.
🔍 Patrón de Validación Base
Usado en 17 módulosPropósito: Validación estándar para campos obligatorios, email, limpieza de datos y valores por defecto.
Código del Patrón:
CREATE OR REPLACE FUNCTION llx_{tabla}_before_insert()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
-- 1. Validación de campos obligatorios
IF NEW.campo_obligatorio IS NULL OR trim(NEW.campo_obligatorio) = '' THEN
RAISE EXCEPTION 'ErrorFieldRequired: campo_obligatorio';
END IF;
-- 2. Limpieza automática de datos
NEW.campo_texto = trim(NEW.campo_texto);
-- 3. Validación de email con regex
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;
-- 4. Formateo de teléfonos
IF NEW.phone IS NOT NULL THEN
NEW.phone = regexp_replace(NEW.phone, '[^0-9+\-\.\s()]', '', 'g');
NEW.phone = trim(NEW.phone);
END IF;
-- 5. Valores por defecto
NEW.entity := COALESCE(NEW.entity, 1);
NEW.status := COALESCE(NEW.status, 1);
NEW.datec := COALESCE(NEW.datec, NOW());
-- 6. Validación de valores numéricos
IF NEW.campo_numerico IS NOT NULL AND NEW.campo_numerico < 0 THEN
RAISE EXCEPTION 'El campo no puede ser negativo';
END IF;
RETURN NEW;
END;
$$;
✅ Beneficios:
- Validación automática garantizada
- Datos siempre limpios y normalizados
- Mensajes de error consistentes
- Facilita debugging y mantenimiento
🏷️ Patrón de Generación de Códigos
Usado en 7 módulosPropósito: Generación automática de códigos únicos y consecutivos con formatos personalizables.
Variantes Implementadas:
📅 Formato Año-Mes-Secuencia (PR2501-0001)
CREATE OR REPLACE FUNCTION llx_tabla_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_tabla
WHERE ref ~ ('^PR' || v_current_year || v_current_month || '-[0-9]{4}$')
AND entity = p_entity;
-- Generar nueva referencia
v_new_ref := 'PR' || v_current_year || v_current_month || '-' ||
lpad(v_current_num::text, 4, '0');
RETURN v_new_ref;
END;
$$ LANGUAGE plpgsql;
🔢 Formato Prefijo + Secuencia (CU0001)
CREATE OR REPLACE FUNCTION llx_tabla_get_next_code(
p_prefix varchar,
p_entity integer DEFAULT 1
) RETURNS varchar AS $$
DECLARE
v_current_num integer;
v_new_code varchar(50);
BEGIN
-- Buscar el siguiente número para el prefijo
SELECT COALESCE(MAX(
CAST(substring(code_field from '[0-9]+$') AS integer)
), 0) + 1 INTO v_current_num
FROM llx_tabla
WHERE code_field ~ ('^' || p_prefix || '[0-9]+$')
AND entity = p_entity;
-- Generar nuevo código
v_new_code := p_prefix || lpad(v_current_num::text, 4, '0');
RETURN v_new_code;
END;
$$ LANGUAGE plpgsql;
🧮 Patrón de Cálculos Automáticos
Usado en 6 módulosPropósito: Recálculo automático de totales, subtotales, precios con IVA y descuentos.
Ejemplos de Cálculos:
💰 Cálculo de Línea de Detalle
-- Trigger para líneas de presupuesto/pedido
CREATE OR REPLACE FUNCTION llx_ligne_before_insert_update()
RETURNS trigger AS $$
BEGIN
-- Calcular total HT de la línea
NEW.total_ht := NEW.qty * NEW.subprice * (1 - NEW.remise_percent / 100);
-- Calcular IVA
NEW.total_tva := NEW.total_ht * NEW.tva_tx / 100;
-- Calcular total TTC
NEW.total_ttc := NEW.total_ht + NEW.total_tva;
-- Establecer rang automáticamente
IF NEW.rang IS NULL THEN
SELECT COALESCE(MAX(rang), 0) + 1 INTO NEW.rang
FROM llx_ligne
WHERE fk_parent = NEW.fk_parent;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
📊 Actualización de Totales Cabecera
-- Función para recalcular totales del documento
CREATE OR REPLACE FUNCTION llx_documento_update_totals(p_doc_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
-- Sumar 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_documentodet
WHERE fk_documento = p_doc_id;
-- Actualizar la cabecera
UPDATE llx_documento
SET
total_ht = v_total_ht,
total_tva = v_total_tva,
total_ttc = v_total_ttc,
tms = NOW()
WHERE rowid = p_doc_id;
END;
$$ LANGUAGE plpgsql;
🔄 Patrón de Control de Estados
Usado en 8 módulosPropósito: Control estricto de transiciones de estado y validaciones de flujo de negocio.
CREATE OR REPLACE FUNCTION llx_documento_validate_status_change()
RETURNS trigger AS $$
BEGIN
-- Solo procesar si cambió el estado
IF OLD.fk_statut != NEW.fk_statut THEN
-- Validar transiciones permitidas
CASE OLD.fk_statut
WHEN 0 THEN -- Borrador
IF NEW.fk_statut NOT IN (1, -1) THEN
RAISE EXCEPTION 'Transición inválida desde borrador a estado %', NEW.fk_statut;
END IF;
WHEN 1 THEN -- Validado
IF NEW.fk_statut NOT IN (2, 3, -1) THEN
RAISE EXCEPTION 'Transición inválida desde validado a estado %', NEW.fk_statut;
END IF;
WHEN 2 THEN -- En proceso
IF NEW.fk_statut NOT IN (3, -1) THEN
RAISE EXCEPTION 'Transición inválida desde en proceso a estado %', NEW.fk_statut;
END IF;
WHEN 3 THEN -- Cerrado
RAISE EXCEPTION 'No se puede modificar un documento cerrado';
WHEN -1 THEN -- Cancelado
IF NEW.fk_statut != 0 THEN
RAISE EXCEPTION 'Solo se puede reabrir un documento cancelado';
END IF;
END CASE;
-- Acciones específicas por transición
IF NEW.fk_statut = 1 AND OLD.fk_statut = 0 THEN
-- Al validar: establecer fecha y generar referencia
NEW.date_valid := COALESCE(NEW.date_valid, NOW());
IF NEW.ref LIKE '(PROV%' THEN
NEW.ref := llx_documento_get_next_ref(NEW.entity);
END IF;
END IF;
IF NEW.fk_statut = -1 THEN
-- Al cancelar: limpiar fechas de proceso
NEW.date_valid := NULL;
NEW.date_close := NULL;
END IF;
IF NEW.fk_statut = 3 THEN
-- Al cerrar: establecer fecha de cierre
NEW.date_close := COALESCE(NEW.date_close, NOW());
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
📝 Patrón de Auditoría Automática
Usado en 17 módulosPropósito: Tracking automático de cambios, timestamps y gestión de históricos.
-- Trigger para auditoría automática
CREATE OR REPLACE FUNCTION llx_audit_changes()
RETURNS trigger AS $$
DECLARE
v_changes jsonb := '{}';
v_campo text;
v_old_value text;
v_new_value text;
BEGIN
-- Para UPDATE: registrar cambios
IF TG_OP = 'UPDATE' THEN
-- Actualizar timestamp automáticamente
NEW.tms := NOW();
-- Detectar cambios significativos para histórico
FOR v_campo IN SELECT column_name
FROM information_schema.columns
WHERE table_name = TG_TABLE_NAME
AND column_name NOT IN ('tms', 'rowid', 'datec')
LOOP
EXECUTE format('SELECT ($1).%I::text, ($2).%I::text', v_campo, v_campo)
INTO v_old_value, v_new_value
USING OLD, NEW;
IF v_old_value IS DISTINCT FROM v_new_value THEN
v_changes := v_changes || jsonb_build_object(
v_campo, jsonb_build_object(
'old', v_old_value,
'new', v_new_value
)
);
END IF;
END LOOP;
-- Registrar en tabla de auditoría si hay cambios significativos
IF v_changes != '{}' THEN
INSERT INTO llx_audit_log (
table_name, record_id, operation, changes,
user_id, change_date
) VALUES (
TG_TABLE_NAME, NEW.rowid, 'UPDATE', v_changes,
COALESCE(NEW.fk_user_modif, NEW.fk_user_author, 1), NOW()
);
END IF;
END IF;
-- Para INSERT: registrar creación
IF TG_OP = 'INSERT' THEN
NEW.datec := COALESCE(NEW.datec, NOW());
NEW.tms := COALESCE(NEW.tms, NOW());
INSERT INTO llx_audit_log (
table_name, record_id, operation,
user_id, change_date
) VALUES (
TG_TABLE_NAME, NEW.rowid, 'INSERT',
COALESCE(NEW.fk_user_author, 1), NOW()
);
END IF;
RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;
⚠️ Errores Comunes y Soluciones
Lecciones aprendidas durante la migración para evitar errores recurrentes.
🏗️ Errores de Estructura de BD
❌ Error: Tablas de Relación Faltantes
Frecuencia: AltaProblema: El código PHP espera tablas de relación que no existen en todas las instalaciones.
-- Error típico en PostgreSQL
ERROR: relation "llx_categorie_fichinter" does not exist
✅ Solución:
-- 1. SIEMPRE verificar existencia antes de implementar funciones
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'llx_categorie_fichinter'
);
-- 2. Crear tabla si no existe
CREATE TABLE IF NOT EXISTS llx_categorie_fichinter (
fk_categorie integer NOT NULL,
fk_fichinter integer NOT NULL,
import_key varchar(14),
PRIMARY KEY (fk_categorie, fk_fichinter)
);
-- 3. Agregar al test de estructura
SELECT has_table('llx_categorie_fichinter', 'Tabla de relación debe existir');
❌ Error: Discrepancia de Tipos de Datos
Frecuencia: MediaProblema: PostgreSQL es estricto con tipos, fallan comparaciones numeric vs integer.
✅ Solución:
-- Usar cast explícito en comparaciones
SELECT is(
(SELECT entity FROM llx_tabla WHERE id = 1)::integer,
1,
'entity debe ser 1'
);
-- En funciones, declarar tipos explícitos
DECLARE
v_amount numeric(24,8);
v_count integer;
BEGIN
-- Código con tipos bien definidos
END;
🧪 Errores de Testing
❌ Error: Tests de Timestamp Fallando
Frecuencia: AltaProblema: Los triggers establecen tms=NOW() simultáneamente con datec, comparaciones fallan.
-- Test fallido
SELECT ok(
(SELECT tms > datec FROM llx_tabla WHERE id = 1),
'tms debe ser posterior a datec'
); -- FALLA porque ambos se ponen a NOW()
✅ Solución:
-- Usar ventana de tiempo en lugar de comparación directa
SELECT ok(
(SELECT tms >= CURRENT_TIMESTAMP - INTERVAL '1 second'
FROM llx_tabla WHERE id = 1),
'tms debe actualizarse recientemente'
);
-- O verificar que tms está presente
SELECT ok(
(SELECT tms IS NOT NULL FROM llx_tabla WHERE id = 1),
'tms debe establecerse automáticamente'
);
❌ Error: Tests Interferencia entre Datos
Frecuencia: MediaProblema: Tests modifican datos que afectan tests posteriores.
✅ Solución:
-- 1. Usar datos específicos para cada test
INSERT INTO llx_don (firstname, lastname, email, amount, fk_user_author)
VALUES ('Test', 'Específico', 'test_específico_caso_29@test.com', 75, 1);
-- 2. Usar transacciones para aislamiento
BEGIN;
-- Tests aquí
SELECT * FROM finish();
ROLLBACK; -- Todo se deshace automáticamente
-- 3. Limpiar al final de cada test crítico
DELETE FROM llx_tabla WHERE email LIKE 'test_%@test.com';
⚙️ Errores de Triggers
❌ Error: Orden de Ejecución de Triggers
Frecuencia: MediaProblema: Triggers se ejecutan en orden alfabético, puede causar problemas.
✅ Solución:
-- Usar numeración en nombres de triggers
CREATE TRIGGER trg_tabla_01_before_insert
BEFORE INSERT ON llx_tabla
FOR EACH ROW EXECUTE FUNCTION llx_tabla_validate();
CREATE TRIGGER trg_tabla_02_before_insert_generate
BEFORE INSERT ON llx_tabla
FOR EACH ROW EXECUTE FUNCTION llx_tabla_generate_code();
CREATE TRIGGER trg_tabla_03_after_insert_audit
AFTER INSERT ON llx_tabla
FOR EACH ROW EXECUTE FUNCTION llx_tabla_audit();
❌ Error: Triggers Recursivos
Frecuencia: BajaProblema: Trigger actualiza tabla que dispara el mismo trigger en bucle.
✅ Solución:
-- Usar condiciones para evitar recursión
CREATE OR REPLACE FUNCTION llx_tabla_update_parent()
RETURNS trigger AS $$
BEGIN
-- Solo actualizar si realmente cambió algo significativo
IF OLD.total_amount IS DISTINCT FROM NEW.total_amount THEN
-- Usar UPDATE con WHERE específico para evitar trigger
UPDATE llx_parent_table
SET total = (SELECT SUM(amount) FROM llx_tabla WHERE fk_parent = NEW.fk_parent)
WHERE rowid = NEW.fk_parent
AND rowid != NEW.rowid; -- Evitar auto-referencia
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
⚡ Errores de Performance
❌ Error: Queries Lentas en Generación de Códigos
Frecuencia: MediaProblema: Funciones de generación de códigos pueden ser lentas sin índices adecuados.
✅ Solución:
-- 1. Crear índices específicos para búsquedas de códigos
CREATE INDEX idx_societe_code_client ON llx_societe(code_client)
WHERE code_client IS NOT NULL;
CREATE INDEX idx_propal_ref_pattern ON llx_propal(ref)
WHERE ref ~ '^PR[0-9]{4}-[0-9]{4}$';
-- 2. Usar LIMIT en queries de generación
SELECT COALESCE(MAX(numero), 0) + 1
FROM (
SELECT CAST(substring(ref from 8 for 4) AS integer) as numero
FROM llx_tabla
WHERE ref ~ '^PR2501-[0-9]{4}$'
ORDER BY numero DESC
LIMIT 100 -- Limitar búsqueda a los últimos N
) AS subconsulta;
🌟 Mejores Prácticas Establecidas
📋 Documentación
- SIEMPRE crear MIGRATION_PLAN.md antes de implementar
- Mantener MIGRATION_LOG.md actualizado en tiempo real
- Comentar código PostgreSQL en español
- Documentar decisiones técnicas y alternativas descartadas
- Incluir ejemplos de uso en comentarios de funciones
🧪 Testing
- Tests de estructura ANTES que funciones
- 100% cobertura obligatoria en tests pgTAP
- Tests comparativos para validar paridad PHP
- Datos de test aislados y predecibles
- Usar transacciones para aislamiento de tests
⚙️ Implementación
- Dividir tareas complejas en subtareas pequeñas
- Usar numeración en nombres de triggers (01_, 02_)
- Validar TODAS las tablas de relación esperadas
- Crear backups (.orig) antes de modificar PHP
- ELIMINAR toda lógica PHP sin excepciones
🔧 Código
- Funciones con un propósito específico y claro
- Mensajes de error consistentes con PHP original
- Usar COALESCE para valores por defecto
- Cast explícito en comparaciones de tipos
- Comentarios explicativos en lógica compleja
🎯 Lecciones Críticas Aprendidas
🔍 "Entender Antes de Implementar"
Es fundamental analizar completamente el comportamiento PHP existente antes de escribir la primera línea de PostgreSQL. Los tests comparativos son esenciales para validar paridad.
📊 "Tests de Estructura Primero"
SIEMPRE verificar que todas las tablas y campos existen antes de implementar funciones. Un test fallido de estructura puede ahorrar horas de debugging.
🔧 "Divide y Vencerás"
Las tareas complejas se resuelven mejor dividiéndolas en partes pequeñas y manejables. Un módulo complejo puede requerir 10-15 subtareas específicas.
⚖️ "No Mejorar, Solo Migrar"
El objetivo es replicar EXACTAMENTE el comportamiento PHP, no mejorarlo. Las optimizaciones pueden introducir discrepancias sutiles.
🗂️ "Verificar Tablas de Relación"
Las tablas de relación pueden no existir si los módulos se agregaron después. SIEMPRE verificar y crear las faltantes antes de implementar.
🎯 "Precisión en Testing"
En un trabajo de alta precisión, no se puede permitir ni un solo test fallando. 100% de tests pasando es un requisito no negociable.
🛠️ Herramientas Desarrolladas
📊 sync-test-databases-final.sh
Script automatizado para sincronizar las 3 bases de datos de testing, garantizando entornos idénticos para tests comparativos.
- Sincronización completa de estructura
- Copia selectiva de datos de prueba
- Validación de integridad post-sync
🧪 run_pgtap_tests.sh
Executor automatizado de todos los tests pgTAP con reporting detallado y estadísticas de cobertura.
- Ejecución paralela de tests
- Reporting con colores y estadísticas
- Detección automática de nuevos tests
⚖️ Tests Comparativos
Sistema de scripts PHP y SQL para comparar comportamiento entre implementación original y PostgreSQL.
- Captura automática de comportamiento PHP
- Ejecución paralela en ambas bases
- Comparación automatizada de resultados