This commit is contained in:
2026-04-25 17:31:15 +02:00
parent 25fb0364ac
commit 973c98723f
8 changed files with 780 additions and 164 deletions

View File

@@ -1,56 +1,47 @@
# STATE
# STATE
## Current Position
Milestone: (ad-hoc) Products — widok "wszystkie kampanie"
Phase: 3 of 3 (Products All Campaigns View) Completed
Plan: 03-01 unified (loop closed)
Milestone: (ad-hoc) Products - aggregate + breakdown
Phase: 4 of 4 (Products Aggregate Breakdown) - Completed
Plan: 04-01 unified (loop closed)
Status: UNIFY complete
Last activity: 2026-04-24 — Zamknięto pętlę planu 03-01
Last activity: 2026-04-25T17:28:08+02:00 - Zamknieto petle planu 04-01
Progress:
- Milestone: [██████████] 100%
- Phase 3: [██████████] 100%
- Phase 4: [██████████] 100%
## Loop Position
```
PLAN ──▶ APPLY ──▶ UNIFY
✓ ✓ [Pętla zamknięta]
PLAN --> APPLY --> UNIFY
✓ ✓ ✓ [Petla zamknieta]
```
## Session Continuity
Last session: 2026-04-24
Stopped at: Loop closed, pętla gotowa do nowego /paul:plan
Next action: Wdróż `templates/products/main_view.php` na produkcję (FTP sync); w razie kolejnego zadania — `/paul:plan` z opisem
Resume file: .paul/phases/03-products-all-campaigns-view/03-01-SUMMARY.md
Last session: 2026-04-25
Stopped at: Loop closed, gotowe do nowego /paul:plan
Next action: Jesli chcesz kontynuowac - uruchom /paul:plan z kolejnym zadaniem
Resume file: .paul/phases/04-products-aggregate-breakdown/04-01-SUMMARY.md
## Historia zrealizowanych planów
## Historia zrealizowanych planow
- `01-01-PLAN.md` CL3 CL1 w tabeli /products (completed 2026-04-22)
- 4 pliki zmodyfikowane: migration 028, factory, controller, template
- 4 AC spełnione
- 2 odchylenia udokumentowane (szerokość kolumny 50→120px, weryfikacja sync GAds)
- `02-01-PLAN.md` — custom_label_1 w supplemental feed TSV (completed 2026-04-22)
- 1 plik zmodyfikowany: autoload/services/class.SupplementalFeed.php (+6/-4)
- 4 AC spełnione
- 0 odchyleń — plan 1:1
- `03-01-PLAN.md` — Powrót do widoku "wszystkie kampanie" w /products (completed 2026-04-24)
- 1 plik zmodyfikowany: templates/products/main_view.php (+1/-3)
- Zmiana: usunięto `placeholder: '- wybierz -'` i `allowClear: true` z Select2 dla `#products_campaign_id` / `#products_ad_group_id`
- 4 AC spełnione
- 1 kosmetyczna różnica w verify (allowClear na linii 2097 to niezwiązany Google taxonomy picker, poza zakresem)
- `01-01-PLAN.md` - CL3 -> CL1 w tabeli /products (completed 2026-04-22)
- `02-01-PLAN.md` - custom_label_1 w supplemental feed TSV (completed 2026-04-22)
- `03-01-PLAN.md` - Powrot do widoku "wszystkie kampanie" w /products (completed 2026-04-24)
- `04-01-PLAN.md` - Agregat produktu + rozwijane podwiersze kampania/grupa w /products (completed 2026-04-25)
## Decisions
| Date | Decision | Phase | Impact |
|------|----------|-------|--------|
| 2026-04-24 | Usunięcie placeholdera zamiast wprowadzania wartości sentinel `0`/`all` — minimalny blast radius | 3 | 1-linijkowa zmiana w JS, brak zmian w kontrolerze/factory |
| 2026-04-24 | Usuniecie placeholdera zamiast sentinel `0/all` | 3 | Minimalny blast radius, bez zmian w kontrolerze/factory |
| 2026-04-25 | Bez wybranej grupy: glowny agregat per produkt + rozwijane podwiersze per kampania/grupa | 4 | Czytelniejsza analiza produktu i szybki drill-down |
| 2026-04-25 | Podwiersze tylko readonly, edycja tylko w parent row | 4 | Spojnosc UX i brak konfliktu akcji edycyjnych |
## Notes
- PAUL framework działa w trybie ad-hoc (bez pełnej roadmapy/PROJECT.md).
- Backend `factory\Products::build_scope_filters` już dziś traktuje `campaign_id ≤ 0` jako "bez filtra" — fix był wyłącznie po stronie JS.
- Ustalony wzorzec: **Select2 + `<option value="">agregat</option>` ⇒ NIE konfigurować `placeholder` ani `allowClear`**, żeby opcja pozostała widoczna w dropdownie po selekcji.
- Deploy: wymaga FTP sync szablonu + hard reload (Ctrl+Shift+R) w przeglądarce klientów.
- PAUL framework dziala tutaj w trybie ad-hoc (bez ROADMAP.md i PROJECT.md).
- Human-verify checkpoint dla planu 04-01 zatwierdzony (`approved`).

View File

@@ -0,0 +1,13 @@
# 2026-04-25
## Co zrobiono
- [Phase 4, Plan 01] Wdrozono agregacje listy produktow: 1 produkt = 1 wiersz glowny przy braku wybranej grupy reklam.
- Dodano rozwijane podwiersze z pelnym rozbiciem na kampanie i grupy reklam.
- Pozostawiono edycje (min_roas, CL1, CL4, akcje) tylko w wierszu glownym.
## Zmienione pliki
- `autoload/factory/class.Products.php`
- `autoload/controls/class.Products.php`
- `templates/products/main_view.php`

View File

