Saltar a contenido

Precios de competencia — guía de consumo

Para quién es este documento: para quien va a leer los precios de la competencia desde afuera del Integrador —una planilla, un notebook, una herramienta de BI— y necesita saber cómo interpretarlos.

Relacionado con: RFC 008 · ADR-0005 · definición de requisitos


1. Qué hay y qué no

El Integrador registra, por producto y competidor, el último precio observado en la página de ese producto en el sitio del competidor. Nada más.

Lo que hay:

  • El último precio de venta y el último precio de lista observados, en guaraníes.
  • La disponibilidad declarada por el sitio del competidor.
  • El título leído de la página, como control de identidad.
  • Dos fechas: cuándo se intentó relevar por última vez, y cuándo se logró.

Lo que NO hay, y no va a haber:

No existe Por qué
Historial de precios Se guarda estado, no evolución. Si hace falta la serie temporal, se construye del lado de quien consume, guardando lo que lee cada día
Nuestro precio de venta ni ninguna comparación El módulo observa; comparar es del lado de quien consume. Cruzar con nuestro precio es un join que se hace afuera
Alertas ante cambios de precio Fuera de alcance
Vista SQL, endpoint o export Se leen las tablas directamente. Es una decisión, no una omisión (ADR-0005, decisión 3)
Columna de estado de error El estado se deriva de las dos fechas (§4)
Motivo del último fallo Vive en el log del sistema, no en la base
La cotización usada al convertir de USD El resultado buscado es el importe en guaraníes

No se construye superficie de salida a propósito. Una vista SQL parece inofensiva, pero se convierte en un contrato: cambiarla rompe a quien la consume. Las tablas son el contrato, y este documento es su explicación.


2. Las dos tablas

competitors — los sitios observados

Columna Para qué sirve al consumir
id Clave para unir con competitor_prices
name, code Identificación legible. code es corto y estable
base_domain El sitio
currency Moneda en que publica el sitio. Los precios ya vienen convertidos a PYG, esto es sólo informativo
frequency_days Cada cuántos días se releva. Es lo que define qué es un dato "fresco" para este competidor
active Si está en 0, dejó de relevarse. Sus datos siguen visibles con su fecha

El resto de las columnas (extraction_config, render_js, fetch_options, separadores) son configuración operativa del relevamiento. No aportan nada al análisis.

competitor_prices — una fila por producto × competidor

Columna Tipo Notas para el análisis
competitor_id FK Une con competitors.id
product_item_id FK La clave para cruzar con nuestro catálogo (§5)
url varchar(500) La página observada
status enum active / pending_verification / paused. Sólo active es publicable (§3)
title varchar(255) Título leído de la página del competidor. Control de identidad
sale_price decimal(12,0) En guaraníes, entero. El precio que paga el cliente hoy en ese sitio
list_price decimal(12,0) En guaraníes, entero. El precio tachado, cuando el sitio lo muestra. Suele ser NULL
availability enum in_stock / out_of_stock / unknown. Puede ser NULL
last_attempt_at datetime Último intento de relevamiento
last_success_at datetime Último relevamiento exitoso. La fecha del precio que estás leyendo

url_hash es interna (garantiza que una misma página no se asigne a dos productos). Ignorala.


3. Las cinco reglas de lectura

Regla 1 · Filtrar siempre por status = 'active'

Es la más importante. Las otras dos situaciones no son observaciones publicables:

  • pending_verification — la URL se cargó pero nadie confirmó que esa página sea de ese producto. Puede apuntar a otro producto. Sus precios pueden estar vacíos o ser de una página equivocada.
  • paused — alguien decidió dejar de relevarla. El precio que tiene es viejo por decisión, no por falla.
WHERE cp.status = 'active'

Regla 2 · Los precios están en guaraníes, enteros, siempre

sale_price y list_price ya están convertidos. Si el competidor publica en dólares, la conversión se hizo con la cotización de venta del día del relevamiento, sin ajustes comerciales. No apliques ninguna conversión adicional.

Sin decimales. El guaraní no tiene subunidad, así que los importes se guardan redondeados al entero — igual que el número que muestra la página del competidor. Vale saber por qué importa: varios sitios (los que corren Magento) publican el precio en sus metadatos como resultado de un cálculo sin redondear, del tipo 4621999.998001, para un producto que en pantalla dice 4.622.000. Se guarda el redondeado, que es el precio real y el comparable.

Regla 3 · La fecha del precio es last_success_at, no updated_at

updated_at cambia en cada intento, exitoso o no. last_success_at es la única fecha que dice cuándo se observó realmente el precio que estás leyendo.

