Saltar a contenido

RFC 007 — Edición masiva de productos (motor genérico de acciones sobre lotes)

Estado: En implementación — bloques 0-3 completados (motor, handlers de categoría y campos directos, comando de consola). Pendientes: UI (bloques 4-6, 8-9) y decisiones D2, D4, D6 Fecha: 2026-08-24 Autores: TI Compulandia + Claude Relacionado con: RFC 001 (migración del sistema de categorías), RFC 003 (deprecación de product_categories), RFC 004 (productos compuestos), ADR-0001 (migraciones en subdirectorios), ADR-0004 (escritura con eventos suprimidos)


1. Objetivo

Dar a la operación una capacidad genérica de edición masiva desde el listado de productos: seleccionar N ítems por filtro o checkbox, elegir qué campo editar y qué operación aplicar, ver una vista previa del cambio y ejecutarlo en cola con traza en el log.

El cambio es irreversible por decisión de producto (D7). No se guarda el estado previo, así que no hay "deshacer": la vista previa, la confirmación explícita y el tope de lote son las tres barreras que lo sustituyen (§5.5).

El disparador concreto fue la recategorización (decenas de iPads en Tablets que deben pasar a la subcategoría iPads), pero el mismo problema aparece en:

  • categoría de salida del canal Compulandia,
  • campos del producto padre (marca, modelo, descripción, ficha técnica),
  • campos de la variante (descripción corta, part number, activo, publicado),
  • atributos (color, capacidad, etc.) y etiquetas.

Hoy todo eso se hace de a un producto por vez.

Dentro de alcance (primera versión): el motor de edición masiva, la categoría de salida CL y los campos directos de products y product_items — excluyendo los que son únicos o identificadores, clasificados uno por uno en §5.3 sobre la base real. Incluye la propagación controlada a los canales de salida y la traza en el log.

Fase siguiente: atributos (color, capacidad) y etiquetas — el motor ya los contempla como handlers, pero requieren pasar por el validador de asimilación (§4.5).

Fuera de alcance: precios (viven en prices por partner, con reglas de escala y margen propias — ver §8), imágenes, composición de combos (RFC 004), edición masiva de categorías de Supplier (afectan precio, ver §4.1), campos únicos e identificadores (§5.3, grupo 1) y reglas automáticas persistentes de categorización (§10, fase 2).

2. Estado actual (verificado en código)

2.1 Dónde se edita hoy cada cosa

Qué Dónde Alcance
Campos de la variante ProductItemViewController::update() → PATCH /product-items/{id} 1 variante
Campos del padre + categorías de partner ProductItemViewController::updateParent() → PATCH /product-items/{id}/parent 1 producto
Atributos (color, etc.) ProductAttributeService::assignAttributesToProduct() 1 variante
Etiquetas addTag() / removeTag() 1 variante

Todos comparten la misma UI: abrir la ficha de la variante, editar, guardar, volver. No existe ninguna acción masiva en el sistema.

2.2 El listado ya tiene la mitad del trabajo hecho

GET /product-items (product_item_view.index) filtra por búsqueda (SKU, nombre, marca, modelo, nombre de categoría), categoría CL, stock, activo, publicado, imágenes, tipo (simple/compuesto), atributo y término. Ya precarga product.partnerCategories acotado a CL.

El selector del lote ya existe. Falta el checkbox, la acción y el motor detrás.

2.3 Modelo de datos afectado

products ──< product_items ──< product_configurations >── terms ── attributes
    │              │                    ▲
    │              └──< product_tags >── tags
    │                                   │
    └──< product_partner_categories >── categories (categorizable = Partner)
                                          └─ CL(2) · TN(1) · CONTI(3) · UM(4)
  • Las categorías de salida cuelgan del producto padre, en la pivot product_partner_categories; filtrar categorizable_id = 2 aísla el canal Compulandia sin tocar los demás.
  • Los atributos viven en product_configurations con is_shared (compartido por todas las variantes) o por variante.
  • El listado muestra variantes, pero varios campos son del padre: mover o editar una variante afecta a sus hermanas (§4.4).

