Saltar a contenido

RFC 004 — Soporte de productos compuestos (combos / recetas PC Manager)

Estado: Análisis cerrado — decisiones D1–D12 confirmadas; listo para diseño de implementación Fecha: 2026-07-13 Autores: TI Compulandia + Claude


1. Objetivo

Dar soporte en el modelo de datos del Integrador a productos compuestos (combos): productos cuyo contenido real es una lista de otros productos (componentes) con cantidades. Ejemplo: PC Gamer X compuesta por gabinete + mainboard + RAM + SSD + fuente.

Alcance acotado: el Integrador NO gestiona la creación del combo ni el cálculo de su precio — eso ya lo hace PC Manager. Lo que falta aquí es recibir, persistir y exponer la información de composición, para que los sistemas aguas abajo (OMS → SAP) puedan operar con ella.

Fuera de alcance (al menos inicialmente): cálculo de precio del combo, validación de stock agregado de componentes, UI de armado de combos en el Integrador.

2. Contexto del ecosistema

                    (1) GET /api/products (diario, pull)
   ┌─────────────┐ ───────────────────────────────────▶ ┌────────────┐
   │  PC Manager │                                       │ Integrador │
   │ (arma combos│ ◀─────────────────────────────────── │  (este     │
   │  y precios) │  (2) push de productos/componentes    │   repo)    │
   └─────────────┘      creados/actualizados             └─────┬──────┘
                                                               │ (3) confirmación manual
                                                               │     en "pendientes"
                                                               ▼
                                                    ┌─────────────────────┐
                                                    │ Algolia             │
                                                    │ (índice por evento) │
                                                    └─────────┬───────────┘
                                                              │ (4) búsqueda de productos
                                                              ▼
                                                    ┌─────────────────────┐    ┌─────────┐
                                                    │ OMS (externo,       │───▶│ SAP B1  │
                                                    │ no está en este     │ (5)│ Service │
                                                    │ repo)               │    │ Layer   │
                                                    └─────────────────────┘    └─────────┘
  1. PC Manager lee diariamente la lista de productos del Integrador (GET /api/products, app/Http/Controllers/Api/ProductController.php) para mantener actualizado su catálogo de componentes candidatos. La respuesta son filas de ProductItemView (la vista SQL de catálogo).
  2. Con esos componentes, en PC Manager se arman productos compuestos y se determina su precio. Cuando se crean o cambian, PC Manager (Python) los pushea al Integrador vía POST /api/supplier-products/sync (Sanctum → SupplierProductSyncController@sync), donde entran como SupplierProduct del supplier CL (Compulandia, supplier_id = 1) con SKU de prefijo pcm-. Payload actual por producto: sku, name, short_description, long_description, regular_price, special_price, special_from_date, special_to_date, stock, category, brand, active, images — sin composición.
  3. En el Integrador quedan en la lista de pendientes (SupplierProduct con id_product_item IS NULL, PendingProductsController), donde un operador los confirma como producto nuevo o como variante de un producto existente. La confirmación crea/enlaza Product + ProductItem.
  4. El producto confirmado se indexa en Algolia por evento (cadena SupplierProduct::boot() / ProductItemObserver → ProductItemView::toSearchableArray()).
  5. Un OMS externo consume ese índice para armar pedidos que se crean en SAP Business One vía Service Layer.

El punto crítico: SAP factura por componentes (receta/BOM creado al vender)

En SAP, el producto compuesto no tiene entrada de inventario propia. Al facturar, el stock que se descuenta es el de los componentes — SAP lo trata como una receta/BOM. Esto ya está reflejado (indirectamente) en el código: los prefijos PC- y PCM- están excluidos de sku_sap justamente porque "son productos especiales sin entrada de inventario en SAP" (SupplierProduct::SKU_PREFIXES_EXCLUDED_FROM_SKU_SAP, app/Models/SupplierProduct.php:796).

Cómo opera el lado SAP (confirmado): SAP no tiene el BOM del combo precargado, pero está configurado para soportar la creación de productos BOM. El BOM no se sincroniza desde el Integrador: recién cuando el combo se vende, el OMS crea en SAP el producto BOM con sus componentes al momento de crear el pedido. La disponibilidad del combo no se gestiona en SAP sino en PC Manager. Por eso el Integrador solo necesita transportar y exponer la receta para que el OMS la tenga disponible al armar el pedido.

3. El problema

Hoy el producto compuesto llega al Integrador como un producto plano: nombre, precio, atributos, stock — igual que cualquier otro. La relación con sus componentes se pierde en la frontera PC Manager → Integrador: PC Manager la conoce, pero no la transmite (o el Integrador no tiene dónde guardarla).

Consecuencia operativa: cuando el OMS encuentra publicado un combo X y necesita crear el pedido en SAP, no hay forma sistemática de saber qué componentes lo integran. Determinar la lista de componentes para cargar el pedido (que en SAP debe ir línea por línea de componente, porque el stock se descuenta por componente) es hoy un proceso manual y laborioso.

4. Estado actual del modelo de datos (verificado en código)

