This commit is contained in:
2026-04-30 01:04:06 +02:00
parent 2903ea2517
commit 2639242ca6
21 changed files with 1989 additions and 116 deletions

View File

@@ -0,0 +1,318 @@
---
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>

View File

@@ -0,0 +1,168 @@
---
phase: 06-xml-feed-import
plan: 01
subsystem: products
tags: [xml-feed, gmc, products, schema-rename, cron, supplemental-feed]
requires:
- phase: 02-supplemental-feed-cl1
provides: SupplementalFeed::generate_for_client (TSV out -> GMC)
- phase: 04-products-aggregate
provides: products_aggregate scope, p.title/p.name display fallback
provides:
- clients.xml_feed_url + xml_feed_last_sync_at
- products schema split: title/description (zrodlo) vs title_gmc/description_gmc (edycja)
- products.price (DECIMAL z feedu)
- \services\XmlFeedImporter (XMLReader streaming + batched manual upsert)
- cron_universal hook na import feedu
affects: [products UI, AI suggestions modal, supplemental feed generation]
tech-stack:
added: [XMLReader streaming, manual SELECT-then-UPDATE/INSERT upsert]
patterns:
- Idempotentne migracje rename via PREPARE/EXECUTE z guardem INFORMATION_SCHEMA
- Source/edit field separation (X / X_gmc) z SupplementalFeed czytajacym _gmc
key-files:
created:
- migrations/029_products_rename_columns_and_xml_feed.sql
- autoload/services/class.XmlFeedImporter.php
modified:
- autoload/factory/class.Products.php
- autoload/controls/class.Products.php
- autoload/controls/class.Cron.php
- autoload/controls/class.Clients.php
- autoload/services/class.SupplementalFeed.php
- templates/clients/main_view.php
- api.php
key-decisions:
- "title/description = ZRODLO (feed XML lub pierwszy fetch GA), title_gmc/description_gmc = EDYTOWALNE (output do GMC supplemental)"
- "INDEX (client_id, offer_id) bez UNIQUE - istniejace duplikaty legacy uniemozliwiaja UNIQUE"
- "Manual upsert (SELECT IN + UPDATE/INSERT) zamiast ON DUPLICATE KEY UPDATE - zgodne z brakiem UNIQUE"
- "Aliasy AS name w SELECTach zachowane - kontrakt JS/DataTables nieruszony"
patterns-established:
- "Source vs Edit field naming: X dla zrodla, X_gmc dla edycji wysylanej do Merchant Center"
- "Importery zewnetrznych feedow: XMLReader + batche w transakcjach + tempfile, set_time_limit(600), memory_limit 512M, gc co batch"
duration: ~50min
started: 2026-04-30T00:00:00Z
completed: 2026-04-30T01:00:00Z
---
# Phase 6 Plan 01: XML Feed Import Summary
**Klient moze podac URL feedu XML w panelu edycji; cron_universal pobiera go strumieniowo (XMLReader, batche 200 w transakcjach), wzbogaca tabele products o title/description/price/custom_label_1, a schemat products zostal rozdzielony na pola zrodlowe (title/description) i edytowalne dla supplemental feed (title_gmc/description_gmc).**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~50 min |
| Started | 2026-04-30T00:00:00Z |
| Completed | 2026-04-30T01:00:00Z |
| Tasks | 4/4 auto + 1 checkpoint approved |
| Files modified | 9 |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Schemat bazy danych | Pass | Migracja 029 idempotentna; kolumny title/title_gmc/description_gmc/price + xml_feed_url/xml_feed_last_sync_at; INDEX zamiast UNIQUE z powodu legacy duplikatow |
| AC-2: Edycja klienta — pole feed XML | Pass | Pole "XML Feed URL" w dialogu edycji + walidacja FILTER_VALIDATE_URL + prefill z `data.xml_feed_url` |
| AC-3: Cron pobiera feed XML i wzbogaca products | Pass | XmlFeedImporter::import_for_client wywolywany w cron_universal; manual upsert (SELECT IN + UPDATE/INSERT); transakcje per batch; ON ERROR pojedynczego itemu - skip+log |
| AC-4: Refaktor odwolan do name/title | Pass | Wszystkie p.name -> p.title; p.title -> p.title_gmc; aliasy AS name zachowane; is_product_core_field ma title_gmc/description_gmc; lint PHP czysty |
## Accomplishments
- **Schemat products przemodelowany** na czysty source/edit split: `title`+`description` to zrodlowe pola wypelniane przez feed XML lub pierwszy fetch GA; `title_gmc`+`description_gmc` to pola edytowane przez UI/AI i wysylane do GMC przez SupplementalFeed.
- **Odporny importer feedu XML** — XMLReader streaming bez ladowania calego pliku do pamieci, batche po 200 pozycji w transakcjach, manual upsert kompatybilny z legacy duplikatami `(client_id, offer_id)`, set_time_limit(600), memory_limit 512M, gc_collect_cycles co batch.
- **UI edycji klienta** uzupelnione o pole `XML Feed URL` (typ `url`, walidacja serwerowa) bez nowych zaleznosci frontendowych.
- **Cron_universal** automatycznie pobiera feed po sync produktow GA dla kazdego klienta z ustawionym `xml_feed_url`; raport `xml_feed: { fetched, updated, inserted, skipped, peak_memory_mb, duration_ms }` dolaczony do response cron-a.
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `migrations/029_products_rename_columns_and_xml_feed.sql` | Created | Idempotentny rename name->title, title->title_gmc + description_gmc + price + xml_feed_url/xml_feed_last_sync_at + INDEX (client_id, offer_id) |
| `autoload/services/class.XmlFeedImporter.php` | Created | XMLReader streaming parser + manual batched upsert (SELECT IN -> UPDATE/INSERT) + cURL streaming download |
| `autoload/factory/class.Products.php` | Modified | SQL rename p.name->p.title, p.title->p.title_gmc; is_product_core_field z title_gmc/description_gmc; aliasy AS name zachowane |
| `autoload/controls/class.Products.php` | Modified | get_product_data dla edycji ('title_gmc', 'description_gmc'); set_product_data zapisuje do _gmc; mapowanie $row['title']/$row['title_gmc'] |
| `autoload/controls/class.Cron.php` | Modified | Hook XmlFeedImporter w cron_universal; SELECTy/INSERTy products zaktualizowane; raport xml_feed w response |
| `autoload/controls/class.Clients.php` | Modified | save() przyjmuje xml_feed_url + walidacja FILTER_VALIDATE_URL |
| `autoload/services/class.SupplementalFeed.php` | Modified | TSV czyta z title_gmc AS title, description_gmc AS description |
| `templates/clients/main_view.php` | Modified | Pole "XML Feed URL" w dialogu edycji + prefill JS |
| `api.php` | Modified | Wszystkie SQL na `products` zaktualizowane (p.name->p.title, p.title->p.title_gmc); aliasy `AS name`/`AS title` zachowane jako kontrakt API; `set_product_data('title', ...)` -> `'title_gmc'` (post-fix po uwadze uzytkownika) |
## Decisions Made
| Decision | Rationale | Impact |
|----------|-----------|--------|
| title/description = zrodlo, title_gmc/description_gmc = edycja | Korekta semantyki w trakcie checkpointa - pierwotna interpretacja byla odwrotna. Edytor pisze do _gmc, supplemental feed czyta z _gmc i pcha do GMC | Cala logika edycyjna i sync GMC zostaly zaktualizowane konsekwentnie; XmlFeedImporter pisze do title/description |
| INDEX zamiast UNIQUE na (client_id, offer_id) | Istniejace dane mialy duplikaty (np. '2-1625' x N), UNIQUE rzucal 1062 IntegrityViolation | Manual upsert w PHP zamiast ON DUPLICATE KEY UPDATE; legacy duplikaty sa wszystkie aktualizowane (lista id z SELECT IN) |
| Manual SELECT IN + UPDATE/INSERT per batch | Konsekwencja braku UNIQUE; daje pelna kontrole nad obsluga duplikatow | Jeden SELECT + N UPDATE/INSERT per batch w transakcji - wciaz wydajne dla 5000+ pozycji |
| Aliasy `... AS name` w SELECTach zachowane | Kontrakt z DataTables/JS w templates/products | Brak zmian frontendu; minimalny blast radius |
## Deviations from Plan
### Summary
| Type | Count | Impact |
|------|-------|--------|
| Auto-fixed | 3 | Konieczne korekty - migracja UNIQUE -> INDEX, semantyka pol, brakujacy api.php |
| Scope additions | 0 | Brak |
| Deferred | 1 | Backfill description -> description_gmc nieobligatoryjny |
**Total impact:** Korekty kierunkowe (semantyka pol + UNIQUE -> INDEX), bez scope creep.
### Auto-fixed Issues
**3. [Coverage] api.php nie byl uwzgledniony w pierwotnym refaktorze**
- **Found during:** post-checkpoint pytanie uzytkownika ("Czy w api.php rowniez poprawiles nazwy kolumn?")
- **Issue:** Plan boundaries i grep nie obejmowaly `api.php` - 4 zapytania SQL na `products` z `p.name`/`p.title`, oraz `set_product_data($product['id'], 'title', ...)` w endpointcie product_title_set
- **Fix:** Wszystkie zapytania zaktualizowane analogicznie do reszty kodu: `p.name` -> `p.title`, `p.title` -> `p.title_gmc`, aliasy `AS name`/`AS title` dodane gdzie publiczny kontrakt API (offer_id/get_default_titles endpointy); `set_product_data` zapisuje teraz do `title_gmc`
- **Files:** `api.php`
- **Verification:** lint PHP czysty; grep `p\.name\|p\.title\b` zwraca tylko legalne aliasy
**1. [Schema] UNIQUE INDEX (client_id, offer_id) blokowal migracje przez legacy duplikaty**
- **Found during:** human-verify checkpoint (php install.php zwrocil SQLSTATE 23000 / 1062 'Duplicate entry 2-1625')
- **Issue:** Istniejace dane w `products` mialy wiele wierszy z tym samym `(client_id, offer_id)`
- **Fix:** Zamiana na non-unique INDEX `idx_products_client_offer` + przepisanie XmlFeedImporter::flush_batch z `ON DUPLICATE KEY UPDATE` na manual SELECT-then-UPDATE/INSERT (aktualizuje WSZYSTKIE legacy duplikaty per offer_id)
- **Files:** `migrations/029_products_rename_columns_and_xml_feed.sql`, `autoload/services/class.XmlFeedImporter.php`
- **Verification:** `php install.php` przechodzi; importer testowo dziala na klientach z xml_feed_url
**2. [Semantics] Inwersja zrodlo vs edycja w polach title/description**
- **Found during:** uwaga uzytkownika podczas Task 4 ("title/description = zrodlo, title_gmc/description_gmc = edycja")
- **Issue:** Pierwotny plan zaklada title=glowny display, title_gmc=zrodlo z feedu - co odwrocilo by przeplyw
- **Fix:** Flip: XmlFeedImporter pisze do title/description; is_product_core_field = title_gmc/description_gmc; SupplementalFeed czyta z _gmc; controls/Products.php: edycja zapisuje do _gmc; AI prompt context czyta z _gmc
- **Files:** `autoload/factory/class.Products.php`, `autoload/controls/class.Products.php`, `autoload/services/class.SupplementalFeed.php`, `autoload/services/class.XmlFeedImporter.php`
- **Verification:** lint PHP czysty wszedzie; flow source -> edit -> GMC zgodny z modelem mentalnym
### Deferred Items
- **Backfill istniejacych edycji do _gmc:** stare wartosci `description` (mieszane: GMC source + uzytkowe edycje) pozostaja w `description`. Jesli historyczne edycje powinny migrowac do `description_gmc`, wymagany jest dodatkowy `UPDATE products SET description_gmc = description WHERE description_gmc IS NULL`. Pozostawione decyzji uzytkownika - brak autoryzacji w planie.
## Issues Encountered
| Issue | Resolution |
|-------|------------|
| `php install.php` -> SQLSTATE 23000 (1062) na uk_products_client_offer | Zamiana UNIQUE -> non-unique INDEX + manual upsert w PHP |
| Niejednoznacznosc semantyki title vs title_gmc po pierwszym renamie | Korekta po uwadze uzytkownika - flip: title=zrodlo, _gmc=edycja, sciagniete na cale stos |
## Next Phase Readiness
**Ready:**
- Schemat `products` rozdziela source/edit - przyszle integracje feedow (np. inne formaty, multi-feed) maja czytelny target
- `\services\XmlFeedImporter` jako referencyjny wzorzec importera feedow (XMLReader + batched transactions)
- Cron pipeline ma jednolity hook point dla wzbogacania danych produktow
**Concerns:**
- Legacy duplikaty `(client_id, offer_id)` w `products` nie sa rozwiazywane przez ten plan - importer aktualizuje wszystkie wiersze z danym offer_id, ale uklad bazy pozostaje "smieciowy". Przyszle zadanie deduplikacji powinno przeniesc historie/agregaty na pojedynczy id przed wymuszeniem UNIQUE.
- Pole `description` istnialo wczesniej i moglo zawierac edycje uzytkownika - po nowej semantyce te dane reprezentuja "zrodlo", co moze byc mylace dla starych klientow. Backfill -> description_gmc jest deferred.
**Blockers:** None.
---
*Phase: 06-xml-feed-import, Plan: 01*
*Completed: 2026-04-30*