3. Problema

  1. Toda edición repetitiva cuesta una navegación completa por ítem. Un lote de 40 productos son 40 idas y vueltas.
  2. Sin vista previa, el operador no puede verificar el alcance antes de aplicar.
  3. Sin traza, un error de criterio no deja rastro de qué se tocó ni con qué valores.
  4. La propagación a los canales de salida hoy es inconsistente según el campo (§4.2): algunos cambios sincronizan solos, otros no sincronizan nunca.

4. Hallazgos que condicionan el diseño

4.1 Categoría de salida CL: cambiarla no afecta precios (riesgo descartado)

ProductPrincingService::getSalePrice(SupplierProduct $product) calcula sobre $product->categories — categorías de Supplier vía category_product (ProductPrincingService.php:28-56). factor, margin_min y scale_id se leen de ahí, y RecalculateCategoryProductPricesJob opera sobre la misma relación.

Mover un producto de Tablets a iPads en el canal CL cambia clasificación y contenido, no el precio de venta. Por eso la edición masiva de categorías de Partner es segura, y por eso las de Supplier quedan fuera de alcance.

4.2 La propagación a canales es hoy inconsistente por campo

Campo editado ¿Propaga hoy? Mecanismo
product_items.seo_name, short_description, ean, upc, part_number ✅ sí ProductItemObserver::updated() → ProductEvaluationStrategy → dispatchSync(..., 'product_basic')
product_items.active / publish ✅ sí idem, contexto product_status
products.* (nombre, marca, modelo, descripción, ficha técnica) ❌ no no hay ProductObserver registrado (AppServiceProvider:110-121)
Categoría CL (product_partner_categories) ❌ no ProductPartnerCategoryObserver sólo reacciona a UMarket
Atributos (product_configurations) ⚠️ parcial ProductAttributeService::dispatchSync() encola sólo WooCommerce, no Medusa/Algolia/Typesense
Etiquetas (product_tags) ✅ sí ProductTagObserver → ProductTagsEvaluationStrategy

Es decir: editar el nombre del producto padre o su categoría deja hoy el catálogo desactualizado en Woo, Medusa, Algolia y Typesense hasta que otro evento lo reindexe. El motor masivo debe cerrar ese hueco de forma uniforme — y conviene cerrarlo también para la edición 1×1.

4.3 Riesgo inverso: avalancha de jobs

Como ProductItem sí propaga en cada save(), un lote de 300 variantes editadas encolaría 300 × 7 canales ≈ 2.100 jobs, y si el lote toca dos campos, el doble. Sumado al problema conocido de jobs de sync lentos, esto puede saturar las colas.

Decisión de diseño: el motor aplica los cambios con los eventos de modelo suprimidos (Model::withoutEvents()) y luego propaga una sola vez por variante al terminar el lote. Ya existe la pieza para eso:

App\Jobs\DispatchProductItemSyncAt(array $productItemIds, string $startsAt, string $syncContext) recibe una lista de IDs, difiere el arranque y tiene WithoutOverlapping. Se reusa tal cual — no hay que inventar propagación.

4.4 Granularidad: variante vs producto padre

El listado muestra variantes; varios campos son del padre. El motor debe:

  • deduplicar por product_id cuando el campo es del padre,
  • avisar en la vista previa ("37 variantes seleccionadas → 31 productos padre; 6 variantes hermanas no seleccionadas también quedarán afectadas"),
  • al propagar, encolar todas las variantes de los padres tocados, no sólo las seleccionadas.

Lo mismo aplica a los atributos compartidos (is_shared = true).

4.5 Los atributos no se pueden escribir directo

ProductConfigurationValidationService implementa las reglas de asimilación: canAssignTermToVariant(), assignTermToVariant(), unassignTermFromVariant(), checkDuplicateCombination(), configureTermInProduct(). Hay validaciones de combinación duplicada entre variantes de un mismo padre.