Entidad Rol Observación relevante
supplier_products Producto crudo del proveedor Sin campo de tipo ni relación padre-hijo. id_product_item NULL = pendiente.
products Producto base unificado Sin campo type/is_composite.
product_items Variante vendible (SKU único, sku_sap, id_medusa_variant) Es la entidad que el OMS termina referenciando vía Algolia.
match_products Única relación producto↔producto existente Es para matching de equivalencias entre proveedores, no composición. El modelo MatchProduct está casi todo comentado (código muerto).
product_item_view Vista SQL para catálogo/Algolia/API Redefinida en database/migrations/65/.... Es lo que consume PC Manager y lo que se indexa.

Conclusión: no existe hoy ninguna estructura para composición (bundle/kit/receta). Hay que crearla.

Vías de entrada de datos desde PC Manager (dónde recibir la composición)

  • Push (vía confirmada para los combos): POST /api/supplier-products/sync → SupplierProductSyncController@sync (routes/api.php:46, Sanctum; existe además el alias POST /api/supplier-products con StaticTokenAuth). PC Manager (Python) envía {supplier_id, supplier_code, products: [...]} con los campos planos listados en §2. Este es el contrato a extender.
  • Pull diario (LEGACY — ya no activo): app:sync-pc-manager → App\Services\PCM\PcManagerSyncService. Confirmado que este flujo ya no se usa: PC Manager pushea los productos cuando cambian. El comando sigue programado en app/Console/Kernel.php:22 (->daily()) — candidato a deprecación/limpieza para evitar que pise datos del push (su setNonUpdatedProductsInactive() desactiva sku LIKE 'PC-%' masivamente si corre).

5. Requerimiento funcional

  1. Tipo de producto: distinguir simple vs compuesto a nivel del producto unificado.
  2. Relación de composición: producto compuesto → N componentes, cada uno con cantidad. Los componentes son productos que ya existen en el Integrador (PC Manager los eligió justamente del catálogo que lee de aquí).
  3. Ingesta: que la composición viaje junto con el producto cuando PC Manager lo envía (alta y cada actualización — la receta puede cambiar).
  4. Exposición: que el índice de Algolia (y/o la API) incluya la composición del combo, con los identificadores que el OMS necesita para cargar líneas de pedido en SAP (sku_sap de cada componente + cantidad).

6. Propuesta de modelo de datos

6.1 Tipo de producto (a nivel variante)

Columna type en product_items (enum string: simple | composite, default simple). La naturaleza de compuesto es de la variante, no del producto (D4 revisada): un mismo Product puede tener conviviendo una variante simple y una compuesta.

Caso canónico (Acer Aspire X): la laptop viene de fábrica con 8GB y se vende así como variante simple. En PC Manager se arma además un compuesto "la misma laptop + servicio de ampliación de RAM a 16/32GB" (el servicio agrega precio). Ese combo entra como pcm- y se confirma como variante del producto existente "Acer Aspire X" — no como producto nuevo. Los combos también pueden ser productos nuevos (PC armada, laptop + mochila + mouse), pero la confirmación como variante es un flujo de primera clase.

Helpers: ProductItem::isComposite() (mira su propio type); constantes ProductItem::TYPE_SIMPLE / TYPE_COMPOSITE.

6.2 Tabla de composición

product_item_components
├── id
├── composite_product_item_id   FK → product_items.id   (el combo)
├── component_product_item_id   FK → product_items.id   (el componente)
├── quantity                    unsigned int, default 1
├── sort_order                  int, nullable
└── timestamps
UNIQUE (composite_product_item_id, component_product_item_id)

Se propone colgar la relación de product_items (no de supplier_products) porque: - El ProductItem es la entidad estable post-confirmación, la que tiene sku_sap y la que llega al OMS vía Algolia. - Un componente puede tener varios supplier_products (multi-proveedor); la receta apunta al ítem unificado.

6.3 Etapa "pendiente": composición cruda en supplier_products

Entre que PC Manager envía el combo y el operador lo confirma, el SupplierProduct no tiene ProductItem — no hay dónde colgar la relación aún. Propuesta: columna JSON components en supplier_products con el payload crudo, p. ej.:

[
  { "sku": "ABC-123", "quantity": 2 },
  { "sku": "XYZ-999", "quantity": 1 }
]

Al confirmarse el pendiente (processNewSupplierProduct() o enlace a variante existente), se materializa: se resuelve cada sku de componente a su ProductItem y se insertan las filas en product_item_components. Si algún componente no resuelve, la confirmación lo reporta (ver pregunta B3).

6.4 Ingesta: extensión del contrato de POST /api/supplier-products/sync

Extender el payload que PC Manager envía por producto con dos campos opcionales (retrocompatible — los productos simples no los envían):

{
  "sku": "pcm-00123",
  "name": "PC Gamer X",
  "type": "composite",
  "components": [
    { "sku": "ABC-123", "quantity": 2 },
    { "sku": "XYZ-999", "quantity": 1 }
  ],
  "...": "resto del payload actual sin cambios"
}

En SupplierProductSyncController@sync: - Validar type (simple|composite, default simple) y components (requerido si composite, con sku y quantity >= 1). - Persistir el array crudo en supplier_products.components (JSON, §6.3). - Si el SupplierProduct ya está confirmado (id_product_item asignado): re-materializar la receta en product_item_components en la misma operación (diff o delete+insert dentro de la transacción). Así los cambios de receta en PC Manager se reflejan sin re-confirmación manual.

