Saltar a contenido

Plan de Implementación: Imágenes de Variantes en Medusa v2.11.2+

Nota de vigencia (2026-08-11): este análisis es anterior a los fixes de junio 2026 (lookup scopeado de product_item_view y política de reintentos — ver incidente 2026-06). Partes pueden estar desactualizadas; vigencia a confirmar por el equipo.

Resumen

Este documento describe la implementación para sincronizar imágenes de variantes usando la nueva API nativa de Medusa v2.11.2+, reemplazando el enfoque actual basado en metadata.variant_images.

Requisitos Previos

  • Backend de Medusa actualizado a v2.11.2 o superior
  • Ejecutar migración en Medusa: npx medusa db:migrate

1. Arquitectura Actual vs Nueva

Actual (metadata)

// En buildCreateVariantPayload()
'metadata' => [
    'variant_images' => [
        ['url' => '...', 'alt' => '...', 'position' => 0]
    ]
]
Problema: Las imágenes no se ven en el admin de Medusa, no se pueden reutilizar.

Nueva (API nativa)

1. POST /admin/products/:id/images         → Subir imagen al producto
2. POST /admin/products/:id/variants/:id/images/batch → Asociar a variante
3. POST /admin/products/:id/variants/:id   → Asignar thumbnail
Ventajas: Imágenes visibles en admin, reutilizables, thumbnails nativos.


2. Nuevos Endpoints a Implementar en MedusaCatalogAdapter

// === Imágenes de Producto ===

/**
 * Subir imagen al producto
 * POST /admin/products/:id/images
 */
public function uploadProductImage(string $productId, string $imageUrl): ?array;

/**
 * Listar imágenes del producto
 * GET /admin/products/:id → extraer images[]
 */
public function listProductImages(string $productId): array;

/**
 * Eliminar imagen del producto
 * DELETE /admin/products/:id/images/:image_id
 */
public function deleteProductImage(string $productId, string $imageId): void;

// === Imágenes de Variante ===

/**
 * Asociar/desasociar imágenes a una variante (batch)
 * POST /admin/products/:id/variants/:variant_id/images/batch
 * Body: { "add": ["img_id1"], "remove": ["img_id2"] }
 */
public function batchVariantImages(
    string $productId,
    string $variantId,
    array $addImageIds = [],
    array $removeImageIds = []
): array;

/**
 * Actualizar thumbnail de variante
 * POST /admin/products/:id/variants/:variant_id
 * Body: { "thumbnail": "url" }
 */
public function updateVariantThumbnail(
    string $productId,
    string $variantId,
    string $thumbnailUrl
): array;

3. Nuevo Servicio: MedusaVariantImageService

<?php

namespace App\Services\Medusa;

use App\Models\ProductItem;
use App\Models\ProductItemImage;

class MedusaVariantImageService
{
    public function __construct(
        private readonly MedusaCatalogAdapter $catalog,
        private readonly MedusaClient $client
    ) {}

    /**
     * Sincroniza todas las imágenes de un ProductItem con Medusa
     */
    public function syncVariantImages(ProductItem $item, string $productId, string $variantId): void
    {
        // 1. Obtener imágenes locales
        $localImages = $item->images()->orderBy('id')->get();

        if ($localImages->isEmpty()) {
            return;
        }

        // 2. Obtener imágenes actuales en Medusa (del producto)
        $remoteImages = $this->catalog->listProductImages($productId);
        $remoteByUrl = collect($remoteImages)->keyBy('url');

        // 3. Subir imágenes que no existen en Medusa
        $imageIdsToAssociate = [];

        foreach ($localImages as $localImg) {
            $publicUrl = $this->getPublicUrl($localImg);

            if ($remoteByUrl->has($publicUrl)) {
                // Ya existe, usar su ID
                $imageIdsToAssociate[] = $remoteByUrl[$publicUrl]['id'];
            } else {
                // Subir nueva imagen al producto
                $uploaded = $this->catalog->uploadProductImage($productId, $publicUrl);
                if ($uploaded && !empty($uploaded['id'])) {
                    $imageIdsToAssociate[] = $uploaded['id'];
                }
            }
        }

        // 4. Asociar imágenes a la variante (batch)
        if (!empty($imageIdsToAssociate)) {
            $this->catalog->batchVariantImages($productId, $variantId, $imageIdsToAssociate);
        }

        // 5. Asignar thumbnail (primera imagen con variante "thumbnail")
        $firstImage = $localImages->first();
        if ($firstImage) {
            $thumbnailUrl = $this->getThumbnailUrl($firstImage);
            $this->catalog->updateVariantThumbnail($productId, $variantId, $thumbnailUrl);
        }
    }

