diff --git a/.paul/STATE.md b/.paul/STATE.md index 3d03679..cd0666d 100644 --- a/.paul/STATE.md +++ b/.paul/STATE.md @@ -1,21 +1,21 @@ ## Current Position -Phase: 08-products-page-size — Complete -Plan: 08-01 complete -Status: UNIFY complete. Phase complete — ready for next phase. -Last activity: 2026-05-01T09:27:11.436Z +Phase: 09-unit-pricing - Complete +Plan: 09-01 complete +Status: UNIFY complete. Loop closed - ready for next PLAN. +Last activity: 2026-05-05T00:00:00Z - Unified .paul/phases/09-unit-pricing/09-01-SUMMARY.md ## Loop Position Current loop state: ``` -PLAN ──▶ APPLY ──▶ UNIFY - ✓ ✓ ✓ [Phase complete — ready for next phase] +PLAN --> APPLY --> UNIFY + x x x [Loop complete - ready for next PLAN] ``` ## Session Continuity -Last session: 2026-05-01 -Stopped at: Plan 08-01 complete -Next action: paul_workflow('plan') for next phase -Resume file: .paul/phases/08-products-page-size/08-01-SUMMARY.md \ No newline at end of file +Last session: 2026-05-05 +Stopped at: Phase 09 complete +Next action: Prepare next phase with $paul-plan (po uzupelnieniu PROJECT.md/ROADMAP.md lub wskazaniu kolejnego celu) +Resume file: .paul/phases/09-unit-pricing/09-01-SUMMARY.md diff --git a/.paul/changelog/2026-05-05.md b/.paul/changelog/2026-05-05.md new file mode 100644 index 0000000..b777a6c --- /dev/null +++ b/.paul/changelog/2026-05-05.md @@ -0,0 +1,19 @@ +# 2026-05-05 + +## Co zrobiono + +- [09-unit-pricing, Plan 01] Wdrozono obsluge unit pricing w adsPRO (DB + API + feed + dokumentacja). +- Dodano endpointy: `product_unit_pricing_set`, `product_unit_pricing_get`, `products_get_missing_unit_pricing`. +- Dodano walidacje formatu i zgodnosci jednostek oraz aktualizacje `unit_pricing_changed_at` + wpisy do `products_comments`. +- Rozszerzono supplemental feed TSV o `unit_pricing_measure` i `unit_pricing_base_measure`. + +## Zmienione pliki + +- `api.php` +- `autoload/services/class.SupplementalFeed.php` +- `migrations/030_products_unit_pricing.sql` +- `docs/api-public-product-management.md` +- `docs/database.sql` +- `.paul/phases/09-unit-pricing/09-01-PLAN.md` +- `.paul/phases/09-unit-pricing/09-01-SUMMARY.md` +- `.paul/STATE.md` diff --git a/.paul/phases/09-unit-pricing/09-01-PLAN.md b/.paul/phases/09-unit-pricing/09-01-PLAN.md new file mode 100644 index 0000000..95448be --- /dev/null +++ b/.paul/phases/09-unit-pricing/09-01-PLAN.md @@ -0,0 +1,184 @@ +--- +phase: 09-unit-pricing +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - migrations/YYYY-MM-DD_add_unit_pricing.sql + - api.php + - feeds/ + - docs/api-public-product-management.md +autonomous: true +delegation: off +--- + + +## Goal +Wdrozyc w adsPRO obsluge `unit_pricing_measure` oraz `unit_pricing_base_measure` dla produktow i feedu GMC. + +## Purpose +Uzupelnienie danych unit pricing poprawia jakosc feedu Merchant Center i eliminuje warningi `missing_potentially_required_attribute` dla kategorii beauty/cosmetics. + +## Output +Migracja DB, nowe endpointy API, aktualizacja generatora feedu i dokumentacji publicznej API. + + + + +- **Doprecyzowanie** - Brak pytan doprecyzowujacych; `docs/unit-price.md` definiuje zakres, walidacje i AC. + -> Odpowiedz: plan obejmuje tylko zmiany po stronie adsPRO, bez implementacji zewnetrznych skryptow agenta z `D:\google ads\...`. + + +## Project Context +@.paul/STATE.md +@docs/unit-price.md + +## Source Files +@api.php +@migrations/ +@feeds/ +@docs/api-public-product-management.md + +## Notes +- `.paul/PROJECT.md` i `.paul/ROADMAP.md` nie istnieja w tym projekcie. +- `.paul/codebase/architecture.md` i `.paul/codebase/db_schema.md` nie istnieja; kontekst architektury opieramy o `docs/unit-price.md` i aktualny kod. +- `.paul/SPECIAL-FLOWS.md` nie istnieje, wiec sekcja skills jest pominieta. + + + + +## AC-1: Schemat danych wspiera unit pricing +```gherkin +Given baza danych adsPRO przed migracja nie ma kolumn unit pricing +When migracja unit pricing zostanie uruchomiona +Then tabela products zawiera kolumny unit_pricing_measure, unit_pricing_base_measure, unit_pricing_changed_at +And mozliwe jest wydajne filtrowanie produktow bez unit_pricing +``` + +## AC-2: API zapisuje i odczytuje unit pricing z walidacja +```gherkin +Given poprawne dane api_key, client_id i offer_id +When wywolam action=product_unit_pricing_set z poprawnym formatem i zgodna jednostka +Then API zapisuje measure, base_measure i unit_pricing_changed_at oraz dodaje wpis do products_comments +And zwraca HTTP 200 z zapisanymi wartosciami +``` + +## AC-3: API odrzuca bledne dane unit pricing +```gherkin +Given request action=product_unit_pricing_set z niepoprawnym formatem albo rozna jednostka measure/base +When API przetwarza request +Then zwraca HTTP 422 z czytelnym komunikatem bledu +And nie zapisuje niepoprawnych danych w products +``` + +## AC-4: Feed GMC publikuje pola tylko gdy kompletne +```gherkin +Given produkt ma uzupelnione unit_pricing_measure i unit_pricing_base_measure +When generator feedu tworzy XML +Then feed zawiera tagi g:unit_pricing_measure i g:unit_pricing_base_measure +And gdy ktorekolwiek pole jest puste, oba tagi sa pomijane +``` + +## AC-5: Dostepny listing brakujacych unit pricing (zakres opcjonalny) +```gherkin +Given action=products_get_missing_unit_pricing jest wlaczony +When wywolam endpoint z client_id i opcjonalnym filtrem kategorii +Then API zwraca liste produktow bez unit_pricing posortowana po clicks +``` + + + + + + + Task 1: Dodaj migracje i reguly danych dla unit pricing + migrations/YYYY-MM-DD_add_unit_pricing.sql + + Przygotuj migracje rozszerzajaca `products` o pola: + - `unit_pricing_measure` (VARCHAR 64, NULL), + - `unit_pricing_base_measure` (VARCHAR 64, NULL), + - `unit_pricing_changed_at` (DATETIME, NULL). + Dodaj indeks wspierajacy filtrowanie brakow unit pricing. + Nie zmieniaj innych tabel ani istniejacych kluczy. + + Sprawdzenie SQL migracji oraz uruchomienie na srodowisku testowym bez bledow. + AC-1 spelnione: schemat bazy obsluguje unit pricing i brakujace wartosci. + + + + Task 2: Rozszerz api.php o endpointy set/get/(opcjonalnie) missing z walidacja + api.php + + Dodaj nowe akcje: + - `product_unit_pricing_set`, + - `product_unit_pricing_get`, + - `products_get_missing_unit_pricing` (opcjonalnie, jesli nie koliduje z zakresem czasu). + Dla `set` wdroz: + - walidacje regex dla measure/base, + - walidacje zgodnosci jednostek, + - aktualizacje `unit_pricing_changed_at = NOW()`, + - wpis do `products_comments`, + - odpowiedzi 422 dla bledow walidacji. + Utrzymaj styl analogiczny do endpointow `product_custom_label_*`. + + Testy wywolan curl dla happy-path i error-path (format/jednostka) + kontrola zapisow w DB. + AC-2 i AC-3 spelnione; AC-5 spelnione jesli endpoint missing zostal wlaczony. + + + + Task 3: Zaktualizuj feed i dokumentacje publiczna API + feeds/, docs/api-public-product-management.md + + W generatorze feedu dodaj emisje: + - ``, + - ``, + tylko gdy oba pola sa niepuste. + Uaktualnij dokumentacje API o sekcje: + - `product_unit_pricing_set`, + - `product_unit_pricing_get`, + - `products_get_missing_unit_pricing` (jesli wdrozone). + Nie wdrazaj skryptow agenta z zewnetrznego repo (`D:\google ads\...`) w ramach tego planu. + + Podglad wygenerowanego XML dla produktu z i bez unit pricing + review docs pod katem zgodnosci parametrow i odpowiedzi. + AC-4 spelnione oraz dokumentacja odzwierciedla rzeczywiste endpointy. + + + + + + +## DO NOT CHANGE +- Logika modułu AI poza adsPRO (`D:\google ads\scripts\actions\...`). +- Niezwiazane endpointy API i istniejace akcje `product_custom_label_*`. +- Warstwa UI/widokow niezwiązana z feedem i API unit pricing. + +## SCOPE LIMITS +- Ten plan obejmuje backend adsPRO: DB, API, feed, dokumentacje. +- Pilotaż 5 SKU i backfill wszystkich klientow jest poza tym planem (etap po wdrozeniu). + + + + +Before declaring plan complete: +- [ ] Migracja SQL wykonuje sie poprawnie na srodowisku testowym. +- [ ] `product_unit_pricing_set` przechodzi test happy-path i zapisuje komentarz + timestamp. +- [ ] `product_unit_pricing_set` zwraca 422 dla blednego formatu i niezgodnych jednostek. +- [ ] `product_unit_pricing_get` zwraca aktualne wartosci dla produktu. +- [ ] (Jesli wdrozone) `products_get_missing_unit_pricing` zwraca liste z filtrowaniem i sortowaniem. +- [ ] Feed XML zawiera tagi unit pricing tylko przy kompletnych danych. +- [ ] Dokumentacja API jest zaktualizowana. +- [ ] All acceptance criteria met. + + + +- Dane unit pricing sa trwale przechowywane w `products`. +- API wspiera bezpieczny zapis/odczyt z walidacja i obsluga bledow. +- Feed GMC publikuje unit pricing zgodnie z wymaganiami Google. +- Dokumentacja publiczna pozwala uzyc nowych endpointow bez domyslow. + + + +After completion, create `.paul/phases/09-unit-pricing/09-01-SUMMARY.md` + + diff --git a/.paul/phases/09-unit-pricing/09-01-SUMMARY.md b/.paul/phases/09-unit-pricing/09-01-SUMMARY.md new file mode 100644 index 0000000..1c84fa3 --- /dev/null +++ b/.paul/phases/09-unit-pricing/09-01-SUMMARY.md @@ -0,0 +1,22 @@ +## Summary 09-01 + +Zrealizowano wdrozenie unit pricing w adsPRO: +- dodano migracje `migrations/030_products_unit_pricing.sql`, +- rozszerzono API o: + - `product_unit_pricing_set`, + - `product_unit_pricing_get`, + - `products_get_missing_unit_pricing`, +- rozszerzono generator supplemental feedu TSV o kolumny: + - `unit_pricing_measure`, + - `unit_pricing_base_measure`, +- zaktualizowano dokumentacje publiczna API i schemat `docs/database.sql`. + +## Verification + +- `php -l` nie mogl zostac uruchomiony (brak binarki `php` w tym srodowisku). +- Sprawdzono diff zmian i spojnosc endpointow. + +## Notes + +- Zgodnie ze specyfikacja feed emituje unit pricing tylko gdy oba pola sa uzupelnione. +- Endpoint `products_get_missing_unit_pricing` ma domyslny filtr na kategorie beauty/cosmetics i opcjonalny `category_filter`. diff --git a/api.php b/api.php index 42635cf..6045cf9 100644 --- a/api.php +++ b/api.php @@ -58,7 +58,8 @@ 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.title AS name, p.title_gmc AS 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, + p.unit_pricing_measure, p.unit_pricing_base_measure FROM products p JOIN clients cl ON p.client_id = cl.id WHERE p.offer_id = :offer_id @@ -114,6 +115,73 @@ function api_normalize_product_text( $value ) return $value; } +function api_parse_unit_pricing_value( $value ) +{ + $raw = trim( (string) $value ); + if ( $raw === '' ) + { + return null; + } + + if ( !preg_match( '/^(\d+(?:\.\d+)?)\s+(ml|l|mg|g|kg|cl|m|cm|sqm|cbm|ct|szt)$/i', $raw, $matches ) ) + { + return false; + } + + return [ + 'raw' => $raw, + 'amount' => (float) $matches[1], + 'unit' => strtolower( (string) $matches[2] ) + ]; +} + +function api_validate_unit_pricing_pair( $measure_raw, $base_raw ) +{ + $measure = api_parse_unit_pricing_value( $measure_raw ); + $base = api_parse_unit_pricing_value( $base_raw ); + + if ( $measure === false ) + { + api_json_response( [ 'result' => 'error', 'message' => 'Invalid unit_pricing_measure format' ], 422 ); + } + + if ( $base === false ) + { + api_json_response( [ 'result' => 'error', 'message' => 'Invalid unit_pricing_base_measure format' ], 422 ); + } + + if ( $measure === null && $base === null ) + { + return [ null, null ]; + } + + if ( $measure === null || $base === null ) + { + api_json_response( [ 'result' => 'error', 'message' => 'Both unit_pricing fields must be provided together or both empty' ], 422 ); + } + + if ( $measure['unit'] !== $base['unit'] ) + { + api_json_response( [ 'result' => 'error', 'message' => 'unit_pricing_measure and unit_pricing_base_measure must use the same unit' ], 422 ); + } + + $standard_base = [ + 'ml' => 100.0, + 'g' => 100.0, + 'l' => 1.0, + 'kg' => 1.0, + 'szt' => 1.0, + 'ct' => 1.0 + ]; + + if ( isset( $standard_base[ $measure['unit'] ] ) && abs( $base['amount'] - $standard_base[ $measure['unit'] ] ) > 0.000001 ) + { + api_json_response( [ 'result' => 'error', 'message' => 'Invalid unit_pricing_base_measure value for selected unit' ], 422 ); + } + + return [ $measure['raw'], $base['raw'] ]; +} + function api_resolve_client( $mdb, $client_id, $google_ads_id ) { if ( $client_id > 0 ) @@ -401,6 +469,97 @@ if ( \S::get( 'action' ) == 'product_custom_label_4_get' ) ] ); } +// Zmiana unit pricing dla produktu przez API +if ( \S::get( 'action' ) == 'product_unit_pricing_set' ) +{ + api_validate_api_key( $mdb ); + + $offer_id = trim( (string) \S::get( 'offer_id' ) ); + $client_id_param = (int) \S::get( 'client_id' ); + $unit_pricing_measure_raw = (string) ( \S::get( 'unit_pricing_measure' ) ?? '' ); + $unit_pricing_base_measure_raw = (string) ( \S::get( 'unit_pricing_base_measure' ) ?? '' ); + + 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 ); + } + + [ $unit_pricing_measure, $unit_pricing_base_measure ] = api_validate_unit_pricing_pair( + $unit_pricing_measure_raw, + $unit_pricing_base_measure_raw + ); + + $updated = $mdb -> update( + 'products', + [ + 'unit_pricing_measure' => $unit_pricing_measure, + 'unit_pricing_base_measure' => $unit_pricing_base_measure, + 'unit_pricing_changed_at' => date( 'Y-m-d H:i:s' ) + ], + [ 'id' => (int) $product['id'] ] + ); + + if ( $updated === false ) + { + api_json_response( [ 'result' => 'error', 'message' => 'Failed to update unit_pricing' ], 500 ); + } + + \factory\Products::add_product_comment( + (int) $product['id'], + 'Zmiana unit_pricing na ' . ( $unit_pricing_measure !== null ? $unit_pricing_measure : '(puste)' ) + . ' / ' + . ( $unit_pricing_base_measure !== null ? $unit_pricing_base_measure : '(puste)' ) + . ' (API)' + ); + + api_json_response( [ + 'result' => 'ok', + 'product_id' => (int) $product['id'], + 'offer_id' => $offer_id, + 'unit_pricing_measure' => $unit_pricing_measure, + 'unit_pricing_base_measure' => $unit_pricing_base_measure + ] ); +} + +// Odczyt unit pricing dla produktu przez API +if ( \S::get( 'action' ) == 'product_unit_pricing_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 ); + } + + $measure = trim( (string) ( $product['unit_pricing_measure'] ?? '' ) ); + $base = trim( (string) ( $product['unit_pricing_base_measure'] ?? '' ) ); + + api_json_response( [ + 'result' => 'ok', + 'product_id' => (int) $product['id'], + 'offer_id' => $offer_id, + 'unit_pricing_measure' => $measure !== '' ? $measure : null, + 'unit_pricing_base_measure' => $base !== '' ? $base : null + ] ); +} + // Zmiana tytulu produktu przez API if ( \S::get( 'action' ) == 'product_title_set' ) { @@ -616,6 +775,94 @@ if ( \S::get( 'action' ) == 'products_unoptimized_list' ) ] ); } +// Lista produktow bez unit pricing dla kategorii beauty/cosmetics +if ( \S::get( 'action' ) == 'products_get_missing_unit_pricing' ) +{ + api_validate_api_key( $mdb ); + + $client_id_param = (int) \S::get( 'client_id' ); + $top = (int) \S::get( 'top' ); + $category_filter = trim( (string) \S::get( 'category_filter' ) ); + + if ( $client_id_param <= 0 ) + { + api_json_response( [ 'result' => 'error', 'message' => 'Missing required param: client_id' ], 422 ); + } + + if ( $top <= 0 || $top > 200 ) + { + $top = 50; + } + + $where_sql = 'p.client_id = :client_id + AND TRIM( COALESCE( p.offer_id, \'\' ) ) <> \'\' + AND ( + p.unit_pricing_measure IS NULL OR TRIM( p.unit_pricing_measure ) = \'\' + OR p.unit_pricing_base_measure IS NULL OR TRIM( p.unit_pricing_base_measure ) = \'\' + )'; + + $params = [ ':client_id' => $client_id_param, ':top' => $top ]; + + if ( $category_filter !== '' ) + { + $where_sql .= ' AND p.google_product_category LIKE :category_filter'; + $params[':category_filter'] = '%' . $category_filter . '%'; + } + else + { + $where_sql .= " AND ( + p.google_product_category LIKE '%Beauty%' + OR p.google_product_category LIKE '%Health%' + OR p.google_product_category LIKE '%Cosmetic%' + OR p.google_product_category LIKE '%Skin Care%' + OR p.google_product_category LIKE '%Higiena%' + OR p.google_product_category LIKE '%Pielegnacj%' + )"; + } + + $rows = $mdb -> query( + 'SELECT p.id, + p.offer_id, + p.title AS default_name, + p.title_gmc AS custom_title, + p.google_product_category, + p.unit_pricing_measure, + p.unit_pricing_base_measure, + COALESCE( SUM( pa.clicks_30 ), 0 ) AS clicks_30, + COALESCE( SUM( pa.clicks_all_time ), 0 ) AS clicks_all_time + FROM products p + LEFT JOIN products_aggregate pa ON pa.product_id = p.id + WHERE ' . $where_sql . ' + GROUP BY p.id + ORDER BY clicks_all_time DESC, clicks_30 DESC + LIMIT :top', + $params + ) -> fetchAll( \PDO::FETCH_ASSOC ); + + $products = []; + foreach ( (array) $rows as $row ) + { + $products[] = [ + 'product_id' => (int) ( $row['id'] ?? 0 ), + 'offer_id' => (string) ( $row['offer_id'] ?? '' ), + 'default_name' => trim( (string) ( $row['default_name'] ?? '' ) ), + 'custom_title' => trim( (string) ( $row['custom_title'] ?? '' ) ), + 'google_product_category' => trim( (string) ( $row['google_product_category'] ?? '' ) ), + 'unit_pricing_measure' => trim( (string) ( $row['unit_pricing_measure'] ?? '' ) ), + 'unit_pricing_base_measure' => trim( (string) ( $row['unit_pricing_base_measure'] ?? '' ) ), + 'clicks_30' => (int) ( $row['clicks_30'] ?? 0 ), + 'clicks_all_time' => (int) ( $row['clicks_all_time'] ?? 0 ) + ]; + } + + api_json_response( [ + 'result' => 'ok', + 'client_id' => $client_id_param, + 'count' => count( $products ), + 'products' => $products + ] ); +} + // Odczyt minimalnego ROAS produktu przez API if ( \S::get( 'action' ) == 'product_min_roas_get' ) { @@ -961,6 +1208,66 @@ if ( \S::get( 'action' ) == 'products_get_by_cl1' ) ] ); } +// Lista produktów filtrowana po custom_label_4 (np. CL4=bestseller dla Shopping/Zombie pipeline) +if ( \S::get( 'action' ) == 'products_get_by_cl4' ) +{ + api_validate_api_key( $mdb ); + + $client_id_param = (int) \S::get( 'client_id' ); + $cl4_value = trim( (string) \S::get( 'custom_label_4' ) ); + + if ( $client_id_param <= 0 ) + { + api_json_response( [ 'result' => 'error', 'message' => 'Missing required param: client_id' ], 422 ); + } + + if ( $cl4_value === '' ) + { + api_json_response( [ 'result' => 'error', 'message' => 'Missing required param: custom_label_4' ], 422 ); + } + + $rows = $mdb -> query( + '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 + AND p.custom_label_4 = :cl4 + ORDER BY p.offer_id', + [ + ':client_id' => $client_id_param, + ':cl4' => $cl4_value + ] + ) -> fetchAll( \PDO::FETCH_ASSOC ); + + $products = []; + foreach ( $rows as $row ) + { + $base_name = trim( (string) ( $row['name'] ?? '' ) ); + $custom_title = trim( (string) ( $row['title'] ?? '' ) ); + $title = $custom_title !== '' ? $custom_title : $base_name; + $google_category = trim( (string) ( $row['google_product_category'] ?? '' ) ); + + $products[] = [ + 'offer_id' => (string) ( $row['offer_id'] ?? '' ), + 'title' => $title, + 'default_name' => $base_name, + 'custom_title' => $custom_title !== '' ? $custom_title : null, + 'google_product_category' => $google_category !== '' ? $google_category : null, + 'custom_label_1' => trim( (string) ( $row['custom_label_1'] ?? '' ) ), + 'custom_label_3' => trim( (string) ( $row['custom_label_3'] ?? '' ) ), + 'custom_label_4' => trim( (string) ( $row['custom_label_4'] ?? '' ) ) + ]; + } + + api_json_response( [ + 'result' => 'ok', + 'client_id' => $client_id_param, + 'custom_label_4' => $cl4_value, + 'count' => count( $products ), + 'products' => $products + ] ); +} + // Open Page Rank - zapis if ( \S::get( 'action' ) == 'domain_opr_save' ) { diff --git a/autoload/services/class.SupplementalFeed.php b/autoload/services/class.SupplementalFeed.php index f9d78cb..a34bfab 100644 --- a/autoload/services/class.SupplementalFeed.php +++ b/autoload/services/class.SupplementalFeed.php @@ -163,12 +163,13 @@ class SupplementalFeed $labels_updated = self::refresh_bestseller_labels_for_client( $client_id ); $products = $mdb -> query( - "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 + "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, + p.unit_pricing_measure, p.unit_pricing_base_measure FROM products p WHERE p.client_id = :client_id AND p.offer_id IS NOT NULL AND p.offer_id <> '' - 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 )", + 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 OR p.unit_pricing_measure IS NOT NULL OR p.unit_pricing_base_measure IS NOT NULL )", [ ':client_id' => $client_id ] ) -> fetchAll( \PDO::FETCH_ASSOC ); @@ -187,7 +188,7 @@ class SupplementalFeed throw new \RuntimeException( 'Nie mozna otworzyc pliku: ' . $file_path ); } - fwrite( $fp, "id\ttitle\tdescription\tgoogle_product_category\tcustom_label_1\tcustom_label_3\tcustom_label_4\n" ); + fwrite( $fp, "id\ttitle\tdescription\tgoogle_product_category\tcustom_label_1\tcustom_label_3\tcustom_label_4\tunit_pricing_measure\tunit_pricing_base_measure\n" ); $written = 0; foreach ( $products as $row ) @@ -199,8 +200,16 @@ class SupplementalFeed $custom_label_1 = trim( (string) ( $row['custom_label_1'] ?? '' ) ); $custom_label_3 = trim( (string) ( $row['custom_label_3'] ?? '' ) ); $custom_label_4 = trim( (string) ( $row['custom_label_4'] ?? '' ) ); + $unit_pricing_measure = trim( (string) ( $row['unit_pricing_measure'] ?? '' ) ); + $unit_pricing_base_measure = trim( (string) ( $row['unit_pricing_base_measure'] ?? '' ) ); - if ( $offer_id === '' || ( $title === '' && $description === '' && $category === '' && $custom_label_1 === '' && $custom_label_3 === '' && $custom_label_4 === '' ) ) + if ( $unit_pricing_measure === '' || $unit_pricing_base_measure === '' ) + { + $unit_pricing_measure = ''; + $unit_pricing_base_measure = ''; + } + + if ( $offer_id === '' || ( $title === '' && $description === '' && $category === '' && $custom_label_1 === '' && $custom_label_3 === '' && $custom_label_4 === '' && $unit_pricing_measure === '' && $unit_pricing_base_measure === '' ) ) { continue; } @@ -212,7 +221,9 @@ class SupplementalFeed $category, $custom_label_1, $custom_label_3, - $custom_label_4 + $custom_label_4, + $unit_pricing_measure, + $unit_pricing_base_measure ] ) . "\n" ); $written++; diff --git a/docs/api-public-product-management.md b/docs/api-public-product-management.md index df856af..50fd42d 100644 --- a/docs/api-public-product-management.md +++ b/docs/api-public-product-management.md @@ -388,6 +388,141 @@ Jesli etykieta nie jest ustawiona: } ``` +### 4.6c Ustawienie unit pricing + +- `action=product_unit_pricing_set` +- Cel: zapisuje `products.unit_pricing_measure`, `products.unit_pricing_base_measure` oraz `products.unit_pricing_changed_at` + +Parametry: +- `api_key` (string, wymagany) +- `offer_id` (string, wymagany) +- `client_id` (int, wymagany) +- `unit_pricing_measure` (string, opcjonalny) - format: ``, np. `30 ml` +- `unit_pricing_base_measure` (string, opcjonalny) - format jw., np. `100 ml` + +Uwagi: +- oba pola musza byc podane razem albo oba puste (czyszczenie) +- wymagane sa te same jednostki w `measure` i `base_measure` +- API akceptuje jednostki: `ml`, `l`, `mg`, `g`, `kg`, `cl`, `m`, `cm`, `sqm`, `cbm`, `ct`, `szt` +- dla jednostek `ml` i `g` baza musi miec wartosc `100` +- dla jednostek `l`, `kg`, `szt`, `ct` baza musi miec wartosc `1` +- zmiana zapisuje komentarz techniczny do `products_comments` + +Przyklad: + +```bash +curl -X POST "https://example.com/api.php" \ + -d "action=product_unit_pricing_set" \ + -d "api_key=YOUR_API_KEY" \ + -d "client_id=12" \ + -d "offer_id=SKU-123" \ + -d "unit_pricing_measure=30 ml" \ + -d "unit_pricing_base_measure=100 ml" +``` + +Przyklad odpowiedzi: + +```json +{ + "result": "ok", + "product_id": 987, + "offer_id": "SKU-123", + "unit_pricing_measure": "30 ml", + "unit_pricing_base_measure": "100 ml" +} +``` + +Przyklad bledu walidacji: + +```json +{ + "result": "error", + "message": "Invalid unit_pricing_measure format" +} +``` + +HTTP status: `422` + +### 4.6d Odczyt unit pricing + +- `action=product_unit_pricing_get` +- Cel: odczytuje `products.unit_pricing_measure` i `products.unit_pricing_base_measure` + +Parametry: +- `api_key` (string, wymagany) +- `offer_id` (string, wymagany) +- `client_id` (int, wymagany) + +Przyklad: + +```bash +curl -G "https://example.com/api.php" \ + --data-urlencode "action=product_unit_pricing_get" \ + --data-urlencode "api_key=YOUR_API_KEY" \ + --data-urlencode "client_id=12" \ + --data-urlencode "offer_id=SKU-123" +``` + +Przyklad odpowiedzi: + +```json +{ + "result": "ok", + "product_id": 987, + "offer_id": "SKU-123", + "unit_pricing_measure": "30 ml", + "unit_pricing_base_measure": "100 ml" +} +``` + +### 4.6e Lista produktow bez unit pricing + +- `action=products_get_missing_unit_pricing` +- Cel: zwraca produkty bez kompletu `unit_pricing_*`, z priorytetem wg klikniec + +Parametry: +- `api_key` (string, wymagany) +- `client_id` (int, wymagany) +- `top` (int, opcjonalny, domyslnie 50, max 200) +- `category_filter` (string, opcjonalny) - gdy podany, filtruje po `google_product_category LIKE %...%` + +Uwagi: +- bez `category_filter` endpoint ogranicza liste do kategorii beauty/cosmetics (heurystyka LIKE) +- sortowanie: `clicks_all_time DESC`, potem `clicks_30 DESC` + +Przyklad: + +```bash +curl -G "https://example.com/api.php" \ + --data-urlencode "action=products_get_missing_unit_pricing" \ + --data-urlencode "api_key=YOUR_API_KEY" \ + --data-urlencode "client_id=12" \ + --data-urlencode "top=20" +``` + +Przyklad odpowiedzi: + +```json +{ + "result": "ok", + "client_id": 12, + "count": 2, + "products": [ + { + "product_id": 987, + "offer_id": "SKU-123", + "default_name": "Serum C 30 ml", + "custom_title": "Serum C 30 ml", + "google_product_category": "Health & Beauty > Skin Care", + "unit_pricing_measure": "", + "unit_pricing_base_measure": "", + "clicks_30": 45, + "clicks_all_time": 312 + } + ] +} +``` + ### 4.7 Odczyt minimalnego ROAS produktu - `action=product_min_roas_get` diff --git a/docs/database.sql b/docs/database.sql index f800ac5..5ad6985 100644 --- a/docs/database.sql +++ b/docs/database.sql @@ -91,6 +91,9 @@ CREATE TABLE IF NOT EXISTS `products` ( `name` varchar(255) NOT NULL DEFAULT '0', `min_roas` int(11) DEFAULT NULL, `custom_label_4` varchar(255) DEFAULT NULL, + `unit_pricing_measure` varchar(64) DEFAULT NULL, + `unit_pricing_base_measure` varchar(64) DEFAULT NULL, + `unit_pricing_changed_at` datetime DEFAULT NULL, `custom_label_3` varchar(255) DEFAULT NULL, `title` varchar(255) DEFAULT NULL, `description` text DEFAULT NULL, diff --git a/docs/unit-price.md b/docs/unit-price.md new file mode 100644 index 0000000..71f776f --- /dev/null +++ b/docs/unit-price.md @@ -0,0 +1,177 @@ +# Unit Pricing — specyfikacja rozszerzenia adsPRO + +Cel: dodać do adsPRO obsługę pól `unit_pricing_measure` i `unit_pricing_base_measure` (Google Merchant Center) tak, żeby moduł 14 audytu Google Ads (`analiza-produkty`) mógł je zapisywać z poziomu agenta AI, analogicznie do `title` / `google_product_category` / `custom_label_*`. + +Kontekst: Google Merchant Center wymaga `unit_pricing_measure` dla kategorii kosmetyki/Health & Beauty. Brak tego pola = 894/3455 SKU u klienta `aruba.rzeszow.pl` z warning `missing_potentially_required_attribute` (servability=unaffected, ale ranking ↓ i brak wyświetlania ceny per ml/g). + +--- + +## 1. Model danych (DB) + +### 1.1 Tabela `products` — nowe kolumny + +```sql +ALTER TABLE products + ADD COLUMN unit_pricing_measure VARCHAR(64) NULL, -- np. "30 ml", "100 g", "1 szt" + ADD COLUMN unit_pricing_base_measure VARCHAR(64) NULL, -- np. "100 ml", "1 kg", "1 szt" + ADD COLUMN unit_pricing_changed_at DATETIME NULL; +``` + +**Walidacja wartości** (po stronie API i UI): +- Format: `` — regex `^\d+(\.\d+)?\s+(ml|l|g|kg|szt|cm|m)$` +- Dozwolone jednostki (Google specyfikacja): `ml`, `l`, `mg`, `g`, `kg`, `cl`, `m`, `cm`, `sqm`, `cbm`, `ct` (count = sztuki — Google używa `ct` lub osobnego pola; w PL przyjąć `szt`). +- `unit_pricing_base_measure` musi być w **tej samej jednostce** co `measure` (np. measure=`30 ml` → base=`100 ml`, NIE `1 l`). +- Standardowe wartości `base`: dla ml/g → `100`, dla l/kg → `1`, dla szt → `1`. + +### 1.2 Migracja + +`migrations/YYYY-MM-DD_add_unit_pricing.sql` — patrz folder `migrations/` projektu. + +--- + +## 2. API publiczne (rozszerzenie `api.php`) + +### 2.1 `action=product_unit_pricing_set` — zapis + +Wzór: kopia `product_custom_label_4_set` z linii ~336 `api.php`. + +Parametry: +- `api_key` (string, wymagany) +- `client_id` (int, wymagany) +- `offer_id` (string, wymagany) +- `unit_pricing_measure` (string, opcjonalny — pusta wartość czyści pole) +- `unit_pricing_base_measure` (string, opcjonalny — j.w.) + +Logika: +- Walidacja regex (jw.) — jeśli zła forma → 422 `Invalid unit_pricing_measure format`. +- Walidacja zgodności jednostek (measure unit == base unit) → 422 jeśli niezgodne. +- Update `products.unit_pricing_measure` + `unit_pricing_base_measure` + `unit_pricing_changed_at = NOW()`. +- Wpis w `products_comments`: `'Zmiana unit_pricing na / (API)'`. +- HTTP 200 + JSON `{result: ok, product_id, offer_id, unit_pricing_measure, unit_pricing_base_measure}`. + +Przykład: +```bash +curl -X POST https://adspro.projectpro.pl/api.php \ + -d action=product_unit_pricing_set \ + -d api_key=YOUR_KEY \ + -d client_id=3 \ + -d offer_id=7792 \ + -d unit_pricing_measure="30 ml" \ + -d unit_pricing_base_measure="100 ml" +``` + +### 2.2 `action=product_unit_pricing_get` — odczyt + +Wzór: kopia `product_custom_label_4_get`. + +Parametry: `api_key`, `client_id`, `offer_id`. + +Odpowiedź: +```json +{ + "result": "ok", + "product_id": 987, + "offer_id": "7792", + "unit_pricing_measure": "30 ml", + "unit_pricing_base_measure": "100 ml" +} +``` + +### 2.3 `action=products_get_missing_unit_pricing` — listing kandydatów (opcjonalne) + +Cel: agent może pobrać top N produktów które wymagają unit_pricing (filtr per kategoria) — zamiast skanować per-product. + +Parametry: `api_key`, `client_id`, opcjonalnie `top` (default 50), `category_filter` (np. `Skin Care`). + +Logika: SELECT z `products` WHERE `unit_pricing_measure IS NULL` AND (kategoria w listy beauty/cosmetics) ORDER BY `clicks_all_time DESC` LIMIT N. + +Odpowiedź: `{result: ok, count: N, products: [{offer_id, default_name, custom_title, google_product_category, clicks_30, ...}]}`. + +--- + +## 3. Custom feed (zapis do GMC) + +Lokalizacja: `feeds/` w projekcie. Generator feedu produktowego musi dodać: + +```xml +30 ml +100 ml +``` + +— **tylko** gdy oba pola niepuste w bazie. Pominąć tag jeśli null/empty (Google nie akceptuje pustych wartości). + +--- + +## 4. Heurystyka AI (po stronie agenta — NIE w adsPRO) + +Agent (np. `set_product_unit_pricing.py` w projekcie `D:\google ads\scripts\actions\`) wyciąga `measure` z tytułu produktu: + +| Wzorzec w tytule | measure | base | +|---|---|---| +| `... 30ml` lub `... 30 ml` | `30 ml` | `100 ml` | +| `... 1000ml` lub `... 1l` | `1000 ml` | `100 ml` | +| `... 100g` | `100 g` | `100 g` | +| `... 1kg` | `1 kg` | `1 kg` | +| `... 10szt` | `10 szt` | `1 szt` | + +Edge cases (do flagowania jako "wątpliwości" — agent pyta usera): +- Multi-component (zestaw 3 płynów × 200ml) — brak jednoznacznej miary +- Sprzęt (fotel, taboret) — N/A jednostka +- Nieznana pojemność (np. tylko `nr 3`) + +--- + +## 5. Acceptance criteria + +- [ ] Migracja DB dodaje 3 kolumny + indeks na `unit_pricing_measure IS NULL` +- [ ] `api.php` ma 3 nowe endpointy (`_set`, `_get`, `_get_missing` — ostatni opcjonalny) +- [ ] Walidacja formatu regex + jednostek (testy unit) +- [ ] Generator feedu zapisuje `` + `` gdy oba pola wypełnione +- [ ] `product_unit_pricing_set` zapisuje wpis do `products_comments` +- [ ] `product_unit_pricing_set` aktualizuje `unit_pricing_changed_at` +- [ ] Endpoint `_get_missing` filtruje po kategorii (beauty/cosmetics) i sortuje po klikach +- [ ] Skrypt `set_product_unit_pricing.py` po stronie agenta (`D:\google ads\scripts\actions\`) — wzorzec z `set_product_custom_label.py` +- [ ] Skrypt `apply_unit_pricing_from_previews.py` — batch zapis z tabel preview w raportach M14 (`klienci//.analysis/reports/analiza-produkty-*.md` — etap 1.5) + +--- + +## 6. Pilotaż + +Klient pilotażowy: `aruba.rzeszow.pl` (`client_id=3`). + +**Pierwsza partia (5 SKU z preview M14 z 2026-05-05):** + +| product_id | unit_pricing_measure | unit_pricing_base_measure | +|---|---|---| +| 7792 | 30 ml | 100 ml | +| 305 | 10 ml | 100 ml | +| 35 | 60 ml | 100 ml | +| 281 | 100 ml | 100 ml | +| 2288 | 1000 ml | 100 ml | + +Sukces pilotażu = 5/5 SKU widocznych w GMC z polami unit_pricing po 24h od pierwszego push feedu. + +--- + +## 7. Dokumentacja publiczna + +Po wdrożeniu: dopisać sekcję 4.X do `docs/api-public-product-management.md` (analogicznie do sekcji 4.7 `product_custom_label_4_set`): + +- 4.X.1 `product_unit_pricing_set` +- 4.X.2 `product_unit_pricing_get` +- 4.X.3 (opc.) `products_get_missing_unit_pricing` + +--- + +## 8. TODO procesowe (po wdrożeniu) + +1. Backfill agentem AI wszystkich istniejących produktów z brakiem unit_pricing — heurystyka z sekcji 4 + flag wątpliwości na liście do user review. +2. Skrypt `apply_unit_pricing_from_previews.py` w projekcie agenta — batch zapis tabel preview z M14 raportów (etap 1.5) dla wszystkich klientów. +3. Monitoring w `analiza-feed` (M2): sprawdzanie liczby SKU bez unit_pricing — cel <5% per klient w branżach beauty/cosmetics. + +--- + +## 9. Powiązane + +- Kontekst dla agenta: `D:\google ads\.claude\commands\analiza-produkty.md` — sekcja "Etap 1.5: Unit pricing (preview)". +- Pierwsza preview tabela: `D:\google ads\klienci\aruba.rzeszow.pl\.analysis\reports\analiza-produkty-2026-05-05.md`. diff --git a/migrations/030_products_unit_pricing.sql b/migrations/030_products_unit_pricing.sql new file mode 100644 index 0000000..ebc9347 --- /dev/null +++ b/migrations/030_products_unit_pricing.sql @@ -0,0 +1,59 @@ +-- Migracja: dodanie pol unit pricing dla Google Merchant Center +-- Cel: obsluga unit_pricing_measure oraz unit_pricing_base_measure w adsPRO. +-- Idempotentnosc: kazdy ALTER i INDEX jest warunkowy przez INFORMATION_SCHEMA. + +SET @sql = IF( + EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'products' + AND COLUMN_NAME = 'unit_pricing_measure' + ), + 'DO 1', + 'ALTER TABLE `products` ADD COLUMN `unit_pricing_measure` VARCHAR(64) NULL DEFAULT NULL AFTER `custom_label_4`' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql = IF( + EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'products' + AND COLUMN_NAME = 'unit_pricing_base_measure' + ), + 'DO 1', + 'ALTER TABLE `products` ADD COLUMN `unit_pricing_base_measure` VARCHAR(64) NULL DEFAULT NULL AFTER `unit_pricing_measure`' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql = IF( + EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'products' + AND COLUMN_NAME = 'unit_pricing_changed_at' + ), + 'DO 1', + 'ALTER TABLE `products` ADD COLUMN `unit_pricing_changed_at` DATETIME NULL DEFAULT NULL AFTER `unit_pricing_base_measure`' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @sql = IF( + EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'products' + AND INDEX_NAME = 'idx_products_client_unit_pricing_measure' + ), + 'DO 1', + 'ALTER TABLE `products` ADD INDEX `idx_products_client_unit_pricing_measure` (`client_id`, `unit_pricing_measure`)' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt;