Saltar a contenido

Sincronización con Tienda Naranja (TN)

Documento as-is: describe el flujo tal como está implementado hoy en staging, no cómo debería ser. Cada afirmación apunta al archivo y método que la sostiene.

Tienda Naranja es un marketplace sobre Magento. El Integrador actúa como seller (TN_SELLER_ID, default 116288) y publica sus productos vía dos APIs distintas del mismo host: la API estándar de Magento (/products) y la API de marketplace (/mpapi/sellers/me/...). Es un canal de salida: el Integrador empuja, TN nunca escribe hacia adentro.


1. Piezas

Archivo Rol
config/tienda_naranja.php Credenciales y seller_id (todo desde .env)
config/product_sync_channels.php Declara el canal TN: job, cola y conexión
app/Services/TiendaNaranja/AuthTN.php Obtiene y cachea el Bearer token
app/Services/TiendaNaranja/ProductApiServiceTN.php Cliente HTTP (5 endpoints)
app/Services/TiendaNaranja/ProductSyncService.php Orquestador: decide crear / actualizar / OOS / limpiar
app/Services/TiendaNaranja/Transformers/ProductTransformer.php Arma los payloads y valida categorías
app/Jobs/SyntToTiendaNaranjaJob.php Job por producto (evento)
app/Jobs/VerifyTnProductSync.php Job de verificación en batch de la cola de TN
app/Console/Commands/SyncTiendaNaranjaProducts.php app:sync-tn-products (barrido)
app/Console/Commands/CleanupTiendaNaranjaProducts.php app:cleanup-tn-products (huérfanos)
app/Console/Commands/SendProductsToTiendaNaranja.php app:send-tn-products — legacy, ver §9

Constantes: SyncChannels::TN = 'Tienda Naranja' (valor guardado en product_sync_logs.channel), LogChannels::TN = 'tienda_naranja', LogChannels::TN_ORPHAN = 'tienda_naranja_orphan', PartnerConstants::TN = 1.

Cola: tn_products sobre la conexión redis_tn_products (retry_after = 150s, deliberadamente mayor al timeout = 120s del job para evitar doble ejecución).


2. Autenticación: dos esquemas conviviendo

TN expone endpoints que no comparten el mismo mecanismo de auth, y el código lo refleja endpoint por endpoint:

Endpoint Verbo Auth Usado en
/integration/customer/token POST OAuth1 HMAC-SHA256 AuthTN::authenticate()
/products (searchCriteria) GET OAuth1 HMAC-SHA256 listSellerProducts()
/products/{sku} GET OAuth1 HMAC-SHA256 getProductBySku()
/products/{sku}/media POST OAuth1 HMAC-SHA256 uploadProductImage()
/mpapi/sellers/me/addproduct POST Bearer saveProduct()
/mpapi/sellers/me/product-status/{processId} GET Bearer getProductQueueStatus()

El Bearer se obtiene firmando con OAuth1 un POST de usuario/contraseña, y se cachea 60 minutos bajo la clave tn_bearer_token. Si TN devuelve error, el método loguea y retorna null — no lanza excepción, así que un fallo de auth se manifiesta más abajo como un 401 en saveProduct().

Reintentos HTTP: saveProduct() y uploadProductImage() reintentan hasta 2 veces sólo ante 401, 429, 500 y 503 (shouldRetry()). Timeout: 30s en todas.


3. Los cuatro disparadores

3.1 Por evento (el camino principal)

cambio en ProductItem / ProductItemImage
  → ProductEvaluationStrategy::evaluate()
  → ProductSyncDispatcherService::dispatchSync($productItem, $contexto)
  → recorre config/product_sync_channels.php
  → SyntToTiendaNaranjaJob → cola tn_products

ProductEvaluationStrategy despacha cuando cambia:

  • seo_name, short_description, ean, upc, part_number → product_basic
  • active → product_status
  • publish → product_status
  • selected_supplier_product_id → product_basic, o product_status si el nuevo valor es null

