feat: Enhance Google Ads API integration and add column visibility control in product view

- Updated GoogleAdsApi class to include new GAQL queries for Performance Max campaigns and fallback mechanisms.
- Refactored data collection logic to handle ad groups and asset groups more effectively.
- Modified get_campaigns_30_days and get_campaigns_all_time methods to accept an optional date parameter for improved date filtering.
- Introduced a new UI feature in the products main view to allow users to toggle column visibility, enhancing user experience.
- Implemented local storage functionality to remember user preferences for column visibility across sessions.
This commit is contained in:
2026-02-18 01:50:08 +01:00
parent 4635cefcbb
commit 3dc06d505a
3 changed files with 721 additions and 137 deletions

View File

@@ -14,6 +14,8 @@ class Cron
}
$date = \S::get( 'date' ) ? date( 'Y-m-d', strtotime( \S::get( 'date' ) ) ) : date( 'Y-m-d', strtotime( '-1 days' ) );
$conversion_window_days = self::get_conversion_window_days();
$import_dates = self::build_backfill_dates( $date, $conversion_window_days );
$client_id = (int) \S::get( 'client_id' );
if ( $client_id > 0 )
@@ -38,6 +40,7 @@ class Cron
'result' => empty( $sync['errors'] ) ? 'Synchronizacja produktow zakonczona.' : 'Synchronizacja produktow zakonczona z bledami.',
'client_id' => (int) $client['id'],
'date' => $date,
'active_date' => $date,
'phase' => 'single_full',
'processed_products' => (int) $sync['processed_products'],
'skipped' => (int) $sync['skipped'],
@@ -58,13 +61,16 @@ class Cron
}
$state_key = 'cron_products_pipeline_state';
$state = self::get_products_pipeline_state( $state_key, $date, $client_ids );
$state = self::get_products_pipeline_state( $state_key, $date, $client_ids, $import_dates );
if ( $state['phase'] === 'done' )
{
echo json_encode( [
'result' => 'Pipeline cron_products jest zakonczony dla tej daty.',
'date' => $state['import_date'],
'result' => 'Pipeline cron_products jest zakonczony dla calego okna dat.',
'date' => $date,
'active_date' => $state['import_date'],
'conversion_window_days' => (int) ( $state['conversion_window_days'] ?? $conversion_window_days ),
'dates_synced' => $state['import_dates'] ?? $import_dates,
'phase' => 'done',
'total_clients' => count( $client_ids )
] );
@@ -80,7 +86,7 @@ class Cron
$done_key = $phase_map[ $state['phase'] ] ?? null;
if ( !$done_key )
{
$state = self::init_products_pipeline_state( $date, $client_ids );
$state = self::init_products_pipeline_state( $date, $client_ids, $import_dates );
$done_key = 'fetch_done_ids';
}
@@ -93,7 +99,9 @@ class Cron
echo json_encode( [
'result' => 'Faza zakonczona. Przejdz do nastepnej fazy kolejnym wywolaniem.',
'date' => $state['import_date'],
'date' => $date,
'active_date' => $state['import_date'],
'conversion_window_days' => (int) ( $state['conversion_window_days'] ?? $conversion_window_days ),
'phase' => $state['phase'],
'total_clients' => count( $client_ids )
] );
@@ -109,7 +117,10 @@ class Cron
$response = [
'result' => '',
'date' => $state['import_date'],
'date' => $date,
'active_date' => $state['import_date'],
'conversion_window_days' => (int) ( $state['conversion_window_days'] ?? $conversion_window_days ),
'dates_synced' => $state['import_dates'] ?? $import_dates,
'phase' => $state['phase'],
'client_id' => (int) $selected_client['id'],
'errors' => []
@@ -151,11 +162,24 @@ class Cron
exit;
}
static private function init_products_pipeline_state( $date, $client_ids )
static private function init_products_pipeline_state( $date, $client_ids, $import_dates )
{
$import_dates = array_values( array_unique( array_map( function( $item )
{
return date( 'Y-m-d', strtotime( $item ) );
}, (array) $import_dates ) ) );
sort( $import_dates );
$anchor_date = date( 'Y-m-d', strtotime( $date ) );
$initial_date = !empty( $import_dates ) ? $import_dates[0] : $anchor_date;
return [
'import_date' => date( 'Y-m-d', strtotime( $date ) ),
'clients_hash' => md5( implode( ',', $client_ids ) ),
'anchor_date' => $anchor_date,
'import_date' => $initial_date,
'import_dates' => $import_dates,
'current_date_index' => 0,
'conversion_window_days' => count( $import_dates ),
'clients_hash' => md5( $anchor_date . '|' . implode( ',', $client_ids ) . '|' . implode( ',', $import_dates ) ),
'phase' => 'fetch',
'fetch_done_ids' => [],
'aggregate_30_done_ids' => [],
@@ -163,24 +187,40 @@ class Cron
];
}
static private function get_products_pipeline_state( $state_key, $date, $client_ids )
static private function get_products_pipeline_state( $state_key, $date, $client_ids, $import_dates )
{
$state_raw = self::get_setting_value( $state_key, '' );
$state = json_decode( (string) $state_raw, true );
$expected_date = date( 'Y-m-d', strtotime( $date ) );
$expected_hash = md5( implode( ',', $client_ids ) );
$expected_dates = array_values( array_unique( array_map( function( $item )
{
return date( 'Y-m-d', strtotime( $item ) );
}, (array) $import_dates ) ) );
sort( $expected_dates );
$expected_hash = md5( $expected_date . '|' . implode( ',', $client_ids ) . '|' . implode( ',', $expected_dates ) );
if ( !is_array( $state ) )
{
return self::init_products_pipeline_state( $expected_date, $client_ids );
return self::init_products_pipeline_state( $expected_date, $client_ids, $expected_dates );
}
if ( ( $state['import_date'] ?? '' ) !== $expected_date || ( $state['clients_hash'] ?? '' ) !== $expected_hash )
if ( ( $state['anchor_date'] ?? '' ) !== $expected_date || ( $state['clients_hash'] ?? '' ) !== $expected_hash )
{
return self::init_products_pipeline_state( $expected_date, $client_ids );
return self::init_products_pipeline_state( $expected_date, $client_ids, $expected_dates );
}
if ( !isset( $state['import_dates'] ) || !is_array( $state['import_dates'] ) || empty( $state['import_dates'] ) )
{
$state['import_dates'] = $expected_dates;
}
if ( !isset( $state['current_date_index'] ) || !is_numeric( $state['current_date_index'] ) )
{
$state['current_date_index'] = 0;
}
$state['current_date_index'] = max( 0, min( count( $state['import_dates'] ) - 1, (int) $state['current_date_index'] ) );
$state['import_date'] = $state['import_dates'][ $state['current_date_index'] ] ?? $expected_date;
foreach ( [ 'fetch_done_ids', 'aggregate_30_done_ids', 'aggregate_temp_done_ids' ] as $key )
{
if ( !isset( $state[ $key ] ) || !is_array( $state[ $key ] ) )
@@ -217,6 +257,21 @@ class Cron
return $state;
}
$current_date_index = (int) ( $state['current_date_index'] ?? 0 );
$import_dates = is_array( $state['import_dates'] ?? null ) ? $state['import_dates'] : [];
$last_index = count( $import_dates ) - 1;
if ( $last_index >= 0 && $current_date_index < $last_index )
{
$state['current_date_index'] = $current_date_index + 1;
$state['import_date'] = $import_dates[ $state['current_date_index'] ];
$state['phase'] = 'fetch';
$state['fetch_done_ids'] = [];
$state['aggregate_30_done_ids'] = [];
$state['aggregate_temp_done_ids'] = [];
return $state;
}
$state['phase'] = 'done';
return $state;
}
@@ -542,22 +597,27 @@ class Cron
return (int) $mdb -> id();
}
static private function aggregate_products_history_30_for_client( $client_id, $date )
static private function aggregate_products_history_30_for_client( $client_id, $date = null )
{
global $mdb;
$client_id = (int) $client_id;
$date = date( 'Y-m-d', strtotime( $date ) );
$params = [ ':client_id' => $client_id ];
$sql = 'SELECT DISTINCT ph.product_id, ph.campaign_id, ph.ad_group_id, ph.date_add
FROM products_history AS ph
INNER JOIN products AS p ON p.id = ph.product_id
WHERE p.client_id = :client_id
AND ph.updated = 1';
$rows = $mdb -> query(
'SELECT DISTINCT ph.product_id, ph.campaign_id, ph.ad_group_id, ph.date_add
FROM products_history AS ph
INNER JOIN products AS p ON p.id = ph.product_id
WHERE p.client_id = ' . $client_id . '
AND ph.updated = 1
AND ph.date_add = \'' . $date . '\'
ORDER BY ph.product_id ASC, ph.campaign_id ASC, ph.ad_group_id ASC'
) -> fetchAll( \PDO::FETCH_ASSOC );
if ( $date )
{
$params[':date_add'] = date( 'Y-m-d', strtotime( $date ) );
$sql .= ' AND ph.date_add = :date_add';
}
$sql .= ' ORDER BY ph.date_add ASC, ph.product_id ASC, ph.campaign_id ASC, ph.ad_group_id ASC';
$rows = $mdb -> query( $sql, $params ) -> fetchAll( \PDO::FETCH_ASSOC );
$processed = 0;
foreach ( $rows as $row )
@@ -1178,6 +1238,9 @@ class Cron
exit;
}
$sync_date = \S::get( 'date' ) ? date( 'Y-m-d', strtotime( \S::get( 'date' ) ) ) : date( 'Y-m-d' );
$conversion_window_days = self::get_conversion_window_days();
$sync_dates = self::build_backfill_dates( $sync_date, $conversion_window_days );
$client_id = (int) \S::get( 'client_id' );
if ( $client_id > 0 )
@@ -1194,11 +1257,13 @@ class Cron
exit;
}
$sync = self::sync_campaigns_for_client( $client, $api );
$sync = self::sync_campaigns_for_client( $client, $api, $sync_date, true );
echo json_encode( [
'result' => empty( $sync['errors'] ) ? 'Synchronizacja kampanii zakonczona.' : 'Synchronizacja kampanii zakonczona z bledami.',
'client_id' => (int) $client['id'],
'date' => $sync_date,
'active_date' => $sync_date,
'processed_records' => (int) $sync['processed_records'],
'ad_groups_synced' => (int) ( $sync['ad_groups_synced'] ?? 0 ),
'search_terms_synced' => (int) ( $sync['search_terms_synced'] ?? 0 ),
@@ -1239,15 +1304,44 @@ class Cron
$client_ids = array_values( array_unique( $client_ids ) );
$window_state_key = 'cron_campaigns_window_state';
$window_state = self::get_campaigns_window_state( $window_state_key, $sync_date, $sync_dates );
self::save_campaigns_window_state( $window_state_key, $window_state );
$active_sync_date = $window_state['sync_date'];
$sync_details = ( $active_sync_date === $sync_date );
$state_key = 'cron_campaigns_state';
$state = self::get_daily_cron_state( $state_key );
$state = self::get_daily_cron_state( $state_key, $active_sync_date );
$next_client_id = self::pick_next_client_id( $client_ids, $state['processed_ids'] );
if ( !$next_client_id )
{
$previous_index = (int) ( $window_state['current_date_index'] ?? 0 );
$window_state = self::advance_campaigns_window_state( $window_state );
$next_index = (int) ( $window_state['current_date_index'] ?? 0 );
if ( $next_index !== $previous_index )
{
self::save_campaigns_window_state( $window_state_key, $window_state );
echo json_encode( [
'result' => 'Wszyscy klienci dla aktualnego dnia zostali przetworzeni. Kolejne wywolanie przejdzie do nastepnego dnia.',
'date' => $sync_date,
'active_date' => $active_sync_date,
'next_active_date' => $window_state['sync_date'],
'conversion_window_days' => $conversion_window_days,
'dates_synced' => $window_state['sync_dates'],
'processed_clients' => count( array_intersect( $client_ids, $state['processed_ids'] ) ),
'total_clients' => count( $client_ids )
] );
exit;
}
echo json_encode( [
'result' => 'Wszyscy klienci kampanii zostali juz dzis przetworzeni.',
'date' => $state['date'],
'result' => 'Wszyscy klienci kampanii zostali juz przetworzeni dla calego okna dat.',
'date' => $sync_date,
'active_date' => $active_sync_date,
'conversion_window_days' => $conversion_window_days,
'dates_synced' => $window_state['sync_dates'],
'processed_clients' => count( array_intersect( $client_ids, $state['processed_ids'] ) ),
'total_clients' => count( $client_ids )
] );
@@ -1261,18 +1355,22 @@ class Cron
exit;
}
$sync = self::sync_campaigns_for_client( $selected_client, $api );
$sync = self::sync_campaigns_for_client( $selected_client, $api, $active_sync_date, $sync_details );
// Oznaczamy klienta jako przetworzonego rowniez po bledzie, aby nie zapetlac wywolan.
$state['processed_ids'][] = (int) $next_client_id;
$state['processed_ids'] = array_values( array_unique( array_map( 'intval', $state['processed_ids'] ) ) );
self::save_daily_cron_state( $state_key, $state );
self::save_daily_cron_state( $state_key, $state, $active_sync_date );
$processed_today = count( array_intersect( $client_ids, $state['processed_ids'] ) );
echo json_encode( [
'result' => empty( $sync['errors'] ) ? 'Synchronizacja kampanii zakonczona.' : 'Synchronizacja kampanii zakonczona z bledami.',
'client_id' => $next_client_id,
'date' => $sync_date,
'active_date' => $active_sync_date,
'conversion_window_days' => $conversion_window_days,
'dates_synced' => $window_state['sync_dates'],
'processed_records' => (int) $sync['processed_records'],
'ad_groups_synced' => (int) ( $sync['ad_groups_synced'] ?? 0 ),
'search_terms_synced' => (int) ( $sync['search_terms_synced'] ?? 0 ),
@@ -1284,17 +1382,18 @@ class Cron
exit;
}
static private function sync_campaigns_for_client( $client, $api )
static private function sync_campaigns_for_client( $client, $api, $as_of_date = null, $sync_details = true )
{
global $mdb;
$today = date( 'Y-m-d' );
$as_of_date = $as_of_date ? date( 'Y-m-d', strtotime( $as_of_date ) ) : date( 'Y-m-d' );
$sync_details = (bool) $sync_details;
$processed = 0;
$errors = [];
$customer_id = $client['google_ads_customer_id'];
$campaigns_db_map = [];
$campaigns_30 = $api -> get_campaigns_30_days( $customer_id );
$campaigns_30 = $api -> get_campaigns_30_days( $customer_id, $as_of_date );
if ( $campaigns_30 === false )
{
$last_err = \services\GoogleAdsApi::get_setting( 'google_ads_last_error' );
@@ -1314,7 +1413,21 @@ class Cron
$campaigns_30 = [];
}
$campaigns_all_time = $api -> get_campaigns_all_time( $customer_id );
$campaigns_all_time = $api -> get_campaigns_all_time( $customer_id, $as_of_date );
if ( $campaigns_all_time === false )
{
$last_err = \services\GoogleAdsApi::get_setting( 'google_ads_last_error' );
$errors[] = 'Blad pobierania danych all time dla klienta ' . $client['name'] . ' (ID: ' . $customer_id . '): ' . $last_err;
return [
'processed_records' => 0,
'ad_groups_synced' => 0,
'search_terms_synced' => 0,
'negative_keywords_synced' => 0,
'errors' => $errors
];
}
$all_time_map = [];
$all_time_totals = [
'cost' => 0.0,
@@ -1325,7 +1438,7 @@ class Cron
{
foreach ( $campaigns_all_time as $cat )
{
$all_time_map[ $cat['campaign_id'] ] = $cat['roas_all_time'];
$all_time_map[ (string) ( $cat['campaign_id'] ?? '' ) ] = (float) ( $cat['roas_all_time'] ?? 0 );
$all_time_totals['cost'] += (float) ( $cat['cost_all_time'] ?? 0 );
$all_time_totals['conversion_value'] += (float) ( $cat['conversion_value_all_time'] ?? 0 );
}
@@ -1393,18 +1506,18 @@ class Cron
if ( $mdb -> count( 'campaigns_history', [ 'AND' => [
'campaign_id' => $db_campaign_id,
'date_add' => $today
'date_add' => $as_of_date
] ] ) )
{
$mdb -> update( 'campaigns_history', $history_data, [ 'AND' => [
'campaign_id' => $db_campaign_id,
'date_add' => $today
'date_add' => $as_of_date
] ] );
}
else
{
$history_data['campaign_id'] = $db_campaign_id;
$history_data['date_add'] = $today;
$history_data['date_add'] = $as_of_date;
$mdb -> insert( 'campaigns_history', $history_data );
}
@@ -1457,26 +1570,37 @@ class Cron
if ( $mdb -> count( 'campaigns_history', [ 'AND' => [
'campaign_id' => $db_account_campaign_id,
'date_add' => $today
'date_add' => $as_of_date
] ] ) )
{
$mdb -> update( 'campaigns_history', $account_history_data, [ 'AND' => [
'campaign_id' => $db_account_campaign_id,
'date_add' => $today
'date_add' => $as_of_date
] ] );
}
else
{
$account_history_data['campaign_id'] = $db_account_campaign_id;
$account_history_data['date_add'] = $today;
$account_history_data['date_add'] = $as_of_date;
$mdb -> insert( 'campaigns_history', $account_history_data );
}
$processed++;
$ad_groups_sync = self::sync_campaign_ad_groups_for_client( $campaigns_db_map, $customer_id, $api, $today );
$search_terms_sync = self::sync_campaign_search_terms_for_client( $campaigns_db_map, $ad_groups_sync['ad_group_map'], $customer_id, $api, $today );
$negative_keywords_sync = self::sync_campaign_negative_keywords_for_client( $campaigns_db_map, $ad_groups_sync['ad_group_map'], $customer_id, $api, $today );
if ( !$sync_details )
{
return [
'processed_records' => $processed,
'ad_groups_synced' => 0,
'search_terms_synced' => 0,
'negative_keywords_synced' => 0,
'errors' => $errors
];
}
$ad_groups_sync = self::sync_campaign_ad_groups_for_client( $campaigns_db_map, $customer_id, $api, $as_of_date );
$search_terms_sync = self::sync_campaign_search_terms_for_client( $campaigns_db_map, $ad_groups_sync['ad_group_map'], $customer_id, $api, $as_of_date );
$negative_keywords_sync = self::sync_campaign_negative_keywords_for_client( $campaigns_db_map, $ad_groups_sync['ad_group_map'], $customer_id, $api, $as_of_date );
$errors = array_merge( $errors, $ad_groups_sync['errors'], $search_terms_sync['errors'], $negative_keywords_sync['errors'] );
@@ -1856,17 +1980,104 @@ class Cron
return [ 'count' => $count, 'errors' => [] ];
}
static private function get_daily_cron_state( $state_key )
static private function get_campaigns_window_state( $state_key, $anchor_date, $sync_dates )
{
$today = date( 'Y-m-d' );
$anchor_date = date( 'Y-m-d', strtotime( $anchor_date ) );
$sync_dates = array_values( array_unique( array_map( function( $item )
{
return date( 'Y-m-d', strtotime( $item ) );
}, (array) $sync_dates ) ) );
sort( $sync_dates );
if ( empty( $sync_dates ) )
{
$sync_dates = [ $anchor_date ];
}
$expected_hash = md5( $anchor_date . '|' . implode( ',', $sync_dates ) );
$state_raw = self::get_setting_value( $state_key, '' );
$state = json_decode( (string) $state_raw, true );
if ( !is_array( $state ) || ( $state['window_hash'] ?? '' ) !== $expected_hash )
{
return [
'anchor_date' => $anchor_date,
'sync_dates' => $sync_dates,
'current_date_index' => 0,
'sync_date' => $sync_dates[0],
'window_hash' => $expected_hash
];
}
$current_date_index = (int) ( $state['current_date_index'] ?? 0 );
$current_date_index = max( 0, min( count( $sync_dates ) - 1, $current_date_index ) );
return [
'anchor_date' => $anchor_date,
'sync_dates' => $sync_dates,
'current_date_index' => $current_date_index,
'sync_date' => $sync_dates[ $current_date_index ],
'window_hash' => $expected_hash
];
}
static private function save_campaigns_window_state( $state_key, $state )
{
$sync_dates = array_values( array_unique( array_map( function( $item )
{
return date( 'Y-m-d', strtotime( $item ) );
}, (array) ( $state['sync_dates'] ?? [] ) ) ) );
sort( $sync_dates );
if ( empty( $sync_dates ) )
{
return;
}
$anchor_date = date( 'Y-m-d', strtotime( $state['anchor_date'] ?? end( $sync_dates ) ) );
$current_date_index = max( 0, min( count( $sync_dates ) - 1, (int) ( $state['current_date_index'] ?? 0 ) ) );
$payload = [
'anchor_date' => $anchor_date,
'sync_dates' => $sync_dates,
'current_date_index' => $current_date_index,
'sync_date' => $sync_dates[ $current_date_index ],
'window_hash' => md5( $anchor_date . '|' . implode( ',', $sync_dates ) )
];
self::set_setting_value( $state_key, json_encode( $payload, JSON_UNESCAPED_UNICODE ) );
}
static private function advance_campaigns_window_state( $state )
{
$sync_dates = is_array( $state['sync_dates'] ?? null ) ? $state['sync_dates'] : [];
$current_date_index = (int) ( $state['current_date_index'] ?? 0 );
$last_index = count( $sync_dates ) - 1;
if ( $last_index < 0 )
{
return $state;
}
if ( $current_date_index < $last_index )
{
$state['current_date_index'] = $current_date_index + 1;
$state['sync_date'] = $sync_dates[ $state['current_date_index'] ];
}
return $state;
}
static private function get_daily_cron_state( $state_key, $state_date = null )
{
$state_date = $state_date ? date( 'Y-m-d', strtotime( $state_date ) ) : date( 'Y-m-d' );
$state_raw = self::get_setting_value( $state_key, '' );
$state = json_decode( (string) $state_raw, true );
if ( !is_array( $state ) || ( $state['date'] ?? '' ) !== $today )
if ( !is_array( $state ) || ( $state['date'] ?? '' ) !== $state_date )
{
return [
'date' => $today,
'date' => $state_date,
'processed_ids' => []
];
}
@@ -1885,15 +2096,16 @@ class Cron
}
return [
'date' => $today,
'date' => $state_date,
'processed_ids' => array_values( array_unique( $processed_ids ) )
];
}
static private function save_daily_cron_state( $state_key, $state )
static private function save_daily_cron_state( $state_key, $state, $state_date = null )
{
$state_date = $state_date ? date( 'Y-m-d', strtotime( $state_date ) ) : date( 'Y-m-d' );
$payload = [
'date' => date( 'Y-m-d' ),
'date' => $state_date,
'processed_ids' => array_values( array_unique( array_map( 'intval', $state['processed_ids'] ?? [] ) ) )
];
@@ -1924,6 +2136,42 @@ class Cron
return 0;
}
static private function get_conversion_window_days()
{
$request_value = (int) \S::get( 'conversion_window_days' );
if ( $request_value > 0 )
{
return min( 90, $request_value );
}
$setting_value = (int) self::get_setting_value( 'google_ads_conversion_window_days', 7 );
if ( $setting_value <= 0 )
{
return 7;
}
return min( 90, $setting_value );
}
static private function build_backfill_dates( $end_date, $window_days )
{
$end_timestamp = strtotime( $end_date );
if ( !$end_timestamp )
{
$end_timestamp = strtotime( date( 'Y-m-d' ) );
}
$window_days = max( 1, min( 90, (int) $window_days ) );
$dates = [];
for ( $i = $window_days - 1; $i >= 0; $i-- )
{
$dates[] = date( 'Y-m-d', strtotime( '-' . $i . ' days', $end_timestamp ) );
}
return $dates;
}
static private function get_setting_value( $setting_key, $default = null )
{
global $mdb;

View File

@@ -465,100 +465,177 @@ class GoogleAdsApi
. "FROM shopping_performance_view "
. "WHERE segments.date = '" . $date . "'";
$results = $this -> search_stream( $customer_id, $gaql_with_ad_group );
$fallback_without_ad_group = false;
$gaql_pmax_asset_group = "SELECT "
. "segments.date, "
. "segments.product_item_id, "
. "segments.product_title, "
. "campaign.id, "
. "campaign.name, "
. "asset_group.id, "
. "asset_group.name, "
. "metrics.impressions, "
. "metrics.clicks, "
. "metrics.cost_micros, "
. "metrics.conversions, "
. "metrics.conversions_value "
. "FROM asset_group_product_group_view "
. "WHERE segments.date = '" . $date . "' "
. "AND campaign.advertising_channel_type = 'PERFORMANCE_MAX'";
if ( $results === false )
$gaql_pmax_campaign_level_fallback = "SELECT "
. "segments.date, "
. "segments.product_item_id, "
. "segments.product_title, "
. "campaign.id, "
. "campaign.name, "
. "campaign.advertising_channel_type, "
. "metrics.impressions, "
. "metrics.clicks, "
. "metrics.cost_micros, "
. "metrics.conversions, "
. "metrics.conversions_value "
. "FROM shopping_performance_view "
. "WHERE segments.date = '" . $date . "'";
$results_with_ad_group = $this -> search_stream( $customer_id, $gaql_with_ad_group );
$results_pmax_asset_group = $this -> search_stream( $customer_id, $gaql_pmax_asset_group );
$results_pmax_campaign_fallback = [];
$had_success = false;
if ( is_array( $results_with_ad_group ) )
{
$gaql_without_ad_group = "SELECT "
. "segments.date, "
. "segments.product_item_id, "
. "segments.product_title, "
. "campaign.id, "
. "campaign.name, "
. "metrics.impressions, "
. "metrics.clicks, "
. "metrics.cost_micros, "
. "metrics.conversions, "
. "metrics.conversions_value "
. "FROM shopping_performance_view "
. "WHERE segments.date = '" . $date . "'";
$had_success = true;
}
else
{
$results_with_ad_group = [];
}
$results = $this -> search_stream( $customer_id, $gaql_without_ad_group );
if ( $results === false )
if ( is_array( $results_pmax_asset_group ) )
{
$had_success = true;
}
else
{
$results_pmax_asset_group = [];
// Fallback dla kont/API, gdzie asset_group_product_group_view moze nie byc dostepny.
$tmp = $this -> search_stream( $customer_id, $gaql_pmax_campaign_level_fallback );
if ( is_array( $tmp ) )
{
return false;
$had_success = true;
foreach ( $tmp as $row )
{
$channel = (string) ( $row['campaign']['advertisingChannelType'] ?? '' );
if ( strtoupper( $channel ) === 'PERFORMANCE_MAX' )
{
$results_pmax_campaign_fallback[] = $row;
}
}
}
}
$fallback_without_ad_group = true;
if ( !$had_success )
{
return false;
}
$products = [];
foreach ( $results as $row )
$collect_rows = function( $rows, $group_kind ) use ( &$products )
{
$offer_id = trim( (string) ( $row['segments']['productItemId'] ?? '' ) );
if ( $offer_id === '' )
if ( !is_array( $rows ) )
{
continue;
return;
}
$campaign_id = (int) ( $row['campaign']['id'] ?? 0 );
$campaign_name = trim( (string) ( $row['campaign']['name'] ?? '' ) );
if ( $campaign_name === '' && $campaign_id > 0 )
foreach ( $rows as $row )
{
$campaign_name = 'Kampania #' . $campaign_id;
}
$ad_group_id = 0;
$ad_group_name = 'PMax (bez grup reklam)';
if ( !$fallback_without_ad_group )
{
$ad_group_id = (int) ( $row['adGroup']['id'] ?? 0 );
$ad_group_name = trim( (string) ( $row['adGroup']['name'] ?? '' ) );
if ( $ad_group_id > 0 && $ad_group_name === '' )
$offer_id = trim( (string) ( $row['segments']['productItemId'] ?? '' ) );
if ( $offer_id === '' )
{
$ad_group_name = 'Ad group #' . $ad_group_id;
continue;
}
else if ( $ad_group_id <= 0 )
$campaign_id = (int) ( $row['campaign']['id'] ?? 0 );
$campaign_name = trim( (string) ( $row['campaign']['name'] ?? '' ) );
if ( $campaign_name === '' && $campaign_id > 0 )
{
$ad_group_name = 'PMax (bez grup reklam)';
$campaign_name = 'Kampania #' . $campaign_id;
}
$ad_group_id = 0;
$ad_group_name = 'PMax (bez grup reklam)';
if ( $group_kind === 'ad_group' )
{
$ad_group_id = (int) ( $row['adGroup']['id'] ?? 0 );
$ad_group_name = trim( (string) ( $row['adGroup']['name'] ?? '' ) );
if ( $ad_group_id > 0 && $ad_group_name === '' )
{
$ad_group_name = 'Ad group #' . $ad_group_id;
}
else if ( $ad_group_id <= 0 )
{
$ad_group_name = 'PMax (bez grup reklam)';
}
}
else if ( $group_kind === 'asset_group' )
{
$ad_group_id = (int) ( $row['assetGroup']['id'] ?? 0 );
$ad_group_name = trim( (string) ( $row['assetGroup']['name'] ?? '' ) );
if ( $ad_group_id > 0 && $ad_group_name === '' )
{
$ad_group_name = 'Asset group #' . $ad_group_id;
}
else if ( $ad_group_id <= 0 )
{
$ad_group_name = 'PMax (bez grup plikow)';
}
}
$scope_key = $offer_id . '|' . $campaign_id . '|' . $ad_group_id;
if ( !isset( $products[ $scope_key ] ) )
{
$products[ $scope_key ] = [
'OfferId' => $offer_id,
'ProductTitle' => (string) ( $row['segments']['productTitle'] ?? $offer_id ),
'CampaignId' => $campaign_id,
'CampaignName' => $campaign_name,
'AdGroupId' => $ad_group_id,
'AdGroupName' => $ad_group_name,
'Impressions' => 0,
'Clicks' => 0,
'Cost' => 0.0,
'Conversions' => 0.0,
'ConversionValue' => 0.0
];
}
$products[ $scope_key ]['Impressions'] += (int) ( $row['metrics']['impressions'] ?? 0 );
$products[ $scope_key ]['Clicks'] += (int) ( $row['metrics']['clicks'] ?? 0 );
$products[ $scope_key ]['Cost'] += (float) ( $row['metrics']['costMicros'] ?? 0 ) / 1000000;
$products[ $scope_key ]['Conversions'] += (float) ( $row['metrics']['conversions'] ?? 0 );
$products[ $scope_key ]['ConversionValue'] += (float) ( $row['metrics']['conversionsValue'] ?? 0 );
}
};
$scope_key = $offer_id . '|' . $campaign_id . '|' . $ad_group_id;
if ( !isset( $products[ $scope_key ] ) )
{
$products[ $scope_key ] = [
'OfferId' => $offer_id,
'ProductTitle' => (string) ( $row['segments']['productTitle'] ?? $offer_id ),
'CampaignId' => $campaign_id,
'CampaignName' => $campaign_name,
'AdGroupId' => $ad_group_id,
'AdGroupName' => $ad_group_name,
'Impressions' => 0,
'Clicks' => 0,
'Cost' => 0.0,
'Conversions' => 0.0,
'ConversionValue' => 0.0
];
}
$products[ $scope_key ]['Impressions'] += (int) ( $row['metrics']['impressions'] ?? 0 );
$products[ $scope_key ]['Clicks'] += (int) ( $row['metrics']['clicks'] ?? 0 );
$products[ $scope_key ]['Cost'] += (float) ( $row['metrics']['costMicros'] ?? 0 ) / 1000000;
$products[ $scope_key ]['Conversions'] += (float) ( $row['metrics']['conversions'] ?? 0 );
$products[ $scope_key ]['ConversionValue'] += (float) ( $row['metrics']['conversionsValue'] ?? 0 );
}
$collect_rows( $results_with_ad_group, 'ad_group' );
$collect_rows( $results_pmax_asset_group, 'asset_group' );
$collect_rows( $results_pmax_campaign_fallback, 'campaign' );
return array_values( $products );
}
public function get_campaigns_30_days( $customer_id )
public function get_campaigns_30_days( $customer_id, $as_of_date = null )
{
$as_of_date = $as_of_date ? date( 'Y-m-d', strtotime( $as_of_date ) ) : date( 'Y-m-d' );
$date_from = date( 'Y-m-d', strtotime( '-29 days', strtotime( $as_of_date ) ) );
$gaql = "SELECT "
. "campaign.id, "
. "campaign.name, "
@@ -570,7 +647,8 @@ class GoogleAdsApi
. "metrics.conversions_value "
. "FROM campaign "
. "WHERE campaign.status = 'ENABLED' "
. "AND segments.date DURING LAST_30_DAYS";
. "AND segments.date >= '" . $date_from . "' "
. "AND segments.date <= '" . $as_of_date . "'";
$results = $this -> search_stream( $customer_id, $gaql );
if ( $results === false ) return false;
@@ -619,16 +697,34 @@ class GoogleAdsApi
// --- Kampanie: dane all-time ---
public function get_campaigns_all_time( $customer_id )
public function get_campaigns_all_time( $customer_id, $as_of_date = null )
{
$gaql = "SELECT "
. "campaign.id, "
. "metrics.cost_micros, "
. "metrics.conversions_value "
. "FROM campaign "
. "WHERE campaign.status = 'ENABLED'";
$as_of_date = $as_of_date ? date( 'Y-m-d', strtotime( $as_of_date ) ) : null;
$gaql_base = "SELECT "
. "campaign.id, "
. "metrics.cost_micros, "
. "metrics.conversions_value "
. "FROM campaign "
. "WHERE campaign.status = 'ENABLED'";
$results = false;
if ( $as_of_date )
{
$gaql_with_date = $gaql_base . " AND segments.date <= '" . $as_of_date . "'";
$results = $this -> search_stream( $customer_id, $gaql_with_date );
// Fallback do starego sposobu, gdy filtr daty nie jest akceptowany na danym koncie.
if ( $results === false )
{
$results = $this -> search_stream( $customer_id, $gaql_base );
}
}
else
{
$results = $this -> search_stream( $customer_id, $gaql_base );
}
$results = $this -> search_stream( $customer_id, $gaql );
if ( $results === false ) return false;
$campaigns = [];
@@ -637,18 +733,33 @@ class GoogleAdsApi
$cid = $row['campaign']['id'] ?? null;
if ( !$cid ) continue;
$cost = (float) ( $row['metrics']['costMicros'] ?? 0 ) / 1000000;
$value = (float) ( $row['metrics']['conversionsValue'] ?? 0 );
if ( !isset( $campaigns[ $cid ] ) )
{
$campaigns[ $cid ] = [
'campaign_id' => $cid,
'cost_micros_total' => 0.0,
'conversion_value_total' => 0.0
];
}
$campaigns[] = [
'campaign_id' => $cid,
'cost_all_time' => $cost,
'conversion_value_all_time' => $value,
'roas_all_time' => ( $cost > 0 ) ? round( ( $value / $cost ) * 100, 2 ) : 0,
$campaigns[ $cid ]['cost_micros_total'] += (float) ( $row['metrics']['costMicros'] ?? 0 );
$campaigns[ $cid ]['conversion_value_total'] += (float) ( $row['metrics']['conversionsValue'] ?? 0 );
}
foreach ( $campaigns as &$campaign )
{
$cost = $campaign['cost_micros_total'] / 1000000;
$value = $campaign['conversion_value_total'];
$campaign = [
'campaign_id' => $campaign['campaign_id'],
'cost_all_time' => $cost,
'conversion_value_all_time' => $value,
'roas_all_time' => ( $cost > 0 ) ? round( ( $value / $cost ) * 100, 2 ) : 0
];
}
return $campaigns;
return array_values( $campaigns );
}
public function get_ad_groups_30_days( $customer_id )

View File

@@ -30,6 +30,13 @@
<label for="bestseller_min_roas"><i class="fa-solid fa-star"></i> Bestseller min ROAS</label>
<input type="text" id="bestseller_min_roas" name="bestseller_min_roas" class="form-control" placeholder="np. 500" value="" />
</div>
<div class="filter-group filter-group-columns">
<label><i class="fa-solid fa-table-columns"></i> Kolumny</label>
<details class="products-columns-control">
<summary>Widocznosc kolumn</summary>
<div class="products-columns-list" id="products_columns_list"></div>
</details>
</div>
</div>
<!-- Akcje bulk -->
@@ -76,9 +83,63 @@
$openai_enabled = \services\GoogleAdsApi::get_setting( 'openai_enabled' ) !== '0';
$claude_enabled = \services\GoogleAdsApi::get_setting( 'claude_enabled' ) !== '0';
?>
<style>
.products-page .products-filters .filter-group.filter-group-columns {
min-width: 240px;
}
.products-columns-control {
border: 1px solid #E2E8F0;
border-radius: 6px;
background: #FFFFFF;
overflow: hidden;
}
.products-columns-control summary {
cursor: pointer;
padding: 8px 10px;
font-size: 12px;
font-weight: 600;
color: #334155;
list-style: none;
}
.products-columns-control summary::-webkit-details-marker {
display: none;
}
.products-columns-control summary::after {
content: '\25BC';
float: right;
font-size: 10px;
color: #64748B;
margin-top: 2px;
}
.products-columns-control[open] summary::after {
content: '\25B2';
}
.products-columns-list {
border-top: 1px solid #EEF2F7;
padding: 8px 10px;
max-height: 220px;
overflow-y: auto;
}
.products-columns-list .products-col-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #334155;
margin-bottom: 6px;
}
.products-columns-list .products-col-item:last-child {
margin-bottom: 0;
}
.products-columns-list .products-col-item input[type=checkbox] {
margin: 0;
}
</style>
<script type="text/javascript">
var AI_OPENAI_ENABLED = <?= $openai_enabled ? 'true' : 'false'; ?>;
var AI_CLAUDE_ENABLED = <?= $claude_enabled ? 'true' : 'false'; ?>;
var PRODUCTS_COLUMNS_STORAGE_KEY = 'products.columns.visibility';
var PRODUCTS_LOCKED_COLUMNS = [ 0, 19 ];
function show_toast( message, type )
{
@@ -123,6 +184,151 @@ function loadGoogleCategories( callback )
});
}
function products_storage_set( key, value )
{
try
{
if ( value === null || typeof value === 'undefined' )
{
localStorage.removeItem( key );
}
else
{
localStorage.setItem( key, String( value ) );
}
}
catch ( e ) {}
}
function products_storage_get( key )
{
try
{
return localStorage.getItem( key ) || '';
}
catch ( e )
{
return '';
}
}
function products_is_locked_column( idx )
{
return PRODUCTS_LOCKED_COLUMNS.indexOf( Number( idx ) ) !== -1;
}
function products_get_saved_columns_visibility( columns_count )
{
var raw = products_storage_get( PRODUCTS_COLUMNS_STORAGE_KEY );
if ( !raw )
{
return null;
}
try
{
var saved = JSON.parse( raw );
if ( !Array.isArray( saved ) || saved.length !== columns_count )
{
return null;
}
return saved;
}
catch ( e )
{
return null;
}
}
function products_save_columns_visibility( table_instance )
{
if ( !table_instance || !table_instance.columns )
{
return;
}
var columns_count = table_instance.columns().count();
var visible_map = [];
var i;
for ( i = 0; i < columns_count; i++ )
{
visible_map.push( table_instance.column( i ).visible() );
}
products_storage_set( PRODUCTS_COLUMNS_STORAGE_KEY, JSON.stringify( visible_map ) );
}
function products_apply_saved_columns_visibility( table_instance )
{
if ( !table_instance || !table_instance.columns )
{
return;
}
var columns_count = table_instance.columns().count();
var saved_visibility = products_get_saved_columns_visibility( columns_count );
var i;
if ( !saved_visibility )
{
return;
}
for ( i = 0; i < columns_count; i++ )
{
if ( products_is_locked_column( i ) )
{
table_instance.column( i ).visible( true, false );
continue;
}
table_instance.column( i ).visible( !!saved_visibility[i], false );
}
table_instance.columns.adjust().draw( false );
}
function products_render_columns_picker( table_instance )
{
var $list = $( '#products_columns_list' );
var columns_count = ( table_instance && table_instance.columns ) ? table_instance.columns().count() : 0;
var i;
if ( !$list.length )
{
return;
}
$list.empty();
if ( !columns_count )
{
$list.append( '<div class="products-col-item">Brak kolumn.</div>' );
return;
}
for ( i = 0; i < columns_count; i++ )
{
if ( products_is_locked_column( i ) )
{
continue;
}
var id = 'products-col-toggle-' + i;
var th = $( '#products thead th' ).eq( i );
var title = $.trim( th.find( '.dt-column-title' ).first().text() || th.text() ) || ( 'Kolumna ' + i );
var checked = table_instance.column( i ).visible() ? ' checked' : '';
$list.append(
'<label class="products-col-item" for="' + id + '">' +
'<input type="checkbox" class="products-col-toggle" id="' + id + '" data-col-index="' + i + '"' + checked + '>' +
'<span>' + title + '</span>' +
'</label>'
);
}
}
$( function()
{
var products_table = new DataTable( '#products', {
@@ -181,6 +387,9 @@ $( function()
}
});
products_apply_saved_columns_visibility( products_table );
products_render_columns_picker( products_table );
function reload_products_table()
{
products_table.ajax.reload( null, false );
@@ -648,6 +857,22 @@ $( function()
updateSelectedCount();
});
$( 'body' ).on( 'change', '.products-col-toggle', function()
{
var col_index = Number( $( this ).data( 'col-index' ) );
var is_visible = $( this ).is( ':checked' );
if ( !products_table || Number.isNaN( col_index ) || products_is_locked_column( col_index ) )
{
return;
}
products_table.column( col_index ).visible( is_visible, false );
products_table.columns.adjust().draw( false );
products_save_columns_visibility( products_table );
products_render_columns_picker( products_table );
});
// Usuwanie zaznaczonych produktów
$( 'body' ).on( 'click', '#delete-selected-products', function()
{