@@ -0,0 +1,206 @@
---
phase: 04-products-aggregate-breakdown
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- autoload/factory/class.Products.php
- autoload/controls/class.Products.php
- templates/products/main_view.php
autonomous: false
delegation: off
---
<objective>
## Goal
Na stronie `/products` zmienic tryb listy tak, aby przy braku wybranej grupy reklam (`ad_group_id` puste/0) tabela pokazywala 1 wiersz glowny na produkt z metrykami zagregowanymi, oraz rozwijane podwiersze z pelnym rozbiciem na kampanie i grupy reklam.
## Purpose
Obecny widok miesza poziomy agregacji i utrudnia szybka analize produktu jako calosci. Wiersz glowny ma pokazywac wynik laczny produktu, a szczegoly (kampania/grupa) maja byc dostepne na zadanie przez rozwiniecie.
## Output
- Backend zwracajacy dane zagregowane per produkt oraz szczegoly per scope (kampania/grupa) dla aktualnej strony.
- Frontend DataTable z przyciskiem rozwin/zwin i podwierszem z pelnymi metrykami.
- Brak mozliwosci edycji w podwierszach (edycja tylko w wierszu glownym).
</objective>
<context>
## Project Context
@.paul/STATE.md
@.paul/phases/03-products-all-campaigns-view/03-01-SUMMARY.md
## Source Files
@autoload/factory/class.Products.php
@autoload/controls/class.Products.php
@templates/products/main_view.php
## Constraints from user
- Podwiersze maja byc zwijane/rozwijane przyciskiem.
- Podwiersze maja pokazywac pelny zestaw metryk.
- Edycja (`min_roas`, `CL1`, `CL4`, akcje) tylko w wierszu glownym produktu.
## Notes
Repo dziala w trybie ad-hoc PAUL (bez ROADMAP.md / PROJECT.md). Plan tworzony jako kolejna faza ad-hoc.
</context>
<acceptance_criteria>
## AC-1: Agregacja produktu przy braku wybranej grupy reklam
```gherkin
Given uzytkownik jest na `/products` i ma wybranego klienta
And filtr grupy reklam jest pusty (`#products_ad_group_id` = "")
When tabela laduje dane
Then kazdy produkt wystepuje tylko raz jako wiersz glowny
And metryki wiersza glownego (wyswietlenia, klikniecia, koszt, konwersje, wartosc konwersji, ROAS itd.) sa suma ze wszystkich pasujacych scope
```
## AC-2: Rozwijane podwiersze z pelnym rozbiciem
```gherkin
Given tabela produktow jest zaladowana w trybie z AC-1
When uzytkownik kliknie przycisk rozwin przy produkcie
Then pod wierszem glownym pojawia sie podwiersz(e) z rozbiciem per kampania+grupa reklam
And kazdy podwiersz pokazuje pelen zestaw kolumn metryk jak w widoku glownym (w formie tylko-do-odczytu)
And ponowne klikniecie przycisku zwija szczegoly
```
## AC-3: Edycja tylko w wierszu glownym
```gherkin
Given produkt ma rozwiniete podwiersze
When uzytkownik przeglada podwiersze
Then nie ma tam inputow ani przyciskow edycji/usuwania
And inputy `min_roas`, `custom_label_1`, `custom_label_4` oraz akcje pozostaja dostepne tylko w wierszu glownym produktu
```
## AC-4: Brak regresji dla filtrowania i wydajnosci listy
```gherkin
Given nowy tryb agregacji i podwierszy
When uzytkownik zmienia filtry (kampania, grupa, search, CL1, CL4), sortowanie i paginacje
Then tabela zachowuje poprawna liczbe rekordow i sortowanie
And przy wybranej konkretnej grupie reklam zachowanie pozostaje logicznie spojne (bez duplikacji i bez bledow JS/PHP)
And endpoint DataTables nie zwraca bledow SQL ani warningow
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Przebudowa warstwy danych na agregat produktu + szczegoly scope</name>
<files>autoload/factory/class.Products.php</files>
<action>
Rozszerzyc factory tak, aby `get_products()` zwracalo dane glownych wierszy agregowane per `p.id` przy `ad_group_id <= 0`, oraz by mozna bylo pobrac szczegoly per kampania/grupa dla zestawu `product_id` z biezacej strony.
Zakres:
- Utrzymac medoo/prepared statements.
- Dostosowac `GROUP BY` i logike licznikow tak, aby `recordsTotal` liczyl produkty, a nie scope, gdy nie ma wybranej grupy.
- Dodac/metody pomocnicze do pobrania breakdownu dla listy produktow bez N+1 (jedno zapytanie z `IN (...)` + grupowanie po stronie PHP).
- Utrzymac zgodnosc sortowania kolumn DataTables dla wiersza glownego.
Unikac:
- SQL sklejanych stringiem bez parametrow.
- Dublowania tej samej logiki obliczen metryk w wielu metodach bez wspolnego helpera.
</action>
<verify>
- `php -l autoload/factory/class.Products.php`
- Szybki test endpointu `/products/get_products/` dla: (a) ad_group_id="", (b) ad_group_id>0 i porownanie liczby rekordow.
- Brak warningow SQL / PHP w logu.
</verify>
<done>AC-1 i czesc AC-4 spelnione: poprawna agregacja i poprawne liczenie rekordow.</done>
</task>
<task type="auto">
<name>Task 2: Adaptacja kontrolera odpowiedzi DataTables pod dane glowne + breakdown</name>
<files>autoload/controls/class.Products.php</files>
<action>
W `get_products()` przebudowac format odpowiedzi JSON tak, aby kazdy wiersz glowny produktu zawieral identyfikator i dane potrzebne do renderu rozwijanych podwierszy (np. `row_meta` / `breakdown_rows`).
Zakres:
- Zachowac obecne formatowanie komorek glownych (ROAS bar, warningi, linki, akcje).
- Dla podwierszy zwracac pelne metryki, ale bez kontrolnych elementow edycji.
- Zachowac kompatybilnosc z istniejacymi handlerami JS, ktore operuja na kolumnach glownych.
Unikac:
- Wykonywania dodatkowego zapytania DB na kazdy pojedynczy wiersz.
- Mieszania HTML edycyjnego do danych podwierszy.
</action>
<verify>
- `php -l autoload/controls/class.Products.php`
- Odpowiedz endpointu zawiera dane breakdown tylko dla produktow z biezacej strony i nie psuje `recordsTotal`, `recordsFiltered`, `data`.
</verify>
<done>AC-2 i AC-3 backend-ready: API dostarcza dane do podwierszy i oddziela je od edycji.</done>
</task>
<task type="auto">
<name>Task 3: UI DataTable - przycisk rozwin i render podwierszy</name>
<files>templates/products/main_view.php</files>
<action>
Dodac w tabeli produktow obsluge rozwijanych podwierszy:
- Przycisk w wierszu glownym (rozwin/zwin) widoczny tylko gdy sa szczegoly.
- Render child-row (DataTables child row) z pelnym zestawem metryk dla kampania+grupa.
- Podwiersze readonly: bez inputow i bez akcji edycyjnych/usuwania.
- Zachowac obecne akcje edycyjne tylko dla glownego wiersza.
Dodatkowo:
- Dodac czytelne style dla child-row (wizualnie odroznione od glownej tabeli).
- Nie przekraczac 3 poziomow zagniezdzenia - wydzielic helpery JS do budowy HTML podwierszy i toggla.
</action>
<verify>
- `php -l templates/products/main_view.php`
- Manual: rozwin/zwin dziala, brak bledow w konsoli, sortowanie/paginacja dalej dziala.
</verify>
<done>AC-2, AC-3 i pozostala czesc AC-4 spelnione po stronie UI.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<what-built>
Nowy widok listy produktow: glowny agregat per produkt + rozwijane podwiersze kampania/grupa z pelnymi metrykami, bez edycji w podwierszach.
</what-built>
<how-to-verify>
1. Otworz `/products`, wybierz klienta.
2. Ustaw kampanie dowolnie, ale pozostaw "Grupa reklam" na "- wszystkie grupy -".
3. Potwierdz: kazdy produkt jest raz jako wiersz glowny, metryki sa sumaryczne.
4. Kliknij przycisk rozwin przy kilku produktach.
5. Potwierdz: podwiersze pokazuja pelne metryki per kampania/grupa, bez inputow i bez akcji.
6. Zweryfikuj, ze edycja (`min_roas`, `CL1`, `CL4`) dziala tylko w wierszu glownym.
7. Sprawdz sortowanie, paginacje i filtry tekstowe.
8. Sprawdz konsolę przegladarki i logi PHP: brak nowych bledow.
</how-to-verify>
<resume-signal>Wpisz "approved" aby przejsc do UNIFY, albo opisz odchylenia do poprawy.</resume-signal>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- Migracje bazy i schemat tabel.
- Logika niezalezna od /products (inne kontrolery/widoki).
- Mechanika CSRF/sesji i routing aplikacji.
## SCOPE LIMITS
- Tylko zakladka `/products`.
- Tylko zachowanie listy i sposob prezentacji danych.
- Bez nowych funkcji biznesowych poza agregacja + podwiersze.
</boundaries>
<verification>
Before declaring plan complete:
- [ ] `php -l autoload/factory/class.Products.php`
- [ ] `php -l autoload/controls/class.Products.php`
- [ ] `php -l templates/products/main_view.php`
- [ ] Manualna weryfikacja AC-1..AC-4
- [ ] Brak nowych bledow JS/PHP/SQL
</verification>
<success_criteria>
- Widok przy pustym `ad_group_id` pokazuje agregat 1 produkt = 1 wiersz glowny.
- Podwiersze sa rozwijane przyciskiem i pokazuja pelne metryki per kampania/grupa.
- Edycja pozostaje tylko w wierszu glownym.
- Brak regresji filtrow, sortowania i paginacji.
</success_criteria>
<output>
After completion, create `.paul/phases/04-products-aggregate-breakdown/04-01-SUMMARY.md`.
</output>