El contexto no llega al job de TN: el canal declara sync_type_resolver => false, así que SyntToTiendaNaranjaJob se construye sólo con el ProductItem y el motivo del cambio se pierde. TN siempre recalcula todo el estado desde cero.

Gate de publicación: si publish = 0, el despachador omite TN (sólo pasan los canales con sync_unpublished, hoy únicamente PC Manager). Aun así el job vuelve a chequear publish === 0 en su handle() y, si lo encuentra, registra ProductSyncLog con FAILED / "NO HABILITADO PARA PUBLICACIÓN" y aborta.

Otros puntos que despachan al mismo canal: ProductImageObserver (product_image), ProductItemViewController, SupplierProductSyncController, ProductAttributeService, DispatchSyncActivity (workflow de cambios de proveedor) y RetryPendingSyncs.

3.2 Barrido programado

routes/console.php:

Schedule::command('app:sync-tn-products')->weeklyOn(2, '3:00'); // martes
Schedule::command('app:sync-tn-products')->weeklyOn(5, '3:00'); // viernes

Ejecuta ProductSyncService::execute(), que recorre en chunkById(100) todos los ProductItemView con channel = 'tienda naranja' que tengan al menos una imagen pública (has('imagesPublic')) y llama a syncProduct() de forma sincrónica — este barrido no pasa por la cola.

3.3 Limpieza de huérfanos

Schedule::command('app:cleanup-tn-products --full')->dailyAt(4);

Ver §7.

3.4 Verificación asincrónica

VerifyTnProductSync se auto-encola; no está en el scheduler. Ver §5.


4. El árbol de decisión de syncProduct()

Es el corazón del flujo. ProductSyncService::syncProduct(ProductItem $item), con $skuTn = 'COML-' . $item->sku:

  1. Categorías TN — ensureTnCategories() delega en ProductTransformer::validateTNCategory(). Si el producto no tiene Product asociado, no tiene categoría TN en product_partner_categories, o la categoría está inactiva → ProductSyncLog en SKIPPED con SIN CATEGORIA TN / CATEGORIA TN INACTIVA / SIN PRODUCTO ASOCIADO, y corta. Ninguna llamada a TN.

  2. ¿Existe en remoto? — getProductBySku($skuTn). $existsRemote es true si la respuesta trae sku. Un 404 se trata como "no existe"; cualquier otro error corta con remote_check_error.

  3. ¿Hay vista TN? — ProductItemView::forProduct($id, SyncChannels::TN). Si no hay fila para ese canal (típicamente porque no hay precio cargado para el partner TN, o el partner/selected_product quedó inactivo) → el producto se marca out of stock en TN (status: 2, qty: 0) y corta.

  4. Imágenes locales — imagesPublic count.

  5. Estados previos en product_sync_logs (canal TN):

  6. wasCreatedButImagesFailed: existe log con PRODUCT_NOT_READY_FOR_IMAGES o PENDIENTE PARA SUBIR IMAGENES.
  7. pendingLog: log con EN_COLA_TN en estado PENDING.

  8. Creación pendiente — si hay pendingLog y el producto todavía no existe en remoto, handlePendingCreation() consulta /mpapi/sellers/me/product-status/{processId}:

  9. completed → marca el log SUCCESS y sigue al flujo de actualización.
  10. aún en cola y no se programó re-sync → marca resync_scheduled y devuelve needs_resync, que el job traduce en: liberar el lock único y re-despacharse con 3 minutos de delay.
  11. sin process_id → log FAILED / SIN_PROCESS_ID.

  12. Rama CREATE (!existsRemote && !wasCreatedButImagesFailed && !hasPending):

  13. Sin imágenes locales → SKIPPED / SIN IMAGEN, corta. TN no acepta altas sin imagen en este flujo.
  14. Con imágenes → createProductWithImages().

  15. Rama sólo-imágenes (wasCreatedButImagesFailed && !existsRemote) → uploadImagesOnly().

  16. Rama UPDATE (existe en remoto):

  17. remoto sin media_gallery_entries y hay imágenes locales → updateProductWithImages() (datos y luego fotos).
  18. en cualquier otro caso → updateProductData() (sólo datos).

