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_FAILEDsin reintento: es error de datos, no transitorio. Se loguea comowarning(noerror) 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 (unthrowre-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:
PcManagerProductPushServiceconsulta la lista víaCache::remember()(store Redis ya disponible), TTL 1 hora, clavepcm:active_categories.- Antes de enviar, compara la
category_namedel producto contra el set: - No está →
ProductSyncLog::STATUS_SKIPPEDcon motivoCATEGORIA_NO_GESTIONADA(sin request HTTP). Loginfo, noerror. - Está (o la lista no está disponible) → se envía.
- Fail-open: si el fetch de categorías falla (timeout, 5xx), se loguea
warningy se envía el producto igual — PCM decide. Nunca se bloquea el sync por no poder consultar la lista. Ante un fallo se marcapcm:active_categories:unavailablepor 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-categoriespara 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=1disponible en PCM (2026-08-03); parser ajustado a la respuesta real (objetos conis_active). Falta formalizar el anexo en el contrato escrito. - [ ] Habilitar el canal por entorno:
PCM_PUSH_ENABLED=trueen.env(defaultfalse; elPCM_API_TOKENya existe en.env, base URL tomaPCM_APP_DOMAINsalvo override conPCM_PUSH_BASE_URL). - [ ] Despliegue:
php artisan horizon:terminatepara levantarsupervisor_pcmanager. - [ ] Verificación en dev vía tinker read-only (no correr tests contra la DB).