El handler de atributos delega en ese servicio por variante y reporta los rechazos individualmente; nunca escribe product_configurations a mano. Consecuencia para la UI: en atributos, la vista previa no puede garantizar el 100 % de éxito — debe mostrar los rechazos al final del lote.

4.6 Unicidad: verificada sobre la base real, no supuesta

Se auditaron las dos tablas (SHOW CREATE TABLE + conteo de distintos sobre 17.561 variantes / 17.559 productos en la base de desarrollo).

Índices únicos declarados en la base:

Tabla Columna Constraint
products id_medusa_product products_id_medusa_product_unique
product_items sku product_items_sku_unique
product_items id_medusa_variant product_items_id_medusa_variant_unique

Sólo tres. El resto de la unicidad es funcional, no está protegida por la base — por eso hacía falta medirla:

Campo No nulos Distintos Lectura
products.medusa_handle 17.554 17.554 único de facto — es el slug de URL en Medusa
product_items.id_woocommerce 15.929 15.929 único de facto — ID externo
product_items.url_woocommerce 15.049 15.047 casi único — derivado del slug
product_items.seo_name 17.560 17.389 casi único (171 repetidos)
products.name 17.559 17.394 casi único (165 repetidos)
product_items.sku_sap 6.596 6.535 casi único (61 repetidos) — código SAP
product_items.ean 3.069 2.656 repetido (hasta 9 veces el mismo EAN)
product_items.upc 1 1 prácticamente sin uso
product_items.part_number 364 167 muy repetido — ver abajo
products.brand 6.247 240 claramente de lote
products.model 122 91 claramente de lote

Dos observaciones que cambian la clasificación:

  • medusa_handle es único de facto sin constraint (17.554/17.554). Es el slug de la URL pública en Medusa: duplicarlo rompe el storefront sin que la base se queje. Es el caso más peligroso de todos, precisamente porque nada lo protege.
  • part_number no es un identificador en la práctica. Sus valores más repetidos son 2024 (15 veces), Watch Series 10 (9), Galaxy A36 (8), SM-X210 (6): el campo se está usando como modelo o año, no como MPN. Es apto para edición masiva completa.

4.7 La exclusión es por (campo × operación), no por campo

Un campo identificador no tolera set masivo — asignar el mismo EAN a 40 variantes es inválido por definición — pero sí tolera operaciones que preservan la distinción entre ítems: clear (vaciar) y replace_text (transformar cada valor).

Esto ordena tres grupos en vez de dos:

  1. Bloqueados por completo: identidad y vínculos externos. Ninguna operación.
  2. Sólo operaciones que preservan distinción: clear y replace_text; nunca set.
  3. Abiertos: todas las operaciones del campo.

Además, guard genérico del motor: antes de aplicar cualquier operación sobre un campo marcado como único o casi único, la vista previa detecta colisiones (valor resultante ya existente en otra fila) y las lista como ítems omitidos. Esto protege también contra futuros campos que se agreguen sin analizar.

4.8 activity_log no sirve como traza del lote

ProductItem usa LogsActivity (Spatie 4.12) y la tabla activity_log incluso trae la columna batch_uuid, pensada justamente para agrupar operaciones masivas. Se evaluó reusarla y se descartó, con datos:

  • La tabla tiene 6.799.950 filas y es de las más calientes del sistema.
  • batch_uuid no tiene índice: agrupar por lote sería un full scan de 6,8M filas, o una migración de índice sobre esa tabla.
  • Mezclaría el lote con la actividad normal del producto: su ficha pasaría a mostrar cientos de entradas de una sola operación administrativa.

Reusar lo existente sale más caro que no persistir nada. La traza va al canal de log BULK_EDIT, en archivo (§5.5).

5. Propuesta: motor de edición masiva con handlers por campo