5. La asincronía de TN: process_id y EN_COLA_TN

POST /mpapi/sellers/me/addproduct no crea el producto en el momento: devuelve un process_id y encola el trabajo del lado de TN. Esto es la causa raíz del bug histórico de SKUs duplicados (COML-07012-1, -2, …) documentado en docs/analisis/tn-sync-verification-solution.md.

El manejo actual:

  1. createProductWithImages() / updateProductData() guardan el process_id en product_sync_logs con status = PENDING y response_message = 'EN_COLA_TN' — antes de intentar subir imágenes.
  2. SyntToTiendaNaranjaJob::handleSyncResult() ve ese log y despacha VerifyTnProductSync con delay (TN_VERIFY_DELAY_MINUTES, default 1 min).
  3. VerifyTnProductSync es ShouldBeUnique sin parámetros: una sola instancia procesa todos los pendientes en batch. Para cada uno consulta product-status/{processId} y resuelve:
  4. queued / processing → sigue pendiente; a los 15 min loguea warning, a los 60 min (MAX_QUEUE_TIME_MINUTES) marca TIMEOUT_EN_COLA_TN.
  5. completed con data.error != 0 → TN_PROCESSING_ERROR.
  6. completed y OK → si el remoto no tiene media y hay imágenes locales, las sube ahí mismo; hasta MAX_VERIFY_ATTEMPTS = 10 intentos, después MAX_IMAGE_UPLOAD_ATTEMPTS.
  7. estado desconocido → UNKNOWN_QUEUE_STATUS.
  8. Al terminar, si quedan pendientes, se re-encola a sí mismo. Si no quedan, la cadena se apaga sola.

Protecciones contra duplicados

  • SyntToTiendaNaranjaJob tiene tries = 1: nunca reintenta automáticamente. Todos los catch loguean y registran en ProductSyncLog pero no relanzan la excepción, precisamente para que Laravel no reintente.
  • Los reintentos deliberados se hacen con dispatch()->delay() (2 minutos para ConnectException, 429 y 5xx), no con release().
  • ShouldBeUnique con uniqueId() = productItemId y uniqueFor = 600s.
  • Para el re-sync programado, el job borra a mano la clave laravel_unique_job:... del cache antes de re-despacharse, porque si no el propio lock impediría entrar de nuevo a la cola.

6. Qué se envía: el ProductTransformer

Payload de creación (transformForCreation) — completo:

{
  "type": "simple", "set": "4", "seller_id": "116288",
  "product": {
    "status": 1, "sku": "COML-07765",
    "category_ids": ["<L3>", "<L2>", "<L1>"],
    "name": "...", "description": "...", "short_description": "…(≤180)",
    "price": "1250000", "special_price": "", 
    "special_from_date": "", "special_to_date": "",
    "stock_data": {"manage_stock": "1", "use_config_manage_stock": "1"},
    "quantity_and_stock_status": {"qty": "3", "is_in_stock": "1"},
    "visibility": "4", "tax_class_id": "2", "attribute_set_id": "4",
    "product_has_weight": "1", "weight": "...",
    "ts_dimensions_width|height|length": "...",
    "mp_product_cart_limit": "1", "meta_title|keyword|description": "..."
  }
}

Payload de actualización (transformForUpdate) — minimalista: sólo entity_id (agregado fuera del transformer, desde el id que devolvió TN), status, price, special_price, las dos fechas y quantity_and_stock_status. Un update nunca toca nombre, descripción, categorías ni dimensiones.

Payload de out of stock (transformForOutofStock): status: 2 + qty: 0 + is_in_stock: 0. El comentario en el código explica el porqué: TN no retira el producto con status: 2 a secas si le llega qty > 0.

