Caso de estudio

Desarrollo de módulos y sistemas internos

Este caso reúne trabajos realizados en entorno de empresa sobre tiendas reales en PrestaShop, WordPress, WooCommerce y Dolibarr. El objetivo principal fue adaptar plataformas ya existentes a reglas de negocio concretas: transportistas, checkout, validaciones fiscales, PDFs, TPV, traducciones, emails, automatizaciones y módulos internos.

No se trata solo de instalar plugins, sino de intervenir sobre sistemas en producción, localizar el origen de errores, modificar plantillas, crear módulos personalizados, usar hooks, aplicar overrides controlados y mantener compatibilidad con código legacy sin romper el funcionamiento de la tienda.

Trabajo en empresa eCommerce real Backend PHP Sistemas legacy

Problema / reto

Resolver necesidades de negocio que los módulos estándar no cubrían bien

Muchas tiendas no fallan porque falte una plantilla bonita, sino porque necesitan reglas internas muy concretas que el CMS no trae de serie. El reto fue adaptar plataformas existentes sin romper ventas, pedidos, transportistas, facturas o procesos internos.

  • Aplicar suplementos de envío según cantidad exacta, categoría y transportista.
  • Evitar documentos fiscales ficticios en formularios de compra y registro.
  • Modificar PDFs de facturas, abonos y contratos para reflejar correctamente descuentos, textos legales y firmas.
  • Personalizar emails transaccionales para que llegaran con información adicional del pedido.
  • Corregir errores provocados por módulos legacy, plantillas antiguas o incompatibilidades con PHP.
  • Integrar y validar métodos de pago como TPV/Redsys, Plazox o pagos por transferencia.
  • Resolver problemas multidioma en menús, URLs, formularios, productos y textos del checkout.

Solución

Módulos, hooks y overrides controlados en lugar de tocar el core directamente

La solución se basó en intervenir de forma localizada y mantenible, usando la vía adecuada según cada plataforma:

  • Módulos personalizados para encapsular reglas de negocio.
  • Hooks de PrestaShop y WordPress para extender funcionalidades sin modificar el núcleo.
  • Overrides controlados cuando el comportamiento estándar no permitía resolver la regla requerida.
  • Plantillas TPL/PHP para adaptar checkout, producto, email y PDFs.
  • Validaciones backend para evitar datos incorrectos antes de crear pedidos.
  • Pruebas por país, transportista y método de pago antes de dar una tarea por finalizada.

Enfoque

Intervenciones pequeñas, pero con impacto directo en ventas y operación

En este tipo de proyectos, una modificación aparentemente pequeña puede afectar al pedido completo. Por eso el enfoque fue analizar primero el flujo real: carrito, transportista, país, idioma, factura, email y backoffice.

  • Revisión del comportamiento estándar antes de modificar.
  • Localización de archivos concretos y puntos de extensión.
  • Separación entre solución temporal, solución final y tareas pendientes.
  • Documentación de rutas, líneas modificadas y módulos afectados.
  • Validación del resultado con capturas y pedidos de prueba.

Código representativo

Sistema de habitaciones y precios dinámicos en WooCommerce

Desarrollo de una personalización avanzada para WooCommerce orientada a productos de tipo reserva, donde el cliente puede configurar habitaciones, viajeros y suplementos desde la ficha de producto.

La solución sustituye los selectores estándar de adultos, niños y bebés por un constructor dinámico de habitaciones, calcula automáticamente el tipo de habitación según ocupación, guarda los datos en el carrito y recalcula el precio final aplicando reglas de negocio como temporada alta, suplemento individual y precios diferenciados por tipo de pasajero.

  • Constructor dinámico de habitaciones en frontend.
  • Ocultación de campos originales del plugin de reservas.
  • Cálculo automático de habitación single, doble o triple.
  • Persistencia de habitaciones en el carrito.
  • Reinyección de pasajeros en los campos originales del plugin.
  • Validación antes de añadir al carrito.
  • Recalculo del precio mediante woocommerce_before_calculate_totals.
  • Suplemento de temporada alta según fechas configuradas en ACF.
  • Suplemento de habitación individual cuando solo viaja una persona.
  • Visualización de habitaciones y suplementos en carrito.
if (!function_exists('we_table_variation_html')) {
  function we_table_variation_html($price, $label, $class) {

    $user = wp_get_current_user();
    $allowed_roles = array('contributor');
    $prhtm = '';

    if (array_intersect($allowed_roles, $user->roles)) {
      if ($class == 'wt-child-price') {
        $prhtm = '{-€3.00}';
      } elseif ($class == 'wt-infant-price') {
        $prhtm = '{-€1.00}';
      }
    }

    $product_id = get_the_ID();
    $activar_habitaciones = get_field('activar_habitaciones', $product_id);

    // Si habitaciones está activo, ocultamos las filas normales de Adultos y Niños
    if ($activar_habitaciones && ($class == 'wt-adult-price' || $class == 'wt-child-price')) {
      return '';
    }

    // Si habitaciones está activo, pintamos TODO dentro del bloque de habitaciones
    if ($activar_habitaciones && $class == 'wt-infant-price') {
$fechas_temporada_alta = [];

$rows = get_field('seating_capactity', $product_id);

if (!empty($rows)) {
  foreach ($rows as $row) {
    if (
      !empty($row['date']) &&
      isset($row['temporada_alta']) &&
      $row['temporada_alta'] == 1
    ) {
      $fechas_temporada_alta[] = travelsur_normalizar_fecha($row['date']);
    }
  }
}

$html = '
div class="travelsur-room-selector">
  ...
/div>

script>
(function() {

  const fechasTemporadaAlta = ' . wp_json_encode($fechas_temporada_alta) . ';
  console.log("Fechas temporada alta:", fechasTemporadaAlta);

  // resto de tu JS aquí

})();
/script>
';
      $html = '
      div class="travelsur-room-selector">

        h4>Elige las habitaciones/h4>

        div id="travelsur-rooms-wrapper">/div>

  div class="travelsur-season-notice" style="display:none; margin-top:15px; color:#b00020; font-weight:600;">
    ⚠ Esta salida tiene suplemento de temporada alta: 20€ por persona
  /div>

        button type="button" id="travelsur-add-room">
          + Añadir habitación
        /button>

      /div>

      script>
(function() {

  const fechasTemporadaAlta = ' . wp_json_encode($fechas_temporada_alta) . ';

  const meses = {
    "enero": "01",
    "febrero": "02",
    "marzo": "03",
    "abril": "04",
    "mayo": "05",
    "junio": "06",
    "julio": "07",
    "agosto": "08",
    "septiembre": "09",
    "octubre": "10",
    "noviembre": "11",
    "diciembre": "12"
  };

  function normalizarFechaJS(fecha) {
    fecha = String(fecha || "").trim().toLowerCase();

    let mTexto = fecha.match(/(\\d{1,2})\\s+([a-záéíóúñ]+),?\\s+(\\d{4})/);
    if (mTexto) {
      return mTexto[3] + meses[mTexto[2]] + mTexto[1].padStart(2, "0");
    }

    let mACF = fecha.match(/(\\d{4})_(\\d{2})_(\\d{2})/);
    if (mACF) {
      return mACF[1] + mACF[2] + mACF[3];
    }

    return "";
  }

  function revisarTemporadaAlta() {
    const aviso = document.querySelector(".travelsur-season-notice");

    if (!aviso) {
      return;
    }

    const posiblesCamposFecha = document.querySelectorAll("input, select");

    let encontrada = false;

    posiblesCamposFecha.forEach(function(campo) {
      const valor = campo.value || "";
      const normalizada = normalizarFechaJS(valor);

      if (fechasTemporadaAlta.includes(normalizada)) {
        encontrada = true;
      }
    });

    aviso.style.display = encontrada ? "block" : "none";
  }

  document.addEventListener("change", function() {
    setTimeout(revisarTemporadaAlta, 50);
    setTimeout(revisarTemporadaAlta, 200);
    setTimeout(revisarTemporadaAlta, 500);
  });

  document.addEventListener("click", function() {
    setTimeout(revisarTemporadaAlta, 50);
    setTimeout(revisarTemporadaAlta, 200);
    setTimeout(revisarTemporadaAlta, 500);
  });

  const observer = new MutationObserver(function() {
    revisarTemporadaAlta();
  });

  observer.observe(document.body, {
    childList: true,
    subtree: true,
    attributes: true
  });

  setInterval(revisarTemporadaAlta, 1000);

  setTimeout(revisarTemporadaAlta, 1000);

  function renumerarHabitaciones() {
    document.querySelectorAll(".travelsur-room-box").forEach(function(room, index) {
      const title = room.querySelector(".room-title");

      if (title) {
        title.textContent = "Habitación " + (index + 1);
      }
    });
  }

  function ocultarCamposOriginales() {

    const campos = [
      "wt_number_adult",
      "wt_number_child",
      "wt_number_infant"
    ];

    campos.forEach(function(nombreCampo) {
      const select = document.querySelector("[name=" + CSS.escape(nombreCampo) + "]");

      if (!select) {
        return;
      }

      const tabla = select.closest("table");

      if (tabla) {
        tabla.style.display = "none";
      }
    });
  }

  let roomIndex = 0;

  function crearHabitacion() {
  const wrapper = document.getElementById("travelsur-rooms-wrapper");

  document.querySelectorAll(".travelsur-room-box").forEach(function(room) {
    room.classList.remove("active");
  });

  const html = `
    div class="travelsur-room-box active" data-room="${roomIndex}">
      
      div class="room-header">
        strong class="room-title">Habitación ${roomIndex + 1}/strong>
        button type="button" class="travelsur-remove-room">x/button>
      /div>

      div class="room-content">

        div class="room-field">
          label>Adultos/label>
          input type="number" name="habitaciones[${roomIndex}][adultos]" value="0" min="0">
        /div>

        div class="room-field">
          label>Niños/label>
          input type="number" name="habitaciones[${roomIndex}][ninos]" value="0" min="0">
        /div>

        div class="room-field">
          label>Bebés/label>
          input type="number" name="habitaciones[${roomIndex}][bebes]" value="0" min="0">
        /div>

      /div>

    /div>
  `;

  wrapper.insertAdjacentHTML("beforeend", html);

  roomIndex++;

  renumerarHabitaciones();
}

document.addEventListener("click", function(e) {

  if (e.target.id === "travelsur-add-room") {
    crearHabitacion();
  }

  if (e.target.classList.contains("travelsur-remove-room")) {

    e.preventDefault();
    e.stopPropagation();

    e.target.closest(".travelsur-room-box").remove();

    renumerarHabitaciones();

    return;
  }

  const header = e.target.closest(".room-header");

  if (header) {

    const room = header.closest(".travelsur-room-box");

    document.querySelectorAll(".travelsur-room-box").forEach(function(box) {

      if (box !== room) {
        box.classList.remove("active");
      }
    });

    room.classList.toggle("active");
  }
});

  crearHabitacion();

  ocultarCamposOriginales();

  setTimeout(ocultarCamposOriginales, 300);
  setTimeout(ocultarCamposOriginales, 800);
  setTimeout(ocultarCamposOriginales, 1500);

})();
/script>
      ';

      return $html;
    }

    // Si habitaciones NO está activo, mostramos el comportamiento normal
    $html = '
    table class="tour-tble">
      tbody>
        tr>
          td>
            div class="woocommerce-variation-' . esc_attr($class) . '">
              ' . $price . ' ' . $prhtm . '
            /div>
          /td>
          td>' . $label . '/td>
        /tr>
      /tbody>
    /table>
    ';

    return $html;
  }
}

if(!function_exists('wt_get_price')){
  function wt_get_price($id, $meta){
    if(get_post_meta( $id, $meta.'_sale', true )!=''){
      $price = get_post_meta( $id, $meta.'_sale', true );
    }else{
      $price = get_post_meta( $id, $meta, true );
    }
    if(function_exists('wmc_get_price')){
      $price =  wmc_get_price($price);
    }
    $price = we_convert_currency($price);
    $price = we_apply_rounding_rules($price);
    return $price;
  }
}

function travelsur_tipo_habitacion_automatico($adultos, $ninos) {

  $plazas = (int) $adultos + (int) $ninos;

  if ($plazas === 1) {
    return 'single';
  }

  if ($plazas === 2) {
    return 'doble';
  }

  return 'triple';
}

add_filter('woocommerce_add_to_cart_validation', 'travelsur_validar_habitaciones', 10, 3);

function travelsur_validar_habitaciones($passed, $product_id, $quantity) {

  if (!get_field('activar_habitaciones', $product_id)) {
    return $passed;
  }

  $habitaciones = isset($_POST['habitaciones']) && is_array($_POST['habitaciones'])
    ? $_POST['habitaciones']
    : array();

  if (empty($habitaciones)) {
    wc_add_notice('Debes añadir al menos una habitación.', 'error');
    return false;
  }

  foreach ($habitaciones as $index => $habitacion) {

    $adultos = isset($habitacion['adultos']) ? (int) $habitacion['adultos'] : 0;
    $ninos   = isset($habitacion['ninos']) ? (int) $habitacion['ninos'] : 0;
    $bebes   = isset($habitacion['bebes']) ? (int) $habitacion['bebes'] : 0;
    $tipo = travelsur_tipo_habitacion_automatico($adultos, $ninos);

    $total_habitacion = $adultos + $ninos;

    if ($total_habitacion <= 0) {
      wc_add_notice('La habitación ' . ($index + 1) . ' debe tener al menos una persona.', 'error');
      return false;
    }

    if ($tipo === 'single') {
      $capacidad = 1;
    } elseif ($tipo === 'doble') {
      $capacidad = 2;
    } elseif ($tipo === 'triple') {
      $capacidad = 3;
    } else {
      wc_add_notice('Tipo de habitación no válido en la habitación ' . ($index + 1) . '.', 'error');
      return false;
    }

    if ($total_habitacion > $capacidad) {
      wc_add_notice(
        'La habitación ' . ($index + 1) . ' supera la capacidad permitida para una habitación ' . $tipo . '.',
        'error'
      );
      return false;
    }
  }

  return $passed;
}