Una sola pieza de dominio, extensible campo por campo. Agregar un campo nuevo al futuro = escribir una clase y registrarla.

  Listado /product-items
  ☑ selección (página o "todo el filtro")
          │
          ▼
  [Editar seleccionados] ──▶ campo ▾ · operación ▾ · valor
          │
          ▼
  BulkEditService
    ├─ resolve(criteria|ids)  → variantes + padres deduplicados
    ├─ preview(handler, op)   → diff por ítem, sin escribir
    ├─ apply()                → withoutEvents + transacción por chunk
    │                           └─ delega en el handler del campo
    └─ propagate()            → PropagateBulkEditJob (1 sync por variante)
          │
          ▼
  Log BULK_EDIT — una línea por producto: campo, antes → después

5.1 Contrato del handler

app/Services/BulkEdit/Contracts/BulkEditFieldHandler.php

interface BulkEditFieldHandler
{
    public function key(): string;            // 'partner_category_cl', 'product.brand', ...
    public function label(): string;          // 'Categoría (Compulandia)'
    public function scope(): string;          // item | product | attribute | tag
    public function operations(): array;      // [Op::SET, Op::REPLACE_TEXT, Op::ADD, ...]
    public function inputType(): string;      // text | select | multiselect | boolean | json_kv
    public function options(): array;         // opciones para select (categorías, terms, tags)

    public function validate(Operation $op): void;          // reglas por campo
    public function preview(Collection $targets, Operation $op): Collection;  // [id => [before, after, skip?]]
    public function apply(Collection $targets, Operation $op): BulkEditResult; // aplicado/omitido/error
}

5.2 Operaciones genéricas

Operación Aplica a Ejemplo
set escalares, booleanos, relación 1:N poner publish = 1; mover a categoría iPads
clear escalares nullables vaciar part_number
replace_text strings reemplazar "Notebook" → "Notebook / Laptop" en la descripción
prepend / append strings anteponer "Apple " a la marca
add / remove relaciones N:N agregar el término Color: Negro; quitar la etiqueta Liquidación
set_json_key / remove_json_key technical_details fijar RAM = 16 GB en la ficha técnica

replace_text con vista previa cubre las correcciones de nomenclatura que hoy se hacen a mano — sobre los campos habilitados (§5.3); en name y seo_name depende de la decisión D6.

5.3 Catálogo de campos: clasificación columna por columna

Alcance de la primera versión: la categoría de salida CL y los campos directos (columnas de products y product_items). Atributos y etiquetas quedan para la fase siguiente (§10). Cada columna de ambas tablas fue clasificada — ninguna queda sin veredicto explícito.

Grupo 1 — Bloqueados por completo (lista negra en código)

Columna Tabla Motivo
id ambas clave primaria
sku product_items UNIQUE en base; identidad del ítem en todo el sistema
id_medusa_variant product_items UNIQUE en base; vínculo con Medusa
id_medusa_product products UNIQUE en base; vínculo con Medusa
medusa_handle products único de facto sin constraint (§4.6); slug de URL pública
id_woocommerce product_items único de facto; ID externo
url_woocommerce product_items derivado del slug de Woo; casi único
product_id product_items reparentar dispara variant_reparent en Medusa (ProductEvaluationStrategy)
selected_supplier_product_id product_items no es un dato: es la decisión de abastecimiento que define precio y stock
type product_items simple/composite (RFC 004); marcar compuesto sin receta rompe el combo
category_id products legacy deprecado por RFC 003; la categoría va por product_partner_categories
created_at / updated_at ambas timestamps
name products excluido por decisión de negocio (identidad comercial y base del SEO)
seo_name product_items excluido por decisión de negocio (base de URL y SEO)

Sobre las dos últimas: la base no las declara únicas y los datos tienen repetidos (165 y 171 respectivamente), así que la exclusión es de negocio, no técnica. Queda anotado como D6: replace_text sobre ellas (corregir "Ipad" → "iPad" en 200 títulos) es una corrección de nomenclatura frecuente, preserva la distinción entre ítems y el guard de colisiones (§4.7) la cubriría. Si la operación lo pide, habilitar sólo replace_text es de bajo riesgo; set, prepend y append quedan fuera en cualquier caso.