Requiere cambio coordinado del lado PC Manager (Python) para emitir type y components en el payload de sync.

6.5 Exposición al OMS: índice product_items_view_index

El OMS consume product_items_view_index (confirmado, §7 D2), alimentado por ProductItemView::toSearchableArray(). Agregar ahí:

{
  "product_type": "composite",
  "components": [
    { "product_item_id": 501, "sku": "ABC-123", "sku_sap": "A00123", "name": "RAM 16GB DDR5", "quantity": 2 },
    { "product_item_id": 502, "sku": "XYZ-999", "sku_sap": "A00999", "name": "SSD 1TB NVMe", "quantity": 1 }
  ]
}

Con esto el OMS tiene todo lo necesario para, al momento de crear el pedido, crear el producto BOM en SAP con sus componentes (el sku_sap de cada componente es la clave para las líneas que descuentan stock). Se incluye name para que el OMS no necesite lookups adicionales.

components[].sku_sap puede venir null (decisión 2026-07-15, §6.7): componentes dropshipping (ej. CPI-*) o servicios no tienen inventario en SAP y aun así integran la receta. El OMS define el tratamiento de esas líneas al crear el pedido.

Semántica de la receta expuesta (D10): es la composición por defecto vigente. El operador del OMS puede cambiar un componente al momento de guardar el pedido (elige otro ítem del mismo índice); ese cambio es de alcance del pedido — no se retroalimenta al Integrador ni a PC Manager, y el BOM en SAP se crea con lo que el OMS finalmente guarda. El Integrador no necesita modelar listas de alternativas.

Nota de implementación: la vista SQL product_item_view no necesita cambiar — la composición se puede resolver en toSearchableArray() con la relación Eloquent del ProductItem (cuidando N+1 en reindexaciones masivas: eager load de la receta).

6.6 Re-indexación en cascada

Si cambia el sku_sap de un componente (o el componente se desactiva), el documento Algolia del combo queda desactualizado. Con la lógica de sustitución de PC Manager (§6.8) esto deja de ser un caso borde: cada cambio de receta debe reindexar el combo en el mismo evento — si el OMS lee una receta vieja, arma el pedido con un componente que justamente ya no está disponible (el motivo de la sustitución). Adicional para diseño detallado: re-index del combo cuando cambian atributos de sus componentes (relación inversa product_item_components.component_product_item_id).

6.7 Componentes no resolubles (expansión de B3)

La resolución de receta convierte cada components[].sku en un product_items.id. Puede fallar por:

  1. SKU inexistente — typo, o producto eliminado del Integrador después de que PC Manager lo leyó (carrera entre el pull de catálogo de PC Manager y el push del combo).
  2. Componente que es a su vez un combo (type = composite) — la receta debe ser plana (D12); recetas anidadas no soportadas.

Decisión 2026-07-15 (a): un componente inactivo NO bloquea la receta. La lógica de estado/disponibilidad la gobierna PC Manager; el Integrador materializa igual y el detalle del estado se muestra en la UI.

Decisión 2026-07-15 (b): un componente sin sku_sap NO bloquea la receta. No es requisito que el componente exista en SAP — es el modelo dropshipping: se publican productos sin inventario interno (ej. componentes CPI-* de Compras Paraguai). En el índice, components[].sku_sap puede venir null; el OMS define cómo tratar esas líneas al crear el pedido. Esta decisión resuelve también el punto de los componentes-servicio (supplier 777): tampoco necesitan sku_sap para formar parte de la receta.

Con esto, la verificación original 3 ("todo componente con sku_sap") se reemplazó por la 2 (no anidamiento), que era el invariante real que protegía.

Comportamiento propuesto por etapa:

Etapa Regla Racional
Ingesta (push) Aceptar siempre y guardar el JSON crudo en supplier_products.components. Responder por producto con warnings listando los SKUs no resolubles. El push no debe fallar por un dato que quizá se corrija solo (el componente puede confirmarse minutos después). El crudo nunca se pierde.
Confirmación del pendiente Bloquear si algún componente no resuelve (las 2 verificaciones de arriba: existe + no anidado). La UI muestra exactamente qué SKU falla y por qué. Confirmar un combo con receta incompleta publica al OMS un producto que reproduce el problema manual actual. Es el único punto con un operador humano delante que puede corregir.
Actualización post-confirmación Re-materialización atómica: si la nueva receta no resuelve completa, se actualizan los campos escalares (precio, stock, etc.) pero se conserva la receta anterior y se marca el desvío (components_synced_at queda desfasado de updated_at + log error canal PCM + warning en la respuesta API). Peor receta vieja consistente que receta nueva a medias. El OMS nunca debe ver una receta parcial. El desvío queda visible y alertable.

Para que la marca de desvío sea consultable: columna components_synced_at (timestamp nullable) en supplier_products — si components (JSON) cambió pero components_synced_at no avanzó, hay receta pendiente de materializar. Un listado/filtro en la UI de pendientes puede mostrarlos.

6.8 Actualizaciones de receta y sustitución de componentes

