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_basicactive→product_statuspublish→product_statusselected_supplier_product_id→product_basic, oproduct_statussi el nuevo valor esnull
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:
-
Categorías TN —
ensureTnCategories()delega enProductTransformer::validateTNCategory(). Si el producto no tieneProductasociado, no tiene categoría TN enproduct_partner_categories, o la categoría está inactiva →ProductSyncLogenSKIPPEDconSIN CATEGORIA TN/CATEGORIA TN INACTIVA/SIN PRODUCTO ASOCIADO, y corta. Ninguna llamada a TN. -
¿Existe en remoto? —
getProductBySku($skuTn).$existsRemotees true si la respuesta traesku. Un 404 se trata como "no existe"; cualquier otro error corta conremote_check_error. -
¿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. -
Imágenes locales —
imagesPubliccount. -
Estados previos en
product_sync_logs(canal TN): wasCreatedButImagesFailed: existe log conPRODUCT_NOT_READY_FOR_IMAGESoPENDIENTE PARA SUBIR IMAGENES.-
pendingLog: log conEN_COLA_TNen estadoPENDING. -
Creación pendiente — si hay
pendingLogy el producto todavía no existe en remoto,handlePendingCreation()consulta/mpapi/sellers/me/product-status/{processId}: completed→ marca el logSUCCESSy sigue al flujo de actualización.- aún en cola y no se programó re-sync → marca
resync_scheduledy devuelveneeds_resync, que el job traduce en: liberar el lock único y re-despacharse con 3 minutos de delay. -
sin
process_id→ logFAILED / SIN_PROCESS_ID. -
Rama CREATE (
!existsRemote && !wasCreatedButImagesFailed && !hasPending): - Sin imágenes locales →
SKIPPED / SIN IMAGEN, corta. TN no acepta altas sin imagen en este flujo. -
Con imágenes →
createProductWithImages(). -
Rama sólo-imágenes (
wasCreatedButImagesFailed && !existsRemote) →uploadImagesOnly(). -
Rama UPDATE (existe en remoto):
- remoto sin
media_gallery_entriesy hay imágenes locales →updateProductWithImages()(datos y luego fotos). - 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:
createProductWithImages()/updateProductData()guardan elprocess_idenproduct_sync_logsconstatus = PENDINGyresponse_message = 'EN_COLA_TN'— antes de intentar subir imágenes.SyntToTiendaNaranjaJob::handleSyncResult()ve ese log y despachaVerifyTnProductSynccon delay (TN_VERIFY_DELAY_MINUTES, default 1 min).VerifyTnProductSyncesShouldBeUniquesin parámetros: una sola instancia procesa todos los pendientes en batch. Para cada uno consultaproduct-status/{processId}y resuelve:queued/processing→ sigue pendiente; a los 15 min loguea warning, a los 60 min (MAX_QUEUE_TIME_MINUTES) marcaTIMEOUT_EN_COLA_TN.completedcondata.error != 0→TN_PROCESSING_ERROR.completedy OK → si el remoto no tiene media y hay imágenes locales, las sube ahí mismo; hastaMAX_VERIFY_ATTEMPTS = 10intentos, despuésMAX_IMAGE_UPLOAD_ATTEMPTS.- estado desconocido →
UNKNOWN_QUEUE_STATUS. - Al terminar, si quedan pendientes, se re-encola a sí mismo. Si no quedan, la cadena se apaga sola.
Protecciones contra duplicados¶
SyntToTiendaNaranjaJobtienetries = 1: nunca reintenta automáticamente. Todos loscatchloguean y registran enProductSyncLogpero no relanzan la excepción, precisamente para que Laravel no reintente.- Los reintentos deliberados se hacen con
dispatch()->delay()(2 minutos paraConnectException, 429 y 5xx), no conrelease(). ShouldBeUniqueconuniqueId() = productItemIdyuniqueFor = 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_pricede la vista TN, entero, como string. Si es 0 se envía""— nunca"0".special_pricesale deProductPriceService::getProductPriceInfo()y se multiplica por el factor del partner (Partner::find(1)->factor, hoy1.07según el seeder). Sólo se envía si hayofferStartyofferEndy el especial ajustado es menor al regular.- Fechas en
Y-m-d H:i:s, confroma las 00:00:00 ytoa las 23:59:59, en el timezone de la app. status:1siproductItems.active == 1, si no2. Un ítem inactivo va conqty: 0forzado.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
imagesPublicde la vista, usando la URL cruda (getRawOriginal('image_url')— típicamente Cloudflare Images). - Toda imagen se re-codifica a JPEG con GD (
imagecreatefromstring+imagejpegcalidad 100) en un archivo temporal, que se borra en elfinally. - Se envían de a una,
POST /products/{sku}/media, en base64, contypes: [image, small_image, thumbnail]ypositionincremental. - Si TN responde
"The product that was requested doesn't exist.", el servicio lo traduce aPRODUCT_NOT_READY_FOR_IMAGESy 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:
- Carga en memoria todos los SKU de
ProductItemViewconstock > 0, normalizados a mayúsculas (sin filtrar por canal). - Pagina
/productsfiltrando porseller_idystatus = 1, opcionalmentecreated_at > última corrida. - Por cada SKU remoto: le quita el prefijo
COML-; si no está en la lista de válidos, lo desactiva conentity_id,status: 2,qty: 0. - Distingue tres desenlaces:
deactivated,config_error(TN se queja decategory_ids— requiere corrección manual en el panel de TN) yerrors. - 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 porlogTag().
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 campoproduct_gallery, que nuestro código nunca envía. El nuevo defaultnoopsólo afecta a quien lo mandaba. Verificado: un update conhas_remote_media: trueno 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}/mediaen 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 devuelverow_iddistinto deproduct_id, señal de que corren Magento Commerce con content staging — de ahí que recomienden elentity_idy 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
(
uploadAllImages71,convertImageToJpeg39,uploadProductImage39,transformImage25,uploadImagesOnly24,updateProductWithImages20,getProductImages14,getProductMedia7, yVerifyTnProductSync::uploadImages91) - Un alta con 3 fotos pasa de 4 llamadas HTTP a 1
- Desaparece
PRODUCT_NOT_READY_FOR_IMAGESy 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.phpfijamemory_limit => 64MB yimagecreatefromstringsobre 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:
- Un 401 se lee como "el producto no existe" y dispara una creación.
getProductBySku()usaHttp::sin->throw(): ante cualquier respuesta no-2xx loguea y retornanull, sin lanzar excepción. EnsyncProduct()eso deja$existsRemote = false, y elcatchque comparagetCode() !== 404es 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.
-
app:send-tn-productses código muerto. InstanciaProductApiServiceTNconnewen vez del contenedor y trabaja sobreExternalPartnersProductSync, una tabla previa al modelo actual. Está agendado enapp/Console/Kernel.php, pero ese archivo no se ejecuta:bootstrap/app.php(Laravel 11) usaroutes/console.php, y elschedule()del Kernel está además comentado entero. -
handleQueued()y Carbon 3.now()->diffInMinutes($log->attempted_at)devuelve un valor con signo en Carbon 3 (el proyecto usa 3.11), y comoattempted_atestá en el pasado el resultado es negativo. En consecuencia ni el warning de 15 min ni elTIMEOUT_EN_COLA_TNde 60 min se disparan nunca: un producto trabado en la cola de TN quedapendingindefinidamente. Se corrige invirtiendo el orden ($log->attempted_at->diffInMinutes(now())). -
Lógica de subida de imágenes duplicada.
VerifyTnProductSync::uploadImages()reimplementaProductSyncService::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. -
Partner::find(1)hardcodeado encalculatePriceAndStockData(), teniendoPartnerConstants::TNdisponible. -
logSync()sólo marca éxito para las accionescreado,actualizadoyactualizado_con_imágenes. La accióncreadono la usa nadie, y varias rutas de éxito no pasan porlogSyncsino que escribenEN_COLA_TNdirectamente, con lo que elstatusfinal depende de la verificación. -
Dos formas de nombrar el canal.
execute()filtra conwhere('channel', 'tienda naranja')mientras el resto usaSyncChannels::TN('Tienda Naranja'); funciona porque el collation de MySQL y elLOWER()descopedSelectSql()lo vuelven insensible a mayúsculas, pero es una convención sin dueño. -
~450 líneas comentadas en
ProductTransformer(dos versiones anteriores decalculatePriceAndStockData). -
TN_ORPHAN_PRODUCT_CATEGORIESno se lee en ningún lado. Está en.envcon2831,2711,2pero no aparece enconfig/ni enapp/; presumiblemente quedó de una versión anterior de la limpieza de huérfanos. -
Desfase horario del servidor. Las fechas de oferta se construyen con
config('app.timezone'); al probarspecial_from_date/special_to_datehay 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 deShouldBeUnique.