Grupo 2 — Sólo operaciones que preservan distinción (clear, replace_text)

Columna Tabla Dato medido
sku_sap product_items 6.596 valores / 6.535 distintos — código SAP, casi identificador
ean product_items 3.069 / 2.656 — identifica el producto físico
upc product_items 1 registro — mismo carácter que EAN

Nunca set: asignar el mismo EAN o el mismo código SAP a N variantes es inválido por definición. Vaciar un campo mal cargado en un lote, o corregir un prefijo, sí.

Grupo 3 — Abiertos (todas las operaciones del campo)

Handler Scope Operaciones Propagación hoy Nota
partner_category_cl product set (reemplaza la CL actual) ❌ cierra hueco §4.2 caso disparador; sin efecto en precios (§4.1)
product.brand product set (del catálogo), clear ❌ cierra hueco §4.2 11.312 de 17.559 productos sin marca; valor acotado a los términos del atributo "Marca" (§5.7)
product.model product set, replace_text, clear ❌ cierra hueco §4.2 17.437 sin modelo
product.description product set, replace_text, clear ❌ cierra hueco §4.2 6.294 sin descripción
product.technical_details product set_json_key, remove_json_key ❌ cierra hueco §4.2 JSON; hoy vacío en el 100 % de los productos
item.short_description item set, replace_text, prepend, append, clear ✅ ya existe 4.697 sin descripción corta
item.part_number item set, replace_text, clear ✅ ya existe no es identificador en la práctica (§4.6)
item.active item set ✅ ya existe alto riesgo
item.publish item set ✅ ya existe alto riesgo

active y publish son los únicos del grupo 3 que sacan productos de la venta: requieren gate propia (bulk-edit-critical), confirmación con el conteo escrito por el operador y tope duro de tamaño de lote.

El caso de uso más grande que revela la auditoría no es la recategorización sino brand: casi dos tercios del catálogo no tiene marca cargada, y el campo sólo tiene 240 valores distintos — es exactamente el perfil de un campo que se completa por lote y hoy se completa de a uno.

5.4 Aplicación y propagación

// por chunk de 200, dentro de transacción
DB::transaction(function () use ($chunk, $handler, $op, $batch) {
    Model::withoutEvents(function () use (...) {   // §4.3: nada de sync por save
        $result = $handler->apply($chunk, $op);
        $batch->recordItems($result);              // snapshot before/after por ítem
    });
});

// al terminar todos los chunks: una sola sincronización por variante afectada
PropagateBulkEditJob::dispatch(
    $batch->id,
    $affectedProductItemIds,          // incluye variantes hermanas (§4.4)
    $handler->syncContext()           // 'product_basic' | 'product_status'
)->onQueue('bulk_edit')
 ->onConnection('redis_bulk_edit')
 ->delay(now()->addSeconds(config('bulk_edit.sync_delay')));

Cola y conexión propias para que un lote grande no bloquee las colas de negocio, con chunk_size y sync_delay en config/bulk_edit.php.

Dos correcciones sobre el borrador, verificadas en el código de los canales:

  1. No se inventa el contexto product_category. SyncToWooCommerceJob resuelve el tipo con un match que lanza InvalidArgumentException ante un valor desconocido: un contexto nuevo haría fallar todos los jobs de Woo del lote. Y no hace falta — buildProductPayload() ya incluye categories y la descripción del padre, así que product_basic arrastra el cambio de categoría. Se usan sólo los contextos que los canales ya manejan.
  2. La propagación va en un job propio (PropagateBulkEditJob) en vez de reusar DispatchProductItemSyncAt directamente. Reusa el mismo ProductSyncDispatcherService —que es la pieza que importa— pero loguea en el canal BULK_EDIT en vez del canal de Medusa que aquel tiene fijo, marca synced_at en el detalle del lote y usa una clave de bloqueo acotada al lote en lugar de una construida concatenando todos los IDs.

