Saltar a contenido

Implementación — Push de productos Integrador → PC Manager

Contrato de referencia: contrato-recepcion-productos-pcm Estado: implementado (2026-08-03); pendiente endpoint de categorías del lado PCM y habilitación por entorno Última actualización: 2026-08-03

1. Contexto

PC Manager expone POST /api/products/receive (upsert por lote, PATCH por campo, auth por X-API-TOKEN) para que el Integrador empuje productos simples cuando ocurren eventos (alta desde SAP, venta, cambio de precio/nombre, activación/desactivación), reemplazando la espera del polling periódico.

El Integrador ya tiene una arquitectura de canales de sync dirigida por configuración: ProductSyncDispatcherService itera config/product_sync_channels.php y despacha un job por canal habilitado. PC Manager se integra como un canal más, siguiendo el patrón Contimarket (job + servicio + cola propia).

No confundir con App\Services\PCM\PcManagerSyncService: ese es el flujo inverso ya existente (pull por polling PC Manager → Integrador, basic auth) y no se modifica.

2. Mapa de la implementación

Archivos nuevos

Archivo Rol
app/Services/PCM/PcManagerProductPushService.php Transforma ProductItem al payload del contrato, envía con X-API-TOKEN, interpreta respuesta por ítem, registra ProductSyncLog. Tag de logs [PCM:{product_id}:{sku}], canal LogChannels::PCM (ya configurado).
app/Jobs/SyncToPcManagerJob.php Espejo de SyncToContimarketJob: ShouldBeUniqueUntilProcessing, traits LogsUniqueLock / RetriesWithBackoff / TracksProductSyncFailures, tries=3, backoff=[60,300,900], timeout=120, uniqueFor=180.
app/Console/Commands/RefreshPcmCategories.php app:pcm-refresh-categories — invalida y recarga la caché de categorías activas de PCM (tras activar una categoría allá, sin esperar el TTL).
tests/Unit/PcManagerProductPushServiceTest.php Tests de transformación, guards y parsing de respuesta. Se escriben pero NO se ejecutan en dev (los tests borran la DB — regla del proyecto).

Archivos modificados

Archivo Cambio
app/Constants/SyncChannels.php const PCM = 'pcmanager';
config/product_sync_channels.php Bloque del canal PCM: queue => 'pcmanager_products', connection => 'redis_pcmanager', sync_type_resolver => false, log_sync => true, sync_unpublished => true (ver decisión 3).
app/Services/Sync/ProductSyncDispatcherService.php El gate publish=0 deja de cortar todo el despacho: se evalúa por canal, respetando el flag sync_unpublished. Comportamiento idéntico al actual para los canales que no declaran el flag.
config/queue.php Conexión redis_pcmanager (retry_after => 150, mayor al timeout de 120s del job).
config/horizon.php supervisor_pcmanager en ambos entornos (processes => 3 en production, 1 en local) + waits: 'redis_pcmanager:pcmanager_products' => 180.
config/pcmanager.php Bloque push: base_url (default PCM_APP_DOMAIN, override PCM_PUSH_BASE_URL), token (PCM_API_TOKEN, ya existente en .env), receive_path, categories_path, categories_cache_ttl. Las claves api_username/api_password del pull quedan intactas.
app/Providers/AppServiceProvider.php Bind explícito de PcManagerProductPushService: sin él, el contenedor inyectaría un Guzzle Client vacío (sin base_uri ni token) en el constructor opcional.
app/Services/Sync/Evaluation/ProductEvaluationStrategy.php Se elimina el early-return por publish=0: el gate se delega al despachador (por canal). El despacho directo a Medusa (variant_reparent) conserva el gate.
app/Console/Commands/ProcessFailedSyncsCommand.php y RetryFailedSyncs.php Se agrega SyncChannels::PCM a createJobInstance() para que los FAILED de PCM sean reintentables.
.env PCM_PUSH_ENABLED (default false; pasar a true cuando el endpoint products/receive esté disponible).

Sin cambios

Observers que invocan al despachador y config/logging.php (canal PCM ya existe).

Despliegue: requiere php artisan horizon:terminate para levantar el supervisor.

3. Decisiones tomadas (2026-08-03)

D1 — Precio: siempre el de CL, sin Partner nuevo

PC Manager es un gestor interno de productos compuestos; recibe el precio del canal CL (Compulandia, PartnerConstants::CL) sin factor de partner adicional. No se crea un Partner PCM. Precios especiales y vigencias vía ProductPriceService::getProductPriceInfo() (mismo patrón que Conti/TN, sin aplicar factor).

D2 — Filtro por categorías activas de PC Manager

PC Manager rechaza por ítem las categorías que no gestiona (inexistentes o inactivas). Para evitar sobrecarga de syncs destinados al rechazo, el Integrador mantiene conocimiento de las categorías activas del otro lado y filtra antes de enviar. Decisión (2026-08-03): se implementa directamente la Fase A (endpoint de categorías + caché, ver §4); la Fase B (caché negativa por rechazos) queda descartada. Regla general: fail-open — si no se puede saber qué categorías gestiona PCM, se envía igual y decide PCM.

D3 — Ignorar la lógica de despublicación: siempre sincronizar

Para PCM se sincroniza siempre, incluso con publish=0 o active=false: el dato de desactivación debe llegar a PC Manager (active: false en el payload) porque los combos que dependen del componente deben reevaluarse. Implementación: flag sync_unpublished => true en la config del canal; el despachador evalúa el gate de publish por canal en vez de cortar todo (el early-return de ProductEvaluationStrategy también se eliminó). El servicio tampoco omite productos inactivos: los envía con active: false.