Regla 4 · Un precio nunca es NULL por un fallo

Si el relevamiento falla, el sistema conserva el último valor conocido y no lo pisa. Eso significa que un precio siempre es un precio que alguna vez fue real — pero puede ser viejo. Cuán viejo, lo dice last_success_at.

Corolario: nunca supongas que un dato presente es un dato actual. Mirá siempre la fecha.

Regla 5 · El competidor debe estar activo

Un competidor desactivado dejó de relevarse. Sus filas conservan el último precio, que se irá poniendo viejo indefinidamente.

WHERE c.active = 1

4. El estado del relevamiento se deriva de las dos fechas

No hay columna de estado. Se deduce comparando last_attempt_at con last_success_at:

Estado Condición Qué significa
Vigente last_attempt_at = last_success_at El último intento salió bien. El precio es de esa fecha
Con error last_attempt_at <> last_success_at El último intento falló. El precio mostrado es el anterior, y sigue siendo válido — sólo es más viejo
Sin relevar last_attempt_at IS NULL Fila nueva, todavía no le tocó su turno
Rota last_success_at IS NULL, o más vieja que el umbral configurado (por defecto 21 días) Hace mucho que no se logra relevar. Está señalada para revisión humana, pero se sigue intentando

Antigüedad del precio = DATEDIFF(NOW(), last_success_at).

Por qué no hay columna de estado. Una columna de estado o un contador de fallos pueden quedar desincronizados del hecho que representan; una fecha no. El estado se calcula, no se guarda (ADR-0005, decisión 2).

Qué es "fresco" depende del competidor

No hay un umbral universal: cada competidor tiene su frequency_days. Un dato de 5 días es normal en un competidor con frecuencia 7, y sospechoso en uno con frecuencia 1.

-- Un dato es "atrasado" si supera el doble de la frecuencia de su competidor
DATEDIFF(NOW(), cp.last_success_at) > c.frequency_days * 2

La clave es product_item_id, que apunta a product_items.id — la variante de producto, no el producto padre.

SELECT pi.sku, pi.short_description, pi.ean
FROM product_items pi
WHERE pi.id = cp.product_item_id

Para nuestro precio de venta y nuestro stock, la fuente es product_item_view (unida por product_item_view.id = product_items.id, filtrando por channel). Ese cruce y la comparación que salga de él son enteramente del lado de quien consume: el Integrador no entrega precios propios ni cálculos comparativos.


6. Consultas de ejemplo

6.1 La consulta base — último precio observado, sólo lo publicable

SELECT
    c.code                                        AS competidor,
    pi.sku,
    pi.short_description                          AS producto,
    cp.title                                      AS titulo_en_el_competidor,
    cp.sale_price                                 AS precio_pyg,
    cp.list_price                                 AS precio_lista_pyg,
    cp.availability                               AS disponibilidad,
    cp.last_success_at                            AS observado_el,
    DATEDIFF(NOW(), cp.last_success_at)           AS antiguedad_dias,
    cp.url
FROM competitor_prices cp
JOIN competitors  c  ON c.id  = cp.competitor_id
JOIN product_items pi ON pi.id = cp.product_item_id
WHERE cp.status = 'active'          -- Regla 1
  AND c.active  = 1                 -- Regla 5
  AND cp.last_success_at IS NOT NULL
ORDER BY pi.sku, c.code;

6.2 Con el estado del relevamiento derivado

SELECT
    c.code AS competidor,
    pi.sku,
    cp.sale_price AS precio_pyg,
    cp.last_success_at AS observado_el,
    CASE
        WHEN cp.last_attempt_at IS NULL                       THEN 'sin_relevar'
        WHEN cp.last_success_at IS NULL                       THEN 'rota'
        WHEN DATEDIFF(NOW(), cp.last_success_at) > 21         THEN 'rota'
        WHEN cp.last_attempt_at <> cp.last_success_at         THEN 'con_error'
        ELSE 'vigente'
    END AS estado_relevamiento,
    DATEDIFF(NOW(), cp.last_success_at) AS antiguedad_dias
FROM competitor_prices cp
JOIN competitors  c  ON c.id  = cp.competitor_id
JOIN product_items pi ON pi.id = cp.product_item_id
WHERE cp.status = 'active' AND c.active = 1;

El 21 es el umbral de "rota" configurado en config/competitor_prices.php (COMPETITOR_BROKEN_AFTER_DAYS). Si se cambia allá, hay que cambiarlo acá: es la contrapartida de no tener una vista SQL que lo encapsule.

6.3 Un producto, una fila, un competidor por columna

