From 781bc459dd4c21311b4a42fce41ef1b434d8bd87 Mon Sep 17 00:00:00 2001 From: Cristian Felipe Cruz Buitron Date: Mon, 24 Aug 2026 15:45:14 -0500 Subject: [PATCH] Add script to migrate legacy collections + cybertron-sentinel category Documents and makes repeatable the Admin API writes for Fase 5 Etapa 2 sub-fase 1: assign real Medusa collections (Toy Story, Intensamente, Lilo & Stitch, Universo Marvel, Reino de Frozen) to 9 legacy products, and the missing "Figuras de accion" category to cybertron-sentinel. Categories for the 17 legacy products were already correct and are untouched. --- docs/migrate-legacy-collections.md | 84 +++++++++++++ migrate_legacy_collections.py | 186 +++++++++++++++++++++++++++++ 2 files changed, 270 insertions(+) create mode 100644 docs/migrate-legacy-collections.md create mode 100644 migrate_legacy_collections.py diff --git a/docs/migrate-legacy-collections.md b/docs/migrate-legacy-collections.md new file mode 100644 index 0000000..876d5a4 --- /dev/null +++ b/docs/migrate-legacy-collections.md @@ -0,0 +1,84 @@ +# Migración de colecciones legacy (Fase 5 / Etapa 2 / sub-fase 1, fix #2) + +Script: `scripts/migrate_legacy_collections.py`. + +## Contexto + +Al cerrar el fix #2 de la sub-fase 1 (categorías reales) se confirmó vía +Store API (2026-08-24) que los 17 productos legacy ya tenían la +`category` real correcta asignada desde una migración anterior, pero +**ningún producto tenía `collection` asignada** — el campo estaba en +`null` en los 27 productos existentes en ese momento (17 legacy + 1 +Cybertron Sentinel + 9 demo). Sólo 2 colecciones existían en Medusa: +"Universo Marvel" y "Reino de Frozen" (creadas en la auditoría de +Etapa 1). Este script cierra esa brecha. + +De paso asigna la categoría "Figuras de accion" a +`cybertron-sentinel-figura-de-coleccion-test-revalidate`, el único de +los 18 productos pre-existentes (no-demo) que quedó sin categoría en +la migración anterior — no es uno de los "17 legacy", es el producto +usado para validar el flujo de auto-refresh en el incidente Fase 4. + +**Fuera de alcance, por decisión explícita del usuario:** `metadata` +(brand/ageRange), precios, descripciones, imágenes. Solo +`categories`/`collection_id`. + +## Mapeo aplicado + +| Colección | Nueva/existente | Productos (handle) | +|---|---|---| +| Toy Story | nueva | toy-story-5-rex, toy-story-5-buzz-lightyear, toy-story-5-jessie, toy-story-5-woody | +| Intensamente | nueva | intensamente-furia, intensamente-tristeza | +| Lilo & Stitch | nueva | angel-lilo-y-stitch | +| Universo Marvel | existente (`pcol_01M0PDFBDA0TXQNCX4E2AP8DJ5`) | mochilas-disney-coleccion-marvel, mochila-escolar-de-spider-man-..., disney-mochila-iron-man-..., mochila-de-disney-marvel-capitan-america-... | +| Reino de Frozen | existente (`pcol_01M0PDFBX1PT3ZA4KHJE6C9VKT`) | mochila-ligera-...-frozen-... | +| — (sin colección, por mapeo explícito del usuario) | — | minnie-y-mickey-mouse, mochila-pequena-bob-esponja, mochila-casual-...-bob-esponja-..., disney-mochila-de-poliester-..., mochila-escolar-estilo-britanico-... | + +Categoría faltante: `cybertron-sentinel-figura-de-coleccion-test-revalidate` +→ "Figuras de accion" (`pcat_01M0PDF24BRVRKKMPMXCTY7QXJ`). + +Las 17 categorías legacy en sí **no se tocan** — se verificaron 1:1 +contra el mapeo del usuario antes de correr este script y ya +coincidían exactamente (ver memoria del proyecto +`project_fase5_etapa2_subfase1_categorias_2026_08_23.md`). + +## Endpoints Admin API (Medusa v2) usados + +- `GET /admin/collections?title=X` — chequear si una colección ya existe. +- `POST /admin/collections` — crear colección (`title`, `handle`). +- `POST /admin/collections/:id/products` con body `{"add": [...], "remove": []}` — asignar productos a una colección. +- `POST /admin/product-categories/:id/products` con body `{"add": [...], "remove": []}` — asignar productos a una categoría (mismo patrón confirmado en el fix #2 de categorías). + +Confirmado que **no existen** en esta versión de Medusa: +`POST /admin/products/:id/categories` (404), ni `collection_id`/`category_ids` +en el body de `PATCH /admin/products/:id` (400). + +## Cómo correrlo + +Requiere un JWT de admin (`ADMIN_JWT`) obtenido vía +`POST /auth/user/emailpass` — no se persiste en este repo. El +bloqueo del clasificador de auto-mode sobre escrituras en lote contra +producción aplica aquí igual que en el fix #2 de categorías (ver +memoria `feedback_auto_mode_classifier_hard_blocks`), así que el +`--apply` lo corre el usuario directamente, no Claude: + +```bash +export MEDUSA_BACKEND_URL=https://commerce.cruzcloud.net +export ADMIN_JWT= + +# 1. Verificar el plan sin escribir nada +python3 scripts/migrate_legacy_collections.py --dry-run + +# 2. Ejecutar +python3 scripts/migrate_legacy_collections.py --apply +``` + +## Rollback + +Cada asignación es aditiva (`add`) sobre relaciones many-to-many, no +destructiva de datos previos. Para revertir, llamar los mismos +endpoints con `{"remove": [...ids], "add": []}` con los mismos +product_id/collection_id/category_id documentados arriba. Las +colecciones creadas ("Toy Story", "Intensamente", "Lilo & Stitch") +se pueden borrar con `DELETE /admin/collections/:id` si hace falta +deshacer la migración por completo. diff --git a/migrate_legacy_collections.py b/migrate_legacy_collections.py new file mode 100644 index 0000000..3e3d046 --- /dev/null +++ b/migrate_legacy_collections.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +""" +Fase 5 / Etapa 2 / sub-fase 1 - cierre de fix #2. + +Asigna `collection_id` real a los productos legacy que el mapeo de +Toy Story / Intensamente / Lilo & Stitch / Marvel / Frozen requiere, y +asigna la categoria "Figuras de accion" al producto +cybertron-sentinel-figura-de-coleccion-test-revalidate (unico de los +18 productos pre-existentes sin categoria real). + +NO toca product.metadata (brand/ageRange), precios, descripciones ni +imagenes. Solo categories/collection_id via Admin API. + +Uso: + ADMIN_JWT= python3 migrate_legacy_collections.py --dry-run + ADMIN_JWT= python3 migrate_legacy_collections.py --apply + +El JWT se obtiene con POST /auth/user/emailpass (usuario de prueba +claude-test@cruzcloud.net de esta sesion) y no se persiste en este +archivo ni en disco. + +Endpoints Medusa v2 confirmados en la sesion previa (ver +apps-registry docs/memoria del lab, PR #27/#28): + - POST /admin/collections (crear coleccion) + - GET /admin/collections?title=X (chequear existencia) + - POST /admin/collections/:id/products {add, remove} (asignar productos) + - POST /admin/product-categories/:id/products {add, remove} (asignar categoria) + Los intentos con category_ids/collection_id en el body del producto + o con /admin/products/:id/categories NO existen en esta version (404/400). +""" +import argparse +import json +import os +import sys +import urllib.request +import urllib.error +import urllib.parse + +BACKEND_URL = os.environ.get("MEDUSA_BACKEND_URL", "https://commerce.cruzcloud.net") +ADMIN_JWT = os.environ.get("ADMIN_JWT") + +# Coleccion -> lista de (handle, product_id) +COLLECTIONS = { + "Toy Story": [ + ("toy-story-5-rex", "prod_01KZW8GK52V50RWNSDQ6CARC16"), + ("toy-story-5-buzz-lightyear", "prod_01KZW8GMEJRG8GPZFYCY2PYA73"), + ("toy-story-5-jessie", "prod_01KZW8GP4XNJHZRJJ8GJXCPQT7"), + ("toy-story-5-woody", "prod_01KZW8GPVFYWE2BM3C8501HKGQ"), + ], + "Intensamente": [ + ("intensamente-furia", "prod_01KZW8GQPTXJNDQVKKMQEPERN7"), + ("intensamente-tristeza", "prod_01KZW8GRA76WYW7RYBRBF64V9B"), + ], + "Lilo & Stitch": [ + ("angel-lilo-y-stitch", "prod_01KZW8GSGXR55AC1W5G3Y1AZY9"), + ], +} + +# Colecciones que YA existen (creadas en Etapa 1) -> (id, lista de (handle, product_id)) +EXISTING_COLLECTIONS = { + "Universo Marvel": ( + "pcol_01M0PDFBDA0TXQNCX4E2AP8DJ5", + [ + ("mochilas-disney-coleccion-marvel", "prod_01KZW8GT45M3B03GH161Z5C87G"), + ("mochila-escolar-de-spider-man-de-disney-moda-estudiantil-libro-de-gran-capacidad-mochila-ligera", "prod_01KZW8GVEJGPC6AWYWAJX25TAP"), + ("disney-mochila-iron-man-de-poliester-ultraligera-y-transpirable-de-gran-tamano-dimensiones-46-cm-x", "prod_01KZW8GZ2KM9E6ZNTSRHYZETPB"), + ("mochila-de-disney-marvel-capitan-america-de-gran-capacidad-ligera-y-resistente-a-salpicaduras-par", "prod_01KZW8H03649RWT2EMG3B0QSG5"), + ], + ), + "Reino de Frozen": ( + "pcol_01M0PDFBX1PT3ZA4KHJE6C9VKT", + [ + ("mochila-ligera-y-resistente-al-agua-de-disney-frozen-mochila-escolar-y-de-viaje-con-multiples-compa", "prod_01KZW8GTSV6DZ1PHQFVKXFKXTR"), + ], + ), +} + +# Categoria "Figuras de accion" (Juguetes > Figuras de accion), ya existe desde Etapa 1 +FIGURAS_ACCION_CATEGORY_ID = "pcat_01M0PDF24BRVRKKMPMXCTY7QXJ" +CATEGORY_ASSIGNMENTS = [ + ("cybertron-sentinel-figura-de-coleccion-test-revalidate", "prod_01KZYM5RZT89DRD3A86W3DQ164"), +] + + +def request(method, path, body=None): + url = f"{BACKEND_URL}{path}" + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(url, data=data, method=method) + req.add_header("Authorization", f"Bearer {ADMIN_JWT}") + req.add_header("Content-Type", "application/json") + try: + with urllib.request.urlopen(req) as resp: + return resp.status, json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + return e.code, {"error": e.read().decode()} + + +def find_collection_id(title): + status, body = request("GET", f"/admin/collections?title={urllib.parse.quote(title)}") + if status != 200: + raise RuntimeError(f"GET /admin/collections?title={title} -> {status} {body}") + cols = body.get("collections", []) + return cols[0]["id"] if cols else None + + +def main(): + parser = argparse.ArgumentParser() + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--dry-run", action="store_true") + group.add_argument("--apply", action="store_true") + args = parser.parse_args() + + if not ADMIN_JWT: + sys.exit("ADMIN_JWT env var requerido") + + plan = [] + + # 1. Colecciones nuevas: crear si no existen, luego asignar productos + for title, products in COLLECTIONS.items(): + existing_id = find_collection_id(title) + if existing_id: + plan.append(("skip-create-collection", title, existing_id)) + collection_id = existing_id + else: + plan.append(("create-collection", title, None)) + collection_id = None + + if args.apply: + if collection_id is None: + status, body = request("POST", "/admin/collections", { + "title": title, + "handle": title.lower().replace(" & ", "-").replace(" ", "-"), + }) + if status not in (200, 201): + raise RuntimeError(f"create collection {title} -> {status} {body}") + collection_id = body["collection"]["id"] + print(f"[apply] creada coleccion '{title}' -> {collection_id}") + + product_ids = [pid for _, pid in products] + status, body = request( + "POST", f"/admin/collections/{collection_id}/products", + {"add": product_ids, "remove": []}, + ) + if status not in (200, 201): + raise RuntimeError(f"assign products to {title} -> {status} {body}") + print(f"[apply] asignados {len(product_ids)} productos a '{title}'") + else: + for handle, pid in products: + plan.append(("assign-collection", title, handle, pid)) + + # 2. Colecciones existentes (Universo Marvel, Reino de Frozen): solo asignar + for title, (collection_id, products) in EXISTING_COLLECTIONS.items(): + if args.apply: + product_ids = [pid for _, pid in products] + status, body = request( + "POST", f"/admin/collections/{collection_id}/products", + {"add": product_ids, "remove": []}, + ) + if status not in (200, 201): + raise RuntimeError(f"assign products to {title} -> {status} {body}") + print(f"[apply] asignados {len(product_ids)} productos a '{title}' (ya existia)") + else: + for handle, pid in products: + plan.append(("assign-collection", title, handle, pid)) + + # 3. Categoria faltante de cybertron-sentinel + if args.apply: + product_ids = [pid for _, pid in CATEGORY_ASSIGNMENTS] + status, body = request( + "POST", f"/admin/product-categories/{FIGURAS_ACCION_CATEGORY_ID}/products", + {"add": product_ids, "remove": []}, + ) + if status not in (200, 201): + raise RuntimeError(f"assign category to cybertron-sentinel -> {status} {body}") + print(f"[apply] asignada categoria 'Figuras de accion' a cybertron-sentinel") + else: + for handle, pid in CATEGORY_ASSIGNMENTS: + plan.append(("assign-category", "Figuras de accion", handle, pid)) + + if not args.apply: + print(json.dumps(plan, indent=2, ensure_ascii=False)) + print(f"\n[dry-run] {len(plan)} acciones planeadas, ninguna ejecutada.") + + +if __name__ == "__main__": + main()