    /**
     * Obtiene URL pública de la imagen (Cloudflare o legacy)
     */
    private function getPublicUrl(ProductItemImage $img): string
    {
        return $img->image_url; // Ya usa el accessor que prioriza Cloudflare
    }

    /**
     * Obtiene URL del thumbnail (variante de Cloudflare)
     * Reemplaza /public por /thumbnail en la URL
     */
    private function getThumbnailUrl(ProductItemImage $img): string
    {
        if (!empty($img->cf_image_id)) {
            return $img->getVariantUrl('thumbnail');
        }

        // Fallback: misma URL si no es Cloudflare
        return $img->image_url;
    }
}

4. Modificaciones en MedusaProductSyncService

4.1. Inyectar el nuevo servicio

public function __construct(
    private readonly MedusaClient $client,
    private ?CatalogPort $catalog = null,
    private ?MedusaInventoryService $inventory = null,
    private ?MedusaOptionService $options = null,
    private ?MedusaCategoryService $categories = null,
    private ?MedusaVariantImageService $images = null, // NUEVO
) {
    // ... inicializaciones existentes ...
    $this->images ??= new MedusaVariantImageService($this->catalog, $this->client);
}

4.2. Modificar syncVariantBasics()

public function syncVariantBasics(ProductItem $item): void
{
    $ids = $this->ensureProductAndVariant($item);
    $variantId = $ids['variant_id'];
    $productId = $ids['product_id'];

    // ... código existente de actualización ...

    // === NUEVO: Sincronizar imágenes de variante ===
    try {
        $this->images->syncVariantImages($item, $productId, $variantId);
        $this->mlog('info', 'Variant images:ok', $this->ctx($item, $productId, $variantId));
    } catch (\Throwable $e) {
        $this->mlog('error', 'Variant images:failed', $this->ctx($item, $productId, $variantId) + [
            'err' => $e->getMessage(),
        ]);
    }

    $this->logSyncOk($item, 'medusa', 'variant_basic', null);
}

4.3. Modificar buildCreateVariantPayload()

Remover variant_images del metadata (ya no es necesario):

'metadata' => [
    'short_description' => $item->short_description,
    'sku_sap'           => $item->sku_sap,
    // ELIMINADO: 'variant_images' => $variantImages,
],

4.4. Agregar sincronización de imágenes post-creación

En createVariantForItem(), después de crear la variante:

// Sincronizar imágenes después de crear la variante
try {
    $this->images->syncVariantImages($item, $productId, $vid);
} catch (\Throwable $e) {
    $this->mlog('warning', 'Variant images:sync failed on create', $this->ctx($item, $productId, $vid) + [
        'err' => $e->getMessage(),
    ]);
}

5. Implementación del Adapter (MedusaCatalogAdapter)

// === Imágenes de Producto ===

public function uploadProductImage(string $productId, string $imageUrl): ?array
{
    try {
        $res = $this->client->post("/admin/products/{$productId}/images", [
            'url' => $imageUrl,
        ]);
        return $res['image'] ?? $res ?? null;
    } catch (ClientException $e) {
        if ($e->getResponse()?->getStatusCode() === 409) {
            // Imagen ya existe, no es error
            return null;
        }
        throw $e;
    }
}