PC Manager tiene lógica de sustitución: si un componente no está disponible, evalúa otro para tomar su lugar. Implicaciones para el Integrador:

  • La receta es volátil por diseño. No es un dato que se fija al crear el combo — cambia cada vez que PC Manager sustituye. El modelo debe tratar components como snapshot autoritativo: en cada push, la receta recibida reemplaza completa a la anterior (delete+insert dentro de la transacción, no merge). No hay que "recordar" componentes que ya no vienen.
  • El Integrador no necesita conocer las reglas de sustitución (qué componente reemplaza a cuál) — solo el resultado. Las alternativas y su evaluación viven en PC Manager. Adicionalmente (D10), el operador del OMS puede cambiar un componente al guardar el pedido — cambio de alcance del pedido, sin retroalimentación al catálogo.
  • Cadena de latencia: sustitución en PC Manager → push → re-materialización → re-index Algolia → OMS. Confirmado (D11) que PC Manager pushea inmediatamente al sustituir, así que la ventana de desactualización depende solo del Integrador: la re-materialización + searchable() deben ocurrir en el mismo request del push (síncrono o job inmediato, no batch). Y si aun así el OMS viera una receta vieja, D10 da la válvula de escape: puede corregir el componente al guardar.
  • Trazabilidad sin versionado: mantener D6 (sin tablas de historial), pero registrar cada cambio de receta en el activity log del SupplierProduct (mecanismo ya existente, ver supplier_added/supplier_removed en SupplierProduct::boot()) con el diff de componentes. Suficiente para auditar "qué receta estaba vigente cuando se vendió X" cruzando timestamps, sin costo de modelo. El registro contable formal queda en SAP (el BOM se crea por pedido).
  • Interacción con §6.7: una sustitución puede introducir un componente no resoluble (el sustituto aún no existe/no está publicado en el Integrador). Aplica la regla de actualización post-confirmación: se conserva la receta anterior completa y se marca el desvío.

6.9 Soporte para Medusa: inventory kits

Medusa (2.15, ya en uso) soporta inventory kits: una variante vinculada a múltiples inventory items, cada uno con required_quantity; la disponibilidad de la variante se deriva como min(floor(stock_componente / required_quantity)). Es el equivalente Medusa de la receta.

El modelo propuesto lo soporta sin cambios de esquema, porque product_item_components ya contiene exactamente lo que el kit necesita: los ítems componentes (cuyo inventory item en Medusa se resuelve por SKU, como ya hace MedusaInventoryService::findInventoryItemIdBySku()) y la cantidad (→ required_quantity). De hecho, la mecánica ya existe en el código: MedusaInventoryService::linkVariantToInventoryItem() (app/Services/Medusa/MedusaInventoryService.php:233) hace el POST a /admin/products/{pid}/variants/{vid}/inventory-items con {inventory_item_id, required_quantity} — hoy siempre con un solo link y required_quantity = 1.

Mapeo por tipo de producto:

Aspecto Simple (hoy) Compuesto (kit)
Inventory item propio Sí (por su SKU) No — el combo no tiene inventario propio (coherente con SAP)
Links variante→inventory items 1, required_quantity=1 N (uno por componente), required_quantity = quantity de la receta
Stock (stocked_quantity) Se pushea al location level del item No se pushea para el combo — Medusa lo deriva de los componentes, cuyo stock ya sincroniza el Integrador por su propio flujo
Precio De la variante De la variante (viene en el payload de PC Manager; los kits de Medusa no derivan precio)

Implementación (2026-07-15, bloque 4b — hecho):

  1. MedusaInventoryService::orchestrateKitForVariant() — reconciliación del kit con semántica snapshot (espejo de product_item_components): primero resuelve TODOS los inventory items de los componentes por SKU (ensureInventoryItemForSku, find-or-create + attach a la ubicación); si alguno falla aborta sin tocar los links vigentes (peor kit viejo consistente que kit a medias). Recién entonces desvincula todos los links de la variante (unlinkAllVariantInventoryItems, idempotente) y crea los N links con required_quantity. El unlink inicial también limpia el link legacy al inventory item propio de combos que sincronizaron como simples pre-RFC 004 (el inventory item huérfano queda en Medusa desvinculado, inocuo). No se crea inventory item para el SKU pcm- ni se pushea stocked_quantity.
  2. orchestrateForVariant() delega al kit cuando $item->isComposite() — cubre automáticamente todos los call sites del flujo de creación (finalizeVariantInventory, auto-fix, reparent).
  3. Ramas composite explícitas en MedusaProductSyncService:
  4. finalizeVariantInventory() (creación/enlace): kit + manage_inventory=true, nunca el link al iitem propio aunque exista uno legacy con el SKU del combo.
  5. syncVariantBasics() STEP 3 (updates product_basic): reconcilia el kit en lugar del flujo iitem-propio/stock (extraído a syncSimpleVariantInventoryLevel()); precios e imágenes siguen igual para el combo.
  6. syncProductStatus(): sales channel + flags + kit + precios; sin nivel de stock propio.
  7. Disparo al cambiar la receta: ComponentRecipeService::syncRecipeFromCurrentState() despacha SyncToMedusaJob (product_basic) junto al reindex de Algolia (dispatchMedusaKitSync()), con la misma garantía: aunque EvaluateSelected decida should_sync=false (cambió solo la receta), el kit se reconcilia. Respeta el corte publish=0 y el flag enabled del canal. El sync es idempotente si DispatchSync también corre.
  8. Precondición (sin cambios respecto al diseño): los componentes deben existir en Medusa como inventory items; ensureInventoryItemForSku los crea si faltan (su stock lo fija el sync propio del componente). Tests en tests/Unit/CompositeProducts/MedusaKitInventoryTest.php (mock de MedusaClient).