Precios

calculatePriceAndStockData():

  • price = regular_price de la vista TN, entero, como string. Si es 0 se envía "" — nunca "0".
  • special_price sale de ProductPriceService::getProductPriceInfo() y se multiplica por el factor del partner (Partner::find(1)->factor, hoy 1.07 según el seeder). Sólo se envía si hay offerStart y offerEnd y el especial ajustado es menor al regular.
  • Fechas en Y-m-d H:i:s, con from a las 00:00:00 y to a las 23:59:59, en el timezone de la app.
  • status: 1 si productItems.active == 1, si no 2. Un ítem inactivo va con qty: 0 forzado.
  • normalizeProductPrices() corre después como red de seguridad: sanea a enteros, limpia "0" y borra el especial si quedó >= al regular.

Categorías

validateTNCategory() + buildTNCategoryHierarchy(): parte de la categoría TN del Product (relación tnCategories() sobre product_partner_categories, filtrada por categorizable_id = PartnerConstants::TN), sube por parent_id usando external_code — no ids locales — excluye la raíz "Default Category" y se queda con exactamente 3 niveles: [L3, L2, L1]. Antes de enviarlos se hace sort($tnCategories, SORT_NUMERIC), porque TN exige el orden jerárquico ascendente.

Dimensiones (RFC-002)

ProductItemView → ProductItem → Product → tnCategories → masterCategory (Partner CL), leyendo effective_dimensions. Si la categoría TN no tiene master o no tiene dimensiones cargadas, cae al fallback legacy category_attributes (atributos peso, largo, altura, ancho). Si nada de eso existe: ceros.

Imágenes

  • Se toman de imagesPublic de la vista, usando la URL cruda (getRawOriginal('image_url') — típicamente Cloudflare Images).
  • Toda imagen se re-codifica a JPEG con GD (imagecreatefromstring + imagejpeg calidad 100) en un archivo temporal, que se borra en el finally.
  • Se envían de a una, POST /products/{sku}/media, en base64, con types: [image, small_image, thumbnail] y position incremental.
  • Si TN responde "The product that was requested doesn't exist.", el servicio lo traduce a PRODUCT_NOT_READY_FOR_IMAGES y corta el ciclo: el producto todavía está en la cola de TN, se reintenta en la verificación.

7. Limpieza de huérfanos

ProductSyncService::cleanupOrphanProducts(), canal de log TN_ORPHAN:

  1. Carga en memoria todos los SKU de ProductItemView con stock > 0, normalizados a mayúsculas (sin filtrar por canal).
  2. Pagina /products filtrando por seller_id y status = 1, opcionalmente created_at > última corrida.
  3. Por cada SKU remoto: le quita el prefijo COML-; si no está en la lista de válidos, lo desactiva con entity_id, status: 2, qty: 0.
  4. Distingue tres desenlaces: deactivated, config_error (TN se queja de category_ids — requiere corrección manual en el panel de TN) y errors.
  5. Guarda la marca de tiempo en storage/app/tn_cleanup_last_run.txt.

El scheduler la corre siempre con --full, así que en la práctica el modo incremental y el archivo de última corrida no se usan. Es lo correcto: en modo incremental sólo se miran productos creados después de la última corrida, por lo que un producto viejo que se quedó sin stock nunca sería revisado.


8. Trazabilidad

product_sync_logs (una fila por product_id + channel)

Se hace updateOrCreate, así que sólo se conserva el último estado por producto y canal.