Útil para la planilla de análisis. Hay que enumerar los competidores a mano, porque SQL no pivota columnas dinámicamente.

SELECT
    pi.sku,
    pi.short_description AS producto,
    MAX(CASE WHEN c.code = 'NIS' THEN cp.sale_price     END) AS nis_precio,
    MAX(CASE WHEN c.code = 'NIS' THEN cp.last_success_at END) AS nis_fecha,
    MAX(CASE WHEN c.code = 'STK' THEN cp.sale_price     END) AS stk_precio,
    MAX(CASE WHEN c.code = 'STK' THEN cp.last_success_at END) AS stk_fecha
FROM competitor_prices cp
JOIN competitors  c  ON c.id  = cp.competitor_id
JOIN product_items pi ON pi.id = cp.product_item_id
WHERE cp.status = 'active' AND c.active = 1
GROUP BY pi.id, pi.sku, pi.short_description;

6.4 Salud del relevamiento — para saber si confiar en los datos

SELECT
    c.code AS competidor,
    COUNT(*)                                                          AS urls_activas,
    SUM(cp.last_attempt_at = cp.last_success_at)                      AS vigentes,
    SUM(cp.last_attempt_at <> cp.last_success_at)                     AS con_error,
    SUM(cp.last_success_at IS NULL)                                   AS nunca_relevadas,
    ROUND(AVG(DATEDIFF(NOW(), cp.last_success_at)), 1)                AS antiguedad_media_dias,
    MAX(DATEDIFF(NOW(), cp.last_success_at))                          AS peor_antiguedad
FROM competitor_prices cp
JOIN competitors c ON c.id = cp.competitor_id
WHERE cp.status = 'active' AND c.active = 1
GROUP BY c.code;

Corré esta consulta antes de confiar en un análisis. Si un competidor tiene la antigüedad media disparada o muchos con_error, sus precios están congelados y compararlos lleva a conclusiones falsas.

6.5 Cobertura — cuántos productos tienen observación

SELECT
    c.code AS competidor,
    COUNT(DISTINCT cp.product_item_id) AS productos_observados,
    SUM(cp.status = 'pending_verification') AS urls_sin_verificar
FROM competitor_prices cp
JOIN competitors c ON c.id = cp.competitor_id
WHERE c.active = 1
GROUP BY c.code;

La segunda columna es trabajo pendiente del lado del Integrador: URLs cargadas que nadie confirmó todavía. No entran en los análisis (Regla 1), pero indican cuánto podría crecer la cobertura.


7. Errores de interpretación a evitar

Error Qué pasa Cómo evitarlo
Leer todas las filas sin filtrar status Se cuelan URLs sin verificar, que pueden ser de otro producto Regla 1, siempre
Usar updated_at como fecha del precio Cambia en cada intento fallido: parece fresco y no lo es Usar last_success_at
Suponer que un precio presente es actual Los fallos conservan el último valor Mirar la antigüedad
Buscar la serie histórica No existe: se guarda estado, no evolución Guardar snapshots del lado del consumidor
Comparar sin mirar availability Un precio out_of_stock es un precio que nadie puede pagar Filtrar o ponderar por disponibilidad
Suponer que faltan productos porque el módulo falló El universo lo definen las URLs cargadas a mano Consulta 6.5
Aplicar cotización a los precios Ya están en guaraníes Regla 2

8. Preguntas frecuentes

¿Por qué este producto no aparece? Porque nadie le cargó la URL en ese competidor. Tener URL cargada es la selección: no hay descubrimiento automático ni match por código de barras. Se carga desde la pestaña «Competencia» del producto en el Integrador.

¿Por qué el precio tiene tres semanas? Porque los últimos relevamientos fallaron. Comparar last_attempt_at con last_success_at lo confirma. El motivo está en el log del sistema; pedíselo al equipo de TI con el SKU y el competidor.

¿Puedo pedir una vista SQL o un endpoint? Se decidió deliberadamente no construirlos (ADR-0005). Si el patrón de consulta se repite mucho, lo razonable es guardar la consulta del lado de la herramienta de análisis, no crear un contrato nuevo en el Integrador.

¿Los precios incluyen impuestos o envío? Es lo que el sitio del competidor muestra en la página del producto. Ni más ni menos: lo que publica es lo que se guarda.

¿Qué pasa si el competidor cambia el diseño de su sitio? El sistema lo detecta —muchos fallos de extracción concentrados en un competidor— y lo avisa en el resumen de la corrida. Se corrige editando la configuración de ese competidor, sin desplegar código. Mientras tanto, sus precios se congelan y la antigüedad crece: la consulta 6.4 lo hace visible.