# Integración DataForSEO Merchant Google Products

## Objetivo

Esta integración obtiene diariamente el precio de productos específicos de la canasta básica de México usando DataForSEO **Merchant API > Google Products** en modo **task-based / async**.

El sistema **no** calcula promedios. Solo guarda el precio del resultado que mejor coincide con un `store_product_target` configurado. Si no hay suficiente confianza, registra un fallo auditado y no guarda un precio válido.

## Comportamiento de la API Merchant Google Products

La API **puede devolver `url` y `domain` como `null`**, lo cual es completamente normal en el endpoint Merchant. Campos importantes:

- `url` → URL directa del producto en la tienda. **Puede ser null.**
- `domain` → Dominio de la tienda. **Puede ser null.**
- `shopping_url` → URL proxy de Google Shopping (ej. `google.com.mx/search?ibp=oshop...`). **No es la URL real de la tienda.** Solo se usa para auditoría y para comparar con `expected_shopping_url_contains`.
- `rank_absolute` → Posición absoluta del resultado. Es el campo principal para ranking.
- `rank_group` → Grupo de posición. Campo secundario de ranking.
- `position` → Cadena como `"left"` o `"right"`. **No se usa para scoring ni ranking.**
- `data_docid` → Identificador interno de Google Shopping. Señal fuerte si está configurado.
- `product_id` → ID del producto. Señal fuerte si está configurado.
- `gid` → Identificador de grupo de productos. Señal fuerte si está configurado.

## Resumen de arquitectura

### Flujo en dos etapas

1. **Dispatch**
   - Command: `dataforseo:dispatch-tasks`
   - Crea un `price_sync_run`
   - Toma targets activos
   - Envía lotes a `POST /v3/merchant/google/products/task_post`
   - Guarda `provider_task_id` en `price_sync_tasks`

2. **Collect**
   - Command: `dataforseo:collect-results`
   - Busca tasks `pending/submitted/processing`
   - Consulta `GET /v3/merchant/google/products/task_get/advanced/{id}`
   - Normaliza resultados
   - Construye mapa de `knownSellerAliases` desde todas las `stores` activas
   - Ejecuta matching por `seller`/`title`/`keywords`/`ranking`/`data_docid`/`shopping_url`
   - Guarda observación o fallo
   - Actualiza el resumen del run

### Scheduler

Configurado en `routes/console.php`:

- `dataforseo:dispatch-tasks --limit=72` → diario a las `02:00`
- `dataforseo:collect-results --limit=150` → cada `30 minutos`

En cPanel el cron real debe seguir ejecutando:

```sh
* * * * * cd /home/usuario/proyecto && php artisan schedule:run >> /dev/null 2>&1
```

## Tablas

### `basic_basket_products`
Catálogo base de productos de canasta básica.

Campos clave:
- `slug`
- `name`
- `category`
- `expected_unit`
- `is_active`

### `stores`
Catálogo de tiendas objetivo.

Campos clave:
- `slug`
- `name`
- `domains` (JSON)
- `aliases` (JSON) — incluye variantes del nombre como las usa DataForSEO en el campo `seller`

### `store_product_targets`
Relaciona producto base + tienda + reglas de búsqueda y matching.

Campos clave:
- `basic_basket_product_id`
- `store_id`
- `slug`
- `search_query`
- `expected_url` — URL exacta del producto; **solo aplica si existe `directUrl` real de tienda**
- `expected_url_contains` (JSON) — fragmentos de URL de tienda; **solo aplica contra `directUrl`**
- `expected_shopping_url_contains` (JSON) — fragmentos comparados contra `shopping_url` de Google Shopping
- `expected_title`
- `expected_seller`
- `expected_domain`
- `expected_position` — representa `rank_absolute`, no el string `"left"`/`"right"`
- `expected_rank_group` — representa `rank_group`
- `expected_data_docid` — señal fuerte complementaria; no es la única señal, puede cambiar
- `expected_product_id`
- `expected_gid`
- `required_keywords` (JSON)
- `optional_keywords` (JSON)
- `negative_keywords` (JSON)
- `selection_strategy`
- `min_match_score`
- `is_active`

### `price_sync_runs`
Audita cada corrida.