Nota (D13): en Medusa la disponibilidad del combo queda derivada de los componentes, mientras PC Manager también calcula su propia disponibilidad y la manda como stock en el payload. Para Medusa gana la derivación del kit (el stock del payload se ignora para combos); ambas convergen porque parten del mismo stock de componentes.

6.10 Ejemplo end-to-end (cómo se conectan las piezas)

La composición vive en dos formas según la etapa: como JSON crudo en supplier_products mientras el combo no está confirmado, y como filas relacionales en product_item_components (colgando de product_items) una vez confirmado. La "transmisión" entre una y otra es la materialización: resolver cada sku del JSON a un product_items.id e insertar las filas.

Paso 1 — Push desde PC Manager. Llega el combo "PC Gamer X":

{ "sku": "pcm-00123", "name": "PC Gamer X", "type": "composite",
  "components": [ { "sku": "MEM-KING-16", "quantity": 2 },
                  { "sku": "SSD-WD-1TB",  "quantity": 1 } ] }

Se crea/actualiza el SupplierProduct. La composición queda solo como JSON:

supplier_products valor
sku pcm-00123
supplier_id 1 (CL)
id_product_item NULL → está pendiente
components [{"sku":"MEM-KING-16","quantity":2}, {"sku":"SSD-WD-1TB","quantity":1}]
components_synced_at NULL → receta aún no materializada

¿Por qué JSON y no filas relacionales en esta etapa? Porque (a) todavía no existe el ProductItem del combo — no hay a qué colgar la relación, y (b) los SKUs de componentes podrían no resolver aún (§6.7); el JSON preserva el dato crudo pase lo que pase.

Paso 2 — Confirmación (materialización). El operador confirma el pendiente como producto nuevo. Ahí ocurre todo junto, en una transacción:

  1. Se crea products id=900 y su variante product_items id=4501 con type = 'composite' (sku pcm-00123, sku_sap = NULL — excluido por prefijo, correcto: el combo no tiene inventario en SAP). supplier_products.id_product_item = 4501. (Si el operador lo confirma como variante de un producto existente — caso Acer Aspire X, §6.1 — no se crea products: la variante compuesta se agrega al producto existente.)
  2. Se resuelve cada sku del JSON contra product_items: MEM-KING-16 → item 501 (sku_sap = A00123), SSD-WD-1TB → item 502 (sku_sap = A00999). Si alguno no resuelve, la confirmación se bloquea (§6.7).
  3. Se insertan las filas relacionales:
product_item_components composite_product_item_id component_product_item_id quantity
fila 1 4501 501 2
fila 2 4501 502 1
  1. components_synced_at = now(). Desde acá la fuente de verdad relacional es product_item_components; el JSON queda como último payload recibido.

Paso 3 — Exposición. El documento Algolia del item 4501 (vía ProductItemView::toSearchableArray()) sale con product_type: "composite" y components: [{sku_sap: "A00123", quantity: 2}, {sku_sap: "A00999", quantity: 1}, ...]. El OMS ya puede crear el BOM en SAP.

Paso 4 — Sustitución. PC Manager reemplaza el SSD WD por un Samsung y pushea de inmediato. El controller ve que pcm-00123 ya tiene id_product_item (4501), entonces en el mismo request: actualiza el JSON, borra las filas de 4501 en product_item_components e inserta las nuevas (snapshot, D9), registra el diff en el activity log, y reindexa el item 4501. Si el nuevo SKU no resolviera, se conserva la receta anterior y se marca el desvío (§6.7).

Resumen del reparto de responsabilidades:

Estructura Etapa Rol
supplier_products.components (JSON) siempre Último payload crudo recibido de PC Manager. Buffer de ingesta, nunca se pierde.
product_item_components (tabla) post-confirmación Fuente de verdad relacional de la receta. Es lo que se expone.
product_items.type post-confirmación Marca la variante como compuesta.
components_synced_at siempre Detector de desvío entre JSON recibido y receta materializada.

6.11 Ingesta asíncrona vía SupplierProductChangeWorkflow (v2 — plan 2026-07-15)

Motivación: el push de PC Manager es síncrono (espera la respuesta para terminar su guardado) y hoy la materialización + reindex ocurren dentro del request — con recetas grandes la latencia se siente, y provocó reintentos del cliente (pushes duplicados, doble materialización). Confirmado: PC Manager no usa los warnings de la respuesta — solo el status — así que no se pierde nada moviendo la resolución fuera del request.

Diseño: el request queda mínimo (persistir el JSON crudo) y la materialización viaja por el SupplierProductChangeWorkflow existente, que ya orquesta precios → contenido → evaluación → dispatch de syncs (incluido el canal algolia → ReindexInAlgoliaJob, vía config/product_sync_channels.php).

