🏪 Modulo compatible con multitienda — patron completo

Actualizado: 2025-01-15

#Que implica multitienda para un modulo

Cuando multitienda esta activa, el admin puede estar en 3 contextos: 'Todas las tiendas', un grupo de tiendas, o una tienda individual. Tu modulo debe respetar este contexto en: lectura/escritura de Configuration, queries a la BD, y formularios del BO.

⚠️
Error comun

El error #1 en multitienda es usar Configuration::get() y Configuration::updateValue() sin entender que leen/escriben para la tienda actual del contexto. Si el admin esta en 'Todas las tiendas' y tu modulo guarda una config, se guarda para TODAS. Si esta en Tienda 2, se guarda solo para Tienda 2.

#Configuration por tienda

Leer y escribir config respetando el contexto
php
<?php
/**
 * Configuration::get() lee automaticamente para la tienda del contexto actual.
 * Pero a veces necesitas leer para una tienda especifica:
 */

// Lee para la tienda actual del contexto (lo normal)
$value = Configuration::get('MI_CONFIG');

// Lee para una tienda especifica (forzar id_shop)
$valueShop2 = Configuration::get('MI_CONFIG', null, null, 2);  // id_shop = 2

// Lee el valor global (no vinculado a ninguna tienda)
$globalValue = Configuration::getGlobalValue('MI_CONFIG');

// Escribir para la tienda actual del contexto
Configuration::updateValue('MI_CONFIG', 'valor');

// Escribir para una tienda especifica
Configuration::updateValue('MI_CONFIG', 'valor_tienda2', false, 0, 2);

// Escribir valor global (todas las tiendas)
Configuration::updateGlobalValue('MI_CONFIG', 'valor_global');

/**
 * Patron recomendado: defaults en install()
 * Guardar valores por defecto para CADA tienda
 */
public function install()
{
    if (!parent::install()) {
        return false;
    }

    // Iterar todas las tiendas para poner valores por defecto
    $shops = Shop::getShops(true, null, true); // Solo IDs
    foreach ($shops as $idShop) {
        Configuration::updateValue('ECOM_BANNER_ENABLED', 1, false, 0, $idShop);
        Configuration::updateValue('ECOM_BANNER_TEXT', 'Envio gratis +50 EUR', false, 0, $idShop);
        Configuration::updateValue('ECOM_BANNER_COLOR', '#2fb5d2', false, 0, $idShop);
    }

    return true;
}

#ObjectModel con shop context

ObjectModel con tabla _shop para multitienda
php
<?php
/**
 * Para que un ObjectModel sea multitienda, necesitas:
 * 1. Una tabla principal: ps_ecom_banner
 * 2. Una tabla _shop: ps_ecom_banner_shop
 * 3. 'multishop' => true en $definition
 */
class EcomBanner extends ObjectModel
{
    public $id_ecom_banner;
    public $title;
    public $content;
    public $active;
    public $position;
    public $date_add;

    public static $definition = [
        'table'      => 'ecom_banner',
        'primary'    => 'id_ecom_banner',
        'multilang'  => true,
        'multishop'  => true,           // <-- Clave para multitienda
        'fields' => [
            'active'   => ['type' => self::TYPE_BOOL,   'validate' => 'isBool',        'shop' => true],
            'position' => ['type' => self::TYPE_INT,    'validate' => 'isUnsignedInt',  'shop' => true],
            'date_add' => ['type' => self::TYPE_DATE,   'validate' => 'isDate'],
            // Campos multilang
            'title'    => ['type' => self::TYPE_STRING, 'validate' => 'isCleanHtml', 'lang' => true, 'required' => true, 'size' => 255],
            'content'  => ['type' => self::TYPE_HTML,   'validate' => 'isCleanHtml', 'lang' => true, 'size' => 65535],
        ],
    ];

    /**
     * Obtener banners activos para la tienda actual
     */
    public static function getActiveBanners(int $idLang, int $idShop): array
    {
        return Db::getInstance()->executeS(
            (new DbQuery())
                ->select('b.*, bl.title, bl.content')
                ->from('ecom_banner', 'b')
                ->innerJoin('ecom_banner_shop', 'bs',
                    'b.id_ecom_banner = bs.id_ecom_banner AND bs.id_shop = ' . $idShop)
                ->innerJoin('ecom_banner_lang', 'bl',
                    'b.id_ecom_banner = bl.id_ecom_banner AND bl.id_lang = ' . $idLang)
                ->where('bs.active = 1')    // active viene de tabla _shop
                ->orderBy('bs.position ASC') // position viene de tabla _shop
                ->build()
        ) ?: [];
    }
}
SQL: tablas con soporte multitienda
sql
-- Tabla principal (datos compartidos)
CREATE TABLE IF NOT EXISTS `PREFIX_ecom_banner` (
    `id_ecom_banner` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
    `date_add` DATETIME NOT NULL,
    PRIMARY KEY (`id_ecom_banner`)
) ENGINE=ENGINE_TYPE DEFAULT CHARSET=utf8mb4;