Semántica de active hacia PCM: active = item.active && item.publish. Un producto despublicado (aunque active=1) viaja como active: false — para PCM "no publicado" equivale a "no asignable como componente".

D4 — Guard de compuestos (del diseño original)

Nunca se envían items type = composite a PC Manager (el contrato los rechaza; los combos son de autoría exclusiva de PCM). Este guard en el servicio corta el ciclo: venta de componente → push a PCM → PCM reevalúa combo → push del combo al Integrador por supplier-products/sync → el despachador se dispara para el combo → el guard evita que rebote hacia PCM. Se registra ProductSyncLog::STATUS_SKIPPED.

D5 — Manejo de errores por ítem

  • created / updated / unchanged → STATUS_SUCCESS.
  • status: "error" con HTTP 200 (categoría desconocida/inactiva, faltantes de alta) → STATUS_FAILED sin reintento: es error de datos, no transitorio. Se loguea como warning (no error) para no escalar a Sentry rechazos esperables del contrato.
  • HTTP 4xx (ej. 403 por token inválido) → $this->fail() en el job: falla inmediata sin reintento (un throw re-encolaría con backoff).
  • HTTP 429 / 5xx / timeout / conexión → reintento con backoff del job.

PATCH por campo: el payload omite los campos sin valor (no se pisa con vacío lo que PCM ya tiene); las excepciones son special_price y sus fechas, que viajan en null explícito para limpiar ofertas vencidas.

D7 — Stock y proveedor: siempre de la oferta seleccionada (2026-08-03)

El payload hacia PCM refleja siempre el supplier product seleccionado del item — la misma lógica de selección que usan los demás canales, vía la vista CL: stock, supplier_id, supplier_name y supplier_sku salen de esa oferta. Si la selección cambia de proveedor, el cambio se transmite a PCM con el supplier_id correspondiente (el observer dispara sync cuando cambia selected_supplier_product_id).

Historial: durante la prueba con el sku 06114 se reportó como bug que PCM quedara con stock 1 teniendo el supplier product SAP stock 0; se implementó brevemente tomar el stock del supplier SAP y se revirtió el mismo día: no era un bug — el item tenía seleccionada una oferta de otro proveedor (CPI-489550, stock 1) y ese es exactamente el dato que debe viajar. La lógica de selección subyacente no se altera para PCM.

D6 — Lote de 1

El contrato acepta lotes, pero el despachador es por producto: cada job envía products: [uno], consistente con los demás canales y con la unicidad por producto (uniqueId = product_item_id). Batching queda como optimización futura.

4. D2 — Conocer las categorías activas de PC Manager (Fase A, elegida)

Endpoint disponible desde 2026-08-03 (verificado contra PCM dev). Mientras no responda, el servicio opera fail-open: envía todo y PCM rechaza por ítem.

Endpoint de categorías + caché

GET /api/categories?active=1
X-API-TOKEN: <token>

→ {
    "count": 37,
    "categories": [
        { "external_id": 1, "name": "Accesorios Pc", "is_active": true },
        ...
    ]
  }

El parser acepta objetos {external_id, name, is_active} (excluye is_active: false) y también una lista plana de strings por compatibilidad. Los nombres se normalizan en minúsculas para la comparación.

Del lado Integrador:

  • PcManagerProductPushService consulta la lista vía Cache::remember() (store Redis ya disponible), TTL 1 hora, clave pcm:active_categories.
  • Antes de enviar, compara la category_name del producto contra el set:
  • No está → ProductSyncLog::STATUS_SKIPPED con motivo CATEGORIA_NO_GESTIONADA (sin request HTTP). Log info, no error.
  • Está (o la lista no está disponible) → se envía.
  • Fail-open: si el fetch de categorías falla (timeout, 5xx), se loguea warning y se envía el producto igual — PCM decide. Nunca se bloquea el sync por no poder consultar la lista. Ante un fallo se marca pcm:active_categories:unavailable por 5 minutos para no repetir un GET condenado en cada job (relevante mientras el endpoint no exista). La lista solo se consulta cuando el producto tiene categoría que filtrar.
  • Comando artisan opcional app:pcm-refresh-categories para invalidar la caché manualmente tras activar una categoría en PCM (evita esperar el TTL).

Costo del lado PCM: mínimo (una query sobre categorías activas). Beneficio: elimina de raíz los envíos destinados al rechazo.

Fase B (descartada, 2026-08-03)

Se evaluó una caché negativa alimentada por los rechazos por ítem (sin cambio de contrato) como fase provisional. Se descartó en favor de implementar la Fase A directamente; el fail-open cubre el período hasta que PCM publique el endpoint.

Nota: la fuente de category_name en el payload es la categoría principal del Partner CL (Product::mainCategory()), que debe coincidir textualmente con los nombres de categoría de PC Manager. Cualquier renombre de categorías en PCM debe coordinarse.

5. Pendientes

  • [x] Decisión D2: Fase A elegida (2026-08-03); Fase B descartada.
  • [x] Implementar servicio, job, constante, comando y configs (§2).
  • [x] Tests escritos (sin ejecutar en dev).
  • [x] Endpoint GET /api/categories?active=1 disponible en PCM (2026-08-03); parser ajustado a la respuesta real (objetos con is_active). Falta formalizar el anexo en el contrato escrito.
  • [ ] Habilitar el canal por entorno: PCM_PUSH_ENABLED=true en .env (default false; el PCM_API_TOKEN ya existe en .env, base URL toma PCM_APP_DOMAIN salvo override con PCM_PUSH_BASE_URL).
  • [ ] Despliegue: php artisan horizon:terminate para levantar supervisor_pcmanager.
  • [ ] Verificación en dev vía tinker read-only (no correr tests contra la DB).