Files
adsPRO/.paul/phases/06-xml-feed-import/06-01-PLAN.md
2026-04-30 01:04:06 +02:00

319 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: 06-xml-feed-import
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- migrations/029_products_rename_columns_and_xml_feed.sql
- autoload/factory/class.Products.php
- autoload/controls/class.Products.php
- autoload/controls/class.Cron.php
- autoload/controls/class.Clients.php
- autoload/services/class.GoogleAdsApi.php
- autoload/services/class.XmlFeedImporter.php
- templates/clients/main_view.php
- templates/products/main_view.php
- templates/products/product_history.php
autonomous: false
delegation: off
---
<objective>
## Goal
Dodać obsługę feedu XML (Google Merchant) per klient: pole `xml_feed_url` w edycji klienta, parser XML odporny na duże feedy (kilka tysięcy pozycji), integrację z `cron_universal` która wzbogaca tabelę `products` o dane z feedu (title, description, price, custom_label_1) oraz dodaje nowe rekordy dla pozycji nieobecnych w Google Ads. Dodatkowo: refaktor schematu `products` — rename `name``title`, `title``title_gmc`, nowe pole `description_gmc` (rozdział danych z feedu od edytowanych/AI).
## Purpose
Klient ma jedno źródło prawdy o swoich produktach (feed XML producenta). Obecnie dane w `products` pochodzą wyłącznie z Google Ads i Merchant API i są ubogie. Wzbogacenie ich o feed pozwoli na: (1) trafniejsze sugestie AI dla title/description, (2) widoczność produktów spoza GA w panelu, (3) świeże ceny i custom_label_1 do segmentacji.
## Output
- Migracja SQL dodająca/renamująca kolumny i pole `xml_feed_url` w `clients`
- Service `\services\XmlFeedImporter` (streaming XMLReader, batched upserts)
- Hook w `Cron::cron_universal` wywołujący importer po sync produktów GA
- Pole `xml_feed_url` w dialogu edycji klienta (`templates/clients/main_view.php` + `controls/Clients.php`)
- Wszystkie odwołania do `products.name` / `products.title` zaktualizowane do nowej semantyki
</objective>
<context>
<clarifications>
- **Pole feed XML** — Jak przechowywać URL feedu XML w bazie?
→ Odpowiedź: Jedno pole `xml_feed_url` w tabeli `clients` (jeden feed na klienta).
- **Mapowanie** — Po czym łączyć wpisy z feedu z produktami w bazie?
→ Odpowiedź: Po `<g:id>` = `products.product_id` (standardowy mapping GMC).
- **Zakres pól** — Które pola z feedu XML uzupełniać w `products`?
→ Odpowiedź: `title`, `description` (rozdzielone od pola edytowalnego/AI — osobne `title_gmc` jeśli kolizja, `description_gmc` jako nowe pole), `custom_label_1`, `price`. Brakujące kolumny dorobić.
- **Rename schematu** — wymaganie dodatkowe z odpowiedzi:
`products.name``products.title` (rename). Stare `products.title` (z GMC API) → `products.title_gmc`. Wszystkie miejsca w kodzie, gdzie te kolumny występują, mają zostać zaktualizowane.
- **Brakujące w GA** — Co robić z pozycjami feedu nieobecnymi w `products`?
→ Odpowiedź: Dodawać nowe rekordy do `products`.
- **Skala** — wymaganie odporności:
→ Pobieranie musi działać dla feedów z kilkoma tysiącami produktów (streaming parser, brak ładowania całego XML do pamięci, batched insert/update, transakcje per batch, timeout-safe wznawianie).
</clarifications>
## Project Context
@.paul/STATE.md
@CLAUDE.md
## Source Files
@autoload/controls/class.Cron.php
@autoload/controls/class.Clients.php
@autoload/factory/class.Products.php
@autoload/controls/class.Products.php
@autoload/services/class.GoogleAdsApi.php
@templates/clients/main_view.php
@migrations/016_products_model_unification.sql
@migrations/028_products_custom_label_1.sql
</context>
<acceptance_criteria>
## AC-1: Schemat bazy danych
```gherkin
Given klient ma w UI dialog edycji
When wykonam migrację 029
Then tabela `clients` zawiera kolumnę `xml_feed_url VARCHAR(500) NULL`
And tabela `products` zawiera kolumny: `title` (po rename z `name`), `title_gmc` (po rename ze starego `title`), `description_gmc TEXT NULL`, `price DECIMAL(10,2) NULL`
And istniejący `custom_label_1` pozostaje bez zmian
And migracja jest idempotentna (drugie uruchomienie nie zmienia stanu)
```
## AC-2: Edycja klienta — pole feed XML
```gherkin
Given otwieram dialog edycji klienta w `/clients`
When wpisuję URL `https://pomysloweprezenty.pl/google-feed.xml` w polu "XML Feed URL" i zapisuję
Then wartość trafia do `clients.xml_feed_url`
And po ponownym otwarciu dialogu pole jest wypełnione
And puste pole zapisuje NULL (czyści feed)
```
## AC-3: Cron pobiera feed XML i wzbogaca products (odporność na 1000+ pozycji)
```gherkin
Given klient ma ustawiony `xml_feed_url`
When uruchamia się `cron_universal` dla tego klienta
Then `\services\XmlFeedImporter::import_for_client($client_id)` wykonuje się po sync produktów GA
And feed jest parsowany strumieniowo (XMLReader) bez ładowania całości do pamięci
And produkty są upsertowane w batchach (np. 200 wierszy per batch) w transakcjach
And dla pozycji `<g:id>` istniejącej w `products.product_id` aktualizowane są pola: `title_gmc`, `description_gmc`, `custom_label_1`, `price`
And dla pozycji nieistniejącej tworzony jest nowy rekord w `products` (z `client_id`, `product_id` = `<g:id>`, oraz pola jak wyżej)
And `products.title` i `products.description` (edytowalne/AI) NIE są nadpisywane
And błąd parsowania pojedynczej pozycji nie przerywa importu (log + skip)
And do `cron_sync_status` lub `settings` zapisywany jest timestamp ostatniego importu per klient
And import limity czasu/pamięci PHP są respektowane (max execution time bumped lokalnie, set_time_limit, gc_collect_cycles co batch)
```
## AC-4: Refaktor odwołań do `name` / `title` w kodzie
```gherkin
Given migracja 029 zmienia nazwy kolumn
When wszystkie pliki PHP/template są zaktualizowane
Then żadne zapytanie SQL ani odwołanie tablicowe nie używa `products.name` ani starego znaczenia `products.title`
And widok `/products` (templates/products/main_view.php) renderuje `title` (dawne `name`) jako głównej nazwy
And widok historii produktu pokazuje również `title_gmc` jeśli różny od `title`
And edycja produktu w `controls/Products.php` zapisuje do nowych kolumn
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Migracja schematu — rename kolumn products + nowe pola + xml_feed_url</name>
<files>migrations/029_products_rename_columns_and_xml_feed.sql</files>
<action>
Stwórz idempotentną migrację SQL w stylu istniejących (PREPARE/EXECUTE z guardem INFORMATION_SCHEMA):
1. Sprawdź czy `products.name` istnieje → jeśli tak, RENAME do `title_tmp_old` (bo `title` już istnieje i koliduje).
Lepsze podejście (kolejność krytyczna):
- Jeśli `products.title` istnieje I `products.title_gmc` NIE istnieje → `ALTER TABLE products CHANGE title title_gmc VARCHAR(255) NULL DEFAULT NULL`.
- Jeśli `products.name` istnieje I `products.title` NIE istnieje (po poprzednim renamie) → `ALTER TABLE products CHANGE name title VARCHAR(255) NULL DEFAULT NULL`.
2. Dodaj `description_gmc TEXT NULL` po `description` (jeśli nie istnieje).
3. Dodaj `price DECIMAL(10,2) NULL` po `custom_label_1` (jeśli nie istnieje).
4. Dodaj `xml_feed_url VARCHAR(500) NULL DEFAULT NULL` w `clients` po `google_merchant_account_id` (jeśli nie istnieje).
5. Dodaj `xml_feed_last_sync_at DATETIME NULL` w `clients` (jeśli nie istnieje).
Zastosuj wzorzec z migracji 028 (SET @sql = IF(EXISTS(...), 'DO 1', 'ALTER ...'); PREPARE; EXECUTE; DEALLOCATE).
Avoid: bezpośrednie ALTER bez guardu (idempotencja jest wymagana — `php install.php --force` musi być bezpieczne).
</action>
<verify>
`php install.php` przechodzi bez błędu;
`SHOW COLUMNS FROM products LIKE 'title'`, `'title_gmc'`, `'description_gmc'`, `'price'` zwracają wiersze;
`SHOW COLUMNS FROM clients LIKE 'xml_feed_url'`, `'xml_feed_last_sync_at'` zwracają wiersze;
drugie uruchomienie `php install.php --force` nie zgłasza błędów typu "Duplicate column".
</verify>
<done>AC-1 satisfied</done>
</task>
<task type="auto">
<name>Task 2: Refaktor odwołań name/title w PHP i templates</name>
<files>autoload/factory/class.Products.php, autoload/controls/class.Products.php, autoload/controls/class.Cron.php, autoload/services/class.GoogleAdsApi.php, templates/products/main_view.php, templates/products/product_history.php</files>
<action>
Zaktualizuj wszystkie miejsca, gdzie używane są kolumny `products.name` / `products.title` w starym znaczeniu:
- `p.name``p.title`
- Stare `p.title` (z GMC) → `p.title_gmc`
- `p.description` (z GMC w starym kodzie) → tam gdzie zapisywane przez sync GMC, zmień na `description_gmc`. Gdzie chodzi o edycję/AI — zostaw `description`.
Pliki do prześwietlenia (z grepu wcześniej):
- `autoload/factory/class.Products.php` — linie 11, 12, 208, 225, 227, 550, 586, 620, 649, 827, 854, 937, 1323
- `autoload/controls/class.Products.php` — linie 325, 335, 706, 857, 925, 994, 1133 (uwaga: `name` w kontekście DataTables `_POST['order'][0]['name']` to JS, NIE zmieniaj)
- `autoload/controls/class.Cron.php` — sekcje sync_products_fetch_for_client (zapisy do products) i mapowanie z GMC API
- `autoload/services/class.GoogleAdsApi.php` — sekcje insertujące/aktualizujące products (NIE ruszaj `ad_group.name`, `campaign.name` itp. — to GAQL, nie nasza tabela)
- `templates/products/main_view.php` i `product_history.php` — wyświetlanie tytułu
Reguła: alias `name` (np. `... AS name`) w SELECTach pozostaw — to publiczny kontrakt API/JS i zmienić tylko gdy faktycznie czytane jako `name` z PHP. Zachowaj backward-compat na poziomie response klucza `name` w AJAXach.
Avoid: globalny find-replace „name” → „title” (są kolumny `name` w innych tabelach!). Edytuj punktowo, tylko `p.name` / `products.name` / `$row['name']` w kontekście produktów.
</action>
<verify>
`grep -rn "p\.name\|products\.name\|products\`\.\`name" autoload/ templates/products/` zwraca pustą listę (lub tylko świadome aliasy AS name);
Otwarcie `/products` w przeglądarce nie wywala błędu SQL;
Edycja produktu (zmiana custom_label_1 / min_roas) nadal działa.
</verify>
<done>AC-4 satisfied</done>
</task>
<task type="auto">
<name>Task 3: UI edycji klienta — pole xml_feed_url</name>
<files>autoload/controls/class.Clients.php, templates/clients/main_view.php</files>
<action>
1. `templates/clients/main_view.php` — w sekcji `settings-field` (analogicznie do `client-gmc-id`, ok. linia 152) dodaj pole:
```html
<div class="settings-field">
<label for="client-xml-feed-url">XML Feed URL</label>
<input type="url" id="client-xml-feed-url" name="xml_feed_url" class="form-control" placeholder="np. https://example.com/google-feed.xml" />
<small class="text-muted">Adres feedu XML w formacie Google Merchant — używany do uzupełniania danych produktów</small>
</div>
```
Oraz w sekcji JS prefill (przy `$('#client-gmc-id').val(...)`) dodaj:
```js
$( '#client-xml-feed-url' ).val( data.xml_feed_url || '' );
```
2. `autoload/controls/class.Clients.php`:
- W metodzie save (ok. linia 105122 — gdzie czytane są pola formularza) dodaj:
```php
$xml_feed_url = trim( \S::get( 'xml_feed_url' ) );
```
- W tablicy zapisu dołóż: `'xml_feed_url' => $xml_feed_url ?: null,`
- W SELECT klientów dla listy (linia 352) dodaj `xml_feed_url` do listy kolumn.
- Walidacja: jeśli `$xml_feed_url` nie jest pusty, sprawdź `filter_var($xml_feed_url, FILTER_VALIDATE_URL)`. Przy błędzie — `\S::alert('Niepoprawny URL feedu XML')` i return.
Avoid: dodawanie nowych zależności frontend; używaj istniejącego stylu form-control.
</action>
<verify>
Otwarcie dialogu edycji klienta pokazuje pole "XML Feed URL";
Zapis URL i ponowne otwarcie dialogu pokazuje wprowadzoną wartość;
Pusty zapis czyści wartość w DB (NULL);
Niepoprawny URL pokazuje alert i nie zapisuje.
</verify>
<done>AC-2 satisfied</done>
</task>
<task type="auto">
<name>Task 4: Service XmlFeedImporter (streaming, batched) + integracja z cron_universal</name>
<files>autoload/services/class.XmlFeedImporter.php, autoload/controls/class.Cron.php</files>
<action>
1. Stwórz `autoload/services/class.XmlFeedImporter.php` (namespace `services`):
Sygnatura: `static public function import_for_client( int $client_id ): array` — zwraca raport `[ 'fetched' => N, 'updated' => N, 'inserted' => N, 'skipped' => N, 'errors' => [] ]`.
Implementacja:
- Pobierz `clients.xml_feed_url` przez `$mdb -> get('clients', ['xml_feed_url'], ['id' => $client_id])`. Jeśli puste → return z `skipped_reason = 'no_feed'`.
- Pobierz feed do pliku tymczasowego (`tempnam(sys_get_temp_dir(), 'xmlfeed_')`) cURL-em ze streamingiem (`CURLOPT_FILE`, `CURLOPT_TIMEOUT = 300`, follow redirects, user-agent). Dla feedów >50MB nie ładuj do pamięci.
- Otwórz `XMLReader::open($tmp)`. Iteruj po elementach `<item>` (lub `<entry>` w Atom — wykryj namespace).
- Dla każdego `<item>` zbuduj asocjację z odczytu `expand()` → SimpleXMLElement → wyciągnij: `g:id`, `title`, `description`, `g:price`, `g:custom_label_1` (z namespace `g:` zarejestrowanym jako `http://base.google.com/ns/1.0`).
- Buforuj wpisy w tablicy `$batch` o rozmiarze 200; po osiągnięciu limitu wykonaj UPSERT batch:
- `INSERT INTO products (client_id, product_id, title_gmc, description_gmc, custom_label_1, price, created_at) VALUES (...), (...), ... ON DUPLICATE KEY UPDATE title_gmc = VALUES(title_gmc), description_gmc = VALUES(description_gmc), custom_label_1 = COALESCE(VALUES(custom_label_1), custom_label_1), price = VALUES(price)`.
- Wykonaj w transakcji `$mdb->pdo->beginTransaction()` / `commit()`. Przy błędzie batchu — rollback, log i kontynuuj.
- WAŻNE: nie nadpisuj pól `title` ani `description` (edytowalne/AI). Tylko `_gmc` warianty.
- WAŻNE: warunek `ON DUPLICATE KEY` wymaga UNIQUE INDEX na `(client_id, product_id)`. Zweryfikuj że istnieje (z migracji 016/006); jeśli nie, dodaj go w migracji 029.
- Po pętli: `gc_collect_cycles()`, usuń plik tymczasowy, zapisz `clients.xml_feed_last_sync_at = NOW()`.
- `set_time_limit(600)` na początku, ini_set memory_limit 512M lokalnie.
- Każdy złapany Exception z parsowania pojedynczego itemu → log do `errors[]`, `skipped++`, kontynuuj.
2. `autoload/controls/class.Cron.php` — w `cron_universal()`:
- Po wywołaniu `sync_products_fetch_for_client` (ok. linia 116) dla każdego klienta dodaj:
```php
if ( !empty( $client['xml_feed_url'] ) )
{
$xml_sync = \services\XmlFeedImporter::import_for_client( (int) $client['id'] );
// dołącz $xml_sync do raportu / cron_sync_status
}
```
- Upewnij się, że SELECT klientów w cronie zwraca pole `xml_feed_url`.
- Loguj wynik do `logs` (analogicznie do innych sync) oraz aktualizuj `cron_sync_status` jeśli stosowne.
Avoid:
- `simplexml_load_file()` na całym feedzie (eksploduje pamięć dla 5000+ pozycji).
- DOMDocument ładowany w całości — to samo.
- Brak transakcji na batchach (przy padzie cron zostawi pół-zapisany stan).
- Nadpisywanie `products.title` / `products.description` (to są pola edytowalne/AI, kontrolowane przez użytkownika).
</action>
<verify>
`php -r "require 'index.php'; \services\XmlFeedImporter::import_for_client(1);"` przy ustawionym `xml_feed_url` dla klienta 1 kończy się bez błędu;
Po pierwszym uruchomieniu `cron_universal` na kliencie z feedem: w `products` pojawiają się/aktualizują wiersze z wypełnionym `title_gmc`, `description_gmc`, `price`;
`clients.xml_feed_last_sync_at` jest ustawione;
Pamięć PHP w trakcie importu nie przekracza 256MB dla feedu z 5000 pozycji (sprawdź `memory_get_peak_usage(true)` w logu raportu).
</verify>
<done>AC-3 satisfied</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<what-built>
Pole xml_feed_url w edycji klienta + import feedu XML w cron_universal z odporną parsowalnością XMLReader.
</what-built>
<how-to-verify>
1. Uruchom `php install.php` — migracja 029 przechodzi.
2. Otwórz `/clients`, edytuj klienta, wprowadź `https://pomysloweprezenty.pl/google-feed.xml` w "XML Feed URL", zapisz.
3. Otwórz dialog ponownie — pole jest wypełnione.
4. Wywołaj cron: `curl https://<host>/cron/cron_universal` (lub bezpośrednio uruchom z CLI).
5. Sprawdź w `products` że dla tego klienta są wiersze z wypełnionym `title_gmc`, `description_gmc`, `price`.
6. Sprawdź w `clients` że `xml_feed_last_sync_at` ma świeży timestamp.
7. Otwórz `/products` — strona renderuje się bez błędu SQL, kolumna nazwy pokazuje `title` (dawne `name`).
8. Edytuj produkt (np. min_roas, custom_label_1) — zapis działa.
9. Sprawdź log z importu: `errors == []` lub akceptowalne (np. pojedyncze pozycje bez g:id).
</how-to-verify>
<resume-signal>Wpisz "approved" aby zamknąć petle, albo opisz problemy do poprawki</resume-signal>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- `migrations/001``028` (zamknięta historia migracji)
- `products.title` i `products.description` po renamie — to są pola EDYTOWALNE/AI, importer NIGDY ich nie nadpisuje
- `products.id`, `products.product_id`, `products.client_id` (klucze i FK)
- Tabele `campaigns`, `campaign_*`, `cron_sync_status` (poza dorzuceniem entry per import XML jeśli pasuje)
- Aliasy `... AS name` w SELECTach które są publicznym kontraktem dla DataTables/JS — zachowaj kompatybilność wsteczną na poziomie kluczy w response
## SCOPE LIMITS
- Brak UI do podglądu/diagnozy treści feedu (tylko zapis URL + log w cronie)
- Brak retry/exponential backoff dla niedostępnego feedu (jednorazowa próba per cron run)
- Brak parsowania `<g:availability>`, `<g:brand>`, `<g:gtin>`, `<g:image_link>` itd. — tylko cztery pola z odpowiedzi (title, description, custom_label_1, price)
- Brak supportu dla feedów Atom (tylko RSS/g: namespace) — jeśli wykryty inny format, log "unsupported_format" i skip
- Brak harmonogramu osobnego od `cron_universal` — feed pobierany razem z synchronizacją produktów GA
</boundaries>
<verification>
Before declaring plan complete:
- [ ] `php install.php` i `php install.php --force` działają bez błędów
- [ ] `grep -rn "p\.name\|products\.name" autoload/ templates/products/` zwraca tylko świadome aliasy
- [ ] `/clients` — edycja zapisuje i wczytuje xml_feed_url
- [ ] `/products` — strona renderuje się, edycja produktu działa
- [ ] Cron `cron_universal` na kliencie z feedem: importuje, aktualizuje `xml_feed_last_sync_at`, NIE nadpisuje `title`/`description` (edytowalnych)
- [ ] Test wydajności: feed z 5000+ pozycji parsuje się w <60s i <256MB peak memory
- [ ] Wszystkie AC spełnione (AC-1 do AC-4)
</verification>
<success_criteria>
- Pole xml_feed_url widoczne i funkcjonalne w edycji klienta
- Cron pobiera feed XML strumieniowo i aktualizuje products w batchach z transakcjami
- Schemat products zrefaktoryzowany (name→title, title→title_gmc, +description_gmc, +price)
- Brak regresji w widokach `/products` i `/clients`
- Import odporny na feedy z kilkoma tysiącami pozycji
</success_criteria>
<output>
After completion, create `.paul/phases/06-xml-feed-import/06-01-SUMMARY.md`
</output>