🎯 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ódulos

Propó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ódulos

Propó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ódulos

Propó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ódulos

Propó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ódulos

Propó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: Alta

Problema: 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: Media

Problema: 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: Alta

Problema: 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: Media

Problema: 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: Media

Problema: 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: Baja

Problema: 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: Media

Problema: 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

Día 1

🔍 "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.

Día 1

📊 "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.

Día 2

🔧 "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.

Día 2

⚖️ "No Mejorar, Solo Migrar"

El objetivo es replicar EXACTAMENTE el comportamiento PHP, no mejorarlo. Las optimizaciones pueden introducir discrepancias sutiles.

Día 3

🗂️ "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.

Día 3

🎯 "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