response_message status Significado
OK success Sincronizado
EN_COLA_TN pending TN aceptó y devolvió process_id; falta verificar
SIN CATEGORIA TN / CATEGORIA TN INACTIVA / SIN PRODUCTO ASOCIADO skipped No se llamó a TN
SIN IMAGEN skipped Alta bloqueada por falta de foto
NO HABILITADO PARA PUBLICACIÓN failed publish = 0
PRODUCT_NOT_READY_FOR_IMAGES failed Creado, fotos pendientes
TIMEOUT_EN_COLA_TN failed >60 min en la cola de TN
TN_PROCESSING_ERROR failed TN terminó con error
MAX_IMAGE_UPLOAD_ATTEMPTS failed 10 intentos de subir fotos
PRODUCT_NOT_FOUND_IN_TN_AFTER_COMPLETED failed TN dijo completed pero el SKU no aparece
SIN_PROCESS_ID / UNKNOWN_QUEUE_STATUS failed Respuesta inesperada de TN
HTTP_ERROR_{code} / EXCEPTION_{Clase} failed Error de transporte

payload_sent y response_body guardan el JSON completo enviado y recibido, lo que permite reproducir un caso sin volver a llamar a TN.

Logs

  • storage/logs/jobs/tienda_naranja/tienda_naranja-Y-m-d.log (14 días, stack con Sentry/GlitchTip).
  • storage/logs/jobs/tienda_naranja_orphan/... para la limpieza.
  • Formato del proyecto: [TN:{product_id}:{sku}] mensaje, generado por logTag().

9. API V3.0 (agosto 2026) — estado de adopción

El 2026-08-14 Tienda Naranja comunicó por correo la V3.0 de su API. No tenemos la documentación adjunta; todo lo de esta sección se verificó contra payloads y respuestas reales de producción del 2026-09-08.

Conclusión: no hay nada roto ni urgente

Los tres caminos de escritura fueron ejercitados en producción después del anuncio y los tres siguen siendo aceptados sin un solo cambio de código:

Camino Origen del payload Evidencia (2026-09-08)
Alta transformForCreation() "Producto agregado con éxito" — SKU COML-CPI-2005509, 14:24
Update transformForUpdate() "Producto editado con éxito" — entity_id 1696797, 14:09
Baja / OOS transformForOutofStock() error: 0, encolado — SKU COML-CPI-899404

Volumen de altas en producción por mes (clasificado por el último payload de cada fila de product_sync_logs): junio 5, julio 6, agosto 28, septiembre 17. El camino de creación se ejercitó decenas de veces bajo V3.0.

Lo que el correo cambia, y por qué no nos afecta

  • Modos de galería noop/append/replace. Aplican al campo product_gallery, que nuestro código nunca envía. El nuevo default noop sólo afecta a quien lo mandaba. Verificado: un update con has_remote_media: true no tocó la galería existente.
  • Imágenes por URL en vez de base64. El correo las presenta como el método recomendado y deja base64 como "alternativa opcional". No es una deprecación: el alta del 2026-09-08 subió sus fotos por POST /products/{sku}/media en base64 (uploaded_images: true) sin problema.
  • "Simplificación del JSON de edición". Retrocompatible: nuestro payload de update, con type / set / product.entity_id, sigue siendo aceptado.
  • Edición por entity_id. Ya lo hacíamos. La respuesta de TN devuelve row_id distinto de product_id, señal de que corren Magento Commerce con content staging — de ahí que recomienden el entity_id y no el SKU.
  • Consulta de pedidos y subestados de ítems. Capacidad que no consumimos: el Integrador es sólo salida de catálogo. Es una integración nueva, no un ajuste.

Migrar a imágenes por URL: mejora opcional

Como nada está roto, el trabajo es voluntario. Lo que se gana:

  • 330 líneas de manejo de imágenes en 4 archivos que se reducen a unas 30 (uploadAllImages 71, convertImageToJpeg 39, uploadProductImage 39, transformImage 25, uploadImagesOnly 24, updateProductWithImages 20, getProductImages 14, getProductMedia 7, y VerifyTnProductSync::uploadImages 91)
  • Un alta con 3 fotos pasa de 4 llamadas HTTP a 1
  • Desaparece PRODUCT_NOT_READY_FOR_IMAGES y sus hasta 10 reintentos: producto e imágenes viajan en la misma llamada, se cierra la ventana de carrera
  • Se elimina el re-encoding con GD. config/horizon.php fija memory_limit => 64 MB y imagecreatefromstring sobre una foto de 4000×3000 carga ~48 MB de bitmap crudo: es candidato a matar workers en silencio
  • Nuestras imágenes ya son URLs públicas de Cloudflare, así que no hay que producir nada