View File

@@ -0,0 +1,138 @@
---
phase: 04-products-aggregate-breakdown
plan: 01
subsystem: ui
tags: [products, datatables, aggregation, breakdown, php, jquery]
requires:
- phase: 03-products-all-campaigns-view
provides: stable products scope filters and all-campaign selector behavior
provides:
- Aggregated product rows (1 product = 1 row when no ad group is selected)
- Expandable campaign/ad-group breakdown rows with full metrics
- Edit actions kept only on the main product row
affects:
- products table data contract from /products/get_products/
- products DataTable row rendering and child-row behavior
tech-stack:
added: []
patterns:
- aggregate main row + readonly scope breakdown in child row
key-files:
created: []
modified:
- autoload/factory/class.Products.php
- autoload/controls/class.Products.php
- templates/products/main_view.php
key-decisions:
- "Use one batch breakdown query for page product IDs to avoid N+1"
- "Expose breakdown as row_meta in DataTables payload"
- "Keep inline edit fields only in parent row"
patterns-established:
- "When scope is not narrowed to ad_group_id, aggregate per product and show scope details on demand"
duration: ~23min
started: 2026-04-25T17:05:05+02:00
completed: 2026-04-25T17:28:08+02:00
---
# Phase 4 Plan 01: Products Aggregate Breakdown (Summary)
**Na /products wdrozono model: glowny agregat per produkt + rozwijane readonly podwiersze kampania/grupa z pelnymi metrykami.**
## Performance
| Metric | Value |
|--------|-------|
| Duration | ~23 min |
| Started | 2026-04-25T17:05:05+02:00 |
| Completed | 2026-04-25T17:28:08+02:00 |
| Tasks | 4/4 (3 auto + 1 human-verify) |
| Files modified | 3 |
## Acceptance Criteria Results
| Criterion | Status | Notes |
|-----------|--------|-------|
| AC-1: Agregacja produktu bez wybranej grupy | Pass | Backend grupuje po `p.id`, recordsTotal liczy DISTINCT produktow |
| AC-2: Rozwijane podwiersze z pelnym rozbiciem | Pass | Child-row DataTables renderuje pelny zestaw metryk per kampania+grupa |
| AC-3: Edycja tylko w wierszu glownym | Pass | Podwiersze sa readonly; inputy i akcje zostaja w parent row |
| AC-4: Brak regresji filtrowania/listy | Pass | User checkpoint `approved`; endpoint i lintery bez bledow |
## Accomplishments
- Przebudowano zapytania listy produktow tak, aby glowny wynik byl agregowany per produkt.
- Dodano zbiorcze pobieranie breakdownu scope dla aktualnej strony (bez N+1).
- Rozszerzono payload DataTables o `row_meta.breakdown_rows` i dodano UI rozwin/zwin z tabela szczegolow.
## Task Commits
Brak commitu fazowego na tym etapie (working tree zawiera rowniez inne lokalne zmiany projektu).
## Files Created/Modified
| File | Change | Purpose |
|------|--------|---------|
| `autoload/factory/class.Products.php` | Modified (+148/-101) | Agregacja per produkt, wspolny filtr, breakdown query, records count |
| `autoload/controls/class.Products.php` | Modified (+53/-19) | Dolaczenie breakdownu do odpowiedzi i row_meta dla DataTables |
| `templates/products/main_view.php` | Modified (+162/-1) | UI toggle, child-row render, style breakdownu |
## Decisions Made
| Decision | Rationale | Impact |
|----------|-----------|--------|
| Batch breakdown query for page products | Ogranicza obciazenie DB i unika N+1 | Stabilna wydajnosc przy paginacji |
| Keep edit controls only in parent row | Zgodnosc z wymaganiem usera | Jasny podzial: edycja vs analiza |
| Render breakdown in DataTables child row | Minimalny blast radius dla istniejacego ukladu kolumn | Niska regresyjnosc UI |
## Deviations from Plan
### Summary
| Type | Count | Impact |
|------|-------|--------|
| Auto-fixed | 1 | Niski, bez zmiany zakresu |
| Scope additions | 0 | Brak |
| Deferred | 0 | Brak |
**Total impact:** Plan zrealizowany zgodnie z zakresem; jedna techniczna korekta podczas implementacji.
### Auto-fixed Issues
1. Tymczasowe uszkodzenie pliku kontrolera podczas edycji skryptowej
- Found during: Task 2
- Issue: `autoload/controls/class.Products.php` zostal chwilowo uszkodzony (parse error)
- Fix: przywrocenie pliku z HEAD i ponowne, punktowe patche
- Verification: `php -l autoload/controls/class.Products.php` OK
### Deferred Items
Brak.
## Issues Encountered
| Issue | Resolution |
|-------|------------|
| Konflikty przy automatycznej podmianie wiekszego bloku kontrolera | Zmiana strategii na male, precyzyjne patche i ponowna walidacja |
## Next Phase Readiness
**Ready:**
- Kontrakt endpointu `/products/get_products/` obsluguje agregat + breakdown.
- UI listy wspiera drill-down bez naruszania obecnych akcji.
**Concerns:**
- W working tree sa tez niezalezne lokalne zmiany (`.vscode/ftp-kr.sync.cache.json`).
**Blockers:**
- Brak.
---
*Phase: 04-products-aggregate-breakdown, Plan: 01*
*Completed: 2026-04-25*

View File

@@ -751,6 +751,12 @@
"size": 796,
"lmtime": 1776845275979,
"modified": false
},
"2026-04-24.md": {
"type": "-",
"size": 742,
"lmtime": 1777064721127,
"modified": false
}
},
"docs": {
@@ -803,6 +809,12 @@
"size": 9033,
"lmtime": 1776845292488,
"modified": false
},
"governance_2026-04-24.jsonl": {
"type": "-",
"size": 1614,
"lmtime": 1777064743676,
"modified": false
}
},
"phases": {
@@ -833,12 +845,26 @@
"lmtime": 1776845256633,
"modified": false
}
},
"03-products-all-campaigns-view": {
"03-01-PLAN.md": {
"type": "-",
"size": 12821,
"lmtime": 1777064481097,
"modified": false
},
"03-01-SUMMARY.md": {
"type": "-",
"size": 6938,
"lmtime": 1777064708436,
"modified": false
}
}
},
"STATE.md": {
"type": "-",
"size": 1518,
"lmtime": 1776845291913,
"size": 2531,
"lmtime": 1777064743120,
"modified": false
}
},
@@ -897,8 +923,8 @@
"products": {
"main_view.php": {
"type": "-",
"size": 83598,
"lmtime": 1776810839722,
"size": 83538,
"lmtime": 1777064546782,
"modified": false
},
"product_history.php": {

View File

@@ -965,6 +965,10 @@ class Products
$db_results = \factory\Products::get_products( $client_id, $search, $limit, $start, $order_name, $order_dir, $campaign_id, $ad_group_id, $filter_cl4, $filter_cl1 );
$recordsTotal = \factory\Products::get_records_total_products( $client_id, $search, $campaign_id, $ad_group_id, $filter_cl4, $filter_cl1 );
$product_ids = array_values( array_unique( array_filter( array_map( function( $row ) {
return (int) ( $row['product_id'] ?? 0 );
}, (array) $db_results ) ) ) );
$breakdown_map = \factory\Products::get_products_scope_breakdown( $client_id, $product_ids, $campaign_id, $ad_group_id, $search, $filter_cl4, $filter_cl1 );
// Sredni CR konta — do obliczenia progu klikniec
$account_cr = \factory\Products::get_account_conversion_rate( (int) $client_id );
@@ -977,11 +981,13 @@ class Products
foreach ( $db_results as $row )
{
$product_id = (int) ( $row['product_id'] ?? 0 );
$breakdown_rows = (array) ( $breakdown_map[ $product_id ] ?? [] );
$custom_class = '';
$custom_label_4 = \factory\Products::get_product_data( $row['product_id'], 'custom_label_4' );
$custom_label_1 = \factory\Products::get_product_data( $row['product_id'], 'custom_label_1' );
$custom_name = \factory\Products::get_product_data( $row['product_id'], 'title' );
$product_url = trim( (string) \factory\Products::get_product_data( $row['product_id'], 'product_url' ) );
$custom_label_4 = \factory\Products::get_product_data( $product_id, 'custom_label_4' );
$custom_label_1 = \factory\Products::get_product_data( $product_id, 'custom_label_1' );
$custom_name = \factory\Products::get_product_data( $product_id, 'title' );
$product_url = trim( (string) \factory\Products::get_product_data( $product_id, 'product_url' ) );
if ( $custom_name )
{
@@ -1074,29 +1080,56 @@ class Products
$history_campaign_id = (int) ( $row['history_campaign_id'] ?? 0 );
$history_ad_group_id = (int) ( $row['history_ad_group_id'] ?? 0 );
$breakdown_for_view = [];
if ( $history_campaign_id <= 0 )
if ( $history_campaign_id <= 0 && !empty( $breakdown_rows ) )
{
$history_campaign_id = (int) ( $row['campaign_id'] ?? 0 );
$history_campaign_id = (int) ( $breakdown_rows[0]['campaign_id'] ?? 0 );
}
if ( $history_ad_group_id <= 0 )
if ( $history_ad_group_id <= 0 && !empty( $breakdown_rows ) )
{
$history_ad_group_id = (int) ( $row['ad_group_id'] ?? 0 );
$history_ad_group_id = (int) ( $breakdown_rows[0]['ad_group_id'] ?? 0 );
}
foreach ( $breakdown_rows as $breakdown_row )
{
$breakdown_for_view[] = [
'campaign_name' => (string) ( $breakdown_row['campaign_name'] ?? '' ),
'ad_group_name' => (string) ( $breakdown_row['ad_group_name'] ?? '' ),
'impressions' => (int) ( $breakdown_row['impressions'] ?? 0 ),
'impressions_30' => (int) ( $breakdown_row['impressions_30'] ?? 0 ),
'clicks' => (int) ( $breakdown_row['clicks'] ?? 0 ),
'clicks_30' => (int) ( $breakdown_row['clicks_30'] ?? 0 ),
'ctr' => (float) ( $breakdown_row['ctr'] ?? 0 ),
'cost' => (float) ( $breakdown_row['cost'] ?? 0 ),
'cpc' => (float) ( $breakdown_row['cpc'] ?? 0 ),
'conversions' => (float) ( $breakdown_row['conversions'] ?? 0 ),
'conversions_value' => (float) ( $breakdown_row['conversions_value'] ?? 0 ),
'roas' => (float) ( $breakdown_row['roas'] ?? 0 ),
'min_roas' => (float) ( $row['min_roas'] ?? 0 ),
'custom_label_1' => (string) $custom_label_1,
'custom_label_4' => (string) $custom_label_4
];
}
$row_meta = [
'can_expand' => count( $breakdown_for_view ) > 0,
'breakdown_rows' => $breakdown_for_view
];
$data['data'][] = [
'', // checkbox column
$row['product_id'],
$product_id,
$row['offer_id'],
htmlspecialchars( (string) ( $row['campaign_name'] ?? '' ) ),
htmlspecialchars( (string) ( $row['ad_group_name'] ?? '' ) ),
$product_url_html,
'<div class="table-product-title" product_id="' . $row['product_id'] . '">
<a href="/products/product_history/client_id=' . $client_id . '&product_id=' . $row['product_id'] . '&campaign_id=' . $history_campaign_id . '&ad_group_id=' . $history_ad_group_id . '" target="_blank" class="' . $custom_class . '">
'<div class="table-product-title" product_id="' . $product_id . '">
<a href="/products/product_history/client_id=' . $client_id . '&product_id=' . $product_id . '&campaign_id=' . $history_campaign_id . '&ad_group_id=' . $history_ad_group_id . '" target="_blank" class="' . $custom_class . '">
' . $row['name'] . '
</a>
<span class="edit-product-title" product_id="' . $row['product_id'] . '">
<span class="edit-product-title" product_id="' . $product_id . '">
<i class="fa fa-pencil"></i>
</span>
</div>',
@@ -1111,14 +1144,15 @@ class Products
round( $row['conversions'], 2 ),
\S::number_display( $row['conversions_value'] ),
$roasCellHtml,
'<input type="text" class="form-control min_roas" product_id="' . $row['product_id'] . '" value="' . $row['min_roas'] . '" style="width: 100px;">',
'<input type="text" class="form-control custom_label_1" product_id="' . $row['product_id'] . '" value="' . $custom_label_1 . '" style="' . $custom_label_1_color . '">',
'<input type="text" class="form-control custom_label_4" product_id="' . $row['product_id'] . '" value="' . $custom_label_4 . '" style="' . $custom_label_4_color . '">',
'<input type="text" class="form-control min_roas" product_id="' . $product_id . '" value="' . $row['min_roas'] . '" style="width: 100px;">',
'<input type="text" class="form-control custom_label_1" product_id="' . $product_id . '" value="' . $custom_label_1 . '" style="' . $custom_label_1_color . '">',
'<input type="text" class="form-control custom_label_4" product_id="' . $product_id . '" value="' . $custom_label_4 . '" style="' . $custom_label_4_color . '">',
'<div class="btn-group btn-group-sm products-row-actions" role="group">'
. '<button type="button" class="btn btn-primary assign-product-scope" product_id="' . $row['product_id'] . '" title="Dodaj produkt do kampanii/grupy"><i class="fa-solid fa-diagram-project"></i></button>'
. '<button type="button" class="btn btn-secondary view-merchant-logs" product_id="' . $row['product_id'] . '" title="Pokaż logi synchronizacji Merchant"><i class="fa-solid fa-clock-rotate-left"></i></button>'
. '<button type="button" class="btn btn-danger delete-product" product_id="' . $row['product_id'] . '" title="Usuń produkt"><i class="fa-solid fa-trash"></i></button>'
. '</div>'
. '<button type="button" class="btn btn-primary assign-product-scope" product_id="' . $product_id . '" title="Dodaj produkt do kampanii/grupy"><i class="fa-solid fa-diagram-project"></i></button>'
. '<button type="button" class="btn btn-secondary view-merchant-logs" product_id="' . $product_id . '" title="Pokaż logi synchronizacji Merchant"><i class="fa-solid fa-clock-rotate-left"></i></button>'
. '<button type="button" class="btn btn-danger delete-product" product_id="' . $product_id . '" title="Usuń produkt"><i class="fa-solid fa-trash"></i></button>'
. '</div>',
$row_meta
];
}

View File

@@ -487,6 +487,39 @@ class Products
$sql .= ' AND ag.status = \'active\'';
}
static private function build_products_filters( &$sql, &$params, $search, $custom_label_4, $custom_label_1 )
{
$search = trim( (string) $search );
$custom_label_4 = trim( (string) $custom_label_4 );
$custom_label_1 = trim( (string) $custom_label_1 );
if ( $search !== '' )
{
$sql .= ' AND (
p.name LIKE :search
OR p.title LIKE :search
OR p.offer_id LIKE :search
OR p.custom_label_4 LIKE :search
OR p.custom_label_1 LIKE :search
OR c.campaign_name LIKE :search
OR ag.ad_group_name LIKE :search
)';
$params[':search'] = '%' . $search . '%';
}
if ( $custom_label_4 !== '' )
{
$sql .= ' AND p.custom_label_4 LIKE :custom_label_4';
$params[':custom_label_4'] = '%' . $custom_label_4 . '%';
}
if ( $custom_label_1 !== '' )
{
$sql .= ' AND p.custom_label_1 LIKE :custom_label_1';
$params[':custom_label_1'] = '%' . $custom_label_1 . '%';
}
}
static public function get_products( $client_id, $search, $limit, $start, $order_name, $order_dir, $campaign_id = 0, $ad_group_id = 0, $custom_label_4 = '', $custom_label_1 = '' )
{
global $mdb;
@@ -522,15 +555,17 @@ class Products
p.offer_id,
p.min_roas,
COALESCE( NULLIF( TRIM( p.custom_label_1 ), \'\' ), \'\' ) AS custom_label_1,
pa.campaign_id AS campaign_id,
COALESCE( NULLIF( TRIM( c.campaign_name ), \'\' ), \'--- brak kampanii ---\' ) AS campaign_name,
CASE
WHEN pa.ad_group_id = 0 THEN \'PMax (bez grup reklam)\'
ELSE COALESCE( NULLIF( TRIM( ag.ad_group_name ), \'\' ), \'--- brak grupy reklam ---\' )
WHEN COUNT( DISTINCT pa.campaign_id ) > 1 THEN CONCAT( \'Wiele kampanii (\', COUNT( DISTINCT pa.campaign_id ), \')\' )
ELSE COALESCE( NULLIF( TRIM( MAX( c.campaign_name ) ), \'\' ), \'--- brak kampanii ---\' )
END AS campaign_name,
CASE
WHEN COUNT( DISTINCT CONCAT( pa.campaign_id, \':\', pa.ad_group_id ) ) > 1 THEN CONCAT( \'Wiele grup (\', COUNT( DISTINCT CONCAT( pa.campaign_id, \':\', pa.ad_group_id ) ), \')\' )
WHEN MIN( pa.ad_group_id ) = 0 THEN \'PMax (bez grup reklam)\'
ELSE COALESCE( NULLIF( TRIM( MAX( ag.ad_group_name ) ), \'\' ), \'--- brak grupy reklam ---\' )
END AS ad_group_name,
pa.ad_group_id AS ad_group_id,
pa.campaign_id AS history_campaign_id,
pa.ad_group_id AS history_ad_group_id,
MIN( pa.campaign_id ) AS history_campaign_id,
MIN( pa.ad_group_id ) AS history_ad_group_id,
COALESCE( NULLIF( TRIM( p.title ), \'\' ), NULLIF( TRIM( p.name ), \'\' ), p.offer_id ) AS name,
SUM( pa.impressions_all_time ) AS impressions,
SUM( pa.impressions_30 ) AS impressions_30,
@@ -558,34 +593,9 @@ class Products
WHERE p.client_id = :client_id';
self::build_scope_filters( $sql, $params, $campaign_id, $ad_group_id );
self::build_products_filters( $sql, $params, $search, $custom_label_4, $custom_label_1 );
if ( $search )
{
$sql .= ' AND (
p.name LIKE :search
OR p.title LIKE :search
OR p.offer_id LIKE :search
OR p.custom_label_4 LIKE :search
OR p.custom_label_1 LIKE :search
OR c.campaign_name LIKE :search
OR ag.ad_group_name LIKE :search
)';
$params[':search'] = '%' . $search . '%';
}
if ( $custom_label_4 !== '' )
{
$sql .= ' AND p.custom_label_4 LIKE :custom_label_4';
$params[':custom_label_4'] = '%' . $custom_label_4 . '%';
}
if ( $custom_label_1 !== '' )
{
$sql .= ' AND p.custom_label_1 LIKE :custom_label_1';
$params[':custom_label_1'] = '%' . $custom_label_1 . '%';
}
$sql .= ' GROUP BY p.id, p.offer_id, p.min_roas, p.custom_label_1, p.name, p.title, pa.campaign_id, c.campaign_name, pa.ad_group_id, ag.ad_group_name';
$sql .= ' GROUP BY p.id, p.offer_id, p.min_roas, p.custom_label_1, p.name, p.title';
$sql .= ' ORDER BY ' . $order_sql . ' ' . $order_dir . ', product_id DESC LIMIT ' . $start . ', ' . $limit;
return $mdb -> query( $sql, $params ) -> fetchAll( \PDO::FETCH_ASSOC );
@@ -597,47 +607,30 @@ class Products
$params = [ ':client_id' => $client_id ];
$sql = 'SELECT MIN( p.min_roas ) AS min_roas,
MAX(
$sql = 'SELECT
MIN( t.min_roas ) AS min_roas,
MAX( t.roas ) AS max_roas
FROM (
SELECT
p.id AS product_id,
p.min_roas AS min_roas,
CASE
WHEN COALESCE( pa.cost_all_time, 0 ) > 0 THEN ROUND( COALESCE( pa.conversion_value_all_time, 0 ) / pa.cost_all_time * 100, 2 )
WHEN SUM( pa.cost_all_time ) > 0 THEN ROUND( SUM( pa.conversion_value_all_time ) / SUM( pa.cost_all_time ) * 100, 2 )
ELSE 0
END
) AS max_roas
END AS roas,
SUM( pa.conversions_all_time ) AS conversions
FROM products_aggregate AS pa
INNER JOIN products AS p ON p.id = pa.product_id
LEFT JOIN campaigns AS c ON c.id = pa.campaign_id
LEFT JOIN campaign_ad_groups AS ag ON ag.id = pa.ad_group_id
WHERE p.client_id = :client_id
AND pa.conversions_all_time > 10';
WHERE p.client_id = :client_id';
self::build_scope_filters( $sql, $params, $campaign_id, $ad_group_id );
self::build_products_filters( $sql, $params, $search, $custom_label_4, $custom_label_1 );
if ( $search )
{
$sql .= ' AND (
p.name LIKE :search
OR p.title LIKE :search
OR p.offer_id LIKE :search
OR p.custom_label_4 LIKE :search
OR p.custom_label_1 LIKE :search
OR c.campaign_name LIKE :search
OR ag.ad_group_name LIKE :search
)';
$params[':search'] = '%' . $search . '%';
}
if ( $custom_label_4 !== '' )
{
$sql .= ' AND p.custom_label_4 LIKE :custom_label_4';
$params[':custom_label_4'] = '%' . $custom_label_4 . '%';
}
if ( $custom_label_1 !== '' )
{
$sql .= ' AND p.custom_label_1 LIKE :custom_label_1';
$params[':custom_label_1'] = '%' . $custom_label_1 . '%';
}
$sql .= ' GROUP BY p.id, p.min_roas
HAVING SUM( pa.conversions_all_time ) > 10
) AS t';
$row = $mdb -> query( $sql, $params ) -> fetch( \PDO::FETCH_ASSOC );
@@ -676,9 +669,7 @@ class Products
global $mdb;
$params = [ ':client_id' => (int) $client_id ];
$sql = 'SELECT COUNT(0)
FROM (
SELECT p.id
$sql = 'SELECT COUNT( DISTINCT p.id )
FROM products_aggregate AS pa
INNER JOIN products AS p ON p.id = pa.product_id
LEFT JOIN campaigns AS c ON c.id = pa.campaign_id
@@ -686,39 +677,95 @@ class Products
WHERE p.client_id = :client_id';
self::build_scope_filters( $sql, $params, $campaign_id, $ad_group_id );
if ( $search )
{
$sql .= ' AND (
p.name LIKE :search
OR p.title LIKE :search
OR p.offer_id LIKE :search
OR p.custom_label_4 LIKE :search
OR p.custom_label_1 LIKE :search
OR c.campaign_name LIKE :search
OR ag.ad_group_name LIKE :search
)';
$params[':search'] = '%' . $search . '%';
}
if ( $custom_label_4 !== '' )
{
$sql .= ' AND p.custom_label_4 LIKE :custom_label_4';
$params[':custom_label_4'] = '%' . $custom_label_4 . '%';
}
if ( $custom_label_1 !== '' )
{
$sql .= ' AND p.custom_label_1 LIKE :custom_label_1';
$params[':custom_label_1'] = '%' . $custom_label_1 . '%';
}
$sql .= ' GROUP BY p.id, pa.campaign_id, pa.ad_group_id
) AS grouped_rows';
self::build_products_filters( $sql, $params, $search, $custom_label_4, $custom_label_1 );
return $mdb -> query( $sql, $params ) -> fetchColumn();
}
static public function get_products_scope_breakdown( $client_id, $product_ids, $campaign_id = 0, $ad_group_id = 0, $search = '', $custom_label_4 = '', $custom_label_1 = '' )
{
global $mdb;
$product_ids = array_values( array_filter( array_map( 'intval', (array) $product_ids ) ) );
if ( empty( $product_ids ) )
{
return [];
}
$params = [ ':client_id' => (int) $client_id ];
$in_placeholders = [];
foreach ( $product_ids as $idx => $product_id )
{
$key = ':product_id_' . $idx;
$in_placeholders[] = $key;
$params[ $key ] = $product_id;
}
$sql = 'SELECT
pa.product_id,
pa.campaign_id,
pa.ad_group_id,
COALESCE( NULLIF( TRIM( c.campaign_name ), \'\' ), \'--- brak kampanii ---\' ) AS campaign_name,
CASE
WHEN pa.ad_group_id = 0 THEN \'PMax (bez grup reklam)\'
ELSE COALESCE( NULLIF( TRIM( ag.ad_group_name ), \'\' ), \'--- brak grupy reklam ---\' )
END AS ad_group_name,
SUM( pa.impressions_all_time ) AS impressions,
SUM( pa.impressions_30 ) AS impressions_30,
SUM( pa.clicks_all_time ) AS clicks,
SUM( pa.clicks_30 ) AS clicks_30,
CASE
WHEN SUM( pa.impressions_all_time ) > 0 THEN ROUND( SUM( pa.clicks_all_time ) / SUM( pa.impressions_all_time ) * 100, 2 )
ELSE 0
END AS ctr,
SUM( pa.cost_all_time ) AS cost,
CASE
WHEN SUM( pa.clicks_all_time ) > 0 THEN ROUND( SUM( pa.cost_all_time ) / SUM( pa.clicks_all_time ), 6 )
ELSE 0
END AS cpc,
SUM( pa.conversions_all_time ) AS conversions,
SUM( pa.conversion_value_all_time ) AS conversions_value,
CASE
WHEN SUM( pa.cost_all_time ) > 0 THEN ROUND( SUM( pa.conversion_value_all_time ) / SUM( pa.cost_all_time ) * 100, 2 )
ELSE 0
END AS roas
FROM products_aggregate AS pa
INNER JOIN products AS p ON p.id = pa.product_id
LEFT JOIN campaigns AS c ON c.id = pa.campaign_id
LEFT JOIN campaign_ad_groups AS ag ON ag.id = pa.ad_group_id
WHERE p.client_id = :client_id
AND pa.product_id IN (' . implode( ', ', $in_placeholders ) . ')';
self::build_scope_filters( $sql, $params, $campaign_id, $ad_group_id );
self::build_products_filters( $sql, $params, $search, $custom_label_4, $custom_label_1 );
$sql .= ' GROUP BY pa.product_id, pa.campaign_id, pa.ad_group_id, c.campaign_name, ag.ad_group_name
ORDER BY campaign_name ASC, ad_group_name ASC';
$rows = $mdb -> query( $sql, $params ) -> fetchAll( \PDO::FETCH_ASSOC );
$map = [];
foreach ( $rows as $row )
{
$product_id = (int) ( $row['product_id'] ?? 0 );
if ( $product_id <= 0 )
{
continue;
}
if ( !isset( $map[ $product_id ] ) )
{
$map[ $product_id ] = [];
}
$map[ $product_id ][] = $row;
}
return $map;
}
static public function get_product_full_context( $product_id )
{
global $mdb;

View File

@@ -187,6 +187,55 @@
background-color: #6690f4;
color: #fff;
}
.products-page .products-id-cell {
display: inline-flex;
align-items: center;
gap: 6px;
}
.products-page .products-breakdown-toggle {
width: 22px;
height: 22px;
border: 1px solid #d1d5db;
border-radius: 4px;
background: #fff;
color: #374151;
cursor: pointer;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.products-page .products-breakdown-wrap {
padding: 10px 14px;
background: #f8fafc;
border-top: 1px solid #e5e7eb;
}
.products-page .products-breakdown-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.products-page .products-breakdown-table th,
.products-page .products-breakdown-table td {
border: 1px solid #e5e7eb;
padding: 6px 8px;
text-align: right;
white-space: nowrap;
}
.products-page .products-breakdown-table th:nth-child(1),
.products-page .products-breakdown-table th:nth-child(2),
.products-page .products-breakdown-table td:nth-child(1),
.products-page .products-breakdown-table td:nth-child(2),
.products-page .products-breakdown-table td:nth-child(15),
.products-page .products-breakdown-table td:nth-child(16) {
text-align: left;
}
</style>
<?php
@@ -528,6 +577,83 @@ function init_products_scope_select_search()
} );
}
function products_breakdown_number( value, digits )
{
var num = Number( value || 0 );
if ( !isFinite( num ) )
{
num = 0;
}
return num.toLocaleString( 'pl-PL', {
minimumFractionDigits: digits,
maximumFractionDigits: digits
} );
}
function products_breakdown_meta( row )
{
if ( Array.isArray( row ) && row.length > 22 && row[22] && typeof row[22] === 'object' )
{
return row[22];
}
return { can_expand: false, breakdown_rows: [] };
}
function products_build_breakdown_html( row_meta )
{
var rows = Array.isArray( row_meta.breakdown_rows ) ? row_meta.breakdown_rows : [];
if ( !rows.length )
{
return '<div class="products-breakdown-wrap">Brak szczegolow dla tego produktu.</div>';
}
var html = '<div class="products-breakdown-wrap"><table class="products-breakdown-table">';
html += '<thead><tr>' +
'<th>Kampania</th>' +
'<th>Grupa reklam</th>' +
'<th>Wysw.</th>' +
'<th>Wysw. (30d)</th>' +
'<th>Klik.</th>' +
'<th>Klik. (30d)</th>' +
'<th>CTR</th>' +
'<th>Koszt</th>' +
'<th>CPC</th>' +
'<th>Konw.</th>' +
'<th>Wart. konw.</th>' +
'<th>ROAS</th>' +
'<th>Min. ROAS</th>' +
'<th>CL1</th>' +
'<th>CL4</th>' +
'</tr></thead><tbody>';
rows.forEach( function( entry ) {
html += '<tr>' +
'<td>' + escape_html( entry.campaign_name || '' ) + '</td>' +
'<td>' + escape_html( entry.ad_group_name || '' ) + '</td>' +
'<td>' + products_breakdown_number( entry.impressions, 0 ) + '</td>' +
'<td>' + products_breakdown_number( entry.impressions_30, 0 ) + '</td>' +
'<td>' + products_breakdown_number( entry.clicks, 0 ) + '</td>' +
'<td>' + products_breakdown_number( entry.clicks_30, 0 ) + '</td>' +
'<td>' + products_breakdown_number( entry.ctr, 2 ) + '%</td>' +
'<td>' + products_breakdown_number( entry.cost, 2 ) + '</td>' +
'<td>' + products_breakdown_number( entry.cpc, 2 ) + '</td>' +
'<td>' + products_breakdown_number( entry.conversions, 2 ) + '</td>' +
'<td>' + products_breakdown_number( entry.conversions_value, 2 ) + '</td>' +
'<td>' + products_breakdown_number( entry.roas, 0 ) + '</td>' +
'<td>' + products_breakdown_number( entry.min_roas, 2 ) + '</td>' +
'<td>' + escape_html( entry.custom_label_1 || '' ) + '</td>' +
'<td>' + escape_html( entry.custom_label_4 || '' ) + '</td>' +
'</tr>';
} );
html += '</tbody></table></div>';
return html;
}
$( function()
{
init_products_scope_select_search();
@@ -557,7 +683,18 @@ $( function()
return '<input type="checkbox" class="product-checkbox" value="' + row[1] + '" />';
}
},
{ width: '50px', orderable: false },
{ width: '70px', orderable: false, render: function( data, type, row ) {
var row_meta = products_breakdown_meta( row );
var toggle = '';
if ( row_meta.can_expand )
{
toggle = '<button type="button" class="products-breakdown-toggle js-products-breakdown-toggle" aria-expanded="false" title="Pokaz rozbicie"><i class="fa-solid fa-chevron-right"></i></button>';
}
return '<div class="products-id-cell">' + toggle + '<span>' + escape_html( data ) + '</span></div>';
}
},
{ width: '80px', name: 'offer_id' },
{ width: '200px', name: 'campaign_name' },
{ width: '200px', name: 'ad_group_name' },
@@ -603,6 +740,30 @@ $( function()
products_apply_saved_columns_visibility( products_table );
products_render_columns_picker( products_table );
$( '#products tbody' ).on( 'click', '.js-products-breakdown-toggle', function( e ) {
e.preventDefault();
var $btn = $( this );
var $tr = $btn.closest( 'tr' );
var dt_row = products_table.row( $tr );
var row_data = dt_row.data() || [];
var row_meta = products_breakdown_meta( row_data );
if ( dt_row.child.isShown() )
{
dt_row.child.hide();
$tr.removeClass( 'products-breakdown-open' );
$btn.attr( 'aria-expanded', 'false' );
$btn.find( 'i' ).removeClass( 'fa-chevron-down' ).addClass( 'fa-chevron-right' );
return;
}
dt_row.child( products_build_breakdown_html( row_meta ) ).show();
$tr.addClass( 'products-breakdown-open' );
$btn.attr( 'aria-expanded', 'true' );
$btn.find( 'i' ).removeClass( 'fa-chevron-right' ).addClass( 'fa-chevron-down' );
} );
function reload_products_table()
{
products_table.ajax.reload( null, false );