Saltar a contenido

RFC 008 — Precios de competencia por producto (observación para analítica)

Estado: Implementado — el camino crítico está construido y verificado (§12). Quedan fuera de esa base dos piezas opcionales: el paso en el modal de alta (§7.2.1) y la carga masiva por CSV. Fecha: 2026-08-24 (diseño) · 2026-08-26 (implementación) Autores: TI Compulandia + Claude Relacionado con: docs/analisis/precios-competencia-requisitos.md (definición de requisitos, aprobada 2026-08-25), ADR-0005 (decisiones estructurales del módulo, extraídas de este RFC), ADR-0001 (migraciones en subdirectorios numerados), PageScrapingService y productos_compras_paraguai (scraping preexistente, §2.2)


1. Objetivo

Registrar, por producto, qué precio tiene ese mismo producto en la competencia, para alimentar el análisis de determinación del precio de venta y evitar precios estáticos.

El módulo es de observación, no de cálculo: no decide ni sugiere precios. Guarda lo que se observó, dónde y cuándo, y lo expone para que se analice fuera del sistema.

Explícitamente fuera de alcance: el precio observado no interviene en el cálculo del precio de venta interno. ProductPriceService, PriceScale y la tabla prices no se tocan.

2. Estado actual (verificado en código, 2026-08-24)

2.1 "Lista de precios" ya es un concepto ocupado

Existen price_lists y prices (App\Models\PriceList, App\Models\Price), que modelan precios propios: una fila por supplier_product × price_list × partner.

Nombrar "lista de precios de la competencia" a lo nuevo genera colisión conceptual directa con esa estructura. Ver D8.

2.2 Ya existe scraping de precios ajenos, con otro propósito

App\Services\Webscraping\PageScrapingService scrapea comprasparaguai.com.br y persiste en productos_compras_paraguai (App\Models\ProductoCompraParaguai).

No sirve como base conceptual, porque su finalidad es distinta: alimenta la creación de supplier_products bajo un proveedor sintético (Supplier::find(999) en SyncComprasParaguaiProducts) — es decir, descubrimiento de productos para comprar, no comparación de precio de venta. Además:

  • Pisa el precio en cada corrida; no hay historial.
  • Guarda un rango (precio_desde, precio_hasta, control_precio), no un precio puntual.
  • Se ata a supplier_product_id, no a product_item.

Sí sirve como infraestructura reutilizable: proxy Playwright propio (services.playwright.endpoint), ScrapeOps, Symfony\DomCrawler, y conversión de moneda vía App\Services\CambiosChaco\ExchangeService.

2.3 Cobertura de identificadores comparables

product_items es la entidad con identificadores externos (ean, upc, part_number, sku_sap). Medición sobre la base de desarrollo:

Métrica Cantidad % del total
product_items totales 17.561 100%
Activos (active = 1) 16.430 94%
Con ean no vacío 3.069 17%
Con part_number no vacío 364 2%

3. Problema

El precio de venta se determina hoy sin una referencia sistemática y actualizada del mercado. No hay dónde consultar a cuánto vende la competencia un producto dado, ni cómo evolucionó ese precio. La consecuencia es precio estático: se fija una vez y no se revisa, porque revisarlo exige relevamiento manual caso por caso.

4. Hallazgos que condicionan el diseño

4.1 El match automático por identificador no es viable

Con 17% de cobertura de EAN y 2% de part number (§2.3), y considerando que los sitios de retail local rara vez publican EAN, cruzar nuestro catálogo contra el del competidor por identificador cubriría una porción marginal del catálogo.

Las alternativas eran match difuso por nombre (mete precios equivocados en la analítica sin que nadie se entere) o curación humana. Se optó por curación (D3).

4.2 Esto invierte el sentido del scraping respecto de §2.2

El scraping existente va del catálogo ajeno hacia nuestro (scrapea todo y después intenta matchear). Este módulo va de nuestro producto hacia la página ajena: se registra la URL del producto en el competidor, y el scraper solo visita esa URL.

Consecuencias: el match se resuelve una sola vez, al cargar la URL; el scraping es determinístico y de volumen acotado; y no hay ambigüedad de a qué producto corresponde cada precio.

4.3 El universo relevado se define solo

No hace falta un flag "producto seleccionado para monitoreo": tener una URL cargada es la selección. Un product_item sin URLs no participa. Esto elimina una entidad y un proceso de mantenimiento.

5. Alcance

Dentro:

  • Alta y administración de competidores.
  • Carga manual de la URL del producto en cada competidor, sobre product_items activos.
  • Relevamiento automático periódico de esas URLs, con frecuencia propia por competidor.
  • Persistencia del último precio observado, precio de lista y disponibilidad.
  • Dejar los datos consultables en la base para las herramientas externas. Sin superficie de salida propia.

Fuera:

  • Cualquier efecto sobre el precio de venta interno (§1).
  • Descubrimiento de productos o alta de supplier_products (eso es §2.2).
  • Match automático o asistido: sin URL cargada, no hay observación.
  • Historial de precios: se guarda el último estado, no la evolución (D5).
  • La analítica en sí misma (D24). El módulo deja los datos en la base y los expone; qué se hace con ellos es de la herramienta externa.
  • Alertas o reglas automáticas ante cambios de precio de la competencia.
  • Cualquier superficie de salida propia: vistas SQL, endpoints, exports o pantallas de consulta. El consumo externo lee las tablas directamente (D13).

6. Modelo conceptual

Esto describe las entidades y su razón de ser; el esquema concreto está en §8.

6.1 Competidor

Entidad dada de alta desde el sistema. Además de identificación (nombre, sitio) lleva su propia frecuencia de relevamiento y su estado activo/inactivo.

Que la frecuencia sea por competidor y no global permite relevar seguido a un sitio tolerante y espaciar a uno frágil o con anti-bot agresivo, sin frenar a los demás.

