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

19 KiB
Raw Blame History

phase, plan, type, wave, depends_on, files_modified, autonomous, delegation
phase plan type wave depends_on files_modified autonomous delegation
06-xml-feed-import 01 execute 1
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
false off
## 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
- **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 `` = `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).

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

<acceptance_criteria>

AC-1: Schemat bazy danych

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

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)

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

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>

Task 1: Migracja schematu — rename kolumn products + nowe pola + xml_feed_url migrations/029_products_rename_columns_and_xml_feed.sql 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).
`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". AC-1 satisfied Task 2: Refaktor odwołań name/title w PHP i templates 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 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.
`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. AC-4 satisfied Task 3: UI edycji klienta — pole xml_feed_url autoload/controls/class.Clients.php, templates/clients/main_view.php 1. `templates/clients/main_view.php` — w sekcji `settings-field` (analogicznie do `client-gmc-id`, ok. linia 152) dodaj pole: ```html
Adres feedu XML w formacie Google Merchant — używany do uzupełniania danych produktów
``` 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.
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. AC-2 satisfied Task 4: Service XmlFeedImporter (streaming, batched) + integracja z cron_universal autoload/services/class.XmlFeedImporter.php, autoload/controls/class.Cron.php 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).
`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). AC-3 satisfied Pole xml_feed_url w edycji klienta + import feedu XML w cron_universal z odporną parsowalnością XMLReader. 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:///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). Wpisz "approved" aby zamknąć petle, albo opisz problemy do poprawki

DO NOT CHANGE

  • migrations/001028 (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
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)

<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>
After completion, create `.paul/phases/06-xml-feed-import/06-01-SUMMARY.md`