From 2639242ca6514201b61e35f70d241ba61259fcfd Mon Sep 17 00:00:00 2001 From: Jacek Pyziak Date: Thu, 30 Apr 2026 01:04:06 +0200 Subject: [PATCH] update --- .paul/STATE.md | 29 +- .paul/changelog/2026-04-29.md | 19 + .paul/changelog/2026-04-30.md | 29 ++ .paul/governance/governance_2026-04-29.jsonl | 23 + .paul/governance/governance_2026-04-30.jsonl | 69 +++ .../05-01-PLAN.md | 293 +++++++++++++ .../05-01-SUMMARY.md | 149 +++++++ .paul/phases/06-xml-feed-import/06-01-PLAN.md | 318 ++++++++++++++ .../06-xml-feed-import/06-01-SUMMARY.md | 168 ++++++++ .serena/project.yml | 88 ++-- .vscode/ftp-kr.sync.cache.json | 10 +- api.php | 86 +++- autoload/controls/class.Clients.php | 9 + autoload/controls/class.Cron.php | 32 +- autoload/controls/class.Products.php | 50 ++- autoload/factory/class.Products.php | 77 +++- autoload/services/class.SupplementalFeed.php | 4 +- autoload/services/class.XmlFeedImporter.php | 398 ++++++++++++++++++ ...9_products_rename_columns_and_xml_feed.sql | 127 ++++++ templates/clients/main_view.php | 6 + templates/products/main_view.php | 121 +++++- 21 files changed, 1989 insertions(+), 116 deletions(-) create mode 100644 .paul/changelog/2026-04-29.md create mode 100644 .paul/changelog/2026-04-30.md create mode 100644 .paul/governance/governance_2026-04-29.jsonl create mode 100644 .paul/governance/governance_2026-04-30.jsonl create mode 100644 .paul/phases/05-products-scope-history-delete/05-01-PLAN.md create mode 100644 .paul/phases/05-products-scope-history-delete/05-01-SUMMARY.md create mode 100644 .paul/phases/06-xml-feed-import/06-01-PLAN.md create mode 100644 .paul/phases/06-xml-feed-import/06-01-SUMMARY.md create mode 100644 autoload/services/class.XmlFeedImporter.php create mode 100644 migrations/029_products_rename_columns_and_xml_feed.sql diff --git a/.paul/STATE.md b/.paul/STATE.md index 1e926d9..b3ddd15 100644 --- a/.paul/STATE.md +++ b/.paul/STATE.md @@ -1,30 +1,30 @@ -# STATE +# STATE ## Current Position -Milestone: (ad-hoc) Products - aggregate + breakdown -Phase: 4 of 4 (Products Aggregate Breakdown) - Completed -Plan: 04-01 unified (loop closed) +Milestone: (ad-hoc) Products - XML feed import +Phase: 6 of 6 (XML Feed Import) - Completed +Plan: 06-01 unified (loop closed) Status: UNIFY complete -Last activity: 2026-04-25T17:28:08+02:00 - Zamknieto petle planu 04-01 +Last activity: 2026-04-30T01:15:00Z - Zamknieto petle planu 06-01 Progress: - Milestone: [██████████] 100% -- Phase 4: [██████████] 100% +- Phase 6: [██████████] 100% ## Loop Position ``` PLAN --> APPLY --> UNIFY - ✓ ✓ ✓ [Petla zamknieta] + ✓ ✓ ✓ [Petla zamknieta] ``` ## Session Continuity -Last session: 2026-04-25 +Last session: 2026-04-30 Stopped at: Loop closed, gotowe do nowego /paul:plan Next action: Jesli chcesz kontynuowac - uruchom /paul:plan z kolejnym zadaniem -Resume file: .paul/phases/04-products-aggregate-breakdown/04-01-SUMMARY.md +Resume file: .paul/phases/06-xml-feed-import/06-01-SUMMARY.md ## Historia zrealizowanych planow @@ -32,6 +32,8 @@ Resume file: .paul/phases/04-products-aggregate-breakdown/04-01-SUMMARY.md - `02-01-PLAN.md` - custom_label_1 w supplemental feed TSV (completed 2026-04-22) - `03-01-PLAN.md` - Powrot do widoku "wszystkie kampanie" w /products (completed 2026-04-24) - `04-01-PLAN.md` - Agregat produktu + rozwijane podwiersze kampania/grupa w /products (completed 2026-04-25) +- `05-01-PLAN.md` - Usuwanie wpisow historii produktu per kampania+grupa z UI breakdown (completed 2026-04-29) +- `06-01-PLAN.md` - XML feed import + rename name/title + xml_feed_url w clients (completed 2026-04-30) ## Decisions @@ -40,8 +42,15 @@ Resume file: .paul/phases/04-products-aggregate-breakdown/04-01-SUMMARY.md | 2026-04-24 | Usuniecie placeholdera zamiast sentinel `0/all` | 3 | Minimalny blast radius, bez zmian w kontrolerze/factory | | 2026-04-25 | Bez wybranej grupy: glowny agregat per produkt + rozwijane podwiersze per kampania/grupa | 4 | Czytelniejsza analiza produktu i szybki drill-down | | 2026-04-25 | Podwiersze tylko readonly, edycja tylko w parent row | 4 | Spojnosc UX i brak konfliktu akcji edycyjnych | +| 2026-04-29 | Usuwanie scope-level: products_aggregate + products_history + products_history_30 (transakcja), bez ruszania `products` ani Google Ads API | 5 | Hard delete lokalny, ograniczony do trojki product+campaign+ad_group | +| 2026-04-29 | UI: nowa kolumna "Akcje" w breakdown z czerwona ikona kosza + dialog $.confirm + ajax.reload(null,false) | 5 | Brak utraty stanu paginacji/filtrow przy odswiezaniu agregatu | +| 2026-04-30 | XML feed: jeden URL w clients.xml_feed_url, mapping po g:id=offer_id, parser XMLReader (streaming), batched manual upsert (200/batch) w transakcjach | 6 | Odporno na feedy z kilkoma tysiacami pozycji, niezalezne pola _gmc dla danych edytowalnych | +| 2026-04-30 | Refaktor schematu products: name->title, title->title_gmc, dodanie description_gmc i price | 6 | Rozdzial danych zrodlowych od edytowalnych | +| 2026-04-30 | Korekta semantyki: title/description = ZRODLOWE (z feedu lub pierwszy fetch GA), title_gmc/description_gmc = EDYTOWALNE (wysylane jako supplemental feed do GMC) | 6 | XmlFeedImporter pisze do title/description, edycja UI/AI pisze do _gmc, SupplementalFeed czyta z _gmc | +| 2026-04-30 | INDEX zamiast UNIQUE na (client_id, offer_id) | 6 | Istniejace dane mialy duplikaty - manual upsert (SELECT IN + UPDATE/INSERT) zamiast ON DUPLICATE KEY UPDATE | ## Notes - PAUL framework dziala tutaj w trybie ad-hoc (bez ROADMAP.md i PROJECT.md). -- Human-verify checkpoint dla planu 04-01 zatwierdzony (`approved`). +- Plan 06-01 zostal zamkniety mimo legacy duplikatow w products - przyszle zadanie deduplikacji moze wymusic UNIQUE. +- Backfill historycznych edycji `description` -> `description_gmc` jest deferred (czeka na decyzje uzytkownika). diff --git a/.paul/changelog/2026-04-29.md b/.paul/changelog/2026-04-29.md new file mode 100644 index 0000000..389969d --- /dev/null +++ b/.paul/changelog/2026-04-29.md @@ -0,0 +1,19 @@ +# 2026-04-29 + +## Co zrobiono + +- [Phase 5, Plan 05-01] Usuwanie wpisow historii produktu per kampania+grupa z UI breakdown w /products +- Factory: nowa metoda `\factory\Products::delete_product_scope_history($pid,$cid,$agid)` z transakcja PDO; atomowy DELETE z `products_aggregate`, `products_history`, `products_history_30` +- Controller: nowa akcja AJAX `/products/delete_product_scope_history/` z walidacja `product_id > 0` +- Controller: rozszerzono `breakdown_for_view` o `product_id`, `campaign_id`, `ad_group_id` (auto-fix - bez tego UI handler dostawal undefined) +- UI: nowa kolumna "Akcje" w tabeli rozbicia, ikona kosza z dialogiem `$.confirm`, optymistyczne usuwanie wiersza + `ajax.reload(null,false)` +- Bonus: przebudowany styl ikony rozwijania breakdown (CSS rotate, hover, fioletowy stan open) - usunieto JS swap klas FA + +## Zmienione pliki + +- `autoload/factory/class.Products.php` +- `autoload/controls/class.Products.php` +- `templates/products/main_view.php` +- `.paul/phases/05-products-scope-history-delete/05-01-PLAN.md` +- `.paul/phases/05-products-scope-history-delete/05-01-SUMMARY.md` +- `.paul/STATE.md` diff --git a/.paul/changelog/2026-04-30.md b/.paul/changelog/2026-04-30.md new file mode 100644 index 0000000..63de7e2 --- /dev/null +++ b/.paul/changelog/2026-04-30.md @@ -0,0 +1,29 @@ +# 2026-04-30 + +## Co zrobiono + +- [Phase 6, Plan 06-01] XML feed import per klient + refaktor schematu products (source/edit split) + integracja z cron_universal +- Dodano pole `xml_feed_url` w edycji klienta (templates/clients/main_view.php) z walidacja URL po stronie serwera +- Migracja 029: rename `products.name` -> `title`, `products.title` -> `title_gmc`, dodanie `description_gmc`, `price`, `clients.xml_feed_url`, `clients.xml_feed_last_sync_at`, INDEX `(client_id, offer_id)` (non-unique - legacy duplikaty) +- Nowy serwis `\services\XmlFeedImporter` - XMLReader streaming + batche 200 w transakcjach + manual upsert (SELECT IN + UPDATE/INSERT); odporny na feedy 5000+ pozycji +- Hook w `cron_universal` wywoluje XmlFeedImporter po sync produktow GA dla klientow z ustawionym feedem; raport `xml_feed` w response +- Refaktor wszystkich odwolan do kolumn `products.name`/`products.title` w factory, controls, services, templates, api.php; aliasy `AS name` zachowane jako kontrakt JS/API +- Korekta semantyki: `title`/`description` = ZRODLO (z feedu lub pierwszy fetch GA), `title_gmc`/`description_gmc` = EDYTOWALNE (wysylane do GMC supplemental feed) +- `is_product_core_field` zaktualizowane do `title_gmc`/`description_gmc` (edytowalne) +- `SupplementalFeed::generate_for_client` czyta `title_gmc AS title, description_gmc AS description` przy generowaniu TSV +- api.php (cztery endpointy products): SQL zaktualizowane, set_product_data zapisuje do title_gmc + +## Zmienione pliki + +- `migrations/029_products_rename_columns_and_xml_feed.sql` +- `autoload/services/class.XmlFeedImporter.php` +- `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` +- `.paul/phases/06-xml-feed-import/06-01-PLAN.md` +- `.paul/phases/06-xml-feed-import/06-01-SUMMARY.md` +- `.paul/STATE.md` diff --git a/.paul/governance/governance_2026-04-29.jsonl b/.paul/governance/governance_2026-04-29.jsonl new file mode 100644 index 0000000..cf32174 --- /dev/null +++ b/.paul/governance/governance_2026-04-29.jsonl @@ -0,0 +1,23 @@ +{"ts":"2026-04-29T18:56:54Z","tool":"Bash","cmd":"mkdir -p \".paul/phases/05-products-scope-history-delete\" && ls .paul/phases/\",\"description\":\"create phase directory\"},\"tool_response\":{\"stdout\":\"01-products-cl1-column\\n02-supplemental-fe","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-29T18:58:47Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\.paul\\\\phases\\\\05-products-scope-history-delete\\\\05-01-PLAN.md","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-29T18:59:10Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-29T18:59:50Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\autoload\\\\factory\\\\class.Products.php","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-29T18:59:54Z","tool":"Bash","cmd":"php -l autoload/factory/class.Products.php\",\"description\":\"syntax check factory\"},\"tool_response\":{\"stdout\":\"No syntax errors detected in autoload/factory/class.Products.php\",\"stderr\":\"\","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-29T19:00:04Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\autoload\\\\controls\\\\class.Products.php","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-29T19:00:07Z","tool":"Bash","cmd":"php -l autoload/controls/class.Products.php\",\"description\":\"syntax check controller\"},\"tool_response\":{\"stdout\":\"No syntax errors detected in autoload/controls/class.Products.php\",\"stderr\","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-29T19:00:22Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\templates\\\\products\\\\main_view.php","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-29T19:00:30Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\templates\\\\products\\\\main_view.php","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-29T19:00:45Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\templates\\\\products\\\\main_view.php","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-29T19:00:54Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\templates\\\\products\\\\main_view.php","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-29T19:00:58Z","tool":"Bash","cmd":"php -l templates/products/main_view.php && git diff --stat\",\"description\":\"syntax + diff stat\"},\"tool_response\":{\"stdout\":\"No syntax errors detected in templates/products/main_view.php\\nwar","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-29T19:02:58Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\autoload\\\\controls\\\\class.Products.php","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-29T19:03:02Z","tool":"Bash","cmd":"php -l autoload/controls/class.Products.php\",\"description\":\"syntax check controller\"},\"tool_response\":{\"stdout\":\"No syntax errors detected in autoload/controls/class.Products.php\",\"stderr\","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-29T19:04:30Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\templates\\\\products\\\\main_view.php","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-29T19:04:46Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\templates\\\\products\\\\main_view.php","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-29T19:04:50Z","tool":"Bash","cmd":"php -l templates/products/main_view.php\",\"description\":\"syntax check template\"},\"tool_response\":{\"stdout\":\"No syntax errors detected in templates/products/main_view.php\",\"stderr\":\"\",\"i","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-29T19:05:06Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-29T19:05:59Z","tool":"Bash","cmd":"date -u +\"%Y-%m-%dT%H:%M:%SZ\"\",\"description\":\"get current UTC time\"},\"tool_response\":{\"stdout\":\"2026-04-29T19:05:59Z\",\"stderr\":\"\",\"interrupted\":false,\"isImage\":false,\"noOutputExp","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-29T19:07:06Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\.paul\\\\phases\\\\05-products-scope-history-delete\\\\05-01-SUMMARY.md","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-29T19:07:23Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\.paul\\\\changelog\\\\2026-04-29.md","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-29T19:07:35Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-29T19:07:39Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual studio code/projekty/adsPRO"} diff --git a/.paul/governance/governance_2026-04-30.jsonl b/.paul/governance/governance_2026-04-30.jsonl new file mode 100644 index 0000000..f9f62a1 --- /dev/null +++ b/.paul/governance/governance_2026-04-30.jsonl @@ -0,0 +1,69 @@ +{"ts":"2026-04-29T22:33:54Z","tool":"Bash","cmd":"sed -n '1052,1110p' autoload/controls/class.Cron.php\",\"description\":\"Read sync_products_fetch\"},\"tool_response\":{\"stdout\":\" static private function sync_products_fetch_for_client( $client, ","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-29T22:39:44Z","tool":"Bash","cmd":"mkdir -p \".paul/phases/06-xml-feed-import\" && grep -n \"price\\\\|sku\\\\|brand\\\\|gtin\\\\|mpn\\\\|product_type\" migrations/016_products_model_unification.sql migrations/006_products_scope_dimens","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-29T22:42:03Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\.paul\\\\phases\\\\06-xml-feed-import\\\\06-01-PLAN.md","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-29T22:42:32Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-29T22:44:31Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\migrations\\\\029_products_rename_columns_and_xml_feed.sql","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-29T22:45:09Z","tool":"Bash","cmd":"sed -n '1,30p' autoload/factory/class.Products.php\",\"description\":\"Read Products factory header\"},\"tool_response\":{\"stdout\":\" 0 )\\n {\\n $client = $mdb -> query(\\n ","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-29T22:52:15Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\autoload\\\\services\\\\class.XmlFeedImporter.php","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-29T22:52:43Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\migrations\\\\029_products_rename_columns_and_xml_feed.sql","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-29T22:52:57Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\autoload\\\\services\\\\class.XmlFeedImporter.php","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-29T22:53:06Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\autoload\\\\controls\\\\class.Cron.php","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-29T22:53:13Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\autoload\\\\controls\\\\class.Cron.php","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-29T22:53:43Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\autoload\\\\factory\\\\class.Products.php","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-29T22:53:48Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\autoload\\\\controls\\\\class.Products.php","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-29T22:53:52Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\autoload\\\\controls\\\\class.Products.php","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-29T22:53:56Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\autoload\\\\controls\\\\class.Products.php","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-29T22:54:02Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\autoload\\\\services\\\\class.SupplementalFeed.php","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-29T22:54:11Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\autoload\\\\services\\\\class.XmlFeedImporter.php","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-29T22:54:16Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\autoload\\\\services\\\\class.XmlFeedImporter.php","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-29T22:54:28Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\autoload\\\\services\\\\class.XmlFeedImporter.php","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-29T22:54:54Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\autoload\\\\controls\\\\class.Products.php","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-29T22:55:08Z","tool":"Bash","cmd":"sed -n '1108,1115p' autoload/controls/class.Cron.php; echo ---; grep -n \"insert.*products\\\\|UPDATE products\\\\|->update.*products\\\\|VALUES.*offer_id\\\\|VALUES.*name\" autoload/controls/class.Cr","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-29T22:55:20Z","tool":"Bash","cmd":"php -l autoload/services/class.XmlFeedImporter.php 2>&1; echo ---; php -l autoload/controls/class.Cron.php 2>&1; echo ---; php -l autoload/factory/class.Products.php 2>&1; echo ---; php -l autoload/co","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-29T22:55:26Z","tool":"Bash","cmd":"php -l autoload/services/class.SupplementalFeed.php 2>&1; echo ---; php -r \"if(\\\\$xml=simplexml_load_string(' +## Goal +Dodac w widoku /products w rozwinietym podwierszu breakdown (per kampania+grupa reklam) przycisk usuwania z potwierdzeniem, ktory wycina lokalne wpisy statystyczno-historyczne dla konkretnej trojki product_id + campaign_id + ad_group_id. + +## Purpose +Pozwala uzytkownikowi rzetelnie wyczyscic statystyki produktu w obrebie jednej grupy reklam (np. po podmianie produktu w kampanii/grupie albo gdy historia jest zasmiecona), bez wplywu na pozostale grupy/kampanie i bez ruszania wpisu w tabeli `products`. + +## Output +- Nowy endpoint AJAX `/products/delete_product_scope_history/` +- Nowa metoda factory `\factory\Products::delete_product_scope_history($product_id, $campaign_id, $ad_group_id)` +- UI: kolumna "Akcje" w tabeli breakdown z czerwona ikona kosza, dialog potwierdzenia, optymistyczne usuniecie wiersza i odswiezenie wiersza nadrzednego (agregatu) bez resetu paginacji. + + + + +- **Zakres usuwania** — Co dokladnie ma usuwac przycisk z podwiersza breakdown? + → Odpowiedz: `products_aggregate` (statystyki) + `products_history` (dzienne) + `products_history_30` (30d), wszystkie dla danej trojki product_id+campaign_id+ad_group_id. +- **Miejsce UI** — Gdzie umiescic akcje usuwania? + → Odpowiedz: Nowa kolumna "Akcje" na koncu tabeli breakdown z czerwona ikona kosza per wiersz. +- **Scope API** — Czy usuwanie lokalne czy z Google Ads API? + → Odpowiedz: Tylko lokalnie (DB). Bez wywolan do Google Ads API. Cron synchronizacji moze i tak ponownie zaciagnac dane jesli produkt nadal aktywny w MC. +- **UI po usun.** — Co po udanym usunieciu? + → Odpowiedz: Usun wiersz breakdown + odswiez agregat parent (`ajax.reload(null, false)`), zachowujac paginacje. + + +## Project Context +@.paul/STATE.md +@.paul/phases/04-products-aggregate-breakdown/04-01-SUMMARY.md + +## Source Files +@autoload/factory/class.Products.php +@autoload/controls/class.Products.php +@templates/products/main_view.php + + + + +## AC-1: Endpoint usuwa lokalne wpisy dla trojki product+campaign+ad_group +```gherkin +Given uzytkownik wysyla POST do /products/delete_product_scope_history/ z parametrami product_id, campaign_id, ad_group_id (wszystkie >= 0, product_id > 0) +When zadanie zostaje przetworzone +Then z products_aggregate znika dokladnie jeden wiersz pasujacy do (product_id, campaign_id, ad_group_id) + And z products_history znikaja wszystkie wpisy pasujace do tej samej trojki + And z products_history_30 znikaja wszystkie wpisy pasujace do tej samej trojki + And zaden wiersz w `products`, `products_aggregate`, `products_history`, `products_history_30` dla *innych* trojek (inne campaign_id lub ad_group_id, ten sam product_id) nie zostaje naruszony + And odpowiedz to JSON `{"status":"ok"}` +``` + +## AC-2: Walidacja parametrow i bledow +```gherkin +Given POST do /products/delete_product_scope_history/ bez product_id (lub product_id <= 0) +When zadanie zostaje przetworzone +Then odpowiedz to JSON `{"status":"error","message":""}` + And zaden wiersz w bazie nie zostaje usuniety +``` +Uwaga: `campaign_id` lub `ad_group_id` rowne 0 sa dozwolone (PMax-y maja `ad_group_id = 0`, breakdown query renderuje takie wiersze). + +## AC-3: UI kolumny Akcje + potwierdzenie +```gherkin +Given uzytkownik rozwinal breakdown produktu w /products +When patrzy na tabele rozbicia +Then na koncu kazdego wiersza widzi nowa kolumne "Akcje" z czerwona ikona kosza (`fa-solid fa-trash`, `btn btn-sm btn-danger`) + And po klinieciu w ikone otwiera sie dialog $.confirm z tekstem zawierajacym nazwe kampanii i grupy reklam danego wiersza oraz pytaniem o potwierdzenie + And dialog ma przyciski "Usun" (akcja) i "Anuluj" + And dopoki uzytkownik nie potwierdzi, zaden request nie jest wysylany +``` + +## AC-4: UI po sukcesie aktualizuje sie bez utraty stanu +```gherkin +Given uzytkownik potwierdzil usuniecie i serwer zwrocil status ok +When odpowiedz dotrze do przegladarki +Then wiersz breakdown znika z aktualnie otwartej tabeli rozbicia (bez przeladowania calej strony) + And glowna tabela `#products` jest odswiezona przez `ajax.reload(null, false)` (zachowana paginacja, sortowanie, filtry) + And uzytkownik widzi toast/komunikat sukcesu (PL, krotki) + And przy bledzie z serwera wyswietla sie czerwony dialog z `message` z odpowiedzi (lub fallback PL) i wiersz NIE jest usuwany z UI +``` + + + + + + + Task 1: Factory - delete_product_scope_history + autoload/factory/class.Products.php + + Dodaj statyczna metode `delete_product_scope_history( $product_id, $campaign_id, $ad_group_id )` w klasie `\factory\Products`. + - Rzutuj wszystkie 3 argumenty na (int). + - Walidacja: `$product_id <= 0` -> return false. + - Uzyj globalnego `$mdb` (Medoo) zgodnie z konwencja pliku. + - Wykonaj 3 osobne DELETE w jednej transakcji `$mdb->pdo->beginTransaction()` / `commit()` / w catch `rollBack()` + `return false`: + 1. `$mdb->delete('products_aggregate', ['product_id' => $product_id, 'campaign_id' => $campaign_id, 'ad_group_id' => $ad_group_id])` + 2. `$mdb->delete('products_history', ['product_id' => $product_id, 'campaign_id' => $campaign_id, 'ad_group_id' => $ad_group_id])` + 3. `$mdb->delete('products_history_30',['product_id' => $product_id, 'campaign_id' => $campaign_id, 'ad_group_id' => $ad_group_id])` + - Zwroc `true` po commit. + - NIE dotykaj tabeli `products` ani `campaigns`/`campaign_ad_groups` (chronione - viz. boundaries). + - Avoid: jakichkolwiek warunkow `LIKE`, surowego SQL z konkatenacja stringow (Medoo dela tu wszystko bezpiecznie). Avoid: usuwania bez `campaign_id`/`ad_group_id` w klauzuli (skasowaloby calego produkta). + + + `php -l autoload/factory/class.Products.php` zwraca "No syntax errors". + Manualny test SQL (na kopii lub z dry-runem): przed zmiana zliczyc `SELECT COUNT(*) FROM products_aggregate WHERE product_id=X AND campaign_id=Y AND ad_group_id=Z` (oczekiwane 1), wywolac metode, ponownie zliczyc (oczekiwane 0). Powtorzyc dla `products_history` i `products_history_30`. Sprawdzic, ze inne trojki dla tego samego product_id pozostaly. + + AC-1 spelnione: usuwanie ograniczone do trojki, AC-2 spelnione w czesci serwerowej (zwrot false dla zlych parametrow). + + + + Task 2: Controller - akcja AJAX delete_product_scope_history + autoload/controls/class.Products.php + + Dodaj public static akcje `delete_product_scope_history()` w klasie `\controls\Products` (obok istniejacych `delete_product()` / `delete_products()`). + - Czytaj parametry przez `\S::get`: + $product_id = (int) \S::get('product_id'); + $campaign_id = (int) \S::get('campaign_id'); + $ad_group_id = (int) \S::get('ad_group_id'); + - Walidacja: `$product_id <= 0` -> `echo json_encode(['status'=>'error','message'=>'Brak identyfikatora produktu.']); exit;` + - Wywolaj `\factory\Products::delete_product_scope_history($product_id, $campaign_id, $ad_group_id)`. + - Sukces: `echo json_encode(['status'=>'ok']); exit;` + - Porazka: `echo json_encode(['status'=>'error','message'=>'Nie udalo sie usunac wpisow historii dla tego zakresu.']); exit;` + - Naladuj wzor istniejacych akcji `delete_product()` (linia ~1163) co do stylu (brak naglowkow, `echo json_encode(...)`, `exit`). + - Avoid: dodawania nagłowkow `Content-Type: application/json` (reszta kontrolera tego nie robi - jQuery sobie radzi). + + + `php -l autoload/controls/class.Products.php` zwraca "No syntax errors". + Recznie z konsoli przegladarki na zalogowanej sesji: + `$.post('/products/delete_product_scope_history/', {product_id: 0}, console.log)` -> `{status:'error', message: ...}` + `$.post('/products/delete_product_scope_history/', {product_id: , campaign_id: , ad_group_id: }, console.log)` -> `{status:'ok'}` i wpisy znikaja. + + AC-1, AC-2 spelnione end-to-end od strony serwera. + + + + Task 3: UI - kolumna Akcje w breakdown + handler + templates/products/main_view.php + + Modyfikacje wylacznie w obrebie breakdown: + + a) `products_build_breakdown_html(row_meta)` (~linia 604): + - Dodaj `Akcje` na koncu naglowka tabeli (po `CL4`). + - W petli `rows.forEach` dodaj na koncu `` jeszcze jedna komorke: + `''` + - WYMAGANE: w tasku 4 (factory/breakdown payload) `entry` musi zawierac `product_id`, `campaign_id`, `ad_group_id`. Sprawdzic istniejacy SELECT (`get_products_scope_breakdown`) - juz je zwraca (linie 707-709). Nie trzeba zmian backendu. + + b) Dopisz delegowany handler kliku (np. tuz przy istniejacym handlerze `js-products-breakdown-toggle`, ~linia 743): + + ```js + $( '#products tbody' ).on( 'click', '.js-products-breakdown-delete', function( e ) { + e.preventDefault(); + e.stopPropagation(); + var $btn = $( this ); + var productId = parseInt( $btn.data( 'product-id' ), 10 ) || 0; + var campaignId = parseInt( $btn.data( 'campaign-id' ), 10 ) || 0; + var adGroupId = parseInt( $btn.data( 'ad-group-id' ), 10 ) || 0; + var campaignName= String( $btn.data( 'campaign-name' ) || '' ); + var adGroupName = String( $btn.data( 'ad-group-name' ) || '' ); + if ( productId <= 0 ) { return; } + + $.confirm({ + title: 'Usun wpisy historii', + content: 'Czy na pewno chcesz usunac wpisy statystyk i historii tego produktu w kampanii ' + + escape_html(campaignName) + ' / grupie ' + escape_html(adGroupName) + '?' + + '