Logging con el formato obligatorio del proyecto: [BULK_EDIT:{product_item_id}:{sku}] Campo actualizado, canal nuevo en config/logging.php + App\Constants\LogChannels.

5.5 Traza: sin persistencia, sin reversión (D7)

Decisión de producto: revertir un lote no es relevante para la operación. Alcanza con advertir que la acción es irreversible. Cero migraciones, cero tablas nuevas.

La consecuencia es directa: el estado previo se pierde al sobrescribirlo. Un lote mal aplicado sobre 200 productos no se deshace con un botón; se reconstruye a mano leyendo el log.

Lo que queda como traza es el canal BULK_EDIT (storage/logs/jobs/bulk_edit/, retención 30 días), con una línea por producto modificado y el formato obligatorio del proyecto:

[BULK_EDIT:14275:07765] product.brand: (vacío) → "Apple"
[BULK_EDIT:14276:07766] partner_category_cl: "Tablets" → "iPads"

El contexto de cada línea lleva la referencia del lote, el usuario, el criterio de selección y los valores completos, de modo que las líneas de una misma operación se puedan agrupar al leer el archivo. Esa referencia se genera en memoria: el lote no es una entidad del sistema, no se guarda en ningún lado.

Las tres barreras que sustituyen a la reversión:

  1. Vista previa obligatoria. No es un modo opcional: el comando no escribe sin --confirm, y la UI no habilitará aplicar antes de mostrar el diff.
  2. Confirmación explícita. Con --confirm, el comando advierte que la acción no se puede deshacer y pide confirmar; --force existe sólo para scripts.
  3. Tope de lote. 200 objetivos por defecto, 100 para los campos críticos (active, publish). Sin reversión el tope deja de ser una protección de rendimiento y pasa a ser el límite del daño posible.

El guard de colisiones sobre campos casi únicos (§4.7) se mantiene.

Puerta abierta: ChangeSet sigue llevando el estado previo de cada objetivo — lo necesita la vista previa para mostrar el diff. Si en el futuro se quiere deshacer, lo único que falta es persistirlo; el motor no habría que rehacerlo.

5.6 Permisos

Acción sensible: se controla con la Gate bulk-edit, y los campos que sacan productos de la venta con una segunda, bulk-edit-critical. Ambas se validan en el servidor al previsualizar y al aplicar, no sólo escondiendo el botón.

El sistema no usa permisos de Spatie —la tabla permissions está vacía—, así que las Gates se resuelven contra roles, listados en config/bulk_edit.php. Ahí se ve una deuda de datos previa: en la base conviven super_admin, superadmin y super_admim (las dos últimas por error de carga), y las tres están en la lista para no dejar afuera a un usuario existente.

5.7 La marca se elige del catálogo, no se escribe

products.brand es una columna de texto libre, pero el catálogo real de marcas son los términos del atributo Marca — la misma fuente que usa el formulario de confirmación de pendientes (ConfirmationModal).

Medido sobre la base: de las 240 marcas distintas cargadas, hay 130 productos con una marca que no existe en el catálogo de 213 términos (TOKYO INDUSTRIAL, REMINGTON, VELOZ…), y no es una diferencia de mayúsculas — se verificó comparando en mayúsculas y el número no baja.

Con texto libre, un solo lote mal escrito agrega decenas de variantes nuevas de una vez. Por eso el handler de marca:

  • admite sólo set con un valor del catálogo y clear;
  • no admite replace_text, que produciría valores arbitrarios — justamente lo que se busca evitar;
  • ante un valor inexistente, sugiere el parecido, contemplando tanto la subcadena como el error de tipeo ("Aple" → ¿Quisiste decir: Apple?);
  • guarda el texto de la marca, no el ID del término: el payload de WooCommerce, el documento de búsqueda y Medusa leen products.brand como cadena.

