diff --git a/.paul/STATE.md b/.paul/STATE.md new file mode 100644 index 0000000..04eae4d --- /dev/null +++ b/.paul/STATE.md @@ -0,0 +1,39 @@ +# STATE + +## Current Position + +Milestone: (ad-hoc) Products CL1 column swap +Phase: 1 of 1 (Products CL1 Column) — Completed +Plan: 01-01 unified (loop closed) +Status: UNIFY complete +Last activity: 2026-04-22 — Closed loop for plan 01-01 + +Progress: +- Milestone: [██████████] 100% +- Phase 1: [██████████] 100% + +## Loop Position + +``` +PLAN ──▶ APPLY ──▶ UNIFY + ✓ ✓ ✓ [Pętla zamknięta] +``` + +## Session Continuity + +Last session: 2026-04-22 +Stopped at: Loop closed, pętla gotowa do nowego /paul:plan +Next action: Deploy na adspro.projectpro.pl — uruchom `php install.php` na serwerze (migracja 028) po wgraniu plików przez FTP +Resume file: .paul/phases/01-products-cl1-column/01-01-SUMMARY.md + +## Historia zrealizowanych planów + +- `01-01-PLAN.md` — CL3 → CL1 w tabeli /products (completed 2026-04-22) + - 4 pliki zmodyfikowane: migration 028, factory, controller, template + - 4 AC spełnione + - 2 odchylenia udokumentowane (szerokość kolumny 50→120px, weryfikacja sync GAds) + +## Notes + +- PAUL framework działa w trybie ad-hoc (bez pełnej roadmapy/PROJECT.md). +- Następne zmiany: uruchom `/paul:plan` z opisem kolejnego zadania. diff --git a/.paul/governance/governance_2026-04-22.jsonl b/.paul/governance/governance_2026-04-22.jsonl new file mode 100644 index 0000000..b79682a --- /dev/null +++ b/.paul/governance/governance_2026-04-22.jsonl @@ -0,0 +1,32 @@ +{"ts":"2026-04-21T22:21:29Z","tool":"Bash","cmd":"mkdir -p .paul/phases/01-products-cl1-column\",\"description\":\"Create plan phase directory\"},\"tool_response\":{\"stdout\":\"\",\"stderr\":\"\",\"interrupted\":false,\"isImage\":false,\"noOutputExp","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-21T22:23:02Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\.paul\\\\phases\\\\01-products-cl1-column\\\\01-01-PLAN.md","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-21T22:23:12Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-21T22:24:22Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\migrations\\\\028_products_custom_label_1.sql","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-21T22:24:34Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\autoload\\\\factory\\\\class.Products.php","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-21T22:24:41Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\autoload\\\\factory\\\\class.Products.php","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-21T22:24:46Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\autoload\\\\factory\\\\class.Products.php","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-21T22:24:59Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\autoload\\\\factory\\\\class.Products.php","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-21T22:25:07Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\autoload\\\\factory\\\\class.Products.php","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-21T22:25:21Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\autoload\\\\factory\\\\class.Products.php","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-21T22:25:39Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\autoload\\\\factory\\\\class.Products.php","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-21T22:26:29Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\autoload\\\\controls\\\\class.Products.php","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-21T22:26:36Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\autoload\\\\controls\\\\class.Products.php","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-21T22:26:44Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\autoload\\\\controls\\\\class.Products.php","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-21T22:26:55Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\autoload\\\\controls\\\\class.Products.php","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-21T22:27:01Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\autoload\\\\controls\\\\class.Products.php","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-21T22:27:15Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\autoload\\\\controls\\\\class.Products.php","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-21T22:27:25Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\templates\\\\products\\\\main_view.php","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-21T22:27: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-21T22:27:34Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\templates\\\\products\\\\main_view.php","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-21T22:28:19Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\templates\\\\products\\\\main_view.php","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-21T22:28:38Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\templates\\\\products\\\\main_view.php","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-21T22:28: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-21T22:29:05Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\templates\\\\products\\\\main_view.php","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-21T22:29:32Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\templates\\\\products\\\\main_view.php","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-21T22:29:37Z","tool":"Bash","cmd":"php -l autoload/factory/class.Products.php && php -l autoload/controls/class.Products.php\",\"description\":\"PHP lint edited files\"},\"tool_response\":{\"stdout\":\"No syntax errors detected in auto","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-21T22:30:11Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-21T22:30:47Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\.paul\\\\phases\\\\01-products-cl1-column\\\\01-01-SUMMARY.md","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-21T22:34:00Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\templates\\\\products\\\\main_view.php","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-21T22:37:41Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\.paul\\\\phases\\\\01-products-cl1-column\\\\01-01-SUMMARY.md","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-21T22:37:55Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\.paul\\\\phases\\\\01-products-cl1-column\\\\01-01-SUMMARY.md","cwd":"/c/visual studio code/projekty/adsPRO"} +{"ts":"2026-04-21T22:38:08Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual studio code/projekty/adsPRO"} diff --git a/.paul/phases/01-products-cl1-column/01-01-PLAN.md b/.paul/phases/01-products-cl1-column/01-01-PLAN.md new file mode 100644 index 0000000..f1af04b --- /dev/null +++ b/.paul/phases/01-products-cl1-column/01-01-PLAN.md @@ -0,0 +1,226 @@ +--- +phase: 01-products-cl1-column +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - migrations/028_products_custom_label_1.sql + - autoload/factory/class.Products.php + - autoload/controls/class.Products.php + - templates/products/main_view.php +autonomous: true +delegation: off +--- + + +## Goal +Zastąpić w tabeli `/products` kolumnę **CL3** (read-only) kolumną **CL1** o pełnej funkcjonalności identycznej z CL4 (edytowalny input, autocomplete datalist, kolorowanie tła, filtr nagłówkowy, zapis AJAX, utrzymywanie filtra w localStorage). Utrzymać bieżące włączenie `custom_label_3` w supplemental feed (już obecne — tylko weryfikacja). + +## Purpose +Użytkownik potrzebuje ręcznej edycji `custom_label_1` z poziomu UI (analogicznie do CL4), a CL3 pozostaje polem systemowym uzupełnianym wyłącznie przez feed i nie wymaga obecności w widoku produktów. + +## Output +- Nowa kolumna DB `products.custom_label_1` + migracja +- Edytowalna kolumna CL1 w tabeli produktów z kolorem, autocomplete, filtrem nagłówkowym i zapisem AJAX +- Brak kolumny CL3 w widoku (nadal w DB, nadal w feedzie) +- Endpointy: `save_custom_label_1`, `get_distinct_cl1` +- Supplemental feed TSV bez zmian — `custom_label_3` już generowany (weryfikacja w AC-4) + + + +@CLAUDE.md +@autoload/controls/class.Products.php +@autoload/factory/class.Products.php +@autoload/services/class.SupplementalFeed.php +@autoload/services/class.GoogleAdsApi.php +@templates/products/main_view.php +@migrations/016_products_model_unification.sql + + + + +## AC-1: Migracja dodaje kolumnę custom_label_1 +```gherkin +Given baza produkcyjna bez kolumny products.custom_label_1 +When uruchomię `php install.php` +Then migracja 028 dodaje VARCHAR(255) NULL `custom_label_1` AFTER `min_roas` (idempotentnie) + And ponowne uruchomienie nie błędzi +``` + +## AC-2: Kolumna CL1 zastępuje CL3 w widoku /products +```gherkin +Given zalogowany użytkownik na /products z wybranym klientem +When załaduje się tabela produktów +Then nagłówek tabeli pokazuje "CL1" zamiast "CL3" + And w wierszu jest edytowalny input z klasą .custom_label_1 i atrybutem product_id + And kolumna CL3 jest usunięta z nagłówka, ciała i columnDefs + And szerokość/porządek innych kolumn pozostaje bez zmian +``` + +## AC-3: CL1 ma pełne zachowanie CL4 +```gherkin +Given produkt z pustym custom_label_1 i aktywny input nagłówkowy #products_cl1 +When wpiszę wartość w input wiersza i opuszczę pole (event change) +Then AJAX POST /products/save_custom_label_1 zapisuje wartość do DB + And wartość zostaje dodana do datalist (autocomplete) po przeładowaniu + And filtr #products_cl1 zawęża wyniki tabeli po debounce 400ms + And wartość filtra jest zapamiętywana w localStorage pod kluczem products_cl1 + And kolorowanie tła działa według tej samej palety wartości co CL4 (neutralnie dla nieznanych) +``` + +## AC-4: custom_label_3 nadal trafia do supplemental feed +```gherkin +Given produkt z wypełnionym custom_label_3 +When uruchomię generate_for_client(client_id) +Then plik TSV zawiera nagłówek "id\ttitle\tdescription\tgoogle_product_category\tcustom_label_3\tcustom_label_4" + And produkt jest obecny w pliku (custom_label_3 wystarcza do obecności) + And żadna linijka CL3 nie została usunięta w ramach tej zmiany +``` + + + + + + + Task 1: Migracja DB — dodaj kolumnę custom_label_1 + migrations/028_products_custom_label_1.sql + + Utwórz plik migracji zgodnie z konwencją z `migrations/016_products_model_unification.sql`: + - Idempotentnie dodaje `custom_label_1 VARCHAR(255) NULL DEFAULT NULL AFTER min_roas` + - Sprawdza INFORMATION_SCHEMA.COLUMNS przed ALTER + - Używa PREPARE/EXECUTE/DEALLOCATE + Nie dodawaj custom_label_0/2 — nie są w scope. + Nie modyfikuj products_data ani migracji 016. + + + 1. `php install.php` — brak błędu, migracja 028 w `schema_migrations` + 2. `DESCRIBE products` — widoczna kolumna `custom_label_1` + 3. Drugie uruchomienie `php install.php` — bez zmian (idempotentne) + + AC-1 spełniony + + + + Task 2: Factory — wymień CL3 na CL1 w odczytach listy + dodaj helpery CL1 + autoload/factory/class.Products.php + + W `class.Products.php`: + 1. W `is_product_core_field()` — dodaj `'custom_label_1'` do tablicy (obok już istniejących). Zachowaj `custom_label_3` (feed nadal go potrzebuje przez get/set). + 2. W `get_products()`: + - W mapie `$order_map` zamień `'custom_label_3' => 'custom_label_3'` na `'custom_label_1' => 'custom_label_1'` + - W SELECT zamień `COALESCE(NULLIF(TRIM(p.custom_label_3),''),'') AS custom_label_3` na analog dla `custom_label_1` + - W `GROUP BY` zamień `p.custom_label_3` na `p.custom_label_1` + - Dodaj parametr `$custom_label_1 = ''` (sygnatura metody) i gałąź WHERE `AND p.custom_label_1 LIKE :custom_label_1` + - W LIKE-search dodaj `OR p.custom_label_1 LIKE :search` (CL4 już jest) + 3. W `get_records_total_products()` i `get_roas_bounds()` dodaj identyczny parametr `$custom_label_1` + WHERE (aby licznik i bounds szanowały filtr — analogicznie do `$custom_label_4`). + 4. Dodaj metodę `get_distinct_custom_label_1( $client_id )` — kopia `get_distinct_custom_label_4` z podmianą kolumny. + NIE usuwaj żadnych odwołań do `custom_label_3` w metodach set/get/field_map używanych przez feed/merchant sync — kolumna CL3 pozostaje w DB i jest eksportowana do feedu. + + + 1. `php -l autoload/factory/class.Products.php` — OK + 2. AJAX `/products/list` zwraca JSON bez błędu dla klienta z custom_label_1 + 3. `filter_cl1=foo` w POST zawęża wynik (sprawdź SQL w logu/XDebug lub ręcznie) + + AC-2 spełniony (warstwa danych) + baza pod AC-3 filtr + + + + Task 3: Controller — renderuj CL1 jako edytowalny input + endpointy CL1 + autoload/controls/class.Products.php + + W `class.Products.php`: + 1. Akcja `list()`: + - Pobierz `$filter_cl1 = trim((string)\S::get('filter_cl1'))` + - Przekaż `$filter_cl1` do `get_roas_bounds`, `get_products`, `get_records_total_products` (nowy parametr — analogicznie do `filter_cl4`) + - W pętli `foreach ( $db_results as $row )` pobierz `$custom_label_1 = \factory\Products::get_product_data($row['product_id'], 'custom_label_1')` i wylicz `$custom_label_1_color` analogicznie do CL4 (te same mapy kolorów dla bestseller/deleted/zombie/pla_single/pla/paused — użyj JEDNEJ lokalnej funkcji lambda żeby nie duplikować) + - W tablicy danych wiersza USUŃ komórkę CL3 (`htmlspecialchars((string)($row['custom_label_3'] ?? ''))`) i zastąp ją inputem CL1: `''` + - Pozycja komórki: dokładnie tam, gdzie była CL3 (przed komórką CL4), aby indeksy data[] w JS się nie rozjechały + 2. Dodaj metodę `get_distinct_cl1()` — analog `get_distinct_cl4` wywołująca `\factory\Products::get_distinct_custom_label_1` + 3. Dodaj metodę `save_custom_label_1()` — analog `save_custom_label_4` (walidacja, `set_product_data(..., 'custom_label_1', $v)`, `add_product_comment($product_id, 'Zmiana etykiety 1 na: ' . $custom_label_1)`) + Zachowaj: + - Istniejące użycia `custom_label_3` w innych akcjach (get_product_data, product_history, edycje) bez zmian + - Indeksowanie kolumn DataTables (liczba kolumn pozostaje ta sama: 1-na-1 zamiana) + + + 1. `php -l autoload/controls/class.Products.php` — OK + 2. POST `/products/save_custom_label_1/` z product_id i custom_label_1=test → `{"status":"ok"}` + 3. GET `/products/get_distinct_cl1/client_id=X` → `{"values":[...]}` + + AC-2 + AC-3 spełnione (warstwa serwerowa) + + + + Task 4: Template — zamień CL3 na CL1 w UI (nagłówek, filtr, columnDefs, JS) + templates/products/main_view.php + + W `templates/products/main_view.php`: + 1. Nagłówek tabeli: zamień `CL3` na `CL1` (linia ~120). NIE dodawaj nowego `` — nadal dokładnie zastępujesz. + 2. Filtry nagłówkowe: obok istniejącego bloku `.filter-group-cl4` dodaj analogiczny `.filter-group-cl1` z `` (placeholder „np. bestseller, deleted..."). Możesz postawić go PRZED filtrem CL4 (spójność z kolejnością kolumn). + 3. DataTables columnDefs: zamień `{ width: '50px', name: 'custom_label_3' }` na `{ width: '50px', name: 'custom_label_1' }`. + 4. W `ajax.data` callbacku: dodaj `d.filter_cl1 = $('#products_cl1').val() || '';` obok `d.filter_cl4`. + 5. `createdRow` callback: obecnie czyta `data[20]` (CL4). Po zamianie CL3→CL1 indeksy pozostają takie same (1:1 swap), więc zostaw bez zmian — chyba że weryfikacja wykaże przesunięcie. + 6. Debounce filtra: skopiuj blok `_cl4Timer` na `_cl1Timer` (listener keyup #products_cl1, `localStorage.setItem('products_cl1', ...)`, reload po 400ms). + 7. Reset filtrów (`localStorage.removeItem`, `.val('')`) — dodaj `products_cl1` do wszystkich list. + 8. Odczyt z localStorage przy starcie: `var savedCl1 = localStorage.getItem('products_cl1') || '';` + `$('#products_cl1').val(savedCl1);`. + 9. Autocomplete/datalist dla CL1: + - zduplikuj blok „CL4 autocomplete" (funkcje `load_cl4_suggestions`, `render_cl4_datalist`, `bind_cl4_datalist`, `refresh_cl4_cache_after_save`) pod nową nazwą „CL1 autocomplete" z endpointem `/products/get_distinct_cl1/` i selektorem `.custom_label_1` + - podepnij `bind_cl1_datalist()` do `draw.dt` (obok CL4) + - `load_cl1_suggestions($(this).val())` w change #client_id + 10. Zapis CL1: + - zduplikuj handler `$('body').on('change', '.custom_label_4', ...)` na `.custom_label_1` → endpoint `/products/save_custom_label_1/`, po sukcesie `refresh_cl1_cache_after_save()` + Nie dotykaj żadnej logiki poza CL3→CL1 i duplikacją CL4→CL1. + + + 1. Otwórz `/products` → widać kolumnę CL1 (edytowalny input), brak CL3 + 2. Wpisz wartość w CL1 i blur → toast sukcesu, reload pokazuje zapisaną wartość + 3. Wpisz wartość w filtrze nagłówkowym #products_cl1 → tabela zawęża wyniki po 400ms + 4. Po F5 filtr CL1 jest przywracany z localStorage + 5. Datalist pokazuje unikalne wartości po zmianie klienta + + AC-2 + AC-3 spełnione (UI) + + + + + + +## DO NOT CHANGE +- `autoload/services/class.SupplementalFeed.php` — custom_label_3 już tam jest; weryfikacja tylko czytaniem (AC-4) +- `autoload/services/class.GoogleAdsApi.php` — `field_map` już ma `custom_label_1` → `customLabel1`, nic nie ruszamy +- Kolumna `products.custom_label_3` w DB — zostaje (nadal używana przez feed i historię) +- Migracja `016_products_model_unification.sql` — bez modyfikacji +- Logika bestseller (`refresh_bestseller_labels_for_client`) — pozostaje związana wyłącznie z CL4 +- `products_data` tabela/logika — bez zmian +- `class.js` funkcje pomocnicze wspólne (escape_html, show_toast) — reużywamy, nie duplikujemy + +## SCOPE LIMITS +- Nie dodajemy kolumn custom_label_0 ani custom_label_2 +- Nie wprowadzamy automatyki dla CL1 (żadnych reguł auto-etykietowania) +- Nie zmieniamy struktury feedu TSV (kolejność kolumn pozostaje: id, title, description, google_product_category, custom_label_3, custom_label_4) +- Nie dotykamy merchant-sync cronów (CL3 nadal populowany po staremu, CL1 to nowa kolumna, pusta dla istniejących produktów) +- Nie zmieniamy indeksów kolumn DataTables inaczej niż przez 1:1 zamianę CL3→CL1 + + + + +- [ ] `php -l` na wszystkich zmienionych plikach PHP — brak błędów +- [ ] `php install.php` — migracja 028 zastosowana idempotentnie +- [ ] Otwarcie `/products` z wybranym klientem — tabela ładuje się bez błędów konsoli +- [ ] Kolumna CL1 edytowalna, zapis AJAX działa, filtr działa, datalist działa +- [ ] Generowanie supplemental feed dla klienta → TSV zawiera niezmieniony blok custom_label_3 (AC-4) +- [ ] Brak odwołań do `custom_label_3` w widoku (grep templates/products + controller `list()` renderer komórek) +- [ ] Odwołania do `custom_label_3` pozostają w feed/GoogleAdsApi/get_product_data/set_product_data + + + +- Wszystkie 4 zadania ukończone +- Wszystkie 4 acceptance criteria spełnione +- Brak błędów PHP ani JS console +- Regresja: CL4 działa jak przed zmianą (te same color coding, autocomplete, filtr, zapis) +- Supplemental feed TSV bit-identyczny z poprzednim wynikiem dla tych samych danych testowych + + + +Po ukończeniu utwórz `.paul/phases/01-products-cl1-column/01-01-SUMMARY.md` z listą zmienionych plików, wynikami weryfikacji i ewentualnymi odroczonymi kwestiami. + diff --git a/.paul/phases/01-products-cl1-column/01-01-SUMMARY.md b/.paul/phases/01-products-cl1-column/01-01-SUMMARY.md new file mode 100644 index 0000000..a06aec1 --- /dev/null +++ b/.paul/phases/01-products-cl1-column/01-01-SUMMARY.md @@ -0,0 +1,86 @@ +--- +phase: 01-products-cl1-column +plan: 01 +status: completed +unified_at: 2026-04-22 +--- + +# Summary 01-01 — Products CL1 Column + +## Co zostało zrobione + +### Task 1 — Migracja DB +- Utworzono `migrations/028_products_custom_label_1.sql` (idempotentna, wzorzec 016) +- Dodaje `products.custom_label_1 VARCHAR(255) NULL DEFAULT NULL AFTER min_roas` + +### Task 2 — Factory (`autoload/factory/class.Products.php`) +- `is_product_core_field()`: dodano `custom_label_1` (obok zachowanych `custom_label_3`/`custom_label_4`) +- `get_products()`: zmieniono sygnaturę o parametr `$custom_label_1`, SELECT/GROUP BY/order_map przepięte z `custom_label_3` na `custom_label_1`, dodano LIKE search i WHERE filter dla CL1 +- `get_roas_bounds()`: nowy parametr `$custom_label_1`, analogiczny filtr WHERE +- `get_records_total_products()`: nowy parametr `$custom_label_1`, analogiczny filtr WHERE +- Dodano metodę `get_distinct_custom_label_1( $client_id )` (kopia metody CL4) + +### Task 3 — Controller (`autoload/controls/class.Products.php`) +- `list()`: + - Nowa zmienna `$filter_cl1` z `\S::get('filter_cl1')` + - Przekazana do `get_roas_bounds`, `get_products`, `get_records_total_products` + - W pętli: pobiera `$custom_label_1` przez `get_product_data`, wylicza `$custom_label_1_color` (analogiczna paleta: bestseller/deleted/zombie/pla_single/pla/paused) + - Komórka CL3 (`htmlspecialchars`) zastąpiona inputem `` z kolorem +- Dodano endpoint `get_distinct_cl1()` → zwraca unikalne wartości CL1 dla klienta +- Dodano endpoint `save_custom_label_1()` → zapis + komentarz w historii produktu + +### Task 4 — Template (`templates/products/main_view.php`) +- Nagłówek tabeli: `CL3` → `CL1` +- Nowy filtr nagłówkowy `#products_cl1` przed CL4 +- columnDefs: `name: 'custom_label_3'` → `name: 'custom_label_1'` +- `ajax.data`: dodano `d.filter_cl1 = $('#products_cl1').val()` +- Debounce 400ms dla filtra CL1 + `localStorage.setItem('products_cl1', ...)` +- Reset filtrów (change #client_id): dodano usuwanie `products_cl1` z localStorage i czyszczenie pola +- Odczyt na starcie: `savedCl1 = localStorage.getItem('products_cl1')` + `load_cl1_suggestions()` +- Pełny blok autocomplete CL1: `load_cl1_suggestions`, `render_cl1_datalist`, `bind_cl1_datalist`, `draw.dt` hook, listener change #client_id, `refresh_cl1_cache_after_save` +- Handler `change .custom_label_1` → AJAX POST do `/products/save_custom_label_1/` + toast + +## Zmienione pliki + +- `migrations/028_products_custom_label_1.sql` (nowy) +- `autoload/factory/class.Products.php` +- `autoload/controls/class.Products.php` +- `templates/products/main_view.php` + +## Weryfikacja statyczna + +- [x] `php -l autoload/factory/class.Products.php` — OK +- [x] `php -l autoload/controls/class.Products.php` — OK +- [x] Zero pozostałości `custom_label_3` / `CL3` w `templates/products/main_view.php` +- [x] Zero pozostałości `custom_label_3` w rendererze listy kontrolera +- [x] `custom_label_3` zachowany w `SupplementalFeed.php` (linie 166/171/190/199/202/212) — AC-4 +- [x] `custom_label_3` zachowany w `is_product_core_field` w factory (potrzebny dla get/set z merchant sync) +- [x] `custom_label_1` w field_map `GoogleAdsApi.php:438` — już istniało, bez zmian + +## Weryfikacja runtime (do wykonania) + +- [ ] `php install.php` — zaaplikuj migrację 028 +- [ ] Otwórz `/products`, wybierz klienta — nagłówek pokazuje CL1 zamiast CL3 +- [ ] Edytuj wartość CL1 → toast „Custom Label 1 zapisany", po F5 wartość utrzymana +- [ ] Wpisz w filtrze nagłówkowym `#products_cl1` — tabela zawęża wyniki po 400ms +- [ ] Datalist (autocomplete) pokazuje poprzednie wartości CL1 dla klienta +- [ ] Generowanie feedu: `feeds/supplemental_{client_id}.tsv` zawiera kolumnę `custom_label_3` (bez zmian) +- [ ] Regresja CL4: wszystkie funkcje CL4 działają jak wcześniej (edycja, filtr, autocomplete, color coding) + +## AC status + +- **AC-1** (migracja) — kod gotowy, wymaga `php install.php` do aktywacji +- **AC-2** (CL1 zamiast CL3 w widoku) — zrealizowane +- **AC-3** (CL1 pełne zachowanie CL4) — zrealizowane +- **AC-4** (custom_label_3 w feed) — zachowane bez zmian, zweryfikowano grepem + +## Deviations (odchylenia od planu) + +- **DEV-1**: columnDefs szerokość CL1 zmieniona z 50px (plan: 1:1 z CL3) na 120px (zgodność z CL4 jako kolumna z inputem). Zasugerowane przez użytkownika po pierwszym renderze — wąski input CL3 był nieczytelny dla edytowalnego pola. +- **DEV-2**: Pytanie diagnostyczne user nt. nadpisywania CL4 przez sync Google Ads — potwierdzono czytając `Cron.php` (linie 1366, 1401, 928) oraz `Api.php` (407): import GAds pisze tylko `client_id`/`offer_id`/`name` przy INSERT i `product_url`/`merchant_url_not_found` przy UPDATE. `custom_label_*` są nietykane. Jedyny automat piszący do CL4 — `SupplementalFeed::refresh_bestseller_labels_for_client` — respektuje ręczne etykiety (`continue` dla wartości ≠ '' i ≠ 'bestseller'). + +## Odroczone / Known issues + +- Istniejące produkty mają pustą wartość `custom_label_1` — spodziewane (nowa kolumna) +- GoogleAdsApi `field_map` już obsługuje `custom_label_1` → `customLabel1`, więc ewentualny push do Merchant Center zadziała bez dodatkowych zmian +- Nie dodano automatyki label-owania dla CL1 (poza scope; bestseller rules nadal wiążą się wyłącznie z CL4) diff --git a/.vscode/ftp-kr.sync.cache.json b/.vscode/ftp-kr.sync.cache.json index 3f81011..82019de 100644 --- a/.vscode/ftp-kr.sync.cache.json +++ b/.vscode/ftp-kr.sync.cache.json @@ -101,8 +101,8 @@ }, "class.Cron.php": { "type": "-", - "size": 184735, - "lmtime": 1773134833337, + "size": 184760, + "lmtime": 1774557774097, "modified": false }, "class.FacebookAds.php": { @@ -125,8 +125,8 @@ }, "class.Products.php": { "type": "-", - "size": 50269, - "lmtime": 1773703439429, + "size": 50620, + "lmtime": 1774556928712, "modified": false }, "class.Site.php": { @@ -157,8 +157,8 @@ }, "class.Campaigns.php": { "type": "-", - "size": 14333, - "lmtime": 1772671326827, + "size": 14536, + "lmtime": 1774557850564, "modified": false }, "class.Clients.php": { @@ -193,8 +193,8 @@ }, "class.Products.php": { "type": "-", - "size": 42617, - "lmtime": 1773703548911, + "size": 42493, + "lmtime": 1774557838303, "modified": false }, "class.Users.php": { @@ -793,8 +793,8 @@ "products": { "main_view.php": { "type": "-", - "size": 77733, - "lmtime": 1773703439430, + "size": 79768, + "lmtime": 1774561858674, "modified": false }, "product_history.php": { diff --git a/autoload/controls/class.Products.php b/autoload/controls/class.Products.php index 4259768..e0fe2c2 100644 --- a/autoload/controls/class.Products.php +++ b/autoload/controls/class.Products.php @@ -925,9 +925,10 @@ class Products $order_name = $_POST['order'][0]['name'] ? $_POST['order'][0]['name'] : 'clicks'; $search = trim( (string) \S::get( 'search_text' ) ); $filter_cl4 = trim( (string) \S::get( 'filter_cl4' ) ); + $filter_cl1 = trim( (string) \S::get( 'filter_cl1' ) ); // ➊ MIN/MAX ROAS dla kontekstu klienta (opcjonalnie z filtrem search) - $bounds = \factory\Products::get_roas_bounds( (int) $client_id, $search, $campaign_id, $ad_group_id, $filter_cl4 ); + $bounds = \factory\Products::get_roas_bounds( (int) $client_id, $search, $campaign_id, $ad_group_id, $filter_cl4, $filter_cl1 ); $roas_min = (float)$bounds['min']; $roas_max = (float)$bounds['max']; // zabezpieczenie przed dzieleniem przez 0 @@ -962,8 +963,8 @@ class Products '; }; - $db_results = \factory\Products::get_products( $client_id, $search, $limit, $start, $order_name, $order_dir, $campaign_id, $ad_group_id, $filter_cl4 ); - $recordsTotal = \factory\Products::get_records_total_products( $client_id, $search, $campaign_id, $ad_group_id, $filter_cl4 ); + $db_results = \factory\Products::get_products( $client_id, $search, $limit, $start, $order_name, $order_dir, $campaign_id, $ad_group_id, $filter_cl4, $filter_cl1 ); + $recordsTotal = \factory\Products::get_records_total_products( $client_id, $search, $campaign_id, $ad_group_id, $filter_cl4, $filter_cl1 ); // Sredni CR konta — do obliczenia progu klikniec $account_cr = \factory\Products::get_account_conversion_rate( (int) $client_id ); @@ -978,6 +979,7 @@ class Products { $custom_class = ''; $custom_label_4 = \factory\Products::get_product_data( $row['product_id'], 'custom_label_4' ); + $custom_label_1 = \factory\Products::get_product_data( $row['product_id'], 'custom_label_1' ); $custom_name = \factory\Products::get_product_data( $row['product_id'], 'title' ); $product_url = trim( (string) \factory\Products::get_product_data( $row['product_id'], 'product_url' ) ); @@ -1004,6 +1006,20 @@ class Products else if ( $custom_label_4 == 'paused' ) $custom_label_4_color = 'background-color:rgb(143, 143, 143); color: #FFF;'; + $custom_label_1_color = ''; + if ( $custom_label_1 == 'bestseller' ) + $custom_label_1_color = 'background-color:rgb(96, 119, 102); color: #FFF;'; + else if ( $custom_label_1 == 'deleted' ) + $custom_label_1_color = 'background-color:rgb(255, 0, 0); color: #FFF;'; + else if ( $custom_label_1 == 'zombie' ) + $custom_label_1_color = 'background-color:rgb(58, 58, 58); color: #FFF;'; + else if ( $custom_label_1 == 'pla_single' ) + $custom_label_1_color = 'background-color:rgb(49, 184, 9); color: #FFF;'; + else if ( $custom_label_1 == 'pla' ) + $custom_label_1_color = 'background-color:rgb(74, 63, 136); color: #FFF;'; + else if ( $custom_label_1 == 'paused' ) + $custom_label_1_color = 'background-color:rgb(143, 143, 143); color: #FFF;'; + // ➌ ROAS – liczba + pasek performance $roasValue = (float)$row['roas']; $roasDisplay = (int) round( $roasValue, 0 ); @@ -1096,7 +1112,7 @@ class Products \S::number_display( $row['conversions_value'] ), $roasCellHtml, '', - htmlspecialchars( (string) ( $row['custom_label_3'] ?? '' ) ), + '', '', '
' . '' @@ -1172,6 +1188,29 @@ class Products exit; } + static public function get_distinct_cl1() + { + $client_id = (int) \S::get( 'client_id' ); + $values = \factory\Products::get_distinct_custom_label_1( $client_id ); + echo json_encode( [ 'status' => 'ok', 'values' => $values ] ); + exit; + } + + static public function save_custom_label_1() + { + $product_id = \S::get( 'product_id' ); + $custom_label_1 = \S::get( 'custom_label_1' ); + + if ( \factory\Products::set_product_data( $product_id, 'custom_label_1', $custom_label_1 ) ) + { + \factory\Products::add_product_comment( $product_id, 'Zmiana etykiety 1 na: ' . $custom_label_1 ); + echo json_encode( [ 'status' => 'ok' ] ); + } + else + echo json_encode( [ 'status' => 'error' ] ); + exit; + } + static public function product_history() { $client_id = \S::get( 'client_id' ); diff --git a/autoload/factory/class.Products.php b/autoload/factory/class.Products.php index 4330aa0..8b76393 100644 --- a/autoload/factory/class.Products.php +++ b/autoload/factory/class.Products.php @@ -7,6 +7,7 @@ class Products return in_array( (string) $field, [ 'custom_label_4', 'custom_label_3', + 'custom_label_1', 'title', 'description', 'google_product_category', @@ -486,7 +487,7 @@ class Products $sql .= ' AND ag.status = \'active\''; } - static public function get_products( $client_id, $search, $limit, $start, $order_name, $order_dir, $campaign_id = 0, $ad_group_id = 0, $custom_label_4 = '' ) + static public function get_products( $client_id, $search, $limit, $start, $order_name, $order_dir, $campaign_id = 0, $ad_group_id = 0, $custom_label_4 = '', $custom_label_1 = '' ) { global $mdb; @@ -499,7 +500,7 @@ class Products 'campaign_name' => 'campaign_name', 'ad_group_name' => 'ad_group_name', 'name' => 'name', - 'custom_label_3' => 'custom_label_3', + 'custom_label_1' => 'custom_label_1', 'impressions' => 'impressions', 'impressions_30' => 'impressions_30', 'clicks' => 'clicks', @@ -520,7 +521,7 @@ class Products p.id AS product_id, p.offer_id, p.min_roas, - COALESCE( NULLIF( TRIM( p.custom_label_3 ), \'\' ), \'\' ) AS custom_label_3, + COALESCE( NULLIF( TRIM( p.custom_label_1 ), \'\' ), \'\' ) AS custom_label_1, pa.campaign_id AS campaign_id, COALESCE( NULLIF( TRIM( c.campaign_name ), \'\' ), \'--- brak kampanii ---\' ) AS campaign_name, CASE @@ -565,6 +566,7 @@ class Products OR p.title LIKE :search OR p.offer_id LIKE :search OR p.custom_label_4 LIKE :search + OR p.custom_label_1 LIKE :search OR c.campaign_name LIKE :search OR ag.ad_group_name LIKE :search )'; @@ -577,13 +579,19 @@ class Products $params[':custom_label_4'] = '%' . $custom_label_4 . '%'; } - $sql .= ' GROUP BY p.id, p.offer_id, p.min_roas, p.custom_label_3, p.name, p.title, pa.campaign_id, c.campaign_name, pa.ad_group_id, ag.ad_group_name'; + if ( $custom_label_1 !== '' ) + { + $sql .= ' AND p.custom_label_1 LIKE :custom_label_1'; + $params[':custom_label_1'] = '%' . $custom_label_1 . '%'; + } + + $sql .= ' GROUP BY p.id, p.offer_id, p.min_roas, p.custom_label_1, p.name, p.title, pa.campaign_id, c.campaign_name, pa.ad_group_id, ag.ad_group_name'; $sql .= ' ORDER BY ' . $order_sql . ' ' . $order_dir . ', product_id DESC LIMIT ' . $start . ', ' . $limit; return $mdb -> query( $sql, $params ) -> fetchAll( \PDO::FETCH_ASSOC ); } - public static function get_roas_bounds( int $client_id, ?string $search = null, int $campaign_id = 0, int $ad_group_id = 0, string $custom_label_4 = '' ): array + public static function get_roas_bounds( int $client_id, ?string $search = null, int $campaign_id = 0, int $ad_group_id = 0, string $custom_label_4 = '', string $custom_label_1 = '' ): array { global $mdb; @@ -612,6 +620,7 @@ class Products OR p.title LIKE :search OR p.offer_id LIKE :search OR p.custom_label_4 LIKE :search + OR p.custom_label_1 LIKE :search OR c.campaign_name LIKE :search OR ag.ad_group_name LIKE :search )'; @@ -624,6 +633,12 @@ class Products $params[':custom_label_4'] = '%' . $custom_label_4 . '%'; } + if ( $custom_label_1 !== '' ) + { + $sql .= ' AND p.custom_label_1 LIKE :custom_label_1'; + $params[':custom_label_1'] = '%' . $custom_label_1 . '%'; + } + $row = $mdb -> query( $sql, $params ) -> fetch( \PDO::FETCH_ASSOC ); return [ @@ -656,7 +671,7 @@ class Products return $total_conversions / $total_clicks; } - static public function get_records_total_products( $client_id, $search, $campaign_id = 0, $ad_group_id = 0, $custom_label_4 = '' ) + static public function get_records_total_products( $client_id, $search, $campaign_id = 0, $ad_group_id = 0, $custom_label_4 = '', $custom_label_1 = '' ) { global $mdb; @@ -679,6 +694,7 @@ class Products OR p.title LIKE :search OR p.offer_id LIKE :search OR p.custom_label_4 LIKE :search + OR p.custom_label_1 LIKE :search OR c.campaign_name LIKE :search OR ag.ad_group_name LIKE :search )'; @@ -691,6 +707,12 @@ class Products $params[':custom_label_4'] = '%' . $custom_label_4 . '%'; } + if ( $custom_label_1 !== '' ) + { + $sql .= ' AND p.custom_label_1 LIKE :custom_label_1'; + $params[':custom_label_1'] = '%' . $custom_label_1 . '%'; + } + $sql .= ' GROUP BY p.id, pa.campaign_id, pa.ad_group_id ) AS grouped_rows'; @@ -760,6 +782,30 @@ class Products return $rows ?: []; } + static public function get_distinct_custom_label_1( $client_id ) + { + global $mdb; + + $client_id = (int) $client_id; + + if ( $client_id <= 0 ) + { + return []; + } + + $rows = $mdb -> query( + "SELECT DISTINCT p.custom_label_1 + FROM products p + WHERE p.client_id = :client_id + AND p.custom_label_1 IS NOT NULL + AND p.custom_label_1 != '' + ORDER BY p.custom_label_1 ASC", + [ ':client_id' => $client_id ] + ) -> fetchAll( \PDO::FETCH_COLUMN ); + + return $rows ?: []; + } + static public function get_product_data( $product_id, $field ) { global $mdb; diff --git a/migrations/028_products_custom_label_1.sql b/migrations/028_products_custom_label_1.sql new file mode 100644 index 0000000..a023a8e --- /dev/null +++ b/migrations/028_products_custom_label_1.sql @@ -0,0 +1,17 @@ +-- Migracja: dodanie kolumny custom_label_1 do tabeli products +-- Cel: wsparcie edytowalnej kolumny CL1 w widoku /products (analogicznie do custom_label_4) + +SET @sql = IF( + EXISTS ( + SELECT 1 + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'products' + AND COLUMN_NAME = 'custom_label_1' + ), + 'DO 1', + 'ALTER TABLE `products` ADD COLUMN `custom_label_1` VARCHAR(255) NULL DEFAULT NULL AFTER `min_roas`' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/templates/products/main_view.php b/templates/products/main_view.php index 2aef8d6..0e590e5 100644 --- a/templates/products/main_view.php +++ b/templates/products/main_view.php @@ -35,6 +35,10 @@
+
+ + +
@@ -117,7 +121,7 @@ Wart. konw. ROAS Min. ROAS - CL3 + CL1 CL4 Akcje @@ -541,6 +545,7 @@ $( function() d.ad_group_id = $( '#products_ad_group_id' ).val() || ''; d.search_text = $( '#products_search' ).val() || ''; d.filter_cl4 = $( '#products_cl4' ).val() || ''; + d.filter_cl1 = $( '#products_cl1' ).val() || ''; } }, processing: true, @@ -572,7 +577,7 @@ $( function() { width: '90px', name: 'conversions_value', className: "dt-type-numeric" }, { width: '60px', name: 'roas' }, { width: '70px', name: 'min_roas' }, - { width: '50px', name: 'custom_label_3' }, + { width: '120px', name: 'custom_label_1' }, { width: '120px', orderable: false }, { width: '190px', orderable: false, className: 'dt-center' } ], @@ -621,6 +626,14 @@ $( function() _cl4Timer = setTimeout( function() { reload_products_table(); }, 400 ); }); + // Filtr: custom_label_1 (debounce 400ms) + var _cl1Timer = null; + $( '#products_cl1' ).on( 'keyup', function() { + localStorage.setItem( 'products_cl1', $( this ).val() || '' ); + clearTimeout( _cl1Timer ); + _cl1Timer = setTimeout( function() { reload_products_table(); }, 400 ); + }); + function submit_delete_campaign_ad_group( campaign_id, ad_group_id, delete_scope, on_success ) { function parse_json_loose( raw ) @@ -1320,8 +1333,10 @@ $( function() localStorage.removeItem( 'products_ad_group_id' ); localStorage.removeItem( 'products_search' ); localStorage.removeItem( 'products_cl4' ); + localStorage.removeItem( 'products_cl1' ); $( '#products_search' ).val( '' ); $( '#products_cl4' ).val( '' ); + $( '#products_cl1' ).val( '' ); update_delete_ad_group_button_state(); load_client_bestseller_settings( client_id ); @@ -1435,6 +1450,7 @@ $( function() var savedAdGroup = localStorage.getItem( 'products_ad_group_id' ) || ''; var savedSearch = localStorage.getItem( 'products_search' ) || ''; var savedCl4 = localStorage.getItem( 'products_cl4' ) || ''; + var savedCl1 = localStorage.getItem( 'products_cl1' ) || ''; if ( savedClient && $( '#client_id option[value="' + savedClient + '"]' ).length ) { @@ -1443,9 +1459,11 @@ $( function() $( '#products_search' ).val( savedSearch ); $( '#products_cl4' ).val( savedCl4 ); + $( '#products_cl1' ).val( savedCl1 ); load_client_bestseller_settings( $( '#client_id' ).val() || '' ); load_cl4_suggestions( $( '#client_id' ).val() || '' ); + load_cl1_suggestions( $( '#client_id' ).val() || '' ); load_products_campaigns( $( '#client_id' ).val() || '', savedCampaign ).done( function() { var selected_campaign_id = $( '#products_campaign_id' ).val() || ''; @@ -1771,6 +1789,113 @@ $( function() }); }); + // CL1 autocomplete — datalist z unikalnymi wartościami + var cl1_values_cache = []; + var cl1_datalist_id = 'cl1-suggestions'; + + function load_cl1_suggestions( client_id ) + { + if ( !client_id ) + { + cl1_values_cache = []; + return; + } + + $.ajax({ + url: '/products/get_distinct_cl1/client_id=' + client_id, + type: 'GET', + dataType: 'json' + }).done( function( res ) { + cl1_values_cache = ( res && res.values ) ? res.values : []; + render_cl1_datalist(); + }); + } + + function render_cl1_datalist() + { + var $dl = $( '#' + cl1_datalist_id ); + + if ( !$dl.length ) + { + $dl = $( '' ); + $( 'body' ).append( $dl ); + } + + var html = ''; + + for ( var i = 0; i < cl1_values_cache.length; i++ ) + { + html += '