Saltar a contenido

Sincronización de categorías de salida a Medusa con árbol

Relacionado con: RFC 009 §2.7 y F-12, línea base as-is de Medusa §2.7, ADR-0007, RFC 002 y RFC 003 (modelo de categorías por Partner), sync:woocommerce-categories (patrón del comando).

Este documento define el qué y el por qué, con los datos medidos que lo justifican. Las decisiones de diseño que comprometen a futuro están en el ADR-0006.

1. Objetivo y alcance

Que las categorías de salida del integrador (Partner CL) existan en Medusa con su jerarquía padre → hija correcta, se mantengan así ante altas, renombres, cambios de padre, desactivaciones y borrados, y que nada de eso ocurra dentro del request HTTP del ABM. El fin último es que el storefront de Medusa pueda consultar y cachear el árbol desde el admin de Medusa sin lógica propia de categorías.

Restricciones fijadas por el equipo (2026-09-15):

  • Sin migraciones nuevas. Todo estado local adicional va a metadata de la categoría, que ya guarda id_medusa_category.
  • Simpleza por sobre completitud. Se resuelve exactamente el problema de arriba; lo que no aporta a ese objetivo queda fuera (ver §7).

Entregables:

  1. Un servicio independiente de sincronización de categorías a Medusa.
  2. Un job en cola, uno por categoría.
  3. Un comando de migración inicial y reparación, con --dry-run.

2. Situación actual

2.1 Código

Pieza Qué hace hoy Problema
CategoryObserver::created/updated Llama a MedusaCategoryService de forma sincrónica dentro del request del ABM La latencia y los fallos de Medusa caen sobre el usuario; el error se registra como warning y no queda en ningún sync log
ProductCategoryObserver Ídem para el modelo legacy ProductCategory Doble camino de escritura a Medusa; RFC 003 lo deprecó pero el observer y el ABM product-categories siguen activos
MedusaCategoryService::ensureByName Recibe sólo el nombre; busca en Medusa por handle (slug(nombre)), después por texto del nombre, y si no encuentra crea Como no recibe la categoría, no conoce al padre: toda categoría se crea como raíz. Resuelve por nombre, así que cuando el id local falta o ya no existe en Medusa, un nombre distinto al remoto termina en una categoría nueva y la vieja queda huérfana (así aparecieron las 6 huérfanas con nombres viejos, creadas desde el modelo legacy antes de la migración a Partner CL)
CategoryObserver::updated (renombre con id válido) Llama a updateById con name y handle = slug(nombre) Renombra bien, pero cambia también el handle y con él la URL pública de la categoría en el storefront
CategoryObserver::updated Sólo reacciona a cambios de name Cambios de parent_id y active no se propagan
CategoryObserver::deleted Vacío Borrar una categoría local deja la de Medusa huérfana
Mapeo local metadata(key=id_medusa_category) Sin fecha ni hash del último envío; no se sabe qué está desactualizado sin preguntar a Medusa

El vínculo producto → categoría lo hace el sync de productos (ensureAndAttachCategory) y no cambia con este trabajo; sólo depende de que id_medusa_category sea correcto.

2.2 Datos (base dev, 2026-09-15)

Partner CL:

Métrica Valor
Categorías 128, todas activas
Raíces / hijas / padres 98 / 30 / 6
Profundidad máxima 2 niveles (no hay nietos)
Con id_medusa_category 125
De esas, el id existe en Medusa 117
Ids obsoletos (no existen en Medusa) 8
Sin id (0 productos cada una) 3
Productos con categoría CL 17 573 en 117 categorías; ninguno con más de una
Productos colgados de un padre 1
Nombres o handles duplicados 0
Padres fuera de CL o inexistentes 0

Medusa 2.15.5:

Métrica Valor
product_categories 131, ninguna con padre, todas activas
Con mapeo local 117
Sin mapeo local 14, todas sin productos

