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
5. Cruzar con nuestro catálogo¶
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
21es el umbral de "rota" configurado enconfig/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.