Estados:
- `running`
- `completed`
- `completed_with_errors`
- `failed`
- `cancelled`

### `price_sync_tasks`
Guarda cada tarea enviada a DataForSEO.

Estados:
- `pending`
- `submitted`
- `processing`
- `completed`
- `failed`
- `empty`
- `expired`

### `price_observations`
Solo observaciones exitosas con precio válido. Incluye campos de auditoría de Google Shopping:
- `shopping_url` — URL proxy de Google Shopping
- `rank_group`, `rank_absolute` — posición del resultado
- `data_docid`, `product_id`, `gid` — identificadores de Google Shopping

### `price_observation_failures`
Guarda fallos, ambigüedades y respuestas sin match válido. Incluye:
- `best_candidate_rank_group`, `best_candidate_rank_absolute`
- `best_candidate_data_docid`, `best_candidate_product_id`, `best_candidate_gid`
- `best_candidate_shopping_url`
- `best_candidate_normalized_url` — URL normalizada (sin www, sin tracking) del mejor candidato; solo desde `directUrl`

## Normalización de resultados (DataForSeoResultNormalizer)

El normalizador extrae por separado:

- `directUrl` — URL real de la tienda. Puede ser null en Merchant API.
- `shoppingUrl` — URL proxy de Google Shopping. **Nunca se usa para inferir el dominio de la tienda.**
- `url` — `directUrl ?? shoppingUrl` (solo para auditoría y compatibilidad).
- `normalizedUrl` — URL normalizada (sin www, sin tracking) solo desde `directUrl`.
- `domain` — solo desde `directUrl` o del campo `domain` del payload. **Nunca desde `shoppingUrl`.**
- `rankAbsolute` — campo principal de posición.
- `rankGroup` — campo secundario de posición.
- `position` — campo legacy = `rankAbsolute` (para compatibilidad).
- `dataDocid`, `productId`, `gid` — identificadores de Google Shopping.

## Matching (TargetProductMatcher)

### Señal de tienda equivocada

`TargetProductMatcher::match()` recibe un parámetro adicional `$knownSellerAliases`:

```
array<string, list<string>>  // store-slug => [nombre, alias1, alias2, ...]
```

Este mapa se construye en `PriceSyncService` consultando `Store::query()->active()->get()` **una sola vez** antes del loop de collect. El matcher no consulta la base de datos.

Si el `seller` del candidato coincide con aliases de **otra tienda conocida**, se aplica `-60` y el candidato queda descartado aunque el título sea similar.

### Señales ponderadas

| Señal | Puntos |
|-------|--------|
| Seller coincide con tienda objetivo | +45 |
| Seller de otra tienda conocida | -60 |
| Seller no coincide (sin tienda conocida) | -15 |
| `expected_data_docid` coincide | +35 |
| `expected_product_id` coincide | +30 |
| `expected_gid` coincide | +30 |
| Título exacto coincide | +30 |
| Título parcial coincide | +18 |
| Todas las `required_keywords` coinciden | +25 |
| `required_keywords` parciales | proporcional hasta +15 |
| `optional_keywords` | hasta +10 |
| `negative_keywords` detectadas | -20 por keyword, máximo -60 |
| `expected_rank_group` coincide con `rank_group` | +20 |
| `expected_position` coincide con `rank_absolute` (exacto) | +20 |
| `expected_position` cercano a `rank_absolute` (distancia 1) | +10 |
| `expected_position` cercano a `rank_absolute` (distancia 2) | +5 |
| `expected_url` coincide contra `directUrl` (solo si directUrl ≠ null) | +40 |
| `expected_url_contains` contra `directUrl` (solo si directUrl ≠ null) | proporcional hasta +25 |
| `expected_shopping_url_contains` contra `shoppingUrl` | proporcional hasta +15 |
| Dominio inferido coincide | +15 |
| Dominio inferido no coincide | -20 |
| Sin precio | -10 |
| Moneda ≠ MXN | -10 |

### Reglas importantes

- `expected_position` se compara contra `rank_absolute`, **no contra el string `"left"`/`"right"`**.
- `expected_rank_group` se compara contra `rank_group`.
- `position` del string DataForSEO (`"left"`, `"right"`) **no participa en scoring**.
- Si `directUrl` es null, **no se penaliza** por tener `expected_url` configurado. Es comportamiento normal de Merchant API.
- `expected_shopping_url_contains` se compara **únicamente contra `shoppingUrl`**.
- `domain` nunca se extrae de `shoppingUrl` (`google.com.mx` no es el dominio de la tienda).