Estimación: 2 a 3 días, no por las 30 líneas nuevas sino porque al sacar la subida de fotos se cae media máquina de estados (wasCreatedButImagesFailed, la rama hasRemoteMedia, image_upload_attempts, MAX_IMAGE_UPLOAD_ATTEMPTS).

Supuestos sin verificar, que sólo cierra la documentación adjunta: que product_gallery funcione en el alta y no sólo en la edición, y que TN pueda descargar desde imagedelivery.net. Si cualquiera de los dos falla, la migración no procede.

10. Incidente: credenciales de staging caídas (agosto-septiembre 2026)

El canal está caído en dev/staging desde algún punto entre el 2026-07-13 (último sync exitoso) y el 2026-08-28 (primer 401 registrado). En esa ventana de 46 días no hubo ni un intento contra la red, así que la fecha exacta se desconoce. Producción no está afectada.

Los tres errores, capturados el 2026-09-08:

Capa Mensaje de TN
OAuth1 (GET /products/{sku}) A consumer having the specified key does not exist
Bearer (POST /integration/customer/token) The account sign-in was incorrect or your account is disabled temporarily
Consecuencia (addproduct) The consumer isn't authorized to access %resources (resources: self)

El consumer key de OAuth1 no existe del lado de TN, y el login del Bearer falla. Descartado que sea nuestro: ningún commit tocó el código de TN en agosto y .env no se modifica desde el 2026-06-08. Resolución: pedir a TN que regenere la integración de staging (consumer key/secret, token key/secret y la contraseña del usuario).

Agravante propio: AuthTN::authenticate() sólo cachea el token cuando tiene éxito, así que cada job reintenta el login desde cero, sin backoff. Desde el 28/08 llevamos cientos de intentos fallidos; el mensaje "disabled temporarily" de Magento es también el de bloqueo por intentos, de modo que es plausible que estemos manteniendo la cuenta bloqueada nosotros mismos. Conviene apagar el canal ('enabled' => false en config/product_sync_channels.php) mientras se resuelve.

11. Estado del código y puntos de atención

Hallazgos de la lectura del código, sin cambios aplicados:

  1. Un 401 se lee como "el producto no existe" y dispara una creación. getProductBySku() usa Http:: sin ->throw(): ante cualquier respuesta no-2xx loguea y retorna null, sin lanzar excepción. En syncProduct() eso deja $existsRemote = false, y el catch que compara getCode() !== 404 es código muerto para errores HTTP. 401, 403, 500 y timeout son indistinguibles de un 404 legítimo, y todos llevan a la rama de creación. Capturado en el log del 2026-09-08:
ERROR  Error fetching product COML-CPI-1373881  {"status":401,...}
INFO   [TN:16229] Sync iniciado  {"existsRemote":false,...}
INFO   [TN:16229] Creando producto