function travelsur_normalizar_fecha($fecha) {
  $fecha = trim(strtolower($fecha));

  $meses = [
    'enero' => '01',
    'febrero' => '02',
    'marzo' => '03',
    'abril' => '04',
    'mayo' => '05',
    'junio' => '06',
    'julio' => '07',
    'agosto' => '08',
    'septiembre' => '09',
    'octubre' => '10',
    'noviembre' => '11',
    'diciembre' => '12',
  ];

  if (preg_match('/(\d{1,2})\s+([a-záéíóúñ]+),?\s+(\d{4})/', $fecha, $m)) {
    return $m[3] . $meses[$m[2]] . str_pad($m[1], 2, '0', STR_PAD_LEFT);
  }

  if (preg_match('/(\d{4})_(\d{2})_(\d{2})/', $fecha, $m)) {
    return $m[1] . $m[2] . $m[3];
  }

  return '';
}

add_action('woocommerce_before_calculate_totals', 'travelsur_sumar_habitaciones', 999);

function travelsur_sumar_habitaciones($cart) {

  if (is_admin() && !defined('DOING_AJAX')) {
    return;
  }

  foreach ($cart->get_cart() as $cart_item) {

    if (!get_field('activar_habitaciones', $cart_item['product_id'])) {
          continue;
      }

    $adultos = isset($cart_item['wt_number_adult']) ? (int) $cart_item['wt_number_adult'] : 0;
    $ninos   = isset($cart_item['wt_number_child']) ? (int) $cart_item['wt_number_child'] : 0;
    $bebes   = isset($cart_item['wt_number_infant']) ? (int) $cart_item['wt_number_infant'] : 0;

    $total_personas = $adultos + $ninos + $bebes;

    $variation_id = !empty($cart_item['variation_id'])
      ? $cart_item['variation_id']
      : $cart_item['product_id'];

    $variation = wc_get_product($variation_id);

    $precio_adulto = $variation ? (float) $variation->get_price() : 0;

    $precio_nino = get_post_meta($variation_id, '_child_price_sale', true) !== ''
      ? (float) get_post_meta($variation_id, '_child_price_sale', true)
      : (float) get_post_meta($variation_id, '_child_price', true);

    $precio_bebe = get_post_meta($variation_id, '_infant_price_sale', true) !== ''
      ? (float) get_post_meta($variation_id, '_infant_price_sale', true)
      : (float) get_post_meta($variation_id, '_infant_price', true);

    $precio_personas =
      ($adultos * $precio_adulto) +
      ($ninos * $precio_nino) +
      ($bebes * 0);

    $variation_id = $cart_item['variation_id'];

    $product_variation = wc_get_product($variation_id);

    if (get_field('activar_habitaciones', $cart_item['product_id'])) {

        $adultos_habitaciones = 0;
        $ninos_habitaciones = 0;
        $bebes_habitaciones = 0;

        if (!empty($cart_item['habitaciones']) && is_array($cart_item['habitaciones'])) {
            foreach ($cart_item['habitaciones'] as $habitacion) {
                $adultos_habitaciones += isset($habitacion['adultos']) ? (int) $habitacion['adultos'] : 0;
                $ninos_habitaciones   += isset($habitacion['ninos']) ? (int) $habitacion['ninos'] : 0;
                $bebes_habitaciones   += isset($habitacion['bebes']) ? (int) $habitacion['bebes'] : 0;
            }
        }

        $precio_personas =
          ($adultos_habitaciones * $precio_adulto) +
          ($ninos_habitaciones * $precio_nino) +
          ($bebes_habitaciones * 0);
    }

    $suplemento_habitaciones = 0;

    if (!empty($cart_item['habitaciones']) && is_array($cart_item['habitaciones'])) {

      foreach ($cart_item['habitaciones'] as $habitacion) {

        $tipo = isset($habitacion['tipo']) ? $habitacion['tipo'] : '';

        if ($tipo === 'single') {
          $suplemento_habitaciones += 0;
        } elseif ($tipo === 'doble') {
          $suplemento_habitaciones += 0;
        } elseif ($tipo === 'triple') {
          $suplemento_habitaciones += 0;
        }
      }
    }

    $suplemento_temporada_alta = 0;

    $fecha_reserva = isset($cart_item['_date']) ? trim($cart_item['_date']) : '';
    $fecha_reserva_normalizada = travelsur_normalizar_fecha($fecha_reserva);

    $rows = get_field('seating_capactity', $cart_item['product_id']);

    if (!empty($rows)) {

      foreach ($rows as $row) {

        $fecha_acf = isset($row['date']) ? trim($row['date']) : '';
        $fecha_acf_normalizada = travelsur_normalizar_fecha($fecha_acf);


        if (
          $fecha_acf_normalizada === $fecha_reserva_normalizada &&
          $row['temporada_alta'] == 1
        ) {
          $suplemento_temporada_alta = $total_personas * 20;
          break;
        }
      }
    }

    $suplemento_individual = 0;

    // Los bebés no cuentan como plaza
    if (($adultos + $ninos) === 1) {
      $suplemento_individual = 30;
    }

    $total = $precio_personas
      + $suplemento_habitaciones
      + $suplemento_temporada_alta
      + $suplemento_individual;

    $cart_item['data']->set_price($total);
  }
}

add_filter('woocommerce_get_item_data', 'travelsur_mostrar_habitaciones_carrito', 10, 2);

function travelsur_mostrar_habitaciones_carrito($item_data, $cart_item) {

  if (!empty($cart_item['habitaciones']) && is_array($cart_item['habitaciones'])) {

    foreach ($cart_item['habitaciones'] as $index => $habitacion) {

      $adultos = isset($habitacion['adultos']) ? (int) $habitacion['adultos'] : 0;
      $ninos   = isset($habitacion['ninos']) ? (int) $habitacion['ninos'] : 0;
      $bebes   = isset($habitacion['bebes']) ? (int) $habitacion['bebes'] : 0;
      $tipo    = isset($habitacion['tipo']) ? ucfirst($habitacion['tipo']) : '';

      $item_data[] = array(
        'name'  => 'Habitación ' . ($index + 1),
        'value' => $tipo . ' | Adultos: ' . $adultos . ' | Niños: ' . $ninos . ' | Bebés: ' . $bebes
      );
    }
  }

  if (!empty($cart_item['_date'])) {
    $fecha_reserva = trim($cart_item['_date']);
    $fecha_reserva_normalizada = travelsur_normalizar_fecha($fecha_reserva);

    $rows = get_field('seating_capactity', $cart_item['product_id']);

    if (!empty($rows)) {
      foreach ($rows as $row) {
        $fecha_acf = isset($row['date']) ? trim($row['date']) : '';
        $fecha_acf_normalizada = travelsur_normalizar_fecha($fecha_acf);

        if (
          $fecha_acf_normalizada === $fecha_reserva_normalizada &&
          $row['temporada_alta'] == 1
        ) {
          $item_data[] = array(
            'name'  => 'Temporada Alta',
            'value' => 'Suplemento aplicado por persoona: 20€'
          );
          break;
        }
      }
    }
  }

/* NUEVO */
$adultos = isset($cart_item['wt_number_adult']) ? (int) $cart_item['wt_number_adult'] : 0;
$ninos   = isset($cart_item['wt_number_child']) ? (int) $cart_item['wt_number_child'] : 0;

if (($adultos + $ninos) === 1) {
  $item_data[] = array(
    'name'  => 'Habitación Individual',
    'value' => 'Suplemento aplicado: 30€'
  );
}

  return $item_data;
}

add_filter('woocommerce_add_cart_item', 'travelsur_reinyectar_pasajeros_en_cart_item', 20, 1);

function travelsur_reinyectar_pasajeros_en_cart_item($cart_item) {

  if (empty($cart_item['habitaciones']) || !is_array($cart_item['habitaciones'])) {
    return $cart_item;
  }

  $total_adultos = 0;
  $total_ninos   = 0;
  $total_bebes   = 0;

  foreach ($cart_item['habitaciones'] as $habitacion) {
    $total_adultos += isset($habitacion['adultos']) ? (int) $habitacion['adultos'] : 0;
    $total_ninos   += isset($habitacion['ninos']) ? (int) $habitacion['ninos'] : 0;
    $total_bebes   += isset($habitacion['bebes']) ? (int) $habitacion['bebes'] : 0;
  }

  $cart_item['wt_number_adult']  = $total_adultos;
  $cart_item['wt_number_child']  = $total_ninos;
  $cart_item['wt_number_infant'] = $total_bebes;

  $cart_item['_adult']  = $total_adultos;
  $cart_item['_child']  = $total_ninos;
  $cart_item['_infant'] = $total_bebes;

  return $cart_item;
}

add_filter('woocommerce_add_cart_item_data', 'travelsur_guardar_habitaciones', 10, 2);

function travelsur_guardar_habitaciones($cart_item_data, $product_id) {

  if (!get_field('activar_habitaciones', $product_id)) {
    return $cart_item_data;
  }

  $cart_item_data['habitaciones'] = array();

  if (isset($_POST['habitaciones']) && is_array($_POST['habitaciones'])) {

    foreach ($_POST['habitaciones'] as $habitacion) {

      $adultos = isset($habitacion['adultos']) ? (int) $habitacion['adultos'] : 0;
      $ninos   = isset($habitacion['ninos']) ? (int) $habitacion['ninos'] : 0;
      $bebes   = isset($habitacion['bebes']) ? (int) $habitacion['bebes'] : 0;

      $cart_item_data['habitaciones'][] = array(
        'adultos' => $adultos,
        'ninos'   => $ninos,
        'bebes'   => $bebes,
        'tipo'    => travelsur_tipo_habitacion_automatico($adultos, $ninos),
      );
    }
  }

  $total_adultos = 0;
  $total_ninos   = 0;
  $total_bebes   = 0;

  if (!empty($cart_item_data['habitaciones']) && is_array($cart_item_data['habitaciones'])) {
    foreach ($cart_item_data['habitaciones'] as $habitacion) {
      $total_adultos += isset($habitacion['adultos']) ? (int) $habitacion['adultos'] : 0;
      $total_ninos   += isset($habitacion['ninos']) ? (int) $habitacion['ninos'] : 0;
      $total_bebes   += isset($habitacion['bebes']) ? (int) $habitacion['bebes'] : 0;
    }
  }

  $cart_item_data['wt_number_adult']  = $total_adultos;
  $cart_item_data['wt_number_child']  = $total_ninos;
  $cart_item_data['wt_number_infant'] = $total_bebes;

  $cart_item_data['_adult']  = $total_adultos;
  $cart_item_data['_child']  = $total_ninos;
  $cart_item_data['_infant'] = $total_bebes;

  $_POST['wt_number_adult']  = $total_adultos;
  $_POST['wt_number_child']  = $total_ninos;
  $_POST['wt_number_infant'] = $total_bebes;

  $_REQUEST['wt_number_adult']  = $total_adultos;
  $_REQUEST['wt_number_child']  = $total_ninos;
  $_REQUEST['wt_number_infant'] = $total_bebes;

  $cart_item_data['unique_key'] = md5(microtime() . rand());

  return $cart_item_data;
}

Una parte clave del sistema es la validación previa al carrito. Cada habitación debe tener al menos una persona y el tipo de habitación se determina automáticamente según adultos y niños, dejando fuera a los bebés del cálculo de plazas.

add_filter('woocommerce_add_to_cart_validation', 'travelsur_validar_habitaciones', 10, 3);

          function travelsur_validar_habitaciones($passed, $product_id, $quantity) {

              if (!get_field('activar_habitaciones', $product_id)) {
                  return $passed;
              }

              $habitaciones = isset($_POST['habitaciones']) && is_array($_POST['habitaciones'])
                  ? $_POST['habitaciones']
                  : array();

              if (empty($habitaciones)) {
                  wc_add_notice('Debes añadir al menos una habitación.', 'error');
                  return false;
              }

              foreach ($habitaciones as $index => $habitacion) {

                  $adultos = isset($habitacion['adultos']) ? (int) $habitacion['adultos'] : 0;
                  $ninos   = isset($habitacion['ninos']) ? (int) $habitacion['ninos'] : 0;

                  $tipo = travelsur_tipo_habitacion_automatico($adultos, $ninos);
                  $total_habitacion = $adultos + $ninos;

                  if ($total_habitacion <= 0) {
                      wc_add_notice(
                          'La habitación ' . ($index + 1) . ' debe tener al menos una persona.',
                          'error'
                      );
                      return false;
                  }
              }

              return $passed;
          }

El cálculo del precio final se realiza directamente sobre los ítems del carrito, combinando precios por pasajero, datos de habitaciones, suplementos de temporada alta y suplemento por habitación individual.

add_action('woocommerce_before_calculate_totals', 'travelsur_sumar_habitaciones', 999);

          function travelsur_sumar_habitaciones($cart) {

              if (is_admin() && !defined('DOING_AJAX')) {
                  return;
              }

              foreach ($cart->get_cart() as $cart_item) {

                  if (!get_field('activar_habitaciones', $cart_item['product_id'])) {
                      continue;
                  }

                  $adultos = 0;
                  $ninos   = 0;
                  $bebes   = 0;

                  foreach ($cart_item['habitaciones'] as $habitacion) {
                      $adultos += isset($habitacion['adultos']) ? (int) $habitacion['adultos'] : 0;
                      $ninos   += isset($habitacion['ninos']) ? (int) $habitacion['ninos'] : 0;
                      $bebes   += isset($habitacion['bebes']) ? (int) $habitacion['bebes'] : 0;
                  }

                  $precio_personas =
                      ($adultos * $precio_adulto) +
                      ($ninos * $precio_nino) +
                      ($bebes * 0);

                  $suplemento_individual = (($adultos + $ninos) === 1) ? 30 : 0;

                  $total = $precio_personas
                      + $suplemento_temporada_alta
                      + $suplemento_individual;

                  $cart_item['data']->set_price($total);
              }
          }

También se desarrolló una capa frontend en JavaScript para crear habitaciones dinámicamente, ocultar campos originales, renumerar habitaciones y mostrar avisos de temporada alta cuando la fecha seleccionada coincide con una fecha marcada en ACF.