Cambios, en orden de implementación:

  1. Detección (SupplierProductEvaluationStrategy::detectChanges()): nuevo tipo CHANGE_COMPONENTS cuando wasChanged('components'). Bonus estructural: un push duplicado idéntico no marca dirty el JSON → no dispara workflow → la doble materialización desaparece de raíz (resuelve la cuestión del doble log sin código extra). El paso combo→simple (components → null) también es wasChanged y viaja por el mismo camino.
  2. Workflow (SupplierProductChangeWorkflow): nuevo paso entre GenerateContent y EvaluateSelected — si CHANGE_COMPONENTS ∈ cambios → yield activity(MaterializeRecipeActivity::class, $supplierProductId). Orden garantizado: la receta ya está materializada cuando el canal algolia reindexa.
  3. Nueva MaterializeRecipeActivity:
  4. Carga el SupplierProduct fresco y materializa desde el estado actual de components (snapshot last-write-wins, D9) — no desde valores capturados al disparar.
  5. Pendiente (id_product_item null) → no-op (materializa la confirmación, que sigue síncrona).
  6. components vacío → clear() (receta eliminada, variante a simple).
  7. No resuelve completo → conserva receta anterior, log error canal PCM, components_synced_at queda desfasado; el detalle queda en el context de auditoría del workflow.
  8. Resuelve → materialize() + components_synced_at + activity log del diff.
  9. Garantía de reindex: si EvaluateSelected decide should_sync = false (cambió solo la receta), el DispatchSync no corre — en ese caso la activity despacha ReindexInAlgoliaJob directamente para el item del combo. (Cuando exista el bloque 4b, esto evoluciona a un sync_type dedicado recipe_change que los canales algolia y medusa interpreten.)
  10. Adelgazar la ingesta (controller): la composición se persiste en el mismo update()/create() que los escalares (componentsAttributesFromPayload()) — un único evento updated → un único workflow por push con todos los cambios detectados juntos. Se eliminan del request la resolución, materialización, reindex, activity log y los warnings de la respuesta (processed[] vuelve a {sku, status}). applyPayload() se eliminó del servicio. Sin colas nuevas: redis_workflows/workflows ya operativas.

Refinamientos de implementación (2026-07-15): - La activity (vía ComponentRecipeService::syncRecipeFromCurrentState()) despacha siempre el ReindexInAlgoliaJob tras materializar/limpiar — sin depender de should_sync — y además el SyncToMedusaJob (variant basic) si el item está publicado y el canal habilitado, para que la disponibilidad derivada del kit no quede apuntando a componentes viejos (anticipa el bloque 4b). Ambos jobs son idempotentes: si DispatchSync también corre, el duplicado es inocuo. - El feature flag WORKFLOW_SUPPLIER_PRODUCT_CHANGE debe estar activo (verificado: lo está); con el flag apagado el path evaluateWithJobs no materializa recetas.

Lo que NO cambia: la confirmación de pendientes (modal Livewire + processNewSupplierProduct) sigue síncrona y bloqueante — hay un operador delante. La validación §6.7 en confirmación tampoco cambia.

Consideraciones: - Carrera push-vs-push: dos sustituciones seguidas generan dos workflows; ambos materializan desde el estado actual → el último estado gana en ambos casos (idempotente). - Latencia OMS: la receta nueva es visible tras el paso por la cola workflows (segundos). Aceptable dado D10 (el operador del OMS puede corregir componentes al guardar). - Tests: los feature tests del endpoint deben reescribirse (ya no asertar materialización inline ni warnings; solo persistencia del JSON) + unit test de la activity. Se escriben sin ejecutar (política del entorno). - Rollout: deploy + horizon:terminate (la activity corre en workers de larga vida).

6.12 La receta sigue al supplier product SELECCIONADO (D14 — implementado 2026-07-16)

Nuevo invariante (D14): el type (simple/compuesto) y la receta de una variante derivan exclusivamente de su supplier product seleccionado (selected_supplier_product_id), no de cualquier SP combo vinculado. Un item puede tener N supplier products (ej. un combo pcm- y un simple de otro proveedor); solo el seleccionado define si la variante lleva componentes:

  • Seleccionado simple (o sin seleccionado) → variante simple, sin filas en product_item_components.
  • Seleccionado compuesto → variante composite con la receta de ESE SP materializada.
  • La selección es dinámica (lógica de selección por stock/criterios) → el flip aplica en ambos sentidos cuando cambia.

Corolario (aprobado): un único punto de asignación — como PASO DE WORKFLOW posterior a la evaluación. Componentes y type de un product item solo pueden asignarse/des-asignarse en MaterializeRecipeActivity, que se reubica en el workflow: deja de ser el paso 2.5 (pre-evaluación) y pasa a ser el paso 3.5, entre EvaluateSelected y DispatchSync. La lógica de evaluación NO se toca en absoluto (ni la activity ni el job legacy): la activity de asignación lee la selección ya escrita y deriva el estado.