Agregar una marca nueva sigue siendo una operación aparte, en el atributo Marca. La edición masiva consume el catálogo, no lo amplía.

6. UI

Según docs/estandares/ui-design-standards.md (Medusa Admin / shadcn: simplicidad, sin iconos decorativos, sin cards anidadas, sin headers de color).

Listado. Checkbox por fila + checkbox en el encabezado. Al haber selección, barra fija inferior:

  37 seleccionados en esta página · seleccionar los 412 del filtro     [Editar]  [Limpiar]

Panel de edición (modal Livewire sobre el listado). Se implementó como modal y no como drawer lateral para seguir el patrón que ya usa el proyecto en el flujo de productos pendientes (ConfirmationModal): mismo componente Bootstrap, misma forma de abrirlo con Livewire.dispatch.

  Editar 37 productos

  Campo      [ Categoría (Compulandia)        ▾ ]
  Operación  [ Reemplazar                     ▾ ]
  Valor      [ Tablets › iPads                ▾ ]

  ─ Vista previa ────────────────────────────────────────────────
   ☑ 07765  Apple iPad Air 11" M2 128GB     Tablets  →  iPads
   ☑ 07766  Apple iPad Pro 13" M4 256GB     Tablets  →  iPads
   ☐ 07801  Funda para iPad Air             Tablets  →  iPads      ← desmarcado
   … 34 más

   ⚠ 37 variantes → 31 productos padre. 6 variantes hermanas no
     seleccionadas también quedarán afectadas.
   ⚠ La categoría destino no tiene id_medusa_category.

                                        [ Cancelar ]  [ Aplicar a 36 ]

La vista previa es obligatoria: preview() no escribe nada y se recalcula al cambiar campo, operación o valor.

Tras aplicar: pantalla de lote con progreso, conteos (aplicados / omitidos / con error) y botón Revertir lote.

Nota de UI aparte: el selector de categorías (getCategoriesDropdown()) hoy devuelve una lista plana ordenada por nombre. Para el caso Tablets › iPads hace falta mostrar la jerarquía (parent_id), o el operador no distingue categorías homónimas en distintos niveles.

7. Comando de consola

Mismo motor, sin UI — para lotes grandes y para preparar cambios antes de exponerlos:

php artisan products:bulk-edit --field=partner_category_cl --op=set --value=87 \
    --filter-category=12 --filter-search="iPad" --dry-run

php artisan products:bulk-edit --field=product.brand --op=set --value="Apple" \
    --filter-category=87 --confirm

8. Fuera de alcance en la primera versión (y por qué)

Tema Motivo
Precios Viven en prices por partner, con escalas (price_scales) y márgenes; se calculan desde categorías de Supplier. Amerita su propio RFC.
Categorías de Supplier Cambiarlas sí altera el precio de venta (§4.1).
Imágenes Flujo propio (Cloudflare, is_public, orden).
Composición de combos RFC 004.
Reglas automáticas persistentes Fase 2 (§10, D5): el motor ya deja el lugar — sólo cambia quién produce la lista de objetivos.

9. Riesgos y mitigaciones

Riesgo Mitigación
Editar más de lo previsto (iPad matchea fundas) Vista previa obligatoria + desmarcado individual + confirmación explícita + tope de lote (§5.5)
Avalancha de jobs de sync (§4.3) withoutEvents + una sync por variante vía DispatchProductItemSyncAt, cola propia, chunk y delay configurables
Romper identidad o vínculos externos Lista negra en código (§5.3 grupo 1), no sólo ausencia en la UI
Colisión de valores en campos únicos o casi únicos Guard genérico (§4.7): la vista previa detecta el valor resultante ya existente en otra fila y lo marca como omitido
Duplicar medusa_handle (único de facto, sin constraint) Bloqueado por completo; es el caso donde la base no avisaría (§4.6)
set masivo de un identificador (mismo EAN a N ítems) Grupo 2 (§5.3): esos campos sólo admiten clear y replace_text
Despublicar productos por error Gate específica, confirmación con conteo escrito, tope de tamaño de lote
Efecto colateral en variantes hermanas (§4.4) Advertencia explícita en la vista previa con el conteo real
Rechazos de validación en atributos (§4.5) Handler delega en el validador y reporta rechazos ítem por ítem; lote queda partially_applied
Estado inconsistente si falla a mitad Transacción por chunk + estado por ítem + reintento del lote
Un lote mal aplicado no se puede deshacer (D7) Las tres barreras de §5.5, y el detalle en el log para reconstruir a mano

