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; filtrarcategorizable_id = 2aísla el canal Compulandia sin tocar los demás. - Los atributos viven en
product_configurationsconis_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¶
- Toda edición repetitiva cuesta una navegación completa por ítem. Un lote de 40 productos son 40 idas y vueltas.
- Sin vista previa, el operador no puede verificar el alcance antes de aplicar.
- Sin traza, un error de criterio no deja rastro de qué se tocó ni con qué valores.
- 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_idcuando 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_handlees ú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_numberno es un identificador en la práctica. Sus valores más repetidos son2024(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:
- Bloqueados por completo: identidad y vínculos externos. Ninguna operación.
- Sólo operaciones que preservan distinción:
clearyreplace_text; nuncaset. - 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_uuidno 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:
- No se inventa el contexto
product_category.SyncToWooCommerceJobresuelve el tipo con unmatchque lanzaInvalidArgumentExceptionante un valor desconocido: un contexto nuevo haría fallar todos los jobs de Woo del lote. Y no hace falta —buildProductPayload()ya incluyecategoriesy la descripción del padre, así queproduct_basicarrastra el cambio de categoría. Se usan sólo los contextos que los canales ya manejan. - La propagación va en un job propio (
PropagateBulkEditJob) en vez de reusarDispatchProductItemSyncAtdirectamente. Reusa el mismoProductSyncDispatcherService—que es la pieza que importa— pero loguea en el canalBULK_EDITen vez del canal de Medusa que aquel tiene fijo, marcasynced_aten 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:
- 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. - Confirmación explícita. Con
--confirm, el comando advierte que la acción no se puede deshacer y pide confirmar;--forceexiste sólo para scripts. - 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
setcon un valor del catálogo yclear; - 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.brandcomo 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áspublish/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
beforede cada objetivo (§5.5). - D6 —
replace_textsobrenameyseo_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óloreplace_textsobre esos dos campos, o se mantienen cerrados?
12. Referencias¶
- ProductItemViewController::update() / updateParent()
- ProductItemObserver · ProductEvaluationStrategy
- ProductPartnerCategoryObserver
- ProductAttributeService · ProductConfigurationValidationService
- ProductSyncDispatcherService · DispatchProductItemSyncAt
- ProductPrincingService
database/migrations/67/2026_01_14_000004_create_product_partner_categories_table.php