diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c752460..0e9f631 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -17,7 +17,11 @@ "Bash(git commit:*)", "Bash(php:*)", "WebFetch(domain:adspro.projectpro.pl)", - "mcp__ide__getDiagnostics" + "mcp__ide__getDiagnostics", + "Bash(python3:*)", + "Bash(py --version)", + "Bash(where:*)", + "Bash(python:*)" ] } } diff --git a/autoload/controls/class.Campaigns.php b/autoload/controls/class.Campaigns.php index d8986b4..34f402f 100644 --- a/autoload/controls/class.Campaigns.php +++ b/autoload/controls/class.Campaigns.php @@ -33,6 +33,7 @@ class Campaigns foreach ( $db_results as $row ) { $result['data'][] = [ + '', $row['date_add'], number_format( $row['roas_30_days'], 0, '', ' ' ), number_format( $row['roas_all_time'], 0, '', ' ' ), @@ -192,4 +193,29 @@ class Campaigns echo json_encode( [ 'success' => $result ? true : false ] ); exit; } + + static public function delete_history_entries() + { + $ids = \S::get( 'ids' ); + + if ( empty( $ids ) || !is_array( $ids ) ) + { + echo json_encode( [ 'success' => false, 'message' => 'Nie wybrano wpisow do usuniecia' ] ); + exit; + } + + $ids = array_map( 'intval', $ids ); + $ids = array_filter( $ids, function( $id ) { return $id > 0; } ); + + if ( empty( $ids ) ) + { + echo json_encode( [ 'success' => false, 'message' => 'Nie wybrano wpisow do usuniecia' ] ); + exit; + } + + $deleted = \factory\Campaigns::delete_history_entries( $ids ); + + echo json_encode( [ 'success' => true, 'deleted' => $deleted ] ); + exit; + } } diff --git a/autoload/controls/class.Clients.php b/autoload/controls/class.Clients.php index 952f485..98e0b2e 100644 --- a/autoload/controls/class.Clients.php +++ b/autoload/controls/class.Clients.php @@ -3,6 +3,48 @@ namespace controls; class Clients { + static private function get_facebook_conversion_window_days() + { + global $settings; + + $settings_value = (int) \services\FacebookAdsApi::get_setting( 'facebook_ads_conversion_window_days' ); + if ( $settings_value > 0 ) + { + return min( 90, $settings_value ); + } + + $config_value = (int) ( $settings['facebook_ads_conversion_window_days'] ?? 30 ); + if ( $config_value <= 0 ) + { + $config_value = 30; + } + + return min( 90, $config_value ); + } + + static private function normalize_facebook_ads_account_id( $value ) + { + $value = trim( (string) $value ); + if ( $value === '' ) + { + return null; + } + + if ( stripos( $value, 'act_' ) === 0 ) + { + $digits = preg_replace( '/\D+/', '', substr( $value, 4 ) ); + return $digits !== '' ? 'act_' . $digits : null; + } + + $digits = preg_replace( '/\D+/', '', $value ); + if ( $digits === '' ) + { + return null; + } + + return 'act_' . $digits; + } + static private function clients_has_deleted_column() { global $mdb; @@ -61,6 +103,7 @@ class Clients $name = trim( \S::get( 'name' ) ); $google_ads_customer_id = trim( \S::get( 'google_ads_customer_id' ) ); $google_merchant_account_id = trim( \S::get( 'google_merchant_account_id' ) ); + $facebook_ads_account_id = self::normalize_facebook_ads_account_id( \S::get( 'facebook_ads_account_id' ) ); $active_raw = \S::get( 'active' ); $active = (string) $active_raw === '0' ? 0 : 1; @@ -77,6 +120,7 @@ class Clients 'name' => $name, 'google_ads_customer_id' => $google_ads_customer_id ?: null, 'google_merchant_account_id' => $google_merchant_account_id ?: null, + 'facebook_ads_account_id' => $facebook_ads_account_id, 'google_ads_start_date' => $google_ads_start_date ?: null, 'active' => $active, ]; @@ -152,6 +196,7 @@ class Clients { global $mdb; $clients_not_deleted_sql = self::sql_clients_not_deleted(); + $clients_not_deleted_sql_c = self::sql_clients_not_deleted( 'c' ); // Kampanie: 1 work unit per row (pending=0, done=1) $campaigns_raw = $mdb->query( @@ -205,6 +250,49 @@ class Clients $data[ $merchant_client_id ]['merchant'] = [ $done, 1 ]; } + $facebook_yesterday = date( 'Y-m-d', strtotime( '-1 day' ) ); + + try + { + $facebook_rows = $mdb -> query( + "SELECT + c.id AS client_id, + MAX( h.date_add ) AS last_synced_date + FROM clients c + LEFT JOIN facebook_campaigns fc + ON fc.client_id = c.id + LEFT JOIN facebook_campaigns_history h + ON h.facebook_campaign_id = fc.id + WHERE " . $clients_not_deleted_sql_c . " + AND COALESCE( c.active, 0 ) = 1 + AND TRIM( COALESCE( c.facebook_ads_account_id, '' ) ) <> '' + GROUP BY c.id" + ) -> fetchAll( \PDO::FETCH_ASSOC ); + + foreach ( (array) $facebook_rows as $row ) + { + $facebook_client_id = (int) ( $row['client_id'] ?? 0 ); + if ( $facebook_client_id <= 0 ) + { + continue; + } + + $last_synced = (string) ( $row['last_synced_date'] ?? '' ); + $done = ( $last_synced === $facebook_yesterday ) ? 1 : 0; + $data[ $facebook_client_id ]['facebook_ads'] = [ $done, 1 ]; + } + + $facebook_force_client_id = (int) \services\FacebookAdsApi::get_setting( 'cron_facebook_ads_force_client_id' ); + if ( $facebook_force_client_id > 0 ) + { + $data[ $facebook_force_client_id ]['facebook_ads'] = [ 0, 1 ]; + } + } + catch ( \Throwable $e ) + { + // Tabele Facebook Ads mogly nie byc jeszcze zainstalowane. + } + echo json_encode( [ 'status' => 'ok', 'data' => $data ] ); exit; } @@ -225,7 +313,7 @@ class Clients $deleted_select = self::clients_has_deleted_column() ? 'COALESCE(deleted, 0) AS deleted' : '0 AS deleted'; $client = $mdb -> query( - "SELECT id, COALESCE(active, 0) AS active, " . $deleted_select . ", google_ads_customer_id, google_merchant_account_id + "SELECT id, COALESCE(active, 0) AS active, " . $deleted_select . ", google_ads_customer_id, google_merchant_account_id, facebook_ads_account_id FROM clients WHERE id = :id LIMIT 1", @@ -274,6 +362,18 @@ class Clients \services\GoogleAdsApi::set_setting( 'cron_campaigns_product_alerts_last_client_id', (string) max( 0, $previous_eligible_id ) ); } + else if ( $pipeline === 'facebook_ads' ) + { + $has_facebook_id = trim( (string) ( $client['facebook_ads_account_id'] ?? '' ) ) !== ''; + if ( !$has_facebook_id ) + { + echo json_encode( [ 'success' => false, 'message' => 'Klient nie ma ustawionego Facebook Ads Account ID.' ] ); + exit; + } + + \services\FacebookAdsApi::set_setting( 'cron_facebook_ads_force_client_id', (string) $id ); + \services\FacebookAdsApi::set_setting( 'cron_facebook_ads_force_requested_at', date( 'Y-m-d H:i:s' ) ); + } else { // Domyslny reset (wszystkie pipeline oparte o cron_sync_status). diff --git a/autoload/controls/class.Cron.php b/autoload/controls/class.Cron.php index 9654f18..8ec805a 100644 --- a/autoload/controls/class.Cron.php +++ b/autoload/controls/class.Cron.php @@ -21,7 +21,7 @@ class Cron } $conversion_window_days = self::get_conversion_window_days( true ); - $default_end_date = date( 'Y-m-d', strtotime( '-2 days' ) ); + $default_end_date = date( 'Y-m-d', strtotime( '-1 day' ) ); $requested_date_raw = trim( (string) \S::get( 'date' ) ); $requested_ts = $requested_date_raw !== '' ? strtotime( $requested_date_raw ) : false; $sync_date_raw = $requested_ts ? date( 'Y-m-d', $requested_ts ) : $default_end_date; @@ -32,6 +32,7 @@ class Cron } $sync_dates = self::build_backfill_dates( $sync_date, $conversion_window_days ); + $campaign_sync_dates = [ $sync_date ]; $client_id = (int) \S::get( 'client_id' ); if ( $client_id > 0 ) @@ -82,7 +83,7 @@ class Cron (int) $client['id'], (string) ( $client['google_ads_customer_id'] ?? '' ), $api, - [ $sync_date ] + $sync_dates ); $search_terms_history_synced_total = (int) ( $terms_sync['history_synced'] ?? 0 ); $search_terms_aggregated_total = (int) ( $terms_sync['aggregated'] ?? 0 ); @@ -181,9 +182,9 @@ class Cron self::output_cron_response( [ 'result' => 'Brak aktywnych klientow z ustawionym Google Ads Customer ID.' ] ); } - self::ensure_sync_rows( 'campaigns', $sync_dates, $client_ids ); + self::ensure_sync_rows( 'campaigns', $campaign_sync_dates, $client_ids ); self::ensure_sync_rows( 'products', $sync_dates, $client_ids ); - self::cleanup_pipeline_rows_outside_window( 'campaigns', $sync_dates ); + self::cleanup_pipeline_rows_outside_window( 'campaigns', $campaign_sync_dates ); self::cleanup_pipeline_rows_outside_window( 'products', $sync_dates ); $active_campaign_client_id = self::get_active_client( 'campaigns' ); @@ -326,7 +327,7 @@ class Cron (int) $selected_client['id'], (string) ( $selected_client['google_ads_customer_id'] ?? '' ), $api, - [ $active_date ] + $sync_dates ); $search_terms_history_synced_total = (int) ( $terms_sync['history_synced'] ?? 0 ); $search_terms_aggregated_total = (int) ( $terms_sync['aggregated'] ?? 0 ); @@ -336,9 +337,7 @@ class Cron } } - $pending_dates_before_finalize = self::count_pending_campaign_dates_for_client( $active_client_id ); - $is_last_pending_chunk_for_client = $pending_dates_before_finalize <= 1; - if ( empty( $campaign_errors ) && $is_last_pending_chunk_for_client ) + if ( empty( $campaign_errors ) ) { $kw_sync = self::sync_campaign_keywords_and_negatives_for_client( (int) $selected_client['id'], @@ -482,272 +481,6 @@ class Cron ] ); } - static public function cron_products() - { - global $mdb, $settings; - self::touch_cron_invocation( __FUNCTION__ ); - - $api = new \services\GoogleAdsApi(); - if ( !$api -> is_configured() ) - { - echo json_encode( [ 'result' => 'Google Ads API nie jest skonfigurowane. Uzupelnij dane w Ustawieniach.' ] ); - exit; - } - - $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 ) - { - $client = $mdb -> get( 'clients', '*', [ 'AND' => [ - 'id' => $client_id, - 'google_ads_customer_id[!]' => null, - 'deleted' => 0 - ] ] ); - - if ( !$client || trim( (string) $client['google_ads_customer_id'] ) === '' ) - { - echo json_encode( [ 'result' => 'Nie znaleziono klienta z poprawnym Google Ads Customer ID.', 'client_id' => $client_id ] ); - exit; - } - - $sync = self::sync_products_fetch_for_client( $client, $api, $date ); - $history_30 = self::aggregate_products_history_30_for_client( (int) $client['id'], $date ); - $temp_rows = self::rebuild_products_temp_for_client( (int) $client['id'] ); - - echo json_encode( [ - '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'], - 'fetch_skipped_reason' => isset( $sync['fetch_skipped_reason'] ) ? (string) $sync['fetch_skipped_reason'] : '', - 'history_30_products' => (int) $history_30, - 'products_temp_rows' => (int) $temp_rows, - 'errors' => $sync['errors'] - ] ); - exit; - } - - self::cleanup_old_sync_rows( 30 ); - - $client_ids = $mdb -> query( "SELECT c.id - FROM clients c - WHERE c.deleted = 0 - AND c.google_ads_customer_id IS NOT NULL - AND c.google_ads_customer_id <> '' - AND ( - NOT EXISTS ( SELECT 1 FROM campaigns cp WHERE cp.client_id = c.id ) - OR EXISTS ( - SELECT 1 - FROM campaigns cp - WHERE cp.client_id = c.id - AND cp.campaign_id > 0 - AND ( cp.campaign_name IS NULL OR cp.campaign_name <> '--- konto ---' ) - ) - ) - ORDER BY c.id ASC" ) -> fetchAll( \PDO::FETCH_COLUMN ); - $client_ids = array_values( array_unique( array_map( 'intval', $client_ids ) ) ); - - if ( empty( $client_ids ) ) - { - echo json_encode( [ 'result' => 'Brak klientow z ustawionym Google Ads Customer ID do przetworzenia.', 'processed_clients' => 0, 'errors' => [] ] ); - exit; - } - - self::ensure_sync_rows( 'products', $import_dates, $client_ids ); - - $active_client_id = self::get_active_client( 'products' ); - - if ( !$active_client_id ) - { - echo json_encode( [ - 'result' => 'Pipeline cron_products jest zakonczony dla calego okna dat.', - 'date' => $date, - 'conversion_window_days' => $conversion_window_days, - 'dates_synced' => $import_dates, - 'phase' => 'done', - 'total_clients' => count( $client_ids ) - ] ); - exit; - } - - $source_phase = self::determine_client_products_phase( $active_client_id ); - - if ( !$source_phase ) - { - echo json_encode( [ - 'result' => 'Brak dat do przetworzenia dla klienta ID: ' . $active_client_id, - 'date' => $date, - 'active_client_id' => $active_client_id, - 'phase' => 'done', - 'total_clients' => count( $client_ids ) - ] ); - exit; - } - - $phase_next_map = [ - 'pending' => 'fetch', - 'fetch' => 'aggregate_30', - 'aggregate_30' => 'done' - ]; - $next_phase = $phase_next_map[ $source_phase ] ?? 'done'; - - $dates_per_run_default = (int) ( $settings['cron_products_clients_per_run'] ?? 10 ); - if ( $dates_per_run_default <= 0 ) - { - $dates_per_run_default = 10; - } - - $dates_per_run = (int) \S::get( 'clients_per_run' ); - if ( $dates_per_run <= 0 ) - { - $dates_per_run = $dates_per_run_default; - } - $dates_per_run = min( 100, $dates_per_run ); - - $dates_batch = self::get_pending_dates_for_client( 'products', $active_client_id, $source_phase, $dates_per_run ); - - if ( empty( $dates_batch ) ) - { - echo json_encode( [ - 'result' => 'Faza zakonczona dla klienta. Kolejne wywolanie przejdzie do nastepnej fazy lub klienta.', - 'date' => $date, - 'active_client_id' => $active_client_id, - 'conversion_window_days' => $conversion_window_days, - 'phase' => $source_phase, - 'total_clients' => count( $client_ids ) - ] ); - exit; - } - - $selected_client = $mdb -> get( 'clients', '*', [ 'id' => $active_client_id ] ); - if ( !$selected_client ) - { - echo json_encode( [ - 'result' => 'Nie udalo sie znalezc klienta do synchronizacji produktow. ID: ' . $active_client_id, - 'active_client_id' => $active_client_id, - 'errors' => [ 'Klient ID ' . $active_client_id . ' nie znaleziony.' ] - ] ); - exit; - } - - $dates_processed_in_call = []; - $errors = []; - $processed_products_total = 0; - $skipped_total = 0; - $fetch_skipped_reasons = []; - $history_30_products_total = 0; - $products_temp_rows_total = 0; - - if ( $source_phase === 'aggregate_30' ) - { - $products_temp_rows_total += (int) self::rebuild_products_temp_for_client( $active_client_id ); - - foreach ( $dates_batch as $active_date ) - { - self::mark_sync_phase( 'products', $active_date, $active_client_id, 'done' ); - $dates_processed_in_call[] = $active_date; - } - } - else - { - foreach ( $dates_batch as $active_date ) - { - $error_msg = null; - - if ( $source_phase === 'pending' ) - { - $sync = self::sync_products_fetch_for_client( $selected_client, $api, $active_date ); - $processed_products_total += (int) ( $sync['processed_products'] ?? 0 ); - $skipped_total += (int) ( $sync['skipped'] ?? 0 ); - $fetch_skipped_reason = trim( (string) ( $sync['fetch_skipped_reason'] ?? '' ) ); - if ( $fetch_skipped_reason !== '' ) - { - $fetch_skipped_reasons[ $fetch_skipped_reason ] = true; - } - if ( !empty( $sync['errors'] ) ) - { - $errors = array_merge( $errors, (array) $sync['errors'] ); - $error_msg = implode( '; ', (array) $sync['errors'] ); - } - } - else if ( $source_phase === 'fetch' ) - { - $history_30_products_total += (int) self::aggregate_products_history_30_for_client( $active_client_id, $active_date ); - } - - self::mark_sync_phase( 'products', $active_date, $active_client_id, $next_phase, $error_msg ); - $dates_processed_in_call[] = $active_date; - } - } - - $done_count = (int) $mdb -> query( - "SELECT COUNT(*) FROM cron_sync_status cs - INNER JOIN clients c ON cs.client_id = c.id AND c.deleted = 0 - WHERE cs.pipeline = 'products' AND cs.client_id = :client_id AND cs.phase = 'done' - AND cs.sync_date >= DATE_SUB(CURDATE(), INTERVAL 90 DAY)", - [ ':client_id' => $active_client_id ] - ) -> fetchColumn(); - $total_count = (int) $mdb -> query( - "SELECT COUNT(*) FROM cron_sync_status cs - INNER JOIN clients c ON cs.client_id = c.id AND c.deleted = 0 - WHERE cs.pipeline = 'products' AND cs.client_id = :client_id - AND cs.sync_date >= DATE_SUB(CURDATE(), INTERVAL 90 DAY)", - [ ':client_id' => $active_client_id ] - ) -> fetchColumn(); - - $remaining_dates = max( 0, $total_count - $done_count ); - $estimated_calls_remaining = (int) ceil( $remaining_dates / max( 1, $dates_per_run ) ); - - $phase_labels = [ - 'pending' => 'fetch', - 'fetch' => 'aggregate_30', - 'aggregate_30' => 'aggregate_temp' - ]; - $display_phase = $phase_labels[ $source_phase ] ?? $source_phase; - - $result_text = 'Brak dat do przetworzenia w tej fazie.'; - if ( $source_phase === 'pending' ) - { - $result_text = empty( $errors ) ? 'Pobieranie produktow zakonczone.' : 'Pobieranie produktow zakonczone z bledami.'; - } - else if ( $source_phase === 'fetch' ) - { - $result_text = 'Pierwsza agregacja (history_30) zakonczona.'; - } - else if ( $source_phase === 'aggregate_30' ) - { - $result_text = 'Druga agregacja (products_temp) zakonczona.'; - } - - echo json_encode( [ - 'result' => $result_text, - 'date' => $date, - 'active_client_id' => $active_client_id, - 'conversion_window_days' => $conversion_window_days, - 'dates_synced' => $import_dates, - 'phase' => $display_phase, - 'dates_per_run' => $dates_per_run, - 'dates_processed_in_call' => count( $dates_processed_in_call ), - 'processed_dates' => $dates_processed_in_call, - 'remaining_dates' => $remaining_dates, - 'estimated_calls_remaining' => $estimated_calls_remaining, - 'total_clients' => count( $client_ids ), - 'processed_products' => $processed_products_total, - 'skipped' => $skipped_total, - 'fetch_skipped_reasons' => array_keys( $fetch_skipped_reasons ), - 'history_30_products' => $history_30_products_total, - 'products_temp_rows' => $products_temp_rows_total, - 'errors' => $errors - ] ); - exit; - } - static public function cron_products_urls() { global $mdb, $settings; @@ -2065,63 +1798,6 @@ class Cron } // products_temp zostalo wycofane. - // Zachowujemy logike aktualizacji custom_label_4 oparta o products_aggregate. - $client_bestseller_min_roas = (int) \factory\Products::get_client_bestseller_min_roas( $client_id ); - - $global_totals = $mdb -> query( - 'SELECT - pa.product_id, - COALESCE( SUM( pa.cost_all_time ), 0 ) AS cost, - COALESCE( SUM( pa.conversions_all_time ), 0 ) AS conversions, - COALESCE( SUM( pa.conversion_value_all_time ), 0 ) AS conversions_value - FROM products_aggregate AS pa - INNER JOIN products AS p ON p.id = pa.product_id - WHERE p.client_id = :client_id - GROUP BY pa.product_id', - [ ':client_id' => $client_id ] - ) -> fetchAll( \PDO::FETCH_ASSOC ); - - foreach ( (array) $global_totals as $total ) - { - $product_id = (int) ( $total['product_id'] ?? 0 ); - if ( $product_id <= 0 ) - { - continue; - } - - $total_cost = (float) ( $total['cost'] ?? 0 ); - $total_conversions = (float) ( $total['conversions'] ?? 0 ); - $total_conversion_value = (float) ( $total['conversions_value'] ?? 0 ); - $total_roas = ( $total_conversions > 0 && $total_cost > 0 ) ? round( $total_conversion_value / $total_cost, 2 ) * 100 : 0; - - $custom_label_4 = (string) $mdb -> get( 'products', 'custom_label_4', [ 'id' => $product_id ] ); - if ( $custom_label_4 == null || ( $custom_label_4 == 'bestseller' && $client_bestseller_min_roas > 0 ) ) - { - $new_custom_label_4 = ( $total_roas > $client_bestseller_min_roas && $total_conversions > 10 ) ? 'bestseller' : null; - - $old_custom_label_4 = (string) $custom_label_4; - if ( $old_custom_label_4 !== (string) $new_custom_label_4 ) - { - $mdb -> update( 'products', [ 'custom_label_4' => $new_custom_label_4 ], [ 'id' => $product_id ] ); - \factory\Products::set_product_data( $product_id, 'custom_label_4', $new_custom_label_4 ); - - $mdb -> insert( 'products_comments', [ - 'product_id' => $product_id, - 'comment' => 'Zmiana pola "custom_label_4" na: ' . (string) $new_custom_label_4, - 'type' => 1, - 'date_add' => date( 'Y-m-d' ) - ] ); - - \controls\Products::sync_product_fields_to_merchant( $product_id, [ - 'custom_label_4' => [ - 'old' => $old_custom_label_4, - 'new' => (string) $new_custom_label_4 - ] - ], 'cron_products' ); - } - } - } - // Zwracamy liczbe scope z tabeli products_aggregate (dla diagnostyki odpowiedzi cron). return (int) $mdb -> query( 'SELECT COUNT(*) @@ -2132,53 +1808,6 @@ class Cron ) -> fetchColumn(); } - static public function cron_products_history_30() - { - global $mdb; - self::touch_cron_invocation( __FUNCTION__ ); - - $start_time = microtime(true); - - $client_id = \S::get( 'client_id' ); - - if ( !$client_id ) - { - echo json_encode( [ 'result' => "Nie podano ID klienta." ] ); - exit; - } - - if ( !$mdb -> count( 'clients', [ 'id' => $client_id ] ) ) - { - echo json_encode( [ 'result' => "Nie znaleziono klienta o podanym ID.", "client" => "Nie istnieje" ] ); - exit; - } - - $products = $mdb -> select( 'products', 'id', [ 'client_id' => $client_id ] ); - foreach ( $products as $product ) - { - $scopes = $mdb -> query( 'SELECT DISTINCT campaign_id, ad_group_id, date_add FROM products_history WHERE product_id = ' . $product . ' AND campaign_id > 0 AND updated = 1 ORDER BY date_add DESC' ) -> fetchAll( \PDO::FETCH_ASSOC ); - foreach ( $scopes as $scope ) - { - $campaign_id = (int) ( $scope['campaign_id'] ?? 0 ); - $ad_group_id = (int) ( $scope['ad_group_id'] ?? 0 ); - $date_add = $scope['date_add'] ?? ''; - - self::cron_product_history_30_save( $product, $date_add, $campaign_id, $ad_group_id ); - $mdb -> update( 'products_history', [ 'updated' => 0 ], [ 'AND' => [ - 'product_id' => $product, - 'campaign_id' => $campaign_id, - 'ad_group_id' => $ad_group_id, - 'date_add' => $date_add - ] ] ); - } - } - - $end_time = microtime(true); - $execution_time = $end_time - $start_time; - echo json_encode( [ 'result' => "Agregacja zakonczona, dane zapisane do offers_history_30. Czas wykonania skryptu: " . round($execution_time, 4) . " sekund." ] ); - exit; - } - static public function get_roas_all_time( $product_id, $date_to, $campaign_id = 0, $ad_group_id = 0 ) { global $mdb; @@ -2332,580 +1961,6 @@ class Cron } } - static public function cron_xml() - { - $result = self::generate_custom_feed_for_client( \S::get( 'client_id' ), true ); - - if ( ( $result['status'] ?? '' ) !== 'ok' ) - { - $response = [ 'result' => $result['message'] ?? 'Nie udalo sie wygenerowac pliku XML.' ]; - if ( !empty( $result['client'] ) ) - { - $response['client'] = $result['client']; - } - echo json_encode( $response ); - exit; - } - - $url = (string) ( $result['url'] ?? '' ); - echo json_encode( [ 'result' => 'Plik XML zostal wygenerowany ' . $url . '.' ] ); - exit; - } - - static public function generate_custom_feed_for_client( $client_id, $touch_invocation = true ) - { - global $mdb; - - $client_id = (int) $client_id; - - if ( $touch_invocation ) - { - self::touch_cron_invocation( 'cron_xml' ); - } - - if ( $client_id <= 0 ) - { - return [ 'status' => 'error', 'message' => 'Nie podano ID klienta.' ]; - } - - if ( !$mdb -> count( 'clients', [ 'id' => $client_id ] ) ) - { - return [ 'status' => 'error', 'message' => 'Nie znaleziono klienta o podanym ID.', 'client' => 'Nie istnieje' ]; - } - - $results = $mdb -> query( - 'SELECT - p.id AS product_id, - p.offer_id, - p.title, - p.description, - p.custom_label_4, - p.custom_label_3, - p.google_product_category, - p.product_url - FROM products AS p - WHERE p.client_id = :client_id', - [ ':client_id' => $client_id ] - ) -> fetchAll( \PDO::FETCH_ASSOC ); - - if ( empty( $results ) ) - { - return [ 'status' => 'error', 'message' => 'Brak produktow do wygenerowania pliku XML.' ]; - } - - $doc = new \DOMDocument( '1.0', 'UTF-8' ); - $xmlRoot = $doc -> createElement( 'rss' ); - $xmlRoot = $doc -> appendChild( $xmlRoot ); - $xmlRoot -> setAttribute( 'version', '2.0' ); - $xmlRoot -> setAttributeNS( 'http://www.w3.org/2000/xmlns/', 'xmlns:g', 'http://base.google.com/ns/1.0' ); - - $channelNode = $xmlRoot -> appendChild( $doc -> createElement( 'channel' ) ); - $channelNode -> appendChild( $doc -> createElement( 'title', 'Custom Feed' ) ); - $channelNode -> appendChild( $doc -> createElement( 'link', 'https://ads.pagedev.pl' ) ); - - $fieldMappings = [ - 'title' => 'g:title', - 'description' => 'g:description', - 'custom_label_4' => 'g:custom_label_4', - 'custom_label_3' => 'g:custom_label_3', - 'google_product_category' => 'g:google_product_category' - ]; - - foreach ( $results as $row ) - { - $hasValidField = false; - foreach ( $fieldMappings as $dbField => $xmlTag ) - { - if ( !empty( $row[ $dbField ] ) ) - { - $hasValidField = true; - break; - } - } - - if ( !$hasValidField ) - { - continue; - } - - $itemNode = $channelNode -> appendChild( $doc -> createElement( 'item' ) ); - - $offer_id = $mdb -> get( 'products', 'offer_id', [ 'id' => $row['product_id'] ] ); - $offer_id = str_replace( 'shopify_pl', 'shopify_PL', $offer_id ); - $itemNode -> appendChild( $doc -> createElement( 'id', $offer_id ) ); - - foreach ( $fieldMappings as $dbField => $xmlTag ) - { - if ( !empty( $row[ $dbField ] ) ) - { - $itemNode -> appendChild( $doc -> createElement( $xmlTag, $row[ $dbField ] ) ); - } - } - } - - $xml_dir = dirname( __DIR__, 2 ) . DIRECTORY_SEPARATOR . 'xml'; - if ( !is_dir( $xml_dir ) ) - { - @mkdir( $xml_dir, 0777, true ); - } - - $file_path = $xml_dir . DIRECTORY_SEPARATOR . 'custom-feed-' . $client_id . '.xml'; - $save_result = @file_put_contents( $file_path, $doc -> saveXML() ); - if ( $save_result === false ) - { - return [ 'status' => 'error', 'message' => 'Nie udalo sie zapisac pliku XML na serwerze.' ]; - } - - $scheme = ( !empty( $_SERVER['HTTPS'] ) && $_SERVER['HTTPS'] !== 'off' ) ? 'https' : 'http'; - $host = $_SERVER['HTTP_HOST'] ?? ( $_SERVER['SERVER_NAME'] ?? 'localhost' ); - $url = $scheme . '://' . $host . '/xml/custom-feed-' . $client_id . '.xml'; - - return [ - 'status' => 'ok', - 'message' => 'Plik XML zostal wygenerowany.', - 'url' => $url, - 'client_id' => $client_id - ]; - } - - static public function cron_phrases() - { - global $mdb; - self::touch_cron_invocation( __FUNCTION__ ); - - if ( !$client_id = \S::get( 'client_id' ) ) - { - echo json_encode( [ 'result' => "Nie podano ID klienta." ] ); - exit; - } - - if ( !$mdb -> count( 'clients', [ 'id' => $client_id ] ) ) - { - echo json_encode( [ 'result' => "Nie znaleziono klienta o podanym ID.", "client" => "Nie istnieje" ] ); - exit; - } - - $data = $mdb -> query( 'SELECT * FROM phrases AS p INNER JOIN phrases_history AS ph ON p.id = ph.phrase_id WHERE p.client_id = ' . $client_id ) -> fetchAll( \PDO::FETCH_ASSOC ); - - $aggregated_data = []; - - foreach ( $data as $row ) - { - $phrase_id = $row['phrase_id']; - - if ( !isset( $aggregated_data[$client_id] ) ) - { - $aggregated_data[$client_id] = []; - } - - if ( !isset( $aggregated_data[$client_id][$phrase_id] ) ) - { - $aggregated_data[$client_id][$phrase_id] = [ - 'phrase_id' => $phrase_id, - 'phrase' => $row['phrase'], - 'impressions' => 0, - 'clicks' => 0, - 'cost' => 0.0, - 'conversions' => 0, - 'conversions_value' => 0.0 - ]; - } - - $aggregated_data[$client_id][$phrase_id]['impressions'] += $row['impressions']; - $aggregated_data[$client_id][$phrase_id]['clicks'] += $row['clicks']; - $aggregated_data[$client_id][$phrase_id]['cost'] += $row['cost']; - $aggregated_data[$client_id][$phrase_id]['conversions'] += $row['conversions']; - $aggregated_data[$client_id][$phrase_id]['conversions_value'] += $row['conversions_value']; - } - - $phrases_ids = $mdb -> select( 'phrases', 'id', [ 'client_id' => $client_id ] ); - foreach ( $phrases_ids as $phrase_id ) - { - $phrases_ids_array[] = $phrase_id -> id; - } - - $mdb -> delete( 'phrases_temp', [ 'phrase_id' => $phrases_ids_array ] ); - - foreach ( $aggregated_data as $client_phrases ) - { - foreach ( $client_phrases as $phrase_data ) - { - $cpc = $phrase_data['clicks'] > 0 ? round( $phrase_data['cost'] / $phrase_data['clicks'], 6 ) : 0; - $roas = ( $phrase_data['conversions'] > 0 and $phrase_data['cost'] ) ? round( $phrase_data['conversions_value'] / $phrase_data['cost'], 2 ) * 100 : 0; - - $mdb -> insert( 'phrases_temp', [ - 'phrase_id' => $phrase_data['phrase_id'], - 'phrase' => $phrase_data['phrase'], - 'impressions' => $phrase_data['impressions'], - 'clicks' => $phrase_data['clicks'], - 'cost' => $phrase_data['cost'], - 'conversions' => $phrase_data['conversions'], - 'conversions_value' => $phrase_data['conversions_value'], - 'cpc' => $cpc, - 'roas' => $roas, - ] ); - } - } - - echo json_encode( [ 'result' => "Agregacja zakonczona, dane zapisane do phrases_temp." ] ); - exit; - } - - static public function cron_phrases_history_30() - { - global $mdb; - self::touch_cron_invocation( __FUNCTION__ ); - - $start_time = microtime( true ); // Rozpoczcie mierzenia czasu - - $client_id = \S::get( 'client_id' ); // Pobranie ID klienta - - if ( !$client_id ) // Jeli nie podano ID klienta - { - echo json_encode( [ 'result' => "Nie podano ID klienta." ] ); // Wyswietlenie komunikatu - exit; // Zakonczenie dziaania skryptu - } - - if ( !$mdb -> count( 'clients', [ 'id' => $client_id ] ) ) // Sprawdzenie, czy klient istnieje - { - echo json_encode( [ 'result' => "Nie znaleziono klienta o podanym ID.", "client" => "Nie istnieje" ] ); // Wyswietlenie komunikatu - exit; // Zakonczenie dziaania skryptu - } - - // Pobranie biecej daty i daty sprzed 30 dni - $phrases = $mdb -> query( 'SELECT * FROM phrases WHERE client_id = ' . $client_id ) -> fetchAll( \PDO::FETCH_ASSOC ); // Pobranie fraz dla danego klienta - - foreach ( $phrases as $phrase ) - { - for ( $i = 0; $i < 30; $i++ ) - { - $date_to = date( 'Y-m-d', strtotime( '-' . ( 1 + $i ) . ' days' ) ); - $date_from = date( 'Y-m-d', strtotime( '-' . ( 31 + $i ) . ' days' ) ); - - $data_updated = false; - - if ( $mdb -> count( 'phrases_history', [ 'AND' => [ 'phrase_id' => $phrase['id'], 'date_add[>=]' => $date_from, 'date_add[<=]' => $date_to, 'updated' => 1 ] ] ) > 0 ) - { - $data_updated = true; - } - - if ( $data_updated ) - { - self::cron_phrase_history_30_save( $phrase['id'], $date_from, $date_to ); - } - } - - $mdb -> update( 'phrases_history', [ 'updated' => 0 ], [ 'AND' => [ 'phrase_id' => $phrase['id'], 'updated' => 1 ] ] ); - } - - $end_time = microtime( true ); // Zakonczenie mierzenia czasu - $execution_time = $end_time - $start_time; // Obliczenie czasu wykonania - - echo json_encode( [ 'result' => "Agregacja zakonczona, dane zapisane do phrases_history_30. Czas wykonania skryptu: " . round( $execution_time, 4 ) . " sekund.", 'client' => \factory\Campaigns::get_client_name( $client_id ) ] ); // Wyswietlenie komunikatu - exit; - } - - // =========================== - // KAMPANIE - Google Ads API - // =========================== - - static public function cron_campaigns() - { - global $mdb, $settings; - self::touch_cron_invocation( __FUNCTION__ ); - $clients_not_deleted_sql = self::sql_clients_not_deleted(); - $clients_not_deleted_sql_c = self::sql_clients_not_deleted( 'c' ); - $clients_deleted_sql_c = self::sql_clients_deleted( 'c' ); - - $api = new \services\GoogleAdsApi(); - if ( !$api -> is_configured() ) - { - self::output_cron_response( [ 'result' => 'Google Ads API nie jest skonfigurowane. Uzupelnij dane w Ustawieniach.' ] ); - } - - $conversion_window_days = self::get_conversion_window_days( true ); - $default_end_date = date( 'Y-m-d', strtotime( '-2 days' ) ); - $requested_date_raw = trim( (string) \S::get( 'date' ) ); - $requested_ts = $requested_date_raw !== '' ? strtotime( $requested_date_raw ) : false; - $sync_date_raw = $requested_ts ? date( 'Y-m-d', $requested_ts ) : $default_end_date; - $sync_date = ( strtotime( $sync_date_raw ) > strtotime( $default_end_date ) ) ? $default_end_date : $sync_date_raw; - if ( !$sync_date || strtotime( $sync_date ) === false ) - { - $sync_date = $default_end_date; - } - - $sync_dates = self::build_backfill_dates( $sync_date, $conversion_window_days ); - $client_id = (int) \S::get( 'client_id' ); - - if ( $client_id > 0 ) - { - $client = $mdb -> query( - "SELECT * - FROM clients - WHERE id = :id - AND COALESCE(active, 0) = 1 - AND " . $clients_not_deleted_sql . " - LIMIT 1", - [ ':id' => $client_id ] - ) -> fetch( \PDO::FETCH_ASSOC ); - - if ( !$client || trim( (string) ( $client['google_ads_customer_id'] ?? '' ) ) === '' ) - { - self::output_cron_response( [ 'result' => 'Nie znaleziono aktywnego klienta z poprawnym Google Ads Customer ID.', 'client_id' => $client_id ] ); - } - - $processed_records_total = 0; - $ad_groups_synced_total = 0; - $search_terms_history_synced_total = 0; - $search_terms_aggregated_total = 0; - $keywords_synced_total = 0; - $negative_keywords_synced_total = 0; - $errors = []; - $processed_dates = []; - - $dates_processed_in_call = [ $sync_date ]; - foreach ( $dates_processed_in_call as $active_date ) - { - $sync = self::sync_campaigns_snapshot_for_client( $client, $api, $active_date ); - $processed_records_total += (int) ( $sync['processed_records'] ?? 0 ); - $ad_groups_synced_total += (int) ( $sync['ad_groups_synced'] ?? 0 ); - if ( !empty( $sync['errors'] ) ) - { - $errors = array_merge( $errors, (array) $sync['errors'] ); - } - $processed_dates[] = $active_date; - } - - if ( empty( $errors ) ) - { - $terms_sync = self::sync_campaign_terms_backfill_for_client( - (int) $client['id'], - (string) ( $client['google_ads_customer_id'] ?? '' ), - $api, - $dates_processed_in_call - ); - $search_terms_history_synced_total = (int) ( $terms_sync['history_synced'] ?? 0 ); - $search_terms_aggregated_total = (int) ( $terms_sync['aggregated'] ?? 0 ); - if ( !empty( $terms_sync['errors'] ) ) - { - $errors = array_merge( $errors, (array) $terms_sync['errors'] ); - } - } - - $last_day_in_window = end( $sync_dates ); - if ( empty( $errors ) && $last_day_in_window && $sync_date === $last_day_in_window ) - { - $kw_sync = self::sync_campaign_keywords_and_negatives_for_client( - (int) $client['id'], - (string) ( $client['google_ads_customer_id'] ?? '' ), - $api, - $sync_date - ); - $keywords_synced_total = (int) ( $kw_sync['keywords_synced'] ?? 0 ); - $negative_keywords_synced_total = (int) ( $kw_sync['negative_keywords_synced'] ?? 0 ); - if ( !empty( $kw_sync['errors'] ) ) - { - $errors = array_merge( $errors, (array) $kw_sync['errors'] ); - } - } - - self::output_cron_response( [ - 'result' => empty( $errors ) ? 'Synchronizacja kampanii zakonczona.' : 'Synchronizacja kampanii zakonczona z bledami.', - 'client_id' => (int) $client['id'], - 'active_date' => $sync_date, - 'conversion_window_days' => $conversion_window_days, - 'dates_synced' => $sync_dates, - 'processed_dates' => $processed_dates, - 'processed_records' => $processed_records_total, - 'ad_groups_synced' => $ad_groups_synced_total, - 'search_terms_history_synced' => $search_terms_history_synced_total, - 'search_terms_aggregated' => $search_terms_aggregated_total, - 'keywords_synced' => $keywords_synced_total, - 'negative_keywords_synced' => $negative_keywords_synced_total, - 'errors' => $errors - ] ); - } - - self::cleanup_old_sync_rows( 30 ); - $mdb -> query( - "DELETE cs FROM cron_sync_status cs - LEFT JOIN clients c ON cs.client_id = c.id - WHERE cs.pipeline = 'campaigns' - AND ( c.id IS NULL OR " . $clients_deleted_sql_c . " OR COALESCE(c.active, 0) <> 1 )" - ); - - $client_ids = $mdb -> query( - "SELECT id - FROM clients - WHERE " . $clients_not_deleted_sql . " - AND COALESCE(active, 0) = 1 - AND TRIM(COALESCE(google_ads_customer_id, '')) <> '' - ORDER BY id ASC" - ) -> fetchAll( \PDO::FETCH_COLUMN ); - $client_ids = array_values( array_unique( array_map( 'intval', $client_ids ) ) ); - - if ( empty( $client_ids ) ) - { - self::output_cron_response( [ 'result' => 'Brak aktywnych klientow z ustawionym Google Ads Customer ID.' ] ); - } - - self::ensure_sync_rows( 'campaigns', $sync_dates, $client_ids ); - self::cleanup_pipeline_rows_outside_window( 'campaigns', $sync_dates ); - - $active_client_id = self::get_active_client( 'campaigns' ); - if ( !$active_client_id ) - { - self::output_cron_response( [ - 'result' => 'Wszyscy aktywni klienci kampanii zostali przetworzeni dla calego okna dat.', - 'active_date' => $sync_date, - 'conversion_window_days' => $conversion_window_days, - 'dates_synced' => $sync_dates, - 'processed_clients' => count( $client_ids ), - 'total_clients' => count( $client_ids ) - ] ); - } - - $dates_per_run = 1; - $dates_batch = self::get_pending_dates_for_client( 'campaigns', $active_client_id, 'pending', 1 ); - if ( empty( $dates_batch ) ) - { - self::output_cron_response( [ - 'result' => 'Wszystkie daty klienta przetworzone. Kolejne wywolanie przejdzie do nastepnego klienta.', - 'active_client_id' => $active_client_id - ] ); - } - - $selected_client = $mdb -> query( - "SELECT * - FROM clients - WHERE id = :id - AND COALESCE(active, 0) = 1 - AND " . $clients_not_deleted_sql . " - LIMIT 1", - [ ':id' => $active_client_id ] - ) -> fetch( \PDO::FETCH_ASSOC ); - if ( !$selected_client || trim( (string) ( $selected_client['google_ads_customer_id'] ?? '' ) ) === '' ) - { - self::output_cron_response( [ - 'result' => 'Nie udalo sie znalezc aktywnego klienta do synchronizacji kampanii. ID: ' . $active_client_id, - 'active_client_id' => $active_client_id, - 'errors' => [ 'Klient ID ' . $active_client_id . ' nie znaleziony lub nieaktywny.' ] - ] ); - } - - $dates_processed_in_call = []; - $errors = []; - $processed_records_total = 0; - $ad_groups_synced_total = 0; - $search_terms_history_synced_total = 0; - $search_terms_aggregated_total = 0; - $keywords_synced_total = 0; - $negative_keywords_synced_total = 0; - - foreach ( $dates_batch as $active_date ) - { - $sync = self::sync_campaigns_snapshot_for_client( $selected_client, $api, $active_date ); - $processed_records_total += (int) ( $sync['processed_records'] ?? 0 ); - $ad_groups_synced_total += (int) ( $sync['ad_groups_synced'] ?? 0 ); - - $error_msg = null; - if ( !empty( $sync['errors'] ) ) - { - $errors = array_merge( $errors, (array) $sync['errors'] ); - } - $dates_processed_in_call[] = $active_date; - } - - if ( empty( $errors ) ) - { - $terms_sync = self::sync_campaign_terms_backfill_for_client( - (int) $selected_client['id'], - (string) ( $selected_client['google_ads_customer_id'] ?? '' ), - $api, - $dates_batch - ); - $search_terms_history_synced_total = (int) ( $terms_sync['history_synced'] ?? 0 ); - $search_terms_aggregated_total = (int) ( $terms_sync['aggregated'] ?? 0 ); - if ( !empty( $terms_sync['errors'] ) ) - { - $errors = array_merge( $errors, (array) $terms_sync['errors'] ); - } - } - - $pending_dates_before_finalize = self::count_pending_campaign_dates_for_client( $active_client_id ); - $is_last_pending_chunk_for_client = $pending_dates_before_finalize <= count( $dates_batch ); - if ( empty( $errors ) && $is_last_pending_chunk_for_client ) - { - $kw_sync = self::sync_campaign_keywords_and_negatives_for_client( - (int) $selected_client['id'], - (string) ( $selected_client['google_ads_customer_id'] ?? '' ), - $api, - (string) ( $dates_batch[ count( $dates_batch ) - 1 ] ?? $sync_date ) - ); - $keywords_synced_total = (int) ( $kw_sync['keywords_synced'] ?? 0 ); - $negative_keywords_synced_total = (int) ( $kw_sync['negative_keywords_synced'] ?? 0 ); - if ( !empty( $kw_sync['errors'] ) ) - { - $errors = array_merge( $errors, (array) $kw_sync['errors'] ); - } - } - - $final_phase = empty( $errors ) ? 'done' : 'pending'; - $final_error = empty( $errors ) ? null : implode( '; ', array_values( array_unique( array_map( 'strval', $errors ) ) ) ); - foreach ( $dates_batch as $active_date ) - { - self::mark_sync_phase( 'campaigns', $active_date, $active_client_id, $final_phase, $final_error ); - } - - $done_count = (int) $mdb -> query( - "SELECT COUNT(*) - FROM cron_sync_status cs - INNER JOIN clients c ON cs.client_id = c.id - WHERE cs.pipeline = 'campaigns' - AND cs.client_id = :client_id - AND cs.phase = 'done' - AND " . $clients_not_deleted_sql_c . " - AND COALESCE(c.active, 0) = 1 - AND cs.sync_date >= DATE_SUB(CURDATE(), INTERVAL 90 DAY)", - [ ':client_id' => $active_client_id ] - ) -> fetchColumn(); - $total_count = (int) $mdb -> query( - "SELECT COUNT(*) - FROM cron_sync_status cs - INNER JOIN clients c ON cs.client_id = c.id - WHERE cs.pipeline = 'campaigns' - AND cs.client_id = :client_id - AND " . $clients_not_deleted_sql_c . " - AND COALESCE(c.active, 0) = 1 - AND cs.sync_date >= DATE_SUB(CURDATE(), INTERVAL 90 DAY)", - [ ':client_id' => $active_client_id ] - ) -> fetchColumn(); - - $remaining_dates = max( 0, $total_count - $done_count ); - $estimated_calls_remaining = (int) ceil( $remaining_dates / max( 1, $dates_per_run ) ); - - self::output_cron_response( [ - 'result' => empty( $errors ) ? 'Synchronizacja kampanii zakonczona.' : 'Synchronizacja kampanii zakonczona z bledami.', - 'active_client_id' => $active_client_id, - 'dates_per_run' => $dates_per_run, - 'dates_processed_in_call' => count( $dates_processed_in_call ), - 'processed_dates' => $dates_processed_in_call, - 'remaining_dates' => $remaining_dates, - 'estimated_calls_remaining' => $estimated_calls_remaining, - 'active_date' => $sync_date, - 'conversion_window_days' => $conversion_window_days, - 'dates_synced' => $sync_dates, - 'processed_records' => $processed_records_total, - 'ad_groups_synced' => $ad_groups_synced_total, - 'search_terms_history_synced' => $search_terms_history_synced_total, - 'search_terms_aggregated' => $search_terms_aggregated_total, - 'keywords_synced' => $keywords_synced_total, - 'negative_keywords_synced' => $negative_keywords_synced_total, - 'total_clients' => count( $client_ids ), - 'errors' => $errors - ] ); - } - // ARCHIWUM: stara wersja cron_campaigns pozostawiona do porownan i ewentualnego rollbacku. static public function cron_campaigns_archive() { @@ -3287,456 +2342,6 @@ class Cron return $map; } - static public function cron_clients_bundle() - { - global $mdb; - self::touch_cron_invocation( __FUNCTION__ ); - - $api = new \services\GoogleAdsApi(); - if ( !$api -> is_configured() ) - { - echo json_encode( [ 'result' => 'Google Ads API nie jest skonfigurowane. Uzupelnij dane w Ustawieniach.' ] ); - exit; - } - - $sync_date_raw = \S::get( 'date' ) ? date( 'Y-m-d', strtotime( \S::get( 'date' ) ) ) : date( 'Y-m-d' ); - $today_date = date( 'Y-m-d' ); - $yesterday_date = date( 'Y-m-d', strtotime( '-1 days', strtotime( $today_date ) ) ); - $sync_date = ( strtotime( $sync_date_raw ) >= strtotime( $today_date ) ) ? $yesterday_date : $sync_date_raw; - if ( strtotime( $sync_date ) === false ) - { - $sync_date = $yesterday_date; - } - - $conversion_window_days = (int) \S::get( 'window_days' ); - if ( $conversion_window_days <= 0 ) - { - $conversion_window_days = (int) self::get_setting_value( 'cron_clients_bundle_window_days', 7 ); - } - if ( $conversion_window_days <= 0 ) - { - $conversion_window_days = 7; - } - $conversion_window_days = min( 90, max( 1, $conversion_window_days ) ); - $sync_dates = self::build_backfill_dates( $sync_date, $conversion_window_days ); - if ( empty( $sync_dates ) ) - { - $sync_dates = [ $sync_date ]; - } - $dates_total = count( $sync_dates ); - - $forced_client_id = (int) \S::get( 'client_id' ); - $reset_state = (int) \S::get( 'reset' ) === 1; - $max_stage_retries = (int) \S::get( 'max_retries' ); - if ( $max_stage_retries <= 0 ) - { - $max_stage_retries = (int) self::get_setting_value( 'cron_clients_bundle_max_stage_retries', 3 ); - } - if ( $max_stage_retries <= 0 ) - { - $max_stage_retries = 3; - } - $max_stage_retries = min( 20, $max_stage_retries ); - - $where = "deleted = 0 - AND google_ads_customer_id IS NOT NULL - AND google_ads_customer_id <> ''"; - if ( $forced_client_id > 0 ) - { - $where .= ' AND id = ' . $forced_client_id; - } - - $clients = $mdb -> query( - "SELECT id, name, google_ads_customer_id, google_merchant_account_id - FROM clients - WHERE " . $where . " - ORDER BY id ASC" - ) -> fetchAll( \PDO::FETCH_ASSOC ); - - if ( !is_array( $clients ) || empty( $clients ) ) - { - self::set_setting_value( 'cron_clients_bundle_stage', 'campaigns' ); - self::set_setting_value( 'cron_clients_bundle_client_id', '0' ); - self::set_setting_value( 'cron_clients_bundle_date_index', '0' ); - - echo json_encode( [ - 'result' => 'Brak klientow z Google Ads Customer ID do przetworzenia.', - 'date' => $sync_date, - 'processed_stage' => 'none', - 'errors' => [] - ] ); - exit; - } - - $stage_order = [ 'campaigns', 'products', 'merchant' ]; - $saved_stage = trim( (string) self::get_setting_value( 'cron_clients_bundle_stage', 'campaigns' ) ); - if ( !in_array( $saved_stage, $stage_order, true ) ) - { - $saved_stage = 'campaigns'; - } - - $saved_client_id = (int) self::get_setting_value( 'cron_clients_bundle_client_id', 0 ); - $saved_date_index = (int) self::get_setting_value( 'cron_clients_bundle_date_index', 0 ); - $client_ids = array_values( array_map( function( $row ) - { - return (int) ( $row['id'] ?? 0 ); - }, (array) $clients ) ); - - self::cleanup_old_sync_rows( 30 ); - self::ensure_sync_rows( 'campaigns', $sync_dates, $client_ids ); - self::ensure_sync_rows( 'products', $sync_dates, $client_ids ); - - if ( !in_array( $saved_client_id, $client_ids, true ) ) - { - $saved_client_id = (int) ( $client_ids[0] ?? 0 ); - $saved_stage = 'campaigns'; - $saved_date_index = 0; - } - - if ( $reset_state ) - { - $saved_stage = 'campaigns'; - $saved_client_id = $forced_client_id > 0 ? $forced_client_id : (int) ( $client_ids[0] ?? 0 ); - $saved_date_index = 0; - if ( !in_array( $saved_client_id, $client_ids, true ) ) - { - $saved_client_id = (int) ( $client_ids[0] ?? 0 ); - } - - self::set_setting_value( 'cron_clients_bundle_stage', $saved_stage ); - self::set_setting_value( 'cron_clients_bundle_client_id', (string) $saved_client_id ); - self::set_setting_value( 'cron_clients_bundle_date_index', '0' ); - self::set_setting_value( 'cron_clients_bundle_retry_signature', '' ); - self::set_setting_value( 'cron_clients_bundle_retry_count', '0' ); - self::set_setting_value( 'cron_clients_bundle_last_error', '' ); - } - - $current_client = null; - foreach ( (array) $clients as $client_row ) - { - if ( (int) ( $client_row['id'] ?? 0 ) === $saved_client_id ) - { - $current_client = $client_row; - break; - } - } - if ( !$current_client ) - { - $current_client = $clients[0]; - $saved_client_id = (int) ( $current_client['id'] ?? 0 ); - $saved_stage = 'campaigns'; - $saved_date_index = 0; - } - - if ( in_array( $saved_stage, [ 'campaigns', 'products' ], true ) ) - { - $saved_date_index = max( 0, min( $dates_total - 1, $saved_date_index ) ); - } - else - { - $saved_date_index = 0; - } - - $current_sync_date = in_array( $saved_stage, [ 'campaigns', 'products' ], true ) - ? (string) ( $sync_dates[ $saved_date_index ] ?? $sync_date ) - : $sync_date; - - $stage_result = self::run_clients_bundle_stage_for_client( $saved_stage, $current_client, $api, $current_sync_date ); - $errors = (array) ( $stage_result['errors'] ?? [] ); - $can_advance = empty( $errors ); - $forced_advance_due_errors = false; - $stage_retry_attempt = 0; - - $stage_signature = (int) ( $current_client['id'] ?? 0 ) . '|' . $saved_stage . '|' . ( in_array( $saved_stage, [ 'campaigns', 'products' ], true ) ? $current_sync_date : 'merchant' ); - $saved_retry_signature = trim( (string) self::get_setting_value( 'cron_clients_bundle_retry_signature', '' ) ); - $saved_retry_count = (int) self::get_setting_value( 'cron_clients_bundle_retry_count', 0 ); - - if ( !$can_advance ) - { - if ( $saved_retry_signature === $stage_signature ) - { - $stage_retry_attempt = $saved_retry_count + 1; - } - else - { - $stage_retry_attempt = 1; - } - - self::set_setting_value( 'cron_clients_bundle_retry_signature', $stage_signature ); - self::set_setting_value( 'cron_clients_bundle_retry_count', (string) $stage_retry_attempt ); - self::set_setting_value( 'cron_clients_bundle_last_error', implode( ' | ', array_slice( $errors, 0, 3 ) ) ); - - if ( $stage_retry_attempt >= $max_stage_retries ) - { - $can_advance = true; - $forced_advance_due_errors = true; - } - } - else - { - self::set_setting_value( 'cron_clients_bundle_retry_signature', '' ); - self::set_setting_value( 'cron_clients_bundle_retry_count', '0' ); - self::set_setting_value( 'cron_clients_bundle_last_error', '' ); - } - - $next_stage = $saved_stage; - $next_client_id = $saved_client_id; - $next_date_index = $saved_date_index; - $advanced = false; - $cycle_completed = false; - - if ( $can_advance ) - { - if ( $saved_stage === 'merchant' ) - { - // Dla wspolnego dashboardu/klientow aktualizujemy ten sam kursor co dedykowany cron Merchant. - self::set_setting_value( 'cron_campaigns_product_alerts_last_client_id', (string) $saved_client_id ); - } - - if ( $saved_stage === 'campaigns' ) - { - if ( $saved_date_index < ( $dates_total - 1 ) ) - { - $next_date_index = $saved_date_index + 1; - } - else - { - $next_stage = 'products'; - $next_date_index = 0; - } - } - else if ( $saved_stage === 'products' ) - { - if ( $saved_date_index < ( $dates_total - 1 ) ) - { - $next_date_index = $saved_date_index + 1; - } - else - { - $next_stage = 'merchant'; - $next_date_index = 0; - } - } - else - { - $next_stage = 'campaigns'; - $next_date_index = 0; - $next_client_id = self::get_next_client_id_in_order( $client_ids, $saved_client_id ); - $advanced = true; - - $last_client_id = (int) end( $client_ids ); - if ( $last_client_id > 0 && $saved_client_id === $last_client_id && $next_client_id === (int) ( $client_ids[0] ?? 0 ) ) - { - $cycle_completed = true; - self::set_setting_value( 'cron_clients_bundle_last_cycle_completed_at', date( 'Y-m-d H:i:s' ) ); - } - } - } - - self::set_setting_value( 'cron_clients_bundle_stage', $next_stage ); - self::set_setting_value( 'cron_clients_bundle_client_id', (string) $next_client_id ); - self::set_setting_value( 'cron_clients_bundle_date_index', (string) $next_date_index ); - - if ( $can_advance ) - { - self::set_setting_value( 'cron_clients_bundle_retry_signature', '' ); - self::set_setting_value( 'cron_clients_bundle_retry_count', '0' ); - } - - self::set_setting_value( 'cron_clients_bundle_last_processed_client_id', (string) (int) ( $current_client['id'] ?? 0 ) ); - self::set_setting_value( 'cron_clients_bundle_last_processed_stage', $saved_stage ); - self::set_setting_value( 'cron_clients_bundle_last_processed_date', (string) $current_sync_date ); - self::set_setting_value( 'cron_clients_bundle_last_processed_status', empty( $errors ) ? 'ok' : ( $forced_advance_due_errors ? 'forced_advance' : 'error' ) ); - self::set_setting_value( 'cron_clients_bundle_last_processed_at', date( 'Y-m-d H:i:s' ) ); - - $next_sync_date = in_array( $next_stage, [ 'campaigns', 'products' ], true ) - ? (string) ( $sync_dates[ $next_date_index ] ?? $sync_date ) - : $sync_date; - - echo json_encode( [ - 'result' => empty( $errors ) ? 'Cron zbiorczy: etap zakonczony.' : ( $forced_advance_due_errors ? 'Cron zbiorczy: przekroczono limit prob, przejscie do kolejnego etapu.' : 'Cron zbiorczy: etap zakonczony z bledami.' ), - 'date' => $sync_date, - 'today_date' => $today_date, - 'window_days' => $conversion_window_days, - 'dates_window' => $sync_dates, - 'current_client_id' => (int) ( $current_client['id'] ?? 0 ), - 'current_client_name' => (string) ( $current_client['name'] ?? '' ), - 'processed_stage' => $saved_stage, - 'processed_date_index' => $saved_date_index, - 'processed_sync_date' => $current_sync_date, - 'next_stage' => $next_stage, - 'next_client_id' => $next_client_id, - 'next_date_index' => $next_date_index, - 'next_sync_date' => $next_sync_date, - 'can_advance' => $can_advance ? 1 : 0, - 'stage_retry_attempt' => $stage_retry_attempt, - 'stage_max_retries' => $max_stage_retries, - 'forced_advance_due_errors' => $forced_advance_due_errors ? 1 : 0, - 'advanced_to_next_client' => $advanced ? 1 : 0, - 'cycle_completed' => $cycle_completed ? 1 : 0, - 'clients_total' => count( $client_ids ), - 'stage_details' => $stage_result, - 'errors' => $errors - ] ); - exit; - } - - static private function run_clients_bundle_stage_for_client( $stage, $client, $api, $sync_date ) - { - $stage = trim( (string) $stage ); - $sync_date = date( 'Y-m-d', strtotime( $sync_date ) ); - $client_id = (int) ( $client['id'] ?? 0 ); - - if ( $client_id <= 0 ) - { - return [ - 'stage' => $stage, - 'count' => 0, - 'errors' => [ 'Brak poprawnego klienta do przetworzenia.' ] - ]; - } - - if ( $stage === 'campaigns' ) - { - $phase = self::get_sync_phase_for_client_date( 'campaigns', $sync_date, $client_id ); - if ( $phase === 'done' ) - { - return [ - 'stage' => 'campaigns', - 'count' => 0, - 'meta' => [ - 'sync_date' => $sync_date, - 'already_done' => 1 - ], - 'errors' => [] - ]; - } - - $sync = self::sync_campaigns_for_client( $client, $api, $sync_date, true ); - $errors = (array) ( $sync['errors'] ?? [] ); - - if ( empty( $errors ) ) - { - self::mark_sync_phase( 'campaigns', $sync_date, $client_id, 'done' ); - } - else - { - self::mark_sync_phase( 'campaigns', $sync_date, $client_id, 'pending', implode( '; ', $errors ) ); - } - - return [ - 'stage' => 'campaigns', - 'count' => (int) ( $sync['processed_records'] ?? 0 ), - 'meta' => [ - 'sync_date' => $sync_date, - 'already_done' => 0, - 'ad_groups_synced' => (int) ( $sync['ad_groups_synced'] ?? 0 ), - 'search_terms_synced' => (int) ( $sync['search_terms_synced'] ?? 0 ), - 'keywords_synced' => (int) ( $sync['keywords_synced'] ?? 0 ), - 'negative_keywords_synced' => (int) ( $sync['negative_keywords_synced'] ?? 0 ), - 'alerts_synced' => (int) ( $sync['alerts_synced'] ?? 0 ) - ], - 'errors' => $errors - ]; - } - - if ( $stage === 'products' ) - { - $phase = self::get_sync_phase_for_client_date( 'products', $sync_date, $client_id ); - if ( $phase === 'done' ) - { - return [ - 'stage' => 'products', - 'count' => 0, - 'meta' => [ - 'sync_date' => $sync_date, - 'already_done' => 1 - ], - 'errors' => [] - ]; - } - - $sync = self::sync_products_fetch_for_client( $client, $api, $sync_date ); - $history_30 = (int) self::aggregate_products_history_30_for_client( $client_id, $sync_date ); - $temp_rows = (int) self::rebuild_products_temp_for_client( $client_id ); - $errors = (array) ( $sync['errors'] ?? [] ); - - if ( empty( $errors ) ) - { - self::mark_sync_phase( 'products', $sync_date, $client_id, 'done' ); - } - else - { - self::mark_sync_phase( 'products', $sync_date, $client_id, 'pending', implode( '; ', $errors ) ); - } - - return [ - 'stage' => 'products', - 'count' => (int) ( $sync['processed_products'] ?? 0 ), - 'meta' => [ - 'sync_date' => $sync_date, - 'already_done' => 0, - 'skipped' => (int) ( $sync['skipped'] ?? 0 ), - 'history_30_products' => $history_30, - 'products_temp_rows' => $temp_rows - ], - 'errors' => $errors - ]; - } - - if ( $stage === 'merchant' ) - { - $merchant_account_id = trim( (string) ( $client['google_merchant_account_id'] ?? '' ) ); - if ( $merchant_account_id === '' ) - { - return [ - 'stage' => 'merchant', - 'count' => 0, - 'meta' => [ 'sync_date' => $sync_date, 'skipped' => 1, 'reason' => 'Klient nie ma Merchant Account ID.' ], - 'errors' => [] - ]; - } - - $campaigns_db_map = self::build_campaigns_db_map_for_client( $client_id ); - if ( empty( $campaigns_db_map ) ) - { - return [ - 'stage' => 'merchant', - 'count' => 0, - 'meta' => [ 'sync_date' => $sync_date, 'skipped' => 1, 'reason' => 'Brak kampanii klienta w bazie.' ], - 'errors' => [] - ]; - } - - $ad_group_db_map = self::build_ad_group_db_map_from_db( $campaigns_db_map ); - $sync = self::sync_product_campaign_alerts_for_client( - $client, - $campaigns_db_map, - $ad_group_db_map, - (string) ( $client['google_ads_customer_id'] ?? '' ), - $api, - $sync_date, - [ - 'run_missing_mapping_alerts' => false, - 'run_merchant_validation_alerts' => true - ] - ); - - return [ - 'stage' => 'merchant', - 'count' => (int) ( $sync['count'] ?? 0 ), - 'meta' => [ 'sync_date' => $sync_date ], - 'errors' => (array) ( $sync['errors'] ?? [] ) - ]; - } - - return [ - 'stage' => $stage, - 'count' => 0, - 'errors' => [ 'Nieznany etap crona zbiorczego: ' . $stage ] - ]; - } - static private function get_sync_phase_for_client_date( $pipeline, $sync_date, $client_id ) { global $mdb; @@ -6261,6 +4866,219 @@ class Cron return null; } + static public function cron_facebook_ads() + { + self::touch_cron_invocation( __FUNCTION__ ); + self::output_cron_response( self::run_facebook_ads_sync_payload( (int) \S::get( 'client_id' ) ) ); + } + + static private function run_facebook_ads_sync_payload( $requested_client_id = 0 ) + { + global $settings; + + $token = trim( (string) ( $settings['facebook_ads_token'] ?? '' ) ); + $api_version = trim( (string) ( $settings['facebook_ads_api_version'] ?? 'v25.0' ) ) ?: 'v25.0'; + $requested_client_id = (int) $requested_client_id; + $client_id = $requested_client_id; + $forced_client_id = 0; + $is_forced_refresh = false; + $days = 30; + $until = date( 'Y-m-d', strtotime( '-1 day' ) ); + $since = date( 'Y-m-d', strtotime( $until . ' -29 days' ) ); + + if ( $client_id <= 0 ) + { + $forced_client_id = (int) \services\FacebookAdsApi::get_setting( 'cron_facebook_ads_force_client_id' ); + if ( $forced_client_id > 0 ) + { + $client_id = $forced_client_id; + $is_forced_refresh = true; + } + } + + if ( $token === '' ) + { + return [ + 'result' => 'Brak facebook_ads_token w config.php.', + 'success' => false + ]; + } + + // Blokada ponownego pobrania w tym samym dniu (chyba ze wymuszone lub konkretny klient) + if ( !$is_forced_refresh && $requested_client_id <= 0 ) + { + $last_synced_date = (string) \services\FacebookAdsApi::get_setting( 'cron_facebook_ads_last_active_date' ); + if ( $last_synced_date === $until ) + { + return [ + 'result' => 'Synchronizacja Facebook Ads juz wykonana dzisiaj (' . $since . ' - ' . $until . ').', + 'success' => true, + 'skipped' => true, + 'days' => $days, + 'since' => $since, + 'until' => $until, + 'last_synced_date' => $last_synced_date + ]; + } + } + + $clients = \factory\FacebookAds::get_clients_for_sync( $client_id ); + if ( empty( $clients ) ) + { + if ( $is_forced_refresh ) + { + \services\FacebookAdsApi::set_setting( 'facebook_ads_last_error', 'Wymuszone odswiezenie FB nie moze wystartowac: klient nie jest aktywny lub nie ma Facebook Ads Account ID.' ); + + return [ + 'result' => 'Wymuszone odswiezenie Facebook Ads nie zostalo wykonane (sprawdz aktywnosc klienta i Facebook Ads Account ID).', + 'success' => false, + 'processed_clients' => 0, + 'failed_clients' => 1, + 'days' => $days, + 'errors' => [ 'Brak aktywnego klienta z ustawionym facebook_ads_account_id dla wymuszonego odswiezenia.' ] + ]; + } + + if ( $requested_client_id > 0 ) + { + return [ + 'result' => 'Nie znaleziono aktywnego klienta z ustawionym facebook_ads_account_id dla podanego ID.', + 'success' => false, + 'processed_clients' => 0, + 'failed_clients' => 1, + 'days' => $days, + 'errors' => [ 'Brak aktywnego klienta z facebook_ads_account_id dla ID=' . $requested_client_id . '.' ] + ]; + } + + return [ + 'result' => 'Brak aktywnych klientow z ustawionym facebook_ads_account_id.', + 'success' => true, + 'processed_clients' => 0, + 'days' => $days + ]; + } + + $api = new \services\FacebookAdsApi( $token, $api_version ); + if ( !$api -> is_configured() ) + { + return [ + 'result' => 'Facebook Ads API nie jest skonfigurowane.', + 'success' => false + ]; + } + + $items = []; + $processed = 0; + $success_count = 0; + $failed_count = 0; + + $totals = [ + 'campaigns_synced' => 0, + 'campaign_history_synced' => 0, + 'ad_sets_synced' => 0, + 'ad_set_history_synced' => 0, + 'ads_synced' => 0, + 'ad_history_synced' => 0 + ]; + + $errors = []; + + foreach ( $clients as $client ) + { + $sync = \factory\FacebookAds::sync_date_range_for_client( $client, $api, $since, $until, true ); + $processed++; + + if ( !empty( $sync['success'] ) ) + { + $success_count++; + } + else + { + $failed_count++; + } + + foreach ( array_keys( $totals ) as $key ) + { + $totals[ $key ] += (int) ( $sync[ $key ] ?? 0 ); + } + + $client_errors = (array) ( $sync['errors'] ?? [] ); + foreach ( $client_errors as $error_text ) + { + $error_text = trim( (string) $error_text ); + if ( $error_text === '' ) + { + continue; + } + + $errors[] = 'Client ID ' . (int) ( $client['id'] ?? 0 ) . ': ' . $error_text; + } + + $items[] = [ + 'client_id' => (int) ( $client['id'] ?? 0 ), + 'client_name' => (string) ( $client['name'] ?? '' ), + 'account_id' => (string) ( $sync['account_id'] ?? ( $client['facebook_ads_account_id'] ?? '' ) ), + 'success' => !empty( $sync['success'] ), + 'campaigns_synced' => (int) ( $sync['campaigns_synced'] ?? 0 ), + 'campaign_history_synced' => (int) ( $sync['campaign_history_synced'] ?? 0 ), + 'ad_sets_synced' => (int) ( $sync['ad_sets_synced'] ?? 0 ), + 'ad_set_history_synced' => (int) ( $sync['ad_set_history_synced'] ?? 0 ), + 'ads_synced' => (int) ( $sync['ads_synced'] ?? 0 ), + 'ad_history_synced' => (int) ( $sync['ad_history_synced'] ?? 0 ), + 'all_time_debug' => $sync['all_time_debug'] ?? null, + 'errors' => $client_errors + ]; + } + + \services\FacebookAdsApi::set_setting( 'cron_facebook_ads_last_processed_clients', (string) $processed ); + \services\FacebookAdsApi::set_setting( 'cron_facebook_ads_last_success_clients', (string) $success_count ); + \services\FacebookAdsApi::set_setting( 'cron_facebook_ads_last_failed_clients', (string) $failed_count ); + \services\FacebookAdsApi::set_setting( 'cron_facebook_ads_last_days', (string) $days ); + \services\FacebookAdsApi::set_setting( 'cron_facebook_ads_last_run_at', date( 'Y-m-d H:i:s' ) ); + \services\FacebookAdsApi::set_setting( 'cron_facebook_ads_last_totals_json', json_encode( $totals, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ) ); + if ( $failed_count === 0 ) + { + \services\FacebookAdsApi::set_setting( 'cron_facebook_ads_last_success_at', date( 'Y-m-d H:i:s' ) ); + } + \services\FacebookAdsApi::set_setting( 'cron_facebook_ads_last_active_date', $until ); + + if ( $forced_client_id > 0 ) + { + if ( $failed_count === 0 ) + { + \services\FacebookAdsApi::set_setting( 'cron_facebook_ads_force_client_id', '0' ); + \services\FacebookAdsApi::set_setting( 'cron_facebook_ads_force_requested_at', '' ); + \services\FacebookAdsApi::set_setting( 'cron_facebook_ads_last_forced_client_id', (string) $forced_client_id ); + } + else + { + \services\FacebookAdsApi::set_setting( 'facebook_ads_last_error', 'Wymuszone odswiezenie FB nie zakonczone: brak pobranych aktywnych danych.' ); + } + } + + $result_text = $failed_count > 0 + ? 'Synchronizacja Facebook Ads zakonczona z bledami.' + : 'Synchronizacja Facebook Ads zakonczona (' . $since . ' - ' . $until . ').'; + + return [ + 'result' => $result_text, + 'success' => $failed_count === 0, + 'days' => $days, + 'since' => $since, + 'until' => $until, + 'active_only' => 1, + 'api_version' => $api -> get_api_version(), + 'window_completed' => 1, + 'processed_clients' => $processed, + 'success_clients' => $success_count, + 'failed_clients' => $failed_count, + 'totals' => $totals, + 'clients' => $items, + 'errors' => $errors + ]; + } + static private function cleanup_old_sync_rows( $days = 30 ) { global $mdb; @@ -6312,6 +5130,49 @@ class Cron return min( 90, $setting_value ); } + static private function get_facebook_conversion_window_days( $prefer_config = false ) + { + global $settings; + + $request_value = (int) \S::get( 'facebook_conversion_window_days' ); + if ( $request_value <= 0 ) + { + $request_value = (int) \S::get( 'days' ); + } + if ( $request_value <= 0 ) + { + $request_value = (int) \S::get( 'conversion_window_days' ); + } + + if ( $request_value > 0 ) + { + return min( 90, $request_value ); + } + + if ( $prefer_config ) + { + $config_value = (int) ( $settings['facebook_ads_conversion_window_days'] ?? 0 ); + if ( $config_value > 0 ) + { + return min( 90, $config_value ); + } + } + + $setting_value = (int) self::get_setting_value( 'facebook_ads_conversion_window_days', 30 ); + if ( $setting_value <= 0 ) + { + $config_value = (int) ( $settings['facebook_ads_conversion_window_days'] ?? 30 ); + if ( $config_value <= 0 ) + { + $config_value = 30; + } + + return min( 90, $config_value ); + } + + return min( 90, $setting_value ); + } + static private function cleanup_pipeline_rows_outside_window( $pipeline, $sync_dates ) { global $mdb; diff --git a/autoload/controls/class.FacebookAds.php b/autoload/controls/class.FacebookAds.php new file mode 100644 index 0000000..e4a6bd3 --- /dev/null +++ b/autoload/controls/class.FacebookAds.php @@ -0,0 +1,137 @@ + \factory\FacebookAds::get_clients_for_reports(), + ] ); + } + + static public function get_entities() + { + $client_id = (int) \S::get( 'client_id' ); + $level = self::sanitize_level( \S::get( 'level' ) ); + $campaign_id = (int) \S::get( 'campaign_id' ); + $ad_set_id = (int) \S::get( 'ad_set_id' ); + + echo json_encode( [ + 'level' => $level, + 'entities' => \factory\FacebookAds::get_entities_with_latest_metrics( $client_id, $level, [ + 'campaign_id' => $campaign_id, + 'ad_set_id' => $ad_set_id + ] ) + ] ); + exit; + } + + static public function get_history_data_table() + { + $level = self::sanitize_level( \S::get( 'level' ) ); + $entity_id = (int) \S::get( 'entity_id' ); + $start = (int) \S::get( 'start' ); + $length = (int) \S::get( 'length' ); + + $rows = \factory\FacebookAds::get_entity_history( $level, $entity_id, $start, $length, false ); + $records_total = \factory\FacebookAds::get_entity_history_total( $level, $entity_id ); + + $result = [ + 'draw' => \S::get( 'draw' ), + 'recordsTotal' => $records_total, + 'recordsFiltered' => $records_total, + 'data' => [] + ]; + + $is_campaign = $level === 'campaign'; + + foreach ( $rows as $row ) + { + $result['data'][] = [ + (string) ( $row['date_add'] ?? '' ), + number_format( (float) ( $row['spend'] ?? 0 ), 2, ',', ' ' ), + (int) ( $row['impressions'] ?? 0 ), + (int) ( $row['clicks'] ?? 0 ), + number_format( (float) ( $row['ctr'] ?? 0 ), 3, ',', ' ' ), + number_format( (float) ( $row['cpc'] ?? 0 ), 3, ',', ' ' ), + number_format( (float) ( $row['conversion_value'] ?? 0 ), 2, ',', ' ' ), + number_format( (float) ( $row['roas'] ?? 0 ), 2, ',', ' ' ), + $is_campaign ? number_format( (float) ( $row['roas_all_time'] ?? 0 ), 2, ',', ' ' ) : '' + ]; + } + + $result['is_campaign'] = $is_campaign; + + echo json_encode( $result ); + exit; + } + + static public function get_history_data_chart() + { + $level = self::sanitize_level( \S::get( 'level' ) ); + $entity_id = (int) \S::get( 'entity_id' ); + + $rows = \factory\FacebookAds::get_entity_history( $level, $entity_id, 0, 1000, true ); + + $is_campaign = $level === 'campaign'; + + $dates = []; + $spend = []; + $impressions = []; + $clicks = []; + $ctr = []; + $cpc = []; + $conversion_value = []; + $roas = []; + $roas_all_time = []; + + foreach ( $rows as $row ) + { + $dates[] = (string) ( $row['date_add'] ?? '' ); + $spend[] = (float) ( $row['spend'] ?? 0 ); + $impressions[] = (int) ( $row['impressions'] ?? 0 ); + $clicks[] = (int) ( $row['clicks'] ?? 0 ); + $ctr[] = (float) ( $row['ctr'] ?? 0 ); + $cpc[] = (float) ( $row['cpc'] ?? 0 ); + $conversion_value[] = (float) ( $row['conversion_value'] ?? 0 ); + $roas[] = (float) ( $row['roas'] ?? 0 ); + if ( $is_campaign ) + { + $roas_all_time[] = (float) ( $row['roas_all_time'] ?? 0 ); + } + } + + $chart_data = [ + [ 'name' => 'Spend', 'data' => $spend, 'visible' => false ], + [ 'name' => 'Impressions', 'data' => $impressions, 'visible' => false ], + [ 'name' => 'Clicks', 'data' => $clicks, 'visible' => false ], + [ 'name' => 'CTR', 'data' => $ctr, 'visible' => false ], + [ 'name' => 'CPC', 'data' => $cpc, 'visible' => false ], + [ 'name' => 'Wartosc konwersji', 'data' => $conversion_value, 'visible' => false ], + [ 'name' => 'ROAS (30 dni)', 'data' => $roas, 'visible' => true ] + ]; + + if ( $is_campaign ) + { + $chart_data[] = [ 'name' => 'ROAS (all time)', 'data' => $roas_all_time, 'visible' => true ]; + } + + echo json_encode( [ + 'dates' => $dates, + 'chart_data' => $chart_data + ] ); + exit; + } +} diff --git a/autoload/controls/class.Products.php b/autoload/controls/class.Products.php index ba9ace8..52598b3 100644 --- a/autoload/controls/class.Products.php +++ b/autoload/controls/class.Products.php @@ -164,33 +164,6 @@ class Products ]; } - static public function get_client_bestseller_min_roas() { - $client_id = \S::get( 'client_id' ); - - $min_roas = \factory\Products::get_client_bestseller_min_roas( $client_id ); - - if ( $min_roas ) - { - echo json_encode( [ 'status' => 'ok', 'min_roas' => $min_roas ] ); - } - else - echo json_encode( [ 'status' => 'error' ] ); - exit; - } - - static public function save_client_bestseller_min_roas() { - $client_id = \S::get( 'client_id' ); - $min_roas = \S::get( 'min_roas' ); - - if ( \factory\Products::save_client_bestseller_min_roas( $client_id, $min_roas ) ) - { - echo json_encode( [ 'status' => 'ok' ] ); - } - else - echo json_encode( [ 'status' => 'error' ] ); - exit; - } - static public function main_view() { return \Tpl::view( 'products/main_view', [ diff --git a/autoload/controls/class.Users.php b/autoload/controls/class.Users.php index 68cae9f..e0ec600 100644 --- a/autoload/controls/class.Users.php +++ b/autoload/controls/class.Users.php @@ -151,7 +151,7 @@ class Users private static function get_cron_dashboard_data() { - global $mdb; + global $mdb, $settings; $base_url = self::get_base_url(); $clients_total = (int) $mdb -> query( @@ -167,6 +167,12 @@ class Users AND TRIM( COALESCE( google_ads_customer_id, '' ) ) <> '' AND TRIM( COALESCE( google_merchant_account_id, '' ) ) <> ''" ) -> fetchColumn(); + $facebook_clients_total = (int) $mdb -> query( + "SELECT COUNT(*) + FROM clients + WHERE COALESCE( active, 0 ) = 1 + AND TRIM( COALESCE( facebook_ads_account_id, '' ) ) <> ''" + ) -> fetchColumn(); // --- Kampanie --- $campaign_stats = $mdb -> query( @@ -234,45 +240,98 @@ class Users $products_meta .= ', ' . $products_eta_meta; } - // --- Walidacja Merchant dla alertow kampanii --- - $merchant_cursor_client_id = (int) \services\GoogleAdsApi::get_setting( 'cron_campaigns_product_alerts_last_client_id' ); - $merchant_processed = 0; - if ( $merchant_cursor_client_id > 0 && $merchant_clients_total > 0 ) - { - $merchant_processed = (int) $mdb -> query( - "SELECT COUNT(*) - FROM clients - WHERE COALESCE( active, 0 ) = 1 - AND TRIM( COALESCE( google_ads_customer_id, '' ) ) <> '' - AND TRIM( COALESCE( google_merchant_account_id, '' ) ) <> '' - AND id <= :cursor_client_id", - [ ':cursor_client_id' => $merchant_cursor_client_id ] - ) -> fetchColumn(); - } + // --- Merchant: postep na poziomie produktow (URL z Merchant Center) --- + $merchant_products_with_offer = (int) $mdb -> query( + "SELECT COUNT(*) + FROM products p + INNER JOIN clients c ON p.client_id = c.id + WHERE COALESCE( c.active, 0 ) = 1 + AND TRIM( COALESCE( c.google_merchant_account_id, '' ) ) <> '' + AND TRIM( COALESCE( p.offer_id, '' ) ) <> ''" + ) -> fetchColumn(); - $merchant_processed = min( $merchant_clients_total, max( 0, $merchant_processed ) ); - $merchant_remaining = max( 0, $merchant_clients_total - $merchant_processed ); - $merchant_meta = 'Kursor klienta: ' . ( $merchant_cursor_client_id > 0 ? '#' . $merchant_cursor_client_id : '-' ) . ', klienci z Merchant ID: ' . $merchant_clients_total; - $merchant_eta_meta = self::build_eta_meta( 'cron_campaigns_product_alerts_merchant', $merchant_remaining ); + $merchant_products_with_url = (int) $mdb -> query( + "SELECT COUNT(*) + FROM products p + INNER JOIN clients c ON p.client_id = c.id + WHERE COALESCE( c.active, 0 ) = 1 + AND TRIM( COALESCE( c.google_merchant_account_id, '' ) ) <> '' + AND TRIM( COALESCE( p.offer_id, '' ) ) <> '' + AND TRIM( COALESCE( p.product_url, '' ) ) <> '' + AND LOWER( TRIM( p.product_url ) ) NOT IN ( '0', '-', 'null' )" + ) -> fetchColumn(); + + $merchant_products_not_found = (int) $mdb -> query( + "SELECT COUNT(*) + FROM products p + INNER JOIN clients c ON p.client_id = c.id + WHERE COALESCE( c.active, 0 ) = 1 + AND TRIM( COALESCE( c.google_merchant_account_id, '' ) ) <> '' + AND TRIM( COALESCE( p.offer_id, '' ) ) <> '' + AND p.product_url IS NULL + AND COALESCE( p.merchant_url_not_found, 0 ) = 1" + ) -> fetchColumn(); + + $merchant_processed = $merchant_products_with_url + $merchant_products_not_found; + $merchant_total = max( 1, $merchant_products_with_offer ); + $merchant_remaining_products = max( 0, $merchant_products_with_offer - $merchant_processed ); + + $merchant_batch_limit = (int) ( $settings['cron_products_urls_limit_per_client'] ?? 100 ); + if ( $merchant_batch_limit <= 0 ) + { + $merchant_batch_limit = 100; + } + $merchant_remaining_calls = ( $merchant_remaining_products > 0 ) ? (int) ceil( $merchant_remaining_products / $merchant_batch_limit ) : 0; + + $merchant_meta = 'Produkty z URL: ' . $merchant_products_with_url + . ', brak w MC: ' . $merchant_products_not_found + . ', pozostało: ' . $merchant_remaining_products + . ', paczka: ' . $merchant_batch_limit . ' szt.' + . ', klienci z Merchant ID: ' . $merchant_clients_total; + + $merchant_eta_meta = self::build_eta_meta( 'cron_products_urls', $merchant_remaining_calls ); if ( $merchant_eta_meta !== '' ) { $merchant_meta .= ', ' . $merchant_eta_meta; } + // --- Facebook Ads --- (postep = klienci zsynchronizowani dzisiaj) + $facebook_yesterday = date( 'Y-m-d', strtotime( '-1 day' ) ); + $facebook_last_active_date = (string) \services\FacebookAdsApi::get_setting( 'cron_facebook_ads_last_active_date' ); + $facebook_synced_today = ( $facebook_last_active_date === $facebook_yesterday ); + + $facebook_processed = $facebook_synced_today ? $facebook_clients_total : 0; + $facebook_total = $facebook_clients_total; + $facebook_remaining = $facebook_synced_today ? 0 : 1; + + $facebook_last_success_at = self::format_datetime( \services\FacebookAdsApi::get_setting( 'cron_facebook_ads_last_success_at' ) ); + $facebook_force_client_id = (int) \services\FacebookAdsApi::get_setting( 'cron_facebook_ads_force_client_id' ); + + $facebook_meta = 'Zakres: 30 dni do ' . $facebook_yesterday + . ', klienci z FB ID: ' . $facebook_clients_total + . ', ostatni sukces: ' . $facebook_last_success_at + . ', sync dzisiaj: ' . ( $facebook_synced_today ? 'tak' : 'nie' ); + if ( $facebook_force_client_id > 0 ) + { + $facebook_meta .= ', zakolejkowany klient: #' . $facebook_force_client_id; + } + + $facebook_eta_meta = self::build_eta_meta( 'cron_facebook_ads', $facebook_remaining ); + if ( $facebook_eta_meta !== '' ) + { + $facebook_meta .= ', ' . $facebook_eta_meta; + } + $cron_schedule = []; // --- Endpointy CRON --- $cron_endpoints = [ [ 'name' => 'Legacy CRON', 'path' => '/cron.php', 'action' => 'cron_legacy', 'plan' => '' ], - [ 'name' => 'Cron zbiorczy (K->P->GMC)', 'path' => '/cron/cron_clients_bundle', 'action' => 'cron_clients_bundle', 'plan' => 'Co 1 min: klient -> kampanie (-7..-1) -> produkty (-7..-1) -> GMC' ], - [ 'name' => 'Cron kampanii', 'path' => '/cron/cron_campaigns', 'action' => 'cron_campaigns', 'plan' => 'Krok 1/2, co 15 min' ], - [ 'name' => 'Cron alertow kampanii (Merchant)', 'path' => '/cron/cron_campaigns_product_alerts_merchant', 'action' => 'cron_campaigns_product_alerts_merchant', 'plan' => 'Krok 2/2, 2-5 min po Cron kampanii, co 15 min' ], - [ 'name' => 'Cron produktów', 'path' => '/cron/cron_products', 'action' => 'cron_products', 'plan' => '' ], + [ 'name' => 'Cron uniwersalny (Google Ads)', 'path' => '/cron/cron_universal', 'action' => 'cron_universal', 'plan' => 'Co 1 min: kampanie (wczoraj) + frazy/produkty (7 dni wstecz) + Merchant URL' ], + [ 'name' => 'Cron alertow kampanii (Merchant)', 'path' => '/cron/cron_campaigns_product_alerts_merchant', 'action' => 'cron_campaigns_product_alerts_merchant', 'plan' => 'Co 15 min: alerty produktowe z Google Merchant' ], [ 'name' => 'Cron URL produktów (Merchant)', 'path' => '/cron/cron_products_urls', 'action' => 'cron_products_urls', 'plan' => '' ], - [ 'name' => 'Cron fraz', 'path' => '/cron/cron_phrases', 'action' => 'cron_phrases', 'plan' => '' ], - [ 'name' => 'Historia 30 dni produktów', 'path' => '/cron/cron_products_history_30', 'action' => 'cron_products_history_30', 'plan' => '' ], - [ 'name' => 'Historia 30 dni fraz', 'path' => '/cron/cron_phrases_history_30', 'action' => 'cron_phrases_history_30', 'plan' => '' ], - [ 'name' => 'Eksport XML', 'path' => '/cron/cron_xml', 'action' => 'cron_xml', 'plan' => '' ], + [ 'name' => 'Cron archiwum kampanii', 'path' => '/cron/cron_campaigns_archive', 'action' => 'cron_campaigns_archive', 'plan' => '' ], + [ 'name' => 'Cron Facebook Ads', 'path' => '/cron/cron_facebook_ads', 'action' => 'cron_facebook_ads', 'plan' => 'Co 5 min: 30 dni wstecz od wczoraj, blokada ponownego pobrania w tym samym dniu' ], ]; $urls = []; @@ -290,6 +349,7 @@ class Users return [ 'overall_last_invoked_at' => self::format_datetime( \services\GoogleAdsApi::get_setting( 'cron_last_invoked_at' ) ), 'clients_total' => $clients_total, + 'facebook_clients_total' => $facebook_clients_total, 'progress' => [ [ 'name' => 'Kampanie', @@ -306,12 +366,19 @@ class Users 'meta' => $products_meta ], [ - 'name' => 'Walidacja Merchant', + 'name' => 'Merchant (URL produktów)', 'processed' => $merchant_processed, - 'total' => $merchant_clients_total, - 'percent' => self::progress_percent( $merchant_processed, $merchant_clients_total ), + 'total' => $merchant_total, + 'percent' => self::progress_percent( $merchant_processed, $merchant_total ), 'meta' => $merchant_meta ], + [ + 'name' => 'Facebook Ads', + 'processed' => $facebook_processed, + 'total' => $facebook_total, + 'percent' => self::progress_percent( $facebook_processed, $facebook_total ), + 'meta' => $facebook_meta + ], ], 'schedule' => $cron_schedule, 'urls' => $urls diff --git a/autoload/controls/class.XmlFiles.php b/autoload/controls/class.XmlFiles.php deleted file mode 100644 index 49b5f2d..0000000 --- a/autoload/controls/class.XmlFiles.php +++ /dev/null @@ -1,38 +0,0 @@ - \factory\XmlFiles::get_clients_with_xml_feed() - ] ); - } - - static public function regenerate() - { - $client_id = (int) \S::get( 'client_id' ); - - if ( $client_id <= 0 ) - { - \S::alert( 'Nie podano ID klienta.' ); - header( 'Location: /xml_files' ); - exit; - } - - $result = \controls\Cron::generate_custom_feed_for_client( $client_id, true ); - - if ( ( $result['status'] ?? '' ) === 'ok' ) - { - \S::alert( 'Plik XML zostal wygenerowany: ' . ( $result['url'] ?? '' ) ); - } - else - { - \S::alert( $result['message'] ?? 'Nie udalo sie wygenerowac pliku XML.' ); - } - - header( 'Location: /xml_files' ); - exit; - } -} diff --git a/autoload/factory/class.Campaigns.php b/autoload/factory/class.Campaigns.php index 766edf2..96daf48 100644 --- a/autoload/factory/class.Campaigns.php +++ b/autoload/factory/class.Campaigns.php @@ -344,4 +344,11 @@ class Campaigns global $mdb; return $mdb -> delete( 'campaigns_history', [ 'id' => $history_id ] ); } + + static public function delete_history_entries( $ids ) + { + global $mdb; + $mdb -> delete( 'campaigns_history', [ 'id' => $ids ] ); + return count( $ids ); + } } diff --git a/autoload/factory/class.FacebookAds.php b/autoload/factory/class.FacebookAds.php new file mode 100644 index 0000000..cf64efe --- /dev/null +++ b/autoload/factory/class.FacebookAds.php @@ -0,0 +1,1014 @@ + pdo -> prepare( 'SHOW COLUMNS FROM clients LIKE :column_name' ); + if ( $stmt ) + { + $stmt -> bindValue( ':column_name', 'deleted', \PDO::PARAM_STR ); + $stmt -> execute(); + $has_deleted = $stmt -> fetch( \PDO::FETCH_ASSOC ) ? 1 : 0; + } + else + { + $has_deleted = 0; + } + } + catch ( \Throwable $e ) + { + $has_deleted = 0; + } + + return (bool) $has_deleted; + } + + static private function sql_clients_not_deleted( $alias = '' ) + { + $alias = trim( (string) $alias ); + $prefix = $alias !== '' ? $alias . '.' : ''; + + if ( self::clients_has_deleted_column() ) + { + return 'COALESCE(' . $prefix . 'deleted, 0) = 0'; + } + + return '1=1'; + } + + static public function get_clients_for_sync( $client_id = 0 ) + { + global $mdb; + + $client_id = (int) $client_id; + $clients_not_deleted_sql = self::sql_clients_not_deleted(); + + $params = []; + $where_client = ''; + if ( $client_id > 0 ) + { + $where_client = ' AND id = :client_id'; + $params[':client_id'] = $client_id; + } + + return $mdb -> query( + 'SELECT * + FROM clients + WHERE ' . $clients_not_deleted_sql . ' + AND COALESCE(active, 0) = 1 + AND TRIM(COALESCE(facebook_ads_account_id, \'\')) <> \'\'' . $where_client . ' + ORDER BY id ASC', + $params + ) -> fetchAll( \PDO::FETCH_ASSOC ); + } + + static public function sync_active_last_days_for_client( $client, $api, $days = 30, $active_only = true ) + { + $client = is_array( $client ) ? $client : []; + $client_id = (int) ( $client['id'] ?? 0 ); + $account_id = \services\FacebookAdsApi::normalize_ad_account_id( $client['facebook_ads_account_id'] ?? '' ); + + if ( $client_id <= 0 || !$account_id ) + { + return [ + 'success' => false, + 'client_id' => $client_id, + 'errors' => [ 'Brak poprawnego client_id lub facebook_ads_account_id.' ] + ]; + } + + $days = max( 1, min( 90, (int) $days ) ); + $payload = $api -> fetch_active_insights_last_days( $account_id, $days, $active_only ); + if ( $payload === false ) + { + $last_error = trim( (string) \services\FacebookAdsApi::get_setting( 'facebook_ads_last_error' ) ); + + return [ + 'success' => false, + 'client_id' => $client_id, + 'account_id' => $account_id, + 'errors' => [ $last_error !== '' ? $last_error : 'Nie udalo sie pobrac danych z Meta API.' ] + ]; + } + + $save_result = self::save_snapshot( $client_id, $payload ); + $save_result['client_id'] = $client_id; + $save_result['account_id'] = $account_id; + $save_result['days'] = $days; + $save_result['meta'] = $payload['meta'] ?? []; + + return $save_result; + } + + static public function sync_active_date_for_client( $client, $api, $sync_date, $active_only = true ) + { + $client = is_array( $client ) ? $client : []; + $client_id = (int) ( $client['id'] ?? 0 ); + $account_id = \services\FacebookAdsApi::normalize_ad_account_id( $client['facebook_ads_account_id'] ?? '' ); + + if ( $client_id <= 0 || !$account_id ) + { + return [ + 'success' => false, + 'client_id' => $client_id, + 'errors' => [ 'Brak poprawnego client_id lub facebook_ads_account_id.' ] + ]; + } + + $sync_timestamp = strtotime( (string) $sync_date ); + if ( !$sync_timestamp ) + { + return [ + 'success' => false, + 'client_id' => $client_id, + 'account_id' => $account_id, + 'errors' => [ 'Niepoprawna data synchronizacji Facebook Ads.' ] + ]; + } + + $sync_date = date( 'Y-m-d', $sync_timestamp ); + $payload = $api -> fetch_active_insights_for_date( $account_id, $sync_date, $active_only ); + if ( $payload === false ) + { + $last_error = trim( (string) \services\FacebookAdsApi::get_setting( 'facebook_ads_last_error' ) ); + + return [ + 'success' => false, + 'client_id' => $client_id, + 'account_id' => $account_id, + 'sync_date' => $sync_date, + 'errors' => [ $last_error !== '' ? $last_error : 'Nie udalo sie pobrac danych z Meta API.' ] + ]; + } + + $save_result = self::save_snapshot( $client_id, $payload ); + $save_result['client_id'] = $client_id; + $save_result['account_id'] = $account_id; + $save_result['days'] = 1; + $save_result['sync_date'] = $sync_date; + $save_result['meta'] = $payload['meta'] ?? []; + + return $save_result; + } + + static public function sync_date_range_for_client( $client, $api, $since, $until, $active_only = true ) + { + $client = is_array( $client ) ? $client : []; + $client_id = (int) ( $client['id'] ?? 0 ); + $account_id = \services\FacebookAdsApi::normalize_ad_account_id( $client['facebook_ads_account_id'] ?? '' ); + + if ( $client_id <= 0 || !$account_id ) + { + return [ + 'success' => false, + 'client_id' => $client_id, + 'errors' => [ 'Brak poprawnego client_id lub facebook_ads_account_id.' ] + ]; + } + + $since_ts = strtotime( (string) $since ); + $until_ts = strtotime( (string) $until ); + if ( !$since_ts || !$until_ts || $since_ts > $until_ts ) + { + return [ + 'success' => false, + 'client_id' => $client_id, + 'account_id' => $account_id, + 'errors' => [ 'Niepoprawny zakres dat synchronizacji Facebook Ads.' ] + ]; + } + + $since = date( 'Y-m-d', $since_ts ); + $until = date( 'Y-m-d', $until_ts ); + $days = (int) floor( ( $until_ts - $since_ts ) / 86400 ) + 1; + + $payload = $api -> fetch_active_insights_for_range( $account_id, $since, $until, $active_only ); + if ( $payload === false ) + { + $last_error = trim( (string) \services\FacebookAdsApi::get_setting( 'facebook_ads_last_error' ) ); + + return [ + 'success' => false, + 'client_id' => $client_id, + 'account_id' => $account_id, + 'since' => $since, + 'until' => $until, + 'errors' => [ $last_error !== '' ? $last_error : 'Nie udalo sie pobrac danych z Meta API.' ] + ]; + } + + foreach ( [ 'campaign', 'adset', 'ad' ] as $level ) + { + if ( is_array( $payload[ $level ] ?? null ) ) + { + foreach ( $payload[ $level ] as &$row ) + { + $row['date_start'] = $until; + } + unset( $row ); + } + } + + $all_time_raw = $api -> fetch_campaigns_all_time( $account_id, $active_only ); + $all_time_error = ''; + if ( $all_time_raw === false ) + { + $all_time_error = (string) \services\FacebookAdsApi::get_setting( 'facebook_ads_last_error' ); + } + $all_time_map = is_array( $all_time_raw ) ? $all_time_raw : []; + + $save_result = self::save_snapshot( $client_id, $payload, $all_time_map ); + $save_result['client_id'] = $client_id; + $save_result['account_id'] = $account_id; + $save_result['days'] = $days; + $save_result['since'] = $since; + $save_result['until'] = $until; + $save_result['meta'] = $payload['meta'] ?? []; + $save_result['all_time_debug'] = [ + 'raw_type' => is_array( $all_time_raw ) ? 'array' : ( $all_time_raw === false ? 'false' : gettype( $all_time_raw ) ), + 'campaigns_count' => count( $all_time_map ), + 'error' => $all_time_error, + 'data' => array_slice( $all_time_map, 0, 5, true ) + ]; + + return $save_result; + } + + static private function save_snapshot( $client_id, $payload, $all_time_map = [] ) + { + global $mdb; + + $stats = [ + 'success' => false, + 'campaigns_synced' => 0, + 'campaign_history_synced' => 0, + 'ad_sets_synced' => 0, + 'ad_set_history_synced' => 0, + 'ads_synced' => 0, + 'ad_history_synced' => 0, + 'errors' => [] + ]; + + $campaign_map = []; + $ad_set_map = []; + $ad_map = []; + + $campaign_rows = is_array( $payload['campaign'] ?? null ) ? $payload['campaign'] : []; + $ad_set_rows = is_array( $payload['adset'] ?? null ) ? $payload['adset'] : []; + $ad_rows = is_array( $payload['ad'] ?? null ) ? $payload['ad'] : []; + + try + { + $started_transaction = false; + if ( method_exists( $mdb -> pdo, 'beginTransaction' ) && !( $mdb -> pdo -> inTransaction() ) ) + { + $mdb -> pdo -> beginTransaction(); + $started_transaction = true; + } + + foreach ( $campaign_rows as $row ) + { + $campaign_db_id = self::upsert_campaign( $client_id, $row ); + if ( $campaign_db_id <= 0 ) + { + continue; + } + + $campaign_external_id = self::normalize_external_id( $row['campaign_id'] ?? '' ); + if ( $campaign_external_id !== null ) + { + $campaign_map[ $campaign_external_id ] = $campaign_db_id; + } + + $roas_at = (float) ( $all_time_map[ $campaign_external_id ]['roas_all_time'] ?? 0 ); + if ( self::upsert_campaign_history( $campaign_db_id, $row, $roas_at ) ) + { + $stats['campaign_history_synced']++; + } + } + + foreach ( $ad_set_rows as $row ) + { + $campaign_db_id = self::resolve_campaign_db_id( $client_id, $row, $campaign_map ); + $ad_set_db_id = self::upsert_ad_set( $client_id, $campaign_db_id, $row ); + if ( $ad_set_db_id <= 0 ) + { + continue; + } + + $ad_set_external_id = self::normalize_external_id( $row['adset_id'] ?? '' ); + if ( $ad_set_external_id !== null ) + { + $ad_set_map[ $ad_set_external_id ] = $ad_set_db_id; + } + + if ( self::upsert_ad_set_history( $ad_set_db_id, $row ) ) + { + $stats['ad_set_history_synced']++; + } + } + + foreach ( $ad_rows as $row ) + { + $campaign_db_id = self::resolve_campaign_db_id( $client_id, $row, $campaign_map ); + $ad_set_db_id = self::resolve_ad_set_db_id( $client_id, $campaign_db_id, $row, $ad_set_map ); + $ad_db_id = self::upsert_ad( $client_id, $campaign_db_id, $ad_set_db_id, $row ); + if ( $ad_db_id <= 0 ) + { + continue; + } + + $ad_external_id = self::normalize_external_id( $row['ad_id'] ?? '' ); + if ( $ad_external_id !== null ) + { + $ad_map[ $ad_external_id ] = $ad_db_id; + } + + if ( self::upsert_ad_history( $ad_db_id, $row ) ) + { + $stats['ad_history_synced']++; + } + } + + $stats['campaigns_synced'] = count( $campaign_map ); + $stats['ad_sets_synced'] = count( $ad_set_map ); + $stats['ads_synced'] = count( $ad_map ); + + if ( !empty( $started_transaction ) && method_exists( $mdb -> pdo, 'commit' ) && $mdb -> pdo -> inTransaction() ) + { + $mdb -> pdo -> commit(); + } + + $stats['success'] = true; + } + catch ( \Throwable $e ) + { + if ( !empty( $started_transaction ) && method_exists( $mdb -> pdo, 'rollBack' ) && $mdb -> pdo -> inTransaction() ) + { + $mdb -> pdo -> rollBack(); + } + + $stats['errors'][] = $e -> getMessage(); + $stats['success'] = false; + } + + return $stats; + } + + static private function upsert_campaign( $client_id, $row ) + { + global $mdb; + + $campaign_external_id = self::normalize_external_id( $row['campaign_id'] ?? '' ); + if ( $campaign_external_id === null ) + { + return 0; + } + + $account_id = self::normalize_external_id( $row['account_id'] ?? '' ) ?: '0'; + $campaign_name = trim( (string) ( $row['campaign_name'] ?? '' ) ); + $date_sync = date( 'Y-m-d H:i:s' ); + + $mdb -> query( + 'INSERT INTO facebook_campaigns + (client_id, account_id, campaign_id, campaign_name, status, effective_status, date_sync) + VALUES + (:client_id, :account_id, :campaign_id, :campaign_name, NULL, \'ACTIVE\', :date_sync) + ON DUPLICATE KEY UPDATE + id = LAST_INSERT_ID(id), + account_id = VALUES(account_id), + campaign_name = VALUES(campaign_name), + effective_status = VALUES(effective_status), + date_sync = VALUES(date_sync)', + [ + ':client_id' => (int) $client_id, + ':account_id' => $account_id, + ':campaign_id' => $campaign_external_id, + ':campaign_name' => $campaign_name, + ':date_sync' => $date_sync + ] + ); + + $db_id = (int) $mdb -> id(); + if ( $db_id > 0 ) + { + return $db_id; + } + + $existing = $mdb -> query( + 'SELECT id + FROM facebook_campaigns + WHERE client_id = :client_id + AND campaign_id = :campaign_id + LIMIT 1', + [ ':client_id' => (int) $client_id, ':campaign_id' => $campaign_external_id ] + ) -> fetchColumn(); + + return (int) $existing; + } + + static private function upsert_campaign_history( $campaign_db_id, $row, $roas_all_time = 0.0 ) + { + global $mdb; + + $date_add = self::normalize_date( $row['date_start'] ?? null ); + if ( !$date_add ) + { + return false; + } + + $spend = self::to_decimal( $row['spend'] ?? 0 ); + $conversion_value = self::extract_conversion_value( $row ); + $roas = self::extract_roas( $row, $conversion_value, $spend ); + + $mdb -> query( + 'INSERT INTO facebook_campaigns_history + (facebook_campaign_id, spend, impressions, clicks, ctr, cpc, conversion_value, roas, roas_all_time, date_add) + VALUES + (:campaign_id, :spend, :impressions, :clicks, :ctr, :cpc, :conversion_value, :roas, :roas_all_time, :date_add) + ON DUPLICATE KEY UPDATE + spend = VALUES(spend), + impressions = VALUES(impressions), + clicks = VALUES(clicks), + ctr = VALUES(ctr), + cpc = VALUES(cpc), + conversion_value = VALUES(conversion_value), + roas = VALUES(roas), + roas_all_time = VALUES(roas_all_time)', + [ + ':campaign_id' => (int) $campaign_db_id, + ':spend' => $spend, + ':impressions' => self::to_int( $row['impressions'] ?? 0 ), + ':clicks' => self::to_int( $row['clicks'] ?? 0 ), + ':ctr' => self::to_decimal( $row['ctr'] ?? 0 ), + ':cpc' => self::to_decimal( $row['cpc'] ?? 0 ), + ':conversion_value' => $conversion_value, + ':roas' => $roas, + ':roas_all_time' => (float) $roas_all_time, + ':date_add' => $date_add + ] + ); + + return true; + } + + static private function upsert_ad_set( $client_id, $campaign_db_id, $row ) + { + global $mdb; + + $ad_set_external_id = self::normalize_external_id( $row['adset_id'] ?? '' ); + if ( $ad_set_external_id === null ) + { + return 0; + } + + $campaign_external_id = self::normalize_external_id( $row['campaign_id'] ?? '' ) ?: '0'; + $ad_set_name = trim( (string) ( $row['adset_name'] ?? '' ) ); + $date_sync = date( 'Y-m-d H:i:s' ); + + $mdb -> query( + 'INSERT INTO facebook_ad_sets + (client_id, facebook_campaign_id, campaign_id, ad_set_id, ad_set_name, status, effective_status, date_sync) + VALUES + (:client_id, :facebook_campaign_id, :campaign_id, :ad_set_id, :ad_set_name, NULL, \'ACTIVE\', :date_sync) + ON DUPLICATE KEY UPDATE + id = LAST_INSERT_ID(id), + facebook_campaign_id = VALUES(facebook_campaign_id), + campaign_id = VALUES(campaign_id), + ad_set_name = VALUES(ad_set_name), + effective_status = VALUES(effective_status), + date_sync = VALUES(date_sync)', + [ + ':client_id' => (int) $client_id, + ':facebook_campaign_id' => $campaign_db_id > 0 ? (int) $campaign_db_id : null, + ':campaign_id' => $campaign_external_id, + ':ad_set_id' => $ad_set_external_id, + ':ad_set_name' => $ad_set_name, + ':date_sync' => $date_sync + ] + ); + + $db_id = (int) $mdb -> id(); + if ( $db_id > 0 ) + { + return $db_id; + } + + $existing = $mdb -> query( + 'SELECT id + FROM facebook_ad_sets + WHERE client_id = :client_id + AND ad_set_id = :ad_set_id + LIMIT 1', + [ ':client_id' => (int) $client_id, ':ad_set_id' => $ad_set_external_id ] + ) -> fetchColumn(); + + return (int) $existing; + } + + static private function upsert_ad_set_history( $ad_set_db_id, $row ) + { + global $mdb; + + $date_add = self::normalize_date( $row['date_start'] ?? null ); + if ( !$date_add ) + { + return false; + } + + $spend = self::to_decimal( $row['spend'] ?? 0 ); + $conversion_value = self::extract_conversion_value( $row ); + $roas = self::extract_roas( $row, $conversion_value, $spend ); + + $mdb -> query( + 'INSERT INTO facebook_ad_sets_history + (facebook_ad_set_id, spend, impressions, clicks, ctr, cpc, conversion_value, roas, date_add) + VALUES + (:ad_set_id, :spend, :impressions, :clicks, :ctr, :cpc, :conversion_value, :roas, :date_add) + ON DUPLICATE KEY UPDATE + spend = VALUES(spend), + impressions = VALUES(impressions), + clicks = VALUES(clicks), + ctr = VALUES(ctr), + cpc = VALUES(cpc), + conversion_value = VALUES(conversion_value), + roas = VALUES(roas)', + [ + ':ad_set_id' => (int) $ad_set_db_id, + ':spend' => $spend, + ':impressions' => self::to_int( $row['impressions'] ?? 0 ), + ':clicks' => self::to_int( $row['clicks'] ?? 0 ), + ':ctr' => self::to_decimal( $row['ctr'] ?? 0 ), + ':cpc' => self::to_decimal( $row['cpc'] ?? 0 ), + ':conversion_value' => $conversion_value, + ':roas' => $roas, + ':date_add' => $date_add + ] + ); + + return true; + } + + static private function upsert_ad( $client_id, $campaign_db_id, $ad_set_db_id, $row ) + { + global $mdb; + + $ad_external_id = self::normalize_external_id( $row['ad_id'] ?? '' ); + if ( $ad_external_id === null ) + { + return 0; + } + + $campaign_external_id = self::normalize_external_id( $row['campaign_id'] ?? '' ) ?: '0'; + $ad_set_external_id = self::normalize_external_id( $row['adset_id'] ?? '' ) ?: '0'; + $ad_name = trim( (string) ( $row['ad_name'] ?? '' ) ); + $date_sync = date( 'Y-m-d H:i:s' ); + + $mdb -> query( + 'INSERT INTO facebook_ads + (client_id, facebook_campaign_id, facebook_ad_set_id, campaign_id, ad_set_id, ad_id, ad_name, status, effective_status, date_sync) + VALUES + (:client_id, :facebook_campaign_id, :facebook_ad_set_id, :campaign_id, :ad_set_id, :ad_id, :ad_name, NULL, \'ACTIVE\', :date_sync) + ON DUPLICATE KEY UPDATE + id = LAST_INSERT_ID(id), + facebook_campaign_id = VALUES(facebook_campaign_id), + facebook_ad_set_id = VALUES(facebook_ad_set_id), + campaign_id = VALUES(campaign_id), + ad_set_id = VALUES(ad_set_id), + ad_name = VALUES(ad_name), + effective_status = VALUES(effective_status), + date_sync = VALUES(date_sync)', + [ + ':client_id' => (int) $client_id, + ':facebook_campaign_id' => $campaign_db_id > 0 ? (int) $campaign_db_id : null, + ':facebook_ad_set_id' => $ad_set_db_id > 0 ? (int) $ad_set_db_id : null, + ':campaign_id' => $campaign_external_id, + ':ad_set_id' => $ad_set_external_id, + ':ad_id' => $ad_external_id, + ':ad_name' => $ad_name, + ':date_sync' => $date_sync + ] + ); + + $db_id = (int) $mdb -> id(); + if ( $db_id > 0 ) + { + return $db_id; + } + + $existing = $mdb -> query( + 'SELECT id + FROM facebook_ads + WHERE client_id = :client_id + AND ad_id = :ad_id + LIMIT 1', + [ ':client_id' => (int) $client_id, ':ad_id' => $ad_external_id ] + ) -> fetchColumn(); + + return (int) $existing; + } + + static private function upsert_ad_history( $ad_db_id, $row ) + { + global $mdb; + + $date_add = self::normalize_date( $row['date_start'] ?? null ); + if ( !$date_add ) + { + return false; + } + + $spend = self::to_decimal( $row['spend'] ?? 0 ); + $conversion_value = self::extract_conversion_value( $row ); + $roas = self::extract_roas( $row, $conversion_value, $spend ); + + $mdb -> query( + 'INSERT INTO facebook_ads_history + (facebook_ad_id, spend, impressions, clicks, ctr, cpc, conversion_value, roas, date_add) + VALUES + (:ad_id, :spend, :impressions, :clicks, :ctr, :cpc, :conversion_value, :roas, :date_add) + ON DUPLICATE KEY UPDATE + spend = VALUES(spend), + impressions = VALUES(impressions), + clicks = VALUES(clicks), + ctr = VALUES(ctr), + cpc = VALUES(cpc), + conversion_value = VALUES(conversion_value), + roas = VALUES(roas)', + [ + ':ad_id' => (int) $ad_db_id, + ':spend' => $spend, + ':impressions' => self::to_int( $row['impressions'] ?? 0 ), + ':clicks' => self::to_int( $row['clicks'] ?? 0 ), + ':ctr' => self::to_decimal( $row['ctr'] ?? 0 ), + ':cpc' => self::to_decimal( $row['cpc'] ?? 0 ), + ':conversion_value' => $conversion_value, + ':roas' => $roas, + ':date_add' => $date_add + ] + ); + + return true; + } + + static private function resolve_campaign_db_id( $client_id, $row, &$campaign_map ) + { + $campaign_external_id = self::normalize_external_id( $row['campaign_id'] ?? '' ); + if ( $campaign_external_id === null ) + { + return 0; + } + + if ( isset( $campaign_map[ $campaign_external_id ] ) ) + { + return (int) $campaign_map[ $campaign_external_id ]; + } + + $campaign_db_id = self::upsert_campaign( $client_id, $row ); + if ( $campaign_db_id > 0 ) + { + $campaign_map[ $campaign_external_id ] = $campaign_db_id; + } + + return $campaign_db_id; + } + + static private function resolve_ad_set_db_id( $client_id, $campaign_db_id, $row, &$ad_set_map ) + { + $ad_set_external_id = self::normalize_external_id( $row['adset_id'] ?? '' ); + if ( $ad_set_external_id === null ) + { + return 0; + } + + if ( isset( $ad_set_map[ $ad_set_external_id ] ) ) + { + return (int) $ad_set_map[ $ad_set_external_id ]; + } + + $ad_set_db_id = self::upsert_ad_set( $client_id, $campaign_db_id, $row ); + if ( $ad_set_db_id > 0 ) + { + $ad_set_map[ $ad_set_external_id ] = $ad_set_db_id; + } + + return $ad_set_db_id; + } + + static private function normalize_external_id( $value ) + { + $digits = preg_replace( '/\D+/', '', (string) $value ); + if ( $digits === '' ) + { + return null; + } + + return $digits; + } + + static private function normalize_date( $value ) + { + $value = trim( (string) $value ); + if ( $value === '' ) + { + return null; + } + + $timestamp = strtotime( $value ); + if ( !$timestamp ) + { + return null; + } + + return date( 'Y-m-d', $timestamp ); + } + + static private function to_decimal( $value ) + { + if ( is_string( $value ) ) + { + $value = str_replace( ',', '.', $value ); + } + + return (float) $value; + } + + static private function to_int( $value ) + { + return (int) round( (float) $value ); + } + + static private function get_purchase_action_types_priority() + { + return [ + 'purchase', + 'omni_purchase', + 'offsite_conversion.fb_pixel_purchase', + 'web_in_store_purchase', + 'onsite_conversion.purchase', + 'app_custom_event.fb_mobile_purchase' + ]; + } + + static private function get_metric_map( $raw ) + { + $map = []; + if ( !is_array( $raw ) ) + { + return $map; + } + + foreach ( $raw as $row ) + { + if ( !is_array( $row ) ) + { + continue; + } + + $action_type = trim( (string) ( $row['action_type'] ?? '' ) ); + if ( $action_type === '' ) + { + continue; + } + + $map[ $action_type ] = self::to_decimal( $row['value'] ?? 0 ); + } + + return $map; + } + + static private function extract_conversion_value( $row ) + { + if ( !is_array( $row ) ) + { + return 0.0; + } + + if ( isset( $row['conversion_value'] ) ) + { + return self::to_decimal( $row['conversion_value'] ); + } + + $action_values_map = self::get_metric_map( $row['action_values'] ?? [] ); + if ( empty( $action_values_map ) ) + { + return 0.0; + } + + foreach ( self::get_purchase_action_types_priority() as $action_type ) + { + if ( array_key_exists( $action_type, $action_values_map ) ) + { + return self::to_decimal( $action_values_map[ $action_type ] ); + } + } + + return 0.0; + } + + static private function extract_roas( $row, $conversion_value, $spend ) + { + if ( !is_array( $row ) ) + { + return 0.0; + } + + $purchase_roas_map = self::get_metric_map( $row['purchase_roas'] ?? [] ); + foreach ( self::get_purchase_action_types_priority() as $action_type ) + { + if ( array_key_exists( $action_type, $purchase_roas_map ) ) + { + return self::to_decimal( $purchase_roas_map[ $action_type ] ) * 100; + } + } + + $conversion_value = self::to_decimal( $conversion_value ); + $spend = self::to_decimal( $spend ); + if ( $conversion_value > 0 && $spend > 0 ) + { + return round( ( $conversion_value / $spend ) * 100, 6 ); + } + + return 0.0; + } + + static private function get_level_config( $level ) + { + $level = strtolower( trim( (string) $level ) ); + + $map = [ + 'campaign' => [ + 'base_table' => 'facebook_campaigns', + 'history_table' => 'facebook_campaigns_history', + 'fk_column' => 'facebook_campaign_id', + 'name_column' => 'campaign_name', + 'external_id_column' => 'campaign_id' + ], + 'adset' => [ + 'base_table' => 'facebook_ad_sets', + 'history_table' => 'facebook_ad_sets_history', + 'fk_column' => 'facebook_ad_set_id', + 'name_column' => 'ad_set_name', + 'external_id_column' => 'ad_set_id' + ], + 'ad' => [ + 'base_table' => 'facebook_ads', + 'history_table' => 'facebook_ads_history', + 'fk_column' => 'facebook_ad_id', + 'name_column' => 'ad_name', + 'external_id_column' => 'ad_id' + ] + ]; + + return $map[ $level ] ?? null; + } + + static public function get_clients_for_reports() + { + return self::get_clients_for_sync( 0 ); + } + + static public function get_entities_with_latest_metrics( $client_id, $level, $filters = [] ) + { + global $mdb; + + $cfg = self::get_level_config( $level ); + if ( !$cfg ) + { + return []; + } + + $client_id = (int) $client_id; + if ( $client_id <= 0 ) + { + return []; + } + + $filters = is_array( $filters ) ? $filters : []; + $campaign_id = (int) ( $filters['campaign_id'] ?? 0 ); + $ad_set_id = (int) ( $filters['ad_set_id'] ?? 0 ); + + $extra_where = ''; + $params = [ ':client_id' => $client_id ]; + + if ( $level === 'adset' && $campaign_id > 0 ) + { + $extra_where .= ' AND b.facebook_campaign_id = :campaign_id'; + $params[':campaign_id'] = $campaign_id; + } + + if ( $level === 'ad' ) + { + if ( $campaign_id > 0 ) + { + $extra_where .= ' AND b.facebook_campaign_id = :campaign_id'; + $params[':campaign_id'] = $campaign_id; + } + + if ( $ad_set_id > 0 ) + { + $extra_where .= ' AND b.facebook_ad_set_id = :ad_set_id'; + $params[':ad_set_id'] = $ad_set_id; + } + } + + $sql = 'SELECT + b.id, + b.' . $cfg['external_id_column'] . ' AS external_id, + b.' . $cfg['name_column'] . ' AS entity_name, + h.date_add, + h.spend, + h.impressions, + h.clicks, + h.ctr, + h.cpc, + h.conversion_value, + h.roas + FROM ' . $cfg['base_table'] . ' b + LEFT JOIN ' . $cfg['history_table'] . ' h + ON h.' . $cfg['fk_column'] . ' = b.id + AND h.date_add = ( + SELECT MAX(h2.date_add) + FROM ' . $cfg['history_table'] . ' h2 + WHERE h2.' . $cfg['fk_column'] . ' = b.id + ) + WHERE b.client_id = :client_id + ' . $extra_where . ' + ORDER BY b.' . $cfg['name_column'] . ' ASC'; + + return $mdb -> query( $sql, $params ) -> fetchAll( \PDO::FETCH_ASSOC ); + } + + static public function get_entity_history( $level, $entity_id, $start, $length, $revert = false ) + { + global $mdb; + + $cfg = self::get_level_config( $level ); + if ( !$cfg ) + { + return []; + } + + $entity_id = (int) $entity_id; + if ( $entity_id <= 0 ) + { + return []; + } + + $start = max( 0, (int) $start ); + $length = max( 1, min( 2000, (int) $length ) ); + $order = $revert ? 'ASC' : 'DESC'; + + $columns = 'date_add, spend, impressions, clicks, ctr, cpc, conversion_value, roas'; + if ( $level === 'campaign' ) + { + $columns .= ', roas_all_time'; + } + + $sql = 'SELECT ' . $columns . ' + FROM ' . $cfg['history_table'] . ' + WHERE ' . $cfg['fk_column'] . ' = :entity_id + ORDER BY date_add ' . $order . ' + LIMIT ' . $start . ', ' . $length; + + return $mdb -> query( $sql, [ ':entity_id' => $entity_id ] ) -> fetchAll( \PDO::FETCH_ASSOC ); + } + + static public function get_entity_history_total( $level, $entity_id ) + { + global $mdb; + + $cfg = self::get_level_config( $level ); + if ( !$cfg ) + { + return 0; + } + + $entity_id = (int) $entity_id; + if ( $entity_id <= 0 ) + { + return 0; + } + + $count = $mdb -> query( + 'SELECT COUNT(*) + FROM ' . $cfg['history_table'] . ' + WHERE ' . $cfg['fk_column'] . ' = :entity_id', + [ ':entity_id' => $entity_id ] + ) -> fetchColumn(); + + return (int) $count; + } +} diff --git a/autoload/factory/class.Products.php b/autoload/factory/class.Products.php index 439f8b5..d85605b 100644 --- a/autoload/factory/class.Products.php +++ b/autoload/factory/class.Products.php @@ -205,18 +205,6 @@ class Products return $mdb -> get( 'products', 'min_roas', [ 'id' => $product_id ] ); } - static public function get_client_bestseller_min_roas( $client_id ) - { - global $mdb; - return $mdb -> get( 'clients', 'bestseller_min_roas', [ 'id' => $client_id ] ); - } - - static public function save_client_bestseller_min_roas( $client_id, $min_roas ) - { - global $mdb; - return $mdb -> update( 'clients', [ 'bestseller_min_roas' => $min_roas ], [ 'id' => $client_id ] ); - } - static public function save_min_roas( $product_id, $min_roas ) { global $mdb; diff --git a/autoload/factory/class.XmlFiles.php b/autoload/factory/class.XmlFiles.php deleted file mode 100644 index 0c4c8e7..0000000 --- a/autoload/factory/class.XmlFiles.php +++ /dev/null @@ -1,43 +0,0 @@ - query( - "SELECT id, name, google_ads_customer_id - FROM clients - WHERE deleted = 0 - ORDER BY name ASC" - ) -> fetchAll( \PDO::FETCH_ASSOC ); - - $rows = []; - - foreach ( $clients as $client ) - { - $client_id = (int) ( $client['id'] ?? 0 ); - $scheme = ( !empty( $_SERVER['HTTPS'] ) && $_SERVER['HTTPS'] !== 'off' ) ? 'https' : 'http'; - $host = $_SERVER['HTTP_HOST'] ?? ( $_SERVER['SERVER_NAME'] ?? 'localhost' ); - $relative_path = '/xml/custom-feed-' . $client_id . '.xml'; - $absolute_url = $scheme . '://' . $host . $relative_path; - $absolute_path = dirname( __DIR__, 2 ) . DIRECTORY_SEPARATOR . 'xml' . DIRECTORY_SEPARATOR . 'custom-feed-' . $client_id . '.xml'; - $exists = is_file( $absolute_path ); - $last_modified = $exists ? date( 'Y-m-d H:i:s', (int) filemtime( $absolute_path ) ) : ''; - - $rows[] = [ - 'client_id' => $client_id, - 'client_name' => (string) ( $client['name'] ?? '' ), - 'google_ads_customer_id' => (string) ( $client['google_ads_customer_id'] ?? '' ), - 'xml_relative_path' => $relative_path, - 'xml_url' => $absolute_url, - 'xml_exists' => $exists, - 'xml_last_modified' => $last_modified - ]; - } - - return $rows; - } -} diff --git a/autoload/services/class.FacebookAdsApi.php b/autoload/services/class.FacebookAdsApi.php new file mode 100644 index 0000000..cc82c4f --- /dev/null +++ b/autoload/services/class.FacebookAdsApi.php @@ -0,0 +1,410 @@ + access_token = trim( (string) $access_token ); + $this -> api_version = trim( (string) $api_version ) ?: 'v25.0'; + } + + public function is_configured() + { + return $this -> access_token !== ''; + } + + public function get_api_version() + { + return $this -> api_version; + } + + public static function get_setting( $key ) + { + global $mdb; + return $mdb -> get( 'settings', 'setting_value', [ 'setting_key' => $key ] ); + } + + public static function set_setting( $key, $value ) + { + global $mdb; + + if ( $mdb -> count( 'settings', [ 'setting_key' => $key ] ) ) + { + $mdb -> update( 'settings', [ 'setting_value' => $value ], [ 'setting_key' => $key ] ); + } + else + { + $mdb -> insert( 'settings', [ 'setting_key' => $key, 'setting_value' => $value ] ); + } + + if ( $key === 'facebook_ads_last_error' ) + { + $error_at = null; + if ( $value !== null && trim( (string) $value ) !== '' ) + { + $error_at = date( 'Y-m-d H:i:s' ); + } + + if ( $mdb -> count( 'settings', [ 'setting_key' => 'facebook_ads_last_error_at' ] ) ) + { + $mdb -> update( 'settings', [ 'setting_value' => $error_at ], [ 'setting_key' => 'facebook_ads_last_error_at' ] ); + } + else + { + $mdb -> insert( 'settings', [ 'setting_key' => 'facebook_ads_last_error_at', 'setting_value' => $error_at ] ); + } + } + } + + public static function normalize_ad_account_id( $account_id ) + { + $account_id = trim( (string) $account_id ); + if ( $account_id === '' ) + { + return null; + } + + if ( stripos( $account_id, 'act_' ) === 0 ) + { + $digits = preg_replace( '/\D+/', '', substr( $account_id, 4 ) ); + return $digits !== '' ? 'act_' . $digits : null; + } + + $digits = preg_replace( '/\D+/', '', $account_id ); + if ( $digits === '' ) + { + return null; + } + + return 'act_' . $digits; + } + + public function fetch_active_insights_last_days( $account_id, $days = 30, $active_only = true ) + { + $days = max( 1, min( 90, (int) $days ) ); + $since = date( 'Y-m-d', strtotime( '-' . ( $days - 1 ) . ' days' ) ); + $until = date( 'Y-m-d' ); + + return $this -> fetch_active_insights_range( $account_id, $since, $until, $active_only, $days ); + } + + public function fetch_active_insights_for_date( $account_id, $date, $active_only = true ) + { + $timestamp = strtotime( (string) $date ); + if ( !$timestamp ) + { + self::set_setting( 'facebook_ads_last_error', 'Niepoprawna data dla synchronizacji Facebook Ads.' ); + return false; + } + + $sync_date = date( 'Y-m-d', $timestamp ); + return $this -> fetch_active_insights_range( $account_id, $sync_date, $sync_date, $active_only, 1 ); + } + + public function fetch_active_insights_for_range( $account_id, $since, $until, $active_only = true ) + { + $since_ts = strtotime( (string) $since ); + $until_ts = strtotime( (string) $until ); + if ( !$since_ts || !$until_ts ) + { + self::set_setting( 'facebook_ads_last_error', 'Niepoprawny zakres dat dla synchronizacji Facebook Ads.' ); + return false; + } + + $days = (int) floor( ( $until_ts - $since_ts ) / 86400 ) + 1; + return $this -> fetch_active_insights_range( $account_id, date( 'Y-m-d', $since_ts ), date( 'Y-m-d', $until_ts ), $active_only, $days, true ); + } + + public function fetch_campaigns_all_time( $account_id, $active_only = true ) + { + $account_id = self::normalize_ad_account_id( $account_id ); + if ( !$account_id ) + { + return false; + } + + if ( !$this -> is_configured() ) + { + return false; + } + + $base_url = 'https://graph.facebook.com/' . rawurlencode( $this -> api_version ) . '/' . rawurlencode( $account_id ) . '/insights'; + + $params = [ + 'access_token' => $this -> access_token, + 'level' => 'campaign', + 'fields' => 'campaign_id,spend,action_values,purchase_roas', + 'date_preset' => 'maximum', + 'limit' => 500 + ]; + + if ( $active_only ) + { + $params['filtering'] = json_encode( [ + [ + 'field' => 'campaign.effective_status', + 'operator' => 'IN', + 'value' => [ 'ACTIVE' ] + ] + ] ); + } + + $rows = $this -> fetch_all_pages( $base_url, $params ); + if ( $rows === false ) + { + return false; + } + + $purchase_action_types = [ + 'purchase', 'omni_purchase', 'offsite_conversion.fb_pixel_purchase', + 'web_in_store_purchase', 'onsite_conversion.purchase', 'app_custom_event.fb_mobile_purchase' + ]; + + $campaigns = []; + foreach ( $rows as $row ) + { + $campaign_id = (string) ( $row['campaign_id'] ?? '' ); + if ( $campaign_id === '' ) + { + continue; + } + + $spend = (float) ( $row['spend'] ?? 0 ); + + // 1. ROAS z purchase_roas (preferowane) + $roas = 0.0; + if ( isset( $row['purchase_roas'] ) && is_array( $row['purchase_roas'] ) ) + { + foreach ( $row['purchase_roas'] as $pr ) + { + $at = trim( (string) ( $pr['action_type'] ?? '' ) ); + if ( in_array( $at, $purchase_action_types, true ) ) + { + $roas = round( (float) ( $pr['value'] ?? 0 ) * 100, 6 ); + break; + } + } + } + + // 2. Fallback: oblicz z action_values / spend + $conversion_value = 0.0; + if ( isset( $row['action_values'] ) && is_array( $row['action_values'] ) ) + { + foreach ( $row['action_values'] as $action ) + { + $at = trim( (string) ( $action['action_type'] ?? '' ) ); + if ( in_array( $at, $purchase_action_types, true ) ) + { + $conversion_value += (float) ( $action['value'] ?? 0 ); + break; + } + } + } + + if ( $roas <= 0 && $spend > 0 && $conversion_value > 0 ) + { + $roas = round( ( $conversion_value / $spend ) * 100, 6 ); + } + + $campaigns[ $campaign_id ] = [ + 'campaign_id' => $campaign_id, + 'spend_all_time' => $spend, + 'conversion_value_all_time' => $conversion_value, + 'roas_all_time' => $roas + ]; + } + + return $campaigns; + } + + private function fetch_active_insights_range( $account_id, $since, $until, $active_only = true, $days = 0, $aggregate = false ) + { + $account_id = self::normalize_ad_account_id( $account_id ); + if ( !$account_id ) + { + self::set_setting( 'facebook_ads_last_error', 'Niepoprawne Facebook Ads Account ID.' ); + return false; + } + + if ( !$this -> is_configured() ) + { + self::set_setting( 'facebook_ads_last_error', 'Brak tokena Facebook Ads API.' ); + return false; + } + + $since_ts = strtotime( (string) $since ); + $until_ts = strtotime( (string) $until ); + if ( !$since_ts || !$until_ts || $since_ts > $until_ts ) + { + self::set_setting( 'facebook_ads_last_error', 'Niepoprawny zakres dat Facebook Ads.' ); + return false; + } + + $since = date( 'Y-m-d', $since_ts ); + $until = date( 'Y-m-d', $until_ts ); + if ( (int) $days <= 0 ) + { + $days = (int) floor( ( $until_ts - $since_ts ) / 86400 ) + 1; + } + $days = max( 1, min( 90, (int) $days ) ); + + $base_url = 'https://graph.facebook.com/' . rawurlencode( $this -> api_version ) . '/' . rawurlencode( $account_id ) . '/insights'; + + $levels = [ + 'campaign' => [ + 'fields' => 'account_id,campaign_id,campaign_name,spend,impressions,clicks,ctr,cpc,action_values,purchase_roas,date_start,date_stop', + 'filtering_field' => 'campaign.effective_status' + ], + 'adset' => [ + 'fields' => 'account_id,campaign_id,campaign_name,adset_id,adset_name,spend,impressions,clicks,ctr,cpc,action_values,purchase_roas,date_start,date_stop', + 'filtering_field' => 'adset.effective_status' + ], + 'ad' => [ + 'fields' => 'account_id,campaign_id,campaign_name,adset_id,adset_name,ad_id,ad_name,spend,impressions,clicks,ctr,cpc,action_values,purchase_roas,date_start,date_stop', + 'filtering_field' => 'ad.effective_status' + ] + ]; + + $result = [ + 'meta' => [ + 'account_id' => $account_id, + 'api_version' => $this -> api_version, + 'days' => $days, + 'since' => $since, + 'until' => $until, + 'generated_at' => date( 'c' ) + ], + 'campaign' => [], + 'adset' => [], + 'ad' => [] + ]; + + foreach ( $levels as $level => $cfg ) + { + $params = [ + 'access_token' => $this -> access_token, + 'level' => $level, + 'fields' => $cfg['fields'], + 'time_range' => json_encode( [ 'since' => $since, 'until' => $until ] ), + 'limit' => 500 + ]; + + if ( !$aggregate ) + { + $params['time_increment'] = 1; + } + + if ( $active_only ) + { + $params['filtering'] = json_encode( [ + [ + 'field' => $cfg['filtering_field'], + 'operator' => 'IN', + 'value' => [ 'ACTIVE' ] + ] + ] ); + } + + $rows = $this -> fetch_all_pages( $base_url, $params ); + if ( $rows === false ) + { + return false; + } + + $result[ $level ] = $rows; + } + + self::set_setting( 'facebook_ads_last_error', null ); + return $result; + } + + private function fetch_all_pages( $url, $params = null ) + { + $all_rows = []; + $next_url = $url; + $next_params = $params; + + while ( $next_url ) + { + $payload = $this -> request_json( $next_url, $next_params ); + if ( $payload === false ) + { + return false; + } + + if ( isset( $payload['data'] ) && is_array( $payload['data'] ) ) + { + foreach ( $payload['data'] as $row ) + { + $all_rows[] = $row; + } + } + + $next_url = ''; + $next_params = null; + + if ( isset( $payload['paging']['next'] ) && is_string( $payload['paging']['next'] ) ) + { + $next_url = $payload['paging']['next']; + } + } + + return $all_rows; + } + + private function request_json( $url, $params = null ) + { + if ( is_array( $params ) ) + { + $query = http_build_query( $params ); + $url .= ( strpos( $url, '?' ) === false ? '?' : '&' ) . $query; + } + + $ch = curl_init( $url ); + curl_setopt_array( $ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_CONNECTTIMEOUT => 20, + CURLOPT_TIMEOUT => 120 + ] ); + + $response = curl_exec( $ch ); + $http_code = (int) curl_getinfo( $ch, CURLINFO_HTTP_CODE ); + $curl_error = curl_error( $ch ); + curl_close( $ch ); + + if ( $response === false ) + { + self::set_setting( 'facebook_ads_last_error', 'cURL error: ' . $curl_error ); + return false; + } + + $decoded = json_decode( (string) $response, true ); + if ( !is_array( $decoded ) ) + { + self::set_setting( 'facebook_ads_last_error', 'Niepoprawny JSON odpowiedzi Meta API. HTTP ' . $http_code ); + return false; + } + + if ( isset( $decoded['error'] ) ) + { + $message = (string) ( $decoded['error']['message'] ?? 'Nieznany blad Meta API' ); + $code = (string) ( $decoded['error']['code'] ?? '' ); + $subcode = (string) ( $decoded['error']['error_subcode'] ?? '' ); + self::set_setting( 'facebook_ads_last_error', 'Meta API: ' . $message . ' (code: ' . $code . ', subcode: ' . $subcode . ')' ); + return false; + } + + if ( $http_code >= 400 ) + { + self::set_setting( 'facebook_ads_last_error', 'Meta API HTTP ' . $http_code . ': ' . substr( (string) $response, 0, 1000 ) ); + return false; + } + + return $decoded; + } +} diff --git a/autoload/services/class.GoogleAdsApi.php b/autoload/services/class.GoogleAdsApi.php index cacef7b..fa5acc0 100644 --- a/autoload/services/class.GoogleAdsApi.php +++ b/autoload/services/class.GoogleAdsApi.php @@ -46,6 +46,17 @@ class GoogleAdsApi { $mdb -> insert( 'settings', [ 'setting_key' => $key, 'setting_value' => $value ] ); } + + if ( $key === 'google_ads_last_error' ) + { + $error_at = null; + if ( $value !== null && trim( (string) $value ) !== '' ) + { + $error_at = date( 'Y-m-d H:i:s' ); + } + + self::set_setting( 'google_ads_last_error_at', $error_at ); + } } // --- Konfiguracja --- @@ -567,7 +578,7 @@ class GoogleAdsApi if ( $http_code !== 200 || !$response ) { - self::set_setting( 'google_ads_last_error', 'searchStream failed: HTTP ' . $http_code . ' | ' . $error . ' | ' . substr( $response, 0, 500 ) ); + self::set_setting( 'google_ads_last_error', 'searchStream failed: HTTP ' . $http_code . ' | ' . $error . ' | ' . (string) $response ); return false; } @@ -634,7 +645,7 @@ class GoogleAdsApi if ( $http_code !== 200 || !$response ) { - self::set_setting( 'google_ads_last_error', 'mutate failed: HTTP ' . $http_code . ' | ' . $error . ' | ' . substr( (string) $response, 0, 1000 ) ); + self::set_setting( 'google_ads_last_error', 'mutate failed: HTTP ' . $http_code . ' | ' . $error . ' | ' . (string) $response ); return false; } @@ -1498,7 +1509,7 @@ class GoogleAdsApi if ( $http_code !== 200 || !$response ) { - self::set_setting( 'google_ads_last_error', 'generateKeywordIdeas failed: HTTP ' . $http_code . ' | ' . $error . ' | ' . substr( (string) $response, 0, 1000 ) ); + self::set_setting( 'google_ads_last_error', 'generateKeywordIdeas failed: HTTP ' . $http_code . ' | ' . $error . ' | ' . (string) $response ); return false; } @@ -2609,7 +2620,7 @@ class GoogleAdsApi } $last_error = (string) self::get_setting( 'google_ads_last_error' ); - $variant_errors[] = 'V' . ( $index + 1 ) . ': ' . substr( $last_error, 0, 350 ); + $variant_errors[] = 'V' . ( $index + 1 ) . ': ' . $last_error; } if ( !is_array( $results ) ) diff --git a/config.php b/config.php index ccf671c..c24aab0 100644 --- a/config.php +++ b/config.php @@ -14,3 +14,7 @@ $settings['cron_products_clients_per_run'] = 1; $settings['cron_campaigns_clients_per_run'] = 1; $settings['cron_products_urls_limit_per_client'] = 100; $settings['google_ads_conversion_window_days'] = 7; + + +$settings['facebook_ads_token'] = 'EAAVtpObUlr8BQ4sZBbRNOMFTMrSF2PhzOT9vZAFJZAX0xDz5NLlJxECbNmUT5cYLOM0UtH6QhH0OjmkYZAdJgYZBTUZA3tSw2KD9cjsDFMRUB7ReZBVcJBZCbwG8H51sckgfDIWaFRn2Hp2VdddC7hjDP5oY50krI0lUFxwcN08axIr3XUrxpydYZBfvlJl40cwZDZD'; +$settings['facebook_ads_conversion_window_days'] = 7; \ No newline at end of file diff --git a/docs/memory.md b/docs/memory.md index 33c2629..e701e3b 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -395,3 +395,32 @@ - Etykieta `custom_label_4` jest czytana i zapisywana z tabeli `products`. - Agregaty (`products_aggregate`) nie sa zrodlem dla pola `custom_label_4`. + +# 2026-02-20 - Usuniecie funkcjonalnosci `bestseller_min_roas` + +## Zmienione pliki + +- `templates/products/main_view.php` + - Usuniety filtr UI: pole `Bestseller min ROAS` (`#bestseller_min_roas`). + - Usuniety frontendowy loader wartosci progu (`load_client_bestseller_min_roas`). + - Usuniete wywolania loadera przy zmianie klienta i przy inicjalizacji strony. + - Usuniety zapis AJAX progu klienta na blur (`/products/save_client_bestseller_min_roas/`). + +- `autoload/controls/class.Products.php` + - Usuniete endpointy: + - `get_client_bestseller_min_roas()` + - `save_client_bestseller_min_roas()` + +- `autoload/factory/class.Products.php` + - Usuniete metody dostepu do progu ROAS klienta: + - `get_client_bestseller_min_roas( $client_id )` + - `save_client_bestseller_min_roas( $client_id, $min_roas )` + +- `autoload/controls/class.Cron.php` + - W `rebuild_products_temp_for_client()` usunieta logika automatycznej zmiany `custom_label_4` oparta o prog `bestseller_min_roas`. + - Funkcja pozostaje jako krok diagnostyczny zwracajacy liczbe scope z `products_aggregate`. + +## Efekt + +- Aplikacja nie odczytuje, nie zapisuje i nie wykorzystuje juz `bestseller_min_roas` w UI, endpointach ani w CRON. +- Automatyczne oznaczanie `custom_label_4 = bestseller` na podstawie tego progu zostalo wycofane. diff --git a/migrations/020_facebook_ads_base.sql b/migrations/020_facebook_ads_base.sql new file mode 100644 index 0000000..c48cab8 --- /dev/null +++ b/migrations/020_facebook_ads_base.sql @@ -0,0 +1,136 @@ +SET @sql = IF( + EXISTS ( + SELECT 1 + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'clients' + AND COLUMN_NAME = 'facebook_ads_account_id' + ), + 'DO 1', + 'ALTER TABLE `clients` ADD COLUMN `facebook_ads_account_id` VARCHAR(40) NULL DEFAULT NULL AFTER `google_ads_customer_id`' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +CREATE TABLE IF NOT EXISTS `facebook_campaigns` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `client_id` INT(11) NOT NULL, + `account_id` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, + `campaign_id` BIGINT(20) UNSIGNED NOT NULL, + `campaign_name` VARCHAR(255) NOT NULL DEFAULT '', + `status` VARCHAR(40) NULL DEFAULT NULL, + `effective_status` VARCHAR(40) NULL DEFAULT NULL, + `date_sync` DATETIME NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_facebook_campaigns_client_campaign` (`client_id`, `campaign_id`), + KEY `idx_facebook_campaigns_client_id` (`client_id`), + KEY `idx_facebook_campaigns_campaign_id` (`campaign_id`), + CONSTRAINT `FK_facebook_campaigns_clients` + FOREIGN KEY (`client_id`) REFERENCES `clients` (`id`) + ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `facebook_campaigns_history` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `facebook_campaign_id` INT(11) NOT NULL, + `spend` DECIMAL(20,6) NOT NULL DEFAULT 0.000000, + `impressions` INT(11) NOT NULL DEFAULT 0, + `clicks` INT(11) NOT NULL DEFAULT 0, + `ctr` DECIMAL(20,6) NOT NULL DEFAULT 0.000000, + `cpc` DECIMAL(20,6) NOT NULL DEFAULT 0.000000, + `date_add` DATE NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_facebook_campaigns_history_day` (`facebook_campaign_id`, `date_add`), + KEY `idx_facebook_campaigns_history_date_add` (`date_add`), + CONSTRAINT `FK_facebook_campaigns_history_campaigns` + FOREIGN KEY (`facebook_campaign_id`) REFERENCES `facebook_campaigns` (`id`) + ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `facebook_ad_sets` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `client_id` INT(11) NOT NULL, + `facebook_campaign_id` INT(11) NULL DEFAULT NULL, + `campaign_id` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, + `ad_set_id` BIGINT(20) UNSIGNED NOT NULL, + `ad_set_name` VARCHAR(255) NOT NULL DEFAULT '', + `status` VARCHAR(40) NULL DEFAULT NULL, + `effective_status` VARCHAR(40) NULL DEFAULT NULL, + `date_sync` DATETIME NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_facebook_ad_sets_client_ad_set` (`client_id`, `ad_set_id`), + KEY `idx_facebook_ad_sets_client_id` (`client_id`), + KEY `idx_facebook_ad_sets_facebook_campaign_id` (`facebook_campaign_id`), + KEY `idx_facebook_ad_sets_campaign_id` (`campaign_id`), + CONSTRAINT `FK_facebook_ad_sets_clients` + FOREIGN KEY (`client_id`) REFERENCES `clients` (`id`) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `FK_facebook_ad_sets_campaigns` + FOREIGN KEY (`facebook_campaign_id`) REFERENCES `facebook_campaigns` (`id`) + ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `facebook_ad_sets_history` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `facebook_ad_set_id` INT(11) NOT NULL, + `spend` DECIMAL(20,6) NOT NULL DEFAULT 0.000000, + `impressions` INT(11) NOT NULL DEFAULT 0, + `clicks` INT(11) NOT NULL DEFAULT 0, + `ctr` DECIMAL(20,6) NOT NULL DEFAULT 0.000000, + `cpc` DECIMAL(20,6) NOT NULL DEFAULT 0.000000, + `date_add` DATE NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_facebook_ad_sets_history_day` (`facebook_ad_set_id`, `date_add`), + KEY `idx_facebook_ad_sets_history_date_add` (`date_add`), + CONSTRAINT `FK_facebook_ad_sets_history_ad_sets` + FOREIGN KEY (`facebook_ad_set_id`) REFERENCES `facebook_ad_sets` (`id`) + ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `facebook_ads` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `client_id` INT(11) NOT NULL, + `facebook_campaign_id` INT(11) NULL DEFAULT NULL, + `facebook_ad_set_id` INT(11) NULL DEFAULT NULL, + `campaign_id` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, + `ad_set_id` BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, + `ad_id` BIGINT(20) UNSIGNED NOT NULL, + `ad_name` VARCHAR(255) NOT NULL DEFAULT '', + `status` VARCHAR(40) NULL DEFAULT NULL, + `effective_status` VARCHAR(40) NULL DEFAULT NULL, + `date_sync` DATETIME NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_facebook_ads_client_ad` (`client_id`, `ad_id`), + KEY `idx_facebook_ads_client_id` (`client_id`), + KEY `idx_facebook_ads_facebook_campaign_id` (`facebook_campaign_id`), + KEY `idx_facebook_ads_facebook_ad_set_id` (`facebook_ad_set_id`), + KEY `idx_facebook_ads_campaign_id` (`campaign_id`), + KEY `idx_facebook_ads_ad_set_id` (`ad_set_id`), + CONSTRAINT `FK_facebook_ads_clients` + FOREIGN KEY (`client_id`) REFERENCES `clients` (`id`) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `FK_facebook_ads_campaigns` + FOREIGN KEY (`facebook_campaign_id`) REFERENCES `facebook_campaigns` (`id`) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `FK_facebook_ads_ad_sets` + FOREIGN KEY (`facebook_ad_set_id`) REFERENCES `facebook_ad_sets` (`id`) + ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `facebook_ads_history` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `facebook_ad_id` INT(11) NOT NULL, + `spend` DECIMAL(20,6) NOT NULL DEFAULT 0.000000, + `impressions` INT(11) NOT NULL DEFAULT 0, + `clicks` INT(11) NOT NULL DEFAULT 0, + `ctr` DECIMAL(20,6) NOT NULL DEFAULT 0.000000, + `cpc` DECIMAL(20,6) NOT NULL DEFAULT 0.000000, + `date_add` DATE NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_facebook_ads_history_day` (`facebook_ad_id`, `date_add`), + KEY `idx_facebook_ads_history_date_add` (`date_add`), + CONSTRAINT `FK_facebook_ads_history_ads` + FOREIGN KEY (`facebook_ad_id`) REFERENCES `facebook_ads` (`id`) + ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/migrations/021_facebook_ads_conversion_metrics.sql b/migrations/021_facebook_ads_conversion_metrics.sql new file mode 100644 index 0000000..d3dc7a2 --- /dev/null +++ b/migrations/021_facebook_ads_conversion_metrics.sql @@ -0,0 +1,91 @@ +-- Metryki konwersji i ROAS dla historii Facebook Ads + +SET @sql = IF( + EXISTS ( + SELECT 1 + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'facebook_campaigns_history' + AND COLUMN_NAME = 'conversion_value' + ), + 'DO 1', + 'ALTER TABLE `facebook_campaigns_history` ADD COLUMN `conversion_value` DECIMAL(20,6) NOT NULL DEFAULT 0.000000 AFTER `cpc`' +); +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 = 'facebook_campaigns_history' + AND COLUMN_NAME = 'roas' + ), + 'DO 1', + 'ALTER TABLE `facebook_campaigns_history` ADD COLUMN `roas` DECIMAL(20,6) NOT NULL DEFAULT 0.000000 AFTER `conversion_value`' +); +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 = 'facebook_ad_sets_history' + AND COLUMN_NAME = 'conversion_value' + ), + 'DO 1', + 'ALTER TABLE `facebook_ad_sets_history` ADD COLUMN `conversion_value` DECIMAL(20,6) NOT NULL DEFAULT 0.000000 AFTER `cpc`' +); +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 = 'facebook_ad_sets_history' + AND COLUMN_NAME = 'roas' + ), + 'DO 1', + 'ALTER TABLE `facebook_ad_sets_history` ADD COLUMN `roas` DECIMAL(20,6) NOT NULL DEFAULT 0.000000 AFTER `conversion_value`' +); +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 = 'facebook_ads_history' + AND COLUMN_NAME = 'conversion_value' + ), + 'DO 1', + 'ALTER TABLE `facebook_ads_history` ADD COLUMN `conversion_value` DECIMAL(20,6) NOT NULL DEFAULT 0.000000 AFTER `cpc`' +); +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 = 'facebook_ads_history' + AND COLUMN_NAME = 'roas' + ), + 'DO 1', + 'ALTER TABLE `facebook_ads_history` ADD COLUMN `roas` DECIMAL(20,6) NOT NULL DEFAULT 0.000000 AFTER `conversion_value`' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/migrations/022_facebook_ads_roas_all_time.sql b/migrations/022_facebook_ads_roas_all_time.sql new file mode 100644 index 0000000..98a357e --- /dev/null +++ b/migrations/022_facebook_ads_roas_all_time.sql @@ -0,0 +1,16 @@ +-- ROAS all time dla historii kampanii Facebook Ads + +SET @sql = IF( + EXISTS ( + SELECT 1 + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'facebook_campaigns_history' + AND COLUMN_NAME = 'roas_all_time' + ), + 'DO 1', + 'ALTER TABLE `facebook_campaigns_history` ADD COLUMN `roas_all_time` DECIMAL(20,6) NOT NULL DEFAULT 0.000000 AFTER `roas`' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/temp_fb_authentication.html b/temp_fb_authentication.html new file mode 100644 index 0000000..d710c5c --- /dev/null +++ b/temp_fb_authentication.html @@ -0,0 +1,121 @@ + + +Authentication - Marketing API - Documentation - Meta for Developers + + + + + + + + + + + + + + + + + +

Authentication

+ +

Marketing API calls require an access token to be passed as a parameter in every API call.

+ +

See Access Tokens for Meta Technologies for more information on the various types of access tokens.

+

Get an Access Token for Your App

+ +

User Access Tokens

+ +

Graph API Explorer

+ +

You can get a user access token using the Graph API Explorer. To learn how to use the Graph API Explorer to make API calls, see the Graph API Explorer Guide.

+ +
    +
  1. In the Meta App field, select an app to obtain the access token for.
  2. +
  3. In the User or Page field, select User Token.
  4. +
  5. In the Add a Permission drop-down under Permissions, select the permissions you need (for example, ads_read and/or ads_management).
  6. +
  7. Click Generate Access Token. The box on top of the button is populated with the access token; store the token for later use.
  8. +
+ +

Debug

+ +

To get more information in the token you just generated, click on the information icon (i) in front of the token to open the Access Token Info table, which displays some basic information about the token. Click Open in Access Token Tool to be redirected to the Access Token Debugger.

+ +

While debugging, you can check:

+ +
    +
  • App ID: The app ID mentioned in the prerequisite section.
  • +
  • Expires: A time stamp. A short-lived token expires in an hour or two.
  • +
  • Scopes: Contains the permissions added in the Graph API Explorer.
  • +
+ +

Extend your access token

+ +
    +
  1. Paste your token in the text box of the Access Token Debugger and click Debug.
  2. +
  3. Click Extend Access Token at the bottom of the Access Token Info table to get a long-lived token, and copy that token for later use.
  4. +
+ +

Check your new token’s properties using the Access Token Debugger. It should have a longer expiration time, such as "60 days", or "Never" under Expires. See Long-Lived Access Token for more information.

+ +

System User Access Tokens

+ +

A system user access token is a type of access token that is associated with a system user account, which is an account that is created in Meta Business Manager for the purpose of managing assets and calling the Marketing API. System user access tokens are useful for server-to-server interactions where there is no user present to authenticate. They can be used to perform actions on behalf of the business, such as reading and writing business data, managing ad campaigns, and other ad objects.

+ +

One benefit of using a system user access token is that it does not expire, so it can be used in long-running scripts or services that need to access the Marketing API. Additionally, because system user accounts are not tied to a specific individual, they can be used to provide a level of separation between personal and business activity on Meta technologies.

+ +

System user tokens are also less likely subject to invalidation for other reasons compared to the long-lived user access tokens.

+ +

See System Users for more information.

+

Get an Access Token for Ad Accounts you Manage

+ +

After the owner of an ad account you are going to manage clicks the Allow button when you prompt for permissions, they are redirected to a URL that contains the value of the redirect_uri parameter and an authorization code:

+
+http://YOUR_URL?code=<AUTHORIZATION_CODE>
+

You can then build the URL for an API call that includes the endpoint for getting a token, your app ID, your site URL, your app secret, and the authorization code you just received:

+
+https://graph.facebook.com/v25.0/oauth/access_token?
+  client_id=<YOUR_APP_ID>
+  &redirect_uri=<YOUR_URL>
+  &client_secret=<YOUR_APP_SECRET>
+  &code=<AUTHORIZATION_CODE>
+

The API response should contain the generated access token:

+ +
    +
  • If you follow the server-side authentication flow, you get a persistent token.
  • +
  • If you follow the client-side authentication flow, you get a token with a finite validity period of about one to two hours. This can be exchanged for a persistent token by calling the Graph API endpoint for Extending Tokens.
  • +
+ +

If the API is to be invoked by a system user of a business, you can use a system user access token.

+ +

You can debug the access token, check for expiration, and validate the permissions granted using the access token debugger or the programmatic validation API.

+

Storing the Token

+ +

Your token should be safely stored in your database for subsequent API calls. Moving tokens between your client and server must be done securely over HTTPS to ensure account security. Read more about the implications of moving tokens between your clients and your server.

+ +

You should regularly check for validity of the token, and if necessary, prompt for permissions renewal. Even a persistent token can become invalid in a few cases, including the following:

+ +
    +
  • A password changes
  • +
  • Permissions are revoked
  • +
+ +

As user access tokens can be invalidated or revoked anytime for some reasons, your app should expect to have a flow to re-request permission from users. Check the validity of the user token when they start your app. If necessary, re-run the authentication flow to get an updated token.

+ +

If this is not possible for your app, you may need a different way to prompt for permissions. This can happen in cases where the API calls are not directly triggered by a user interface or are made by periodically run scripts. A possible solution is to send an email with instructions.

+

Best Practices for Secure Credential Management

+ +

To ensure the security of user credentials and access tokens, you should adhere to the following best practices:

+ +
    +
  • Use HTTPS: Always transmit access tokens over secure connections (HTTPS) to prevent interception by malicious actors.
  • +
  • Store Tokens Securely: Utilize secure storage solutions, such as encrypted databases, for storing access and refresh tokens, minimizing the risk of unauthorized access.
  • +
  • Limit Token Scope: Request only the minimum necessary permissions, reducing the risk of overexposure to user data.
  • +
  • Implement Token Expiration: Regularly refresh tokens and have a robust mechanism to handle expiration, ensuring continued access without exposing long-lived tokens.
  • +
+
+ + + diff --git a/temp_fb_authorization.html b/temp_fb_authorization.html new file mode 100644 index 0000000..65b5eb9 --- /dev/null +++ b/temp_fb_authorization.html @@ -0,0 +1,209 @@ + + +Authorization - Marketing API - Documentation - Meta for Developers + + + + + + + + + + + + + + +

Authorization

+ +

The authorization process verifies the users and apps that will be accessing the Marketing API and grants them permissions.

+

App Roles

+ +

In your app's dashboard, you can set roles for yourself or team members as necessary: Admin, Developer, Tester.

+ +

Note: Depending on your intended use case, you may need to submit your app for review to gain access to specific permissions related to ad management.

+

Access Levels, Permissions, and Features

+ +

Business apps are subject to an additional layer of Graph API authorization called access levels. During App Review, your app must also request specific permissions and features.

+

All developers must follow all Meta Platform Terms and Developer Policies. Calls on ANY access level are against production data.

+

Marketing API Access Levels vs. Ads Management Standard Access

+

Permissions and features for apps have two different access levels: standard access and advanced access (Note: The use of the term "standard access" here is not related to the Ads Management Standard Access feature.) The advanced access level of Ads Management Standard Access still requires an app to pass through review in order to have access to the feature.

Marketing API Access vs Ads Management Standard Access Mapping

+
Marketing API AccessAds Management Standard AccessAction

Development access

+

Standard access

+

By default

+

Standard access

+

Advanced access

+

Apply in App Dashboard

+

To check your current access level, go to App Dashboard > App Review > Permissions and Features.

+ +

Permissions and Features

+ +

Permissions

+ +

The permissions you should request change depending on which API you want to access.

+ +

If your app is only managing your ad account, standard access to the ads_read and ads_management permissions are sufficient. If your app is managing other people's ad accounts, you need advanced access to the ads_read and/or ads_management permissions. See all available permissions for business apps.

+ +

Features

+ +

The features you should request change depending on how you want to use our APIs. If you're managing ads, a common feature to request is Ads Management Standard Access. See all available features for business apps.

+ +
Feature access levels
+
Feature Access LevelDescription

Standard access

+

Business apps are automatically approved for standard access for all permissions and features available to the Business app type.

+ +

Use this option if you're getting started. You can build end-to-end workflows before requesting full permissions, and you can access an unlimited number of ad accounts.

+ +

Some API calls may not be available with standard access because they may belong to multiple accounts or the affected account can't be identified programmatically.

+

Advanced access

+

Advanced access must be approved through the App Review process on an individual permission and feature basis.

+ +
    +
  1. To request advanced access, go to your app’s dashboard and click App Review > Permissions and Features.
  2. +
  3. Find the permission or feature you would like to access and, under Action, click Request advanced access. You can select one or more features. Once you have selected your options, click Continue the Request. You'll be taken to a screen that guides you through the submission process.
  4. +
+ +

After you submit your information, Meta responds with an approval or denial and additional information if your app is not qualified for standard access.

+ +

If you're approved for advanced access, you need to do the following to maintain your status:

+ +
    +
  • Have successfully made at least 1500 Marketing API calls in the last 15 days.
  • +
  • Have made Marketing API calls with an error rate of less than 15% in the last 15 days.
  • +
+
Access level significance
+ +

The table below shows how standard and advanced access levels impact the Ads Management Standard Access feature.

+
+Standard Access + +Advanced Access +

Account Limits

+

Manage an unlimited number of ad accounts. App admins or developers can make API calls on behalf of ad account admins or advertisers.

+

Manage an unlimited number of ad accounts, assuming you get ads_read or ads_management permission from the ad account.

+

Rate Limits

+

Heavily rate-limited per ad account. For development only. Not for production apps running for live advertisers.

+

Lightly rate limited per ad account.

+

Business Manager

+

Limited access to Business Manager and Catalog APIs. No Business Manager access to manage ad accounts, user permissions and Pages.

+

Access to all Business Manager and Catalog APIs.

+

System User

+

Can create 1 system user and 1 admin system user.

+

Can create 10 system users and 1 admin system user.

+

Page Creation

+

Cannot create Pages through the API.

+

Cannot create Pages through the API.

+
Get Advanced Access
+ +

In order to get advanced access of Ads Management Standard Access, your app needs to meet these requirements:

+ +
    +
  • Have successfully made at least 1500 Marketing API calls in the last 15 days.
  • +
  • Have made Marketing API calls with an error rate of less than 15% in the last 15 days.
  • +
+ +

If you're managing someone's ads, use the scope parameter to prompt them for the ads_management or ads_read permissions. Your app gets access when they click Allow.

+
+https://www.facebook.com/v25.0/dialog/oauth?
+  client_id=<YOUR_APP_ID>
+  &redirect_uri=<YOUR_URL>
+  &scope=ads_management
+

Note: When inputting the YOUR_URL field, put a trailing / (for example, http://www.facebook.com/).

+ +
Example Use Cases
+
Use CaseWhat To Request

You want to read and manage ads for ad accounts you own or have been granted access to by the ad account owner.

+
    +
  • Permission: ads_management
  • +
  • Feature: Ads Management Standard Access
  • +
+

You want to read ad reports for ad accounts you own or have been granted access to by the ad account owner.

+
    +
  • Permission: ads_read
  • +
  • Feature: Ads Management Standard Access
  • +
+

You want to pull ad reports from a set of clients and to both read and manage ads from another set of clients.

+
    +
  • Permissions: ads_management and ads_read
  • +
  • Feature: Ads Management Standard Access
  • +
+

Business Verification

+ +

Business verification is a process that allows us to verify your identity as a business entity, which we require if your app will access sensitive data. Learn more about the Business Verification process.

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + + + + diff --git a/temp_fb_get_started.html b/temp_fb_get_started.html new file mode 100644 index 0000000..7eddf3e --- /dev/null +++ b/temp_fb_get_started.html @@ -0,0 +1,49 @@ + + +Get Started - Marketing API - Documentation - Meta for Developers + + + + + + + + + + + + + + + + + +

Get Started with the Marketing API

+ +

To effectively utilize the Marketing API, users must follow some key steps to set up their environment and gain access to the API's features. This section outlines the prerequisites necessary for getting started.

+

Ad Account Requirements

+

To manage your ads through the Marketing API, you must have an active ad account. This account is crucial not only for running campaigns but also for managing billing settings and setting spending limits. An ad account allows you to track your advertising expenses, monitor performance, and optimize your campaigns effectively.

Finding Your Ad Account Number

+

Locating your ad account number can be done through the Meta Ads Manager.

    +
  1. Log into Facebook: Start by logging into your Facebook account that is associated with your business.
  2. +
  3. Access Ads Manager: Ads Manager can be found in the drop-down menu in the upper right corner of your Facebook homepage or business page.
  4. +
  5. Locate your ad account: In Ads Manager, click on the ad account Settings from the menu on the bottom left of the screen.
  6. +
  7. View ad account information: In the Settings screen, you will find your ad account number listed along with other details such as your billing information and spending limits.
  8. +
+

Meta Developer Account

+

See Register as a Meta Developer for more information.

Create an App

+

See Create an App for more information on setting up an app in the App Dashboard as well as app types and use cases.

Authorization and Authentication

+ +

See Authorization for more information on verifying the users and apps that will be accessing the Marketing API and granting them permissions.

+ +

See Authentication for more information on getting, extending, and renewing access tokens with the Marketing API.

+
+ + + diff --git a/temp_fb_insights_async.html b/temp_fb_insights_async.html new file mode 100644 index 0000000..73c20b0 --- /dev/null +++ b/temp_fb_insights_async.html @@ -0,0 +1,303 @@ + + +Limits & Best Practices - Marketing API - Documentation - Meta for Developers + + + + + + + + + + + + + + +

Limits and Best Practices

+

Beginning June 10, 2025, to improve overall API performance, reach will no longer be returned for standard queries that apply breakdowns and use start_dates more than 13 months old. (Responses to such requests will omit reach and related fields, such as frequency and cpp.)

+ +

To apply breakdowns and still retrieve >13-month-old reach values, you can use asynchronous jobs to make up to 10 requests per Ad Account per day. Check the x-Fb-Ads-Insights-Reach-Throttle header to monitor how close you are to that rate-limit, and note that once the rate-limit is breached, requests will omit reach and related fields.

+ +

When the rate limit threshold for reach-related breakdowns is exceeded, the following error message will be returned:

+
 Reach-related metric breakdowns are unavailable due to rate limit threshold.

Facebook Insights API provides performance data from Facebook marketing campaigns. To protect system performance and stability, we have protective measures to equally distribute system resources among applications. All policies we describe below are subject to change.

+

Timeouts

+ +

The most common issue causing failure for Ads Insights API calls is too many requests and time outs.

+ +
    +
  • /GET or synchronous requests can return out-of-memory or timeout errors.
  • +
  • /POST or asynchronous requests can return timeout errors. For asynchronous requests, it can take up to an hour to complete a request including retry attempts. For example, if you make a query that tries to fetch a large volume of data for many ad level objects.
  • +
+ +

Recommendations

+ +
    +
  • There is no explicit limit for when a query will fail. When it times out, try to break down the query into smaller queries by using filters like date range.
  • +
  • Unique metrics are time consuming to compute. Try to query unique metrics in a separate call to improve performance of non-unique metrics.
  • +
+

Data Per Call Limits

+ +

We use data-per-call limits to prevent a query from retrieving too much data beyond what the system can handle. There are 2 types of data limits:

+ +
    +
  1. By number of rows in response, and
  2. +
  3. By number of data points required to compute the total, such as summary row.
  4. +
+ +

These limits apply to both sync and async /insights calls, and we return an error:

+
error_code = 100,  CodeException (error subcode: 1487534)

Best Practices, Data Per Call Limits

+ +
    +
  • Limit your query by limiting the date range or number of ad ids. You can also limit your query to metrics that are necessary, or break it down into multiple queries with each requesting a subset of metrics.
  • +
  • Avoid account-level queries that include high cardinality breakdowns such as action_target_id or product_id, and wider date ranges like lifetime.
  • +
  • Use /insights edge directly with lower level ad objects to retrieve granular data for that level. For example, first use the account-level query to fetch the list of lower-level object ids with level and filtering parameters. In this example, we fetch all campaigns that recorded some impressions:
  • +
+
+curl -G \
+-d 'access_token=<ACCESS_TOKEN>' \
+-d 'level=campaign' \
+-d 'filtering=[{field:"ad.impressions",operator:"GREATER_THAN",value:0}]' \
+'https://graph.facebook.com/v2.7/act_<ACCOUNT_ID>/insights'
+
    +
  • We can then use /<campaign_id>/insights with each returned value to query and batch the insights requests for these campaigns in a single call:
  • +
+
+curl \
+-F 'access_token=<ACCESS_TOKEN>' \
+-F 'batch=[ \
+  { \
+    "method": "GET", \
+    "relative_url": "v25.0/<CAMPAIGN_ID_1>/insights?fields=impressions,spend,ad_id,adset_id&level=ad" \
+  }, \
+  { \
+    "method": "GET", \
+    "relative_url": "v25.0/<CAMPAIGN_ID_2>/insights?fields=impressions,spend,ad_id,adset_id&level=ad" \
+  }, \
+  { \
+    "method": "GET", \
+    "relative_url": "v25.0/<CAMPAIGN_ID_3>/insights?fields=impressions,spend,ad_id,adset_id&level=ad" \
+  } \
+]' \
+'https://graph.facebook.com'
+
    +
  • Use filtering parameter only to retrieve insights for ad objects with data. The field value specified in filtering uses DOT notation to denote the fields under the object. Please note that filtering with STARTS_WITH and CONTAIN does not change the summary data. In this case, use the IN operator. See example of a filtering request:
  • +
+
curl -G \
+-d 'access_token=<ACCESS_TOKEN>' \
+-d 'level=ad' \
+-d 'filtering=[{field:"ad.impressions",operator:"GREATER_THAN",value:0},]' \
+'https://graph.facebook.com/v25.0/act_<ACCOUNT_ID>/insights'
    +
  • Use date_preset if possible. Custom date ranges are less efficient to run in our system.
  • +
  • Use batch requests for multiple sync calls and async to query for large volume of data to avoid timeouts.
  • +
  • Try sync calls first and then use async calls in cases where sync calls timeout
  • +
  • Insights refresh every 15 minutes and do not change after 28 days of being reported
  • +
+

Insights Call Load Limits

+ +

Ninety days from the release of v3.3 and effective for all public versions, we change the ad account level rate limit to better reflect the volume of API calls needed. We compute the rate limit quota on your Marketing API access tier and the business owning your app. see Access and Authentication. This change applies to all Ads Insights API endpoints: GET {adaccount_ID}/insights, GET {campaign_ID}/insights, GET {adset_ID}/insights, GET {ad_ID}/insights, POST {adaccount_ID}/insights, POST {campaign_ID}/insights, POST {adset_ID}/insights, POST {ad_ID}/insights.

+ +

We use load limits for optimal reporting experience. We measure API calls for their rate as well as the resources they require. We allow a fixed load limit per application per second. When you exceed that limit, your requests fail.

+ +

Check the x-fb-ads-insights-throttle HTTP header in every API response to know how close your app is to its limit as well as to estimate how heavy a particular query may be. Insights calls are also subject to the default ad account limits shown in the x-ad-account-usage HTTP header. More details can be found here Marketing API, Best Practices

+ +

Once an app reaches its limit, the call gets an error response with error_code = 4, CodedException. You should stay well below your limit. If your app reaches its allowed limits, only a certain percentage of requests go through, depending on the query, and the rate.

+ +

We apply rate limiting to each app sending synchronous and asynchronous /insights calls combined. The two main parameters limits are counted against are by application, and by ad account.

+ +

Here's an example of the HTTP header with an application's accrued score as a percentage of the limits:

+
X-FB-Ads-Insights-Throttle: { "app_id_util_pct": 100, "acc_id_util_pct": 10, "ads_api_access_tier": "standard_access" }

The header "x-fb-ads-insights-throttle" is a JSON value containing these info:

+ +
    +
  • app_id_util_pct — The percentage of allocated capacity for the associated app_id has consumed.
  • +
  • acc_id_util_pct — The percentage of allocated capacity for the associated ad account_id has consumed.
  • +
  • ads_api_access_tier — Tiers allows your app to access the Marketing API. standard_access enables lower rate limiting.
  • +
+ +

Global Rate Limits

+ +

During periods of elevated global load to the /insights endpoint, the system can throttle requests to protect the backend. This can occur in rare cases when too many queries of high complexity (large time ranges, complex metrics, and/or high number of ad object IDs) are coming at the same time. This will manifest in an error that looks like this:

+
error_code = 4,  CodeException (error subcode: 1504022), error_title: Too many API requests

During these periods, it is advised to reduce calls, wait a short period, and query again.

+ +

Rate Limits Best Practices

+ +
    +
  • Sending several queries at once are more likely to trigger our rate limiting. Try to spread your /insights queries by pacing them with wait time in your job.
  • +
  • Use the rate information in the HTTP response header to moderate your calls. Add a back-off mechanism to slow down or pause your /insights queries when you come close to hitting 100% utility for your application, or for your ad account.
  • +
  • We report ad insights data in the ad account's timezone. To retrieve insights data for the associated ad account daily, consider the time of day using the account timezone. This helps pace queries throughout the day.
  • +
  • Check the ads_api_access_tier that allows you to access the Marketing API. By default, apps are in the development_access tier and standard_access enables lower rate limiting. To get a higher rate limit and get to the standard tier, you can apply for the "Advanced Access" to the Ads Management Standard Access feature.
  • +
+

Insights API Asynchronous Jobs

+ +

Fetch stats on many objects and apply filtering and sorting; we made the asynchronous workflow simpler:

+ +

1. Send a POST request to <AD_OBJECT>/insights endpoint, which responds with the id of an Ad Report Run.

+
+{
+  "report_run_id": 6023920149050,
+}
+

Do not store the report_run_id for long term use, it expires after 30 days.

+ +

2. Ad Report Runs contain information about this asynchronous job, such as async_status. Poll this field until async_status is Job Completed and async_percent_completion is 100.

+
+{
+  "id": "6044775548468",
+  "account_id": "1010035716096012",
+  "time_ref": 1459788928,
+  "time_completed": 1459788990,
+  "async_status": "Job Completed",
+  "async_percent_completion": 100
+}
+

Note: Beginning with Marketing API v25.0, if the report fails, you will see the corresponding error_code, error_message, error_subcode, error_user_title, and error_user_msg fields returned by default. See the Ads Insights Error Codes for more details on the returned error codes.

+ +

3. Then you can query <AD_REPORT_RUN_ID>/insights edge to fetch the final result.

+
+{
+  "data": [
+    {
+      "impressions": "9708",
+      "date_start": "2009-03-28",
+      "date_stop": "2016-04-04"
+    },
+    {
+      "impressions": "18841",
+      "date_start": "2009-03-28",
+      "date_stop": "2016-04-04"
+    }
+  ],
+  "paging": {
+    "cursors": {
+      "before": "MAZDZD",
+      "after": "MQZDZD"
+    }
+  }
+}
+

This job gets all stats for the account and returns an asynchronous job ID:

+
+curl \
+  -F 'level=campaign' \
+  -F 'access_token=<ACCESS_TOKEN>' \
+  https://graph.facebook.com/v25.0/<CAMPAIGN_ID>/insights
+curl -G \
+  -d 'access_token=<ACCESS_TOKEN>' \
+  https://graph.facebook.com/v25.0/1000002
+curl -G \
+  -d 'access_token=<ACCESS_TOKEN>' \
+  https://graph.facebook.com/v25.0/1000003/insights
+

Async Job Status

+
StatusDescription

Job Not Started

+

Job has not started yet.

+

Job Started

+

Job has been started, but is not yet running.

+

Job Running

+

Job has started running.

+

Job Completed

+

Job has successfully completed.

+

Job Failed

+

Job has failed. Review your query and try again.

+

Job Skipped

+

Job has expired and skipped. Please resubmit your job and try again.

+

Export Reports

+ +

We provide a convenience endpoint for exporting <AD_REPORT_RUN_ID> to a localized human-readable format.

+ +

Note: this endpoint is not part of our versioned Graph API and therefore does not conform to its breaking-change policy. Scripts and programs should not rely on the format of the result as it may change unexpectedly.

+
+  curl -G \
+  -d 'report_run_id=<AD_REPORT_RUN_ID>' \
+  -d 'name=myreport' \
+  -d 'format=xls' \
+'https://www.facebook.com/ads/ads_insights/export_report/'
+  
NameDescription

name

+
string

Name of downloaded file

+

format

+
enum{csv,xls}

Format of file

+

report_run_id

+
integer

ID of report to run

+

access_token

+
string

Permissions granted by the logged-in user. Provide this to export reports for another user.

+

Discrepancy with Ads Manager

+

Beginning June 10, 2025, to reduce discrepancies with Meta Ads Manager, the use_unified_attribution_setting and action_report_time parameters will be disregarded and API responses will mimic Ads Manager settings:

+ +
    +
  • Attributed values will be based on ad set level attribution settings (similar to use_unified_attribution_setting=true), and inline/on-ad actions will be included in 1d_click or 1d_view attribution window data. After this change, standalone inline attribution window data will no longer be returned.
  • +
  • Actions will be reported using action_report_time=mixed: on-Meta actions (e.g., Link Clicks) will use impression-based reporting time; whereas off-Meta actions (e.g., Web Purchases) will leverage conversion-based reporting time.
  • +
+

The default behavior of the API is different from the default behavior of Ads Manager. If you would like to observe the same behavior as in Ads Manager, set the use_unified_attribution_setting field to true.

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + +
+ + + + + + diff --git a/temp_fb_system_users.html b/temp_fb_system_users.html new file mode 100644 index 0000000..621e4bf --- /dev/null +++ b/temp_fb_system_users.html @@ -0,0 +1,125 @@ + + +System Users - Business Management APIs - Documentation - Meta for Developers + + + + + + + + + + + + + +

System Users

+ +

Make programatic, automated actions on ad objects or Pages, or do programmatic ads buying. System users represent servers or software making API calls to assets owned or managed by a Business Manager.

+

The easiest, quickest way to create a system user is in the Business Manager tool. See Ads Help Center: How do I add a new System User.

+

To take actions for a person managing an ad account, you should take them through the standard Facebook oAuth flow and get a user access token. If you try to use system user tokens to work on ad objects or Pages on behalf of a real user of your software, you cannot link this user to those actions unless you take them through Facebook Login.

+

Documentation Contents

+

Overview

+ +

Learn about the two different types of system users: admin system user and system user. Read API limits for system users.

+ +

Guide: Create System Users

+ +

Create, retrieve, and update a system user. Learn how to invalidate access tokens.

+ +

Guide: Install Apps and Generate Tokens

+ +

A guide to install apps and generate access tokens through your system user.

+

Guide: Permissions

+ +

Assign system user tasks on ad accounts, user page, and proxied assets. Retrieve permissions.

+ +

Guide: API Calls

+ +

How to make automated API calls for Marketing API and Pages API.

+ +

System Users And Custom Audiences

+ +

Rules for system users to operate with a Custom File Custom Audience.

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + +
+ + + + + + diff --git a/templates/campaign_terms/main_view.php b/templates/campaign_terms/main_view.php index d277dc8..321236a 100644 --- a/templates/campaign_terms/main_view.php +++ b/templates/campaign_terms/main_view.php @@ -1733,11 +1733,16 @@ function load_campaigns_for_client( restore_campaign_id ) var data = JSON.parse( response ); var campaigns = Object.entries( data.campaigns || {} ); + campaigns = campaigns.filter( function( pair ) + { + var row = pair[1] || {}; + var campaign_name = String( row.campaign_name || '' ).toLowerCase().trim(); + return campaign_name !== '--- konto ---'; + } ); + campaigns.sort( function( a, b ) { var nameA = String( ( a[1] && a[1].campaign_name ) ? a[1].campaign_name : '' ).toLowerCase(); var nameB = String( ( b[1] && b[1].campaign_name ) ? b[1].campaign_name : '' ).toLowerCase(); - if ( nameA === '--- konto ---' ) return -1; - if ( nameB === '--- konto ---' ) return 1; if ( nameA > nameB ) return 1; if ( nameA < nameB ) return -1; return 0; diff --git a/templates/campaigns/main_view.php b/templates/campaigns/main_view.php index 9c0642f..a58bdbc 100644 --- a/templates/campaigns/main_view.php +++ b/templates/campaigns/main_view.php @@ -36,9 +36,15 @@
+ + @@ -468,6 +474,8 @@ $( function() $( '#products' ).DataTable().destroy(); $( '#products tbody' ).empty(); } + $( '#history_bulk_actions' ).hide(); + $( '#select_all_history' ).prop( 'checked', false ); if ( vals.length === 1 ) { @@ -485,6 +493,7 @@ $( function() lengthChange: false, pageLength: 15, columns: [ + { width: '30px', orderable: false, className: 'dt-center', searchable: false }, { width: '130px', name: 'date', orderable: false, className: "nowrap" }, { width: '120px', name: 'roas30', orderable: false, className: "dt-type-numeric" }, { width: '120px', name: 'roas_all_time', orderable: false, className: "dt-type-numeric" }, @@ -518,6 +527,93 @@ $( function() } }); + // --- Checkboxy historii: select all, licznik, bulk delete --- + + function updateHistoryBulkActions() + { + var checked = $( '#products .history-check:checked' ); + var count = checked.length; + $( '#history_selected_count' ).text( count ); + $( '#history_bulk_actions' ).toggle( count > 0 ); + } + + $( 'body' ).on( 'change', '#select_all_history', function() + { + var isChecked = this.checked; + $( '#products .history-check' ).prop( 'checked', isChecked ); + updateHistoryBulkActions(); + }); + + $( 'body' ).on( 'change', '.history-check', function() + { + var all = $( '#products .history-check' ); + var checked = $( '#products .history-check:checked' ); + $( '#select_all_history' ).prop( 'checked', all.length > 0 && all.length === checked.length ); + updateHistoryBulkActions(); + }); + + $( 'body' ).on( 'draw.dt', '#products', function() + { + $( '#select_all_history' ).prop( 'checked', false ); + updateHistoryBulkActions(); + }); + + $( 'body' ).on( 'click', '#delete_history_entries', function() + { + var ids = []; + $( '#products .history-check:checked' ).each( function() { + ids.push( $( this ).val() ); + }); + + if ( !ids.length ) return; + + $.confirm({ + title: 'Potwierdzenie usuniecia', + content: 'Czy na pewno chcesz usunac ' + ids.length + ' ' + ( ids.length === 1 ? 'wpis' : 'wpisow' ) + ' z historii?
Ta operacja jest nieodwracalna.', + type: 'red', + buttons: { + confirm: { + text: 'Usun (' + ids.length + ')', + btnClass: 'btn-red', + keys: ['enter'], + action: function() + { + $.ajax({ + url: '/campaigns/delete_history_entries/', + type: 'POST', + data: { ids: ids }, + success: function( response ) + { + var data = JSON.parse( response ); + if ( data.success ) + { + $.alert({ + title: 'Sukces', + content: 'Usunieto ' + data.deleted + ' ' + ( data.deleted === 1 ? 'wpis' : 'wpisow' ) + '.', + type: 'green', + autoClose: 'ok|2000' + }); + if ( $.fn.DataTable.isDataTable( '#products' ) ) + $( '#products' ).DataTable().ajax.reload( null, false ); + reloadChart(); + } + else + { + $.alert({ + title: 'Blad', + content: data.message || 'Nie udalo sie usunac wpisow.', + type: 'red' + }); + } + } + }); + } + }, + cancel: { text: 'Anuluj' } + } + }); + }); + var saved_client_id = storage_get( STORAGE_CLIENT_KEY ); var saved_campaign_id = storage_get( STORAGE_CAMPAIGN_KEY ); diff --git a/templates/clients/main_view.php b/templates/clients/main_view.php index 344ffa4..9c8c6be 100644 --- a/templates/clients/main_view.php +++ b/templates/clients/main_view.php @@ -14,6 +14,7 @@ + @@ -41,6 +42,13 @@ — brak — + - @@ -121,6 +134,11 @@ +
+ + + Mozesz podac act_... albo same cyfry +
@@ -148,6 +166,7 @@ function openClientForm() $( '#client-name' ).val( '' ); $( '#client-active' ).val( '1' ); $( '#client-gads-id' ).val( '' ); + $( '#client-fbads-id' ).val( '' ); $( '#client-gmc-id' ).val( '' ); $( '#client-gads-start' ).val( '' ); $( '#client-modal' ).fadeIn(); @@ -166,6 +185,7 @@ function editClient( id ) $( '#client-name' ).val( data.name ); $( '#client-active' ).val( parseInt( data.active, 10 ) === 0 ? '0' : '1' ); $( '#client-gads-id' ).val( data.google_ads_customer_id || '' ); + $( '#client-fbads-id' ).val( data.facebook_ads_account_id || '' ); $( '#client-gmc-id' ).val( data.google_merchant_account_id || '' ); $( '#client-gads-start' ).val( data.google_ads_start_date || '' ); $( '#client-modal' ).fadeIn(); @@ -245,7 +265,8 @@ function syncClient( id, pipeline, btn ) var labels = { campaigns: 'kampanii', products: 'produktow', - campaigns_product_alerts_merchant: 'walidacji Merchant' + campaigns_product_alerts_merchant: 'walidacji Merchant', + facebook_ads: 'Facebook Ads' }; $.post( '/clients/force_sync', { id: id, pipeline: pipeline }, function( response ) @@ -259,9 +280,16 @@ function syncClient( id, pipeline, btn ) { $btn.addClass( 'is-queued' ); + var cron_hint = pipeline === 'facebook_ads' + ? ' Dane zostana pobrane przy najblizszym uruchomieniu /cron/cron_facebook_ads.' + : ' Dane zostana pobrane przy najblizszym uruchomieniu CRON.'; + var refresh_hint = pipeline === 'facebook_ads' + ? ' Wymuszenie Facebook Ads nadpisuje dane dla okresu z config.php i pobiera tylko aktywne kampanie/zestawy/reklamy.' + : ''; + $.alert({ title: 'Zakolejkowano', - content: 'Synchronizacja ' + labels[ pipeline ] + ' zostala zakolejkowana. Dane zostana pobrane przy najblizszym uruchomieniu CRON.', + content: 'Synchronizacja ' + labels[ pipeline ] + ' zostala zakolejkowana.' + cron_hint + refresh_hint, type: 'green', autoClose: 'ok|3000' }); @@ -362,6 +390,7 @@ function loadSyncStatus() if ( info.campaigns ) html += renderSyncBar( 'K:', info.campaigns[0], info.campaigns[1] ); if ( info.products ) html += renderSyncBar( 'P:', info.products[0], info.products[1] ); if ( info.merchant ) html += renderSyncBar( 'M:', info.merchant[0], info.merchant[1] ); + if ( info.facebook_ads ) html += renderSyncBar( 'FB:', info.facebook_ads[0], info.facebook_ads[1] ); html += '
'; $cell.html( html ); diff --git a/templates/facebook_ads/main_view.php b/templates/facebook_ads/main_view.php new file mode 100644 index 0000000..23fc89d --- /dev/null +++ b/templates/facebook_ads/main_view.php @@ -0,0 +1,448 @@ +
+
+

Facebook Ads

+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+
+
+ +
+
Data ROAS (30 dni) ROAS (all time)Nazwa klienta Status Google Ads Customer IDFacebook Ads Account ID Merchant Account ID Dane od Sync + + + + — brak — + + @@ -73,6 +81,11 @@ + + + @@ -84,7 +97,7 @@
+

Brak klientów. Dodaj pierwszego klienta.

+ + + + + + + + + + + + + + +
DataSpendImpressionsClicksCTRCPCWartosc konwersjiROAS (30 dni)ROAS (all time)
+
+ + + diff --git a/templates/products/main_view.php b/templates/products/main_view.php index 68c0c27..a7a0bc2 100644 --- a/templates/products/main_view.php +++ b/templates/products/main_view.php @@ -31,10 +31,6 @@ -
- - -
@@ -521,25 +517,6 @@ $( function() return false; } - function load_client_bestseller_min_roas( client_id ) - { - if ( !client_id ) - { - $( '#bestseller_min_roas' ).val( '' ); - return; - } - - $.ajax({ - url: '/products/get_client_bestseller_min_roas/', - type: 'POST', - data: { client_id: client_id }, - success: function( response ) { - var data = JSON.parse( response ); - $( '#bestseller_min_roas' ).val( data.status == 'ok' ? data.min_roas : '' ); - } - }); - } - function load_products_campaigns( client_id, selected_campaign_id ) { var $campaign = $( '#products_campaign_id' ); @@ -1084,7 +1061,6 @@ $( function() localStorage.removeItem( 'products_ad_group_id' ); update_delete_ad_group_button_state(); - load_client_bestseller_min_roas( client_id ); load_products_campaigns( client_id, '' ).done( function() { load_products_ad_groups( '', '' ).done( function() { update_delete_ad_group_button_state(); @@ -1189,7 +1165,6 @@ $( function() $( '#client_id' ).val( savedClient ); } - load_client_bestseller_min_roas( $( '#client_id' ).val() || '' ); load_products_campaigns( $( '#client_id' ).val() || '', savedCampaign ).done( function() { var selected_campaign_id = $( '#products_campaign_id' ).val() || ''; load_products_ad_groups( selected_campaign_id, savedAdGroup ).done( function() { @@ -1631,25 +1606,6 @@ $( function() }); }); - // Zapis min ROAS klienta (bestseller) - $( 'body' ).on( 'blur', '#bestseller_min_roas', function() - { - var min_roas = $( this ).val(); - var client_id = $( '#client_id' ).val(); - - $.ajax({ - url: '/products/save_client_bestseller_min_roas/', - type: 'POST', - data: { client_id: client_id, min_roas: min_roas }, - success: function( response ) { - data = JSON.parse( response ); - if ( data.status == 'ok' ) { - $.alert({ title: 'Zapisano', content: 'Minimalny ROAS bestsellerów został zapisany.', type: 'green', autoClose: 'ok|2000', buttons: { ok: function() {} } }); - } - } - }); - }); - // Checkbox: zaznacz/odznacz wszystkie function updateSelectedCount() { var count = $( '.product-checkbox:checked' ).length; diff --git a/templates/site/layout-logged.php b/templates/site/layout-logged.php index 043d4c1..c6c2f14 100644 --- a/templates/site/layout-logged.php +++ b/templates/site/layout-logged.php @@ -39,6 +39,8 @@ $module = $this -> current_module; $google_ads_modules = [ 'campaigns', 'campaign_terms', 'products', 'campaign_alerts', 'clients', 'xml_files' ]; $is_google_ads_module = in_array( $module, $google_ads_modules, true ); + $facebook_ads_modules = [ 'facebook_ads' ]; + $is_facebook_ads_module = in_array( $module, $facebook_ads_modules, true ); ?>