### Deduplicación

Antes del scoring se dedupliclican resultados con el mismo producto usando:
1. `data_docid`
2. `product_id`
3. `gid`
4. `title + seller + price` (fallback)

Se prioriza `google_shopping_serp` sobre `google_shopping_carousel_element` cuando ambos representan el mismo producto. Esto evita marcar `ambiguous` por duplicados.

### Estado de matching

- `matched`
- `not_matched`
- `ambiguous`
- `empty_results`
- `api_error`

## Seeders

### Seeders incluidos

- `BasicBasketProductSeeder`
- `StoreSeeder`
- `StoreProductTargetSeeder`

Todos están registrados en `DatabaseSeeder`.

### Productos iniciales

Se cargan **24 productos** base.

### Tiendas iniciales

- `bodega-aurrera` (aliases: Bodega Aurrera Despensa a tu Casa, Bodega Aurrera en línea)
- `soriana` (aliases: Soriana.com, Soriana Súper, Soriana Mercado)
- `chedraui` (aliases: Chedraui.com.mx, Chedraui en línea)

### Targets iniciales

Se generan **72 targets** (`24 productos × 3 tiendas`).

## Cómo editar manualmente el catálogo

### Agregar o actualizar productos base

Editar `database/seeders/BasicBasketProductSeeder.php`:

- agrega un nuevo elemento al arreglo `$products`
- usa un `slug` estable
- cambia `is_active` a `false` para desactivarlo sin borrar histórico

### Agregar o actualizar tiendas

Editar `database/seeders/StoreSeeder.php`:

- agrega la tienda al arreglo `$stores`
- define `domains` y `aliases` con **todos los nombres que usa DataForSEO como `seller`**
- usa `slug` estable

### Afinar targets por producto/tienda

Editar `database/seeders/StoreProductTargetSeeder.php`:

- `productProfiles()` para términos base, keywords y fragmentos genéricos
- `storeProfiles()` para prefijos por tienda
- `targetOverrides()` para ajustes finos por target específico

#### Campos disponibles en `targetOverrides()`

| Campo | Descripción |
|-------|-------------|
| `search_query` | Query de búsqueda que reemplaza por completo al `query_prefix + search_phrase` genérico |
| `expected_seller` | Nombre del seller exactamente como lo devuelve DataForSEO |
| `expected_title` | Título exacto o cercano del producto en esa tienda |
| `expected_position` | `rank_absolute` esperado |
| `expected_rank_group` | `rank_group` esperado |
| `expected_data_docid` | `data_docid` de Google Shopping (complementario; puede cambiar entre semanas) |
| `expected_product_id` | `product_id` si está disponible |
| `expected_gid` | `gid` si está disponible |
| `expected_shopping_url_contains` | Fragmentos comparados contra `shopping_url` de Google Shopping |
| `expected_url` | URL directa exacta del producto en la tienda (solo si existe `directUrl` real) |
| `expected_url_contains` | Fragmentos de URL de tienda (se **suman** a los genéricos de `productProfiles` y al dominio) |
| `expected_domain` | Dominio esperado del resultado |
| `required_keywords` | Sobreescribe los del `productProfile` |
| `optional_keywords` | Sobreescribe los del `productProfile` |
| `negative_keywords` | Sobreescribe los del `productProfile` |
| `min_match_score` | Umbral mínimo de score (por defecto: 80) |
| `is_active` | Activa o desactiva este target específico |
| `location_name` | Nombre de ubicación para la query (sobreescribe el global) |
| `language_code` | Código de idioma (sobreescribe el global) |