-- Tabla _shop (datos por tienda: active, position, etc.)
CREATE TABLE IF NOT EXISTS `PREFIX_ecom_banner_shop` (
    `id_ecom_banner` INT(10) UNSIGNED NOT NULL,
    `id_shop` INT(10) UNSIGNED NOT NULL,
    `active` TINYINT(1) NOT NULL DEFAULT 1,
    `position` INT(10) UNSIGNED NOT NULL DEFAULT 0,
    PRIMARY KEY (`id_ecom_banner`, `id_shop`)
) ENGINE=ENGINE_TYPE DEFAULT CHARSET=utf8mb4;

-- Tabla _lang (datos por idioma)
CREATE TABLE IF NOT EXISTS `PREFIX_ecom_banner_lang` (
    `id_ecom_banner` INT(10) UNSIGNED NOT NULL,
    `id_lang` INT(10) UNSIGNED NOT NULL,
    `title` VARCHAR(255) NOT NULL,
    `content` TEXT,
    PRIMARY KEY (`id_ecom_banner`, `id_lang`)
) ENGINE=ENGINE_TYPE DEFAULT CHARSET=utf8mb4;

#Queries con filtro de tienda

Siempre filtrar por id_shop en queries
php
<?php
// INCORRECTO — devuelve datos de TODAS las tiendas
$products = Db::getInstance()->executeS(
    'SELECT * FROM `' . _DB_PREFIX_ . 'product` WHERE active = 1'
);

// CORRECTO — filtra por tienda actual
$idShop = (int) Context::getContext()->shop->id;
$products = Db::getInstance()->executeS(
    (new DbQuery())
        ->select('p.*, ps.active, ps.price')
        ->from('product', 'p')
        ->innerJoin('product_shop', 'ps',
            'p.id_product = ps.id_product AND ps.id_shop = ' . $idShop)
        ->where('ps.active = 1')
        ->build()
);

// Contexto multitienda: respetar el contexto del admin (BO)
if (Shop::isFeatureActive()) {
    $shopContext = Shop::getContext();

    switch ($shopContext) {
        case Shop::CONTEXT_ALL:
            // Sin filtro de tienda — mostrar todo
            break;
        case Shop::CONTEXT_GROUP:
            $idGroup = (int) Shop::getContextShopGroupID();
            $shopIds = Shop::getShops(true, $idGroup, true);
            $sql->where('ps.id_shop IN (' . implode(',', array_map('intval', $shopIds)) . ')');
            break;
        case Shop::CONTEXT_SHOP:
            $sql->where('ps.id_shop = ' . (int) Shop::getContextShopID());
            break;
    }
}

#Formulario que respeta el contexto

HelperForm con indicador de tienda
php
<?php
public function getContent()
{
    $output = '';

    // Mostrar aviso de contexto multitienda
    if (Shop::isFeatureActive()) {
        $shopContext = Shop::getContext();
        if ($shopContext === Shop::CONTEXT_ALL) {
            $output .= $this->displayWarning(
                'Estas editando la configuracion para TODAS las tiendas. '
                . 'Selecciona una tienda especifica para configurar individualmente.'
            );
        }
    }

    if (Tools::isSubmit('submitConfig')) {
        // Configuration::updateValue() respeta el contexto automaticamente
        Configuration::updateValue('ECOM_BANNER_ENABLED', (int) Tools::getValue('ECOM_BANNER_ENABLED'));
        Configuration::updateValue('ECOM_BANNER_TEXT', Tools::getValue('ECOM_BANNER_TEXT'));
        $output .= $this->displayConfirmation('Guardado correctamente.');
    }

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

#Checklist de compatibilidad

  • Configuration::get/updateValue sin id_shop explicito (respeta contexto automaticamente)
  • Queries a product/category/customer siempre con JOIN a tabla _shop
  • ObjectModel con 'multishop' => true si los datos varian por tienda
  • install() registra valores por defecto para cada tienda existente
  • Hooks de front leen Context::getContext()->shop->id para filtrar datos
  • AdminController usa Shop::getContextShopID() en las queries del listado
  • No hardcodeas id_shop = 1 en ninguna parte
  • Testeas con al menos 2 tiendas activas en contextos: all, group, shop
Descargar en Markdown Pensado para pegar en ChatGPT, Claude u otra IA. Incluye solo el contenido de esta pagina.