function crearHabitacion() {
              const wrapper = document.getElementById("travelsur-rooms-wrapper");

              const html = `
                  <div class="travelsur-room-box active" data-room="${roomIndex}">
                      <div class="room-header">
                          <strong class="room-title">Habitación ${roomIndex + 1}</strong>
                          <button type="button" class="travelsur-remove-room">x</button>
                      </div>

                      <div class="room-content">
                          <div class="room-field">
                              <label>Adultos</label>
                              <input type="number" name="habitaciones[${roomIndex}][adultos]" value="0" min="0">
                          </div>

                          <div class="room-field">
                              <label>Niños</label>
                              <input type="number" name="habitaciones[${roomIndex}][ninos]" value="0" min="0">
                          </div>

                          <div class="room-field">
                              <label>Bebés</label>
                              <input type="number" name="habitaciones[${roomIndex}][bebes]" value="0" min="0">
                          </div>
                      </div>
                  </div>
              `;

              wrapper.insertAdjacentHTML("beforeend", html);
              roomIndex++;

              renumerarHabitaciones();
          }

A nivel técnico, este desarrollo demuestra capacidad para intervenir en WooCommerce de forma profunda, conectar frontend y backend, trabajar con datos personalizados del carrito, aplicar reglas de negocio complejas y mantener compatibilidad con un plugin de reservas existente.

Código representativo

Módulo de facturas externas y adjuntos en pedidos

Desarrollo de un módulo personalizado para PrestaShop orientado a gestionar documentos externos asociados a pedidos. El módulo permite desactivar la generación automática de facturas de PrestaShop y sustituirla por un sistema propio de subida, almacenamiento, descarga y visualización de archivos desde el Back Office y el área de cliente.

La solución se integra directamente en la ficha del pedido mediante hooks administrativos, registra los documentos en una tabla propia mediante ObjectModel, valida permisos de acceso y permite que cada cliente descargue únicamente los documentos asociados a sus propios pedidos.

  • Creación de módulo personalizado externalinvoice.
  • Modelo propio ExternalInvoiceFile para persistir archivos asociados a pedidos.
  • Subida de documentos desde el Back Office del pedido.
  • Descarga segura desde controlador administrativo.
  • Descarga frontend validando cliente propietario del pedido.
  • Control de duplicados por pedido, nombre y tamaño de archivo.
  • Gestión de tokens para proteger acciones administrativas.
  • Creación automática del directorio de subida.
  • Desactivación opcional de facturas nativas de PrestaShop.
===== C:\Users\rodri\Desktop\externalinvoice\classes\ExternalInvoiceFile.php =====
 'externalinvoicefile',
'primary' => 'id_externalinvoicefile',
'fields' => [
'id_order' => ['type' => self::TYPE_INT, 'validate' => 'isUnsignedId', 'required' => true],
'original_name' => ['type' => self::TYPE_STRING, 'required' => true, 'size' => 255],
'stored_name' => ['type' => self::TYPE_STRING, 'required' => true, 'size' => 64],
'mime' => ['type' => self::TYPE_STRING, 'size' => 128],
'filesize' => ['type' => self::TYPE_INT],
'date_add' => ['type' => self::TYPE_DATE],
],
];


public static function getByOrderId($id_order)
{
$sql = 'SELECT * FROM ' . _DB_PREFIX_ . 'externalinvoicefile WHERE id_order=' . (int)$id_order . ' ORDER BY id_externalinvoicefile DESC';
return Db::getInstance()->executeS($sql);
}


public static function getPath($stored_name)
{
return _PS_UPLOAD_DIR_ . 'externalinvoice/' . $stored_name;
}


/**
* Elimina el registro y su fichero físico
*/
public function deleteWithFile()
{
$path = self::getPath($this->stored_name);
if (file_exists($path)) {
@unlink($path);
}
return parent::delete();
}
}
===== C:\Users\rodri\Desktop\externalinvoice\controllers\admin\AdminExternalInvoiceDownloadController.php =====
bootstrap = true;
}


public function initContent()
{
parent::initContent();


$action = Tools::getValue('action');
$token = Tools::getValue('token');
$id = (int)Tools::getValue('id_externalinvoicefile');


if (!$id) {
die($this->l('Falta id_externalinvoicefile'));
}
$row = new ExternalInvoiceFile($id);
if (!Validate::isLoadedObject($row)) {
die($this->l('Archivo no encontrado'));
}


// Borrar
if ($action === 'delete') {
// CSRF + permisos
if ($token !== Tools::getAdminTokenLite('AdminExternalInvoiceDownload')) {
die($this->l('Token inválido'));
}
if (!$this->tabAccess || (isset($this->tabAccess['delete']) && (int)$this->tabAccess['delete'] === 0)) {
die($this->l('No tienes permisos para borrar.'));
}


$id_order = (int)$row->id_order;
$row->deleteWithFile();


// Volver al pedido con aviso
$url = $this->context->link->getAdminLink('AdminOrders', true, [], [
'vieworder' => 1,
'id_order' => $id_order,
'conf' => 1, // mostrará un success genérico
]);
Tools::redirectAdmin($url);
}


// Descargar
$path = ExternalInvoiceFile::getPath($row->stored_name);
if (!file_exists($path)) {
die($this->l('El fichero físico no existe'));
}


header('Content-Description: File Transfer');
header('Content-Type: ' . ($row->mime ?: 'application/octet-stream'));
header('Content-Disposition: attachment; filename="' . str_replace('"', '', $row->original_name) . '"');
header('Content-Length: ' . (string)filesize($path));
readfile($path);
exit;
}
}
===== C:\Users\rodri\Desktop\externalinvoice\controllers\front\download.php =====
ajax = true;
    }

    public function postProcess()
    {
        // Debe estar logueado cliente (dueño) o empleado
        $isEmployee = (isset($this->context->employee) && $this->context->employee && (int)$this->context->employee->id > 0);
        $isCustomer = (isset($this->context->customer) && $this->context->customer && (int)$this->context->customer->id > 0);
        if (!$isEmployee && !$isCustomer) {
            Tools::redirect('index.php?controller=authentication');
        }

        $idFile   = (int)Tools::getValue('id_externalinvoicefile');
        $idOrder  = (int)Tools::getValue('id_order');
        $refOrder = Tools::getValue('reference');

// Resolver id_order por referencia (SQL crudo + fallbacks, sin caché)
if ($idOrder <= 0 && $refOrder) {
    $db  = Db::getInstance();
    $sql = 'SELECT id_order
            FROM `'._DB_PREFIX_.'orders`
            WHERE reference="'.pSQL($refOrder).'"
            ORDER BY id_order DESC
            LIMIT 1';

    // 1) getRow
    $row = $db->getRow($sql, false);
    if (is_array($row) && isset($row['id_order'])) {
        $idOrder = (int)$row['id_order'];
    } else {
        // 2) executeS
        $rows = $db->executeS($sql, false);
        if (is_array($rows) && isset($rows[0]['id_order'])) {
            $idOrder = (int)$rows[0]['id_order'];
        } else {
            // 3) query + fetch
            $stmt = $db->query($sql);
            if ($stmt && method_exists($stmt, 'fetch')) {
                $f = $stmt->fetch(\PDO::FETCH_ASSOC);
                if (is_array($f) && isset($f['id_order'])) {
                    $idOrder = (int)$f['id_order'];
                }
            }
        }
    }

    if ($idOrder <= 0) {
        // Mensaje claro para depurar si la referencia no existe
        $this->fail('No se encontró pedido para la referencia: '.pSQL($refOrder), 404);
    }
}


        if ($idFile <= 0 && $idOrder <= 0) {
            $this->fail($this->module->l('Falta id_order o id_externalinvoicefile', 'download'), 400);
        }

        // --- Localizar el archivo ---
        if ($idFile > 0) {
            // Por ID de archivo
            $row = new ExternalInvoiceFile($idFile);
            if (!Validate::isLoadedObject($row) || !(int)$row->id) {
                $this->fail($this->module->l('Archivo no encontrado.', 'download'), 404);
            }
            $idOrder = (int)$row->id_order;
        } else {
            // Por pedido: seleccionar el último (robusto, sin caché y con fallbacks)
            $sql = 'SELECT id_externalinvoicefile
                    FROM `'._DB_PREFIX_.'externalinvoicefile`
                    WHERE id_order='.(int)$idOrder.'
                    ORDER BY id_externalinvoicefile DESC
                    LIMIT 1';

            $idFile = 0;
            $db = Db::getInstance();

            // 1) getRow
            $rowDb = $db->getRow($sql, false);
            if (is_array($rowDb) && isset($rowDb['id_externalinvoicefile'])) {
                $idFile = (int)$rowDb['id_externalinvoicefile'];
            } else {
                // 2) executeS
                $rows = $db->executeS($sql, false);
                if (is_array($rows) && isset($rows[0]['id_externalinvoicefile'])) {
                    $idFile = (int)$rows[0]['id_externalinvoicefile'];
                } else {
                    // 3) query + fetch
                    $stmt = $db->query($sql);
                    if ($stmt && method_exists($stmt, 'fetch')) {
                        $f = $stmt->fetch(\PDO::FETCH_ASSOC);
                        if (is_array($f) && isset($f['id_externalinvoicefile'])) {
                            $idFile = (int)$f['id_externalinvoicefile'];
                        }
                    }
                }
            }

            if ($idFile <= 0) {
                $sqlErrNo  = method_exists($db, 'getNumberError') ? $db->getNumberError() : null;
                $sqlErrMsg = method_exists($db, 'getMsgError') ? $db->getMsgError() : null;

                $this->fail(
                    'Este pedido no tiene documentos disponibles.',
                    404
                );
            }

            $row = new ExternalInvoiceFile($idFile);
        }

        // Seguridad por pedido
        $order = new Order((int)$idOrder);
        if (!Validate::isLoadedObject($order)) {
            $this->fail($this->module->l('Pedido no encontrado.', 'download'), 404);
        }
        $isOwner = $isCustomer && ((int)$order->id_customer === (int)$this->context->customer->id);
        if (!$isOwner && !$isEmployee) {
            $this->fail($this->module->l('No autorizado.', 'download'), 403);
        }

        // Ruta física
        $path = ExternalInvoiceFile::getPath($row->stored_name);
        if (!is_file($path) || !is_readable($path)) {
            $this->fail($this->module->l('El fichero físico no existe.', 'download'), 404);
        }

        $mime = !empty($row->mime) ? $row->mime : 'application/octet-stream';
        $name = $row->original_name ? str_replace('"', '', $row->original_name) : basename($path);

        if (ob_get_length()) { @ob_end_clean(); }
        header('Content-Description: File Transfer');
        header('Content-Type: '.$mime);
        header('Content-Disposition: attachment; filename="'.$name.'"');
        header('Content-Length: '.(string)filesize($path));
        header('Cache-Control: private, must-revalidate');
        readfile($path);
        exit; // <— MUY IMPORTANTE: no renderizar nada tras la descarga
    }

    private function fail($msg, $code = 200)
    {
        if (ob_get_length()) { @ob_end_clean(); }
        http_response_code((int)$code);
        header('Content-Type: text/plain; charset=utf-8');
        echo $msg;
        exit; // <— terminar siempre
    }
}
===== C:\Users\rodri\Desktop\externalinvoice\translations\es.php =====
===== C:\Users\rodri\Desktop\externalinvoice\externalinvoice.php =====
name = 'externalinvoice';
        $this->tab = 'administration';
        $this->version = '1.1.0';
        $this->author = 'Tu Agencia';
        $this->need_instance = 0;
        $this->ps_versions_compliancy = ['min' => '1.7.0.0', 'max' => _PS_VERSION_];

        parent::__construct();

        $this->displayName = $this->l('Facturas externas + Adjuntos en pedidos');
        $this->description = $this->l('Desactiva la generación automática de facturas y permite adjuntar archivos a los pedidos desde el back office.');
        $this->confirmUninstall = $this->l('¿Seguro que quieres desinstalar? Se eliminarán los registros de adjuntos.');
    }

    public function install()
    {
        if (!parent::install()) {
            return false;
        }

        if (!$this->installSql() || !$this->ensureUploadDir()) {
            return false;
        }

        foreach (['displayAdminOrderTabLink', 'displayAdminOrderTabContent', 'displayOrderDetail', 'displayExternalInvoiceHistory'] as $hook) {
            if (!$this->registerHook($hook)) {
                return false;
            }
        }

        if (!$this->installAdminTab()) {
            return false;
        }

        Configuration::updateValue('EXTINV_DISABLE_INVOICES', 1);
        if ((int)Configuration::get('EXTINV_DISABLE_INVOICES') === 1) {
            Configuration::updateValue('PS_INVOICE', 0);
        }

        return true;
    }

    public function uninstall()
    {
        Configuration::deleteByName('EXTINV_DISABLE_INVOICES');
        $this->uninstallAdminTab();
        $this->uninstallSql();
        return parent::uninstall();
    }

    public function getContent()
    {
        $output = '';

        if (Tools::isSubmit('submitExternalInvoiceConfig')) {
            $disable = (int)Tools::getValue('EXTINV_DISABLE_INVOICES');
            Configuration::updateValue('EXTINV_DISABLE_INVOICES', $disable);
            if ($disable) {
                Configuration::updateValue('PS_INVOICE', 0);
            }
            $output .= $this->displayConfirmation($this->l('Configuración guardada.'));
        }

        $helper = new HelperForm();
        $helper->show_toolbar = false;
        $helper->default_form_language = (int)Configuration::get('PS_LANG_DEFAULT');
        $helper->identifier = $this->identifier;
        $helper->submit_action = 'submitExternalInvoiceConfig';
        $helper->currentIndex = AdminController::$currentIndex . '&configure=' . $this->name;
        $helper->token = Tools::getAdminTokenLite('AdminModules');
        $helper->module = $this;

        $fields_form = [
            'form' => [
                'legend' => [
                    'title' => $this->l('Ajustes del módulo'),
                    'icon' => 'icon-cogs',
                ],
                'input' => [[
                    'type' => 'switch',
                    'label' => $this->l('Desactivar facturas automáticas'),
                    'name' => 'EXTINV_DISABLE_INVOICES',
                    'desc' => $this->l('Si está activado, deshabilita la generación de facturas en PrestaShop.'),
                    'values' => [
                        ['id' => 'active_on', 'value' => 1, 'label' => $this->l('Sí')],
                        ['id' => 'active_off', 'value' => 0, 'label' => $this->l('No')],
                    ],
                ]],
                'submit' => ['title' => $this->l('Guardar')],
            ],
        ];

        $helper->fields_value['EXTINV_DISABLE_INVOICES'] = (int)Configuration::get('EXTINV_DISABLE_INVOICES');
        return $output . $helper->generateForm([$fields_form]);
    }

    /** Pestaña de adjuntos en el pedido */
    public function hookDisplayAdminOrderTabLink(array $params)
    {
        return $this->context->smarty->fetch($this->local_path . 'views/templates/hook/admin_order_tab.tpl');
    }

    /** Contenido de la pestaña con PRG (Post-Redirect-Get) */
    public function hookDisplayAdminOrderTabContent(array $params)
    {
        $id_order = (int)$params['id_order'];

        // Mostrar mensajes según extinv_msg
        $msg = Tools::getValue('extinv_msg');
        if ($msg === 'uploaded') {
            $this->context->controller->confirmations[] = $this->l('Archivo adjuntado correctamente.');
        } elseif ($msg === 'dup') {
            $this->context->controller->warnings[] = $this->l('Archivo duplicado ignorado.');
        } elseif ($msg === 'error') {
            $this->context->controller->errors[] = $this->l('Error al adjuntar el archivo.');
        } elseif ($msg === 'deleted') {
            $this->context->controller->confirmations[] = $this->l('Archivo eliminado correctamente.');
        }

        // Procesar subida y redirigir (PRG)
        if (((int)Tools::getValue('extinv_id_order') === $id_order) && isset($_FILES['extinv_file'])) {
            $result = $this->handleUpload($id_order);
            $url = $this->context->link->getAdminLink('AdminOrders', true, [], [
                'vieworder' => 1,
                'id_order' => $id_order,
                'extinv_msg' => $result,
            ]);
            Tools::redirectAdmin($url);
            exit;
        }

        // Datos para el template
        $files = ExternalInvoiceFile::getByOrderId($id_order);
        $boToken = $this->context->controller->token;
        $orderViewUrl = $this->context->link->getAdminLink('AdminOrders', true, [], [
            'vieworder' => 1,
            'id_order' => $id_order,
        ]);

        $this->context->smarty->assign([
            'id_order' => $id_order,
            'files' => $files,
            'download_link_base' => $this->context->link->getAdminLink('AdminExternalInvoiceDownload'),
            'order_view_url' => $orderViewUrl,
            'bo_token' => $boToken,
        ]);

        return $this->context->smarty->fetch($this->local_path . 'views/templates/hook/admin_order_tab_content.tpl');
    }

    /** Subida con validación + anti-duplicados. Devuelve 'uploaded' | 'dup' | 'error' */
    private function handleUpload($id_order)
    {
        // Validar token desde campo oculto, URL o controlador actual
        $formToken = (string)Tools::getValue('extinv_token');
        $pageToken = (string)Tools::getValue('token');
        $ordersToken = (string)Tools::getAdminTokenLite('AdminOrders');
        $currentToken = (string)$this->context->controller->token;

        $okToken =
            hash_equals($ordersToken, $formToken) ||
            hash_equals($ordersToken, $pageToken) ||
            ($currentToken && hash_equals($currentToken, $formToken)) ||
            ($currentToken && hash_equals($currentToken, $pageToken));

        if (!$okToken) {
            $this->context->controller->errors[] = $this->l('Token inválido.');
            return 'error';
        }

        if (!isset($_FILES['extinv_file']) || !is_uploaded_file($_FILES['extinv_file']['tmp_name'])) {
            $this->context->controller->errors[] = $this->l('No se ha subido ningún archivo.');
            return 'error';
        }

        $file = $_FILES['extinv_file'];
        $max = (int)Tools::getMaxUploadSize();
        if ($file['error'] !== UPLOAD_ERR_OK) {
            $this->context->controller->errors[] = $this->l('Error al subir el archivo.');
            return 'error';
        }
        if ($file['size'] > $max) {
            $this->context->controller->errors[] = sprintf($this->l('El archivo supera el límite permitido (%s).'), Tools::formatBytes($max));
            return 'error';
        }

        // Evitar duplicados (misma orden, nombre y tamaño en 10 segundos)
        $dup = (int)Db::getInstance()->getValue(
            'SELECT COUNT(*) FROM ' . _DB_PREFIX_ . 'externalinvoicefile
             WHERE id_order=' . (int)$id_order . '
               AND original_name="' . pSQL($file['name']) . '"
               AND filesize=' . (int)$file['size'] . '
               AND TIMESTAMPDIFF(SECOND, date_add, NOW()) < 10'
        );
        if ($dup > 0) {
            return 'dup';
        }

        $dir = _PS_UPLOAD_DIR_ . 'externalinvoice/';
        $this->ensureUploadDir();

        $ext = pathinfo($file['name'], PATHINFO_EXTENSION);
        $stored = sha1(uniqid('', true)) . ($ext ? '.' . $ext : '');

        if (!move_uploaded_file($file['tmp_name'], $dir . $stored)) {
            $this->context->controller->errors[] = $this->l('No se pudo guardar el archivo.');
            return 'error';
        }

        $row = new ExternalInvoiceFile();
        $row->id_order = (int)$id_order;
        $row->original_name = pSQL($file['name']);
        $row->stored_name = pSQL($stored);
        $row->mime = pSQL($file['type']);
        $row->filesize = (int)$file['size'];
        $row->date_add = date('Y-m-d H:i:s');

        if ($row->add()) {
            return 'uploaded';
        }

        @unlink($dir . $stored);
        return 'error';
    }

    /** Crea carpeta de subida */
    private function ensureUploadDir()
    {
        $dir = _PS_UPLOAD_DIR_ . 'externalinvoice/';
        if (!is_dir($dir)) {
            return @mkdir($dir, 0755, true);
        }
        return true;
    }

    /** Instala SQL */
    private function installSql()
    {
        $sql = file_get_contents($this->local_path . 'sql/install.sql');
        $sql = str_replace(['PREFIX_', 'ENGINE_TYPE'], [_DB_PREFIX_, _MYSQL_ENGINE_], $sql);
        return Db::getInstance()->execute($sql);
    }

    private function uninstallSql()
    {
        $sql = file_get_contents($this->local_path . 'sql/uninstall.sql');
        $sql = str_replace(['PREFIX_'], [_DB_PREFIX_], $sql);
        return Db::getInstance()->execute($sql);
    }

    /** Controlador oculto para descargas */
    private function installAdminTab()
    {
        $tab = new Tab();
        $tab->class_name = 'AdminExternalInvoiceDownload';
        $tab->module = $this->name;
        $tab->id_parent = (int)Tab::getIdFromClassName('AdminParentOrders');
        foreach (Language::getLanguages(false) as $lang) {
            $tab->name[$lang['id_lang']] = 'Descarga adjuntos pedidos';
        }
        $tab->active = 0;
        return $tab->add();
    }

    private function uninstallAdminTab()
    {
        $id_tab = (int)Tab::getIdFromClassName('AdminExternalInvoiceDownload');
        if ($id_tab) {
            $tab = new Tab($id_tab);
            return $tab->delete();
        }
        return true;
    }

    public function hookDisplayOrderDetail($params)
    {
        if (!isset($params['order']) || !Validate::isLoadedObject($params['order'])) {
            return '';
        }
        $order = $params['order'];

        // Seguridad: solo el dueño del pedido
        $customer = $this->context->customer;
        if (!$customer || (int)$customer->id !== (int)$order->id_customer) {
            return '';
        }

        // Archivos del pedido
        $files = ExternalInvoiceFile::getByOrderId((int)$order->id);
        if (!$files) {
            return '';
        }

        // URL de descarga para cada fila (con ?id_externalinvoicefile=)
        foreach ($files as &$f) {
            $f['download_url'] = $this->context->link->getModuleLink(
                $this->name,
                'download',
                ['id_externalinvoicefile' => (int)$f['id_externalinvoicefile']],
                true
            );
        }
        unset($f);

        // Pasar datos al tpl
        $this->context->smarty->assign([
            'extinv_files' => $files,
        ]);

        return $this->display(__FILE__, 'views/templates/hook/front_order_files.tpl');
    }

    public function hookDisplayMyAccountOrder($params)
    {
        $id_order = (int)$params['order']['id_order'];

        // Buscar si el pedido tiene algún documento adjunto tipo “Factura externa”
        $files = $this->getFilesByOrder($id_order); // Usa tu propia función si se llama diferente

        if (!$files) {
            return '';
        }

        $this->context->smarty->assign([
            'files' => $files,
        ]);

        return $this->fetch('module:' . $this->name . '/views/templates/hook/account_order_files.tpl');
    }

    public function hookDisplayExternalInvoiceHistory($params)
    {
        $orderRef   = isset($params['order_reference']) ? pSQL($params['order_reference']) : '';
        if (!$orderRef) { return ''; }

        $idCustomer = (int)$this->context->customer->id;
        $idShop     = (int)$this->context->shop->id;

        $rows = Db::getInstance(_PS_USE_SQL_SLAVE_)->executeS('
            SELECT e.id_externalinvoicefile, e.id_order, e.original_name, e.stored_name,
                   e.mime, e.filesize, e.date_add
            FROM `'._DB_PREFIX_.'externalinvoicefile` e
            INNER JOIN `'._DB_PREFIX_.'orders` o ON (o.id_order = e.id_order)
            WHERE o.reference = "'.pSQL($orderRef).'"
              AND o.id_customer = '.$idCustomer.'
              AND o.id_shop = '.$idShop.'
            ORDER BY e.id_externalinvoicefile DESC
            LIMIT 1
        ');

        // Puedes recuperar id_order del primer registro si lo necesitas
        $idOrder = isset($rows[0]['id_order']) ? (int)$rows[0]['id_order'] : 0;

        $this->context->smarty->assign([
            'order_reference' => $orderRef,
            'order_id'        => $idOrder,
            'external_files'  => $rows ?: [],
        ]);

        return $this->fetch('module:externalinvoice/views/templates/hook/externalinvoice.tpl');
    }



}
===== C:\Users\rodri\Desktop\externalinvoice\index.php =====

  