public function listProductImages(string $productId): array
{
    $product = $this->getProduct($productId);
    return $product['images'] ?? [];
}

public function deleteProductImage(string $productId, string $imageId): void
{
    try {
        $this->client->delete("/admin/products/{$productId}/images/{$imageId}");
    } catch (ClientException $e) {
        if ($e->getResponse()?->getStatusCode() === 404) {
            return; // Idempotente
        }
        throw $e;
    }
}

// === Imágenes de Variante ===

public function batchVariantImages(
    string $productId,
    string $variantId,
    array $addImageIds = [],
    array $removeImageIds = []
): array {
    $body = [];
    if (!empty($addImageIds)) {
        $body['add'] = array_values($addImageIds);
    }
    if (!empty($removeImageIds)) {
        $body['remove'] = array_values($removeImageIds);
    }

    if (empty($body)) {
        return ['added' => [], 'removed' => []];
    }

    $res = $this->client->post(
        "/admin/products/{$productId}/variants/{$variantId}/images/batch",
        $body
    );

    return $res;
}

public function updateVariantThumbnail(string $productId, string $variantId, string $thumbnailUrl): array
{
    return $this->updateVariant($productId, $variantId, [
        'thumbnail' => $thumbnailUrl,
    ]);
}

6. URLs de Cloudflare

Variantes configuradas

Variante Uso URL Pattern
public Imagen principal /{hash}/{cf_image_id}/public
thumbnail Miniatura de variante /{hash}/{cf_image_id}/thumbnail

Helper en ProductItemImage

Ya existe el método getVariantUrl():

public function getVariantUrl(string $variant): string
{
    if (!empty($this->cf_image_id)) {
        return app(CloudflareImagesService::class)->url($this->cf_image_id, $variant);
    }
    return $this->image_url;
}


7. Migración de Datos Existentes

Crear comando para sincronizar imágenes de variantes existentes:

php artisan medusa:sync-variant-images
    --dry-run           # Mostrar cambios sin aplicar
    --limit=100         # Limitar cantidad
    --product-item=123  # Sincronizar uno específico

8. Orden de Implementación

  1. Fase 1: Adapter (sin afectar flujo actual)
  2. Agregar métodos de imágenes a MedusaCatalogAdapter
  3. Crear MedusaVariantImageService
  4. Tests unitarios

  5. Fase 2: Integración (activar gradualmente)

  6. Agregar variable de entorno MEDUSA_VARIANT_IMAGES_ENABLED=false
  7. Modificar syncVariantBasics() con condicional
  8. Probar con productos seleccionados

  9. Fase 3: Migración

  10. Ejecutar comando de migración masiva
  11. Remover variant_images del metadata
  12. Activar por defecto

  13. Fase 4: Limpieza

  14. Remover código legacy de metadata
  15. Remover condicional de feature flag

9. Feature Flag

# .env
MEDUSA_VARIANT_IMAGES_ENABLED=false  # Cambiar a true cuando esté listo
// En MedusaProductSyncService
private function shouldUseNativeVariantImages(): bool
{
    return (bool) config('medusa.variant_images_enabled', false);
}

10. Resultado Esperado

Antes (metadata)

{
  "variant": {
    "metadata": {
      "variant_images": [{"url": "...", "alt": "..."}]
    }
  }
}

Después (API nativa)

{
  "variant": {
    "id": "var_123",
    "thumbnail": "https://imagedelivery.net/.../thumbnail",
    "images": [
      {"id": "img_123", "url": "https://imagedelivery.net/.../public"}
    ]
  }
}

Notas

  • Las imágenes se suben al producto y se asocian a variantes
  • Una imagen puede estar asociada a múltiples variantes
  • Imágenes no asociadas a ninguna variante aparecen para todas
  • El thumbnail es independiente del array de imágenes