6.2 Precio de competencia

Una fila por product_item × competidor. El vínculo y la observación viven en la misma fila — al guardarse solo el último valor (D5), separar "URL curada" de "precio observado" en dos tablas no aporta nada.

Contiene: la URL curada, su estado, el precio final de venta, el precio de lista (tachado, cuando el sitio lo muestra), la disponibilidad, el título leído de la página como control de identidad, y las dos fechas de relevamiento —último intento y último éxito— y nada más (D18, D19). Ni el motivo del fallo ni un contador de errores: el motivo vive en el log (D22) y el estado se deduce de las dos fechas (§8.3).

Todos los importes se guardan normalizados a PYG (D6).

6.3 Salida: las tablas mismas

No hay superficie de salida: ni vista SQL, ni endpoints, ni pantallas de consulta. Las herramientas externas leen las tablas directamente; todo lo que necesitan saber para interpretarlas (estados derivados de las fechas, §8.3) queda documentado, no implementado.

6.4 Flujo

  1. Se da de alta el competidor con su frecuencia.
  2. Un operador pega la URL de ese producto en el competidor — al confirmar el alta del producto, o desde la pestaña "Competencia" del product_item (§7.2). Eso lo incorpora al universo relevado.
  3. Cada competidor corre según su propia frecuencia: visita sus URLs, extrae precio, precio de lista y disponibilidad, y actualiza la fila junto con la fecha de scrapeo.
  4. Ante fallo (404, producto discontinuado, cambio de layout), se conserva el último precio conocido; la divergencia entre las dos fechas delata que falló y cuán viejo es el precio (D7, D18).
  5. Las herramientas externas leen las tablas directamente.

7. Flujos operativos

7.1 ABM de competidor

El competidor no es solo una etiqueta: concentra toda la configuración operativa y de extracción del sitio, de modo que adaptarse a un cambio en la página del competidor sea una edición de datos y no un despliegue de código.

Datos del competidor

Grupo Campos Para qué
Identidad nombre, código, dominio base, activo Identificación y validación de URLs (§7.2)
Relevamiento frecuencia en días, opciones de ScrapeOps (render_js y extras) Gobierno del proceso diario y del costo (§7.3)
Formato moneda del sitio, separador decimal, separador de miles Interpretar Gs. 3.500.000 o US$ 450,00 sin ambigüedad
Extracción reglas por campo (abajo) Obtener los datos de la página

Configuración de extracción: cadena de estrategias por campo

Para cada dato a obtener se define una lista ordenada de reglas. El extractor las prueba en orden y gana la primera que devuelve un valor válido. Esto es lo que da tolerancia: si el sitio cambia el HTML pero mantiene sus metadatos, la primera regla sigue funcionando; si rompe todo, se agrega una regla nueva sin tocar código.

Tipos de regla, del más estable al más frágil:

Tipo Qué lee Estabilidad
json_ld Ruta dentro del <script type="application/ld+json"> de schema.org (Product → offers → price, priceCurrency, availability) Alta — es metadato para buscadores, sobrevive rediseños
meta Etiquetas og:price:amount, product:price:amount Alta
css Selector CSS + de dónde tomar el valor (texto del nodo o un atributo como content / data-price) Media — se rompe con cada cambio de tema
regex Expresión sobre el HTML crudo Baja — último recurso

Recomendación: que json_ld sea siempre la primera regla de la cadena. La mayoría de las plataformas de e-commerce lo emiten, y es el único punto de la página pensado para ser leído por máquinas.

Pero no se asume que el sitio esté normalizado. Si el competidor no publica datos estructurados, la prueba de esa regla simplemente falla y la cadena cae al selector parametrizado. Ese es el propósito de la cadena: json_ld es el camino barato y estable cuando existe, y la parametrización por selector es la red que garantiza cobertura cuando no.

Campos a extraer

  1. precio_venta — obligatorio. El precio que paga el cliente hoy.
  2. precio_lista — opcional. El precio tachado, cuando el sitio lo muestra.
  3. disponibilidad — requiere además un mapeo a nuestro vocabulario: los valores de schema.org (InStock / OutOfStock) o los textos del sitio ("Agotado", "Sin stock", "Disponible") se traducen a en_stock / sin_stock. Admite también la forma "existe este elemento en la página ⇒ hay stock".
  4. titulo — no es un dato analítico: es el control de identidad. Sirve para que el operador confirme al cargar la URL (§7.2) y para detectar más adelante que la página pasó a mostrar otro producto.

Guardarraíles

La configuración dinámica sin validación es un arma: un selector mal apuntado toma el 12 de "12 cuotas" y lo guarda como precio, sin que nadie se entere. Por eso:

  • Rango plausible. El precio debe ser mayor a cero y no apartarse del último valor conocido más allá de un porcentaje configurable (§7.4). Fuera de rango ⇒ no se escribe.
  • Sin valor válido ⇒ no se pisa el precio. Si ninguna regla de la cadena devuelve un valor aceptable, se registra el intento fallido con su motivo y el precio anterior queda como estaba (§7.3).
  • Botón "Probar" en el ABM. Se pega una URL de muestra, corre la configuración actual y muestra qué extrajo cada campo y con qué regla de la cadena lo obtuvo. Ese último dato es el que responde, al dar de alta el competidor, la pregunta que importa: si ganó json_ld, el sitio está normalizado y el mantenimiento futuro será mínimo; si ganó el selector CSS, ya se sabe que ese competidor va a requerir atención cada vez que rediseñe. Sin el probador el dinamismo no sirve: editar selectores a ciegas falla en silencio, que es peor que tenerlos en código.
  • Auditoría. Toda edición de la configuración queda registrada (el proyecto ya usa activity log), para poder responder "esto se rompió cuando alguien tocó la regla".