El módulo también incorpora una capa de seguridad para impedir accesos no autorizados. En el frontend, el sistema comprueba que el usuario esté autenticado y que el pedido pertenezca al cliente actual antes de permitir la descarga del documento.

A nivel técnico, este desarrollo demuestra capacidad para crear módulos completos en PrestaShop, trabajar con controladores propios, hooks, permisos, subida de archivos, persistencia en base de datos y lógica segura de descarga documental.

Código representativo

Módulo para controlar transportistas visibles en checkout

Desarrollo de un módulo personalizado para PrestaShop que permite controlar qué transportistas se muestran al cliente durante el checkout, sin desactivar esos transportistas en el Back Office.

La necesidad principal era mantener transportistas disponibles internamente para gestión administrativa o asignación manual de pedidos, pero ocultarlos al cliente en el proceso de compra. Para resolverlo, el módulo filtra dinámicamente las opciones de envío mediante el hook filterShippingDeliveryOptions.

  • Creación de módulo personalizado duyalhidecarriers.
  • Uso del hook filterShippingDeliveryOptions para modificar las opciones visibles en checkout.
  • Panel de configuración con checkboxes para seleccionar transportistas permitidos.
  • Campo adicional para introducir IDs manualmente en formato CSV.
  • Persistencia de configuración mediante Configuration.
  • Obtención dinámica de transportistas activos desde PrestaShop.
  • Filtrado por id_carrier manteniendo intacto el Back Office.
  • Fallback seguro: si no hay coincidencias, se conservan las opciones originales.