```php
'tortilla-maiz-kilo@bodega-aurrera' => [
    // Query de búsqueda diferente a la genérica (opcional)
    'search_query'       => 'bodega aurrera tortillas de maiz 1 kg',
    // Identificación del resultado esperado
    'expected_seller'    => 'Bodega Aurrera Despensa a tu Casa', // exactamente como aparece en DataForSEO
    'expected_title'     => 'Tortilla de maíz por kilo',
    'expected_position'  => 2,   // rank_absolute esperado
    'expected_rank_group' => 2,  // rank_group esperado
    'expected_data_docid' => '1712851062477582571', // complementario, puede cambiar
    'expected_product_id' => null,
    'expected_gid'        => null,
    'expected_shopping_url_contains' => [
        'headlineOfferDocid:1712851062477582571',
    ],
    // Para URL real de tienda (cuando exista directUrl):
    'expected_url'          => null,
    'expected_url_contains' => [],
    // Keywords (sobreescriben los del productProfile)
    'required_keywords' => ['tortilla', 'maiz', 'kilo'],
    'optional_keywords' => ['masa'],
    'negative_keywords' => ['azul', 'amarilla', 'taquera', 'mission', 'harina', 'paquete'],
    'min_match_score'   => 80,
],
```

**Notas:**
- `expected_data_docid` es complementario: ayuda mucho cuando está presente, pero **no debe ser la única señal**. DataForSEO puede cambiar este valor entre semanas.
- `expected_position` y `expected_rank_group` representan `rank_absolute` y `rank_group` respectivamente, nunca el string `"left"`/`"right"`.
- `expected_url` y `expected_url_contains` solo aplican si el resultado trae `url` directa de tienda (no siempre ocurre en Merchant API).
- `expected_shopping_url_contains` aplica contra la URL de Google Shopping (`shopping_url`).

## Variables de entorno

Definidas en `config/dataforseo.php` y documentadas en `.env.example`.

Variables clave:

- `DATAFORSEO_ENABLED`
- `DATAFORSEO_LOGIN`
- `DATAFORSEO_PASSWORD`
- `DATAFORSEO_BASE_URL`
- `DATAFORSEO_LOCATION_NAME`
- `DATAFORSEO_LANGUAGE_CODE`
- `DATAFORSEO_MAX_TASKS_PER_RUN`
- `DATAFORSEO_BATCH_SIZE`
- `DATAFORSEO_TIMEOUT`
- `DATAFORSEO_SAVE_RAW_RESPONSE`
- `DATAFORSEO_MIN_MATCH_SCORE`
- `DATAFORSEO_COLLECT_DELAY_MINUTES`
- `DATAFORSEO_MAX_TASK_AGE_HOURS`

## Commands

### Despachar tareas

```sh
php artisan dataforseo:dispatch-tasks
php artisan dataforseo:dispatch-tasks --store=chedraui --limit=10
php artisan dataforseo:dispatch-tasks --product=tortilla-maiz-kilo --dry-run
```

Opciones:
- `--target=`
- `--store=`
- `--product=`
- `--limit=`
- `--dry-run`

### Recolectar resultados

```sh
php artisan dataforseo:collect-results
php artisan dataforseo:collect-results --run=1 --debug-candidates
php artisan dataforseo:collect-results --store=chedraui --limit=20
```

Opciones:
- `--run=`
- `--target=`
- `--store=`
- `--product=`
- `--limit=`
- `--dry-run`
- `--debug-candidates`

## Pruebas manuales con un solo producto

Para probar sin esperar el ciclo completo de 72 targets, usa el filtro `--product`:

```sh
# 1. Despachar solo los 3 tasks de un producto (1 por tienda)
php artisan dataforseo:dispatch-tasks --product=tortilla-maiz-kilo

# 2. Esperar ≥ DATAFORSEO_COLLECT_DELAY_MINUTES (default: 20 min)

# 3. Ver candidatos evaluados sin guardar nada
php artisan dataforseo:collect-results --product=tortilla-maiz-kilo --dry-run

# 4. Recolectar y guardar, con detalle de candidatos en price_observation_failures
php artisan dataforseo:collect-results --product=tortilla-maiz-kilo --debug-candidates
```

> **Nota sobre el delay:** `DATAFORSEO_COLLECT_DELAY_MINUTES` (default: `20`) es el tiempo mínimo que debe haber pasado desde el dispatch para que el collect procese una task. Si ejecutas `collect-results` antes de ese tiempo, la tabla mostrará `0` en todas las columnas aunque las tasks existan. Puedes reducirlo temporalmente en `.env` para pruebas:
> ```
> DATAFORSEO_COLLECT_DELAY_MINUTES=5
> ```