10. Plan de implementación

Bloque Contenido Estado
0 config/bulk_edit.php, canal de log BULK_EDIT, cola redis_bulk_edit + supervisor de Horizon (sin migraciones, D7) ✅ hecho
1 BulkEditService + contrato BulkEditFieldHandler + operaciones + guard de colisiones + traza en log + tests ✅ hecho
2 Handler partner_category_cl (resuelve el caso iPad) + cierre del hueco de propagación §4.2 en updateParent() ✅ hecho
3 Comando products:bulk-edit (vista previa por defecto, --confirm + confirmación interactiva para aplicar) ✅ hecho
5 Handlers de campos directos: brand, model, description, technical_details, short_description, part_number, sku_sap, ean, upc ✅ hecho
6 Handlers de active / publish con tope de lote propio y gate crítica ✅ hecho
4 Selección en el listado + barra de acciones + panel Livewire con vista previa y desmarcado ✅ hecho
8 (descartado por D7) Pantalla de lotes con reversión descartado
9 Gates bulk-edit / bulk-edit-critical + selector de categorías jerárquico ✅ hecho
7 (fase siguiente) Handlers de atributos y etiquetas pendiente · 1 día

Los handlers de campos directos (bloque 5) y los críticos (bloque 6) se adelantaron porque el motor ya los soportaba sin código adicional: son declaraciones en el registry, no clases nuevas.

Estado actual: completo para el alcance de la primera versión — motor, consola y UI — sobre la categoría de salida y los nueve campos directos. Queda pendiente la fase siguiente (atributos y etiquetas, bloque 7) y la validación de una escritura real contra la base de desarrollo, que hasta ahora sólo se ejercitó en vista previa.

11. Decisiones abiertas

  • D1 — Alcance de la primera entrega. ¿Bloques 0-3 (motor + categoría por consola, esta semana) y la UI después, o el paquete completo 0-8?
  • D2 — Prioridad de handlers. Después de categoría, la auditoría sugiere brand (11.312 de 17.559 productos sin marca, sólo 240 marcas distintas). ¿Se confirma, o duele más publish/active (altas y bajas de venta)?
  • D3 — Semántica de la categoría de salida. Se asume reemplazo de la única categoría CL, consistente con updateParent(). ¿Existe algún caso donde un producto deba estar en dos categorías CL a la vez?
  • D4 — Selección "todo el filtro". ¿Se permite aplicar sobre el resultado completo del filtro (posiblemente miles) o se exige un tope duro (p. ej. 500 por lote)?
  • D5 — Fase 2 (reglas automáticas). ¿RFC aparte para categorización por reglas al alta de producto, o se descarta por ahora?
  • D7 — Reversión: descartada. Decidido el 2026-08-24: revertir un lote no es relevante para la operación, alcanza con advertir que la acción es irreversible. Se eliminaron las dos tablas de lote y todo el flujo de reversión. Reabrir esta decisión implica volver a persistir el before de cada objetivo (§5.5).
  • D6 — replace_text sobre name y seo_name. Quedaron excluidos por decisión de negocio, no por restricción técnica: la base no los declara únicos y ya tienen repetidos (165 y 171). Corregir nomenclatura en lote ("Ipad" → "iPad") preserva la distinción entre ítems y quedaría cubierto por el guard de colisiones (§4.7). ¿Se habilita sólo replace_text sobre esos dos campos, o se mantienen cerrados?

12. Referencias