Árbol local actual (padre → hijas):

  • Climatización y Calefacción → Aires Acondicionados, Calefacción, Termocalefones, Ventiladores
  • Pequeños Electrodomésticos → Balanzas, Exprimidoras, Freidoras, Licuadoras/Batidoras/Procesadores, Microondas, Pipoqueras, Sandwicheras
  • Aspiración, Limpieza y Planchado → Aspiradoras, Planchas
  • Laptops → Accesorios p/ Notebook, Memoria RAM para Notebook, Notebook Gamer, Notebooks
  • Línea Blanca → Anafes y Placas, Bebederos, Cocinas, Congeladores, Extractores, Fabricadoras de Hielo, Heladeras, Hornos, Lavarropas, Lavavajillas, Secarropas
  • Cafetería → Cafeteras, Hervidoras

3. Hallazgos

  1. Los 8 ids obsoletos son las 6 categorías padre más dos vacías. Se crearon en Medusa (tienen id guardado) y hoy no existen allí. Como Medusa nunca recibió el árbol, esas categorías se veían como raíces sin productos y fueron borradas desde el admin. Es el síntoma directo de no enviar parent_category_id. No afecta productos porque los padres no tienen ninguno asignado.
  2. 6 de las 14 huérfanas de Medusa son nombres viejos (Aire Acondicionado, Cocina, Heladera, Aspiradora, Freidora Air Fryer, Hornos Eléctricos): renombres locales que ensureByName resolvió creando una categoría nueva. Las otras 8 son de prueba o de pilas, ninguna con productos.
  3. Un producto vinculado a una hija no aparece al listar la padre en Medusa salvo que el storefront pida descendientes. Hoy el storefront pide *products de cada categoría sólo para armar el menú (7,2 MB, tarea abierta en la HU-01 del backlog de Medusa). Con el árbol en Medusa, una sola consulta con include_descendants_tree devuelve la jerarquía completa y es eso lo que se cachea.
  4. WooCommerce y Medusa son dos canales de salida distintos sobre el mismo Partner CL. Ambos están habilitados. El servicio nuevo no crea un Partner nuevo y no toca el camino de WooCommerce.

4. Contrato de Medusa que se usa

POST /admin/product-categories y POST /admin/product-categories/{id} aceptan name, description, handle, is_internal, is_active, parent_category_id (nulo desengancha), external_id (nulo permitido), metadata y rank. GET /admin/product-categories filtra por handle, parent_category_id, external_id e is_active, y admite include_descendants_tree.

  • external_id guarda el id local de la categoría y permite reconciliar sin depender del nombre ni del handle.
  • handle es único en Medusa y forma la URL pública /categories/{handle}.
  • rank ordena entre hermanos.

5. Diseño

Sigue la estructura del repo: servicio en app/Services/Medusa/, job en app/Jobs/, canal de log MEDUSA, tag [MEDUSA:cat:{category_id}].

5.1 Servicio MedusaCategorySyncService

  • desiredState(Category): arma el payload sólo desde la base local, sin llamadas a Medusa: name, handle, parent_category_id (id Medusa del padre local o nulo), is_active (= active), is_internal = false, external_id = id local, rank = 0, y un metadata que fusiona el remoto con integrador_category_id (ver §6, D-9). Devuelve también el hash del payload.
  • resolveRemote(Category): (1) GET /{id_medusa_category} si hay id guardado; (2) si no existe, GET ?external_id=; (3) GET ?handle=; (4) no existe. Persiste el id encontrado en metadata.
  • plan(Category, remoto, idPadre): función pura que compara el estado deseado con el remoto y devuelve create, update (sólo los campos que difieren) o unchanged.
  • sync(Category): resuelve y sincroniza el padre antes que la hija (con guard contra ciclos, como WoocommerceApiService::parentWooId); aplica el plan; al éxito escribe medusa_synced_at, medusa_hash (traza del último payload) y limpia medusa_last_error; al fallo escribe medusa_last_error y relanza.
  • deactivate(Category): envía is_active = false; para borrado local, además is_internal = true. No se borra en Medusa.
  • Sólo categorías del partner configurado (PartnerConstants::CL por defecto). Nunca ProductCategory legacy.
  • Llave de apagado propia (MEDUSA_CATEGORY_SYNC_ENABLED), independiente del canal de productos.