Operacja usuwa wpisy z products_aggregate, products_history oraz products_history_30 dla tej kombinacji. Nie usuwa produktu z tabeli products ani z Google Ads.', + buttons: { + confirm: { + text: 'Usun', + btnClass: 'btn-danger', + action: function() { + var $tr = $btn.closest( 'tr' ); + $btn.prop( 'disabled', true ); + $.ajax({ + url: '/products/delete_product_scope_history/', + type: 'POST', + dataType: 'json', + data: { product_id: productId, campaign_id: campaignId, ad_group_id: adGroupId }, + success: function( res ) { + if ( res && res.status === 'ok' ) { + $tr.remove(); + if ( typeof products_table !== 'undefined' && products_table ) { + products_table.ajax.reload( null, false ); + } + show_toast( 'Usunieto wpisy historii dla wybranego zakresu.', 'success' ); + } else { + $btn.prop( 'disabled', false ); + $.alert({ title: 'Blad', content: ( res && res.message ) || 'Nie udalo sie usunac wpisow.' }); + } + }, + error: function() { + $btn.prop( 'disabled', false ); + $.alert({ title: 'Blad', content: 'Blad polaczenia z serwerem.' }); + } + }); + } + }, + cancel: { text: 'Anuluj' } + } + }); + }); + ``` + + Uwaga: `show_toast` i `escape_html` istnieja juz w pliku - uzyj ich. Jezeli `products_table` nie jest w scope tego closure (sprawdzic - jest zdefiniowana w glownym `$( function() { ... })`), uzyj `$( '#products' ).DataTable().ajax.reload( null, false )`. + + c) CSS: nie wymagany dodatkowy CSS - kolumna "Akcje" przejmuje style domyslne `.products-breakdown-table td`. Ewentualnie w sekcji styli (~linia 217-237) dopisac `.products-breakdown-table td:last-child { width: 56px; text-align: center; }` jezeli kolumna sie rozjedzie. + + d) Zaktualizuj komentarz/uwage na gorze tabeli breakdown jezeli istnieje (sprawdzic - prawdopodobnie nie ma). + + Avoid: zmian w naglowkach pozostalych kolumn. Avoid: globalnego refresh `location.reload()`. Avoid: ruszania w `products_breakdown_meta` ani `get_products_scope_breakdown` (payload juz ma wymagane pola). +
+ + Manual w przegladarce na /products: + 1. Zaloguj sie, wybierz klienta, znajdz produkt z breakdownem (kilka kampanii/grup). + 2. Rozwin produkt -> widoczna kolumna "Akcje" z koszem na koncu tabeli. + 3. Klik kosz -> dialog z nazwa kampanii i grupy. + 4. Anuluj -> nic sie nie dzieje. + 5. Klik kosz -> Usun -> toast sukcesu, wiersz znika z tabeli rozbicia, glowny wiersz produktu ma zaktualizowane sumy (np. impressions, cost, conversions sa pomniejszone o usuniety zakres). + 6. SQL post-check: brak wpisow w `products_aggregate`, `products_history`, `products_history_30` dla skasowanej trojki; pozostale trojki dla tego product_id sa nietkniete. + 7. Browser console - brak bledow JS. + + AC-3, AC-4 spelnione: dialog potwierdzenia + bezpieczne odswiezenie UI bez utraty paginacji. +
+ + + + Dodano lokalne usuwanie wpisow statystyczno-historycznych (products_aggregate + products_history + products_history_30) per trojka product_id + campaign_id + ad_group_id z UI breakdown w /products. + + + 1. Otworz https://adspro.projectpro.pl/products i wybierz klienta z aktywnymi kampaniami. + 2. Znajdz produkt, ktory wystepuje w >= 2 grupach reklam (lub kampaniach), rozwin breakdown. + 3. Sprawdz, ze nowa kolumna "Akcje" istnieje i ma czerwona ikone kosza w kazdym wierszu rozbicia. + 4. Klik kosz na jednym z wierszy -> potwierdz, ze dialog pokazuje wlasciwa nazwe kampanii i grupy reklam. + 5. Anuluj raz - wiersz nie powinien zniknac. + 6. Powtorz, kliknij "Usun" - wiersz znika, parent agregat sie aktualizuje (sumy maleja), pozostale wiersze breakdown w tym samym produkcie nadal sa. + 7. Wejdz na ten sam produkt po przeladowaniu strony - wiersz breakdown dla skasowanej trojki nie wraca (do nastepnego crona sync). + 8. Sprawdz w przegladarce DevTools, ze response z `/products/delete_product_scope_history/` to `{"status":"ok"}` i nie ma 500. + + Wpisz "approved" aby zamknac plan, albo opisz problemy do poprawy. + + +
+ + + +## DO NOT CHANGE +- `autoload/factory/class.Products.php::delete_product()` / `delete_products()` (istniejaca logika usuwania calego produktu - inny przypadek) +- `autoload/factory/class.Products.php::get_products_scope_breakdown()` (payload juz zawiera product_id/campaign_id/ad_group_id - nie modyfikujemy SELECT) +- Tabela `products` - usuwanie nie tyka rekordu produktu (tylko statystyki+historia) +- `services/GoogleAdsApi.php` i jakikolwiek call do Google Ads API - operacja jest scisle lokalna +- Logika cron sync (`cron/cron_universal` itd.) - nie zmieniamy; po usunieciu cron moze ponownie zaciagnac dane jezeli produkt jest aktywny w MC, to jest oczekiwane + +## SCOPE LIMITS +- Brak operacji bulk (usuwanie wybranych wielu wierszy breakdown jednoczesnie) - jeden wiersz = jeden klik = jedno potwierdzenie +- Brak undo / soft-delete - hard delete z DB +- Brak zmian w endpoincie `/products/get_products/` - payload juz zawiera potrzebne ID +- Brak telemetrii / audit log - jezeli okaze sie potrzebny, oddzielny plan + + + +Przed zamknieciem planu: +- [ ] `php -l autoload/factory/class.Products.php` OK +- [ ] `php -l autoload/controls/class.Products.php` OK +- [ ] Manual: scenariusz Task 3 verify (kroki 1-7) i checkpoint human-verify (kroki 1-8) +- [ ] DevTools: brak bledow JS i 500 z endpointu +- [ ] SQL post-check: usunieto dokladnie te trojki, ktore mialy zniknac +- [ ] Wszystkie AC-1..AC-4 spelnione + + + +- Wszystkie 3 auto taski + 1 human-verify ukonczone i potwierdzone +- Brak nowych bledow lintera / parse errors +- UI breakdown zachowuje sie zgodnie z AC-3 i AC-4 +- Operacja jest atomowa (transakcja) i ograniczona do podanej trojki + + + +Po ukonczeniu utworz `.paul/phases/05-products-scope-history-delete/05-01-SUMMARY.md` zgodnie z konwencja phase 04 (frontmatter + sekcje Performance / AC Results / Files / Decisions / Deviations / Issues / Next Phase Readiness). + diff --git a/.paul/phases/05-products-scope-history-delete/05-01-SUMMARY.md b/.paul/phases/05-products-scope-history-delete/05-01-SUMMARY.md new file mode 100644 index 0000000..e9dd9b9 --- /dev/null +++ b/.paul/phases/05-products-scope-history-delete/05-01-SUMMARY.md @@ -0,0 +1,149 @@ +--- +phase: 05-products-scope-history-delete +plan: 01 +subsystem: ui +tags: [products, datatables, breakdown, delete, history, php, jquery, transactions] + +requires: + - phase: 04-products-aggregate-breakdown + provides: rozwijane podwiersze breakdown per kampania+grupa z payloadem row_meta.breakdown_rows + +provides: + - Lokalne usuwanie wpisow statystyczno-historycznych per trojka product_id+campaign_id+ad_group_id z UI breakdown + - Endpoint AJAX `/products/delete_product_scope_history/` + - Factory method `\factory\Products::delete_product_scope_history()` (transakcja PDO) + - Kolumna "Akcje" w tabeli rozbicia + dialog `$.confirm` z potwierdzeniem + - Odswiezony, schludniejszy styl ikony rozwijania breakdown (CSS rotate, hover, stan open) + +affects: + - autoload/factory/class.Products.php (delete API) + - autoload/controls/class.Products.php (payload breakdown_for_view + nowa akcja) + - templates/products/main_view.php (kolumna Akcje + handler + style) + +tech-stack: + added: [] + patterns: + - "Hard delete scope-level: AGGREGATE + HISTORY + HISTORY_30 atomowo w transakcji PDO" + - "Breakdown payload eksponuje pelna trojke ID (product, campaign, ad_group) dla operacji per-wiersz" + - "Toggle expand stylowany CSS-em (rotate 90deg) zamiast swapu klas FA w JS" + +key-files: + created: [] + modified: + - autoload/factory/class.Products.php + - autoload/controls/class.Products.php + - templates/products/main_view.php + +key-decisions: + - "Hard delete lokalny w transakcji - bez Google Ads API, bez ruszania tabeli `products`" + - "Eksponowanie product_id/campaign_id/ad_group_id w breakdown_for_view (poza zaplanowana edycja factory)" + - "Style toggle: chevron rotowany przez CSS, JS nie podmienia klas FA" + +patterns-established: + - "Operacje per-wiersz w child rows DataTables wymagaja eksponowania ID we payloadzie row_meta - nie polegaj na danych z parent row" + - "Transakcja PDO przed serwisem Medoo: `$mdb->pdo->beginTransaction()` + `commit/rollBack` + `$pdo->inTransaction()` guard" + +duration: ~30min +started: 2026-04-29T18:35:00Z +completed: 2026-04-29T19:05:00Z +--- + +# Phase 5 Plan 01: Products Scope History Delete (Summary) + +**Dodano w UI /products na rozwijanym podwierszu breakdown przycisk usuwania, ktory atomowo kasuje wpisy z `products_aggregate`, `products_history` i `products_history_30` dla konkretnej trojki product+campaign+ad_group, bez ruszania tabeli `products` i bez wywolan Google Ads API.** + +## Performance + +| Metric | Value | +|--------|-------| +| Duration | ~30 min | +| Started | 2026-04-29T18:35:00Z | +| Completed | 2026-04-29T19:05:00Z | +| Tasks | 4/4 (3 auto + 1 human-verify) + 1 unplanned bonus (toggle styling) | +| Files modified | 3 | + +## Acceptance Criteria Results + +| Criterion | Status | Notes | +|-----------|--------|-------| +| AC-1: Endpoint usuwa lokalne wpisy dla trojki product+campaign+ad_group | Pass | Factory uzywa `$mdb->delete()` z dokladnym WHERE per kazda z 3 tabel; transakcja PDO; inne trojki nietkniete (zweryfikowane przez usera) | +| AC-2: Walidacja parametrow i bledow | Pass | Controller: `product_id <= 0 -> {status:error,message:...}` przed wywolaniem factory; factory tez waliduje (defense in depth) | +| AC-3: UI kolumny Akcje + potwierdzenie | Pass | Nowa kolumna "Akcje" widoczna na koncu breakdown; ikona kosza `fa-solid fa-trash` w `btn btn-sm btn-danger`; dialog `$.confirm` z nazwa kampanii/grupy + przyciski "Usun"/"Anuluj" | +| AC-4: UI po sukcesie aktualizuje sie bez utraty stanu | Pass | `$tr.remove()` + `products_table.ajax.reload(null,false)`; toast PL na success; `$.alert` z `res.message` na blad - human-verify approved | + +## Accomplishments + +- Dodano scope-level usuwanie historii produktu z UI breakdown bez ryzyka skasowania samego produktu ani wplywu na inne trojki kampania/grupa. +- Atomowa operacja DELETE-z-trzech-tabel w pojedynczej transakcji PDO z guardem `inTransaction()` i `rollBack` na throw. +- Naprawiono nieoczywisty bug: breakdown_for_view nie eksponowal ID, przez co handler dostawal `undefined` i nic sie nie dzialo - dolozono trzy pola. +- Bonus: ikona rozwijania breakdown przemodelowana na okragly button (hover, fioletowy stan open, CSS rotate chevron) - usunieto JS swap klas FA. + +## Task Commits + +Brak commitu fazowego na tym etapie (working tree zawiera tez `.vscode/ftp-kr.sync.cache.json`, `.serena/project.yml` i `.paul/STATE.md` z innych watkow). + +## Files Created/Modified + +| File | Change | Purpose | +|------|--------|---------| +| `autoload/factory/class.Products.php` | Modified (+51) | Nowa metoda `delete_product_scope_history($pid,$cid,$agid)` z transakcja PDO i 3-tabelowym DELETE | +| `autoload/controls/class.Products.php` | Modified (+23 +3) | Nowa akcja AJAX `delete_product_scope_history()`; payload `breakdown_for_view` rozszerzony o `product_id`, `campaign_id`, `ad_group_id` | +| `templates/products/main_view.php` | Modified (+86, ~30 styling) | Kolumna "Akcje" w `products_build_breakdown_html`, handler kliku z `$.confirm`, przebudowane style `.products-breakdown-toggle` (CSS rotate zamiast JS), CSS `td:last-child` szerokosci 56px | + +## Decisions Made + +| Decision | Rationale | Impact | +|----------|-----------|--------| +| Eksponowac product/campaign/ad_group ID w `breakdown_for_view` | Bez tego JS dostawal `undefined` ID i handler ucinal sie na walidacji `<= 0` | Ustanawia wzorzec: jak chcesz akcje per-wiersz w child row, dodaj ID do payloadu row_meta | +| Stylowac chevron rotacja CSS, nie podmiana klas FA w JS | Mniej kodu JS, plynniejsza animacja, jeden zrodlowy stan (`.products-breakdown-open`) | Pojedynczy CSS transition zarzadza wizualem; usunieto 2 linie JS | +| Transakcja PDO + guard `inTransaction()` zamiast nested-transaction errora | Inny kod moze otworzyc transakcje wczesniej (np. w cron/migracji) | Bezpieczne uzycie metody w roznych kontekstach wywolania | + +## Deviations from Plan + +### Summary + +| Type | Count | Impact | +|------|-------|--------| +| Auto-fixed | 1 | Niezbedne - bez tej korekty UI nie dziala (bug: undefined ID) | +| Scope additions | 1 | Maly bonus stylowania ikony toggle na prosbe usera, w obrebie tego samego pliku | +| Deferred | 0 | Brak | + +**Total impact:** Plan zrealizowany zgodnie z zakresem; jedna konieczna korekta payloadu (Task 2) i jedno male rozszerzenie kosmetyczne na zyczenie usera. + +### Auto-fixed Issues + +**1. UI Breakdown - brak ID produktu/kampanii/grupy w payloadzie** +- Found during: Task 4 (human-verify) +- Issue: `js-products-breakdown-delete` po kliknieciu nie robil nic - `parseInt(undefined,10) || 0` dalo `0`, a handler ma `if (product_id <= 0) return`. Powod: `breakdown_for_view` w `controls/class.Products.php:1097` mapowal tylko statystyki + nazwy, bez ID. +- Fix: Dodano `product_id`, `campaign_id`, `ad_group_id` jako pierwsze pola mappingu (z fallbackami). +- Files: `autoload/controls/class.Products.php` +- Verification: Czesc human-verify - klik kosza po patchu wywoluje dialog z poprawnymi nazwami kampanii/grupy. +- Commit: TBD (pojedynczy commit fazowy zostanie wykonany w nastepnym etapie) + +### Deferred Items + +Brak. + +## Issues Encountered + +| Issue | Resolution | +|-------|------------| +| Klik w kosz nie wywolywal dialogu (puste dane w `entry.product_id` itd.) | Zlokalizowano: payload breakdown filtrowany w controllerze. Dolozono 3 pola ID i pominieto cache state DataTables (force reload) | + +## Next Phase Readiness + +**Ready:** +- Wzorzec dla operacji per-wiersz w breakdown ustanowiony (ID w row_meta). +- Wzorzec transakcyjnego DELETE w factory ustanowiony. +- Style toggle bardziej spojne wizualnie - mozna wykorzystac wzorzec rotate-on-state w innych collapsible UI. + +**Concerns:** +- Cron sync moze ponownie zaciagnac dane jezeli produkt nadal aktywny w MC dla danej kampanii/grupy - to jest oczekiwane zachowanie, ale uzytkownik powinien byc tego swiadom. +- Working tree zawiera zmiany niepowiazane z planem (`.vscode/ftp-kr.sync.cache.json`, `.serena/project.yml`) - przy commit fazowym selektywne stage'owanie. + +**Blockers:** +- Brak. + +--- +*Phase: 05-products-scope-history-delete, Plan: 01* +*Completed: 2026-04-29* diff --git a/.paul/phases/06-xml-feed-import/06-01-PLAN.md b/.paul/phases/06-xml-feed-import/06-01-PLAN.md new file mode 100644 index 0000000..8510c44 --- /dev/null +++ b/.paul/phases/06-xml-feed-import/06-01-PLAN.md @@ -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 +--- + + +## 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 + + + + +## 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 `` 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` = ``, 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 +``` + + + + + + + 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 105–122 — 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 `` (lub `` w Atom — wykryj namespace). + - Dla każdego `` 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/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 ``, ``, ``, `` 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) + + + +- 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 + + + +After completion, create `.paul/phases/06-xml-feed-import/06-01-SUMMARY.md` + diff --git a/.paul/phases/06-xml-feed-import/06-01-SUMMARY.md b/.paul/phases/06-xml-feed-import/06-01-SUMMARY.md new file mode 100644 index 0000000..150d48e --- /dev/null +++ b/.paul/phases/06-xml-feed-import/06-01-SUMMARY.md @@ -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* diff --git a/.serena/project.yml b/.serena/project.yml index df4551f..3ee7ec6 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -3,15 +3,18 @@ project_name: "adsPRO" # list of languages for which language servers are started; choose from: -# al bash clojure cpp csharp -# csharp_omnisharp dart elixir elm erlang -# fortran fsharp go groovy haskell -# java julia kotlin lua markdown -# matlab nix pascal perl php -# php_phpactor powershell python python_jedi r -# rego ruby ruby_solargraph rust scala -# swift terraform toml typescript typescript_vts -# vue yaml zig +# al ansible bash clojure cpp +# cpp_ccls crystal csharp csharp_omnisharp dart +# elixir elm erlang fortran fsharp +# go groovy haskell haxe hlsl +# java json julia kotlin lean4 +# lua luau markdown matlab msl +# nix ocaml pascal perl php +# php_phpactor powershell python python_jedi python_ty +# r rego ruby ruby_solargraph rust +# scala solidity swift systemverilog terraform +# toml typescript typescript_vts vue yaml +# zig # (This list may be outdated. For the current list, see values of Language enum here: # https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py # For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) @@ -46,52 +49,19 @@ ignored_paths: [] # Added on 2025-04-18 read_only: false -# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. -# Below is the complete list of tools for convenience. -# To make sure you have the latest list of tools, and to view their descriptions, -# execute `uv run scripts/print_tool_overview.py`. -# -# * `activate_project`: Activates a project by name. -# * `check_onboarding_performed`: Checks whether project onboarding was already performed. -# * `create_text_file`: Creates/overwrites a file in the project directory. -# * `delete_lines`: Deletes a range of lines within a file. -# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. -# * `execute_shell_command`: Executes a shell command. -# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. -# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). -# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). -# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. -# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. -# * `initial_instructions`: Gets the initial instructions for the current project. -# Should only be used in settings where the system prompt cannot be set, -# e.g. in clients you have no control over, like Claude Desktop. -# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. -# * `insert_at_line`: Inserts content at a given line in a file. -# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. -# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). -# * `list_memories`: Lists memories in Serena's project-specific memory store. -# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). -# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). -# * `read_file`: Reads a file within the project directory. -# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. -# * `remove_project`: Removes a project from the Serena configuration. -# * `replace_lines`: Replaces a range of lines within a file with new content. -# * `replace_symbol_body`: Replaces the full definition of a symbol. -# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. -# * `search_for_pattern`: Performs a search for a pattern in the project. -# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. -# * `switch_modes`: Activates modes by providing a list of their names -# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. -# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. -# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. -# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. +# list of tool names to exclude. +# This extends the existing exclusions (e.g. from the global configuration) +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html excluded_tools: [] -# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default) +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). +# This extends the existing inclusions (e.g. from the global configuration). +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html included_optional_tools: [] # fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. # This cannot be combined with non-empty excluded_tools or included_optional_tools. +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html fixed_tools: [] # list of mode names to that are always to be included in the set of active modes @@ -102,19 +72,24 @@ fixed_tools: [] # Set this to a list of mode names to always include the respective modes for this project. base_modes: -# list of mode names that are to be activated by default. -# The full set of modes to be activated is base_modes + default_modes. -# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply. +# list of mode names that are to be activated by default, overriding the setting in the global configuration. +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply. # Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply +# for this project. # This setting can, in turn, be overridden by CLI parameters (--mode). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes default_modes: # initial prompt for the project. It will always be given to the LLM upon activating the project # (contrary to the memories, which are loaded on demand). initial_prompt: "" -# override of the corresponding setting in serena_config.yml, see the documentation there. -# If null or missing, the value from the global config is used. +# time budget (seconds) per tool call for the retrieval of additional symbol information +# such as docstrings or parameter information. +# This overrides the corresponding setting in the global configuration; see the documentation there. +# If null or missing, use the setting from the global configuration. symbol_info_budget: # The language backend to use for this project. @@ -146,3 +121,8 @@ ls_specific_settings: {} # Extends the list from the global configuration, merging the two lists. # Example: ["_archive/.*", "_episodes/.*"] ignored_memory_patterns: [] + +# list of mode names to be activated additionally for this project, e.g. ["query-projects"] +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +added_modes: diff --git a/.vscode/ftp-kr.sync.cache.json b/.vscode/ftp-kr.sync.cache.json index 8cbcbe5..b271882 100644 --- a/.vscode/ftp-kr.sync.cache.json +++ b/.vscode/ftp-kr.sync.cache.json @@ -9,8 +9,8 @@ }, "api.php": { "type": "-", - "size": 28579, - "lmtime": 1774049776000, + "size": 30668, + "lmtime": 1777417113752, "modified": false }, "autoload": { @@ -1071,6 +1071,12 @@ } }, "tools": {}, + "unit-price.md": { + "type": "-", + "size": 10688, + "lmtime": 0, + "modified": false + }, "upload": {}, "xml": {} } diff --git a/api.php b/api.php index a5ec289..42635cf 100644 --- a/api.php +++ b/api.php @@ -58,7 +58,7 @@ function api_validate_api_key( $mdb ) function api_get_product_by_offer_and_client( $mdb, $offer_id, $client_id ) { return $mdb -> query( - 'SELECT p.id, p.name, p.title, p.google_product_category, p.custom_label_3, p.custom_label_4 + 'SELECT p.id, p.title AS name, p.title_gmc AS title, p.google_product_category, p.custom_label_3, p.custom_label_4 FROM products p JOIN clients cl ON p.client_id = cl.id WHERE p.offer_id = :offer_id @@ -423,7 +423,7 @@ if ( \S::get( 'action' ) == 'product_title_set' ) } $old_title = (string) ( $product['title'] ?? '' ); - \factory\Products::set_product_data( (int) $product['id'], 'title', $new_title ); + \factory\Products::set_product_data( (int) $product['id'], 'title_gmc', $new_title ); $old_title_for_log = trim( $old_title ) !== '' ? $old_title : '[pusty]'; $new_title_for_log = $new_title !== null ? $new_title : '[pusty]'; @@ -566,7 +566,7 @@ if ( \S::get( 'action' ) == 'products_unoptimized_list' ) } $rows = $mdb -> query( - 'SELECT p.id, p.offer_id, p.name, p.title, p.google_product_category, + 'SELECT p.id, p.offer_id, p.title AS name, p.title_gmc AS title, p.google_product_category, COALESCE( SUM( pa.clicks_all_time ), 0 ) AS clicks_all_time, COALESCE( SUM( pa.impressions_all_time ), 0 ) AS impressions_all_time, COALESCE( SUM( pa.cost_all_time ), 0 ) AS cost_all_time @@ -574,7 +574,7 @@ if ( \S::get( 'action' ) == 'products_unoptimized_list' ) LEFT JOIN products_aggregate pa ON pa.product_id = p.id WHERE p.client_id = :client_id AND ( - p.title IS NULL OR p.title = \'\' OR p.title = p.name + p.title_gmc IS NULL OR p.title_gmc = \'\' OR p.title_gmc = p.title OR p.google_product_category IS NULL OR p.google_product_category = \'\' ) GROUP BY p.id @@ -778,8 +778,8 @@ if ( \S::get( 'action' ) == 'products_to_optimize' ) $products = $mdb -> query( 'SELECT p.id, p.offer_id, - p.name AS original_name, - p.title AS custom_title, + p.title AS original_name, + p.title_gmc AS custom_title, p.google_product_category, p.product_url, SUM( pa.clicks_30 ) AS clicks_30, @@ -792,7 +792,7 @@ if ( \S::get( 'action' ) == 'products_to_optimize' ) INNER JOIN products_aggregate AS pa ON pa.product_id = p.id WHERE p.client_id = :client_id AND ( - TRIM( COALESCE( p.title, \'\' ) ) = \'\' + TRIM( COALESCE( p.title_gmc, \'\' ) ) = \'\' OR TRIM( COALESCE( p.google_product_category, \'\' ) ) = \'\' ) GROUP BY p.id @@ -920,7 +920,7 @@ if ( \S::get( 'action' ) == 'products_get_by_cl1' ) } $rows = $mdb -> query( - 'SELECT p.id, p.offer_id, p.name, p.title, p.google_product_category, + 'SELECT p.id, p.offer_id, p.title AS name, p.title_gmc AS title, p.google_product_category, p.custom_label_1, p.custom_label_3, p.custom_label_4 FROM products p WHERE p.client_id = :client_id @@ -974,3 +974,73 @@ if ( \S::get( 'action' ) == 'domain_opr_save' ) echo json_encode( ['result' => 'ok'] ); exit; } + +// Zmiana custom_label_1 dla produktu przez API +if ( \S::get( 'action' ) == 'product_custom_label_1_set' ) +{ + api_validate_api_key( $mdb ); + + $offer_id = trim( (string) \S::get( 'offer_id' ) ); + $client_id_param = (int) \S::get( 'client_id' ); + $custom_label_1 = trim( (string) ( \S::get( 'custom_label_1' ) ?? \S::get( 'value' ) ?? '' ) ); + + if ( $offer_id === '' || $client_id_param <= 0 ) + { + api_json_response( [ 'result' => 'error', 'message' => 'Missing required params: offer_id, client_id' ], 422 ); + } + + $product = api_get_product_by_offer_and_client( $mdb, $offer_id, $client_id_param ); + + if ( !$product ) + { + api_json_response( [ 'result' => 'error', 'message' => 'Product not found' ], 404 ); + } + + $update_result = \factory\Products::set_product_data( (int) $product['id'], 'custom_label_1', $custom_label_1 ); + + if ( !$update_result ) + { + api_json_response( [ 'result' => 'error', 'message' => 'Failed to update custom_label_1' ], 500 ); + } + + \factory\Products::add_product_comment( (int) $product['id'], 'Zmiana etykiety 1 na: ' . ( $custom_label_1 !== '' ? $custom_label_1 : '(puste)' ) . ' (API)' ); + + api_json_response( [ + 'result' => 'ok', + 'product_id' => (int) $product['id'], + 'offer_id' => $offer_id, + 'client_id' => $client_id_param, + 'custom_label_1' => $custom_label_1 + ] ); +} + +// Odczyt custom_label_1 dla produktu przez API +if ( \S::get( 'action' ) == 'product_custom_label_1_get' ) +{ + api_validate_api_key( $mdb ); + + $offer_id = trim( (string) \S::get( 'offer_id' ) ); + $client_id_param = (int) \S::get( 'client_id' ); + + if ( $offer_id === '' || $client_id_param <= 0 ) + { + api_json_response( [ 'result' => 'error', 'message' => 'Missing required params: offer_id, client_id' ], 422 ); + } + + $product = api_get_product_by_offer_and_client( $mdb, $offer_id, $client_id_param ); + + if ( !$product ) + { + api_json_response( [ 'result' => 'error', 'message' => 'Product not found' ], 404 ); + } + + api_json_response( [ + 'result' => 'ok', + 'product_id' => (int) $product['id'], + 'offer_id' => $offer_id, + 'client_id' => $client_id_param, + 'custom_label_1' => trim( (string) ( $product['custom_label_1'] ?? '' ) ) + ] ); +} + +// === KONIEC BLOKU === diff --git a/autoload/controls/class.Clients.php b/autoload/controls/class.Clients.php index eaa71ae..ea96d5a 100644 --- a/autoload/controls/class.Clients.php +++ b/autoload/controls/class.Clients.php @@ -104,6 +104,7 @@ class Clients $google_ads_customer_id = trim( \S::get( 'google_ads_customer_id' ) ); $google_merchant_account_id = trim( \S::get( 'google_merchant_account_id' ) ); $facebook_ads_account_id = self::normalize_facebook_ads_account_id( \S::get( 'facebook_ads_account_id' ) ); + $xml_feed_url = trim( \S::get( 'xml_feed_url' ) ); $active_raw = \S::get( 'active' ); $active = (string) $active_raw === '0' ? 0 : 1; @@ -114,6 +115,13 @@ class Clients exit; } + if ( $xml_feed_url !== '' && !filter_var( $xml_feed_url, FILTER_VALIDATE_URL ) ) + { + \S::alert( 'Niepoprawny URL feedu XML.' ); + header( 'Location: /clients' ); + exit; + } + $google_ads_start_date = trim( \S::get( 'google_ads_start_date' ) ); $data = [ @@ -121,6 +129,7 @@ class Clients 'google_ads_customer_id' => $google_ads_customer_id ?: null, 'google_merchant_account_id' => $google_merchant_account_id ?: null, 'facebook_ads_account_id' => $facebook_ads_account_id, + 'xml_feed_url' => $xml_feed_url ?: null, 'google_ads_start_date' => $google_ads_start_date ?: null, 'active' => $active, ]; diff --git a/autoload/controls/class.Cron.php b/autoload/controls/class.Cron.php index 53f737c..98a6670 100644 --- a/autoload/controls/class.Cron.php +++ b/autoload/controls/class.Cron.php @@ -132,6 +132,23 @@ class Cron $products_temp_rows_total += (int) self::rebuild_products_temp_for_client( (int) $client['id'] ); } + $xml_feed_report = null; + if ( !empty( $client['xml_feed_url'] ) ) + { + try + { + $xml_feed_report = \services\XmlFeedImporter::import_for_client( (int) $client['id'] ); + if ( !empty( $xml_feed_report['errors'] ) ) + { + $products_errors = array_merge( $products_errors, (array) $xml_feed_report['errors'] ); + } + } + catch ( \Throwable $e ) + { + $products_errors[] = 'XML feed import: ' . $e -> getMessage(); + } + } + $errors = array_merge( $campaign_errors, $products_errors ); self::output_cron_response( [ @@ -152,6 +169,7 @@ class Cron 'products_fetch_skipped_reasons' => array_keys( $products_fetch_skipped_reasons ), 'history_30_products' => $history_30_products_total, 'products_temp_rows' => $products_temp_rows_total, + 'xml_feed' => $xml_feed_report, 'errors' => $errors ] ); } @@ -934,7 +952,7 @@ class Cron } $not_found_rows = $mdb -> query( - "SELECT id AS product_id, offer_id, name, title + "SELECT id AS product_id, offer_id, title, title_gmc FROM products WHERE client_id = :client_id AND TRIM( COALESCE( offer_id, '' ) ) <> '' @@ -950,10 +968,10 @@ class Cron { $offer_id = trim( (string) ( $row['offer_id'] ?? '' ) ); $product_id = (int) ( $row['product_id'] ?? 0 ); - $product_name = trim( (string) ( $row['title'] ?? '' ) ); + $product_name = trim( (string) ( $row['title_gmc'] ?? '' ) ); if ( $product_name === '' ) { - $product_name = trim( (string) ( $row['name'] ?? '' ) ); + $product_name = trim( (string) ( $row['title'] ?? '' ) ); } if ( $product_name === '' ) { @@ -1109,7 +1127,7 @@ class Cron } $existing_products_rows = $mdb -> query( - 'SELECT id, offer_id, name, title, product_url + 'SELECT id, offer_id, title, title_gmc, product_url FROM products WHERE client_id = :client_id ORDER BY id ASC', @@ -1127,8 +1145,8 @@ class Cron $products_by_offer_id[ $offer_id ] = [ 'id' => (int) ( $row['id'] ?? 0 ), - 'name' => (string) ( $row['name'] ?? '' ), 'title' => (string) ( $row['title'] ?? '' ), + 'title_gmc' => (string) ( $row['title_gmc'] ?? '' ), 'product_url' => (string) ( $row['product_url'] ?? '' ) ]; } @@ -1366,14 +1384,14 @@ class Cron $mdb -> insert( 'products', [ 'client_id' => $client_id, 'offer_id' => $offer_external_id, - 'name' => $product_title + 'title' => $product_title ] ); $product_id = $mdb -> id(); $products_by_offer_id[ $offer_external_id ] = [ 'id' => (int) $product_id, - 'name' => $product_title, + 'title' => $product_title, 'product_url' => '' ]; } diff --git a/autoload/controls/class.Products.php b/autoload/controls/class.Products.php index 9320362..34161b1 100644 --- a/autoload/controls/class.Products.php +++ b/autoload/controls/class.Products.php @@ -319,10 +319,10 @@ class Products } $offer_id = trim( (string) ( $row['offer_id'] ?? '' ) ); - $product_name = trim( (string) ( $row['title'] ?? '' ) ); + $product_name = trim( (string) ( $row['title_gmc'] ?? '' ) ); if ( $product_name === '' ) { - $product_name = trim( (string) ( $row['name'] ?? '' ) ); + $product_name = trim( (string) ( $row['title'] ?? '' ) ); } if ( $product_name === '' ) { @@ -697,8 +697,8 @@ class Products $product_id = \S::get( 'product_id' ); $product_name = \factory\Products::get_product_name( $product_id ); - $product_title = \factory\Products::get_product_data( $product_id, 'title' ); - $product_description = \factory\Products::get_product_data( $product_id, 'description' ); + $product_title = \factory\Products::get_product_data( $product_id, 'title_gmc' ); + $product_description = \factory\Products::get_product_data( $product_id, 'description_gmc' ); $google_product_category = \factory\Products::get_product_data( $product_id, 'google_product_category' ); $product_url = \factory\Products::get_product_data( $product_id, 'product_url' ); @@ -854,9 +854,9 @@ class Products } $context = [ - 'original_name' => $product['name'], - 'current_title' => \factory\Products::get_product_data( $product_id, 'title' ), - 'current_description' => \factory\Products::get_product_data( $product_id, 'description' ), + 'original_name' => $product['title'], + 'current_title' => \factory\Products::get_product_data( $product_id, 'title_gmc' ), + 'current_description' => \factory\Products::get_product_data( $product_id, 'description_gmc' ), 'current_category' => \factory\Products::get_product_data( $product_id, 'google_product_category' ), 'offer_id' => $product['offer_id'], 'impressions_30' => $product['impressions_30'] ?? 0, @@ -986,7 +986,7 @@ class Products $custom_class = ''; $custom_label_4 = \factory\Products::get_product_data( $product_id, 'custom_label_4' ); $custom_label_1 = \factory\Products::get_product_data( $product_id, 'custom_label_1' ); - $custom_name = \factory\Products::get_product_data( $product_id, 'title' ); + $custom_name = \factory\Products::get_product_data( $product_id, 'title_gmc' ); $product_url = trim( (string) \factory\Products::get_product_data( $product_id, 'product_url' ) ); if ( $custom_name ) @@ -1095,6 +1095,9 @@ class Products foreach ( $breakdown_rows as $breakdown_row ) { $breakdown_for_view[] = [ + 'product_id' => (int) ( $breakdown_row['product_id'] ?? $product_id ), + 'campaign_id' => (int) ( $breakdown_row['campaign_id'] ?? 0 ), + 'ad_group_id' => (int) ( $breakdown_row['ad_group_id'] ?? 0 ), 'campaign_name' => (string) ( $breakdown_row['campaign_name'] ?? '' ), 'ad_group_name' => (string) ( $breakdown_row['ad_group_name'] ?? '' ), 'impressions' => (int) ( $breakdown_row['impressions'] ?? 0 ), @@ -1185,6 +1188,29 @@ class Products exit; } + static public function delete_product_scope_history() + { + $product_id = (int) \S::get( 'product_id' ); + $campaign_id = (int) \S::get( 'campaign_id' ); + $ad_group_id = (int) \S::get( 'ad_group_id' ); + + if ( $product_id <= 0 ) + { + echo json_encode( [ 'status' => 'error', 'message' => 'Brak identyfikatora produktu.' ] ); + exit; + } + + if ( \factory\Products::delete_product_scope_history( $product_id, $campaign_id, $ad_group_id ) ) + { + echo json_encode( [ 'status' => 'ok' ] ); + } + else + { + echo json_encode( [ 'status' => 'error', 'message' => 'Nie udalo sie usunac wpisow historii dla tego zakresu.' ] ); + } + exit; + } + static public function save_min_roas() { $product_id = \S::get( 'product_id' ); @@ -1390,8 +1416,8 @@ class Products $google_product_category = \S::get( 'google_product_category' ); $product_url = \S::get( 'product_url' ); - $old_title = (string) \factory\Products::get_product_data( $product_id, 'title' ); - $old_description = (string) \factory\Products::get_product_data( $product_id, 'description' ); + $old_title = (string) \factory\Products::get_product_data( $product_id, 'title_gmc' ); + $old_description = (string) \factory\Products::get_product_data( $product_id, 'description_gmc' ); $old_category = (string) \factory\Products::get_product_data( $product_id, 'google_product_category' ); $changed_for_merchant = []; @@ -1400,13 +1426,13 @@ class Products { if ( $custom_title ) { - \factory\Products::set_product_data( $product_id, 'title', $custom_title ); + \factory\Products::set_product_data( $product_id, 'title_gmc', $custom_title ); $changed_for_merchant['title'] = [ 'old' => $old_title, 'new' => (string) $custom_title ]; } if ( $custom_description ) { - \factory\Products::set_product_data( $product_id, 'description', $custom_description ); + \factory\Products::set_product_data( $product_id, 'description_gmc', $custom_description ); $changed_for_merchant['description'] = [ 'old' => $old_description, 'new' => (string) $custom_description ]; } diff --git a/autoload/factory/class.Products.php b/autoload/factory/class.Products.php index e646467..70f76c1 100644 --- a/autoload/factory/class.Products.php +++ b/autoload/factory/class.Products.php @@ -8,8 +8,8 @@ class Products 'custom_label_4', 'custom_label_3', 'custom_label_1', - 'title', - 'description', + 'title_gmc', + 'description_gmc', 'google_product_category', 'product_url' ], true ); @@ -32,6 +32,57 @@ class Products return true; } + static public function delete_product_scope_history( $product_id, $campaign_id, $ad_group_id ) + { + global $mdb; + + $product_id = (int) $product_id; + $campaign_id = (int) $campaign_id; + $ad_group_id = (int) $ad_group_id; + + if ( $product_id <= 0 ) + { + return false; + } + + $where = [ + 'product_id' => $product_id, + 'campaign_id' => $campaign_id, + 'ad_group_id' => $ad_group_id + ]; + + $pdo = $mdb -> pdo; + $started_tx = false; + + try + { + if ( !$pdo -> inTransaction() ) + { + $pdo -> beginTransaction(); + $started_tx = true; + } + + $mdb -> delete( 'products_aggregate', $where ); + $mdb -> delete( 'products_history', $where ); + $mdb -> delete( 'products_history_30', $where ); + + if ( $started_tx ) + { + $pdo -> commit(); + } + + return true; + } + catch ( \Throwable $e ) + { + if ( $started_tx && $pdo -> inTransaction() ) + { + $pdo -> rollBack(); + } + return false; + } + } + static public function get_product_comments( $product_id ) { global $mdb; @@ -154,8 +205,8 @@ class Products $sql = 'SELECT p.id AS product_id, p.offer_id, - p.name, p.title, + p.title_gmc, SUM( pa.impressions_30 ) AS impressions_30 FROM products_aggregate AS pa INNER JOIN products AS p ON p.id = pa.product_id @@ -171,9 +222,9 @@ class Products } $sql .= ' - GROUP BY p.id, p.offer_id, p.name, p.title + GROUP BY p.id, p.offer_id, p.title, p.title_gmc HAVING COALESCE( SUM( pa.impressions_30 ), 0 ) = 0 - ORDER BY COALESCE( NULLIF( TRIM( p.title ), \'\' ), NULLIF( TRIM( p.name ), \'\' ), p.offer_id ) ASC, p.id ASC + ORDER BY COALESCE( NULLIF( TRIM( p.title_gmc ), \'\' ), NULLIF( TRIM( p.title ), \'\' ), p.offer_id ) ASC, p.id ASC LIMIT ' . $limit; try @@ -496,8 +547,8 @@ class Products if ( $search !== '' ) { $sql .= ' AND ( - p.name LIKE :search - OR p.title LIKE :search + p.title LIKE :search + OR p.title_gmc LIKE :search OR p.offer_id LIKE :search OR p.custom_label_4 LIKE :search OR p.custom_label_1 LIKE :search @@ -566,7 +617,7 @@ class Products END AS ad_group_name, MIN( pa.campaign_id ) AS history_campaign_id, MIN( pa.ad_group_id ) AS history_ad_group_id, - COALESCE( NULLIF( TRIM( p.title ), \'\' ), NULLIF( TRIM( p.name ), \'\' ), p.offer_id ) AS name, + COALESCE( NULLIF( TRIM( p.title_gmc ), \'\' ), NULLIF( TRIM( p.title ), \'\' ), p.offer_id ) AS name, SUM( pa.impressions_all_time ) AS impressions, SUM( pa.impressions_30 ) AS impressions_30, SUM( pa.clicks_all_time ) AS clicks, @@ -595,7 +646,7 @@ class Products self::build_scope_filters( $sql, $params, $campaign_id, $ad_group_id ); self::build_products_filters( $sql, $params, $search, $custom_label_4, $custom_label_1 ); - $sql .= ' GROUP BY p.id, p.offer_id, p.min_roas, p.custom_label_1, p.name, p.title'; + $sql .= ' GROUP BY p.id, p.offer_id, p.min_roas, p.custom_label_1, p.title, p.title_gmc'; $sql .= ' ORDER BY ' . $order_sql . ' ' . $order_dir . ', product_id DESC LIMIT ' . $start . ', ' . $limit; return $mdb -> query( $sql, $params ) -> fetchAll( \PDO::FETCH_ASSOC ); @@ -773,7 +824,7 @@ class Products 'SELECT p.id, p.offer_id, - p.name, + p.title, p.min_roas, COALESCE( SUM( pa.impressions_all_time ), 0 ) AS impressions, COALESCE( SUM( pa.impressions_30 ), 0 ) AS impressions_30, @@ -800,7 +851,7 @@ class Products FROM products AS p LEFT JOIN products_aggregate AS pa ON pa.product_id = p.id WHERE p.id = :pid - GROUP BY p.id, p.offer_id, p.name, p.min_roas', + GROUP BY p.id, p.offer_id, p.title, p.min_roas', [ ':pid' => $product_id ] ) -> fetch( \PDO::FETCH_ASSOC ); } @@ -883,7 +934,7 @@ class Products return null; } - return $mdb -> get( 'products', 'name', [ 'id' => $product_id ] ); + return $mdb -> get( 'products', 'title', [ 'id' => $product_id ] ); } static public function get_product_merchant_context( $product_id ) @@ -1269,7 +1320,7 @@ class Products p.id, p.client_id, p.offer_id, - p.name, + p.title, cl.google_ads_customer_id, cl.google_merchant_account_id FROM products AS p diff --git a/autoload/services/class.SupplementalFeed.php b/autoload/services/class.SupplementalFeed.php index 31b5cc9..f9d78cb 100644 --- a/autoload/services/class.SupplementalFeed.php +++ b/autoload/services/class.SupplementalFeed.php @@ -163,12 +163,12 @@ class SupplementalFeed $labels_updated = self::refresh_bestseller_labels_for_client( $client_id ); $products = $mdb -> query( - "SELECT p.offer_id, p.title, p.description, p.google_product_category, p.custom_label_1, p.custom_label_3, p.custom_label_4 + "SELECT p.offer_id, p.title_gmc AS title, p.description_gmc AS description, p.google_product_category, p.custom_label_1, p.custom_label_3, p.custom_label_4 FROM products p WHERE p.client_id = :client_id AND p.offer_id IS NOT NULL AND p.offer_id <> '' - AND ( p.title IS NOT NULL OR p.description IS NOT NULL OR p.google_product_category IS NOT NULL OR p.custom_label_1 IS NOT NULL OR p.custom_label_3 IS NOT NULL OR p.custom_label_4 IS NOT NULL )", + AND ( p.title_gmc IS NOT NULL OR p.description_gmc IS NOT NULL OR p.google_product_category IS NOT NULL OR p.custom_label_1 IS NOT NULL OR p.custom_label_3 IS NOT NULL OR p.custom_label_4 IS NOT NULL )", [ ':client_id' => $client_id ] ) -> fetchAll( \PDO::FETCH_ASSOC ); diff --git a/autoload/services/class.XmlFeedImporter.php b/autoload/services/class.XmlFeedImporter.php new file mode 100644 index 0000000..08f650e --- /dev/null +++ b/autoload/services/class.XmlFeedImporter.php @@ -0,0 +1,398 @@ + GMC). + * Dla pozycji nieobecnej w products tworzy nowy rekord. + * + * @param int $client_id + * @return array raport z polami: feed_url, fetched, updated, inserted, skipped, errors, peak_memory_mb, duration_ms + */ + static public function import_for_client( $client_id ) + { + global $mdb; + + $client_id = (int) $client_id; + $report = [ + 'feed_url' => '', + 'fetched' => 0, + 'updated' => 0, + 'inserted' => 0, + 'skipped' => 0, + 'errors' => [], + 'peak_memory_mb' => 0, + 'duration_ms' => 0, + ]; + + if ( $client_id <= 0 ) + { + $report['errors'][] = 'Nieprawidlowy client_id'; + return $report; + } + + $client = $mdb -> get( 'clients', [ 'id', 'xml_feed_url' ], [ 'id' => $client_id ] ); + $feed_url = trim( (string) ( $client['xml_feed_url'] ?? '' ) ); + if ( $feed_url === '' ) + { + $report['skipped_reason'] = 'no_feed'; + return $report; + } + $report['feed_url'] = $feed_url; + + $started_at = microtime( true ); + @set_time_limit( 600 ); + @ini_set( 'memory_limit', '512M' ); + + $tmp_file = tempnam( sys_get_temp_dir(), 'xmlfeed_' ); + if ( $tmp_file === false ) + { + $report['errors'][] = 'Nie mozna utworzyc pliku tymczasowego'; + return $report; + } + + $download_ok = self::download_feed( $feed_url, $tmp_file, $report ); + if ( !$download_ok ) + { + @unlink( $tmp_file ); + return $report; + } + + $reader = new \XMLReader(); + if ( !$reader -> open( $tmp_file ) ) + { + $report['errors'][] = 'Nie mozna otworzyc feedu XML do parsowania'; + @unlink( $tmp_file ); + return $report; + } + + $batch = []; + while ( $reader -> read() ) + { + if ( $reader -> nodeType !== \XMLReader::ELEMENT ) + { + continue; + } + + $local = $reader -> localName; + if ( $local !== 'item' && $local !== 'entry' ) + { + continue; + } + + try + { + $node = $reader -> expand(); + if ( !$node ) + { + $report['skipped']++; + continue; + } + + $item = self::extract_item_fields( $node ); + if ( $item === null || $item['offer_id'] === '' ) + { + $report['skipped']++; + continue; + } + + $report['fetched']++; + $batch[] = $item; + + if ( count( $batch ) >= self::BATCH_SIZE ) + { + self::flush_batch( $client_id, $batch, $report ); + $batch = []; + gc_collect_cycles(); + } + } + catch ( \Throwable $e ) + { + $report['skipped']++; + if ( count( $report['errors'] ) < 20 ) + { + $report['errors'][] = 'Item parse error: ' . $e -> getMessage(); + } + } + } + + if ( !empty( $batch ) ) + { + self::flush_batch( $client_id, $batch, $report ); + $batch = []; + } + + $reader -> close(); + @unlink( $tmp_file ); + + $mdb -> update( 'clients', [ 'xml_feed_last_sync_at' => date( 'Y-m-d H:i:s' ) ], [ 'id' => $client_id ] ); + + $report['peak_memory_mb'] = round( memory_get_peak_usage( true ) / 1024 / 1024, 1 ); + $report['duration_ms'] = (int) round( ( microtime( true ) - $started_at ) * 1000 ); + + return $report; + } + + static private function download_feed( $url, $tmp_file, &$report ) + { + $fp = fopen( $tmp_file, 'wb' ); + if ( $fp === false ) + { + $report['errors'][] = 'Nie mozna otworzyc pliku tymczasowego do zapisu'; + return false; + } + + $ch = curl_init( $url ); + curl_setopt_array( $ch, [ + CURLOPT_FILE => $fp, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_MAXREDIRS => 5, + CURLOPT_TIMEOUT => self::HTTP_TIMEOUT, + CURLOPT_CONNECTTIMEOUT => 30, + CURLOPT_FAILONERROR => true, + CURLOPT_USERAGENT => 'adsPRO XML Feed Importer/1.0', + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_ENCODING => '', + ] ); + + $ok = curl_exec( $ch ); + $http_code = (int) curl_getinfo( $ch, CURLINFO_HTTP_CODE ); + $err = curl_error( $ch ); + curl_close( $ch ); + fclose( $fp ); + + if ( !$ok || ( $http_code >= 400 ) ) + { + $report['errors'][] = 'Pobieranie feedu nieudane (HTTP ' . $http_code . '): ' . $err; + return false; + } + + if ( filesize( $tmp_file ) === 0 ) + { + $report['errors'][] = 'Feed jest pusty'; + return false; + } + + return true; + } + + static private function extract_item_fields( \DOMNode $node ) + { + $doc = new \DOMDocument(); + $imported = $doc -> importNode( $node, true ); + $doc -> appendChild( $imported ); + + $sxe = simplexml_import_dom( $doc -> documentElement ); + if ( $sxe === false ) + { + return null; + } + + $g = $sxe -> children( self::GMC_NS ); + + $offer_id = ''; + if ( isset( $g -> id ) ) + { + $offer_id = trim( (string) $g -> id ); + } + if ( $offer_id === '' && isset( $sxe -> id ) ) + { + $offer_id = trim( (string) $sxe -> id ); + } + if ( $offer_id === '' ) + { + return null; + } + + $title = ''; + if ( isset( $g -> title ) ) + { + $title = trim( (string) $g -> title ); + } + if ( $title === '' && isset( $sxe -> title ) ) + { + $title = trim( (string) $sxe -> title ); + } + + $description = ''; + if ( isset( $g -> description ) ) + { + $description = trim( (string) $g -> description ); + } + if ( $description === '' && isset( $sxe -> description ) ) + { + $description = trim( (string) $sxe -> description ); + } + + $custom_label_1 = isset( $g -> custom_label_1 ) ? trim( (string) $g -> custom_label_1 ) : ''; + + $price = null; + if ( isset( $g -> price ) ) + { + $price_raw = trim( (string) $g -> price ); + $price = self::parse_price( $price_raw ); + } + if ( $price === null && isset( $g -> sale_price ) ) + { + $price = self::parse_price( trim( (string) $g -> sale_price ) ); + } + + return [ + 'offer_id' => self::truncate( $offer_id, 255 ), + 'title' => self::truncate( $title, 255 ), + 'description' => $description, + 'custom_label_1' => self::truncate( $custom_label_1, 255 ), + 'price' => $price, + ]; + } + + static private function parse_price( $raw ) + { + if ( $raw === '' ) + { + return null; + } + if ( preg_match( '/([0-9]+(?:[.,][0-9]+)?)/', $raw, $m ) ) + { + $value = (float) str_replace( ',', '.', $m[1] ); + if ( $value > 0 ) + { + return round( $value, 2 ); + } + } + return null; + } + + static private function truncate( $value, $max ) + { + if ( function_exists( 'mb_substr' ) ) + { + return mb_substr( (string) $value, 0, $max, 'UTF-8' ); + } + return substr( (string) $value, 0, $max ); + } + + static private function flush_batch( $client_id, array $batch, array &$report ) + { + global $mdb; + + if ( empty( $batch ) ) + { + return; + } + + $pdo = $mdb -> pdo; + + try + { + $pdo -> beginTransaction(); + + $offer_ids = []; + foreach ( $batch as $item ) + { + $offer_ids[ $item['offer_id'] ] = true; + } + $offer_ids = array_keys( $offer_ids ); + + $existing = []; + if ( !empty( $offer_ids ) ) + { + $placeholders = []; + $select_params = [ ':client_id' => $client_id ]; + foreach ( $offer_ids as $idx => $oid ) + { + $key = ':oid_' . $idx; + $placeholders[] = $key; + $select_params[ $key ] = $oid; + } + $sel_sql = 'SELECT id, offer_id FROM products WHERE client_id = :client_id AND offer_id IN (' . implode( ', ', $placeholders ) . ')'; + $sel_stmt = $pdo -> prepare( $sel_sql ); + $sel_stmt -> execute( $select_params ); + while ( $row = $sel_stmt -> fetch( \PDO::FETCH_ASSOC ) ) + { + // jeden offer_id moze miec wiele wierszy (legacy duplikaty) - zbieramy wszystkie id + $existing[ (string) $row['offer_id'] ][] = (int) $row['id']; + } + } + + $update_stmt = $pdo -> prepare( + 'UPDATE products SET + title = :title, + description = :description, + custom_label_1 = COALESCE(:custom_label_1, custom_label_1), + price = COALESCE(:price, price) + WHERE id = :id' + ); + + $insert_stmt = $pdo -> prepare( + 'INSERT INTO products (client_id, offer_id, title, description, custom_label_1, price) + VALUES (:client_id, :offer_id, :title, :description, :custom_label_1, :price)' + ); + + $updated_count = 0; + $inserted_count = 0; + + foreach ( $batch as $item ) + { + $title = $item['title'] !== '' ? $item['title'] : null; + $desc = $item['description'] !== '' ? $item['description'] : null; + $cl1 = $item['custom_label_1'] !== '' ? $item['custom_label_1'] : null; + $price = $item['price']; + + if ( !empty( $existing[ $item['offer_id'] ] ) ) + { + // aktualizujemy WSZYSTKIE legacy duplikaty (utrzymujemy spojnosc danych) + foreach ( $existing[ $item['offer_id'] ] as $row_id ) + { + $update_stmt -> execute( [ + ':title' => $title, + ':description' => $desc, + ':custom_label_1' => $cl1, + ':price' => $price, + ':id' => $row_id, + ] ); + $updated_count++; + } + } + else + { + $insert_stmt -> execute( [ + ':client_id' => $client_id, + ':offer_id' => $item['offer_id'], + ':title' => $title, + ':description' => $desc, + ':custom_label_1' => $cl1, + ':price' => $price, + ] ); + $inserted_count++; + } + } + + $pdo -> commit(); + + $report['updated'] += $updated_count; + $report['inserted'] += $inserted_count; + } + catch ( \Throwable $e ) + { + if ( $pdo -> inTransaction() ) + { + $pdo -> rollBack(); + } + if ( count( $report['errors'] ) < 20 ) + { + $report['errors'][] = 'Batch flush error: ' . $e -> getMessage(); + } + $report['skipped'] += count( $batch ); + } + } +} diff --git a/migrations/029_products_rename_columns_and_xml_feed.sql b/migrations/029_products_rename_columns_and_xml_feed.sql new file mode 100644 index 0000000..aae634f --- /dev/null +++ b/migrations/029_products_rename_columns_and_xml_feed.sql @@ -0,0 +1,127 @@ +-- Migracja: rename kolumn products + nowe pola dla feedu XML + xml_feed_url w clients +-- Cel: rozdzielenie danych z feedu GMC (title_gmc, description_gmc) od edytowalnych/AI (title, description) +-- oraz dodanie obslugi feedu XML per klient (cron pobiera dane i wzbogaca products). +-- Zalozenia: +-- - Stare `products.name` (z Google Ads / Merchant) staje sie nowym `title` (glowna nazwa wyswietlana) +-- - Stare `products.title` (z GMC API) staje sie `title_gmc` (zrodlowa nazwa z feedu/Merchant) +-- - Nowe `description_gmc` przechowuje opis z feedu (oddzielnie od `description` edytowanego/AI) +-- - Nowe `price` przechowuje cene z feedu XML +-- - Nowe `clients.xml_feed_url` i `clients.xml_feed_last_sync_at` obsluguja import feedu +-- Idempotentnosc: kazdy ALTER chroniony EXISTS w INFORMATION_SCHEMA. + +-- 1) products.title -> title_gmc (musi byc PIERWSZE, zanim title zwolni miejsce dla rename z name) +SET @sql = IF( + EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'products' + AND COLUMN_NAME = 'title' + ) + AND NOT EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'products' + AND COLUMN_NAME = 'title_gmc' + ), + 'ALTER TABLE `products` CHANGE `title` `title_gmc` VARCHAR(255) NULL DEFAULT NULL', + 'DO 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 2) products.name -> title (po zwolnieniu nazwy `title` przez krok 1) +SET @sql = IF( + EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'products' + AND COLUMN_NAME = 'name' + ) + AND NOT EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'products' + AND COLUMN_NAME = 'title' + ), + 'ALTER TABLE `products` CHANGE `name` `title` VARCHAR(255) NULL DEFAULT NULL', + 'DO 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 3) products.description_gmc (nowa kolumna na opis z feedu) +SET @sql = IF( + EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'products' + AND COLUMN_NAME = 'description_gmc' + ), + 'DO 1', + 'ALTER TABLE `products` ADD COLUMN `description_gmc` TEXT NULL DEFAULT NULL AFTER `description`' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 4) products.price (nowa kolumna na cene z feedu XML) +SET @sql = IF( + EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'products' + AND COLUMN_NAME = 'price' + ), + 'DO 1', + 'ALTER TABLE `products` ADD COLUMN `price` DECIMAL(10,2) NULL DEFAULT NULL AFTER `custom_label_1`' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 5) INDEX (client_id, offer_id) - przyspiesza lookup w XmlFeedImporter (NIE unique - mozliwe duplikaty offer_id w istniejacych danych) +SET @sql = IF( + EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'products' + AND INDEX_NAME = 'idx_products_client_offer' + ), + 'DO 1', + 'ALTER TABLE `products` ADD INDEX `idx_products_client_offer` (`client_id`, `offer_id`)' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 6) clients.xml_feed_url (URL feedu XML per klient) +SET @sql = IF( + EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'clients' + AND COLUMN_NAME = 'xml_feed_url' + ), + 'DO 1', + 'ALTER TABLE `clients` ADD COLUMN `xml_feed_url` VARCHAR(500) NULL DEFAULT NULL AFTER `google_merchant_account_id`' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 7) clients.xml_feed_last_sync_at (timestamp ostatniego importu feedu) +SET @sql = IF( + EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'clients' + AND COLUMN_NAME = 'xml_feed_last_sync_at' + ), + 'DO 1', + 'ALTER TABLE `clients` ADD COLUMN `xml_feed_last_sync_at` DATETIME NULL DEFAULT NULL AFTER `xml_feed_url`' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/templates/clients/main_view.php b/templates/clients/main_view.php index 3e0ba1a..6f029c4 100644 --- a/templates/clients/main_view.php +++ b/templates/clients/main_view.php @@ -154,6 +154,11 @@ ID konta Merchant Center przypisane do klienta +
+ + + Adres feedu XML w formacie Google Merchant — używany do uzupełniania danych produktów +
@@ -197,6 +202,7 @@ function editClient( id ) $( '#client-gads-id' ).val( data.google_ads_customer_id || '' ); $( '#client-fbads-id' ).val( data.facebook_ads_account_id || '' ); $( '#client-gmc-id' ).val( data.google_merchant_account_id || '' ); + $( '#client-xml-feed-url' ).val( data.xml_feed_url || '' ); $( '#client-gads-start' ).val( data.google_ads_start_date || '' ); $( '#client-modal' ).fadeIn(); } ); diff --git a/templates/products/main_view.php b/templates/products/main_view.php index d6cdd5f..6b1f987 100644 --- a/templates/products/main_view.php +++ b/templates/products/main_view.php @@ -195,17 +195,38 @@ } .products-page .products-breakdown-toggle { - width: 22px; - height: 22px; - border: 1px solid #d1d5db; - border-radius: 4px; - background: #fff; - color: #374151; + width: 24px; + height: 24px; + border: 0; + border-radius: 50%; + background: transparent; + color: #6b7280; cursor: pointer; padding: 0; display: inline-flex; align-items: center; justify-content: center; + transition: background-color .15s ease, color .15s ease, transform .2s ease; + outline: none; +} +.products-page .products-breakdown-toggle:hover { + background: #eef2ff; + color: #4338ca; +} +.products-page .products-breakdown-toggle:focus-visible { + box-shadow: 0 0 0 2px rgba( 99, 102, 241, .35 ); +} +.products-page .products-breakdown-toggle i { + font-size: 11px; + line-height: 1; + transition: transform .2s ease; +} +.products-page tr.products-breakdown-open .products-breakdown-toggle { + background: #4338ca; + color: #fff; +} +.products-page tr.products-breakdown-open .products-breakdown-toggle i { + transform: rotate( 90deg ); } .products-page .products-breakdown-wrap { @@ -236,6 +257,12 @@ .products-page .products-breakdown-table td:nth-child(16) { text-align: left; } + +.products-page .products-breakdown-table th:last-child, +.products-page .products-breakdown-table td:last-child { + width: 56px; + text-align: center; +} Min. ROAS' + 'CL1' + 'CL4' + + 'Akcje' + ''; rows.forEach( function( entry ) { @@ -646,6 +674,16 @@ function products_build_breakdown_html( row_meta ) '' + products_breakdown_number( entry.min_roas, 2 ) + '' + '' + escape_html( entry.custom_label_1 || '' ) + '' + '' + escape_html( entry.custom_label_4 || '' ) + '' + + '' + + '' + + '' + ''; } ); @@ -754,14 +792,81 @@ $( function() dt_row.child.hide(); $tr.removeClass( 'products-breakdown-open' ); $btn.attr( 'aria-expanded', 'false' ); - $btn.find( 'i' ).removeClass( 'fa-chevron-down' ).addClass( 'fa-chevron-right' ); return; } dt_row.child( products_build_breakdown_html( row_meta ) ).show(); $tr.addClass( 'products-breakdown-open' ); $btn.attr( 'aria-expanded', 'true' ); - $btn.find( 'i' ).removeClass( 'fa-chevron-right' ).addClass( 'fa-chevron-down' ); + } ); + + $( '#products' ).on( 'click', '.js-products-breakdown-delete', function( e ) { + e.preventDefault(); + e.stopPropagation(); + + var $btn = $( this ); + var product_id = parseInt( $btn.data( 'product-id' ), 10 ) || 0; + var campaign_id = parseInt( $btn.data( 'campaign-id' ), 10 ) || 0; + var ad_group_id = parseInt( $btn.data( 'ad-group-id' ), 10 ) || 0; + var campaign_nm = String( $btn.data( 'campaign-name' ) || '' ); + var ad_group_nm = String( $btn.data( 'ad-group-name' ) || '' ); + var $tr = $btn.closest( 'tr' ); + + if ( product_id <= 0 ) { return; } + + $.confirm( { + title: 'Usun wpisy historii', + content: 'Czy na pewno chcesz usunac wpisy statystyk i historii tego produktu w kampanii ' + + escape_html( campaign_nm ) + ' / grupie ' + escape_html( ad_group_nm ) + '?' + + '

Operacja usuwa wpisy z products_aggregate, products_history oraz products_history_30 dla tej kombinacji. Nie usuwa produktu z tabeli products ani z Google Ads.', + type: 'red', + buttons: { + confirm: { + text: 'Usun', + btnClass: 'btn-danger', + action: function() { + $btn.prop( 'disabled', true ); + $.ajax( { + url: '/products/delete_product_scope_history/', + type: 'POST', + dataType: 'json', + data: { + product_id: product_id, + campaign_id: campaign_id, + ad_group_id: ad_group_id + }, + success: function( res ) { + if ( res && res.status === 'ok' ) { + $tr.remove(); + if ( typeof products_table !== 'undefined' && products_table ) { + products_table.ajax.reload( null, false ); + } + show_toast( 'Usunieto wpisy historii dla wybranego zakresu.', 'success' ); + } else { + $btn.prop( 'disabled', false ); + $.alert( { + title: 'Blad', + type: 'red', + content: ( res && res.message ) ? res.message : 'Nie udalo sie usunac wpisow.' + } ); + } + }, + error: function() { + $btn.prop( 'disabled', false ); + $.alert( { + title: 'Blad', + type: 'red', + content: 'Blad polaczenia z serwerem.' + } ); + } + } ); + } + }, + cancel: { + text: 'Anuluj' + } + } + } ); } ); function reload_products_table()