Workflow: CalculatePrices → GenerateContent → EvaluateSelected (intacta)
   → [3.5] MaterializeRecipeActivity → reconcileForItem(item)   ← ÚNICO punto de asignación
        seleccionado composite → materializar SU receta + type=composite
        seleccionado simple/ninguno → limpiar receta + type=simple
        idempotente: no-op si ya coincide (sin dispatches)
   → DispatchSync
  • Condición de ejecución del paso 3.5 (decidible sin DB, replay-safe): CHANGE_COMPONENTS detectado o selection_changed en el resultado de EvaluateSelected. Cubre push de receta, self-heal y flips por cambio de selección.
  • Cambios de selección fuera del workflow (job legacy, comandos batch): no se tocan — el self-heal (comparando contra el seleccionado) los repara en el siguiente evento del producto. Es la red de seguridad que evita tocar cualquier código de evaluación.
  • Implicación de deploy: mover el paso cambia la secuencia del workflow durable → los workflows en vuelo creados pre-deploy pueden fallar el replay al reanudar post-restart. Mitigación: deploy + restart en ventana tranquila; los que fallen se auto-reparan con el siguiente push (self-heal).

Los puntos de asignación existentes dejan de asignar: 1. MaterializeRecipeActivity (paso 2.5 del workflow) → no-op compatible (se conserva en la secuencia por los workflows en vuelo; retiro posterior). 2. Confirmación (ConfirmationModal casos A/B/C, processNewSupplierProduct, link path de PendingProductsController) → solo vinculan; la asignación llega vía el workflow que la confirmación dispara (CHANGE_REASSIGNMENT → evaluación → reconcile). La receta aparece segundos después de confirmar (async), no en la misma transacción. 3. Self-heal / isRecipeInSync → comparan contra el seleccionado (crítico: sin esto, todo push de un combo no seleccionado dispararía self-heal eterno). 4. Excepción que se conserva: la limpieza en SupplierProduct::boot() al desvincular/revincular id_product_item — es des-asignación del item que quedó atrás, que ninguna evaluación cubre.

Decisiones tomadas (2026-07-16): - D14a (ex P1) — Sin validación de resolubilidad: esa lógica vive en PC Manager (los componentes salen del propio catálogo del Integrador). Se retira el bloqueo por componentes de validateForConfirmation; si un SKU no resuelve al reconciliar, se loguea y queda el desvío marcado — sin gates adicionales. - D14b (ex P2) — Sin efecto dominó: no se re-evalúan recetas padres cuando un componente cambia de naturaleza (no se usan componentes compuestos en la sincronización con PC Manager). El chequeo de anidamiento en resolve() queda solo como red de seguridad. - Indicador de desvío (components_synced_at): significativo solo para el SP seleccionado. - Medusa, flip composite→simple: la rama simple reconcilia links igual que el kit (deseado = su inventory item propio; remueve links de componentes sobrantes). El flip inverso ya está cubierto por el reconcile diferencial del kit. - Saneo one-shot al desplegar: reconcileForItem sobre todos los items con algún SP con components, para alinear el estado existente al invariante.

7. Decisiones confirmadas

  • D1 — Vía de entrada: los combos llegan por push de PC Manager (Python) a POST /api/supplier-products/sync. Ese es el contrato a extender; PC Manager deberá emitir type + components.
  • D2 — Índice del OMS: product_items_view_index (ProductItemView::toSearchableArray()). Ahí va la composición.
  • D3 — SAP y el BOM: SAP no tiene el BOM precargado y no debe sincronizarse desde el Integrador. SAP está configurado para soportar productos BOM; el OMS crea el producto BOM con sus componentes al momento de crear el pedido, solo si se vende. La disponibilidad del combo se gestiona en PC Manager, no en SAP. Rol del Integrador: transportar y exponer la receta.
  • D4 (revisada 2026-07-14) — La composición es de la variante: un combo puede confirmarse como producto nuevo o como variante de un producto existente (ej. "Acer Aspire X" con variante de fábrica simple y variante compuesta con ampliación de RAM, ver §6.1). Por eso type vive en product_items y la receta cuelga del product_item del combo (1 variante compuesta = 1 receta). ~~Versión original: combo siempre producto nuevo, type en products~~ — invalidada por la práctica real de confirmación.
  • D5 (ex B1) — Clave canónica de componente: product_items.sku. PC Manager consulta los productos por GET /api/products?sku={sku}, es decir, opera con el sku de product_items (único). La resolución de receta usa esa clave.
  • D6 (ex A6) — Sin versionado de recetas: el OMS usa la receta vigente al momento del pedido (estado actual del índice); el registro contable/histórico queda en SAP, que crea el BOM por pedido. La trazabilidad de cambios se cubre con el activity log (§6.8).
  • D7 (ex B2) — El pull está muerto: app:sync-pc-manager ya no se usa; PC Manager pushea al cambiar. Deprecar/retirar el comando (sigue programado daily en Kernel.php) como parte de esta iniciativa, para que no interfiera.
  • D8 (ex B4) — Composición visible en la UI de pendientes: el operador ve la receta (solo lectura) al confirmar, con el estado de resolución de cada componente (§6.7).
  • D9 — Receta como snapshot autoritativo: cada push reemplaza la receta completa (delete+insert atómico), sin merge, por la lógica de sustitución de PC Manager (§6.8).
  • D10 (ex C1) — La receta expuesta es la composición por defecto, editable a nivel pedido: el operador del OMS puede cambiar un componente al guardar el pedido. Ese cambio es de alcance del pedido (el BOM en SAP se crea con lo que el OMS guarda) y no se retroalimenta al catálogo. El Integrador no modela listas de alternativas.
  • D11 (ex C2) — PC Manager pushea inmediatamente al sustituir: la ventana de desactualización depende solo del Integrador → materialización + reindex en el mismo evento del push.
  • D12 (ex C3) — Receta siempre plana: no existe combo dentro de combo; la receta llega con la lista de ítems finales. La resolución rechaza explícitamente componentes con type = composite (desde 2026-07-15 este chequeo directo reemplazó a la exigencia de sku_sap, que se eliminó por el modelo dropshipping — ver §6.7).
  • D13 — Combos en Medusa como inventory kits: la variante del combo no tiene inventory item propio; se vincula a los inventory items de sus componentes con required_quantity = cantidad de la receta (§6.9). La disponibilidad en Medusa se deriva de los componentes; el stock del payload de PC Manager se ignora para combos en el canal Medusa.