Baja. El competidor no se elimina: se desactiva. Borrarlo arrastraría las URLs curadas, que son trabajo humano acumulado. Desactivado deja de relevarse y sus datos quedan visibles con su fecha.

7.2 Asignación de URL del producto en el competidor

Es el flujo que produce el universo relevado (§4.3). Tiene dos puntos de entrada, con exigencias distintas, porque uno ocurre dentro de un flujo operativo de alto volumen y el otro es una gestión dedicada.

7.2.1 En el alta del producto — modal de confirmación de productos pendientes

El lugar natural para capturar la URL es donde el producto nace: el modal multipaso de App\Livewire\PendingProducts\ConfirmationModal, con un paso extra que lista los competidores activos y ofrece un campo para pegar la URL de cada uno.

Aplica cuando el flujo crea un product_item nuevo, y cuando el product_item destino aún no tiene URL para ese competidor.

Se valida en el acto, pero no se bloquea la confirmación. Al pegar la URL se corre la extracción y se muestra lo obtenido —título, precio, disponibilidad— para que el operador confirme que es el producto correcto, igual que en la pestaña (§7.2.2). La diferencia está en qué pasa cuando eso no sale bien:

  • Los campos son opcionales: se puede confirmar el producto sin cargar ninguna URL.
  • Si la extracción falla, tarda demasiado o el operador prefiere no esperar, la URL se guarda como pendiente_verificacion y el producto se confirma igual.
  • Lo que siempre se valida, por barato y local, es formato de URL, pertenencia al dominio del competidor y unicidad (§7.2.3).

El motivo de esa concesión es concreto: cada verificación es un ida y vuelta a ScrapeOps de varios segundos, y confirmar productos pendientes es una tarea de volumen. Si el sitio de un competidor está caído, el alta de productos no puede quedar tomada de rehén.

El estado pendiente_verificacion no es un cabo suelto: es una bandeja de trabajo. Las URLs que quedaron sin verificar se listan para resolverlas desde la pestaña, que es donde la revisión tiene sentido.

7.2.2 En el producto — pestaña "Competencia" en el detalle del product_item

Es la gestión dedicada, y cubre lo que el modal no puede: actualizar URLs y cargar las de los productos que ya existen sin URL — que hoy son todos.

Se agrega como pestaña nueva en resources/views/components/product-items/tabs.blade.php, junto a Variantes, Producto, Etiquetas, Galería e Historial. Sin ícono decorativo, según docs/estandares/ui-design-standards.md (las pestañas existentes los tienen por antigüedad, no por criterio vigente).

Muestra una fila por competidor activo, con su URL, último precio, disponibilidad, fechas y estado. Y acá la verificación sí es bloqueante:

  1. El operador pega la URL.
  2. Se valida formato, pertenencia al dominio y unicidad (§7.2.3).
  3. Se corre la extracción en el acto y se muestra qué se obtuvo: título, precio, precio de lista, disponibilidad.
  4. El operador confirma que es el producto correcto. Recién ahí se persiste como activa.

El paso 4 es el que convierte el riesgo "la URL apunta a otro producto" (§10) de silencioso en imposible de ignorar: quien curó la URL ve el título extraído antes de confirmar. Y si la extracción falla, es señal temprana de que la configuración del competidor no cubre ese tipo de página — se detecta al cargar una URL, no meses después en la analítica.

Desde esta pestaña también se verifican y confirman las URLs que llegaron en estado pendiente_verificacion desde el modal de alta.

7.2.3 Reglas comunes a ambos puntos de entrada

Validación de pertenencia: el dominio de la URL debe corresponder al del competidor. Evita el error trivial de pegar la URL de un competidor en la fila de otro.

Unicidad, en dos sentidos:

  • (competidor, url) única — una misma página no puede estar asignada a dos productos nuestros; si pasa, es un error de carga.
  • (product_item, competidor) única — un producto tiene a lo sumo una URL por competidor.

Edición: cambiar la URL repite la verificación y descarta el precio anterior — es otra página, y probablemente otro producto.

Estados de la asignación:

Estado Significado Cómo se llega
pendiente_verificacion URL cargada pero no confirmada contra la página Alta desde el modal de productos pendientes (§7.2.1)
activa Participa del relevamiento diario Alta verificada y confirmada (§7.2.2)
pausada Se conserva la URL curada, no se releva Decisión manual

Son los tres estados que alguien decide, y por eso se almacenan. Que una URL esté rota no lo decide nadie: se deduce de las fechas (§8.3).

Quitar un producto del universo es pausar, no borrar: conserva la URL, que costó trabajo curar. El borrado existe pero es una acción aparte y explícita.

Carga masiva. Para el alta inicial de volumen, importación por CSV (sku, competidor, url) que deja las filas en pendiente_verificacion y devuelve el reporte de cuáles no pudieron verificarse.

7.3 Proceso diario de relevamiento

Corre todos los días. El lote del día no es "todo el universo", sino lo que está vencido y es relevante — con lo cual la carga se reparte entre días distintos.

Elegibilidad. Una URL entra al lote del día si se cumple todo:

  • el competidor está activo;
  • la asignación está activa;
  • el product_item está activo;
  • pasa la política de stock: si el relevamiento sin stock está desactivado, el producto debe tener stock (abajo);
  • su último relevamiento es más viejo que la frecuencia del competidor, o nunca se relevó.

Orden: por fecha de último relevamiento ascendente — lo más rancio primero, así nada queda postergado indefinidamente.

Política de stock

El stock del producto se resuelve por product_item_view.stock > 0, que es donde el sistema ya encapsula "producto vendible" (ver ADR-0002 antes de tocar esa vista).

Un único parámetro global (§7.4) decide si los productos sin stock propio entran al lote o no. Activado, se relevan igual que los demás; desactivado, quedan fuera hasta que vuelvan a tener stock.