Es el mismo bug de fondo que produjo los COML-07012-1, por otra puerta. Hoy no duplica sólo porque la escritura también está rota: si TN restaura el consumer key pero el login sigue fallando, empezamos a crear duplicados de productos que ya existen. Arreglo: distinguir 404 de los demás códigos y cortar el flujo ante cualquier otro error.

  1. app:send-tn-products es código muerto. Instancia ProductApiServiceTN con new en vez del contenedor y trabaja sobre ExternalPartnersProductSync, una tabla previa al modelo actual. Está agendado en app/Console/Kernel.php, pero ese archivo no se ejecuta: bootstrap/app.php (Laravel 11) usa routes/console.php, y el schedule() del Kernel está además comentado entero.

  2. handleQueued() y Carbon 3. now()->diffInMinutes($log->attempted_at) devuelve un valor con signo en Carbon 3 (el proyecto usa 3.11), y como attempted_at está en el pasado el resultado es negativo. En consecuencia ni el warning de 15 min ni el TIMEOUT_EN_COLA_TN de 60 min se disparan nunca: un producto trabado en la cola de TN queda pending indefinidamente. Se corrige invirtiendo el orden ($log->attempted_at->diffInMinutes(now())).

  3. Lógica de subida de imágenes duplicada. VerifyTnProductSync::uploadImages() reimplementa ProductSyncService::uploadAllImages() (conversión a JPEG, payload, manejo de "doesn't exist") con diferencias sutiles. Un cambio en el contrato de media de TN hay que aplicarlo en los dos lugares.

  4. Partner::find(1) hardcodeado en calculatePriceAndStockData(), teniendo PartnerConstants::TN disponible.

  5. logSync() sólo marca éxito para las acciones creado, actualizado y actualizado_con_imágenes. La acción creado no la usa nadie, y varias rutas de éxito no pasan por logSync sino que escriben EN_COLA_TN directamente, con lo que el status final depende de la verificación.

  6. Dos formas de nombrar el canal. execute() filtra con where('channel', 'tienda naranja') mientras el resto usa SyncChannels::TN ('Tienda Naranja'); funciona porque el collation de MySQL y el LOWER() de scopedSelectSql() lo vuelven insensible a mayúsculas, pero es una convención sin dueño.

  7. ~450 líneas comentadas en ProductTransformer (dos versiones anteriores de calculatePriceAndStockData).

  8. TN_ORPHAN_PRODUCT_CATEGORIES no se lee en ningún lado. Está en .env con 2831,2711,2 pero no aparece en config/ ni en app/; presumiblemente quedó de una versión anterior de la limpieza de huérfanos.

  9. Desfase horario del servidor. Las fechas de oferta se construyen con config('app.timezone'); al probar special_from_date / special_to_date hay que contrastar contra el reloj de Laravel, no el del sistema (ver el desfase conocido de marzo a octubre).


12. Variables de entorno

TN_API_BASE_URL=https://mcstaging.tiendanaranja.com.py/rest/V1   # staging; la ruta /rest/V1 va incluida
TN_CONSUMER_KEY=
TN_CONSUMER_SECRET=
TN_TOKEN_KEY=
TN_TOKEN_SECRET=
TN_USERNAME=
TN_PASSWORD=
TN_SELLER_ID=116288
TN_VERIFY_DELAY_MINUTES=1

# Presente en .env pero SIN ninguna lectura en el código (residuo):
TN_ORPHAN_PRODUCT_CATEGORIES=2831,2711,2

TN_API_BASE_URL incluye el prefijo /rest/V1; todo el código concatena rutas directamente sobre ese valor.


13. Cómo operar

# barrido completo (sincrónico, no usa colas) — pesado, correr con criterio
php artisan app:sync-tn-products

# limpieza de huérfanos
php artisan app:cleanup-tn-products --full
php artisan app:cleanup-tn-products --page-size=50   # incremental

# reintentar un canal desde ProductSyncLog
php artisan sync:retry-pending

# seguir el flujo
tail -f storage/logs/jobs/tienda_naranja/tienda_naranja-$(date +%F).log

Tras editar SyntToTiendaNaranjaJob o VerifyTnProductSync hay que reiniciar los workers (php artisan queue:restart + Horizon/Supervisor), si no siguen corriendo el código viejo.

Referencias

  • docs/analisis/tn-sync-verification-solution.md — historia del bug de duplicados.
  • docs/SYNC_CHANNELS_ARQUITECTURA.md — el patrón de canales de salida.
  • docs/UNIQUE_JOB_LOCKS.md — locks de ShouldBeUnique.