name = 'duyalhidecarriers';
        $this->version = '1.1.0';
        $this->author = 'Duyal';
        $this->tab = 'shipping_logistics';
        $this->bootstrap = true;

        parent::__construct();
        $this->displayName = $this->l('Ocultar transportistas en checkout');
        $this->description = $this->l('Muestra solo los transportistas seleccionados en el checkout, manteniendo los demás disponibles en el back-office.');
    }

    public function install()
    {
        return parent::install()
            && Configuration::updateValue(self::CFG_ALLOWED, '')
            && $this->registerHook('filterShippingDeliveryOptions');
    }

    public function uninstall()
    {
        return parent::uninstall()
            && Configuration::deleteByName(self::CFG_ALLOWED);
    }

    public function hookFilterShippingDeliveryOptions($params)
    {
        $allowedIds = $this->getAllowedCarrierIds();
        if (empty($allowedIds)) {
            return $params['delivery_options'];
        }

        $filtered = [];
        foreach ($params['delivery_options'] as $addrKey => $optionList) {
            $kept = [];
            foreach ($optionList as $option) {
                $carrierIds = array_map(function($c){ return (int)$c['id_carrier']; }, $option['carrier_list']);
                if (count(array_intersect($carrierIds, $allowedIds)) > 0) {
                    $kept[] = $option;
                }
            }
            $filtered[$addrKey] = !empty($kept) ? $kept : $optionList;
        }
        return $filtered;
    }

    public function getContent()
    {
        $output = '';

        if (Tools::isSubmit('submitDuyalHideCarriers')) {
            $selected = Tools::getValue('DUYAL_ALLOWED', []);
            if (!is_array($selected)) { $selected = []; }
            $selected = array_unique(array_filter(array_map('intval', $selected)));
            $csv = implode(',', $selected);

            $manualCsv = trim(Tools::getValue('DUYAL_ALLOWED_CSV', ''));
            if ($manualCsv !== '') {
                $extra = array_filter(array_map('intval', preg_split('/[,\s;]+/', $manualCsv)));
                $selected = array_unique(array_merge($selected, $extra));
                $csv = implode(',', $selected);
            }

            Configuration::updateValue(self::CFG_ALLOWED, $csv);
            $output .= $this->displayConfirmation($this->l('Configuración guardada.'));
        }

        $carriers = $this->getAllActiveCarriers();
        $allowed = $this->getAllowedCarrierIds();
        $helper = new HelperForm();
        $helper->module = $this;
        $helper->name_controller = $this->name;
        $helper->identifier = $this->identifier;
        $helper->token = Tools::getAdminTokenLite('AdminModules');
        $helper->currentIndex = AdminController::$currentIndex.'&configure='.$this->name;
        $helper->show_toolbar = false;
        $helper->table = $this->table;
        $helper->submit_action = 'submitDuyalHideCarriers';
        $helper->default_form_language = (int)Configuration::get('PS_LANG_DEFAULT');

        $inputs = [];

        $inputs[] = [
            'type' => 'checkbox',
            'label' => $this->l('Transportistas a mostrar en checkout'),
            'name'  => 'DUYAL_ALLOWED',
            'desc'  => $this->l('Marca los transportistas que los clientes podrán ver en el checkout. Los no marcados seguirán disponibles en el Back-Office para asignarlos al enviar.'),
            'values' => [
                'query' => array_map(function($c){
                    return [
                        'id' => (int)$c['id_carrier'],
                        'name' => sprintf('#%d — %s', (int)$c['id_carrier'], $c['name']),
                        'val' => (int)$c['id_carrier'],
                    ];
                }, $carriers),
                'id'   => 'id',
                'name' => 'name'
            ],
        ];

        $inputs[] = [
            'type' => 'text',
            'label' => $this->l('IDs manuales (CSV opcional)'),
            'name' => 'DUYAL_ALLOWED_CSV',
            'desc' => $this->l('Puedes pegar IDs separados por comas (ej. 12,34). Se combinarán con la selección de arriba.'),
        ];

        $form = [
            'form' => [
                'legend' => [
                    'title' => $this->l('Configuración de visibilidad de transportistas'),
                    'icon'  => 'icon-truck'
                ],
                'input' => $inputs,
                'submit' => [
                    'title' => $this->l('Guardar'),
                    'class' => 'btn btn-primary'
                ]
            ]
        ];

        $fieldsValue = [];
        foreach ($carriers as $c) {
            $fieldsValue['DUYAL_ALLOWED_'.(int)$c['id_carrier']] = in_array((int)$c['id_carrier'], $allowed, true);
        }
        $fieldsValue['DUYAL_ALLOWED_CSV'] = implode(',', $allowed);

        $helper->fields_value = $fieldsValue;

        return $output.$helper->generateForm([$form]);
    }

    protected function getAllActiveCarriers()
    {
        $context = Context::getContext();
        $id_lang = (int)$context->language->id;

        $carriers = Carrier::getCarriers(
            $id_lang,
            true,
            false,
            false,
            null,
            Carrier::ALL_CARRIERS
        );

        usort($carriers, function($a,$b){ return (int)$a['id_carrier'] <=> (int)$b['id_carrier']; });

        return $carriers;
    }

    protected function getAllowedCarrierIds()
    {
        $csv = trim((string)Configuration::get(self::CFG_ALLOWED));
        if ($csv === '') { return []; }
        $ids = array_filter(array_map('intval', preg_split('/[,\s;]+/', $csv)));
        $ids = array_values(array_unique($ids));
        return $ids;
    }
}

El módulo incluye una pantalla de configuración dentro del Back Office desde la que se pueden seleccionar los transportistas visibles para el cliente. Esto permite modificar el comportamiento del checkout sin tocar código cada vez que cambia una regla logística.

A nivel técnico, este desarrollo demuestra capacidad para extender el checkout de PrestaShop mediante hooks, crear paneles de configuración con HelperForm, trabajar con configuraciones persistentes y resolver una necesidad logística real sin alterar el funcionamiento interno de los pedidos.

Código representativo

Generador PDF personalizado para contratos en Dolibarr

Desarrollo de un modelo PDF completamente personalizado para Dolibarr orientado a la generación automática de contratos y documentos de encargo inmobiliario.

El objetivo del proyecto era sustituir las plantillas estándar del ERP por documentos corporativos totalmente adaptados a la imagen de la empresa, incorporando datos dinámicos del cliente, contratos, importes, cláusulas legales, firmas y diseño personalizado.

  • Desarrollo de modelo PDF personalizado para Dolibarr.
  • Generación dinámica de contratos a partir de datos reales del ERP.
  • Integración con terceros, contratos y líneas de producto.
  • Diseño visual completamente personalizado.
  • Inserción dinámica de datos fiscales y dirección del cliente.
  • Sistema de firmas y zonas de validación documental.
  • Carga automática de logotipos corporativos.
  • Generación de cláusulas legales dinámicas.
  • Compatibilidad con TCPDF y sistema documental de Dolibarr.
/* Copyright (C) ...  (mantengo tu cabecera) */

/**
 *  \file       htdocs/core/modules/contract/doc/pdf_loremipsum.modules.php
 *  \ingroup    ficheinter
 *  \brief      Strato contracts template class file (modificado para Documento Lorem Ipsum)
 */
require_once DOL_DOCUMENT_ROOT.'/core/modules/contract/modules_contract.php';
require_once DOL_DOCUMENT_ROOT.'/core/lib/company.lib.php';
require_once DOL_DOCUMENT_ROOT.'/core/lib/pdf.lib.php';
require_once DOL_DOCUMENT_ROOT.'/core/lib/date.lib.php';
require_once DOL_DOCUMENT_ROOT.'/product/class/product.class.php';
require_once DOL_DOCUMENT_ROOT.'/core/lib/functions2.lib.php';
require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php';

/**
 *  Class to build contracts documents with model Strato
 */
class pdf_doccompradores extends ModelePDFContract
{
    /** @var DoliDB */
    public $db;
    public $entity;
    public $name;
    public $description;
    public $update_main_doc_field;
    public $type;
    public $version = 'dolibarr';
    /** @var Societe */
    public $recipient;

    public function __construct($db)
    {
        global $langs, $mysoc;

        $this->db = $db;
        $this->name = 'doccompradores'; // mantenemos el nombre para que te siga apareciendo "strato" en el selector
        $this->description = "Documento lorem ipsum";

        $this->update_main_doc_field = 1;

        $this->type = 'pdf';
        $formatarray = pdf_getFormat();

        $this->page_largeur = $formatarray['width'];
        $this->page_hauteur = $formatarray['height'];
        $this->format = array($this->page_largeur, $this->page_hauteur);
        $this->marge_gauche = getDolGlobalInt('MAIN_PDF_MARGIN_LEFT', 20);
        $this->marge_droite = getDolGlobalInt('MAIN_PDF_MARGIN_RIGHT', 20);
        $this->marge_haute  = getDolGlobalInt('MAIN_PDF_MARGIN_TOP', 20);
        $this->marge_basse  = getDolGlobalInt('MAIN_PDF_MARGIN_BOTTOM', 20);
        $this->corner_radius = getDolGlobalInt('MAIN_PDF_FRAME_CORNER_RADIUS', 0);
        $this->option_logo = 1;

        if ($mysoc === null) {
            dol_syslog(get_class($this).'::__construct() Global $mysoc should not be null.'. getCallerInfoString(), LOG_ERR);
            return;
        }
        $this->emetteur = $mysoc;
        if (empty($this->emetteur->country_code)) {
            $this->emetteur->country_code = substr($langs->defaultlang, -2);
        }
    }

    // Helper: bloque de campo con fondo gris y líneas de escritura
    private function drawFieldBlock($pdf, $labelA, $labelB, $x, $y, $page_width, $margin_right, $labelW = 42, $numLines = 4, $lineH = 10)
    {
        // Colores
        $bg = [242,242,242];
        $line = [120,120,120];
        $text = [40,40,40];

        $boxX = $x + $labelW;
        $boxW = $page_width - $margin_right - $boxX;
        $boxH = $numLines * $lineH;

        // Fondo gris
        $pdf->SetFillColor($bg[0], $bg[1], $bg[2]);
        $pdf->Rect($boxX, 40, $boxW, 43.5, 'F');

        // Etiqueta
        $pdf->SetTextColor($text[0], $text[1], $text[2]);
        $pdf->SetXY($x+5, 41);
        $pdf->Cell($labelW - 6, 7, $labelA, 0, 0, 'R');

        // Etiqueta
        $pdf->SetTextColor($text[0], $text[1], $text[2]);
        $pdf->SetXY($x+5, 67.5);
        $pdf->Cell($labelW - 6, 7, $labelB, 0, 0, 'R');

        // Líneas
        $pdf->SetDrawColor($line[0], $line[1], $line[2]);
        $pdf->SetLineWidth(0.3);
        for ($i = 1; $i <= $numLines; $i++) {
            $yy = 42 + $i * $lineH - 2;
            $pdf->Line($boxX + 0, $yy, $boxX + $boxW - 0, $yy);
        }

        return $y + $boxH + 6;
    }