Se evaluó espaciar el relevamiento según cuánto tiempo lleva agotado un producto, y se descartó: obliga a llevar la fecha de cuándo se quedó sin stock —que el sistema no registra— a cambio de un ahorro que todavía nadie midió. Si el gasto de créditos lo justifica, se retoma.

Volumen por corrida

La frecuencia por competidor alcanza como mecanismo de reparto. Queda previsto pero no implementado un límite máximo de URLs por ráfaga de corrida (§7.4), como techo duro de pedidos contra el sitio.

Vale registrar el efecto que ese límite corregiría, para no descubrirlo después: la frecuencia sola no desincroniza cohortes. Si N URLs se relevan el mismo día, con frecuencia de 7 días vuelven a vencer juntas siete días después, indefinidamente. En la práctica el riesgo es acotado, porque el alta de URLs ocurre en el flujo de confirmación de productos (§7.2.1), que sucede de a poco y deja las fechas naturalmente repartidas. El caso que sí concentraría carga es la carga masiva por CSV, donde todas las filas nacen el mismo día — y es exactamente ahí donde el límite por ráfaga se vuelve útil.

Obtención de la página: ScrapeOps

Las páginas no se piden directamente: se piden a través de ScrapeOps (https://proxy.scrapeops.io/v1/), que ya está integrado y configurado en el proyecto (services.scraperops.token, desde SCRAPEOPS_API_KEY). El servicio se ocupa de la rotación de IP y del renderizado, con lo cual el módulo no necesita el proxy Playwright propio — ese queda para el scraping preexistente de §2.2.

El costo es el parámetro de diseño. ScrapeOps cobra por créditos y el código actual ya dejó medida la diferencia: con premium=level_1 y render_js=false un pedido cuesta 1,5 créditos; activando render_js pasa a 15. Diez veces más por página.

Eso tiene tres consecuencias directas:

  1. render_js es una decisión por competidor, no un default. Se activa solo si sin él la página no entrega los datos, y esa pregunta la responde el probador del ABM (§7.1).
  2. Refuerza la preferencia por json_ld. Los datos estructurados de schema.org viven casi siempre en el HTML que devuelve el servidor, sin necesidad de ejecutar JavaScript. Elegir esa regla primero no es solo más estable (§7.1): es diez veces más barato.
  3. El volumen del lote diario es presupuesto. La frecuencia por competidor, la política de productos sin stock (D20) y el límite por ráfaga (D16) dejan de ser solo perillas de cortesía contra el sitio y pasan a ser también control de gasto. D16 gana con esto un segundo motivo para existir.

El premium=level_1 se usa solo en producción, siguiendo el criterio ya aplicado en el servicio existente.

Ejecución: un comando, no una cola

El proceso corre como un comando diario que atiende la corrida completa de principio a fin, con concurrencia acotada — no despachando un job por URL a una cola.

Es una desviación deliberada del patrón que usan las integraciones de salida del proyecto, y las razones son tres:

  1. La corrida necesita un principio y un fin. La cabecera y el resumen del log (abajo) no tienen dónde emitirse si el comando despacha jobs y termina. Resolverlo con la cola exigiría Bus::batch y su callback final, un patrón que el proyecto no usa hoy y que arrastra la tabla job_batches.
  2. El lote diario es chico por diseño. La frecuencia por competidor reparte el universo entre días, así que una corrida son decenas de URLs, no miles.
  3. Nada compite por esta ventana. Es un proceso diario, aislado, que no publica nada hacia afuera. No hay riesgo de atascar las colas de salida — que fue la lección del incidente de junio 2026 (ADR-0003).

Lo que se resigna es el reintento por job con backoff de la cola; los reintentos se hacen inline dentro del comando, con timeout explícito por pedido.

Detalles:

  • Corre por scheduler, con opciones para depurar: un solo competidor, una sola URL, modo simulación.
  • Concurrencia acotada al plan de ScrapeOps: el techo de pedidos simultáneos es el de la cuenta, no el de cada competidor, y se fija en un parámetro global (§7.4).
  • Los contadores de la corrida viven en memoria del propio comando — no hacen falta contadores efímeros en Redis ni estado compartido entre jobs.

Traza: cabecera, una línea por producto, resumen

El archivo de log del módulo es la traza de auditoría, y tiene tres partes:

Parte Contenido
Cabecera Inicio de la corrida: fecha, competidores incluidos, cuántas URLs entraron al lote y por qué criterio
Una línea por producto relevado Formato obligatorio [{CANAL}:{product_id}:{sku}], con desenlace, precio obtenido y datos variables en el contexto (D22)
Resumen Cierre: relevadas, exitosas, fallos separados por tipo, y el desglose por competidor

El resumen es la alerta. Ahí es donde se publica la proporción de fallos de extracción por competidor, que es la señal de cambio de layout (abajo). Una línea como "Nissei: 30 relevadas, 5 exitosas, 25 fallos de extracción (83%)" dice sola lo que pasó, sin infraestructura de alertas.

Desenlaces por URL. Distinguir estos tres casos es lo que hace confiable el dato:

Desenlace Ejemplos Qué pasa con el precio Qué se registra
Éxito Extracción válida y dentro de rango Se actualiza Fecha de intento y de éxito
Fallo transitorio Timeout, 429, 5xx No se toca Fecha de intento; la de éxito no avanza. Motivo al log
Fallo permanente o de extracción 404/410, ninguna regla matchea, valor fuera de rango No se toca Fecha de intento; la de éxito no avanza. Motivo al log

rota es una etiqueta, no una regla de exclusión. Una URL sin relevamiento exitoso desde hace más de X días (§7.4) se muestra como rota y aparece en la bandeja de revisión, pero se sigue intentando a la cadencia normal. No hace falta sacarla del ciclo: como la elegibilidad se mide por la fecha de último intento, una página muerta se toca cada N días, no todos los días — que era el único problema que había que evitar. Y así, si el sitio vuelve, la URL se recupera sola sin que nadie la rehabilite.

El estado de error no se guarda: se deduce. Con las dos fechas —último intento y último relevamiento exitoso— alcanza: si no coinciden, la última corrida falló, y la distancia entre ellas es la antigüedad real del precio. No hace falta ni un campo "con error" ni un contador de fallos: ambos pueden quedar desincronizados del hecho, las fechas no (§8.3).

El motivo del fallo tampoco se almacena: se loguea. Una línea por URL relevada en un canal propio del servicio, con el formato obligatorio [{CANAL}:{product_id}:{sku}] y los datos variables en el contexto. Eso alcanza para auditar por producto, SKU, competidor o fecha, sin sumar columnas que solo sirven para mirar hacia atrás. Sigue el precedente de la traza de edición masiva en storage/logs/jobs/.

Que sea una línea por URL no contradice la convención de "un log por acción significativa, no por iteración": cada URL es un pedido HTTP externo con un desenlace de negocio propio, no una vuelta de bucle sobre datos en memoria.

En la base queda solo lo que el proceso necesita para decidir: las dos fechas.

Detección de cambio de layout

Es el rédito del diseño dinámico. Si en una corrida muchas URLs del mismo competidor fallan por extracción — no por red — no se cayeron doscientas páginas: el sitio cambió el HTML. Superado el porcentaje configurado (§7.4) se dispara el aviso, y esa es la señal para ir a editar la cadena de reglas y verificarla con el botón "Probar" (§7.1).

Como el motivo no se guarda en la base (D22), el conteo se lleva en memoria durante la corrida y se publica en el resumen del log al cerrar. Es estado de ejecución, no esquema — se descarta cuando la corrida termina, y la traza fina queda línea por línea.

Sin esa distinción entre fallo de red y fallo de extracción, el módulo se degrada en silencio: sigue corriendo, no actualiza nada, y nadie se entera hasta que alguien nota que todos los precios tienen la misma fecha vieja.

7.4 Parámetros

Dos niveles, según qué tan específico es el valor:

Por competidor (en su registro, editable desde el ABM): frecuencia en días, opciones de obtención (render_js y extras de ScrapeOps), moneda y formato numérico, y la configuración de extracción.

Global (en .env, expuesto por un archivo de configuración propio, al estilo de config/bulk_edit.php):

Parámetro Para qué §
Relevar productos sin stock Si los productos propios agotados entran al lote diario 7.3
Variación máxima de precio aceptada Rango plausible; fuera de eso no se escribe 7.1
Días sin relevamiento exitoso para marcar rota Cuándo señalar una URL para revisión 7.3
Porcentaje de fallos de extracción para alertar Umbral de sospecha de cambio de layout 7.3
Pedidos simultáneos permitidos Concurrencia del comando; se ajusta al plan contratado de ScrapeOps 7.3
Límite de URLs por ráfaga de corrida Techo de pedidos —y de gasto— por corrida — previsto, no implementado 7.3

Los valores globales son iguales para todos los competidores. Si en la práctica alguno necesita un umbral propio, el parámetro se muda a su registro; empezar global evita inventar configuración por competidor antes de saber si hace falta.

Recordatorio operativo: al tocar .env o config en el servidor de desarrollo, php artisan config:clear — nunca config:cache (ver CLAUDE.md y runbook).

8. Modelo de datos

Dos tablas, un archivo de configuración y un canal de log. Nombres en inglés según la convención del repo; se evita deliberadamente el término price_list (D8).

8.1 competitors

Columna Tipo Notas
id bigint PK
name string Nombre visible
code string único Identificador corto para CSV y logs
base_domain string Validación de pertenencia de la URL (§7.2.3)
currency enum('PYG','USD') Moneda en que publica el sitio (D23)
decimal_separator char(1) Por defecto ,
thousands_separator char(1) Por defecto .
render_js boolean Por defecto false. Activa el renderizado en ScrapeOps — decuplica el costo (§7.3)
fetch_options json nullable Extras de ScrapeOps para ese sitio (país, tipo de proxy, esperas)
frequency_days unsignedSmallInteger Cada cuántos días se releva cada URL
extraction_config json Cadena de reglas por campo (§8.4)
active boolean Por defecto true. La baja es desactivación, no borrado
created_at / updated_at

Modelo Competitor. La edición de extraction_config se audita con activity log (§7.1).

8.2 competitor_prices

Una fila por product_item × competidor: el vínculo curado y la última observación en la misma fila (§6.2).

Columna Tipo Notas
id bigint PK
competitor_id FK → competitors restrictOnDelete — el competidor no se borra
product_item_id FK → product_items cascadeOnDelete
url string(500) URL de la página del producto en el competidor
url_hash char(64) SHA-256 de url, para poder indexar la unicidad
status enum('pending_verification','active','paused') Los tres estados que alguien decide (§7.2.3)
title string nullable Título leído de la página — control de identidad (§7.1)
sale_price decimal(12,0) nullable En PYG (D6). Sin decimales — ver D36
list_price decimal(12,0) nullable En PYG, cuando el sitio muestra precio tachado
availability enum('in_stock','out_of_stock','unknown') nullable Disponibilidad en el competidor
last_attempt_at datetime nullable Último intento de relevamiento — gobierna la elegibilidad
last_success_at datetime nullable Último relevamiento exitoso — gobierna la frescura
created_at / updated_at

Modelo CompetitorPrice.

Índices

Índice Para qué
único (competitor_id, url_hash) Una misma página no puede estar en dos productos (§7.2.3)
único (product_item_id, competitor_id) Un producto tiene a lo sumo una URL por competidor
(competitor_id, status, last_attempt_at) Query de elegibilidad del lote diario (§7.3)
(last_success_at) Estado derivado y consultas externas (§8.3, §8.5)

Por qué url_hash. La unicidad tiene que ser sobre la URL completa, y una URL de producto supera cómodamente el largo indexable en MySQL con utf8mb4. Se indexa el hash y se conserva la URL legible en su columna.

Por qué la elegibilidad usa last_attempt_at y no last_success_at. Porque así una URL que falla se reintenta en su próximo vencimiento normal —cada N días— y no todos los días. Es lo que permite que rota sea solo una etiqueta y no una regla de exclusión (§7.3).

8.3 Estados derivados: ninguna columna

Estado Se deduce de
Relevada con éxito la última vez last_attempt_at = last_success_at
Con error en el último intento last_attempt_at ≠ last_success_at
Rota last_success_at es NULL, o más vieja que el umbral de días (§7.4)
Antigüedad del precio Distancia entre last_success_at y hoy

No hay campo de estado ni contador de fallos: los tres primeros salen de comparar dos fechas, y una fecha no puede quedar desincronizada del hecho que representa (D18).

8.4 Formato de extraction_config

Un objeto por campo a extraer, con la lista ordenada de reglas (D14). El extractor las prueba en orden y gana la primera que devuelve un valor válido.

{
  "sale_price": [
    { "type": "json_ld", "path": "offers.price" },
    { "type": "meta",    "property": "product:price:amount" },
    { "type": "css",     "selector": ".summary .price ins .amount" }
  ],
  "list_price": [
    { "type": "css",     "selector": ".summary .price del .amount" }
  ],
  "availability": [
    { "type": "json_ld", "path": "offers.availability",
      "map": { "InStock": "in_stock", "OutOfStock": "out_of_stock" } },
    { "type": "css",     "selector": ".stock",
      "map": { "Agotado": "out_of_stock", "*": "in_stock" } }
  ],
  "title": [
    { "type": "json_ld", "path": "name" },
    { "type": "css",     "selector": "h1.product_title" }
  ]
}
  • json_ld — ruta dentro del <script type="application/ld+json"> de schema.org.
  • meta — property o name de la etiqueta.
  • css — selector, con attr opcional para leer un atributo en vez del texto.
  • regex — pattern sobre el HTML crudo, último recurso.
  • map — solo para availability: traduce el valor leído al vocabulario propio, con * como comodín.

Es un JSON validado contra un esquema al guardar, no texto libre: un tipo desconocido o una regla sin su campo obligatorio se rechaza en el ABM, antes de llegar a una corrida.

8.5 Consumo externo: sin superficie de salida

No se construye vista, endpoint ni export (D13): las herramientas externas consultan competitor_prices y competitors directamente. Lo que necesita saber quien consulte:

  • Considerar solo filas en estado active: una URL sin verificar o pausada no es una observación publicable.
  • Los precios están en PYG (D6).
  • El estado del relevamiento se deriva de las fechas según §8.3; no existe columna de estado de error.
  • La clave para cruzar con el resto del sistema es product_item_id.

El módulo no entrega nuestro precio de venta ni ningún cálculo comparativo: la comparación es enteramente del lado de quien consume (D24).

8.6 Configuración y logging

  • config/competitor_prices.php, leyendo de .env, con los parámetros globales de §7.4 (política de stock, variación máxima aceptada, días para rota, umbral de alerta por cambio de layout, y el límite por ráfaga previsto en D16). Sigue el patrón de config/bulk_edit.php.
  • Canal de log propio en config/logging.php más su constante en App\Constants\LogChannels, con una línea por URL relevada en el formato obligatorio [{CANAL}:{product_id}:{sku}] y los datos variables en el contexto (D22).
  • Sin conexión de cola: el proceso es un comando único (§7.3), así que no agrega colas ni workers. La concurrencia se controla dentro del propio comando.
  • Credencial de ScrapeOps: se reusa la existente (SCRAPEOPS_API_KEY en .env, leída por services.scraperops.token). No se agrega configuración nueva ni se versiona el valor.

8.7 Migración

Va en un subdirectorio numerado nuevo de database/migrations/ (ADR-0001). El número se asigna al implementar, no ahora: hay ramas en curso que ya tomaron los siguientes, y elegirlo por anticipado garantiza el choque.

No toca product_item_view — solo la consulta —, así que no aplica la auditoría de equivalencia del ADR-0002.

9. Decisiones tomadas

# Decisión Valor Razón
D1 Efecto sobre el pricing interno Ninguno El objetivo es analítica; acoplarlo al cálculo multiplicaría el riesgo sin beneficio inmediato
D2 Origen de los datos Scraping automático Hay infraestructura montada (§2.2)
D3 Resolución del match URL curada a mano por producto El match automático no es viable (§4.1); el difuso contamina en silencio
D4 Entidad de vínculo product_item, match obligatorio Es la entidad con identificadores y con precio de venta asociado
D5 Historial Solo último precio El módulo guarda estado, no evolución; llevar serie temporal es del lado de quien consume (D24)
D6 Moneda Normalizado a PYG Comparación directa sin conversión del lado de quien consume
D7 Ante fallo de scrapeo Conservar último precio; no pisar nunca con un fallo No perder el dato por fallas transitorias
D8 Nomenclatura Evitar "price list" Colisión con PriceList (§2.1)
D9 Universo de productos Activos con URL cargada Se define solo (§4.3)
D10 Cantidad de competidores 2-3 sitios propios definidos Acotado y predecible de mantener
D11 Frecuencia En días, parametrizada por competidor §6.1
D12 Datos capturados Precio de venta, precio de lista, disponibilidad, título El título es control de identidad, no dato analítico (§7.1)
D13 Consumo Directo de las tablas. Sin vista SQL, endpoints ni pantallas: nada de la salida se implementa aquí Todo lo relacionado a analítica queda fuera del módulo, incluida su superficie de lectura; lo que el consumidor necesita saber se documenta (§8.5)
D14 Configuración de extracción Dinámica, por competidor, como cadena ordenada de reglas con json_ld primero y selector parametrizado de respaldo Adaptarse a un cambio de la página debe ser edición de datos, no despliegue; y no se asume que el sitio publique datos estructurados (§7.1)
D15 Reparto de carga del proceso diario Solo por frecuencia y fecha de último relevamiento. Sin cupo diario Suficiente en la práctica, porque las altas de URL ocurren distribuidas en el tiempo (§7.3)
D16 Límite por ráfaga de corrida Parámetro previsto, no implementado en esta versión Techo duro contra el sitio; se vuelve útil recién con carga masiva por CSV (§7.3)
D17 Alta de URL: dos puntos de entrada Paso extra en el modal de productos pendientes, y pestaña "Competencia" del product_item Capturar la URL donde el producto nace, y tener una gestión dedicada para actualizar y para los productos que ya existen (§7.2). El comportamiento ante fallo de verificación difiere: ver D28
D18 Estado de error y estado rota Derivados, no almacenados. Sin contador de fallos: alcanza con comparar las dos fechas Un campo de estado o un contador pueden desincronizarse del hecho; las fechas no (§8.3)
D19 Fechas Dos: último intento y último relevamiento exitoso Es lo que permite conocer la antigüedad real del precio (§7.3). Reemplaza la reserva sobre D7
D20 Productos sin stock Un parámetro global de encendido/apagado. Sin decaimiento por tiempo agotado ni fecha de "sin stock desde" Es el control de volumen más simple que resuelve el caso; el decaimiento exigía registrar un dato que el sistema no lleva, sin ahorro medido (§7.3)
D21 Umbrales Parametrizados globalmente en .env vía archivo de configuración propio Se ajustan sin desplegar; se mudan al competidor solo si alguno lo necesita (§7.4)
D22 Motivo del fallo No se almacena: una línea de log por URL relevada, en canal propio del servicio El log es auditable por producto, SKU, competidor y fecha; una columna de motivo solo sirve para mirar hacia atrás y hay que mantenerla (§7.3)
D23 Moneda de origen Dato del competidor (publica en PYG o en USD); no se almacena la cotización usada La conversión a guaraníes es el resultado buscado; reconstruir el importe original no hace falta
D24 Analítica Fuera de alcance por completo. El módulo deja los datos en la base; no analiza, no compara, no alerta sobre precios Mantiene el módulo chico y con una sola responsabilidad: observar y persistir
D25 Servicio de scraping Nuevo, no se reusa PageScrapingService Está acoplado a comprasparaguai en constructor y base URL, y responde a otro propósito (§2.2); el extractor nuevo se guía por la configuración de D14
D26 Obtención de las páginas ScrapeOps, ya integrado en el proyecto. Sin proxy Playwright propio para este módulo Resuelve rotación de IP y renderizado; una dependencia en vez de dos. render_js queda como decisión por competidor por su costo (§7.3)
D27 Ejecución del proceso diario Comando único con concurrencia acotada, sin cola ni jobs por URL La corrida necesita principio y fin para la cabecera y el resumen; el lote diario es chico y no compite con las colas de salida (§7.3)
D28 Verificación en el modal de alta Se valida in situ y se muestra el resultado, pero no bloquea la confirmación del producto Da la garantía de identidad donde se carga la URL, sin que una caída del sitio ajeno frene el alta de productos (§7.2.1)

10. Riesgos y mitigaciones

Riesgo Impacto Mitigación
Cambio de layout del competidor rompe la extracción en silencio El precio queda congelado y se analiza como vigente Cadena de reglas con json_ld primero (§7.1); alerta por umbral de fallos de extracción contados durante la corrida (§7.3); las dos fechas hacen visible la antigüedad
Selector mal apuntado guarda un número que no es el precio Dato falso, indistinguible de uno bueno Validación de rango plausible y botón "Probar" antes de guardar la config (§7.1)
Anti-bot o bloqueo por IP Se pierde la cobertura de un competidor Deja de ser problema propio: la rotación la resuelve ScrapeOps (D26); la frecuencia por competidor modera la carga igual
Costo de créditos mayor al previsto Gasto que crece con el universo relevado render_js desactivado por defecto y preferencia por json_ld (§7.3); frecuencia por competidor y exclusión de productos sin stock como control de volumen; límite por ráfaga (D16)
URL cargada en el modal de alta queda sin verificar Se releva una página que quizá no es el producto Solo las active entran al relevamiento y al consumo externo (§8.5); las pendientes quedan en bandeja visible desde la pestaña (§7.2)
Página muerta relevada indefinidamente Ruido y pedidos inútiles al sitio La cadencia por competidor ya la espacia; se señala como rota para revisión humana (§7.3)
Carga masiva por CSV concentra todo el universo en un mismo día de vencimiento Picos periódicos de pedidos contra el sitio Documentado en §7.3; el límite por ráfaga (D16) es la corrección si aparece
Costo de curación de URLs El universo relevado no crece El alta queda incorporada al flujo donde el producto nace (§7.2.1), que es donde el costo marginal es menor

11. Decisiones abiertas

Quedan dos, y ninguna bloquea el modelo de datos.

  1. Cuáles son los 2-3 competidores concretos. No hace falta para diseñar: todo lo que varía entre sitios ya está parametrizado — modo de obtención, formato numérico, cadena de reglas. Hace falta para el piloto: recién con páginas reales se sabe si la extracción funciona, cuánta cobertura de json_ld hay y qué valores tomar en el punto 2.
  2. Valores iniciales de los parámetros de §7.4 — variación máxima aceptada, días para marcar rota y umbral de alerta por cambio de layout. Salen de mirar los sitios reales, no de decidirlos en abstracto.

12. Estado de la implementación

Actualizado 2026-08-26. El plan de implementación por bloques y su checklist viven fuera del repositorio, en docs/_borradores/precios-competencia/ (carpeta de trabajo no versionada).

12.1 Construido y verificado

Bloque Qué quedó Verificación
Fundaciones config/competitor_prices.php (10 parámetros), canal de log COMPETITOR_PRICES Config y canal probados en ejecución
Modelo de datos competitors + competitor_prices en database/migrations/74, modelos Competitor y CompetitorPrice Esquema verificado en MariaDB; los dos índices únicos rechazan los duplicados
Motor de extracción ExtractionEngine + 4 clases de regla + ExtractionConfigValidator + PageDocument 3 fixtures en resources/fixtures/competitors/; validador probado contra 8 errores de configuración
Obtención PageFetcher + FetchResult, ScrapeOps con concurrencia acotada 5 desenlaces probados con handler simulado; concurrencia medida (8 URLs: 8 s → 2 s)
Normalización ValueNormalizer + PlausibilityGuard Tabla de casos de parseo, conversión y rechazo
ABM + probador CompetitorController, Livewire\Competitors\CompetitorForm Vistas compilan; ida y vuelta formulario ↔ extraction_config
Vinculación UrlLinker, pestaña «Competencia» del product_item 6 casos de pertenencia de dominio, incluido el ataque nissei.com.ajeno.io
Relevamiento diario SurveyRunner + competitor:survey, en el scheduler a las 05:00 Invariante verificado: en los 5 desenlaces de fallo el precio queda intacto
Consumo externo docs/integraciones/precios-competencia.md —

Comandos nuevos: competitor:survey (corrida diaria, con --dry-run, --competitor, --url, --limit) y competitor:test-extraction (probador por CLI sobre fixture o URL).

12.2 Decisiones que se cerraron al implementar

Ninguna contradice el diseño; todas lo precisan.

# Decisión Resolución
D29 Cotización para USD→PYG Cotización cruda (ExchangeService::getExchangeRates()['salePrice']), no USDToPYG(): ese método suma un ajuste comercial fijo de +50 Gs que, aplicado a un precio ajeno, introduce un sesgo sistemático de ~0,8%. ExchangeService no se modifica
D30 Cómo se mantiene url_hash Columna generada STORED por la base (SHA2(url,256), charset ascii). Verificado: un UPDATE por SQL crudo recalcula el hash igual. Imposible de desincronizar
D31 El formato numérico depende de la regla ganadora schema.org publica el precio en forma canónica aunque el sitio muestre otra: 215.50 en JSON-LD y US$ 215,50 en pantalla. Aplicar los separadores del competidor al valor de json_ld lo multiplicaría por 100. Por eso ValueNormalizer recibe el ExtractionResult, no un string
D32 Desenlaces del relevamiento Cinco, no tres. Se separan fallo_red_permanente, fallo_extraccion y valor_implausible: juntarlos destruye la señal de cambio de layout, que es lo único que distingue «el sitio se cayó» de «el sitio cambió»
D33 Reintentos de la corrida Por rondas, no por URL. Los transitorios de una ronda se reintentan en la siguiente; los permanentes (404/410) no se reintentan
D34 Mecanismo de concurrencia GuzzleHttp\Pool, no Http::pool: PageFetcher ya usa Guzzle y así reusa cliente, manejo de códigos y contador de créditos
D36 Precisión de los importes Enteros, sin decimales, a diferencia del resto de las columnas de precio del sistema (decimal(12,2)). El guaraní no tiene subunidad, y los sitios Magento publican el precio sin redondear —Nissei emite 4621999.998001 para un producto que muestra como 4.622.000—: conservar decimales guardaría un número que no coincide con el que exhibe la página. Se redondea después de convertir de USD, nunca antes
D35 Resolución del stock Una consulta aparte a product_item_view (channel = 'Compulandia'), no un join en la consulta principal ni forProduct() por ítem. La vista es pesada (ADR-0002)

12.3 Pendiente

  • Piloto. Falta definir los 2-3 competidores concretos (§11.1) y, con páginas reales, fijar los valores de los parámetros de §7.4. Hoy corren con defaults razonables.
  • Paso «Competencia» en el modal de productos pendientes (§7.2.1). Fuera del camino crítico: el módulo funciona con la pestaña del producto. Es el bloque de mayor riesgo de implementación —ConfirmationModal tiene 3.156 líneas y una máquina de pasos por match— y su ausencia es lo que limita el crecimiento del universo relevado.
  • Carga masiva por CSV (§7.2.3). Fuera del camino crítico.
  • Tests unitarios. La regresión hoy la cubre competitor:test-extraction --fixture=…, que sí viaja en el PR; tests/ está en .gitignore.

13. Referencias

  • app/Services/Webscraping/PageScrapingService.php — scraping existente; referencia del uso de ScrapeOps y sus costos por crédito
  • config/services.php — scraperops.token, desde SCRAPEOPS_API_KEY
  • app/Console/Commands/SyncComprasParaguaiProducts.php — consumo del scraping existente
  • app/Models/ProductoCompraParaguai.php — persistencia del scraping existente
  • app/Services/CambiosChaco/ExchangeService.php — cotización
  • app/Models/Price.php, app/Models/PriceList.php — precios propios (colisión de nombre)
  • app/Models/ProductItemView.php — resolución de stock del producto (§7.3)
  • app/Livewire/PendingProducts/ConfirmationModal.php — modal de alta de productos (§7.2.1)
  • resources/views/components/product-items/tabs.blade.php — pestañas del producto (§7.2.2)
  • docs/analisis/pending-product-confirmation-flow.md — flujo de confirmación de pendientes
  • config/logging.php y app/Constants/LogChannels.php — canal de log propio (§7.3)
  • docs/estandares/job-implementation-standards.md — estándar de jobs
  • docs/estandares/ui-design-standards.md — estándar de UI
  • ADR-0001 — migraciones en subdirectorios numerados
  • ADR-0002 — lookup scopeado sobre product_item_view
  • ADR-0003 — política de reintentos de jobs