--- 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.