update
This commit is contained in:
@@ -1,21 +1,21 @@
|
|||||||
## Current Position
|
## Current Position
|
||||||
|
|
||||||
Phase: 08-products-page-size — Complete
|
Phase: 09-unit-pricing - Complete
|
||||||
Plan: 08-01 complete
|
Plan: 09-01 complete
|
||||||
Status: UNIFY complete. Phase complete — ready for next phase.
|
Status: UNIFY complete. Loop closed - ready for next PLAN.
|
||||||
Last activity: 2026-05-01T09:27:11.436Z
|
Last activity: 2026-05-05T00:00:00Z - Unified .paul/phases/09-unit-pricing/09-01-SUMMARY.md
|
||||||
|
|
||||||
## Loop Position
|
## Loop Position
|
||||||
|
|
||||||
Current loop state:
|
Current loop state:
|
||||||
```
|
```
|
||||||
PLAN ──▶ APPLY ──▶ UNIFY
|
PLAN --> APPLY --> UNIFY
|
||||||
✓ ✓ ✓ [Phase complete — ready for next phase]
|
x x x [Loop complete - ready for next PLAN]
|
||||||
```
|
```
|
||||||
|
|
||||||
## Session Continuity
|
## Session Continuity
|
||||||
|
|
||||||
Last session: 2026-05-01
|
Last session: 2026-05-05
|
||||||
Stopped at: Plan 08-01 complete
|
Stopped at: Phase 09 complete
|
||||||
Next action: paul_workflow('plan') for next phase
|
Next action: Prepare next phase with $paul-plan (po uzupelnieniu PROJECT.md/ROADMAP.md lub wskazaniu kolejnego celu)
|
||||||
Resume file: .paul/phases/08-products-page-size/08-01-SUMMARY.md
|
Resume file: .paul/phases/09-unit-pricing/09-01-SUMMARY.md
|
||||||
|
|||||||
19
.paul/changelog/2026-05-05.md
Normal file
19
.paul/changelog/2026-05-05.md
Normal file
@@ -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`
|
||||||
184
.paul/phases/09-unit-pricing/09-01-PLAN.md
Normal file
184
.paul/phases/09-unit-pricing/09-01-PLAN.md
Normal file
@@ -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
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
## 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.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
<clarifications>
|
||||||
|
- **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\...`.
|
||||||
|
</clarifications>
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<acceptance_criteria>
|
||||||
|
|
||||||
|
## 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
|
||||||
|
```
|
||||||
|
|
||||||
|
</acceptance_criteria>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Dodaj migracje i reguly danych dla unit pricing</name>
|
||||||
|
<files>migrations/YYYY-MM-DD_add_unit_pricing.sql</files>
|
||||||
|
<action>
|
||||||
|
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.
|
||||||
|
</action>
|
||||||
|
<verify>Sprawdzenie SQL migracji oraz uruchomienie na srodowisku testowym bez bledow.</verify>
|
||||||
|
<done>AC-1 spelnione: schemat bazy obsluguje unit pricing i brakujace wartosci.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Rozszerz api.php o endpointy set/get/(opcjonalnie) missing z walidacja</name>
|
||||||
|
<files>api.php</files>
|
||||||
|
<action>
|
||||||
|
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_*`.
|
||||||
|
</action>
|
||||||
|
<verify>Testy wywolan curl dla happy-path i error-path (format/jednostka) + kontrola zapisow w DB.</verify>
|
||||||
|
<done>AC-2 i AC-3 spelnione; AC-5 spelnione jesli endpoint missing zostal wlaczony.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 3: Zaktualizuj feed i dokumentacje publiczna API</name>
|
||||||
|
<files>feeds/, docs/api-public-product-management.md</files>
|
||||||
|
<action>
|
||||||
|
W generatorze feedu dodaj emisje:
|
||||||
|
- `<g:unit_pricing_measure>`,
|
||||||
|
- `<g:unit_pricing_base_measure>`,
|
||||||
|
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.
|
||||||
|
</action>
|
||||||
|
<verify>Podglad wygenerowanego XML dla produktu z i bez unit pricing + review docs pod katem zgodnosci parametrow i odpowiedzi.</verify>
|
||||||
|
<done>AC-4 spelnione oraz dokumentacja odzwierciedla rzeczywiste endpointy.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<boundaries>
|
||||||
|
|
||||||
|
## 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).
|
||||||
|
|
||||||
|
</boundaries>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
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.
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- 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.
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.paul/phases/09-unit-pricing/09-01-SUMMARY.md`
|
||||||
|
</output>
|
||||||
|
|
||||||
22
.paul/phases/09-unit-pricing/09-01-SUMMARY.md
Normal file
22
.paul/phases/09-unit-pricing/09-01-SUMMARY.md
Normal file
@@ -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`.
|
||||||
309
api.php
309
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 )
|
function api_get_product_by_offer_and_client( $mdb, $offer_id, $client_id )
|
||||||
{
|
{
|
||||||
return $mdb -> query(
|
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
|
FROM products p
|
||||||
JOIN clients cl ON p.client_id = cl.id
|
JOIN clients cl ON p.client_id = cl.id
|
||||||
WHERE p.offer_id = :offer_id
|
WHERE p.offer_id = :offer_id
|
||||||
@@ -114,6 +115,73 @@ function api_normalize_product_text( $value )
|
|||||||
return $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 )
|
function api_resolve_client( $mdb, $client_id, $google_ads_id )
|
||||||
{
|
{
|
||||||
if ( $client_id > 0 )
|
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
|
// Zmiana tytulu produktu przez API
|
||||||
if ( \S::get( 'action' ) == 'product_title_set' )
|
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
|
// Odczyt minimalnego ROAS produktu przez API
|
||||||
if ( \S::get( 'action' ) == 'product_min_roas_get' )
|
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
|
// Open Page Rank - zapis
|
||||||
if ( \S::get( 'action' ) == 'domain_opr_save' )
|
if ( \S::get( 'action' ) == 'domain_opr_save' )
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -163,12 +163,13 @@ class SupplementalFeed
|
|||||||
$labels_updated = self::refresh_bestseller_labels_for_client( $client_id );
|
$labels_updated = self::refresh_bestseller_labels_for_client( $client_id );
|
||||||
|
|
||||||
$products = $mdb -> query(
|
$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
|
FROM products p
|
||||||
WHERE p.client_id = :client_id
|
WHERE p.client_id = :client_id
|
||||||
AND p.offer_id IS NOT NULL
|
AND p.offer_id IS NOT NULL
|
||||||
AND p.offer_id <> ''
|
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 ]
|
[ ':client_id' => $client_id ]
|
||||||
) -> fetchAll( \PDO::FETCH_ASSOC );
|
) -> fetchAll( \PDO::FETCH_ASSOC );
|
||||||
|
|
||||||
@@ -187,7 +188,7 @@ class SupplementalFeed
|
|||||||
throw new \RuntimeException( 'Nie mozna otworzyc pliku: ' . $file_path );
|
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;
|
$written = 0;
|
||||||
foreach ( $products as $row )
|
foreach ( $products as $row )
|
||||||
@@ -199,8 +200,16 @@ class SupplementalFeed
|
|||||||
$custom_label_1 = trim( (string) ( $row['custom_label_1'] ?? '' ) );
|
$custom_label_1 = trim( (string) ( $row['custom_label_1'] ?? '' ) );
|
||||||
$custom_label_3 = trim( (string) ( $row['custom_label_3'] ?? '' ) );
|
$custom_label_3 = trim( (string) ( $row['custom_label_3'] ?? '' ) );
|
||||||
$custom_label_4 = trim( (string) ( $row['custom_label_4'] ?? '' ) );
|
$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;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -212,7 +221,9 @@ class SupplementalFeed
|
|||||||
$category,
|
$category,
|
||||||
$custom_label_1,
|
$custom_label_1,
|
||||||
$custom_label_3,
|
$custom_label_3,
|
||||||
$custom_label_4
|
$custom_label_4,
|
||||||
|
$unit_pricing_measure,
|
||||||
|
$unit_pricing_base_measure
|
||||||
] ) . "\n" );
|
] ) . "\n" );
|
||||||
|
|
||||||
$written++;
|
$written++;
|
||||||
|
|||||||
@@ -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: `<liczba><spacja><jednostka>`, 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
|
### 4.7 Odczyt minimalnego ROAS produktu
|
||||||
|
|
||||||
- `action=product_min_roas_get`
|
- `action=product_min_roas_get`
|
||||||
|
|||||||
@@ -91,6 +91,9 @@ CREATE TABLE IF NOT EXISTS `products` (
|
|||||||
`name` varchar(255) NOT NULL DEFAULT '0',
|
`name` varchar(255) NOT NULL DEFAULT '0',
|
||||||
`min_roas` int(11) DEFAULT NULL,
|
`min_roas` int(11) DEFAULT NULL,
|
||||||
`custom_label_4` varchar(255) 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,
|
`custom_label_3` varchar(255) DEFAULT NULL,
|
||||||
`title` varchar(255) DEFAULT NULL,
|
`title` varchar(255) DEFAULT NULL,
|
||||||
`description` text DEFAULT NULL,
|
`description` text DEFAULT NULL,
|
||||||
|
|||||||
177
docs/unit-price.md
Normal file
177
docs/unit-price.md
Normal file
@@ -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: `<liczba><spacja><jednostka>` — 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 <m> / <b> (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
|
||||||
|
<g:unit_pricing_measure>30 ml</g:unit_pricing_measure>
|
||||||
|
<g:unit_pricing_base_measure>100 ml</g:unit_pricing_base_measure>
|
||||||
|
```
|
||||||
|
|
||||||
|
— **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 `<g:unit_pricing_measure>` + `<g:unit_pricing_base_measure>` 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/<dom>/.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`.
|
||||||
59
migrations/030_products_unit_pricing.sql
Normal file
59
migrations/030_products_unit_pricing.sql
Normal file
@@ -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;
|
||||||
Reference in New Issue
Block a user