8. Plan de implementación propuesto (alto nivel)

Con el análisis cerrado, el trabajo se organiza en bloques (cada uno con sus tests):

  1. Modelo de datos — migraciones:
  2. product_items.type (simple|composite, default simple).
  3. Tabla product_item_components (§6.2).
  4. supplier_products.components (JSON, nullable) + supplier_products.components_synced_at (timestamp, nullable).
  5. Ingesta — SupplierProductSyncController@sync:
  6. Validación de type + components en el payload (§6.4).
  7. Persistencia del JSON crudo; respuesta con warnings por componentes no resolubles (§6.7).
  8. Re-materialización atómica + activity log + reindex inmediato para combos ya confirmados (§6.4, §6.8).
  9. Confirmación de pendientes:
  10. Extensión de validateForConfirmation() con las 3 verificaciones de componentes (§6.7).
  11. Materialización de la receta en processNewSupplierProduct() / enlace a variante, con type = composite en el Product.
  12. Exposición — ProductItemView::toSearchableArray(): product_type + components[] con eager loading (§6.5); reindexación en cascada si aplica (§6.6).
  13. 4b. Sync Medusa (inventory kits) — ✅ implementado 2026-07-15: orchestrateKitForVariant() + ramas composite en creación/product_basic/product_status + dispatch desde syncRecipeFromCurrentState() (§6.9, D13).
  14. UI de pendientes — vista de composición solo lectura con estado de resolución por componente (D8), y filtro/listado de combos con receta desincronizada (components_synced_at).
  15. Limpieza — deprecar app:sync-pc-manager y su schedule en Kernel.php (D7).
  16. Coordinación externa (fuera de este repo):
  17. PC Manager (Python): emitir type + components: [{sku, quantity}] en el payload de sync, con push inmediato al sustituir (ya es el comportamiento).
  18. OMS: consumir product_type y components del índice product_items_view_index para crear el producto BOM con sus componentes en SAP al guardar el pedido, permitiendo editar componentes.

Orden sugerido: 1 → 2 → 3 → 4 (núcleo funcional end-to-end), luego 5 y 6 en paralelo. El bloque 7 (PC Manager) puede avanzar en paralelo desde que se congele el contrato del payload (§6.4).

9. Supuestos de entendimiento (validados 2026-07-13)

  1. ✅ El "otro sistema" es PC Manager; sus productos entran como SupplierProduct de CL (supplier_id = 1) con SKU pcm-, vía push a /api/supplier-products/sync.
  2. ✅ PC Manager es dueño de la definición del combo (componentes, precio, disponibilidad, sustituciones); el Integrador solo debe transportar y exponer esa información.
  3. ✅ El consumidor de la composición es el OMS, que crea el producto BOM en SAP con sus componentes al crear el pedido; el stock se descuenta por componente.
  4. ✅ Los componentes de un combo son productos que ya existen en el catálogo del Integrador: PC Manager los obtiene de GET /api/products y los referencia por el sku de product_items.
  5. ✅ El flujo de confirmación de pendientes y la indexación por evento no cambian; solo se enriquecen con la composición.

10. Referencias en código

Qué Dónde
Sync pull PC Manager app/Console/Commands/SyncPcManager.php, app/Services/PCM/PcManagerSyncService.php, config/pcmanager.php
Push genérico de supplier products app/Http/Controllers/Api/SupplierProductSyncController.php, routes/api.php:52-56
API que lee PC Manager app/Http/Controllers/Api/ProductController.php, routes/api.php:34-42
Flujo de pendientes/confirmación app/Http/Controllers/PendingProductsController.php, SupplierProduct::processNewSupplierProduct() (app/Models/SupplierProduct.php:1034)
Exclusión de sku_sap para PC-/PCM- app/Models/SupplierProduct.php:777-796
Indexación Algolia catálogo app/Models/ProductItemView.php:126-248, app/Models/ProductItem.php:227-283
Vista SQL vigente database/migrations/65/2025_11_27_000001_add_medusa_fields_to_product_item_view.php
Relación producto-producto existente (matching, no composición) database/migrations/03/2023_10_17_191328_create_match_products_table.php, app/Models/MatchProduct.php (comentado)
Doc relacionada docs/technical/pending-product-confirmation-flow.md