update
This commit is contained in:
@@ -1,56 +1,47 @@
|
|||||||
# STATE
|
# STATE
|
||||||
|
|
||||||
## Current Position
|
## Current Position
|
||||||
|
|
||||||
Milestone: (ad-hoc) Products — widok "wszystkie kampanie"
|
Milestone: (ad-hoc) Products - aggregate + breakdown
|
||||||
Phase: 3 of 3 (Products All Campaigns View) — Completed
|
Phase: 4 of 4 (Products Aggregate Breakdown) - Completed
|
||||||
Plan: 03-01 unified (loop closed)
|
Plan: 04-01 unified (loop closed)
|
||||||
Status: UNIFY complete
|
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:
|
Progress:
|
||||||
- Milestone: [██████████] 100%
|
- Milestone: [██████████] 100%
|
||||||
- Phase 3: [██████████] 100%
|
- Phase 4: [██████████] 100%
|
||||||
|
|
||||||
## Loop Position
|
## Loop Position
|
||||||
|
|
||||||
```
|
```
|
||||||
PLAN ──▶ APPLY ──▶ UNIFY
|
PLAN --> APPLY --> UNIFY
|
||||||
✓ ✓ ✓ [Pętla zamknięta]
|
✓ ✓ ✓ [Petla zamknieta]
|
||||||
```
|
```
|
||||||
|
|
||||||
## Session Continuity
|
## Session Continuity
|
||||||
|
|
||||||
Last session: 2026-04-24
|
Last session: 2026-04-25
|
||||||
Stopped at: Loop closed, pętla gotowa do nowego /paul:plan
|
Stopped at: Loop closed, gotowe do nowego /paul:plan
|
||||||
Next action: Wdróż `templates/products/main_view.php` na produkcję (FTP sync); w razie kolejnego zadania — `/paul:plan` z opisem
|
Next action: Jesli chcesz kontynuowac - uruchom /paul:plan z kolejnym zadaniem
|
||||||
Resume file: .paul/phases/03-products-all-campaigns-view/03-01-SUMMARY.md
|
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)
|
- `01-01-PLAN.md` - CL3 -> CL1 w tabeli /products (completed 2026-04-22)
|
||||||
- 4 pliki zmodyfikowane: migration 028, factory, controller, template
|
- `02-01-PLAN.md` - custom_label_1 w supplemental feed TSV (completed 2026-04-22)
|
||||||
- 4 AC spełnione
|
- `03-01-PLAN.md` - Powrot do widoku "wszystkie kampanie" w /products (completed 2026-04-24)
|
||||||
- 2 odchylenia udokumentowane (szerokość kolumny 50→120px, weryfikacja sync GAds)
|
- `04-01-PLAN.md` - Agregat produktu + rozwijane podwiersze kampania/grupa w /products (completed 2026-04-25)
|
||||||
- `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)
|
|
||||||
|
|
||||||
## Decisions
|
## Decisions
|
||||||
|
|
||||||
| Date | Decision | Phase | Impact |
|
| 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
|
## Notes
|
||||||
|
|
||||||
- PAUL framework działa w trybie ad-hoc (bez pełnej roadmapy/PROJECT.md).
|
- PAUL framework dziala tutaj w trybie ad-hoc (bez ROADMAP.md i PROJECT.md).
|
||||||
- Backend `factory\Products::build_scope_filters` już dziś traktuje `campaign_id ≤ 0` jako "bez filtra" — fix był wyłącznie po stronie JS.
|
- Human-verify checkpoint dla planu 04-01 zatwierdzony (`approved`).
|
||||||
- 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.
|
|
||||||
|
|||||||
13
.paul/changelog/2026-04-25.md
Normal file
13
.paul/changelog/2026-04-25.md
Normal 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`
|
||||||
206
.paul/phases/04-products-aggregate-breakdown/04-01-PLAN.md
Normal file
206
.paul/phases/04-products-aggregate-breakdown/04-01-PLAN.md
Normal 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>
|
||||||
138
.paul/phases/04-products-aggregate-breakdown/04-01-SUMMARY.md
Normal file
138
.paul/phases/04-products-aggregate-breakdown/04-01-SUMMARY.md
Normal 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*
|
||||||
34
.vscode/ftp-kr.sync.cache.json
vendored
34
.vscode/ftp-kr.sync.cache.json
vendored
@@ -751,6 +751,12 @@
|
|||||||
"size": 796,
|
"size": 796,
|
||||||
"lmtime": 1776845275979,
|
"lmtime": 1776845275979,
|
||||||
"modified": false
|
"modified": false
|
||||||
|
},
|
||||||
|
"2026-04-24.md": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 742,
|
||||||
|
"lmtime": 1777064721127,
|
||||||
|
"modified": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"docs": {
|
"docs": {
|
||||||
@@ -803,6 +809,12 @@
|
|||||||
"size": 9033,
|
"size": 9033,
|
||||||
"lmtime": 1776845292488,
|
"lmtime": 1776845292488,
|
||||||
"modified": false
|
"modified": false
|
||||||
|
},
|
||||||
|
"governance_2026-04-24.jsonl": {
|
||||||
|
"type": "-",
|
||||||
|
"size": 1614,
|
||||||
|
"lmtime": 1777064743676,
|
||||||
|
"modified": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"phases": {
|
"phases": {
|
||||||
@@ -833,12 +845,26 @@
|
|||||||
"lmtime": 1776845256633,
|
"lmtime": 1776845256633,
|
||||||
"modified": false
|
"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": {
|
"STATE.md": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
"size": 1518,
|
"size": 2531,
|
||||||
"lmtime": 1776845291913,
|
"lmtime": 1777064743120,
|
||||||
"modified": false
|
"modified": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -897,8 +923,8 @@
|
|||||||
"products": {
|
"products": {
|
||||||
"main_view.php": {
|
"main_view.php": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
"size": 83598,
|
"size": 83538,
|
||||||
"lmtime": 1776810839722,
|
"lmtime": 1777064546782,
|
||||||
"modified": false
|
"modified": false
|
||||||
},
|
},
|
||||||
"product_history.php": {
|
"product_history.php": {
|
||||||
|
|||||||
@@ -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 );
|
$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 );
|
$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
|
// Sredni CR konta — do obliczenia progu klikniec
|
||||||
$account_cr = \factory\Products::get_account_conversion_rate( (int) $client_id );
|
$account_cr = \factory\Products::get_account_conversion_rate( (int) $client_id );
|
||||||
@@ -977,11 +981,13 @@ class Products
|
|||||||
|
|
||||||
foreach ( $db_results as $row )
|
foreach ( $db_results as $row )
|
||||||
{
|
{
|
||||||
|
$product_id = (int) ( $row['product_id'] ?? 0 );
|
||||||
|
$breakdown_rows = (array) ( $breakdown_map[ $product_id ] ?? [] );
|
||||||
$custom_class = '';
|
$custom_class = '';
|
||||||
$custom_label_4 = \factory\Products::get_product_data( $row['product_id'], 'custom_label_4' );
|
$custom_label_4 = \factory\Products::get_product_data( $product_id, 'custom_label_4' );
|
||||||
$custom_label_1 = \factory\Products::get_product_data( $row['product_id'], 'custom_label_1' );
|
$custom_label_1 = \factory\Products::get_product_data( $product_id, 'custom_label_1' );
|
||||||
$custom_name = \factory\Products::get_product_data( $row['product_id'], 'title' );
|
$custom_name = \factory\Products::get_product_data( $product_id, 'title' );
|
||||||
$product_url = trim( (string) \factory\Products::get_product_data( $row['product_id'], 'product_url' ) );
|
$product_url = trim( (string) \factory\Products::get_product_data( $product_id, 'product_url' ) );
|
||||||
|
|
||||||
if ( $custom_name )
|
if ( $custom_name )
|
||||||
{
|
{
|
||||||
@@ -1074,29 +1080,56 @@ class Products
|
|||||||
|
|
||||||
$history_campaign_id = (int) ( $row['history_campaign_id'] ?? 0 );
|
$history_campaign_id = (int) ( $row['history_campaign_id'] ?? 0 );
|
||||||
$history_ad_group_id = (int) ( $row['history_ad_group_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'][] = [
|
$data['data'][] = [
|
||||||
'', // checkbox column
|
'', // checkbox column
|
||||||
$row['product_id'],
|
$product_id,
|
||||||
$row['offer_id'],
|
$row['offer_id'],
|
||||||
htmlspecialchars( (string) ( $row['campaign_name'] ?? '' ) ),
|
htmlspecialchars( (string) ( $row['campaign_name'] ?? '' ) ),
|
||||||
htmlspecialchars( (string) ( $row['ad_group_name'] ?? '' ) ),
|
htmlspecialchars( (string) ( $row['ad_group_name'] ?? '' ) ),
|
||||||
$product_url_html,
|
$product_url_html,
|
||||||
'<div class="table-product-title" product_id="' . $row['product_id'] . '">
|
'<div class="table-product-title" product_id="' . $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 . '">
|
<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'] . '
|
' . $row['name'] . '
|
||||||
</a>
|
</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>
|
<i class="fa fa-pencil"></i>
|
||||||
</span>
|
</span>
|
||||||
</div>',
|
</div>',
|
||||||
@@ -1111,14 +1144,15 @@ class Products
|
|||||||
round( $row['conversions'], 2 ),
|
round( $row['conversions'], 2 ),
|
||||||
\S::number_display( $row['conversions_value'] ),
|
\S::number_display( $row['conversions_value'] ),
|
||||||
$roasCellHtml,
|
$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 min_roas" product_id="' . $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_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="' . $row['product_id'] . '" value="' . $custom_label_4 . '" style="' . $custom_label_4_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">'
|
'<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-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="' . $row['product_id'] . '" title="Pokaż logi synchronizacji Merchant"><i class="fa-solid fa-clock-rotate-left"></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="' . $row['product_id'] . '" title="Usuń produkt"><i class="fa-solid fa-trash"></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>'
|
. '</div>',
|
||||||
|
$row_meta
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -487,6 +487,39 @@ class Products
|
|||||||
$sql .= ' AND ag.status = \'active\'';
|
$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 = '' )
|
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;
|
global $mdb;
|
||||||
@@ -522,15 +555,17 @@ class Products
|
|||||||
p.offer_id,
|
p.offer_id,
|
||||||
p.min_roas,
|
p.min_roas,
|
||||||
COALESCE( NULLIF( TRIM( p.custom_label_1 ), \'\' ), \'\' ) AS custom_label_1,
|
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
|
CASE
|
||||||
WHEN pa.ad_group_id = 0 THEN \'PMax (bez grup reklam)\'
|
WHEN COUNT( DISTINCT pa.campaign_id ) > 1 THEN CONCAT( \'Wiele kampanii (\', COUNT( DISTINCT pa.campaign_id ), \')\' )
|
||||||
ELSE COALESCE( NULLIF( TRIM( ag.ad_group_name ), \'\' ), \'--- brak grupy reklam ---\' )
|
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,
|
END AS ad_group_name,
|
||||||
pa.ad_group_id AS ad_group_id,
|
MIN( pa.campaign_id ) AS history_campaign_id,
|
||||||
pa.campaign_id AS history_campaign_id,
|
MIN( pa.ad_group_id ) AS history_ad_group_id,
|
||||||
pa.ad_group_id AS history_ad_group_id,
|
|
||||||
COALESCE( NULLIF( TRIM( p.title ), \'\' ), NULLIF( TRIM( p.name ), \'\' ), p.offer_id ) AS name,
|
COALESCE( NULLIF( TRIM( p.title ), \'\' ), NULLIF( TRIM( p.name ), \'\' ), p.offer_id ) AS name,
|
||||||
SUM( pa.impressions_all_time ) AS impressions,
|
SUM( pa.impressions_all_time ) AS impressions,
|
||||||
SUM( pa.impressions_30 ) AS impressions_30,
|
SUM( pa.impressions_30 ) AS impressions_30,
|
||||||
@@ -558,34 +593,9 @@ class Products
|
|||||||
WHERE p.client_id = :client_id';
|
WHERE p.client_id = :client_id';
|
||||||
|
|
||||||
self::build_scope_filters( $sql, $params, $campaign_id, $ad_group_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 .= ' GROUP BY p.id, p.offer_id, p.min_roas, p.custom_label_1, p.name, p.title';
|
||||||
{
|
|
||||||
$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 .= ' ORDER BY ' . $order_sql . ' ' . $order_dir . ', product_id DESC LIMIT ' . $start . ', ' . $limit;
|
$sql .= ' ORDER BY ' . $order_sql . ' ' . $order_dir . ', product_id DESC LIMIT ' . $start . ', ' . $limit;
|
||||||
|
|
||||||
return $mdb -> query( $sql, $params ) -> fetchAll( \PDO::FETCH_ASSOC );
|
return $mdb -> query( $sql, $params ) -> fetchAll( \PDO::FETCH_ASSOC );
|
||||||
@@ -597,47 +607,30 @@ class Products
|
|||||||
|
|
||||||
$params = [ ':client_id' => $client_id ];
|
$params = [ ':client_id' => $client_id ];
|
||||||
|
|
||||||
$sql = 'SELECT MIN( p.min_roas ) AS min_roas,
|
$sql = 'SELECT
|
||||||
MAX(
|
MIN( t.min_roas ) AS min_roas,
|
||||||
CASE
|
MAX( t.roas ) AS max_roas
|
||||||
WHEN COALESCE( pa.cost_all_time, 0 ) > 0 THEN ROUND( COALESCE( pa.conversion_value_all_time, 0 ) / pa.cost_all_time * 100, 2 )
|
FROM (
|
||||||
ELSE 0
|
SELECT
|
||||||
END
|
p.id AS product_id,
|
||||||
) AS max_roas
|
p.min_roas AS min_roas,
|
||||||
FROM products_aggregate AS pa
|
CASE
|
||||||
INNER JOIN products AS p ON p.id = pa.product_id
|
WHEN SUM( pa.cost_all_time ) > 0 THEN ROUND( SUM( pa.conversion_value_all_time ) / SUM( pa.cost_all_time ) * 100, 2 )
|
||||||
LEFT JOIN campaigns AS c ON c.id = pa.campaign_id
|
ELSE 0
|
||||||
LEFT JOIN campaign_ad_groups AS ag ON ag.id = pa.ad_group_id
|
END AS roas,
|
||||||
WHERE p.client_id = :client_id
|
SUM( pa.conversions_all_time ) AS conversions
|
||||||
AND pa.conversions_all_time > 10';
|
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';
|
||||||
|
|
||||||
self::build_scope_filters( $sql, $params, $campaign_id, $ad_group_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 .= ' GROUP BY p.id, p.min_roas
|
||||||
{
|
HAVING SUM( pa.conversions_all_time ) > 10
|
||||||
$sql .= ' AND (
|
) AS t';
|
||||||
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 . '%';
|
|
||||||
}
|
|
||||||
|
|
||||||
$row = $mdb -> query( $sql, $params ) -> fetch( \PDO::FETCH_ASSOC );
|
$row = $mdb -> query( $sql, $params ) -> fetch( \PDO::FETCH_ASSOC );
|
||||||
|
|
||||||
@@ -676,49 +669,103 @@ class Products
|
|||||||
global $mdb;
|
global $mdb;
|
||||||
|
|
||||||
$params = [ ':client_id' => (int) $client_id ];
|
$params = [ ':client_id' => (int) $client_id ];
|
||||||
$sql = 'SELECT COUNT(0)
|
$sql = 'SELECT COUNT( DISTINCT p.id )
|
||||||
FROM (
|
FROM products_aggregate AS pa
|
||||||
SELECT p.id
|
INNER JOIN products AS p ON p.id = pa.product_id
|
||||||
FROM products_aggregate AS pa
|
LEFT JOIN campaigns AS c ON c.id = pa.campaign_id
|
||||||
INNER JOIN products AS p ON p.id = pa.product_id
|
LEFT JOIN campaign_ad_groups AS ag ON ag.id = pa.ad_group_id
|
||||||
LEFT JOIN campaigns AS c ON c.id = pa.campaign_id
|
WHERE p.client_id = :client_id';
|
||||||
LEFT JOIN campaign_ad_groups AS ag ON ag.id = pa.ad_group_id
|
|
||||||
WHERE p.client_id = :client_id';
|
|
||||||
|
|
||||||
self::build_scope_filters( $sql, $params, $campaign_id, $ad_group_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, pa.campaign_id, pa.ad_group_id
|
|
||||||
) AS grouped_rows';
|
|
||||||
|
|
||||||
return $mdb -> query( $sql, $params ) -> fetchColumn();
|
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 )
|
static public function get_product_full_context( $product_id )
|
||||||
{
|
{
|
||||||
global $mdb;
|
global $mdb;
|
||||||
|
|||||||
@@ -187,6 +187,55 @@
|
|||||||
background-color: #6690f4;
|
background-color: #6690f4;
|
||||||
color: #fff;
|
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>
|
</style>
|
||||||
|
|
||||||
<?php
|
<?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()
|
$( function()
|
||||||
{
|
{
|
||||||
init_products_scope_select_search();
|
init_products_scope_select_search();
|
||||||
@@ -557,7 +683,18 @@ $( function()
|
|||||||
return '<input type="checkbox" class="product-checkbox" value="' + row[1] + '" />';
|
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: '80px', name: 'offer_id' },
|
||||||
{ width: '200px', name: 'campaign_name' },
|
{ width: '200px', name: 'campaign_name' },
|
||||||
{ width: '200px', name: 'ad_group_name' },
|
{ width: '200px', name: 'ad_group_name' },
|
||||||
@@ -603,6 +740,30 @@ $( function()
|
|||||||
products_apply_saved_columns_visibility( products_table );
|
products_apply_saved_columns_visibility( products_table );
|
||||||
products_render_columns_picker( 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()
|
function reload_products_table()
|
||||||
{
|
{
|
||||||
products_table.ajax.reload( null, false );
|
products_table.ajax.reload( null, false );
|
||||||
|
|||||||
Reference in New Issue
Block a user