feat: Implement client bestseller settings management with UI and backend support
This commit is contained in:
34
.vscode/ftp-kr.sync.cache.json
vendored
34
.vscode/ftp-kr.sync.cache.json
vendored
@@ -125,8 +125,8 @@
|
||||
},
|
||||
"class.Products.php": {
|
||||
"type": "-",
|
||||
"size": 47926,
|
||||
"lmtime": 1772127837185,
|
||||
"size": 48016,
|
||||
"lmtime": 1772529696252,
|
||||
"modified": false
|
||||
},
|
||||
"class.Site.php": {
|
||||
@@ -193,8 +193,8 @@
|
||||
},
|
||||
"class.Products.php": {
|
||||
"type": "-",
|
||||
"size": 34756,
|
||||
"lmtime": 1772126750737,
|
||||
"size": 35004,
|
||||
"lmtime": 1772529689598,
|
||||
"modified": false
|
||||
},
|
||||
"class.Users.php": {
|
||||
@@ -243,8 +243,8 @@
|
||||
},
|
||||
"class.SupplementalFeed.php": {
|
||||
"type": "-",
|
||||
"size": 3860,
|
||||
"lmtime": 1772118229909,
|
||||
"size": 4797,
|
||||
"lmtime": 1772527954403,
|
||||
"modified": false
|
||||
}
|
||||
},
|
||||
@@ -359,7 +359,7 @@
|
||||
"supplemental_10.tsv": {
|
||||
"type": "-",
|
||||
"size": 573,
|
||||
"lmtime": 1772126067385,
|
||||
"lmtime": 1772528275128,
|
||||
"modified": false
|
||||
},
|
||||
"supplemental_1.tsv": {
|
||||
@@ -370,9 +370,9 @@
|
||||
},
|
||||
"supplemental_2.tsv": {
|
||||
"type": "-",
|
||||
"size": 1331,
|
||||
"size": 1522,
|
||||
"lmtime": 1772126067543,
|
||||
"modified": false
|
||||
"modified": true
|
||||
},
|
||||
"supplemental_3.tsv": {
|
||||
"type": "-",
|
||||
@@ -388,9 +388,9 @@
|
||||
},
|
||||
"supplemental_5.tsv": {
|
||||
"type": "-",
|
||||
"size": 436,
|
||||
"size": 1191,
|
||||
"lmtime": 1772126067716,
|
||||
"modified": false
|
||||
"modified": true
|
||||
},
|
||||
"supplemental_6.tsv": {
|
||||
"type": "-",
|
||||
@@ -400,15 +400,15 @@
|
||||
},
|
||||
"supplemental_7.tsv": {
|
||||
"type": "-",
|
||||
"size": 297,
|
||||
"size": 501,
|
||||
"lmtime": 1772126067881,
|
||||
"modified": false
|
||||
"modified": true
|
||||
},
|
||||
"supplemental_8.tsv": {
|
||||
"type": "-",
|
||||
"size": 449,
|
||||
"size": 546,
|
||||
"lmtime": 1772126067984,
|
||||
"modified": false
|
||||
"modified": true
|
||||
},
|
||||
"supplemental_9.tsv": {
|
||||
"type": "-",
|
||||
@@ -774,8 +774,8 @@
|
||||
"products": {
|
||||
"main_view.php": {
|
||||
"type": "-",
|
||||
"size": 70245,
|
||||
"lmtime": 1772126874952,
|
||||
"size": 72463,
|
||||
"lmtime": 1772530521923,
|
||||
"modified": false
|
||||
},
|
||||
"product_history.php": {
|
||||
|
||||
15
AGENTS.md
15
AGENTS.md
@@ -3,6 +3,21 @@
|
||||
## Sposób pracy
|
||||
- Pisz do mnie po polsku, zwięźle i krótko, ale merytorycznie
|
||||
|
||||
## Zasady pisania kodu
|
||||
- Kod ma być czytelny „dla obcego”: jasne nazwy, mało magii
|
||||
- Brak „skrótów na szybko” typu logika w widokach, copy-paste, losowe helpery bez spójności
|
||||
- Każda funkcja/klasa ma mieć jedną odpowiedzialność, zwykle do 30–50 linii (jeśli dłuższe – dzielić)
|
||||
- max 3 poziomy zagnieżdżeń (if/foreach), reszta do osobnych metod
|
||||
- Nazewnictwo:
|
||||
- klasy: PascalCase
|
||||
- metody/zmienne: camelCase
|
||||
- stałe: UPPER_SNAKE_CASE
|
||||
- Zero „skrótologii” w nazwach (np. $d, $tmp, $x1) poza pętlami 2–3 linijki
|
||||
- medoo + prepared statements bez wyjątków (żadnego sklejania SQL stringiem)
|
||||
- XSS: escape w widokach (np. helper e())
|
||||
- CSRF dla formularzy, sensowna obsługa sesji
|
||||
- Kod ma mieć komentarze tylko tam, gdzie wyjaśniają „dlaczego”, nie „co”
|
||||
|
||||
## Wprowadzanie zmian
|
||||
- Przeanalizuj wprowadzone zadanie
|
||||
- Jeżeli masz jakieś wątpliwości pytaj
|
||||
|
||||
@@ -200,6 +200,78 @@ class Products
|
||||
exit;
|
||||
}
|
||||
|
||||
static public function get_client_bestseller_settings()
|
||||
{
|
||||
$client_id = (int) \S::get( 'client_id' );
|
||||
|
||||
if ( $client_id <= 0 )
|
||||
{
|
||||
echo json_encode( [
|
||||
'status' => 'error',
|
||||
'message' => 'Nieprawidlowe ID klienta.'
|
||||
] );
|
||||
exit;
|
||||
}
|
||||
|
||||
$settings = \factory\Products::get_client_bestseller_settings( $client_id );
|
||||
|
||||
echo json_encode( [
|
||||
'status' => 'ok',
|
||||
'settings' => $settings
|
||||
] );
|
||||
exit;
|
||||
}
|
||||
|
||||
static public function save_client_bestseller_settings()
|
||||
{
|
||||
$client_id = (int) \S::get( 'client_id' );
|
||||
if ( $client_id <= 0 )
|
||||
{
|
||||
echo json_encode( [ 'status' => 'error', 'message' => 'Nieprawidlowe ID klienta.' ] );
|
||||
exit;
|
||||
}
|
||||
|
||||
$settings = [
|
||||
'bestseller_roas_entry' => \S::get( 'bestseller_roas_entry' ),
|
||||
'bestseller_roas_exit' => \S::get( 'bestseller_roas_exit' ),
|
||||
'min_conversions' => \S::get( 'min_conversions' ),
|
||||
'cooldown_period' => \S::get( 'cooldown_period' )
|
||||
];
|
||||
|
||||
$saved = \factory\Products::save_client_bestseller_settings( $client_id, $settings );
|
||||
|
||||
echo json_encode( [
|
||||
'status' => $saved ? 'ok' : 'error',
|
||||
'settings' => \factory\Products::get_client_bestseller_settings( $client_id )
|
||||
] );
|
||||
exit;
|
||||
}
|
||||
|
||||
static public function preview_client_bestseller_settings()
|
||||
{
|
||||
$client_id = (int) \S::get( 'client_id' );
|
||||
if ( $client_id <= 0 )
|
||||
{
|
||||
echo json_encode( [ 'status' => 'error', 'message' => 'Nieprawidlowe ID klienta.' ] );
|
||||
exit;
|
||||
}
|
||||
|
||||
$settings = [
|
||||
'bestseller_roas_entry' => \S::get( 'bestseller_roas_entry' ),
|
||||
'bestseller_roas_exit' => \S::get( 'bestseller_roas_exit' ),
|
||||
'min_conversions' => \S::get( 'min_conversions' ),
|
||||
'cooldown_period' => \S::get( 'cooldown_period' )
|
||||
];
|
||||
|
||||
$count = \factory\Products::get_client_bestseller_preview_count( $client_id, $settings );
|
||||
|
||||
echo json_encode( [
|
||||
'status' => 'ok',
|
||||
'count' => (int) $count
|
||||
] );
|
||||
exit;
|
||||
}
|
||||
|
||||
static public function get_scope_alerts()
|
||||
{
|
||||
$client_id = (int) \S::get( 'client_id' );
|
||||
|
||||
@@ -204,6 +204,230 @@ class Products
|
||||
return $mdb -> update( 'products', [ 'min_roas' => $min_roas ], [ 'id' => $product_id ] );
|
||||
}
|
||||
|
||||
static public function get_client_bestseller_settings( $client_id )
|
||||
{
|
||||
global $mdb;
|
||||
|
||||
$client_id = (int) $client_id;
|
||||
if ( $client_id <= 0 )
|
||||
{
|
||||
return [
|
||||
'bestseller_roas_entry' => null,
|
||||
'bestseller_roas_exit' => null,
|
||||
'min_conversions' => 10,
|
||||
'cooldown_period' => 14
|
||||
];
|
||||
}
|
||||
|
||||
$row = $mdb -> query(
|
||||
'SELECT
|
||||
bestseller_roas_entry,
|
||||
bestseller_roas_exit,
|
||||
min_conversions,
|
||||
cooldown_period
|
||||
FROM clients
|
||||
WHERE id = :client_id
|
||||
LIMIT 1',
|
||||
[ ':client_id' => $client_id ]
|
||||
) -> fetch( \PDO::FETCH_ASSOC );
|
||||
|
||||
if ( !is_array( $row ) )
|
||||
{
|
||||
return [
|
||||
'bestseller_roas_entry' => null,
|
||||
'bestseller_roas_exit' => null,
|
||||
'min_conversions' => 10,
|
||||
'cooldown_period' => 14
|
||||
];
|
||||
}
|
||||
|
||||
$entry = isset( $row['bestseller_roas_entry'] ) ? trim( (string) $row['bestseller_roas_entry'] ) : '';
|
||||
$exit = isset( $row['bestseller_roas_exit'] ) ? trim( (string) $row['bestseller_roas_exit'] ) : '';
|
||||
$min_conversions = (int) ( $row['min_conversions'] ?? 10 );
|
||||
$cooldown_period = (int) ( $row['cooldown_period'] ?? 14 );
|
||||
|
||||
return [
|
||||
'bestseller_roas_entry' => $entry !== '' ? (float) $entry : null,
|
||||
'bestseller_roas_exit' => $exit !== '' ? (float) $exit : null,
|
||||
'min_conversions' => $min_conversions > 0 ? $min_conversions : 10,
|
||||
'cooldown_period' => $cooldown_period > 0 ? $cooldown_period : 14
|
||||
];
|
||||
}
|
||||
|
||||
static public function save_client_bestseller_settings( $client_id, $settings )
|
||||
{
|
||||
global $mdb;
|
||||
|
||||
$client_id = (int) $client_id;
|
||||
if ( $client_id <= 0 || !is_array( $settings ) )
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
$entry_raw = trim( (string) ( $settings['bestseller_roas_entry'] ?? '' ) );
|
||||
$exit_raw = trim( (string) ( $settings['bestseller_roas_exit'] ?? '' ) );
|
||||
|
||||
$entry = is_numeric( $entry_raw ) ? round( (float) $entry_raw, 6 ) : null;
|
||||
$exit = is_numeric( $exit_raw ) ? round( (float) $exit_raw, 6 ) : null;
|
||||
$min_conversions = max( 0, (int) ( $settings['min_conversions'] ?? 10 ) );
|
||||
$cooldown_period = max( 1, (int) ( $settings['cooldown_period'] ?? 14 ) );
|
||||
|
||||
return $mdb -> update( 'clients', [
|
||||
'bestseller_roas_entry' => $entry,
|
||||
'bestseller_roas_exit' => $exit,
|
||||
'min_conversions' => $min_conversions,
|
||||
'cooldown_period' => $cooldown_period
|
||||
], [ 'id' => $client_id ] );
|
||||
}
|
||||
|
||||
static public function get_client_bestseller_preview_count( $client_id, $settings )
|
||||
{
|
||||
global $mdb;
|
||||
|
||||
$client_id = (int) $client_id;
|
||||
if ( $client_id <= 0 || !is_array( $settings ) )
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
$entry_raw = trim( (string) ( $settings['bestseller_roas_entry'] ?? '' ) );
|
||||
$exit_raw = trim( (string) ( $settings['bestseller_roas_exit'] ?? '' ) );
|
||||
|
||||
if ( !is_numeric( $entry_raw ) || !is_numeric( $exit_raw ) )
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
$entry = (float) $entry_raw;
|
||||
$exit = (float) $exit_raw;
|
||||
$min_conversions = max( 0, (float) ( $settings['min_conversions'] ?? 10 ) );
|
||||
$cooldown_period = max( 1, (int) ( $settings['cooldown_period'] ?? 14 ) );
|
||||
|
||||
$rows = $mdb -> query(
|
||||
'SELECT
|
||||
p.id,
|
||||
p.custom_label_4,
|
||||
COALESCE( SUM( pa.cost_30 ), 0 ) AS cost_30,
|
||||
COALESCE( SUM( pa.conversion_value_30 ), 0 ) AS conversion_value_30,
|
||||
COALESCE( SUM( pa.conversions_30 ), 0 ) AS conversions_30
|
||||
FROM products p
|
||||
LEFT JOIN products_aggregate pa ON pa.product_id = p.id
|
||||
WHERE p.client_id = :client_id
|
||||
GROUP BY p.id, p.custom_label_4',
|
||||
[ ':client_id' => $client_id ]
|
||||
) -> fetchAll( \PDO::FETCH_ASSOC );
|
||||
|
||||
if ( empty( $rows ) )
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
$product_ids = [];
|
||||
foreach ( $rows as $row )
|
||||
{
|
||||
$pid = (int) ( $row['id'] ?? 0 );
|
||||
if ( $pid > 0 )
|
||||
{
|
||||
$product_ids[] = $pid;
|
||||
}
|
||||
}
|
||||
|
||||
if ( empty( $product_ids ) )
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
$history_by_product = [];
|
||||
$history_rows = $mdb -> query(
|
||||
'SELECT
|
||||
h30.product_id,
|
||||
h30.date_add,
|
||||
CASE
|
||||
WHEN SUM( h30.cost ) > 0 THEN ( SUM( h30.conversions_value ) / SUM( h30.cost ) ) * 100
|
||||
ELSE 0
|
||||
END AS roas
|
||||
FROM products_history_30 h30
|
||||
WHERE h30.product_id IN (' . implode( ',', array_map( 'intval', $product_ids ) ) . ')
|
||||
GROUP BY h30.product_id, h30.date_add
|
||||
ORDER BY h30.product_id ASC, h30.date_add DESC'
|
||||
) -> fetchAll( \PDO::FETCH_ASSOC );
|
||||
|
||||
foreach ( (array) $history_rows as $history_row )
|
||||
{
|
||||
$pid = (int) ( $history_row['product_id'] ?? 0 );
|
||||
if ( $pid <= 0 )
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( !isset( $history_by_product[ $pid ] ) )
|
||||
{
|
||||
$history_by_product[ $pid ] = [];
|
||||
}
|
||||
|
||||
$history_by_product[ $pid ][] = (float) ( $history_row['roas'] ?? 0 );
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
foreach ( $rows as $row )
|
||||
{
|
||||
$product_id = (int) ( $row['id'] ?? 0 );
|
||||
if ( $product_id <= 0 )
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
$cost_30 = (float) ( $row['cost_30'] ?? 0 );
|
||||
$conversion_value_30 = (float) ( $row['conversion_value_30'] ?? 0 );
|
||||
$conversions_30 = (float) ( $row['conversions_30'] ?? 0 );
|
||||
$roas_30 = $cost_30 > 0 ? ( $conversion_value_30 / $cost_30 ) * 100 : 0;
|
||||
$current_label = trim( (string) ( $row['custom_label_4'] ?? '' ) );
|
||||
if ( $current_label !== '' && $current_label !== 'bestseller' )
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
$entry_met = $roas_30 >= $entry && $conversions_30 >= $min_conversions;
|
||||
$is_bestseller = false;
|
||||
|
||||
if ( $entry_met )
|
||||
{
|
||||
$is_bestseller = true;
|
||||
}
|
||||
else if ( $current_label === 'bestseller' )
|
||||
{
|
||||
$roas_history = $history_by_product[ $product_id ] ?? [];
|
||||
$has_full_window = count( $roas_history ) >= $cooldown_period;
|
||||
$below_exit_all_days = true;
|
||||
|
||||
if ( !$has_full_window )
|
||||
{
|
||||
$below_exit_all_days = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
for ( $i = 0; $i < $cooldown_period; $i++ )
|
||||
{
|
||||
if ( (float) $roas_history[ $i ] >= $exit )
|
||||
{
|
||||
$below_exit_all_days = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$is_bestseller = !$below_exit_all_days;
|
||||
}
|
||||
|
||||
if ( $is_bestseller )
|
||||
{
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
static private function build_scope_filters( &$sql, &$params, $campaign_id, $ad_group_id )
|
||||
{
|
||||
$campaign_id = (int) $campaign_id;
|
||||
|
||||
@@ -3,6 +3,154 @@ namespace services;
|
||||
|
||||
class SupplementalFeed
|
||||
{
|
||||
static private function get_client_bestseller_settings( $client_id )
|
||||
{
|
||||
global $mdb;
|
||||
|
||||
$row = $mdb -> query(
|
||||
'SELECT
|
||||
bestseller_roas_entry,
|
||||
bestseller_roas_exit,
|
||||
min_conversions,
|
||||
cooldown_period
|
||||
FROM clients
|
||||
WHERE id = :client_id
|
||||
LIMIT 1',
|
||||
[ ':client_id' => (int) $client_id ]
|
||||
) -> fetch( \PDO::FETCH_ASSOC );
|
||||
|
||||
if ( !is_array( $row ) )
|
||||
{
|
||||
return [
|
||||
'bestseller_roas_entry' => null,
|
||||
'bestseller_roas_exit' => null,
|
||||
'min_conversions' => 10,
|
||||
'cooldown_period' => 14
|
||||
];
|
||||
}
|
||||
|
||||
$entry_raw = trim( (string) ( $row['bestseller_roas_entry'] ?? '' ) );
|
||||
$exit_raw = trim( (string) ( $row['bestseller_roas_exit'] ?? '' ) );
|
||||
$min_conversions = (int) ( $row['min_conversions'] ?? 10 );
|
||||
$cooldown_period = (int) ( $row['cooldown_period'] ?? 14 );
|
||||
|
||||
return [
|
||||
'bestseller_roas_entry' => $entry_raw !== '' ? (float) $entry_raw : null,
|
||||
'bestseller_roas_exit' => $exit_raw !== '' ? (float) $exit_raw : null,
|
||||
'min_conversions' => $min_conversions > 0 ? $min_conversions : 10,
|
||||
'cooldown_period' => $cooldown_period > 0 ? $cooldown_period : 14
|
||||
];
|
||||
}
|
||||
|
||||
static private function is_below_exit_for_cooldown( $product_id, $roas_exit, $cooldown_period )
|
||||
{
|
||||
global $mdb;
|
||||
|
||||
$cooldown_period = max( 1, (int) $cooldown_period );
|
||||
$rows = $mdb -> query(
|
||||
'SELECT
|
||||
h30.date_add,
|
||||
CASE
|
||||
WHEN SUM( h30.cost ) > 0 THEN ( SUM( h30.conversions_value ) / SUM( h30.cost ) ) * 100
|
||||
ELSE 0
|
||||
END AS roas
|
||||
FROM products_history_30 h30
|
||||
WHERE h30.product_id = :product_id
|
||||
GROUP BY h30.date_add
|
||||
ORDER BY h30.date_add DESC
|
||||
LIMIT ' . $cooldown_period,
|
||||
[ ':product_id' => (int) $product_id ]
|
||||
) -> fetchAll( \PDO::FETCH_ASSOC );
|
||||
|
||||
if ( count( (array) $rows ) < $cooldown_period )
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ( $rows as $row )
|
||||
{
|
||||
$roas = (float) ( $row['roas'] ?? 0 );
|
||||
if ( $roas >= (float) $roas_exit )
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static private function refresh_bestseller_labels_for_client( $client_id )
|
||||
{
|
||||
global $mdb;
|
||||
|
||||
$settings = self::get_client_bestseller_settings( $client_id );
|
||||
$entry = $settings['bestseller_roas_entry'];
|
||||
$exit = $settings['bestseller_roas_exit'];
|
||||
$min_conversions = (float) $settings['min_conversions'];
|
||||
$cooldown_period = (int) $settings['cooldown_period'];
|
||||
|
||||
if ( $entry === null || $exit === null )
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
$products = $mdb -> query(
|
||||
'SELECT
|
||||
p.id,
|
||||
p.custom_label_4,
|
||||
COALESCE( SUM( pa.cost_30 ), 0 ) AS cost_30,
|
||||
COALESCE( SUM( pa.conversion_value_30 ), 0 ) AS conversion_value_30,
|
||||
COALESCE( SUM( pa.conversions_30 ), 0 ) AS conversions_30
|
||||
FROM products p
|
||||
LEFT JOIN products_aggregate pa ON pa.product_id = p.id
|
||||
WHERE p.client_id = :client_id
|
||||
GROUP BY p.id, p.custom_label_4',
|
||||
[ ':client_id' => (int) $client_id ]
|
||||
) -> fetchAll( \PDO::FETCH_ASSOC );
|
||||
|
||||
$updated_count = 0;
|
||||
foreach ( (array) $products as $row )
|
||||
{
|
||||
$product_id = (int) ( $row['id'] ?? 0 );
|
||||
if ( $product_id <= 0 )
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
$cost_30 = (float) ( $row['cost_30'] ?? 0 );
|
||||
$conversion_value_30 = (float) ( $row['conversion_value_30'] ?? 0 );
|
||||
$conversions_30 = (float) ( $row['conversions_30'] ?? 0 );
|
||||
$roas_30 = $cost_30 > 0 ? ( $conversion_value_30 / $cost_30 ) * 100 : 0;
|
||||
|
||||
$current_label = trim( (string) ( $row['custom_label_4'] ?? '' ) );
|
||||
if ( $current_label !== '' && $current_label !== 'bestseller' )
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
$entry_met = $roas_30 >= (float) $entry && $conversions_30 >= $min_conversions;
|
||||
$target_label = '';
|
||||
|
||||
if ( $entry_met )
|
||||
{
|
||||
$target_label = 'bestseller';
|
||||
}
|
||||
else if ( $current_label === 'bestseller' )
|
||||
{
|
||||
$can_exit = self::is_below_exit_for_cooldown( $product_id, (float) $exit, $cooldown_period );
|
||||
$target_label = $can_exit ? '' : 'bestseller';
|
||||
}
|
||||
|
||||
if ( $target_label !== $current_label )
|
||||
{
|
||||
$mdb -> update( 'products', [ 'custom_label_4' => $target_label ], [ 'id' => $product_id ] );
|
||||
$updated_count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $updated_count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generuje supplemental feed TSV dla klienta.
|
||||
* Zwraca tablice ze statystykami: products_total, products_written, file.
|
||||
@@ -12,14 +160,15 @@ class SupplementalFeed
|
||||
global $mdb;
|
||||
|
||||
$client_id = (int) $client_id;
|
||||
$labels_updated = self::refresh_bestseller_labels_for_client( $client_id );
|
||||
|
||||
$products = $mdb -> query(
|
||||
"SELECT p.offer_id, p.title, p.description, p.google_product_category
|
||||
"SELECT p.offer_id, p.title, p.description, p.google_product_category, p.custom_label_4
|
||||
FROM products p
|
||||
WHERE p.client_id = :client_id
|
||||
AND p.offer_id IS NOT NULL
|
||||
AND p.offer_id <> ''
|
||||
AND ( p.title IS NOT NULL OR p.description IS NOT NULL OR p.google_product_category IS NOT NULL )",
|
||||
AND ( p.title IS NOT NULL OR p.description IS NOT NULL OR p.google_product_category IS NOT NULL OR p.custom_label_4 IS NOT NULL )",
|
||||
[ ':client_id' => $client_id ]
|
||||
) -> fetchAll( \PDO::FETCH_ASSOC );
|
||||
|
||||
@@ -38,7 +187,7 @@ class SupplementalFeed
|
||||
throw new \RuntimeException( 'Nie mozna otworzyc pliku: ' . $file_path );
|
||||
}
|
||||
|
||||
fwrite( $fp, "id\ttitle\tdescription\tgoogle_product_category\n" );
|
||||
fwrite( $fp, "id\ttitle\tdescription\tgoogle_product_category\tcustom_label_4\n" );
|
||||
|
||||
$written = 0;
|
||||
foreach ( $products as $row )
|
||||
@@ -47,8 +196,9 @@ class SupplementalFeed
|
||||
$title = self::sanitize_for_tsv( $row['title'] ?? '' );
|
||||
$description = self::sanitize_for_tsv( $row['description'] ?? '' );
|
||||
$category = trim( (string) ( $row['google_product_category'] ?? '' ) );
|
||||
$custom_label_4 = trim( (string) ( $row['custom_label_4'] ?? '' ) );
|
||||
|
||||
if ( $offer_id === '' || ( $title === '' && $description === '' && $category === '' ) )
|
||||
if ( $offer_id === '' || ( $title === '' && $description === '' && $category === '' && $custom_label_4 === '' ) )
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -57,7 +207,8 @@ class SupplementalFeed
|
||||
$offer_id,
|
||||
$title,
|
||||
$description,
|
||||
$category
|
||||
$category,
|
||||
$custom_label_4
|
||||
] ) . "\n" );
|
||||
|
||||
$written++;
|
||||
@@ -68,7 +219,8 @@ class SupplementalFeed
|
||||
return [
|
||||
'products_total' => count( $products ),
|
||||
'products_written' => $written,
|
||||
'file' => $filename
|
||||
'file' => $filename,
|
||||
'labels_updated' => $labels_updated
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
62
migrations/026_clients_bestseller_settings.sql
Normal file
62
migrations/026_clients_bestseller_settings.sql
Normal file
@@ -0,0 +1,62 @@
|
||||
-- Ustawienia automatycznego oznaczania bestsellerow per klient.
|
||||
|
||||
SET @sql = IF(
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'clients'
|
||||
AND COLUMN_NAME = 'bestseller_roas_entry'
|
||||
),
|
||||
'DO 1',
|
||||
'ALTER TABLE `clients` ADD COLUMN `bestseller_roas_entry` DECIMAL(20,6) NULL DEFAULT NULL AFTER `google_ads_start_date`'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @sql = IF(
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'clients'
|
||||
AND COLUMN_NAME = 'bestseller_roas_exit'
|
||||
),
|
||||
'DO 1',
|
||||
'ALTER TABLE `clients` ADD COLUMN `bestseller_roas_exit` DECIMAL(20,6) NULL DEFAULT NULL AFTER `bestseller_roas_entry`'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @sql = IF(
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'clients'
|
||||
AND COLUMN_NAME = 'min_conversions'
|
||||
),
|
||||
'DO 1',
|
||||
'ALTER TABLE `clients` ADD COLUMN `min_conversions` INT(11) NOT NULL DEFAULT 10 AFTER `bestseller_roas_exit`'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @sql = IF(
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'clients'
|
||||
AND COLUMN_NAME = 'cooldown_period'
|
||||
),
|
||||
'DO 1',
|
||||
'ALTER TABLE `clients` ADD COLUMN `cooldown_period` INT(11) NOT NULL DEFAULT 14 AFTER `min_conversions`'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
@@ -48,6 +48,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="products-filters" style="margin-top:10px;">
|
||||
<div class="filter-group filter-group-bestseller" style="min-width: 560px;">
|
||||
<label><i class="fa-solid fa-ranking-star"></i> Bestsellery reguły</label>
|
||||
<div style="display:grid;grid-template-columns:repeat(4,minmax(120px,1fr));gap:8px;">
|
||||
<input type="number" id="bestseller_roas_entry" class="form-control" min="0" step="0.01" placeholder="ROAS wejście" />
|
||||
<input type="number" id="bestseller_roas_exit" class="form-control" min="0" step="0.01" placeholder="ROAS wyjście" />
|
||||
<input type="number" id="min_conversions" class="form-control" min="0" step="1" value="10" placeholder="Min. konwersje" />
|
||||
<input type="number" id="cooldown_period" class="form-control" min="1" step="1" value="14" placeholder="Cooldown (dni)" />
|
||||
</div>
|
||||
<div style="margin-top:8px;">
|
||||
<button type="button" id="save_bestseller_rules" class="btn btn-primary btn-sm">
|
||||
<i class="fa-solid fa-floppy-disk"></i> Zapisz
|
||||
</button>
|
||||
<span id="bestseller_rules_preview" style="margin-left:10px;color:#555;">Spelnia: -</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details id="products_scope_alerts_box" class="products-scope-alerts hide">
|
||||
<summary>
|
||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||
@@ -121,6 +139,8 @@ var AI_CLAUDE_ENABLED = <?= $claude_enabled ? 'true' : 'false'; ?>;
|
||||
var AI_GEMINI_ENABLED = <?= $gemini_enabled ? 'true' : 'false'; ?>;
|
||||
var PRODUCTS_COLUMNS_STORAGE_KEY = 'products.columns.visibility';
|
||||
var PRODUCTS_LOCKED_COLUMNS = [ 0, 21 ];
|
||||
var products_bestseller_settings_loading = false;
|
||||
var products_bestseller_preview_timer = null;
|
||||
|
||||
function show_toast( message, type )
|
||||
{
|
||||
@@ -204,6 +224,109 @@ function products_is_locked_column( idx )
|
||||
return PRODUCTS_LOCKED_COLUMNS.indexOf( Number( idx ) ) !== -1;
|
||||
}
|
||||
|
||||
function reset_client_bestseller_settings_form()
|
||||
{
|
||||
$( '#bestseller_roas_entry' ).val( '' );
|
||||
$( '#bestseller_roas_exit' ).val( '' );
|
||||
$( '#min_conversions' ).val( '10' );
|
||||
$( '#cooldown_period' ).val( '14' );
|
||||
$( '#bestseller_rules_preview' ).text( 'Spelnia: -' );
|
||||
}
|
||||
|
||||
function preview_client_bestseller_settings()
|
||||
{
|
||||
var client_id = $( '#client_id' ).val() || '';
|
||||
|
||||
if ( !client_id || products_bestseller_settings_loading )
|
||||
{
|
||||
$( '#bestseller_rules_preview' ).text( 'Spelnia: -' );
|
||||
return;
|
||||
}
|
||||
|
||||
$( '#bestseller_rules_preview' ).text( 'Spelnia: licze...' );
|
||||
|
||||
$.ajax({
|
||||
url: '/products/preview_client_bestseller_settings/',
|
||||
type: 'POST',
|
||||
dataType: 'json',
|
||||
data: {
|
||||
client_id: client_id,
|
||||
bestseller_roas_entry: $( '#bestseller_roas_entry' ).val(),
|
||||
bestseller_roas_exit: $( '#bestseller_roas_exit' ).val(),
|
||||
min_conversions: $( '#min_conversions' ).val(),
|
||||
cooldown_period: $( '#cooldown_period' ).val()
|
||||
}
|
||||
}).done( function( res ) {
|
||||
if ( res && res.status === 'ok' )
|
||||
{
|
||||
$( '#bestseller_rules_preview' ).text( 'Spelnia: ' + Number( res.count || 0 ) );
|
||||
}
|
||||
else
|
||||
{
|
||||
$( '#bestseller_rules_preview' ).text( 'Spelnia: -' );
|
||||
}
|
||||
}).fail( function() {
|
||||
$( '#bestseller_rules_preview' ).text( 'Spelnia: -' );
|
||||
});
|
||||
}
|
||||
|
||||
function load_client_bestseller_settings( client_id )
|
||||
{
|
||||
if ( !client_id )
|
||||
{
|
||||
reset_client_bestseller_settings_form();
|
||||
return $.Deferred().resolve().promise();
|
||||
}
|
||||
|
||||
products_bestseller_settings_loading = true;
|
||||
|
||||
return $.ajax({
|
||||
url: '/products/get_client_bestseller_settings/client_id=' + client_id,
|
||||
type: 'GET',
|
||||
dataType: 'json'
|
||||
}).done( function( res ) {
|
||||
var settings = ( res && res.settings ) ? res.settings : {};
|
||||
$( '#bestseller_roas_entry' ).val( settings.bestseller_roas_entry == null ? '' : settings.bestseller_roas_entry );
|
||||
$( '#bestseller_roas_exit' ).val( settings.bestseller_roas_exit == null ? '' : settings.bestseller_roas_exit );
|
||||
$( '#min_conversions' ).val( settings.min_conversions != null ? settings.min_conversions : 10 );
|
||||
$( '#cooldown_period' ).val( settings.cooldown_period != null ? settings.cooldown_period : 14 );
|
||||
}).fail( function() {
|
||||
show_toast( 'Nie udalo sie pobrac ustawien bestsellera.', 'error' );
|
||||
}).always( function() {
|
||||
products_bestseller_settings_loading = false;
|
||||
preview_client_bestseller_settings();
|
||||
});
|
||||
}
|
||||
|
||||
function save_client_bestseller_settings()
|
||||
{
|
||||
var client_id = $( '#client_id' ).val() || '';
|
||||
|
||||
if ( !client_id || products_bestseller_settings_loading )
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$.ajax({
|
||||
url: '/products/save_client_bestseller_settings/',
|
||||
type: 'POST',
|
||||
dataType: 'json',
|
||||
data: {
|
||||
client_id: client_id,
|
||||
bestseller_roas_entry: $( '#bestseller_roas_entry' ).val(),
|
||||
bestseller_roas_exit: $( '#bestseller_roas_exit' ).val(),
|
||||
min_conversions: $( '#min_conversions' ).val(),
|
||||
cooldown_period: $( '#cooldown_period' ).val()
|
||||
},
|
||||
error: function() {
|
||||
show_toast( 'Nie udalo sie zapisac ustawien bestsellera.', 'error' );
|
||||
},
|
||||
success: function() {
|
||||
preview_client_bestseller_settings();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function products_get_saved_columns_visibility( columns_count )
|
||||
{
|
||||
var raw = products_storage_get( PRODUCTS_COLUMNS_STORAGE_KEY );
|
||||
@@ -1107,6 +1230,7 @@ $( function()
|
||||
$( '#products_search' ).val( '' );
|
||||
$( '#products_cl4' ).val( '' );
|
||||
update_delete_ad_group_button_state();
|
||||
load_client_bestseller_settings( client_id );
|
||||
|
||||
load_products_campaigns( client_id, '' ).done( function() {
|
||||
load_products_ad_groups( '', '' ).done( function() {
|
||||
@@ -1226,6 +1350,7 @@ $( function()
|
||||
|
||||
$( '#products_search' ).val( savedSearch );
|
||||
$( '#products_cl4' ).val( savedCl4 );
|
||||
load_client_bestseller_settings( $( '#client_id' ).val() || '' );
|
||||
|
||||
load_cl4_suggestions( $( '#client_id' ).val() || '' );
|
||||
|
||||
@@ -1435,6 +1560,17 @@ $( function()
|
||||
});
|
||||
});
|
||||
|
||||
$( 'body' ).on( 'click', '#save_bestseller_rules', function()
|
||||
{
|
||||
save_client_bestseller_settings();
|
||||
});
|
||||
|
||||
$( 'body' ).on( 'input change', '#bestseller_roas_entry, #bestseller_roas_exit, #min_conversions, #cooldown_period', function()
|
||||
{
|
||||
clearTimeout( products_bestseller_preview_timer );
|
||||
products_bestseller_preview_timer = setTimeout( preview_client_bestseller_settings, 300 );
|
||||
});
|
||||
|
||||
// CL4 autocomplete — datalist z unikalnymi wartościami
|
||||
var cl4_values_cache = [];
|
||||
var cl4_datalist_id = 'cl4-suggestions';
|
||||
|
||||
Reference in New Issue
Block a user