5.2 Job SyncCategoryToMedusaJob

Cola medusa_products, conexión redis_medusa_products (el supervisor de Horizon ya existe). ShouldBeUniqueUntilProcessing con uniqueId = category_id, tries = 3, backoff escalonado, withoutRelations(), try/catch con re-throw, según el estándar de jobs del repo. Recibe sólo el id y lee el estado fresco al ejecutarse.

5.3 Observer

CategoryObserver deja de llamar a Medusa. Despacha el job en created, en updated cuando cambió name, parent_id o active, y en deleted (modo desactivación). Se elimina el camino Medusa de ProductCategoryObserver; su camino WooCommerce queda hasta que RFC 003 termine.

5.4 Comando medusa:sync-categories

Mismo espíritu que sync:woocommerce-categories:

  • Carga las categorías locales y las remotas en dos consultas y clasifica cada local en ok, crear, actualizar, id obsoleto (re-resolver).
  • Recorre en orden topológico: padres primero.
  • --dry-run imprime el plan por categoría (acción y payload) sin escribir nada.
  • --create, --update, --regenerate-handles, --ids=, --only-missing separados, para aplicar por etapas.
  • Reporta huérfanas de Medusa sin mapeo local y colisiones de handle. No las borra.

5.5 Estado local (sin tablas nuevas)

Claves en metadata de la categoría: id_medusa_category (existente), medusa_synced_at, medusa_hash, medusa_last_error. Los fallos definitivos quedan además en failed_jobs y en el log MEDUSA.

6. Decisiones tomadas

ID Decisión Elegido Motivo
D-1 Handle en renombre Estable una vez creado; sólo cambia name; el comando permite regenerarlo con flag No romper URLs del storefront ni SEO
D-2 Registro del resultado Sólo metadata Sin migraciones; la traza de fallos ya existe en failed_jobs y log
D-3 active = 0 local is_active = false en Medusa Oculta en la tienda y conserva vínculos
D-4 Borrado local is_active = false + is_internal = true; no DELETE Reversible; evita que Medusa reubique hijas
D-5 Huérfanas en Medusa Reporte en el comando; limpieza manual en el admin 14 categorías, ninguna con productos
D-6 rank 0 Sin campo local; el storefront ordena al cachear
D-7 Producto colgado de un padre Se permite 1 caso; avisar al negocio
D-8 Columna level Se ignora La fuente de verdad es parent_id
D-9 metadata de la categoría en Medusa Se fusiona: el integrador sólo es dueño de integrador_category_id El icono y la imagen de la navegación los pone el negocio desde el admin de Medusa; Medusa reemplaza el objeto entero en cada update, así que reemplazar los borraría

7. Fuera de alcance

  • Vínculo producto → categoría (sigue en el sync de productos hasta RFC 009).
  • Categorías de otros partners (TN, UM, Conti) y de suppliers.
  • Cambios en el storefront para navegar y cachear el árbol (HU-01 del backlog Medusa); este trabajo los habilita.
  • Retiro del canal WooCommerce.

8. Riesgos

Riesgo Mitigación
Recrear los 6 padres con ids nuevos No afecta productos (0 asignados); las hijas sólo reciben parent_category_id
Colisión de handle con huérfanas de Medusa El --dry-run la detecta; hoy no hay ninguna
Observer nuevo activo antes de la migración inicial Orden obligatorio: comando primero, llave de sync después
Jobs viejos en cola tras el despliegue php artisan queue:restart y reinicio de Horizon

9. Verificación

  • Tests unitarios del estado deseado, del hash y del orden topológico, sin IO, con cliente fake. Se escriben pero no se ejecutan en el servidor dev compartido.
  • En dev: --dry-run, revisión del plan, --create, --update.
  • Criterio de aceptación: GET /admin/product-categories?include_descendants_tree=true devuelve los 6 padres con sus 30 hijas; 0 categorías locales con id obsoleto; un renombre, un cambio de padre y una desactivación desde el ABM se reflejan en Medusa sin que el request espere a Medusa.