## Manejo de errores

### Si el provider devuelve task sin resultado aún
La tarea queda en `processing` y se intenta otra vez en la siguiente corrida del scheduler.

### Si el provider nunca entrega resultado útil
La tarea puede terminar en:
- `failed`
- `empty`
- `expired`

Y se registra detalle en `price_observation_failures`.

### Si hay dos candidatos muy parecidos
Se marca `ambiguous` y se guardan candidatos principales si `--debug-candidates` está activo.

La ambigüedad se evita cuando los dos candidatos representan **el mismo producto** (mismo `data_docid`, `product_id`, `gid`, o misma combinación title+seller+price). En ese caso se deduplica y se elige el de tipo `google_shopping_serp`.

## Consideraciones de cPanel

Esta solución está diseñada para:

- no usar workers permanentes
- no usar Chrome/Chromium
- no usar Puppeteer/Playwright
- operar solo con HTTP desde Laravel
- tolerar timeouts mediante tareas async
- reintentar en ejecuciones futuras sin perder estado

## Recomendaciones operativas

1. Cargar credenciales reales de DataForSEO en `.env`
2. Ejecutar migraciones
3. Ejecutar seeders
4. Verificar que `stores` tenga los `aliases` correctos (nombres exactos como aparecen en DataForSEO campo `seller`)
5. Completar `targetOverrides` con `expected_seller`, `expected_position`, `expected_rank_group` y `expected_data_docid` cuando se conocen
6. Probar primero con `--dry-run`
7. Después probar dispatch real y collect manual

## Archivos principales

- `config/dataforseo.php`
- `app/Services/PriceSyncService.php`
- `app/Services/DataForSeo/DataForSeoClient.php`
- `app/Services/DataForSeo/DataForSeoResultNormalizer.php`
- `app/Services/DataForSeo/NormalizedResult.php`
- `app/Services/DataForSeo/UrlNormalizer.php`
- `app/Services/DataForSeo/TargetProductMatcher.php`
- `app/Console/Commands/DataForSeoDispatchTasks.php`
- `app/Console/Commands/DataForSeoCollectResults.php`
- `database/seeders/BasicBasketProductSeeder.php`
- `database/seeders/StoreSeeder.php`
- `database/seeders/StoreProductTargetSeeder.php`

## Estado actual de la base técnica

La base técnica ya soporta:

- catálogo inicial por seeders (72 targets = 24 productos × 3 tiendas)
- task dispatch async
- task collect async
- matching principal por `seller` cuando `url`/`domain` son null (caso habitual en Merchant API)
- matching complementario por `data_docid`, `product_id`, `gid`, `rank_absolute`, `rank_group`, `shopping_url_contains`
- rechazo por tienda equivocada usando aliases de todas las stores activas
- deduplicación de SERP vs carousel_element
- auditoría de runs/tasks/observaciones/fallos con campos completos de Google Shopping (`data_docid`, `product_id`, `gid`, `shopping_url`, `rank_group`, `rank_absolute`, `normalized_url`)
- tests unitarios y feature cubriendo todos los escenarios

### Migraciones de shopping fields (2026-05-25)

Se añadieron campos de Google Shopping a tres tablas mediante las migraciones:
- `2026_05_25_213133_add_shopping_fields_to_store_product_targets` → agrega `expected_rank_group`, `expected_data_docid`, `expected_product_id`, `expected_gid`, `expected_shopping_url_contains`
- `2026_05_25_213134_add_shopping_fields_to_price_observations` → agrega `shopping_url`, `rank_group`, `rank_absolute`, `data_docid`, `product_id`, `gid`
- `2026_05_25_213135_add_shopping_fields_to_price_observation_failures` → agrega `best_candidate_rank_group`, `best_candidate_rank_absolute`, `best_candidate_data_docid`, `best_candidate_product_id`, `best_candidate_gid`, `best_candidate_shopping_url`, `best_candidate_normalized_url`

Lo más importante para afinar precisión en producción es:
1. Completar los `aliases` de `StoreSeeder` con exactamente los nombres que usa DataForSEO en el campo `seller`.
2. Agregar `expected_data_docid` y `expected_rank_group` en `targetOverrides` para los productos críticos.