    // =====================================================================
    //  MÉTODO REESCRITO: genera el “Documento Lorem Ipsum” directamente
    // =====================================================================
    public function write_file($object, $outputlangs, $srctemplatepath = '', $hidedetails = 0, $hidedesc = 0, $hideref = 0)
    {
        global $user, $langs, $conf, $hookmanager;

        if (!is_object($outputlangs)) $outputlangs = $langs;
        
        if (getDolGlobalString('MAIN_USE_FPDF')) {
            $this->error = 'This template requires TCPDF. Disable MAIN_USE_FPDF in Setup → PDF.';
            return -1;
        }
        $object->fetch_thirdparty();
        $this->recipient = $object->thirdparty;
        $client_name = $this->recipient->name;
        $client_dni  = $this->recipient->idprof1; // en España se suele usar idprof1 = NIF/DNI
        $client_addr = $this->recipient->address;
        $client_zip  = $this->recipient->zip;
        $client_town = $this->recipient->town;
        $client_country = $this->recipient->country;   // objeto país (opcional)

        // Componer dirección completa
        $client_full_address = trim($client_addr);

        $outputlangs->loadLangs(array('main','contracts','companies'));



        // --------- Definir carpeta/archivo de salida ----------
        if (empty($conf->contract->multidir_output[$conf->entity])) {
            $this->error = $langs->transnoentities("ErrorConstantNotDefined","CONTRACT_OUTPUTDIR");
            return 0;
        }
        $objectref = dol_sanitizeFileName($object->ref);
        $dir  = getMultidirOutput($object)."/".$objectref;
        $file = $dir."/".$objectref.".pdf";

        if (!file_exists($dir) && dol_mkdir($dir) < 0) {
            $this->error = $langs->transnoentitiesnoconv("ErrorCanNotCreateDir", $dir);
            return 0;
        }

        // --------- Hooks before PDF ----------
        if (!is_object($hookmanager)) {
            include_once DOL_DOCUMENT_ROOT.'/core/class/hookmanager.class.php';
            $hookmanager = new HookManager($this->db);
        }
        $hookmanager->initHooks(array('pdfgeneration'));

        global $action;
        $action = '';                               // debe ser variable, no literal
        $parameters = array('file'=>$file,'object'=>$object,'outputlangs'=>$outputlangs);
        $reshook = $hookmanager->executeHooks('beforePDFCreation', $parameters, $object, $action);
        if ($reshook < 0) { $this->error = $hookmanager->error; return 0; }

        // --------- Crear PDF ----------
        $pdf = pdf_getInstance(array($this->page_largeur, $this->page_hauteur));
        $default_font      = pdf_getPDFFont($outputlangs);
        $default_font_size = pdf_getPDFFontSize($outputlangs);

        if (class_exists('TCPDF')) { $pdf->setPrintHeader(false); $pdf->setPrintFooter(false); }
        $pdf->SetCreator("Dolibarr ".DOL_VERSION);
        $pdf->SetAuthor($outputlangs->convToOutputCharset($user->getFullName($outputlangs)));
        $pdf->SetTitle($outputlangs->convToOutputCharset($object->ref));
        $pdf->SetSubject($outputlangs->transnoentities("Contract"));
        if (getDolGlobalString('MAIN_DISABLE_PDF_COMPRESSION')) $pdf->SetCompression(false);

        // Márgenes
        $pdf->SetMargins($this->marge_gauche, $this->marge_haute, $this->marge_droite);
        $pdf->SetAutoPageBreak(true, $this->marge_basse);
        $pdf->SetFont($default_font, '', $default_font_size);

        // --------- Página ----------
        $pdf->AddPage();

        // (Opcional) Logo arriba centrado
        if (!getDolGlobalString('PDF_DISABLE_MYCOMPANY_LOGO') && !empty($this->emetteur->logo)) {
            $logodir = !empty($conf->mycompany->multidir_output[$object->entity]) ? $conf->mycompany->multidir_output[$object->entity] : $conf->mycompany->dir_output;
            $logo = $logodir.'/logos/'.$this->emetteur->logo;
            if (is_readable($logo)) {
                $w = 45; // ancho deseado
                $x = ($this->page_largeur - $this->marge_gauche - $this->marge_droite - $w)/2 + $this->marge_gauche;
                $pdf->Image($logo, $x, $this->marge_haute, $w, 0);
            }
        }

        // ======== CONTENIDO DEL DOCUMENTO ========

        // ==== Cabecera estilizada a todo el ancho ====
        // Requisitos previos: $pdf, $default_font, $default_font_size, $this->marge_gauche, $this->marge_droite, $this->page_largeur

        // Colores
        $bg = array(34,45,55);         // fondo
        $white = array(255,255,255);   // texto

        // Geometría del banner
        $X = 0;
        $Y = 0;                        // pegado arriba
        $W = $this->page_largeur;      // ancho total
        $H = 33;                       // alto de la franja
        $padX = $this->marge_gauche;   // “padding” lateral para el texto
        $padY = 6;                     // “padding” superior para primera línea

        // 1) Fondo
        $pdf->SetFillColor($bg[0], $bg[1], $bg[2]);
        $pdf->Rect($X, $Y, $W, $H, 'F');

        // 2) Título y subtítulo en blanco
        $pdf->SetTextColor($white[0], $white[1], $white[2]);

        // Título
        $pdf->SetFont($default_font, 'B', $default_font_size + 8);
        $pdf->SetXY($padX, $Y + $padY);
        $pdf->Cell(0, 6, $outputlangs->convToOutputCharset('Lorem Ipsum Dolor Sit Amet'), 0, 1, 'L');

        // Subtítulo (línea siguiente, estilo fino)
        $pdf->SetFont($default_font, '', $default_font_size + 16);
        $pdf->SetXY($padX, $Y + $padY + 8);
        $pdf->Cell(0, 6, $outputlangs->convToOutputCharset('loremipsum.example.com'), 0, 1, 'L');

        // 3) Logo a la derecha dentro de la franja
        // (ajusta $logoPath si lo cargas desde Dolibarr)
        $logoW = 36;                   // ancho deseado del logo en mm
        $logoPadRight = $this->marge_droite;  // padding derecho
        $logoX = $W - $logoPadRight - $logoW; // colocación a la derecha
        $logoY = $Y + 4;               // alineación vertical dentro del banner

        // === Logo a la derecha dentro del banner ===
        if (!getDolGlobalString('PDF_DISABLE_MYCOMPANY_LOGO')) {
            $logodir = !empty($conf->mycompany->multidir_output[$object->entity])
                ? $conf->mycompany->multidir_output[$object->entity]
                : $conf->mycompany->dir_output;

            // Candidatos de logo (grande y miniatura)
            $logo_large = $logodir.'/logos/'.$this->emetteur->logo;               // ej: mycompany/xxx/logos/mi_logo.png
            $logo_small = $logodir.'/logos/thumbs/'.$this->emetteur->logo_small;  // ej: .../thumbs/mi_logo_small.png

            // Elige el primero legible
            $logo_path = '';
            if (!empty($this->emetteur->logo) && is_readable($logo_large)) {
                $logo_path = $logo_large;
            } elseif (!empty($this->emetteur->logo_small) && is_readable($logo_small)) {
                $logo_path = $logo_small;
            } else {
                dol_syslog(__METHOD__.': logo no encontrado: '.$logo_large.' o '.$logo_small, LOG_WARNING);
            }

            if ($logo_path) {
                $logoW = 30;                                  // ancho deseado
                $logoX = $this->page_largeur - $this->marge_droite - $logoW;
                $logoY = 4;                                   // dentro de la franja
                $pdf->Image($logo_path, $logoX, $logoY, $logoW, 0); // alto proporcional
            }
        }


        // 4) Restaurar color de texto y dejar el cursor debajo del banner
        $pdf->SetTextColor(0,0,0);
        $pdf->SetY($Y + $H + 6);       // cursor listo para el contenido siguiente
        // ============================================

        $x = $this->marge_gauche;
        $y = max($this->marge_haute + 15, 0);
        $pdf->SetXY($x, $y);

        $pdf->SetFont($default_font, '', $default_font_size + 1);

        $x = $this->marge_gauche;
        $y = /* donde empieces tus campos, p.ej. */ $pdf->GetY() + 10;

        // Nombre/s (4 líneas)
        $y = $this->drawFieldBlock(
            $pdf,
            $outputlangs->convToOutputCharset('Lorem:'),
            $outputlangs->convToOutputCharset('Ipsum:'),
            $x,
            $y,
            $this->page_largeur,
            $this->marge_droite,
            15,
            5,
            8.8
        );

        $pdf->SetFont($default_font, '', $default_font_size + 4);
        // Nombre
        $pdf->SetXY($x + 17, $y - 53);
        $pdf->Cell(100, 6, $outputlangs->convToOutputCharset($client_name), 0, 1, 'L');

        // DNI
        $pdf->SetXY($x + 17, $y - 27);
        $pdf->Cell(100, 6, $outputlangs->convToOutputCharset($client_dni), 0, 1, 'L');

        // (opcional) volver a negro para el resto del documento
        $pdf->SetTextColor(0,0,0);

        // Enunciado
        $pdf->SetXY($x, $y-7);
        $pdf->SetFont($default_font, 'B', $default_font_size + 2);

        // Color grisáceo
        $pdf->SetTextColor(80, 80, 80); // prueba con 80-80-80 o 100-100-100 según quieras más claro/oscuro

        // Juntar letras (tracking negativo)
        $pdf->setFontSpacing(-0.3); // valores entre -0.2 y -0.5 funcionan bien

        // Texto en mayúsculas, centrado
        $pdf->Cell(
            0, 8,
            $outputlangs->convToOutputCharset('LOREM IPSUM DOLOR SIT AMET CONSECTETUR:'),
            0, 1, 'C'
        );

        // Restaurar tracking para el resto del documento
        $pdf->setFontSpacing(0);



        // Línea subrayada (del ancho del texto)
        $curY = $pdf->GetY();
        $pdf->Line($this->marge_gauche+10, $curY-2, $this->page_largeur - $this->marge_droite-9, $curY-2);

        $y = $curY + 3; // espacio después
        $pdf->SetTextColor(0,0,0);

        $W = $this->page_largeur - $this->marge_gauche - $this->marge_droite;
        $par = function($html) use ($pdf, $x, &$y, $W, $outputlangs) {
            $pdf->writeHTMLCell($W, 0, $x, $y, $outputlangs->convToOutputCharset($html), 0, 1, false, true, 'L');
            $y = $pdf->GetY() + 2;
        };

        // Dirección
        $pdf->SetFont($default_font, '', $default_font_size);
        $pdf->SetXY($x + 70, $y + 4.4);
        $pdf->MultiCell(
            130, 
            1, 
            $outputlangs->convToOutputCharset($client_full_address), 
            0, 
            'L'
        );

        // Ciudad
        $pdf->SetFont($default_font, '', $default_font_size);
        $pdf->SetXY($x + 25, $y + 4.4);
        $pdf->MultiCell(
            130, 
            1, 
            $outputlangs->convToOutputCharset($client_town), 
            0, 
            'L'
        );

        
        $pdf->SetFont($default_font, '', $default_font_size);
        $pdf->SetXY($x + 36, $y + 9);
        $pdf->MultiCell(
            130, 
            1, 
            $outputlangs->convToOutputCharset("Lorem Ipsum Empresa S.L."), 
            0, 
            'L'
        );

        // PRIMERO
        $pdf->SetFont($default_font, 'B', $default_font_size + 1);
        $pdf->SetXY($x, $y);




        $pdf->SetFont($default_font, '', $default_font_size);
        $par('PRIMERO.- Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer non lorem vitae augue facilisis placerat. Sed vitae lorem at ipsum malesuada consequat.');

        // SEGUNDO
        if (!empty($object->lines)) {
            $line = $object->lines[0]; // primera línea del contrato
            $unitPriceHT = $line->subprice; // precio unitario sin impuestos
        } else {
            $unitPriceHT = 0;
        }
        $pdf->SetFont($default_font,'',$default_font_size);
        $curX = $pdf->GetX();
        $curY = $pdf->GetY();

        $html = ''.price($unitPriceHT).'';
        $pdf->writeHTMLCell(0, 20, 130, $curY + 15, $html, 0, 0, false, true, 'L');

        $pdf->SetFont($default_font, 'B', $default_font_size + 1);
        $pdf->SetXY($x, $y);
        $pdf->SetFont($default_font, '', $default_font_size);
        $par('SEGUNDO.- Que ha sido informada por la agencia de la posibilidad de utilizar un procedimiento de compra mediante subasta a través del portal web “loremipsum.example.com”, que consiste en la exposición en el mismo de la vivienda objeto del encargo para proceder a su venta mediante el sistema de LOREM IPSUM partiendo, para este inmueble, de un PRECIO DE SALIDA de __________________ €.');

        // TERCERO
        $pdf->SetFont($default_font, 'B', $default_font_size + 1);
        $pdf->SetXY($x, $y);
        $pdf->SetFont($default_font, '', $default_font_size);
        $par('TERCERO.- Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse potenti:');

        $leftIndent = 6;  $x2 = $x + $leftIndent;  $W2 = $W - $leftIndent;

        $pdf->writeHTMLCell($W2, 0, $x2, $y, $outputlangs->convToOutputCharset(
            '1. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec vitae neque sed lorem tincidunt dictum.'
        ), 0, 1, false, true, 'L');
        $y = $pdf->GetY() + 2;

        $pdf->writeHTMLCell($W2, 0, $x2, $y, $outputlangs->convToOutputCharset(
            '2. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer non justo sed risus tempor pretium.'
        ), 0, 1, false, true, 'L');
        $y = $pdf->GetY() + 2;

        $pdf->writeHTMLCell($W2, 0, $x2, $y, $outputlangs->convToOutputCharset(
            '3. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent feugiat, lorem at facilisis tincidunt, neque neque vulputate eros, vitae consequat ipsum lorem nec lorem.'
        ), 0, 1, false, true, 'L');
        $y = $pdf->GetY() + 3;


        $pdf->writeHTMLCell($W2, 0, $x2, $y, $outputlangs->convToOutputCharset(
            '4. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur tempus, lorem non tempor gravida, ipsum erat congue lorem, sed cursus ipsum lorem vitae lorem.'
        ), 0, 1, false, true, 'L');
        $y = $pdf->GetY() + 4;

        // Lugar y fecha
        $pdf->SetXY($x, $y);
        $pdf->Cell(
            0, 6, 
            $outputlangs->convToOutputCharset("En _______________________ a _______ de _______________________ de _____________"),
            0, 1, 'C'
        );
        $pdf->SetY($pdf->GetY() - 6);
        $pdf->SetX(50);
        $pdf->Cell(0, 6, $outputlangs->convToOutputCharset("Lorem Ipsum"), 0, 1, 'L');
        $pdf->SetY($pdf->GetY() - 6);
        $pdf->SetX(88);
        $pdf->Cell(0, 6, $outputlangs->convToOutputCharset(dol_print_date(dol_now(), '%d')), 0, 1, 'L');
        $pdf->SetY($pdf->GetY() - 6);
        $pdf->SetX(115);
        $pdf->Cell(0, 6, $outputlangs->convToOutputCharset(dol_print_date(dol_now(), '%B')), 0, 1, 'L');
        $pdf->SetY($pdf->GetY() - 6);
        $pdf->SetX(161);
        $pdf->Cell(0, 6, $outputlangs->convToOutputCharset(dol_print_date(dol_now(), '%Y')), 0, 1, 'L');
        $y += 18;

        // === Bloque de firmas (2 columnas) ===
        $labelGray = array(130,130,130);
        $boxBg     = array(247,247,247);      // gris muy claro
        $lineGray  = array(150,150,150);      // línea inferior
        $footerBar = array(238,169,89);       // banda inferior (naranja suave)

        // Geometría
        $gap   = 28;                          // separación entre columnas
        $colW  = 100;             // ancho de cada columna
        $leftX = $x;
        $rightX= 100;
        $yRow  = $y-10;                          // punto de inicio

        // Etiquetas grises y centradas
        $pdf->SetFont($default_font, '', $default_font_size);    // no negrita, como en la imagen
        $pdf->SetTextColor($labelGray[0], $labelGray[1], $labelGray[2]);
        $pdf->SetXY($leftX, $yRow);
        $pdf->Cell($colW, 6, $outputlangs->convToOutputCharset('Lorem ipsum'), 0, 0, 'C');
        $pdf->SetXY($rightX, $yRow);
        $pdf->Cell($colW, 6, $outputlangs->convToOutputCharset('Lorem dolor'), 0, 1, 'C');

        $yRow += 8;                            // pequeño margen debajo del texto

        // Cajas para firmar (relleno claro + línea inferior)
        $padX  = 18;                           // padding lateral dentro de la columna
        $boxW  = $colW - ($padX * 2);          // ancho de la caja
        $boxH  = 28;                           // alto de la caja (ajusta si quieres más/menos)

        $leftBoxX  = $leftX  + $padX;
        $rightBoxX = $rightX + $padX;
        $boxY      = $yRow;

        $pdf->SetFillColor($boxBg[0], $boxBg[1], $boxBg[2]);
        $pdf->Rect($leftBoxX,  $boxY, $boxW, $boxH, 'F');
        $pdf->Rect($rightBoxX, $boxY, $boxW, $boxH, 'F');

        // Línea fina sólo en la parte inferior de cada caja
        $pdf->SetDrawColor($lineGray[0], $lineGray[1], $lineGray[2]);
        $pdf->SetLineWidth(0.3);
        $pdf->Line($leftBoxX,  $boxY + $boxH, $leftBoxX  + $boxW, $boxY + $boxH);
        $pdf->Line($rightBoxX, $boxY + $boxH, $rightBoxX + $boxW, $boxY + $boxH);

        // Avanza Y para continuar el documento
        $y = $boxY + $boxH + 14;

        // === Banda de color al pie de página, a todo el ancho ===
        $barH = 8;  // alto de la banda
        $barY = $this->page_hauteur - $this->marge_basse - $barH+25;
        $pdf->SetFillColor($footerBar[0], $footerBar[1], $footerBar[2]);
        $pdf->Rect(0, $barY, $this->page_largeur, $barH, 'F');

        // (opcional) devuelve color de texto a negro para lo siguiente
        $pdf->SetTextColor(0,0,0);


        // --------- Guardar ----------
        $pdf->Close();
        $pdf->Output($file, 'F');
        dolChmod($file);

        // Hooks after
        $hookmanager->initHooks(array('pdfgeneration'));
        $parameters = array('file'=>$file,'object'=>$object,'outputlangs'=>$outputlangs);
        $hookmanager->executeHooks('afterPDFCreation', $parameters, $object, $action);

        $this->result = array('fullpath'=>$file);
        return 1;
    }


    // ---------------------------------------------------------------------------------
    // El resto de métodos de tu archivo (tabla, firmas por defecto, cabecera/pie genéricos)
    // no son necesarios para este modelo, pero los dejamos por compatibilidad.
    // ---------------------------------------------------------------------------------

    protected function _tableau(&$pdf, $tab_top, $tab_height, $nexY, $outputlangs, $hidetop = 0, $hidebottom = 0)
    {
        $hidebottom = 0;
        if ($hidetop) $hidetop = -1;
        $this->printRoundedRect($pdf, $this->marge_gauche, $tab_top, $this->page_largeur - $this->marge_gauche - $this->marge_droite, $tab_height + 3, $this->corner_radius, $hidetop, $hidebottom, 'D');
    }

