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
metadatade la categoría, que ya guardaid_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:
- Un servicio independiente de sincronización de categorías a Medusa.
- Un job en cola, uno por categoría.
- 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¶
- 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. - 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
ensureByNameresolvió creando una categoría nueva. Las otras 8 son de prueba o de pilas, ninguna con productos. - Un producto vinculado a una hija no aparece al listar la padre en Medusa salvo que
el storefront pida descendientes. Hoy el storefront pide
*productsde 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 coninclude_descendants_treedevuelve la jerarquía completa y es eso lo que se cachea. - 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_idguarda el id local de la categoría y permite reconciliar sin depender del nombre ni del handle.handlees único en Medusa y forma la URL pública/categories/{handle}.rankordena 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 unmetadataque fusiona el remoto conintegrador_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 devuelvecreate,update(sólo los campos que difieren) ounchanged.sync(Category): resuelve y sincroniza el padre antes que la hija (con guard contra ciclos, comoWoocommerceApiService::parentWooId); aplica el plan; al éxito escribemedusa_synced_at,medusa_hash(traza del último payload) y limpiamedusa_last_error; al fallo escribemedusa_last_errory relanza.deactivate(Category): envíais_active = false; para borrado local, ademásis_internal = true. No se borra en Medusa.- Sólo categorías del partner configurado (
PartnerConstants::CLpor defecto). NuncaProductCategorylegacy. - 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-runimprime el plan por categoría (acción y payload) sin escribir nada.--create,--update,--regenerate-handles,--ids=,--only-missingseparados, 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=truedevuelve 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.