    protected function tabSignature(&$pdf, $tab_top, $tab_height, $outputlangs)
    {
        $pdf->SetDrawColor(128,128,128);
        $posmiddle = $this->marge_gauche + round(($this->page_largeur - $this->marge_gauche - $this->marge_droite) / 2);
        $posy = $tab_top + $tab_height + 6;
        $pdf->SetXY($this->marge_gauche, $posy);
        $pdf->MultiCell($posmiddle - $this->marge_gauche - 5, 5, $outputlangs->transnoentities("ContactNameAndSignature", $this->emetteur->name), 0, 'L', 0);
        $pdf->SetXY($posmiddle + 5, $posy);
        $pdf->MultiCell($this->page_largeur - $this->marge_droite - $posmiddle - 5, 5, $outputlangs->transnoentities("ContactNameAndSignature", $this->recipient->name), 0, 'L', 0);
    }

    protected function _pagehead(&$pdf, $object, $showaddress, $outputlangs, $outputlangsbis = null, $titlekey = "Contract")
    {
        // No usamos esta cabecera en este modelo (dibujamos manual en write_file),
        // pero la dejamos por compatibilidad si se llamara desde fuera.
        return 0;
    }

    protected function _pagefoot(&$pdf, $object, $outputlangs, $hidefreetext = 0)
    {
        // Pie estándar si alguna vez lo necesitas:
        return pdf_pagefoot($pdf, $outputlangs, 'CONTRACT_FREE_TEXT', $this->emetteur, $this->marge_basse, $this->marge_gauche, $this->page_hauteur, $object, getDolGlobalInt('MAIN_GENERATE_DOCUMENTS_SHOW_FOOT_DETAILS', 0), $hidefreetext, $this->page_largeur, $this->watermark);
    }
}

Uno de los aspectos más importantes fue la construcción manual de toda la maquetación PDF mediante TCPDF. El documento genera cabeceras corporativas, bloques de información, campos dinámicos, zonas de firma y estilos visuales personalizados sin depender de plantillas externas.

private function drawFieldBlock(
    $pdf,
    $labelA,
    $labelB,
    $x,
    $y,
    $page_width,
    $margin_right,
    $labelW = 42,
    $numLines = 4,
    $lineH = 10
)
{
    $pdf->SetFillColor(242,242,242);

    $pdf->Rect(
        $boxX,
        $y,
        $boxW,
        $boxH,
        'F'
    );

    for ($i = 1; $i <= $numLines; $i++) {
        $pdf->Line(
            $boxX,
            $yy,
            $boxX + $boxW,
            $yy
        );
    }
}

Además del diseño visual, el sistema recupera automáticamente información de contratos, terceros y productos registrados en Dolibarr, permitiendo generar documentación completamente personalizada sin intervención manual.

$object->fetch_thirdparty();

$client_name = $object->thirdparty->name;
$client_dni  = $object->thirdparty->idprof1;

if (!empty($object->lines)) {
    $line = $object->lines[0];
    $unitPriceHT = $line->subprice;
}

$pdf->writeHTMLCell(
    $W,
    0,
    $x,
    $y,
    $html,
    0,
    1
);

A nivel técnico, este desarrollo demuestra capacidad para extender sistemas ERP, trabajar con TCPDF, generar documentos complejos desde código, integrar datos empresariales dinámicos y construir soluciones documentales adaptadas a procesos reales de negocio.

Código representativo

Módulo de validación de documento fiscal en PrestaShop

Desarrollo de un módulo personalizado para PrestaShop orientado a mejorar la calidad de los datos fiscales introducidos por los clientes durante el registro, edición de dirección y proceso de compra.

El módulo valida el campo de documento fiscal, bloquea valores ficticios o genéricos y, en el caso de España, permite aplicar una validación estricta de NIF, NIE y CIF. La solución se integra directamente en los formularios de dirección y puede revalidar los datos antes de crear el pedido.

  • Creación de módulo personalizado psdocvalidator.
  • Clase dedicada PsDocValidatorDocumentValidator para separar la lógica de validación.
  • Normalización del documento eliminando espacios, puntos, guiones y barras.
  • Detección de documentos falsos mediante blacklist y patrones sospechosos.
  • Validación estricta de documentos españoles: DNI, NIE y CIF.
  • Panel de configuración con longitudes mínimas/máximas y blacklist editable.
  • Integración con actionValidateCustomerAddressForm.
  • Revalidación opcional antes de crear el pedido con actionValidateOrderBefore.
  • Mensajes de error integrados en el propio formulario de dirección.
===== C:\Users\rodri\Desktop\psdocvalidator\classes\DocumentValidator.php =====
module = $module;
    }

    /**
     * Validate document according to country ISO code.
     *
     * @param string $document
     * @param string $countryIso
     *
     * @return array{valid:bool,message:string}
     */
    public function validate($document, $countryIso)
    {
        $document = $this->normalize($document);
        $countryIso = Tools::strtoupper((string) $countryIso);

        if ($document === '') {
            return [
                'valid' => false,
                'message' => $this->module->l('El documento fiscal es obligatorio.', 'DocumentValidator'),
            ];
        }

        if ($this->isFakeDocument($document)) {
            return [
                'valid' => false,
                'message' => $this->module->l('El documento fiscal introducido no parece válido.', 'DocumentValidator'),
            ];
        }

        if ($countryIso === 'ES' && (bool) Configuration::get('PSDOCVALIDATOR_STRICT_ES')) {
            if (!$this->isValidSpanishDocument($document)) {
                return [
                    'valid' => false,
                    'message' => $this->module->l('El NIF/NIE/CIF introducido no es válido.', 'DocumentValidator'),
                ];
            }
        }

        return [
            'valid' => true,
            'message' => '',
        ];
    }

    /**
     * Normalize the document removing spaces, dots, hyphens and slashes.
     *
     * @param string $value
     *
     * @return string
     */
    public function normalize($value)
    {
        $value = Tools::strtoupper(trim((string) $value));
        $value = preg_replace('/[\s\.\-\/]+/', '', $value);

        return (string) $value;
    }

    /**
     * Detect obviously fake documents.
     *
     * @param string $value
     *
     * @return bool
     */
    public function isFakeDocument($value)
    {
        $value = $this->normalize($value);

        if ($value === '') {
            return true;
        }

        $len = Tools::strlen($value);
        $minLen = (int) Configuration::get('PSDOCVALIDATOR_MIN_LENGTH');
        $maxLen = (int) Configuration::get('PSDOCVALIDATOR_MAX_LENGTH');

        if ($minLen <= 0) {
            $minLen = 5;
        }

        if ($maxLen <= 0) {
            $maxLen = 20;
        }

        if ($len < $minLen || $len > $maxLen) {
            return true;
        }

        if (!preg_match('/^[A-Z0-9]+$/', $value)) {
            return true;
        }

        if (preg_match('/^(.)\1+$/', $value)) {
            return true;
        }

        $blacklist = $this->getBlacklist();
        if (in_array($value, $blacklist, true)) {
            return true;
        }

        // Bloquear secuencias tipo 123456 o 654321 automáticamente
        if (preg_match('/^(0123|1234|2345|3456|4567|5678|6789)/', $value)) {
            return true;
        }

        if (preg_match('/^(9876|8765|7654|6543|5432|4321)/', $value)) {
            return true;
        }

        // Bloquear patrones repetidos tipo ABABAB o 121212
        if (preg_match('/^(.{1,3})\1+$/', $value)) {
            return true;
        }

        // Bloquear solo letras sin sentido
        if (preg_match('/^[A-Z]+$/', $value) && strlen($value) < 6) {
            return true;
        }


        $numericSequences = [
            '01234', '012345', '0123456', '01234567', '012345678', '0123456789',
            '12345', '123456', '1234567', '12345678', '123456789', '1234567890',
            '98765', '987654', '9876543', '98765432', '987654321', '9876543210',
            '87654', '876543', '8765432', '87654321',
        ];

        if (in_array($value, $numericSequences, true)) {
            return true;
        }

        if (preg_match('/^(.{1,3})\1+$/', $value)) {
            return true;
        }

        if (preg_match('/^[A-Z]+$/', $value)) {
            $alphaBlacklist = [
                'DOCUMENTO', 'DOC', 'DNI', 'NIF', 'CIF', 'VAT', 'FISCAL', 'PRUEBA',
            ];

            if (in_array($value, $alphaBlacklist, true)) {
                return true;
            }
        }

        return false;
    }

    /**
     * @return array
     */
    protected function getBlacklist()
    {
        $default = [
    // Vacíos / genéricos
    'N/A', 'NA', 'NONE', 'NULL', 'SIN', 'NO', 'NOAPLICA', 'NOAPLICABLE',

    // Test / pruebas
    'TEST', 'TEST123', 'PRUEBA', 'PRUEBAS', 'DEMO', 'DUMMY',

    // Placeholder típico
    'XXXX', 'XXXXX', 'XXXXXX', 'XXXXXXXX',
    'AAAA', 'AAAAA', 'AAAAAA', 'AAAAAAAA',
    'BBBBBB', 'CCCCCC', 'ZZZZZZ',

    // Letras simples
    'ABC', 'ABCD', 'ABCDE', 'ABCDEF', 'ABCDEFG',
    'QWERTY', 'ASDFGH', 'ZXCVBN',

    // Teclado típico
    'QWERT', 'QWERTY', 'ASDF', 'ASDFG', 'ZXCV', 'ZXCVB',

    // Números secuenciales
    '123', '1234', '12345', '123456', '1234567', '12345678', '123456789', '1234567890',
    '0123', '01234', '012345', '0123456', '01234567', '0123456789',

    // Secuencia inversa
    '9876', '98765', '987654', '9876543', '98765432', '987654321',

    // Repeticiones numéricas
    '0000', '00000', '000000', '0000000', '00000000', '000000000',
    '1111', '11111', '111111', '11111111',
    '2222', '22222', '3333', '33333',
    '9999', '99999', '999999', '99999999',

    // Documentos genéricos
    'DNI', 'NIF', 'CIF', 'NIE', 'VAT', 'TAX', 'FISCAL', 'DOCUMENTO',

    // Mezclas típicas falsas
    'ABC123', 'TEST000', 'AAAA1111', 'XXXX1234',

    // Correos usados como DNI (muy típico)
    'EMAIL', 'MAIL', 'CORREO',

    // Otros comunes en tiendas
    'CLIENTE', 'INVITADO', 'GUEST'
];

        $custom = trim((string) Configuration::get('PSDOCVALIDATOR_BLACKLIST'));
        if ($custom === '') {
            return $default;
        }

        $rows = preg_split('/\r\n|\r|\n/', $custom);
        $rows = array_map('trim', $rows);
        $rows = array_filter($rows);
        $rows = array_map(function ($row) {
            return Tools::strtoupper((string) $row);
        }, $rows);

        return array_values(array_unique(array_merge($default, $rows)));
    }

    /**
     * Validate a Spanish DNI/NIF/NIE/CIF.
     *
     * @param string $value
     *
     * @return bool
     */
    public function isValidSpanishDocument($value)
    {
        $value = $this->normalize($value);

        return $this->isValidDni($value)
            || $this->isValidNie($value)
            || $this->isValidCif($value);
    }

    /**
     * @param string $value
     *
     * @return bool
     */
    protected function isValidDni($value)
    {
        if (!preg_match('/^\d{8}[A-Z]$/', $value)) {
            return false;
        }

        $letters = 'TRWAGMYFPDXBNJZSQVHLCKE';
        $number = (int) substr($value, 0, 8);
        $letter = substr($value, -1);

        return $letter === $letters[$number % 23];
    }

    /**
     * @param string $value
     *
     * @return bool
     */
    protected function isValidNie($value)
    {
        if (!preg_match('/^[XYZ]\d{7}[A-Z]$/', $value)) {
            return false;
        }

        $prefixMap = [
            'X' => '0',
            'Y' => '1',
            'Z' => '2',
        ];

        $replaced = $prefixMap[$value[0]] . substr($value, 1);

        return $this->isValidDni($replaced);
    }

    /**
     * CIF validation with control digit/letter.
     *
     * @param string $value
     *
     * @return bool
     */
    protected function isValidCif($value)
    {
        if (!preg_match('/^[ABCDEFGHJNPQRSUVW]\d{7}[0-9A-J]$/', $value)) {
            return false;
        }

        $letter = $value[0];
        $control = substr($value, -1);
        $digits = substr($value, 1, 7);

        $sumEven = 0;
        $sumOdd = 0;

        for ($i = 0; $i < 7; ++$i) {
            $digit = (int) $digits[$i];

            if (($i % 2) === 0) {
                $product = $digit * 2;
                $sumOdd += (int) floor($product / 10) + ($product % 10);
            } else {
                $sumEven += $digit;
            }
        }

        $total = $sumEven + $sumOdd;
        $unit = (10 - ($total % 10)) % 10;
        $controlDigit = (string) $unit;
        $controlLetter = 'JABCDEFGHI'[$unit];

        $mustBeLetter = in_array($letter, ['P', 'Q', 'R', 'S', 'N', 'W'], true);
        $mustBeDigit = in_array($letter, ['A', 'B', 'E', 'H'], true);

        if ($mustBeLetter) {
            return $control === $controlLetter;
        }

        if ($mustBeDigit) {
            return $control === $controlDigit;
        }

        return $control === $controlDigit || $control === $controlLetter;
    }
}
===== C:\Users\rodri\Desktop\psdocvalidator\classes\index.php =====
name = 'psdocvalidator';
        $this->tab = 'front_office_features';
        $this->version = '1.0.0';
        $this->author = 'OpenAI';
        $this->need_instance = 0;
        $this->bootstrap = true;

        parent::__construct();

        $this->displayName = $this->l('Validador de documento fiscal');
        $this->description = $this->l('Bloquea documentos ficticios y valida NIF/NIE/CIF en España.');
        $this->ps_versions_compliancy = [
            'min' => '1.7.8.0',
            'max' => _PS_VERSION_,
        ];

        $this->documentValidator = new PsDocValidatorDocumentValidator($this);
    }

    public function install()
    {
        return parent::install()
            && $this->installConfiguration()
            && $this->registerHook('actionValidateCustomerAddressForm')
            && $this->registerHook('actionValidateOrderBefore');
    }

    public function uninstall()
    {
        return $this->uninstallConfiguration() && parent::uninstall();
    }

    protected function installConfiguration()
    {
        return Configuration::updateValue(static::CONF_STRICT_ES, 1)
            && Configuration::updateValue(static::CONF_MIN_LENGTH, 5)
            && Configuration::updateValue(static::CONF_MAX_LENGTH, 20)
            && Configuration::updateValue(static::CONF_USE_IN_ORDER, 0)
            && Configuration::updateValue(static::CONF_BLACKLIST, "XXXX\nXXXXX\nXXXXXX\nAAAAAA\nTEST\nTEST123\nN/A");
    }

    protected function uninstallConfiguration()
    {
        $keys = [
            static::CONF_STRICT_ES,
            static::CONF_MIN_LENGTH,
            static::CONF_MAX_LENGTH,
            static::CONF_BLACKLIST,
            static::CONF_USE_IN_ORDER,
        ];

        foreach ($keys as $key) {
            Configuration::deleteByName($key);
        }

        return true;
    }

    public function getContent()
    {
        $output = '';

        if (Tools::isSubmit('submitPsDocValidator')) {
            $strictEs = (int) Tools::getValue(static::CONF_STRICT_ES, 1);
            $minLen = (int) Tools::getValue(static::CONF_MIN_LENGTH, 5);
            $maxLen = (int) Tools::getValue(static::CONF_MAX_LENGTH, 20);
            $useInOrder = (int) Tools::getValue(static::CONF_USE_IN_ORDER, 1);
            $blacklist = trim((string) Tools::getValue(static::CONF_BLACKLIST, ''));

            if ($minLen < 1) {
                $output .= $this->displayError($this->l('La longitud mínima debe ser mayor que 0.'));
            } elseif ($maxLen < $minLen) {
                $output .= $this->displayError($this->l('La longitud máxima debe ser mayor o igual que la mínima.'));
            } else {
                Configuration::updateValue(static::CONF_STRICT_ES, $strictEs);
                Configuration::updateValue(static::CONF_MIN_LENGTH, $minLen);
                Configuration::updateValue(static::CONF_MAX_LENGTH, $maxLen);
                Configuration::updateValue(static::CONF_USE_IN_ORDER, $useInOrder);
                Configuration::updateValue(static::CONF_BLACKLIST, $blacklist);

                $output .= $this->displayConfirmation($this->l('Configuración guardada.'));
            }
        }

        return $output . $this->renderForm();
    }

    protected function renderForm()
    {
        $fieldsForm = [
            'form' => [
                'legend' => [
                    'title' => $this->l('Configuración'),
                    'icon' => 'icon-cogs',
                ],
                'input' => [
                    [
                        'type' => 'switch',
                        'label' => $this->l('Validación estricta para España'),
                        'name' => static::CONF_STRICT_ES,
                        'is_bool' => true,
                        'values' => [
                            [
                                'id' => 'strict_es_on',
                                'value' => 1,
                                'label' => $this->l('Sí'),
                            ],
                            [
                                'id' => 'strict_es_off',
                                'value' => 0,
                                'label' => $this->l('No'),
                            ],
                        ],
                        'desc' => $this->l('Valida NIF, NIE y CIF cuando el país de la dirección es España.'),
                    ],
                    [
                        'type' => 'switch',
                        'label' => $this->l('Revalidar antes de crear el pedido'),
                        'name' => static::CONF_USE_IN_ORDER,
                        'is_bool' => true,
                        'values' => [
                            [
                                'id' => 'use_in_order_on',
                                'value' => 1,
                                'label' => $this->l('Sí'),
                            ],
                            [
                                'id' => 'use_in_order_off',
                                'value' => 0,
                                'label' => $this->l('No'),
                            ],
                        ],
                        'desc' => $this->l('Evita que un pedido avance si el documento de la dirección es inválido.'),
                    ],
                    [
                        'type' => 'text',
                        'label' => $this->l('Longitud mínima'),
                        'name' => static::CONF_MIN_LENGTH,
                        'class' => 'fixed-width-sm',
                    ],
                    [
                        'type' => 'text',
                        'label' => $this->l('Longitud máxima'),
                        'name' => static::CONF_MAX_LENGTH,
                        'class' => 'fixed-width-sm',
                    ],
                    [
                        'type' => 'textarea',
                        'label' => $this->l('Blacklist adicional'),
                        'name' => static::CONF_BLACKLIST,
                        'rows' => 10,
                        'cols' => 60,
                        'desc' => $this->l('Introduce un valor por línea. Se aplicará después de normalizar el texto.'),
                    ],
                ],
                'submit' => [
                    'title' => $this->l('Guardar'),
                ],
            ],
        ];

        $helper = new HelperForm();
        $helper->module = $this;
        $helper->name_controller = $this->name;
        $helper->token = Tools::getAdminTokenLite('AdminModules');
        $helper->currentIndex = AdminController::$currentIndex . '&configure=' . $this->name;
        $helper->submit_action = 'submitPsDocValidator';
        $helper->default_form_language = (int) Configuration::get('PS_LANG_DEFAULT');
        $helper->allow_employee_form_lang = (int) Configuration::get('PS_BO_ALLOW_EMPLOYEE_FORM_LANG');
        $helper->title = $this->displayName;
        $helper->show_toolbar = false;
        $helper->fields_value = $this->getFormValues();

        return $helper->generateForm([$fieldsForm]);
    }

    protected function getFormValues()
    {
        return [
            static::CONF_STRICT_ES => (int) Configuration::get(static::CONF_STRICT_ES),
            static::CONF_MIN_LENGTH => (int) Configuration::get(static::CONF_MIN_LENGTH),
            static::CONF_MAX_LENGTH => (int) Configuration::get(static::CONF_MAX_LENGTH),
            static::CONF_USE_IN_ORDER => (int) Configuration::get(static::CONF_USE_IN_ORDER),
            static::CONF_BLACKLIST => (string) Configuration::get(static::CONF_BLACKLIST),
        ];
    }

public function hookActionValidateCustomerAddressForm($params)
{
    if (empty($params['form'])) {
        return true;
    }

    $form = $params['form'];
    $dni = trim((string) Tools::getValue('dni'));

    if ($dni === '') {
        return true;
    }

    $countryIso = $this->resolveCountryIsoFromRequest();
    $result = $this->documentValidator->validate($dni, $countryIso);

    if ($result['valid']) {
        return true;
    }

    $field = $form->getField('dni');
    if ($field) {
        $field->addError($result['message']);
    }

    return false;
}

    public function hookActionValidateOrderBefore($params)
    {
        if (!(bool) Configuration::get(static::CONF_USE_IN_ORDER)) {
            return;
        }

        if (empty($params['cart']) || !Validate::isLoadedObject($params['cart'])) {
            return;
        }

        /** @var Cart $cart */
        $cart = $params['cart'];
        $addressIds = array_unique([
            (int) $cart->id_address_invoice,
            (int) $cart->id_address_delivery,
        ]);

        foreach ($addressIds as $addressId) {
            if ($addressId <= 0) {
                continue;
            }

            $address = new Address($addressId);
            if (!Validate::isLoadedObject($address)) {
                continue;
            }

            $countryIso = $this->resolveCountryIsoFromAddress($address);
            $dni = isset($address->dni) ? (string) $address->dni : '';
            $result = $this->documentValidator->validate($dni, $countryIso);

            if (!$result['valid']) {
                if (isset($this->context->controller->errors) && is_array($this->context->controller->errors)) {
                    $this->context->controller->errors[] = $result['message'];
                }

                PrestaShopLogger::addLog('[psdocvalidator] ' . $result['message'], 2);

                return;
            }
        }
    }

    /**
     * Try to resolve country ISO from current request.
     *
     * @return string
     */
    protected function resolveCountryIsoFromRequest()
    {
        $countryId = (int) Tools::getValue('id_country');

        if ($countryId <= 0) {
            $addressId = (int) Tools::getValue('id_address');
            if ($addressId > 0) {
                $address = new Address($addressId);
                if (Validate::isLoadedObject($address)) {
                    $countryId = (int) $address->id_country;
                }
            }
        }

        if ($countryId <= 0) {
            $countryId = (int) Configuration::get('PS_COUNTRY_DEFAULT');
        }

        $country = new Country($countryId);
        if (!Validate::isLoadedObject($country)) {
            return '';
        }

        return (string) $country->iso_code;
    }

    /**
     * @param Address $address
     *
     * @return string
     */
    protected function resolveCountryIsoFromAddress(Address $address)
    {
        $country = new Country((int) $address->id_country);

        if (!Validate::isLoadedObject($country)) {
            return '';
        }

        return (string) $country->iso_code;
    }
}

La parte más importante del módulo es que no se limita a comprobar si el campo está relleno. También identifica patrones típicos de datos falsos como secuencias numéricas, caracteres repetidos, valores genéricos, textos de prueba y placeholders habituales introducidos por clientes.

public function isFakeDocument($value)
{
    $value = $this->normalize($value);

    if ($value === '') {
        return true;
    }

    $len = Tools::strlen($value);
    $minLen = (int) Configuration::get('PSDOCVALIDATOR_MIN_LENGTH');
    $maxLen = (int) Configuration::get('PSDOCVALIDATOR_MAX_LENGTH');

    if ($len < $minLen || $len > $maxLen) {
        return true;
    }

    if (!preg_match('/^[A-Z0-9]+$/', $value)) {
        return true;
    }

    if (preg_match('/^(.)\1+$/', $value)) {
        return true;
    }

    if (in_array($value, $this->getBlacklist(), true)) {
        return true;
    }

    if (preg_match('/^(0123|1234|2345|3456|4567|5678|6789)/', $value)) {
        return true;
    }

    if (preg_match('/^(.{1,3})\1+$/', $value)) {
        return true;
    }

    return false;
}

A nivel técnico, este desarrollo demuestra capacidad para crear módulos funcionales en PrestaShop, separar responsabilidades, trabajar con hooks del checkout, validar datos antes de persistirlos y mejorar la fiabilidad de la información fiscal en pedidos reales.

Código representativo

Módulo de aviso de suplemento de envío en checkout

Desarrollo de un módulo personalizado para PrestaShop que muestra un aviso informativo en el checkout cuando el carrito contiene exactamente dos productos pertenecientes a una categoría concreta.

El caso real consistía en informar al cliente de que, al comprar exactamente dos tumbonas, se aplica un suplemento de envío de 30 € en total, equivalente a 15 € por unidad. El módulo analiza los productos del carrito, comprueba su pertenencia a la categoría configurada y muestra el aviso solo cuando se cumple la condición exacta.

  • Creación de módulo personalizado tumbonasshippingnotice.
  • Integración en checkout mediante el hook displayCheckoutSubtotalDetails.
  • Detección de productos por categoría usando Product::getProductCategories().
  • Cálculo de cantidad total de productos de la categoría objetivo dentro del carrito.
  • Condición exacta: mostrar aviso únicamente cuando hay 2 tumbonas.
  • Separación entre lógica de detección, condición y renderizado del aviso.
  • Renderizado mediante plantilla Smarty propia del módulo.
  • Compatibilidad con el flujo nativo de checkout de PrestaShop.
===== C:\Users\rodri\Desktop\tumbonasshippingnotice\tumbonasshippingnotice.php =====
name = 'tumbonasshippingnotice';
        $this->tab = 'front_office_features';
        $this->version = '1.0.1';
        $this->author = 'Custom';
        $this->need_instance = 0;
        $this->bootstrap = true;

        parent::__construct();

        $this->displayName = $this->l('Aviso suplemento envío tumbonas');
        $this->description = $this->l('Muestra un aviso cuando el carrito contiene exactamente 2 tumbonas.');
    }

    public function install()
    {
        return parent::install()
            && $this->registerHook('displayCheckoutSubtotalDetails');
    }

    protected function isTumbonaProduct($idProduct)
    {
        $tumbonasCategoryId = 17; // Cambia esto si no es la correcta

        $productCategories = Product::getProductCategories((int)$idProduct);

        if (is_array($productCategories) && in_array((int)$tumbonasCategoryId, array_map('intval', $productCategories))) {
            return true;
        }

        return false;
    }

    protected function getTumbonasQuantityInCart(Cart $cart)
    {
        $products = $cart->getProducts();
        $qty = 0;

        foreach ($products as $product) {
            if ($this->isTumbonaProduct((int)$product['id_product'])) {
                $qty += (int)$product['cart_quantity'];
            }
        }

        return $qty;
    }

    protected function shouldShowNotice()
    {
        if (!Validate::isLoadedObject($this->context->cart)) {
            return false;
        }

        $tumbonasQty = $this->getTumbonasQuantityInCart($this->context->cart);

        return ($tumbonasQty === 2);
    }

    protected function renderNotice()
    {
        if (!$this->shouldShowNotice()) {
            return '';
        }

        $this->context->smarty->assign([
            'tumbonas_shipping_notice' => 'Se aplica un suplemento de envío de 30 € en total (15 € por tumbona).',
        ]);

        return $this->fetch('module:'.$this->name.'/views/templates/hook/notice.tpl');
    }

    public function hookDisplayCheckoutSubtotalDetails($params)
    {
        return $this->renderNotice();
    }
}

El módulo está planteado de forma sencilla pero limpia: una función identifica si un producto pertenece a la categoría objetivo, otra calcula la cantidad total en carrito y otra decide si debe mostrarse el aviso. De esta forma, la lógica no queda mezclada directamente dentro del hook.

protected function isTumbonaProduct($idProduct)
{
    $tumbonasCategoryId = 17;

    $productCategories = Product::getProductCategories((int)$idProduct);

    if (
        is_array($productCategories)
        && in_array((int)$tumbonasCategoryId, array_map('intval', $productCategories))
    ) {
        return true;
    }

    return false;
}

public function hookDisplayCheckoutSubtotalDetails($params)
{
    return $this->renderNotice();
}

A nivel técnico, este desarrollo demuestra capacidad para crear módulos específicos en PrestaShop, trabajar con hooks del checkout, leer el contenido real del carrito y aplicar lógica condicional basada en categorías y cantidades.

Galería

Capturas representativas del trabajo realizado

Implementaciones desarrolladas sobre proyectos reales, incluyendo personalizaciones de interfaz en PrestaShop, mapas interactivos con Google Maps API y adaptación de correos automáticos en WooCommerce.

Contacto

¿Quieres ver más desarrollos backend, módulos o integraciones eCommerce?

Puedo enseñar más casos de PrestaShop, WordPress, WooCommerce, automatizaciones, checkout, pagos y sistemas internos.