produkty. static public function cron_universal() { global $mdb; self::$current_cron_action = __FUNCTION__; 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( '-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; $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 ); $campaign_sync_dates = [ $sync_date ]; $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 ] ); } $campaign_errors = []; $products_errors = []; $processed_dates = [ $sync_date ]; $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; $processed_products_total = 0; $products_skipped_total = 0; $history_30_products_total = 0; $products_temp_rows_total = 0; $products_fetch_skipped_reasons = []; $sync = self::sync_campaigns_snapshot_for_client( $client, $api, $sync_date ); $processed_records_total += (int) ( $sync['processed_records'] ?? 0 ); $ad_groups_synced_total += (int) ( $sync['ad_groups_synced'] ?? 0 ); if ( !empty( $sync['errors'] ) ) { $campaign_errors = array_merge( $campaign_errors, (array) $sync['errors'] ); } if ( empty( $campaign_errors ) ) { $terms_sync = self::sync_campaign_terms_backfill_for_client( (int) $client['id'], (string) ( $client['google_ads_customer_id'] ?? '' ), $api, $sync_dates ); $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'] ) ) { $campaign_errors = array_merge( $campaign_errors, (array) $terms_sync['errors'] ); } } $last_day_in_window = end( $sync_dates ); if ( empty( $campaign_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'] ) ) { $campaign_errors = array_merge( $campaign_errors, (array) $kw_sync['errors'] ); } } $products_sync = self::sync_products_fetch_for_client( $client, $api, $sync_date ); $processed_products_total += (int) ( $products_sync['processed_products'] ?? 0 ); $products_skipped_total += (int) ( $products_sync['skipped'] ?? 0 ); $products_fetch_skipped_reason = trim( (string) ( $products_sync['fetch_skipped_reason'] ?? '' ) ); if ( $products_fetch_skipped_reason !== '' ) { $products_fetch_skipped_reasons[ $products_fetch_skipped_reason ] = true; } if ( !empty( $products_sync['errors'] ) ) { $products_errors = array_merge( $products_errors, (array) $products_sync['errors'] ); } if ( empty( $products_errors ) ) { $history_30_products_total += (int) self::aggregate_products_history_30_for_client( (int) $client['id'], $sync_date ); $products_temp_rows_total += (int) self::rebuild_products_temp_for_client( (int) $client['id'] ); } $errors = array_merge( $campaign_errors, $products_errors ); self::output_cron_response( [ 'result' => empty( $errors ) ? 'Synchronizacja uniwersalna zakonczona.' : 'Synchronizacja uniwersalna 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, 'processed_products' => $processed_products_total, 'products_skipped' => $products_skipped_total, 'products_fetch_skipped_reasons' => array_keys( $products_fetch_skipped_reasons ), 'history_30_products' => $history_30_products_total, 'products_temp_rows' => $products_temp_rows_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 )" ); $mdb -> query( "DELETE cs FROM cron_sync_status cs LEFT JOIN clients c ON cs.client_id = c.id WHERE cs.pipeline = 'products' 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', $campaign_sync_dates, $client_ids ); self::ensure_sync_rows( 'products', $sync_dates, $client_ids ); 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' ); $active_products_client_id = self::get_active_client( 'products' ); if ( !$active_campaign_client_id && !$active_products_client_id ) { $merchant_client = $mdb -> query( "SELECT c.* FROM clients c WHERE " . $clients_not_deleted_sql_c . " AND COALESCE(c.active, 0) = 1 AND TRIM(COALESCE(c.google_ads_customer_id, '')) <> '' AND TRIM(COALESCE(c.google_merchant_account_id, '')) <> '' AND EXISTS ( SELECT 1 FROM products p WHERE p.client_id = c.id AND TRIM(COALESCE(p.offer_id, '')) <> '' AND ( TRIM(COALESCE(p.product_url, '')) = '' OR LOWER(TRIM(p.product_url)) IN ('0', '-', 'null') ) AND ( COALESCE(p.merchant_url_not_found, 0) = 0 OR ( COALESCE(p.merchant_url_not_found, 0) = 1 AND ( p.merchant_url_last_check IS NULL OR p.merchant_url_last_check < DATE_SUB(NOW(), INTERVAL 7 DAY) ) ) ) ) ORDER BY c.id ASC LIMIT 1" ) -> fetch( \PDO::FETCH_ASSOC ); if ( is_array( $merchant_client ) && !empty( $merchant_client ) ) { $urls_sync = self::sync_products_urls_and_alerts_for_client( $merchant_client, $api, $sync_date ); $merchant_errors = (array) ( $urls_sync['errors'] ?? [] ); self::output_cron_response( [ 'result' => empty( $merchant_errors ) ? 'Synchronizacja URL produktow (Merchant) zakonczona.' : 'Synchronizacja URL produktow (Merchant) zakonczona z bledami.', 'merchant_only' => 1, 'active_client_id' => (int) ( $merchant_client['id'] ?? 0 ), 'active_date' => $sync_date, 'conversion_window_days' => $conversion_window_days, 'dates_synced' => $sync_dates, 'merchant_urls_checked' => (int) ( $urls_sync['checked_products'] ?? 0 ), 'merchant_urls_updated' => (int) ( $urls_sync['updated_urls'] ?? 0 ), 'merchant_missing_in_mc_count' => (int) ( $urls_sync['missing_in_merchant_count'] ?? 0 ), 'merchant_missing_offer_ids' => array_values( array_unique( array_map( 'strval', (array) ( $urls_sync['missing_offer_ids'] ?? [] ) ) ) ), 'errors' => $merchant_errors ] ); } // --- Supplemental Feed: generuj raz dziennie gdy pipeline'y ukonczone --- $today = date( 'Y-m-d' ); $feed_last_date = (string) self::get_setting_value( 'cron_supplemental_feed_last_date', '' ); if ( $feed_last_date !== $today ) { $feed_clients = $mdb -> query( "SELECT id, name, google_merchant_account_id FROM clients WHERE " . $clients_not_deleted_sql . " AND COALESCE(active, 0) = 1 AND google_merchant_account_id IS NOT NULL AND google_merchant_account_id <> '' ORDER BY id ASC" ) -> fetchAll( \PDO::FETCH_ASSOC ); $feed_generated = 0; $feed_products_total = 0; $feed_errors = []; $feed_details = []; foreach ( $feed_clients as $fc ) { try { $feed_result = \services\SupplementalFeed::generate_for_client( (int) $fc['id'] ); $feed_generated++; $feed_products_total += (int) $feed_result['products_written']; $feed_details[] = [ 'client_id' => (int) $fc['id'], 'client_name' => (string) $fc['name'], 'products_written' => (int) $feed_result['products_written'], 'file' => (string) $feed_result['file'] ]; } catch ( \Throwable $e ) { $feed_errors[] = 'Feed klient #' . $fc['id'] . ': ' . $e -> getMessage(); } } self::set_setting_value( 'cron_supplemental_feed_last_date', $today ); self::set_setting_value( 'cron_supplemental_feed_last_count', (string) $feed_products_total ); self::set_setting_value( 'cron_supplemental_feed_files', (string) $feed_generated ); self::output_cron_response( [ 'result' => 'Supplemental feed: ' . $feed_generated . ' plikow, ' . $feed_products_total . ' produktow.', 'supplemental_feed' => 1, 'feed_generated' => $feed_generated, 'feed_products_total' => $feed_products_total, 'feed_details' => $feed_details, 'active_date' => $sync_date, 'errors' => $feed_errors ] ); } self::output_cron_response( [ 'result' => 'Wszyscy aktywni klienci 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 ) ] ); } $active_client_id = $active_campaign_client_id ?: $active_products_client_id; $dates_per_run = 1; $campaign_dates_batch = []; $products_dates_batch = []; if ( $active_campaign_client_id && $active_campaign_client_id === $active_client_id ) { $campaign_dates_batch = self::get_pending_dates_for_client( 'campaigns', $active_client_id, 'pending', 1 ); } if ( $active_products_client_id && $active_products_client_id === $active_client_id ) { $products_dates_batch = self::get_pending_dates_for_client( 'products', $active_client_id, 'pending', 1 ); } if ( empty( $campaign_dates_batch ) && empty( $products_dates_batch ) ) { self::output_cron_response( [ 'result' => 'Brak dat do przetworzenia dla aktywnego klienta. Kolejne wywolanie przejdzie dalej.', '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. ID: ' . $active_client_id, 'active_client_id' => $active_client_id, 'errors' => [ 'Klient ID ' . $active_client_id . ' nie znaleziony lub nieaktywny.' ] ] ); } $active_date = (string) ( $campaign_dates_batch[0] ?? ( $products_dates_batch[0] ?? '' ) ); if ( $active_date === '' ) { self::output_cron_response( [ 'result' => 'Brak daty do przetworzenia.', 'active_client_id' => $active_client_id ] ); } $run_campaigns = !empty( $campaign_dates_batch ) && (string) $campaign_dates_batch[0] === $active_date; $run_products = !empty( $products_dates_batch ) && (string) $products_dates_batch[0] === $active_date; $campaign_errors = []; $products_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; $processed_products_total = 0; $products_skipped_total = 0; $history_30_products_total = 0; $products_temp_rows_total = 0; $products_fetch_skipped_reasons = []; $products_sync_skipped_reason = ''; if ( $run_campaigns ) { $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 ); if ( !empty( $sync['errors'] ) ) { $campaign_errors = array_merge( $campaign_errors, (array) $sync['errors'] ); } if ( empty( $campaign_errors ) ) { $terms_sync = self::sync_campaign_terms_backfill_for_client( (int) $selected_client['id'], (string) ( $selected_client['google_ads_customer_id'] ?? '' ), $api, $sync_dates ); $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'] ) ) { $campaign_errors = array_merge( $campaign_errors, (array) $terms_sync['errors'] ); } } if ( empty( $campaign_errors ) ) { $kw_sync = self::sync_campaign_keywords_and_negatives_for_client( (int) $selected_client['id'], (string) ( $selected_client['google_ads_customer_id'] ?? '' ), $api, $active_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'] ) ) { $campaign_errors = array_merge( $campaign_errors, (array) $kw_sync['errors'] ); } } } if ( $run_products ) { $products_sync = self::sync_products_fetch_for_client( $selected_client, $api, $active_date ); $processed_products_total += (int) ( $products_sync['processed_products'] ?? 0 ); $products_skipped_total += (int) ( $products_sync['skipped'] ?? 0 ); $products_fetch_skipped_reason = trim( (string) ( $products_sync['fetch_skipped_reason'] ?? '' ) ); if ( $products_fetch_skipped_reason !== '' ) { $products_fetch_skipped_reasons[ $products_fetch_skipped_reason ] = true; } if ( !empty( $products_sync['errors'] ) ) { $products_errors = array_merge( $products_errors, (array) $products_sync['errors'] ); } if ( empty( $products_errors ) ) { $history_30_products_total += (int) self::aggregate_products_history_30_for_client( $active_client_id, $active_date ); $products_temp_rows_total += (int) self::rebuild_products_temp_for_client( $active_client_id ); } } else { $products_sync_skipped_reason = 'already_done_for_day'; } if ( $run_campaigns ) { $campaign_phase = empty( $campaign_errors ) ? 'done' : 'pending'; $campaign_error_text = empty( $campaign_errors ) ? null : implode( '; ', array_values( array_unique( array_map( 'strval', $campaign_errors ) ) ) ); self::mark_sync_phase( 'campaigns', $active_date, $active_client_id, $campaign_phase, $campaign_error_text ); } if ( $run_products ) { $products_phase = empty( $products_errors ) ? 'done' : 'pending'; $products_error_text = empty( $products_errors ) ? null : implode( '; ', array_values( array_unique( array_map( 'strval', $products_errors ) ) ) ); self::mark_sync_phase( 'products', $active_date, $active_client_id, $products_phase, $products_error_text ); } $errors = array_merge( $campaign_errors, $products_errors ); $campaign_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(); $campaign_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(); $products_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 = 'products' 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(); $products_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 = 'products' 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(); $campaign_remaining_dates = max( 0, $campaign_total_count - $campaign_done_count ); $products_remaining_dates = max( 0, $products_total_count - $products_done_count ); $remaining_dates = max( $campaign_remaining_dates, $products_remaining_dates ); $estimated_calls_remaining = (int) ceil( $remaining_dates / max( 1, $dates_per_run ) ); self::output_cron_response( [ 'result' => empty( $errors ) ? 'Synchronizacja uniwersalna zakonczona.' : 'Synchronizacja uniwersalna zakonczona z bledami.', 'active_client_id' => $active_client_id, 'dates_per_run' => $dates_per_run, 'dates_processed_in_call' => 1, 'processed_dates' => [ $active_date ], 'campaigns_processed_in_call' => $run_campaigns ? 1 : 0, 'products_processed_in_call' => $run_products ? 1 : 0, 'remaining_dates' => $remaining_dates, 'campaigns_remaining_dates' => $campaign_remaining_dates, 'products_remaining_dates' => $products_remaining_dates, 'estimated_calls_remaining' => $estimated_calls_remaining, 'active_date' => $active_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, 'processed_products' => $processed_products_total, 'products_skipped' => $products_skipped_total, 'products_fetch_skipped_reasons' => array_keys( $products_fetch_skipped_reasons ), 'products_sync_skipped_reason' => $products_sync_skipped_reason, 'history_30_products' => $history_30_products_total, 'products_temp_rows' => $products_temp_rows_total, 'total_clients' => count( $client_ids ), 'errors' => $errors ] ); } static public function cron_products_urls() { global $mdb, $settings; self::$current_cron_action = __FUNCTION__; self::touch_cron_invocation( __FUNCTION__ ); $api = new \services\GoogleAdsApi(); if ( !$api -> is_merchant_configured() ) { echo json_encode( [ 'result' => 'Merchant API nie jest skonfigurowane. Uzupelnij OAuth2 Client ID/Secret oraz Merchant Refresh Token w Ustawieniach.' ] ); exit; } $client_id = (int) \S::get( 'client_id' ); $batch_limit = (int) \S::get( 'limit' ); $debug_mode = (int) \S::get( 'debug' ) === 1; if ( $batch_limit <= 0 ) { $batch_limit = (int) ( $settings['cron_products_urls_limit_per_client'] ?? 100 ); } if ( $batch_limit <= 0 ) { $batch_limit = 100; } $batch_limit = min( 1000, $batch_limit ); $clients_per_run_default = (int) ( $settings['cron_products_urls_clients_per_run'] ?? ( $settings['cron_products_clients_per_run'] ?? 1 ) ); if ( $clients_per_run_default <= 0 ) { $clients_per_run_default = 1; } $clients_per_run = (int) \S::get( 'clients_per_run' ); if ( $clients_per_run <= 0 ) { $clients_per_run = (int) self::get_setting_value( 'cron_products_urls_clients_per_run', $clients_per_run_default ); } if ( $clients_per_run <= 0 ) { $clients_per_run = $clients_per_run_default; } $clients_per_run = min( 20, $clients_per_run ); $where = "deleted = 0 AND google_merchant_account_id IS NOT NULL AND google_merchant_account_id <> ''"; if ( $client_id > 0 ) { $where .= ' AND id = ' . $client_id; } $clients = $mdb -> query( 'SELECT id, name, google_merchant_account_id FROM clients WHERE ' . $where . ' ORDER BY id ASC' ) -> fetchAll( \PDO::FETCH_ASSOC ); if ( !is_array( $clients ) || empty( $clients ) ) { echo json_encode( [ 'result' => 'Brak klientow z ustawionym Merchant Account ID.', 'processed_clients' => 0, 'checked_products' => 0, 'updated_urls' => 0, 'errors' => [] ] ); exit; } $total_clients_available = count( $clients ); if ( $client_id <= 0 ) { $last_client_cursor = (int) self::get_setting_value( 'cron_products_urls_last_client_id', 0 ); $clients = self::pick_clients_batch_by_cursor( $clients, $clients_per_run, $last_client_cursor ); } else { $clients_per_run = 1; } $checked_products = 0; $updated_urls = 0; $unresolved_products = 0; $processed_clients = 0; $errors = []; $details = []; foreach ( $clients as $client ) { $processed_clients++; $selected_products = self::get_products_missing_url_for_client( (int) $client['id'], $batch_limit ); $product_count = count( $selected_products ); $diag = $debug_mode ? self::get_products_url_sync_diagnostics_for_client( (int) $client['id'] ) : null; if ( $product_count === 0 ) { $detail_row = [ 'client_id' => (int) $client['id'], 'client_name' => (string) $client['name'], 'merchant_account_id' => (string) $client['google_merchant_account_id'], 'selected_products' => 0, 'updated_urls' => 0, 'unresolved_products' => 0 ]; if ( $debug_mode ) { $detail_row['diag'] = $diag; } $details[] = $detail_row; continue; } $checked_products += $product_count; $offer_ids = []; foreach ( $selected_products as $row ) { $offer_ids[] = (string) $row['offer_id']; } $links_map = $api -> get_merchant_product_links_for_offer_ids( (string) $client['google_merchant_account_id'], $offer_ids ); if ( $links_map === false ) { $last_err = (string) \services\GoogleAdsApi::get_setting( 'google_merchant_last_error' ); $errors[] = 'Blad Merchant API dla klienta ' . $client['name'] . ' (ID: ' . $client['id'] . '): ' . $last_err; $unresolved_products += $product_count; $detail_row = [ 'client_id' => (int) $client['id'], 'client_name' => (string) $client['name'], 'merchant_account_id' => (string) $client['google_merchant_account_id'], 'selected_products' => $product_count, 'updated_urls' => 0, 'unresolved_products' => $product_count ]; if ( $debug_mode ) { $detail_row['diag'] = $diag; } $details[] = $detail_row; continue; } $client_updated = 0; foreach ( $selected_products as $row ) { $offer_id = (string) $row['offer_id']; if ( !isset( $links_map[ $offer_id ] ) ) { continue; } \factory\Products::set_product_data( (int) $row['product_id'], 'product_url', (string) $links_map[ $offer_id ] ); $client_updated++; } $updated_urls += $client_updated; $client_unresolved = max( 0, $product_count - $client_updated ); $unresolved_products += $client_unresolved; $detail_row = [ 'client_id' => (int) $client['id'], 'client_name' => (string) $client['name'], 'merchant_account_id' => (string) $client['google_merchant_account_id'], 'selected_products' => $product_count, 'updated_urls' => $client_updated, 'unresolved_products' => $client_unresolved ]; if ( $debug_mode ) { $detail_row['diag'] = $diag; } $details[] = $detail_row; } if ( $client_id <= 0 && !empty( $clients ) ) { $last_client = end( $clients ); $last_client_id = (int) ( $last_client['id'] ?? 0 ); if ( $last_client_id > 0 ) { self::set_setting_value( 'cron_products_urls_last_client_id', (string) $last_client_id ); } } echo json_encode( [ 'result' => empty( $errors ) ? 'Synchronizacja URL produktow zakonczona.' : 'Synchronizacja URL produktow zakonczona z bledami.', 'total_clients_available' => $total_clients_available, 'processed_clients' => $processed_clients, 'clients_per_run' => $clients_per_run, 'checked_products' => $checked_products, 'updated_urls' => $updated_urls, 'unresolved_products' => $unresolved_products, 'errors' => $errors, 'details' => $details ] ); exit; } static private function get_products_url_sync_diagnostics_for_client( $client_id ) { global $mdb; $client_id = (int) $client_id; if ( $client_id <= 0 ) { return []; } $diag = []; $diag['products_total'] = (int) $mdb -> query( 'SELECT COUNT(*) FROM products WHERE client_id = ' . $client_id ) -> fetchColumn(); $diag['products_not_deleted'] = (int) $mdb -> query( 'SELECT COUNT(*) FROM products WHERE client_id = ' . $client_id ) -> fetchColumn(); $diag['products_with_offer_id'] = (int) $mdb -> query( 'SELECT COUNT(*) FROM products WHERE client_id = ' . $client_id . ' AND TRIM( COALESCE( offer_id, "" ) ) <> ""' ) -> fetchColumn(); $diag['products_with_pd_rows'] = 0; $diag['products_with_real_url'] = (int) $mdb -> query( 'SELECT COUNT(*) FROM products p WHERE p.client_id = ' . $client_id . ' AND TRIM( COALESCE( p.offer_id, \"\" ) ) <> \"\" AND TRIM( COALESCE( p.product_url, \"\" ) ) <> \"\" AND LOWER( TRIM( p.product_url ) ) NOT IN ( \"0\", \"-\", \"null\" )' ) -> fetchColumn(); $diag['products_missing_url'] = (int) $mdb -> query( 'SELECT COUNT(*) FROM products p WHERE p.client_id = ' . $client_id . ' AND TRIM( COALESCE( p.offer_id, \"\" ) ) <> \"\" AND p.product_url IS NULL' ) -> fetchColumn(); $diag['products_missing_url_pending_check'] = (int) $mdb -> query( 'SELECT COUNT(*) FROM products p WHERE p.client_id = ' . $client_id . ' AND TRIM( COALESCE( p.offer_id, \"\" ) ) <> \"\" AND p.product_url IS NULL AND COALESCE( p.merchant_url_not_found, 0 ) = 0' ) -> fetchColumn(); $diag['products_missing_url_not_found'] = (int) $mdb -> query( 'SELECT COUNT(*) FROM products p WHERE p.client_id = ' . $client_id . ' AND TRIM( COALESCE( p.offer_id, \"\" ) ) <> \"\" AND p.product_url IS NULL AND COALESCE( p.merchant_url_not_found, 0 ) = 1' ) -> fetchColumn(); return $diag; } static private function get_products_missing_url_for_client( $client_id, $limit ) { global $mdb; $client_id = (int) $client_id; $limit = max( 1, min( 1000, (int) $limit ) ); if ( $client_id <= 0 ) { return []; } $retry_after_days = 7; $sql = 'SELECT p.id AS product_id, p.offer_id ' . 'FROM products p ' . 'WHERE p.client_id = ' . $client_id . ' ' . 'AND TRIM( COALESCE( p.offer_id, \'\' ) ) <> \'\' ' . 'AND ( TRIM( COALESCE( p.product_url, \'\' ) ) = \'\' OR LOWER( TRIM( p.product_url ) ) IN ( \'0\', \'-\', \'null\' ) ) ' . 'AND ( ' . 'COALESCE( p.merchant_url_not_found, 0 ) = 0 ' . 'OR ( COALESCE( p.merchant_url_not_found, 0 ) = 1 ' . 'AND ( p.merchant_url_last_check IS NULL ' . 'OR p.merchant_url_last_check < DATE_SUB( NOW(), INTERVAL ' . $retry_after_days . ' DAY ) ) ) ' . ') ' . 'ORDER BY COALESCE( p.merchant_url_not_found, 0 ) ASC, p.id ASC ' . 'LIMIT ' . $limit; $rows = $mdb -> query( $sql ) -> fetchAll( \PDO::FETCH_ASSOC ); return is_array( $rows ) ? $rows : []; } static private function sync_products_urls_and_alerts_for_client( $client, $api, $date_sync = null, $limit = null ) { global $mdb, $settings; $client_id = (int) ( $client['id'] ?? 0 ); $client_name = trim( (string) ( $client['name'] ?? '' ) ); $merchant_account_id = trim( (string) ( $client['google_merchant_account_id'] ?? '' ) ); $date_sync = $date_sync ? date( 'Y-m-d', strtotime( $date_sync ) ) : date( 'Y-m-d' ); $alert_type = 'products_missing_in_merchant_center'; if ( $client_id <= 0 ) { return [ 'checked_products' => 0, 'updated_urls' => 0, 'missing_in_merchant_count' => 0, 'missing_offer_ids' => [], 'missing_product_ids' => [], 'errors' => [ 'Brak poprawnego klienta do synchronizacji URL.' ] ]; } if ( $merchant_account_id === '' ) { return [ 'checked_products' => 0, 'updated_urls' => 0, 'missing_in_merchant_count' => 0, 'missing_offer_ids' => [], 'missing_product_ids' => [], 'skipped_reason' => 'missing_merchant_account_id', 'errors' => [] ]; } if ( !$api -> is_merchant_configured() ) { return [ 'checked_products' => 0, 'updated_urls' => 0, 'missing_in_merchant_count' => 0, 'missing_offer_ids' => [], 'missing_product_ids' => [], 'errors' => [ 'Merchant API nie jest skonfigurowane.' ] ]; } $limit = (int) $limit; if ( $limit <= 0 ) { $limit = (int) ( $settings['cron_products_urls_limit_per_client'] ?? 200 ); } if ( $limit <= 0 ) { $limit = 200; } $limit = min( 2000, $limit ); $checked_products = 0; $updated_urls = 0; $missing_offer_ids_from_run = []; $missing_product_ids_from_run = []; $sync_errors = []; $selected_products = self::get_products_missing_url_for_client( $client_id, $limit ); $checked_products = count( (array) $selected_products ); if ( $checked_products > 0 ) { $offer_ids = []; foreach ( $selected_products as $row ) { $offer_id = trim( (string) ( $row['offer_id'] ?? '' ) ); if ( $offer_id !== '' ) { $offer_ids[] = $offer_id; } } $offer_ids = array_values( array_unique( $offer_ids ) ); $links_map = $api -> get_merchant_product_links_for_offer_ids( $merchant_account_id, $offer_ids ); if ( $links_map === false ) { $last_err = trim( (string) \services\GoogleAdsApi::get_setting( 'google_merchant_last_error' ) ); if ( $last_err === '' ) { $last_err = 'Nie udalo sie pobrac danych z Merchant Center.'; } $sync_errors[] = $last_err; } else { if ( !is_array( $links_map ) ) { $links_map = []; } foreach ( $selected_products as $row ) { $offer_id = trim( (string) ( $row['offer_id'] ?? '' ) ); $product_id = (int) ( $row['product_id'] ?? 0 ); if ( $offer_id === '' || $product_id <= 0 ) { continue; } $product_url = trim( (string) ( $links_map[ $offer_id ] ?? '' ) ); if ( $product_url !== '' ) { \factory\Products::set_product_data( $product_id, 'product_url', $product_url ); $updated_urls++; continue; } $missing_offer_ids_from_run[] = $offer_id; $missing_product_ids_from_run[] = $product_id; } $missing_product_ids_from_run = array_values( array_unique( array_map( 'intval', $missing_product_ids_from_run ) ) ); if ( !empty( $missing_product_ids_from_run ) ) { $mdb -> update( 'products', [ 'merchant_url_not_found' => 1, 'merchant_url_last_check' => date( 'Y-m-d H:i:s' ) ], [ 'id' => $missing_product_ids_from_run ] ); } } } $not_found_rows = $mdb -> query( "SELECT id AS product_id, offer_id, name, title FROM products WHERE client_id = :client_id AND TRIM( COALESCE( offer_id, '' ) ) <> '' AND ( TRIM( COALESCE( product_url, '' ) ) = '' OR LOWER( TRIM( product_url ) ) IN ( '0', '-', 'null' ) ) AND COALESCE( merchant_url_not_found, 0 ) = 1", [ ':client_id' => $client_id ] ) -> fetchAll( \PDO::FETCH_ASSOC ); $missing_offer_ids = []; $missing_product_ids = []; $missing_products = []; foreach ( (array) $not_found_rows as $row ) { $offer_id = trim( (string) ( $row['offer_id'] ?? '' ) ); $product_id = (int) ( $row['product_id'] ?? 0 ); $product_name = trim( (string) ( $row['title'] ?? '' ) ); if ( $product_name === '' ) { $product_name = trim( (string) ( $row['name'] ?? '' ) ); } if ( $product_name === '' ) { $product_name = $offer_id !== '' ? $offer_id : ( 'Produkt #' . $product_id ); } if ( $offer_id !== '' ) { $missing_offer_ids[] = $offer_id; } if ( $product_id > 0 ) { $missing_product_ids[] = $product_id; $missing_products[] = [ 'product_id' => $product_id, 'offer_id' => $offer_id, 'product_name' => $product_name ]; } } $missing_offer_ids = array_values( array_unique( array_map( 'strval', $missing_offer_ids ) ) ); $missing_product_ids = array_values( array_unique( array_map( 'intval', $missing_product_ids ) ) ); $missing_count = count( $missing_offer_ids ); // Odświeżamy dzienne alerty per produkt (1 alert = 1 produkt). $mdb -> delete( 'campaign_alerts', [ 'AND' => [ 'client_id' => $client_id, 'alert_type' => $alert_type, 'date_detected' => $date_sync ] ] ); if ( $missing_count > 0 ) { foreach ( $missing_products as $product_row ) { $product_id = (int) ( $product_row['product_id'] ?? 0 ); if ( $product_id <= 0 ) { continue; } $offer_id = trim( (string) ( $product_row['offer_id'] ?? '' ) ); $product_name = trim( (string) ( $product_row['product_name'] ?? '' ) ); if ( $product_name === '' ) { $product_name = $offer_id !== '' ? $offer_id : ( 'Produkt #' . $product_id ); } $message = 'Brak produktu w Merchant Center: "' . $product_name . '"'; if ( $offer_id !== '' ) { $message .= ' (offer_id: ' . $offer_id . ')'; } $message .= '. Klient: "' . ( $client_name !== '' ? $client_name : ( 'ID ' . $client_id ) ) . '".'; $meta = [ 'merchant_account_id' => $merchant_account_id, 'product_id' => $product_id, 'product_name' => $product_name, 'offer_id' => $offer_id, 'checked_products' => $checked_products, 'updated_urls' => $updated_urls, 'source' => 'cron_universal_products_urls' ]; $mdb -> insert( 'campaign_alerts', [ 'client_id' => $client_id, 'campaign_id' => null, // Techniczny scope alertu: 1 alert = 1 produkt. 'campaign_external_id' => $product_id, 'ad_group_id' => null, 'ad_group_external_id' => 0, 'product_id' => $product_id, 'alert_type' => $alert_type, 'message' => $message, 'meta_json' => json_encode( $meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ), 'date_detected' => $date_sync, 'date_add' => date( 'Y-m-d H:i:s' ) ] ); } } return [ 'checked_products' => $checked_products, 'updated_urls' => $updated_urls, 'missing_in_merchant_count' => $missing_count, 'missing_offer_ids' => $missing_offer_ids, 'missing_product_ids' => $missing_product_ids, 'errors' => $sync_errors ]; } static private function sync_products_fetch_for_client( $client, $api, $date ) { global $mdb; $client_id = (int) $client['id']; $customer_id = trim( (string) ( $client['google_ads_customer_id'] ?? '' ) ); $date = date( 'Y-m-d', strtotime( $date ) ); $known_campaign_types = $mdb -> query( 'SELECT DISTINCT UPPER( TRIM( advertising_channel_type ) ) AS channel_type FROM campaigns WHERE client_id = :client_id AND campaign_id > 0 AND advertising_channel_type IS NOT NULL AND TRIM( advertising_channel_type ) <> ""', [ ':client_id' => $client_id ] ) -> fetchAll( \PDO::FETCH_COLUMN ); $known_campaign_types = array_values( array_unique( array_filter( array_map( function( $item ) { return strtoupper( trim( (string) $item ) ); }, (array) $known_campaign_types ) ) ) ); $product_campaign_types = [ 'SHOPPING', 'PERFORMANCE_MAX' ]; $has_product_campaign_type = count( array_intersect( $known_campaign_types, $product_campaign_types ) ) > 0; if ( !empty( $known_campaign_types ) && !$has_product_campaign_type ) { return [ 'date' => $date, 'processed_products' => 0, 'skipped' => 0, 'history_30_products' => 0, 'products_temp_rows' => 0, 'touched_products' => 0, 'fetch_skipped_reason' => 'non_product_campaign_types', 'errors' => [] ]; } $products = $api -> get_products_for_date( $customer_id, $date ); if ( $products === false ) { $last_err = \services\GoogleAdsApi::get_setting( 'google_ads_last_error' ); return [ 'date' => $date, 'processed_products' => 0, 'skipped' => 0, 'history_30_products' => 0, 'products_temp_rows' => 0, 'errors' => [ 'Blad API dla klienta ' . $client['name'] . ' (ID: ' . $customer_id . '): ' . $last_err ] ]; } if ( !is_array( $products ) ) { $products = []; } $existing_products_rows = $mdb -> query( 'SELECT id, offer_id, name, title, product_url FROM products WHERE client_id = :client_id ORDER BY id ASC', [ ':client_id' => $client_id ] ) -> fetchAll( \PDO::FETCH_ASSOC ); $products_by_offer_id = []; foreach ( $existing_products_rows as $row ) { $offer_id = trim( (string) ( $row['offer_id'] ?? '' ) ); if ( $offer_id === '' || isset( $products_by_offer_id[ $offer_id ] ) ) { continue; } $products_by_offer_id[ $offer_id ] = [ 'id' => (int) ( $row['id'] ?? 0 ), 'name' => (string) ( $row['name'] ?? '' ), 'title' => (string) ( $row['title'] ?? '' ), 'product_url' => (string) ( $row['product_url'] ?? '' ) ]; } $existing_campaigns_rows = $mdb -> query( 'SELECT id, campaign_id, campaign_name FROM campaigns WHERE client_id = :client_id AND campaign_id > 0', [ ':client_id' => $client_id ] ) -> fetchAll( \PDO::FETCH_ASSOC ); $campaigns_by_external_id = []; $campaigns_by_db_id = []; foreach ( $existing_campaigns_rows as $row ) { $db_campaign_id = (int) ( $row['id'] ?? 0 ); $external_campaign_id = (int) ( $row['campaign_id'] ?? 0 ); if ( $db_campaign_id <= 0 ) { continue; } $campaign_data = [ 'id' => $db_campaign_id, 'campaign_name' => (string) ( $row['campaign_name'] ?? '' ) ]; if ( !isset( $campaigns_by_external_id[ $external_campaign_id ] ) ) { $campaigns_by_external_id[ $external_campaign_id ] = $campaign_data; } $campaigns_by_db_id[ $db_campaign_id ] = $campaign_data; } $existing_ad_groups_rows = $mdb -> query( 'SELECT ag.id, ag.campaign_id, ag.ad_group_id, ag.ad_group_name FROM campaign_ad_groups AS ag INNER JOIN campaigns AS c ON c.id = ag.campaign_id WHERE c.client_id = :client_id', [ ':client_id' => $client_id ] ) -> fetchAll( \PDO::FETCH_ASSOC ); $ad_groups_by_scope = []; foreach ( $existing_ad_groups_rows as $row ) { $db_campaign_id = (int) ( $row['campaign_id'] ?? 0 ); $external_ad_group_id = (int) ( $row['ad_group_id'] ?? 0 ); $db_ad_group_id = (int) ( $row['id'] ?? 0 ); if ( $db_campaign_id <= 0 || $db_ad_group_id <= 0 ) { continue; } $scope_key = $db_campaign_id . '|' . $external_ad_group_id; if ( isset( $ad_groups_by_scope[ $scope_key ] ) ) { continue; } $ad_groups_by_scope[ $scope_key ] = [ 'id' => $db_ad_group_id, 'ad_group_name' => (string) ( $row['ad_group_name'] ?? '' ) ]; } $existing_history_rows = $mdb -> query( 'SELECT ph.product_id, ph.campaign_id, ph.ad_group_id, ph.impressions, ph.clicks, ph.cost, ph.conversions, ph.conversions_value FROM products_history AS ph INNER JOIN products AS p ON p.id = ph.product_id WHERE p.client_id = :client_id AND ph.date_add = :date_add', [ ':client_id' => $client_id, ':date_add' => $date ] ) -> fetchAll( \PDO::FETCH_ASSOC ); $history_by_scope = []; foreach ( $existing_history_rows as $row ) { $history_key = (int) ( $row['product_id'] ?? 0 ) . '|' . (int) ( $row['campaign_id'] ?? 0 ) . '|' . (int) ( $row['ad_group_id'] ?? 0 ); $history_by_scope[ $history_key ] = [ 'impressions' => (int) ( $row['impressions'] ?? 0 ), 'clicks' => (int) ( $row['clicks'] ?? 0 ), 'cost' => (float) ( $row['cost'] ?? 0 ), 'conversions' => (float) ( $row['conversions'] ?? 0 ), 'conversions_value' => (float) ( $row['conversions_value'] ?? 0 ) ]; } $resolve_scope_ids = function( $campaign_external_id, $campaign_name, $ad_group_external_id, $ad_group_name ) use ( &$campaigns_by_external_id, &$campaigns_by_db_id, &$ad_groups_by_scope, $client_id, $date, $mdb ) { $campaign_external_id = (int) $campaign_external_id; $campaign_name = trim( (string) $campaign_name ); $ad_group_external_id = (int) $ad_group_external_id; $ad_group_name = trim( (string) $ad_group_name ); if ( $campaign_external_id <= 0 ) { return [ 'campaign_id' => 0, 'ad_group_id' => 0 ]; } $campaign_data = $campaigns_by_external_id[ $campaign_external_id ] ?? null; if ( !$campaign_data ) { $campaign_name_to_save = $campaign_name; if ( $campaign_name_to_save === '' ) { $campaign_name_to_save = $campaign_external_id > 0 ? 'Kampania #' . $campaign_external_id : '--- konto ---'; } $mdb -> insert( 'campaigns', [ 'client_id' => $client_id, 'campaign_id' => $campaign_external_id, 'campaign_name' => $campaign_name_to_save ] ); $db_campaign_id = (int) $mdb -> id(); $campaign_data = [ 'id' => $db_campaign_id, 'campaign_name' => $campaign_name_to_save ]; $campaigns_by_external_id[ $campaign_external_id ] = $campaign_data; $campaigns_by_db_id[ $db_campaign_id ] = $campaign_data; } else if ( $campaign_name !== '' && $campaign_name !== (string) ( $campaign_data['campaign_name'] ?? '' ) ) { $mdb -> update( 'campaigns', [ 'campaign_name' => $campaign_name ], [ 'id' => (int) $campaign_data['id'] ] ); $campaign_data['campaign_name'] = $campaign_name; $campaigns_by_external_id[ $campaign_external_id ] = $campaign_data; $campaigns_by_db_id[ (int) $campaign_data['id'] ] = $campaign_data; } $db_campaign_id = (int) ( $campaign_data['id'] ?? 0 ); if ( $db_campaign_id <= 0 ) { return [ 'campaign_id' => 0, 'ad_group_id' => 0 ]; } if ( $ad_group_external_id <= 0 ) { $scope_key = $db_campaign_id . '|0'; if ( !isset( $ad_groups_by_scope[ $scope_key ] ) ) { $db_ad_group_id = (int) self::ensure_campaign_level_ad_group( $db_campaign_id, $date ); $ad_groups_by_scope[ $scope_key ] = [ 'id' => $db_ad_group_id, 'ad_group_name' => '--- kampania (brak grupy reklam) ---' ]; } return [ 'campaign_id' => $db_campaign_id, 'ad_group_id' => (int) ( $ad_groups_by_scope[ $scope_key ]['id'] ?? 0 ) ]; } $scope_key = $db_campaign_id . '|' . $ad_group_external_id; $ad_group_data = $ad_groups_by_scope[ $scope_key ] ?? null; if ( !$ad_group_data ) { $ad_group_name_to_save = $ad_group_name !== '' ? $ad_group_name : 'Ad group #' . $ad_group_external_id; $mdb -> insert( 'campaign_ad_groups', [ 'campaign_id' => $db_campaign_id, 'ad_group_id' => $ad_group_external_id, 'ad_group_name' => $ad_group_name_to_save, 'status' => 'paused', 'impressions_30' => 0, 'clicks_30' => 0, 'cost_30' => 0, 'conversions_30' => 0, 'conversion_value_30' => 0, 'roas_30' => 0, 'impressions_all_time' => 0, 'clicks_all_time' => 0, 'cost_all_time' => 0, 'conversions_all_time' => 0, 'conversion_value_all_time' => 0, 'roas_all_time' => 0, 'date_sync' => $date ] ); $ad_group_data = [ 'id' => (int) $mdb -> id(), 'ad_group_name' => $ad_group_name_to_save ]; $ad_groups_by_scope[ $scope_key ] = $ad_group_data; } else if ( $ad_group_name !== '' && $ad_group_name !== (string) ( $ad_group_data['ad_group_name'] ?? '' ) ) { $mdb -> update( 'campaign_ad_groups', [ 'ad_group_name' => $ad_group_name ], [ 'id' => (int) $ad_group_data['id'] ] ); $ad_group_data['ad_group_name'] = $ad_group_name; $ad_groups_by_scope[ $scope_key ] = $ad_group_data; } return [ 'campaign_id' => $db_campaign_id, 'ad_group_id' => (int) ( $ad_group_data['id'] ?? 0 ) ]; }; $processed = 0; $skipped = 0; $touched_product_ids = []; foreach ( $products as $offer ) { $offer_external_id = trim( (string) ( $offer['OfferId'] ?? '' ) ); if ( $offer_external_id === '' ) { $skipped++; continue; } $product_title = trim( (string) ( $offer['ProductTitle'] ?? '' ) ); if ( $product_title === '' ) { $product_title = $offer_external_id; } $existing_product = $products_by_offer_id[ $offer_external_id ] ?? null; if ( !$existing_product ) { $mdb -> insert( 'products', [ 'client_id' => $client_id, 'offer_id' => $offer_external_id, 'name' => $product_title ] ); $product_id = $mdb -> id(); $products_by_offer_id[ $offer_external_id ] = [ 'id' => (int) $product_id, 'name' => $product_title, 'product_url' => '' ]; } else { $product_id = (int) ( $existing_product['id'] ?? 0 ); } if ( !$product_id ) { $skipped++; continue; } $product_url = trim( (string) ( $offer['ProductUrl'] ?? '' ) ); $product_url_path = strtolower( (string) parse_url( $product_url, PHP_URL_PATH ) ); $is_image_url = (bool) preg_match( '/\.(jpg|jpeg|png|gif|webp|bmp|svg|avif)$/i', $product_url_path ); if ( $product_url !== '' && filter_var( $product_url, FILTER_VALIDATE_URL ) && !$is_image_url ) { $existing_product_url = trim( (string) ( $products_by_offer_id[ $offer_external_id ]['product_url'] ?? '' ) ); if ( $existing_product_url !== $product_url ) { $mdb -> update( 'products', [ 'product_url' => $product_url, 'merchant_url_not_found' => 0 ], [ 'id' => $product_id ] ); $products_by_offer_id[ $offer_external_id ]['product_url'] = $product_url; } } $campaign_external_id = (int) ( $offer['CampaignId'] ?? 0 ); if ( $campaign_external_id <= 0 ) { $skipped++; continue; } $campaign_name = trim( (string) ( $offer['CampaignName'] ?? '' ) ); $ad_group_external_id = (int) ( $offer['AdGroupId'] ?? 0 ); $ad_group_name = trim( (string) ( $offer['AdGroupName'] ?? '' ) ); $scope = $resolve_scope_ids( $campaign_external_id, $campaign_name, $ad_group_external_id, $ad_group_name ); $db_campaign_id = (int) ( $scope['campaign_id'] ?? 0 ); $db_ad_group_id = (int) ( $scope['ad_group_id'] ?? 0 ); $impressions = (int) round( (float) ( $offer['Impressions'] ?? 0 ) ); $clicks = (int) round( (float) ( $offer['Clicks'] ?? 0 ) ); $cost = (float) ( $offer['Cost'] ?? 0 ); $conversions = (float) ( $offer['Conversions'] ?? 0 ); $conversion_value = (float) ( $offer['ConversionValue'] ?? 0 ); $ctr = ( $impressions > 0 ) ? round( $clicks / $impressions, 4 ) * 100 : 0; $offer_data = [ 'impressions' => $impressions, 'clicks' => $clicks, 'ctr' => $ctr, 'cost' => $cost, 'conversions' => $conversions, 'conversions_value' => $conversion_value, 'updated' => 1, 'campaign_id' => $db_campaign_id, 'ad_group_id' => $db_ad_group_id ]; $history_scope_key = (int) $product_id . '|' . (int) $db_campaign_id . '|' . (int) $db_ad_group_id; $offer_data_old = $history_by_scope[ $history_scope_key ] ?? null; if ( $offer_data_old ) { if ( $offer_data_old['impressions'] == $offer_data['impressions'] and $offer_data_old['clicks'] == $offer_data['clicks'] and number_format( (float) str_replace( ',', '.', $offer_data_old['cost'] ), 5 ) == number_format( (float) $offer_data['cost'], 5 ) and (float) $offer_data_old['conversions'] == (float) $offer_data['conversions'] and number_format( (float) str_replace( ',', '.', $offer_data_old['conversions_value'] ), 5 ) == number_format( (float) $offer_data['conversions_value'], 5 ) ) { $touched_product_ids[ $product_id ] = true; $processed++; continue; } $mdb -> update( 'products_history', $offer_data, [ 'AND' => [ 'product_id' => $product_id, 'campaign_id' => $db_campaign_id, 'ad_group_id' => $db_ad_group_id, 'date_add' => $date ] ] ); $history_by_scope[ $history_scope_key ] = [ 'impressions' => $offer_data['impressions'], 'clicks' => $offer_data['clicks'], 'cost' => $offer_data['cost'], 'conversions' => $offer_data['conversions'], 'conversions_value' => $offer_data['conversions_value'] ]; } else { $offer_data['product_id'] = $product_id; $offer_data['date_add'] = $date; $mdb -> insert( 'products_history', $offer_data ); $history_by_scope[ $history_scope_key ] = [ 'impressions' => $offer_data['impressions'], 'clicks' => $offer_data['clicks'], 'cost' => $offer_data['cost'], 'conversions' => $offer_data['conversions'], 'conversions_value' => $offer_data['conversions_value'] ]; } $touched_product_ids[ $product_id ] = true; $processed++; } return [ 'date' => $date, 'processed_products' => $processed, 'skipped' => $skipped, 'touched_products' => count( $touched_product_ids ), 'errors' => [] ]; } static public function resolve_products_scope_ids( $client_id, $campaign_external_id, $campaign_name, $ad_group_external_id, $ad_group_name, $date_sync ) { $client_id = (int) $client_id; $campaign_external_id = (int) $campaign_external_id; $ad_group_external_id = (int) $ad_group_external_id; $db_campaign_id = self::ensure_products_campaign( $client_id, $campaign_external_id, $campaign_name, $date_sync ); if ( $db_campaign_id <= 0 ) { $db_campaign_id = self::ensure_products_campaign( $client_id, 0, '--- konto ---', $date_sync ); } $db_ad_group_id = self::ensure_products_ad_group( $db_campaign_id, $ad_group_external_id, $ad_group_name, $date_sync ); return [ 'campaign_id' => (int) $db_campaign_id, 'ad_group_id' => (int) $db_ad_group_id ]; } static private function ensure_products_campaign( $client_id, $campaign_external_id, $campaign_name, $date_sync ) { global $mdb; $client_id = (int) $client_id; $campaign_external_id = (int) $campaign_external_id; $campaign_name = trim( (string) $campaign_name ); if ( $client_id <= 0 ) { return 0; } $db_campaign_id = (int) $mdb -> get( 'campaigns', 'id', [ 'AND' => [ 'client_id' => $client_id, 'campaign_id' => $campaign_external_id ] ] ); if ( $db_campaign_id > 0 ) { if ( $campaign_name !== '' ) { $mdb -> update( 'campaigns', [ 'campaign_name' => $campaign_name ], [ 'id' => $db_campaign_id ] ); } return $db_campaign_id; } if ( $campaign_name === '' ) { $campaign_name = $campaign_external_id > 0 ? 'Kampania #' . $campaign_external_id : '--- konto ---'; } $mdb -> insert( 'campaigns', [ 'client_id' => $client_id, 'campaign_id' => $campaign_external_id, 'campaign_name' => $campaign_name ] ); $db_campaign_id = (int) $mdb -> id(); return $db_campaign_id; } static private function ensure_products_ad_group( $db_campaign_id, $ad_group_external_id, $ad_group_name, $date_sync ) { global $mdb; $db_campaign_id = (int) $db_campaign_id; $ad_group_external_id = (int) $ad_group_external_id; $ad_group_name = trim( (string) $ad_group_name ); if ( $db_campaign_id <= 0 ) { return 0; } if ( $ad_group_external_id <= 0 ) { return (int) self::ensure_campaign_level_ad_group( $db_campaign_id, $date_sync ); } $db_ad_group_id = (int) $mdb -> get( 'campaign_ad_groups', 'id', [ 'AND' => [ 'campaign_id' => $db_campaign_id, 'ad_group_id' => $ad_group_external_id ] ] ); if ( $db_ad_group_id > 0 ) { if ( $ad_group_name !== '' ) { $mdb -> update( 'campaign_ad_groups', [ 'ad_group_name' => $ad_group_name ], [ 'id' => $db_ad_group_id ] ); } return $db_ad_group_id; } if ( $ad_group_name === '' ) { $ad_group_name = 'Ad group #' . $ad_group_external_id; } $mdb -> insert( 'campaign_ad_groups', [ 'campaign_id' => $db_campaign_id, 'ad_group_id' => $ad_group_external_id, 'ad_group_name' => $ad_group_name, 'status' => 'paused', 'impressions_30' => 0, 'clicks_30' => 0, 'cost_30' => 0, 'conversions_30' => 0, 'conversion_value_30' => 0, 'roas_30' => 0, 'impressions_all_time' => 0, 'clicks_all_time' => 0, 'cost_all_time' => 0, 'conversions_all_time' => 0, 'conversion_value_all_time' => 0, 'roas_all_time' => 0, 'date_sync' => $date_sync ] ); return (int) $mdb -> id(); } static private function aggregate_products_history_30_for_client( $client_id, $date = null ) { global $mdb; $client_id = (int) $client_id; if ( $client_id <= 0 ) { return 0; } $target_date = $date ? date( 'Y-m-d', strtotime( $date ) ) : date( 'Y-m-d', strtotime( '-1 days' ) ); $params = [ ':client_id' => $client_id ]; $sql = 'SELECT DISTINCT ph.product_id, ph.campaign_id, ph.ad_group_id, ph.date_add FROM products_history AS ph INNER JOIN products AS p ON p.id = ph.product_id WHERE p.client_id = :client_id AND ph.campaign_id > 0 AND ph.updated = 1'; if ( $date ) { $params[':date_add'] = $target_date; $sql .= ' AND ph.date_add = :date_add'; } $sql .= ' ORDER BY ph.date_add ASC, ph.product_id ASC, ph.campaign_id ASC, ph.ad_group_id ASC'; $rows = $mdb -> query( $sql, $params ) -> fetchAll( \PDO::FETCH_ASSOC ); $processed = 0; foreach ( $rows as $row ) { $product_id = (int) $row['product_id']; $campaign_id = (int) ( $row['campaign_id'] ?? 0 ); $ad_group_id = (int) ( $row['ad_group_id'] ?? 0 ); self::cron_product_history_30_save( $product_id, $row['date_add'], $campaign_id, $ad_group_id ); $mdb -> query( 'UPDATE products_history AS ph INNER JOIN products AS p ON p.id = ph.product_id SET ph.updated = 0 WHERE ph.product_id = :product_id AND ph.campaign_id = :campaign_id AND ph.ad_group_id = :ad_group_id AND ph.date_add = :date_add AND p.client_id = :client_id', [ ':product_id' => $product_id, ':campaign_id' => $campaign_id, ':ad_group_id' => $ad_group_id, ':date_add' => $row['date_add'], ':client_id' => $client_id ] ); $processed++; } self::rebuild_products_aggregate_for_client( $client_id, $target_date ); return $processed; } static private function rebuild_products_aggregate_for_client( $client_id, $date_sync = null ) { global $mdb; $client_id = (int) $client_id; if ( $client_id <= 0 ) { return 0; } $date_sync = $date_sync ? date( 'Y-m-d', strtotime( $date_sync ) ) : date( 'Y-m-d', strtotime( '-1 days' ) ); $date_from_30 = date( 'Y-m-d', strtotime( '-29 days', strtotime( $date_sync ) ) ); $product_ids = $mdb -> select( 'products', 'id', [ 'client_id' => $client_id ] ); $product_ids = array_values( array_unique( array_map( 'intval', (array) $product_ids ) ) ); if ( empty( $product_ids ) ) { return 0; } $mdb -> delete( 'products_aggregate', [ 'product_id' => $product_ids ] ); $rows = $mdb -> query( 'SELECT ph.product_id, ph.campaign_id, ph.ad_group_id, SUM( CASE WHEN ph.date_add BETWEEN :date_from_30 AND :date_sync THEN ph.impressions ELSE 0 END ) AS impressions_30, SUM( CASE WHEN ph.date_add BETWEEN :date_from_30 AND :date_sync THEN ph.clicks ELSE 0 END ) AS clicks_30, SUM( CASE WHEN ph.date_add BETWEEN :date_from_30 AND :date_sync THEN ph.cost ELSE 0 END ) AS cost_30, SUM( CASE WHEN ph.date_add BETWEEN :date_from_30 AND :date_sync THEN ph.conversions ELSE 0 END ) AS conversions_30, SUM( CASE WHEN ph.date_add BETWEEN :date_from_30 AND :date_sync THEN ph.conversions_value ELSE 0 END ) AS conversion_value_30, SUM( CASE WHEN ph.date_add <= :date_sync THEN ph.impressions ELSE 0 END ) AS impressions_all_time, SUM( CASE WHEN ph.date_add <= :date_sync THEN ph.clicks ELSE 0 END ) AS clicks_all_time, SUM( CASE WHEN ph.date_add <= :date_sync THEN ph.cost ELSE 0 END ) AS cost_all_time, SUM( CASE WHEN ph.date_add <= :date_sync THEN ph.conversions ELSE 0 END ) AS conversions_all_time, SUM( CASE WHEN ph.date_add <= :date_sync THEN ph.conversions_value ELSE 0 END ) AS conversion_value_all_time FROM products_history AS ph INNER JOIN products AS p ON p.id = ph.product_id WHERE p.client_id = :client_id AND ph.campaign_id > 0 AND ph.date_add <= :date_sync GROUP BY ph.product_id, ph.campaign_id, ph.ad_group_id HAVING impressions_30 > 0 OR clicks_30 > 0 OR cost_30 > 0 OR conversions_30 > 0 OR conversion_value_30 > 0 OR impressions_all_time > 0 OR clicks_all_time > 0 OR cost_all_time > 0 OR conversions_all_time > 0 OR conversion_value_all_time > 0', [ ':client_id' => $client_id, ':date_from_30' => $date_from_30, ':date_sync' => $date_sync ] ) -> fetchAll( \PDO::FETCH_ASSOC ); $processed = 0; foreach ( (array) $rows as $row ) { $impressions_30 = (int) ( $row['impressions_30'] ?? 0 ); $clicks_30 = (int) ( $row['clicks_30'] ?? 0 ); $cost_30 = (float) ( $row['cost_30'] ?? 0 ); $conversions_30 = (float) ( $row['conversions_30'] ?? 0 ); $conversion_value_30 = (float) ( $row['conversion_value_30'] ?? 0 ); $ctr_30 = $impressions_30 > 0 ? round( $clicks_30 / $impressions_30 * 100, 6 ) : 0; $roas_30 = $cost_30 > 0 ? round( $conversion_value_30 / $cost_30 * 100, 6 ) : 0; $impressions_all = (int) ( $row['impressions_all_time'] ?? 0 ); $clicks_all = (int) ( $row['clicks_all_time'] ?? 0 ); $cost_all = (float) ( $row['cost_all_time'] ?? 0 ); $conversions_all = (float) ( $row['conversions_all_time'] ?? 0 ); $conversion_value_all = (float) ( $row['conversion_value_all_time'] ?? 0 ); $ctr_all = $impressions_all > 0 ? round( $clicks_all / $impressions_all * 100, 6 ) : 0; $roas_all = $cost_all > 0 ? round( $conversion_value_all / $cost_all * 100, 6 ) : 0; $mdb -> insert( 'products_aggregate', [ 'product_id' => (int) ( $row['product_id'] ?? 0 ), 'campaign_id' => (int) ( $row['campaign_id'] ?? 0 ), 'ad_group_id' => (int) ( $row['ad_group_id'] ?? 0 ), 'impressions_30' => $impressions_30, 'clicks_30' => $clicks_30, 'ctr_30' => $ctr_30, 'cost_30' => $cost_30, 'conversions_30' => $conversions_30, 'conversion_value_30' => $conversion_value_30, 'roas_30' => $roas_30, 'impressions_all_time' => $impressions_all, 'clicks_all_time' => $clicks_all, 'ctr_all_time' => $ctr_all, 'cost_all_time' => $cost_all, 'conversions_all_time' => $conversions_all, 'conversion_value_all_time' => $conversion_value_all, 'roas_all_time' => $roas_all, 'date_sync' => $date_sync ] ); $processed++; } return $processed; } static public function rebuild_products_temp_for_client( $client_id ) { global $mdb; $client_id = (int) $client_id; if ( $client_id <= 0 ) { return 0; } // products_temp zostalo wycofane. // Zwracamy liczbe scope z tabeli products_aggregate (dla diagnostyki odpowiedzi cron). return (int) $mdb -> query( 'SELECT COUNT(*) FROM products_aggregate pa INNER JOIN products p ON p.id = pa.product_id WHERE p.client_id = :client_id', [ ':client_id' => $client_id ] ) -> fetchColumn(); } static public function get_roas_all_time( $product_id, $date_to, $campaign_id = 0, $ad_group_id = 0 ) { global $mdb; $product_id = (int) $product_id; $campaign_id = (int) $campaign_id; $ad_group_id = (int) $ad_group_id; $sql = 'SELECT SUM(conversions_value) / SUM(cost) * 100 AS roas_all_time FROM products_history WHERE product_id = :product_id AND date_add <= :date_to AND campaign_id = :campaign_id AND ad_group_id = :ad_group_id'; $roas_all_time = $mdb -> query( $sql, [ ':product_id' => $product_id, ':date_to' => $date_to, ':campaign_id' => $campaign_id, ':ad_group_id' => $ad_group_id ] ) -> fetchColumn(); return round( $roas_all_time, 2 ); } static public function cron_product_history_30_save( $product_id, $date_to, $campaign_id = 0, $ad_group_id = 0 ) { global $mdb; $product_id = (int) $product_id; $campaign_id = (int) $campaign_id; $ad_group_id = (int) $ad_group_id; if ( $campaign_id <= 0 ) { return; } $data = $mdb -> query( 'SELECT date_add, SUM( impressions ) AS impressions, SUM( clicks ) AS clicks, SUM( cost ) AS cost, SUM( conversions ) AS conversions, SUM( conversions_value ) AS conversions_value FROM products_history WHERE product_id = :product_id AND campaign_id = :campaign_id AND ad_group_id = :ad_group_id AND date_add <= :date_to GROUP BY date_add ORDER BY date_add DESC LIMIT 30', [ ':product_id' => $product_id, ':campaign_id' => $campaign_id, ':ad_group_id' => $ad_group_id, ':date_to' => $date_to ] ) -> fetchAll( \PDO::FETCH_ASSOC ); $day_count = count( $data ); if ( $day_count <= 0 ) { return; } $days_count_for_product = (int) $mdb -> query( 'SELECT COUNT( DISTINCT date_add ) FROM products_history WHERE product_id = :product_id AND campaign_id = :campaign_id AND ad_group_id = :ad_group_id AND date_add <= :date_to', [ ':product_id' => $product_id, ':campaign_id' => $campaign_id, ':ad_group_id' => $ad_group_id, ':date_to' => $date_to ] ) -> fetchColumn(); if ( $days_count_for_product < 14 ) { return; } $impressions_sum = 0; $clicks_sum = 0; $cost_sum = 0.0; $conversions_sum = 0.0; $conversions_value_sum = 0.0; foreach ( $data as $entry ) { $impressions_sum += (int) ( $entry['impressions'] ?? 0 ); $clicks_sum += (int) ( $entry['clicks'] ?? 0 ); $cost_sum += (float) ( $entry['cost'] ?? 0 ); $conversions_sum += (float) ( $entry['conversions'] ?? 0 ); $conversions_value_sum += (float) ( $entry['conversions_value'] ?? 0 ); } // products_history_30 przechowuje srednie dzienne dla okna do 30 dni. $impressions = (int) round( $impressions_sum / $day_count ); $clicks = (int) round( $clicks_sum / $day_count ); $cost = round( $cost_sum / $day_count, 6 ); $conversions = round( $conversions_sum / $day_count, 6 ); $conversions_value = round( $conversions_value_sum / $day_count, 6 ); $ctr = ( $impressions > 0 ) ? round( $clicks / $impressions, 6 ) * 100 : 0; $roas = ( $cost > 0 ) ? round( $conversions_value / $cost, 6 ) * 100 : 0; if ( $mdb -> count( 'products_history_30', [ 'AND' => [ 'product_id' => $product_id, 'campaign_id' => $campaign_id, 'ad_group_id' => $ad_group_id, 'date_add' => $date_to ] ] ) > 0 ) { $mdb -> update( 'products_history_30', [ 'impressions' => $impressions, 'clicks' => $clicks, 'ctr' => $ctr, 'cost' => $cost, 'conversions' => $conversions, 'conversions_value' => $conversions_value, 'roas' => $roas, 'roas_all_time' => self::get_roas_all_time( $product_id, $date_to, $campaign_id, $ad_group_id ) ], [ 'AND' => [ 'product_id' => $product_id, 'campaign_id' => $campaign_id, 'ad_group_id' => $ad_group_id, 'date_add' => $date_to ] ] ); } else { $mdb -> insert( 'products_history_30', [ 'product_id' => $product_id, 'campaign_id' => $campaign_id, 'ad_group_id' => $ad_group_id, 'impressions' => $impressions, 'clicks' => $clicks, 'ctr' => $ctr, 'cost' => $cost, 'conversions' => $conversions, 'conversions_value' => $conversions_value, 'roas' => $roas, 'roas_all_time' => self::get_roas_all_time( $product_id, $date_to, $campaign_id, $ad_group_id ), 'date_add' => $date_to ] ); } } static public function cron_campaigns_product_alerts_merchant() { global $mdb, $settings; self::$current_cron_action = __FUNCTION__; 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; } if ( !$api -> is_merchant_configured() ) { echo json_encode( [ 'result' => 'Merchant API nie jest skonfigurowane. Uzupelnij dane w Ustawieniach.' ] ); exit; } $sync_date = \S::get( 'date' ) ? date( 'Y-m-d', strtotime( \S::get( 'date' ) ) ) : date( 'Y-m-d' ); $client_id = (int) \S::get( 'client_id' ); $where = "deleted = 0 AND google_ads_customer_id IS NOT NULL AND google_ads_customer_id <> '' AND google_merchant_account_id IS NOT NULL AND google_merchant_account_id <> ''"; if ( $client_id > 0 ) { $where .= ' AND id = ' . $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 ) ) { echo json_encode( [ 'result' => 'Brak klientow z Google Ads Customer ID i Merchant Account ID.', 'processed_clients' => 0, 'alerts_synced' => 0, 'errors' => [] ] ); exit; } $clients_per_run_default = (int) ( $settings['cron_campaigns_product_alerts_clients_per_run'] ?? ( $settings['cron_campaigns_clients_per_run'] ?? 2 ) ); if ( $clients_per_run_default <= 0 ) { $clients_per_run_default = 2; } $clients_per_run = (int) \S::get( 'clients_per_run' ); if ( $clients_per_run <= 0 ) { $clients_per_run = (int) self::get_setting_value( 'cron_campaigns_product_alerts_clients_per_run', $clients_per_run_default ); } if ( $clients_per_run <= 0 ) { $clients_per_run = $clients_per_run_default; } $clients_per_run = min( 20, $clients_per_run ); $total_clients_available = count( $clients ); if ( $client_id <= 0 ) { $last_client_cursor = (int) self::get_setting_value( 'cron_campaigns_product_alerts_last_client_id', 0 ); $clients = self::pick_clients_batch_by_cursor( $clients, $clients_per_run, $last_client_cursor ); } else { $clients_per_run = 1; } $processed_clients = 0; $alerts_synced = 0; $errors = []; $details = []; $last_client_id = 0; foreach ( (array) $clients as $client ) { $processed_clients++; $last_client_id = (int) ( $client['id'] ?? 0 ); $campaigns_db_map = self::build_campaigns_db_map_for_client( (int) ( $client['id'] ?? 0 ) ); if ( empty( $campaigns_db_map ) ) { $details[] = [ 'client_id' => (int) ( $client['id'] ?? 0 ), 'client_name' => (string) ( $client['name'] ?? '' ), 'alerts_synced' => 0, 'note' => 'Brak kampanii w bazie dla klienta.' ]; continue; } $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 ] ); $alerts_synced += (int) ( $sync['count'] ?? 0 ); if ( !empty( $sync['errors'] ) ) { $errors = array_merge( $errors, (array) $sync['errors'] ); } $details[] = [ 'client_id' => (int) ( $client['id'] ?? 0 ), 'client_name' => (string) ( $client['name'] ?? '' ), 'alerts_synced' => (int) ( $sync['count'] ?? 0 ), 'errors_count' => count( (array) ( $sync['errors'] ?? [] ) ) ]; } if ( $client_id <= 0 && $last_client_id > 0 ) { self::set_setting_value( 'cron_campaigns_product_alerts_last_client_id', (string) $last_client_id ); } echo json_encode( [ 'result' => empty( $errors ) ? 'Walidacja Merchant dla alertow kampanii zakonczona.' : 'Walidacja Merchant zakonczona z bledami.', 'date' => $sync_date, 'processed_clients' => $processed_clients, 'clients_per_run' => $clients_per_run, 'total_clients_available' => $total_clients_available, 'alerts_synced' => $alerts_synced, 'errors' => $errors, 'details' => $details ] ); exit; } static private function build_campaigns_db_map_for_client( $client_id ) { global $mdb; $client_id = (int) $client_id; if ( $client_id <= 0 ) { return []; } $rows = $mdb -> query( "SELECT id, campaign_id FROM campaigns WHERE client_id = :client_id AND campaign_id > 0", [ ':client_id' => $client_id ] ) -> fetchAll( \PDO::FETCH_ASSOC ); $map = []; foreach ( (array) $rows as $row ) { $campaign_external_id = (string) ( (int) ( $row['campaign_id'] ?? 0 ) ); $db_campaign_id = (int) ( $row['id'] ?? 0 ); if ( $campaign_external_id === '0' || $db_campaign_id <= 0 ) { continue; } $map[ $campaign_external_id ] = $db_campaign_id; } return $map; } static private function get_sync_phase_for_client_date( $pipeline, $sync_date, $client_id ) { global $mdb; $row = $mdb -> get( 'cron_sync_status', [ 'phase' ], [ 'AND' => [ 'pipeline' => $pipeline, 'sync_date' => $sync_date, 'client_id' => (int) $client_id ] ] ); if ( !is_array( $row ) || !isset( $row['phase'] ) ) { return null; } return (string) $row['phase']; } static private function get_next_client_id_in_order( $client_ids, $current_client_id ) { $client_ids = array_values( array_filter( array_map( 'intval', (array) $client_ids ) ) ); $current_client_id = (int) $current_client_id; if ( empty( $client_ids ) ) { return 0; } $index = array_search( $current_client_id, $client_ids, true ); if ( $index === false ) { return (int) $client_ids[0]; } $next_index = ( $index + 1 ) % count( $client_ids ); return (int) $client_ids[ $next_index ]; } static private function sync_campaigns_snapshot_for_client( $client, $api, $as_of_date ) { global $mdb; $as_of_date = $as_of_date ? date( 'Y-m-d', strtotime( $as_of_date ) ) : date( 'Y-m-d' ); $processed = 0; $errors = []; $customer_id = trim( (string) ( $client['google_ads_customer_id'] ?? '' ) ); if ( $customer_id === '' ) { return [ 'processed_records' => 0, 'ad_groups_synced' => 0, 'errors' => [ 'Brak Google Ads Customer ID dla klienta ID ' . (int) ( $client['id'] ?? 0 ) . '.' ] ]; } $campaigns_30 = $api -> get_campaigns_30_days( $customer_id, $as_of_date ); if ( $campaigns_30 === false ) { $last_err = \services\GoogleAdsApi::get_setting( 'google_ads_last_error' ); return [ 'processed_records' => 0, 'ad_groups_synced' => 0, 'errors' => [ 'Blad API kampanii (30 dni) dla klienta ' . (string) ( $client['name'] ?? '' ) . ': ' . $last_err ] ]; } $campaigns_all_time = $api -> get_campaigns_all_time( $customer_id, $as_of_date ); if ( $campaigns_all_time === false ) { $last_err = \services\GoogleAdsApi::get_setting( 'google_ads_last_error' ); return [ 'processed_records' => 0, 'ad_groups_synced' => 0, 'errors' => [ 'Blad API kampanii (all time) dla klienta ' . (string) ( $client['name'] ?? '' ) . ': ' . $last_err ] ]; } if ( !is_array( $campaigns_30 ) ) { $campaigns_30 = []; } if ( !is_array( $campaigns_all_time ) ) { $campaigns_all_time = []; } // Brak danych kampanii dla dnia: nie zapisujemy zerowego snapshotu historii. if ( empty( $campaigns_30 ) ) { return [ 'processed_records' => 0, 'ad_groups_synced' => 0, 'errors' => [] ]; } $all_time_map = []; $all_time_totals = [ 'cost' => 0.0, 'conversion_value' => 0.0 ]; foreach ( $campaigns_all_time as $cat ) { $cid = (string) ( $cat['campaign_id'] ?? '' ); if ( $cid === '' ) { continue; } $all_time_map[ $cid ] = [ 'roas_all_time' => (float) ( $cat['roas_all_time'] ?? 0 ), 'cost_all_time' => (float) ( $cat['cost_all_time'] ?? 0 ), 'conversion_value_all_time' => (float) ( $cat['conversion_value_all_time'] ?? 0 ) ]; $all_time_totals['cost'] += (float) ( $cat['cost_all_time'] ?? 0 ); $all_time_totals['conversion_value'] += (float) ( $cat['conversion_value_all_time'] ?? 0 ); } $account_30_totals = [ 'budget' => 0.0, 'money_spent' => 0.0, 'conversion_value' => 0.0 ]; $campaigns_db_map = []; foreach ( $campaigns_30 as $campaign ) { $external_campaign_id = isset( $campaign['campaign_id'] ) ? (string) $campaign['campaign_id'] : ''; if ( $external_campaign_id === '' ) { continue; } $campaign_name = trim( (string) ( $campaign['campaign_name'] ?? '' ) ); if ( $campaign_name === '' ) { $campaign_name = 'Campaign #' . $external_campaign_id; } $advertising_channel_type = strtoupper( trim( (string) ( $campaign['advertising_channel_type'] ?? '' ) ) ); $account_30_totals['budget'] += (float) ( $campaign['budget'] ?? 0 ); $account_30_totals['money_spent'] += (float) ( $campaign['money_spent'] ?? 0 ); $account_30_totals['conversion_value'] += (float) ( $campaign['conversion_value'] ?? 0 ); $db_campaign_id = (int) $mdb -> get( 'campaigns', 'id', [ 'AND' => [ 'client_id' => (int) $client['id'], 'campaign_id' => $external_campaign_id ] ] ); if ( $db_campaign_id > 0 ) { $mdb -> update( 'campaigns', [ 'campaign_name' => $campaign_name, 'advertising_channel_type' => $advertising_channel_type !== '' ? $advertising_channel_type : null ], [ 'id' => $db_campaign_id ] ); } else { $mdb -> insert( 'campaigns', [ 'client_id' => (int) $client['id'], 'campaign_id' => $external_campaign_id, 'campaign_name' => $campaign_name, 'advertising_channel_type' => $advertising_channel_type !== '' ? $advertising_channel_type : null ] ); $db_campaign_id = (int) $mdb -> id(); } if ( $db_campaign_id <= 0 ) { continue; } $all_time_for_campaign = $all_time_map[ $external_campaign_id ] ?? [ 'roas_all_time' => 0 ]; $bidding_strategy = self::format_bidding_strategy( $campaign['bidding_strategy'] ?? '', $campaign['target_roas'] ?? 0 ); $history_data = [ 'roas_30_days' => (float) ( $campaign['roas_30_days'] ?? 0 ), 'roas_all_time' => (float) ( $all_time_for_campaign['roas_all_time'] ?? 0 ), 'budget' => (float) ( $campaign['budget'] ?? 0 ), 'money_spent' => (float) ( $campaign['money_spent'] ?? 0 ), 'conversion_value' => (float) ( $campaign['conversion_value'] ?? 0 ), 'bidding_strategy' => $bidding_strategy ]; if ( $mdb -> count( 'campaigns_history', [ 'AND' => [ 'campaign_id' => $db_campaign_id, 'date_add' => $as_of_date ] ] ) ) { $mdb -> update( 'campaigns_history', $history_data, [ 'AND' => [ 'campaign_id' => $db_campaign_id, 'date_add' => $as_of_date ] ] ); } else { $history_data['campaign_id'] = $db_campaign_id; $history_data['date_add'] = $as_of_date; $mdb -> insert( 'campaigns_history', $history_data ); } $campaigns_db_map[ $external_campaign_id ] = $db_campaign_id; $processed++; } $db_account_campaign_id = (int) $mdb -> get( 'campaigns', 'id', [ 'AND' => [ 'client_id' => (int) $client['id'], 'campaign_id' => 0 ] ] ); if ( $db_account_campaign_id > 0 ) { $mdb -> update( 'campaigns', [ 'campaign_name' => '--- konto ---', 'advertising_channel_type' => null ], [ 'id' => $db_account_campaign_id ] ); } else { $mdb -> insert( 'campaigns', [ 'client_id' => (int) $client['id'], 'campaign_id' => 0, 'campaign_name' => '--- konto ---', 'advertising_channel_type' => null ] ); $db_account_campaign_id = (int) $mdb -> id(); } if ( $db_account_campaign_id > 0 ) { $account_roas_30 = ( $account_30_totals['money_spent'] > 0 ) ? round( ( $account_30_totals['conversion_value'] / $account_30_totals['money_spent'] ) * 100, 2 ) : 0; $account_roas_all_time = ( $all_time_totals['cost'] > 0 ) ? round( ( $all_time_totals['conversion_value'] / $all_time_totals['cost'] ) * 100, 2 ) : 0; $account_history_data = [ 'roas_30_days' => $account_roas_30, 'roas_all_time' => $account_roas_all_time, 'budget' => (float) $account_30_totals['budget'], 'money_spent' => (float) $account_30_totals['money_spent'], 'conversion_value' => (float) $account_30_totals['conversion_value'], 'bidding_strategy' => 'Konto (agregacja wszystkich kampanii)' ]; if ( $mdb -> count( 'campaigns_history', [ 'AND' => [ 'campaign_id' => $db_account_campaign_id, 'date_add' => $as_of_date ] ] ) ) { $mdb -> update( 'campaigns_history', $account_history_data, [ 'AND' => [ 'campaign_id' => $db_account_campaign_id, 'date_add' => $as_of_date ] ] ); } else { $account_history_data['campaign_id'] = $db_account_campaign_id; $account_history_data['date_add'] = $as_of_date; $mdb -> insert( 'campaigns_history', $account_history_data ); } $processed++; } // Grupy reklam / grupy plikow PMAX zapisujemy do campaign_ad_groups. $ad_groups_sync = self::sync_campaign_ad_groups_for_client( $campaigns_db_map, $customer_id, $api, $as_of_date, $as_of_date ); $errors = array_merge( $errors, (array) ( $ad_groups_sync['errors'] ?? [] ) ); return [ 'processed_records' => $processed, 'ad_groups_synced' => (int) ( $ad_groups_sync['count'] ?? 0 ), 'errors' => $errors ]; } static private function sync_campaign_terms_backfill_for_client( $client_id, $customer_id, $api, $sync_dates ) { $client_id = (int) $client_id; $customer_id = trim( (string) $customer_id ); $sync_dates = array_values( array_filter( array_map( function( $date_item ) { $ts = strtotime( (string) $date_item ); return $ts ? date( 'Y-m-d', $ts ) : null; }, (array) $sync_dates ) ) ); if ( $client_id <= 0 || $customer_id === '' || empty( $sync_dates ) ) { return [ 'history_synced' => 0, 'aggregated' => 0, 'errors' => [] ]; } $campaigns_db_map = self::build_campaigns_db_map_for_client( $client_id ); if ( empty( $campaigns_db_map ) ) { return [ 'history_synced' => 0, 'aggregated' => 0, 'errors' => [] ]; } $ad_group_db_map = self::build_ad_group_db_map_from_db( $campaigns_db_map ); $history_synced = 0; $errors = []; foreach ( $sync_dates as $sync_date_item ) { $daily = self::sync_campaign_search_terms_daily( $campaigns_db_map, $ad_group_db_map, $customer_id, $api, $sync_date_item ); $history_synced += (int) ( $daily['count'] ?? 0 ); if ( !empty( $daily['errors'] ) ) { $errors = array_merge( $errors, (array) $daily['errors'] ); } } $aggregate_date = end( $sync_dates ); if ( !$aggregate_date ) { $aggregate_date = date( 'Y-m-d' ); } $aggregated = self::aggregate_campaign_search_terms_for_client( $client_id, $aggregate_date ); return [ 'history_synced' => (int) $history_synced, 'aggregated' => (int) $aggregated, 'errors' => $errors ]; } static private function sync_campaign_keywords_and_negatives_for_client( $client_id, $customer_id, $api, $date_sync ) { $client_id = (int) $client_id; $customer_id = trim( (string) $customer_id ); $date_sync = $date_sync ? date( 'Y-m-d', strtotime( $date_sync ) ) : date( 'Y-m-d' ); if ( $client_id <= 0 || $customer_id === '' ) { return [ 'keywords_synced' => 0, 'negative_keywords_synced' => 0, 'errors' => [] ]; } $campaigns_db_map = self::build_campaigns_db_map_for_client( $client_id ); if ( empty( $campaigns_db_map ) ) { return [ 'keywords_synced' => 0, 'negative_keywords_synced' => 0, 'errors' => [] ]; } $ad_group_db_map = self::build_ad_group_db_map_from_db( $campaigns_db_map ); $keywords_sync = self::sync_campaign_keywords_for_client( $campaigns_db_map, $ad_group_db_map, $customer_id, $api, $date_sync ); $negative_sync = self::sync_campaign_negative_keywords_for_client( $campaigns_db_map, $ad_group_db_map, $customer_id, $api, $date_sync ); return [ 'keywords_synced' => (int) ( $keywords_sync['count'] ?? 0 ), 'negative_keywords_synced' => (int) ( $negative_sync['count'] ?? 0 ), 'errors' => array_merge( (array) ( $keywords_sync['errors'] ?? [] ), (array) ( $negative_sync['errors'] ?? [] ) ) ]; } static private function sync_product_campaign_alerts_for_client( $client, $campaigns_db_map, $ad_group_db_map, $customer_id, $api, $date_sync, $options = [] ) { global $mdb; $run_missing_mapping_alerts = array_key_exists( 'run_missing_mapping_alerts', (array) $options ) ? (bool) $options['run_missing_mapping_alerts'] : true; $run_merchant_validation_alerts = array_key_exists( 'run_merchant_validation_alerts', (array) $options ) ? (bool) $options['run_merchant_validation_alerts'] : true; if ( !$run_missing_mapping_alerts && !$run_merchant_validation_alerts ) { return [ 'count' => 0, 'errors' => [] ]; } self::log_product_alerts_debug( 'sync_product_campaign_alerts_for_client:start', [ 'client_id' => (int) ( $client['id'] ?? 0 ), 'customer_id' => (string) $customer_id, 'date_sync' => (string) $date_sync, 'campaigns_db_map_count' => count( (array) $campaigns_db_map ), 'ad_group_db_map_count' => count( (array) $ad_group_db_map ), 'run_missing_mapping_alerts' => $run_missing_mapping_alerts ? 1 : 0, 'run_merchant_validation_alerts' => $run_merchant_validation_alerts ? 1 : 0 ] ); $campaign_db_ids = array_values( array_unique( array_map( 'intval', array_values( $campaigns_db_map ) ) ) ); if ( empty( $campaign_db_ids ) ) { self::log_product_alerts_debug( 'sync_product_campaign_alerts_for_client:skip_no_campaigns', [] ); return [ 'count' => 0, 'errors' => [] ]; } $date_sync = date( 'Y-m-d', strtotime( $date_sync ) ); $client_id = (int) ( $client['id'] ?? 0 ); $client_name = trim( (string) ( $client['name'] ?? '' ) ); $merchant_account_id = preg_replace( '/\D+/', '', (string) ( $client['google_merchant_account_id'] ?? '' ) ); if ( $run_merchant_validation_alerts && $merchant_account_id === '' ) { self::log_product_alerts_debug( 'sync_product_campaign_alerts_for_client:skip_no_merchant_account', [ 'client_id' => $client_id ] ); return [ 'count' => 0, 'errors' => [] ]; } $shopping_campaigns = $mdb -> query( "SELECT id, campaign_id, campaign_name FROM campaigns WHERE id IN (" . implode( ',', $campaign_db_ids ) . ") AND UPPER( TRIM( COALESCE( advertising_channel_type, '' ) ) ) = 'SHOPPING'" ) -> fetchAll( \PDO::FETCH_ASSOC ); if ( !is_array( $shopping_campaigns ) || empty( $shopping_campaigns ) ) { self::log_product_alerts_debug( 'sync_product_campaign_alerts_for_client:skip_no_shopping_campaigns', [ 'client_id' => $client_id, 'campaign_db_ids' => $campaign_db_ids ] ); return [ 'count' => 0, 'errors' => [] ]; } $shopping_campaign_external_ids = []; $shopping_campaign_names_by_db_id = []; foreach ( $shopping_campaigns as $campaign_row ) { $db_campaign_id = (int) ( $campaign_row['id'] ?? 0 ); $external_campaign_id = (int) ( $campaign_row['campaign_id'] ?? 0 ); if ( $db_campaign_id <= 0 || $external_campaign_id <= 0 ) { continue; } $shopping_campaign_external_ids[ (string) $external_campaign_id ] = true; $shopping_campaign_names_by_db_id[ $db_campaign_id ] = trim( (string) ( $campaign_row['campaign_name'] ?? '' ) ); } if ( empty( $shopping_campaign_external_ids ) ) { self::log_product_alerts_debug( 'sync_product_campaign_alerts_for_client:skip_no_external_campaign_ids', [] ); return [ 'count' => 0, 'errors' => [] ]; } $shopping_ad_groups_rows = $mdb -> query( "SELECT c.id AS campaign_db_id, c.campaign_id AS campaign_external_id, c.campaign_name, ag.id AS ad_group_db_id, ag.ad_group_id AS ad_group_external_id, ag.ad_group_name, ag.clicks_30, ag.clicks_all_time FROM campaign_ad_groups AS ag INNER JOIN campaigns AS c ON c.id = ag.campaign_id WHERE c.id IN (" . implode( ',', array_keys( $shopping_campaign_names_by_db_id ) ) . ") AND ag.ad_group_id > 0" ) -> fetchAll( \PDO::FETCH_ASSOC ); $shopping_ad_groups_by_scope = []; foreach ( (array) $shopping_ad_groups_rows as $ag_row ) { $campaign_external_id = (int) ( $ag_row['campaign_external_id'] ?? 0 ); $ad_group_external_id = (int) ( $ag_row['ad_group_external_id'] ?? 0 ); if ( $campaign_external_id <= 0 || $ad_group_external_id <= 0 ) { continue; } $scope_key = $campaign_external_id . '|' . $ad_group_external_id; $shopping_ad_groups_by_scope[ $scope_key ] = $ag_row; } $ad_groups_offer_ids = []; $offer_mapping_sources = []; $missing_mapping_source_reliable = false; $ad_groups_offer_ids_listing = $api -> get_shopping_ad_group_offer_ids( $customer_id ); self::log_product_alerts_debug( 'google_ads:get_shopping_ad_group_offer_ids', [ 'result_type' => is_array( $ad_groups_offer_ids_listing ) ? 'array' : ( $ad_groups_offer_ids_listing === false ? 'false' : gettype( $ad_groups_offer_ids_listing ) ), 'rows_count' => is_array( $ad_groups_offer_ids_listing ) ? count( $ad_groups_offer_ids_listing ) : 0, 'sample' => is_array( $ad_groups_offer_ids_listing ) ? array_slice( $ad_groups_offer_ids_listing, 0, 3 ) : [] ] ); if ( is_array( $ad_groups_offer_ids_listing ) ) { $ad_groups_offer_ids = self::merge_shopping_ad_group_offer_rows( $ad_groups_offer_ids, $ad_groups_offer_ids_listing ); $offer_mapping_sources['listing_group'] = true; $missing_mapping_source_reliable = true; } $ad_groups_offer_ids_shopping_product = $api -> get_shopping_ad_group_offer_ids_from_shopping_product( $customer_id ); self::log_product_alerts_debug( 'google_ads:get_shopping_ad_group_offer_ids_from_shopping_product', [ 'result_type' => is_array( $ad_groups_offer_ids_shopping_product ) ? 'array' : ( $ad_groups_offer_ids_shopping_product === false ? 'false' : gettype( $ad_groups_offer_ids_shopping_product ) ), 'rows_count' => is_array( $ad_groups_offer_ids_shopping_product ) ? count( $ad_groups_offer_ids_shopping_product ) : 0, 'sample' => is_array( $ad_groups_offer_ids_shopping_product ) ? array_slice( $ad_groups_offer_ids_shopping_product, 0, 3 ) : [] ] ); if ( is_array( $ad_groups_offer_ids_shopping_product ) ) { $ad_groups_offer_ids = self::merge_shopping_ad_group_offer_rows( $ad_groups_offer_ids, $ad_groups_offer_ids_shopping_product ); $offer_mapping_sources['shopping_product'] = true; } if ( empty( $ad_groups_offer_ids ) ) { $ad_groups_offer_ids_performance = $api -> get_shopping_ad_group_offer_ids_from_performance( $customer_id ); self::log_product_alerts_debug( 'google_ads:get_shopping_ad_group_offer_ids_from_performance', [ 'result_type' => is_array( $ad_groups_offer_ids_performance ) ? 'array' : ( $ad_groups_offer_ids_performance === false ? 'false' : gettype( $ad_groups_offer_ids_performance ) ), 'rows_count' => is_array( $ad_groups_offer_ids_performance ) ? count( $ad_groups_offer_ids_performance ) : 0, 'sample' => is_array( $ad_groups_offer_ids_performance ) ? array_slice( $ad_groups_offer_ids_performance, 0, 3 ) : [] ] ); if ( is_array( $ad_groups_offer_ids_performance ) ) { $ad_groups_offer_ids = self::merge_shopping_ad_group_offer_rows( $ad_groups_offer_ids, $ad_groups_offer_ids_performance ); $offer_mapping_sources['performance'] = true; } } if ( empty( $ad_groups_offer_ids ) ) { $ad_groups_offer_ids_history = self::get_shopping_ad_group_offer_ids_from_history( $client_id, array_keys( $shopping_campaign_names_by_db_id ) ); self::log_product_alerts_debug( 'db:get_shopping_ad_group_offer_ids_from_history', [ 'rows_count' => is_array( $ad_groups_offer_ids_history ) ? count( $ad_groups_offer_ids_history ) : 0, 'sample' => is_array( $ad_groups_offer_ids_history ) ? array_slice( $ad_groups_offer_ids_history, 0, 3 ) : [] ] ); if ( is_array( $ad_groups_offer_ids_history ) ) { $ad_groups_offer_ids = self::merge_shopping_ad_group_offer_rows( $ad_groups_offer_ids, $ad_groups_offer_ids_history ); $offer_mapping_sources['history'] = true; } } self::log_product_alerts_debug( 'google_ads:offer_id_sources_summary', [ 'sources' => array_values( array_keys( $offer_mapping_sources ) ), 'rows_count' => count( (array) $ad_groups_offer_ids ), 'missing_mapping_source_reliable' => $missing_mapping_source_reliable ? 1 : 0 ] ); if ( !is_array( $ad_groups_offer_ids ) || empty( $ad_groups_offer_ids ) ) { self::log_product_alerts_debug( 'sync_product_campaign_alerts_for_client:skip_no_ad_group_offer_rows', [] ); return [ 'count' => 0, 'errors' => [] ]; } $offer_ids_to_verify = []; $candidate_rows = []; foreach ( $ad_groups_offer_ids as $row ) { $campaign_external_id = (string) ( (int) ( $row['campaign_id'] ?? 0 ) ); $ad_group_external_id = (string) ( (int) ( $row['ad_group_id'] ?? 0 ) ); $offer_ids = array_values( array_unique( array_filter( array_map( function( $item ) { return self::normalize_offer_id_for_lookup( $item ); }, (array) ( $row['offer_ids'] ?? [] ) ) ) ) ); if ( $campaign_external_id === '0' || $ad_group_external_id === '0' || empty( $offer_ids ) ) { continue; } if ( !isset( $shopping_campaign_external_ids[ $campaign_external_id ] ) ) { continue; } $scope_key = $campaign_external_id . '|' . $ad_group_external_id; $candidate_rows[ $scope_key ] = [ 'campaign_external_id' => (int) $campaign_external_id, 'ad_group_external_id' => (int) $ad_group_external_id, 'campaign_name' => trim( (string) ( $row['campaign_name'] ?? '' ) ), 'ad_group_name' => trim( (string) ( $row['ad_group_name'] ?? '' ) ), 'offer_ids' => $offer_ids ]; if ( !$run_merchant_validation_alerts ) { continue; } foreach ( $offer_ids as $offer_id ) { foreach ( self::build_offer_id_lookup_variants( $offer_id ) as $offer_id_variant ) { $offer_ids_to_verify[ $offer_id_variant ] = true; } } } if ( $run_merchant_validation_alerts ) { self::log_product_alerts_debug( 'merchant:offer_ids_to_verify', [ 'count' => count( $offer_ids_to_verify ), 'sample' => array_slice( array_values( array_keys( $offer_ids_to_verify ) ), 0, 30 ) ] ); } $merchant_items_map = []; if ( $run_merchant_validation_alerts && !empty( $offer_ids_to_verify ) ) { $merchant_items_map = $api -> get_merchant_products_for_offer_ids( $merchant_account_id, array_keys( $offer_ids_to_verify ) ); self::log_product_alerts_debug( 'merchant:get_merchant_products_for_offer_ids', [ 'merchant_account_id' => $merchant_account_id, 'request_count' => count( $offer_ids_to_verify ), 'response_type' => is_array( $merchant_items_map ) ? 'array' : ( $merchant_items_map === false ? 'false' : gettype( $merchant_items_map ) ), 'response_count' => is_array( $merchant_items_map ) ? count( $merchant_items_map ) : 0, 'response_sample_keys' => is_array( $merchant_items_map ) ? array_slice( array_values( array_keys( $merchant_items_map ) ), 0, 30 ) : [] ] ); if ( $merchant_items_map === false ) { $merchant_error = trim( (string) \services\GoogleAdsApi::get_setting( 'google_merchant_last_error' ) ); if ( $merchant_error === '' ) { $merchant_error = 'Nie udalo sie pobrac produktow z Merchant Center.'; } return [ 'count' => 0, 'errors' => [ 'Pominieto alerty produktowe: ' . $merchant_error ] ]; } } if ( $run_merchant_validation_alerts && !is_array( $merchant_items_map ) ) { $merchant_items_map = []; } $local_offer_ids_map = []; if ( $run_merchant_validation_alerts ) { $local_offer_ids_rows = $mdb -> query( "SELECT offer_id FROM products WHERE client_id = :client_id AND TRIM( COALESCE( offer_id, '' ) ) <> ''", [ ':client_id' => $client_id ] ) -> fetchAll( \PDO::FETCH_ASSOC ); foreach ( (array) $local_offer_ids_rows as $local_row ) { $local_offer_norm = self::normalize_offer_id_for_compare( $local_row['offer_id'] ?? '' ); if ( $local_offer_norm !== '' ) { $local_offer_ids_map[ $local_offer_norm ] = true; } } } if ( $run_merchant_validation_alerts ) { self::log_product_alerts_debug( 'db:local_offer_ids', [ 'count' => count( $local_offer_ids_map ), 'sample' => array_slice( array_values( array_keys( $local_offer_ids_map ) ), 0, 30 ) ] ); } $merchant_items_map_normalized = []; if ( $run_merchant_validation_alerts ) { foreach ( $merchant_items_map as $merchant_offer_id => $merchant_item ) { $offer_id_norm = self::normalize_offer_id_for_compare( $merchant_offer_id ); if ( $offer_id_norm === '' || isset( $merchant_items_map_normalized[ $offer_id_norm ] ) ) { continue; } $merchant_items_map_normalized[ $offer_id_norm ] = $merchant_item; } } $inserted = 0; $problematic_scopes_by_type = []; if ( $run_merchant_validation_alerts ) { $problematic_scopes_by_type['ad_group_without_active_product'] = []; $problematic_scopes_by_type['ad_group_with_orphaned_offers'] = []; } if ( $run_missing_mapping_alerts ) { $problematic_scopes_by_type['ad_group_without_detected_product'] = []; } $insert_alert = function( $alert_type, $campaign_external_id, $ad_group_external_id, $db_campaign_id, $db_ad_group_id, $message, $meta ) use ( $mdb, $client_id, $date_sync ) { $existing_id = (int) $mdb -> get( 'campaign_alerts', 'id', [ 'AND' => [ 'client_id' => $client_id, 'campaign_external_id' => (int) $campaign_external_id, 'ad_group_external_id' => (int) $ad_group_external_id, 'alert_type' => (string) $alert_type, 'date_detected' => $date_sync ] ] ); if ( $existing_id > 0 ) { return false; } $mdb -> insert( 'campaign_alerts', [ 'client_id' => $client_id, 'campaign_id' => (int) $db_campaign_id > 0 ? (int) $db_campaign_id : null, 'campaign_external_id' => (int) $campaign_external_id, 'ad_group_id' => (int) $db_ad_group_id > 0 ? (int) $db_ad_group_id : null, 'ad_group_external_id' => (int) $ad_group_external_id, 'alert_type' => (string) $alert_type, 'message' => (string) $message, 'meta_json' => json_encode( (array) $meta, JSON_UNESCAPED_UNICODE ), 'date_detected' => $date_sync, 'date_add' => date( 'Y-m-d H:i:s' ) ] ); return true; }; if ( $run_merchant_validation_alerts ) { foreach ( $candidate_rows as $scope_key => $row ) { $campaign_external_id = (int) $row['campaign_external_id']; $ad_group_external_id = (int) $row['ad_group_external_id']; $offer_ids = (array) $row['offer_ids']; if ( empty( $offer_ids ) ) { continue; } $active_offer_count = 0; $orphaned_offer_ids = []; $local_known_offer_count = 0; foreach ( $offer_ids as $offer_id ) { $matched = false; foreach ( self::build_offer_id_lookup_variants( $offer_id ) as $offer_id_variant ) { if ( isset( $merchant_items_map[ $offer_id_variant ] ) ) { $matched = true; break; } } $offer_id_norm = self::normalize_offer_id_for_compare( $offer_id ); if ( $matched || ( $offer_id_norm !== '' && isset( $merchant_items_map_normalized[ $offer_id_norm ] ) ) ) { $active_offer_count++; } else { $orphaned_offer_ids[] = $offer_id; } if ( $offer_id_norm !== '' && isset( $local_offer_ids_map[ $offer_id_norm ] ) ) { $local_known_offer_count++; } } self::log_product_alerts_debug( 'scope:verification', [ 'scope_key' => $scope_key, 'campaign_external_id' => $campaign_external_id, 'ad_group_external_id' => $ad_group_external_id, 'offer_ids' => $offer_ids, 'active_offer_count' => $active_offer_count, 'orphaned_offer_ids' => $orphaned_offer_ids, 'local_known_offer_count' => $local_known_offer_count ] ); if ( $active_offer_count > 0 && empty( $orphaned_offer_ids ) ) { continue; } $db_campaign_id = (int) ( $campaigns_db_map[ (string) $campaign_external_id ] ?? 0 ); $db_ad_group_id = (int) ( $ad_group_db_map[ (string) $campaign_external_id . '|' . (string) $ad_group_external_id ] ?? 0 ); $campaign_name = trim( (string) ( $row['campaign_name'] ?? '' ) ); if ( $campaign_name === '' ) { $campaign_name = trim( (string) ( $shopping_campaign_names_by_db_id[ $db_campaign_id ] ?? '' ) ); } if ( $campaign_name === '' ) { $campaign_name = 'Kampania #' . $campaign_external_id; } $ad_group_name = trim( (string) ( $row['ad_group_name'] ?? '' ) ); if ( $ad_group_name === '' ) { $ad_group_name = 'Grupa reklam #' . $ad_group_external_id; } if ( $active_offer_count === 0 ) { if ( $local_known_offer_count > 0 ) { self::log_product_alerts_debug( 'scope:skip_alert_local_offer_match', [ 'scope_key' => $scope_key, 'campaign_external_id' => $campaign_external_id, 'ad_group_external_id' => $ad_group_external_id, 'local_known_offer_count' => $local_known_offer_count ] ); continue; } $message = 'Brak aktywnych produktów w Merchant Center. Grupa reklam to "' . $ad_group_name . '" w kampanii "' . $campaign_name . '" na koncie klienta "' . $client_name . '".'; if ( $insert_alert( 'ad_group_without_active_product', $campaign_external_id, $ad_group_external_id, $db_campaign_id, $db_ad_group_id, $message, [ 'offer_ids' => $offer_ids, 'merchant_account_id' => $merchant_account_id, 'source' => 'cron_campaigns_sync_merchant' ] ) ) { $inserted++; } $problematic_scopes_by_type['ad_group_without_active_product'][ $scope_key ] = true; } if ( !empty( $orphaned_offer_ids ) && $active_offer_count > 0 ) { $orphaned_list = implode( ', ', array_slice( $orphaned_offer_ids, 0, 10 ) ); if ( count( $orphaned_offer_ids ) > 10 ) { $orphaned_list .= ' (i ' . ( count( $orphaned_offer_ids ) - 10 ) . ' więcej)'; } $message = count( $orphaned_offer_ids ) . ' osieroconych produktów (brak w MC), aktywnych: ' . $active_offer_count . '. Grupa reklam to "' . $ad_group_name . '" w kampanii "' . $campaign_name . '" na koncie klienta "' . $client_name . '". Osierocone ID: ' . $orphaned_list . '.'; if ( $insert_alert( 'ad_group_with_orphaned_offers', $campaign_external_id, $ad_group_external_id, $db_campaign_id, $db_ad_group_id, $message, [ 'orphaned_offer_ids' => $orphaned_offer_ids, 'active_offer_count' => $active_offer_count, 'total_offer_count' => count( $offer_ids ), 'merchant_account_id' => $merchant_account_id, 'source' => 'cron_campaigns_sync_merchant' ] ) ) { $inserted++; } $problematic_scopes_by_type['ad_group_with_orphaned_offers'][ $scope_key ] = true; } } } if ( $run_missing_mapping_alerts && $missing_mapping_source_reliable ) { foreach ( $shopping_ad_groups_by_scope as $scope_key => $ag_row ) { if ( isset( $candidate_rows[ $scope_key ] ) ) { continue; } $campaign_external_id = (int) ( $ag_row['campaign_external_id'] ?? 0 ); $ad_group_external_id = (int) ( $ag_row['ad_group_external_id'] ?? 0 ); $db_campaign_id = (int) ( $ag_row['campaign_db_id'] ?? 0 ); $db_ad_group_id = (int) ( $ag_row['ad_group_db_id'] ?? 0 ); if ( $campaign_external_id <= 0 || $ad_group_external_id <= 0 ) { continue; } $campaign_name = trim( (string) ( $ag_row['campaign_name'] ?? '' ) ); if ( $campaign_name === '' ) { $campaign_name = 'Kampania #' . $campaign_external_id; } $ad_group_name = trim( (string) ( $ag_row['ad_group_name'] ?? '' ) ); if ( $ad_group_name === '' ) { $ad_group_name = 'Grupa reklam #' . $ad_group_external_id; } $message = 'Brak wykrytego przypisanego produktu. Grupa reklam to "' . $ad_group_name . '" w kampanii "' . $campaign_name . '" na koncie klienta "' . $client_name . '".'; if ( $insert_alert( 'ad_group_without_detected_product', $campaign_external_id, $ad_group_external_id, $db_campaign_id, $db_ad_group_id, $message, [ 'merchant_account_id' => $merchant_account_id, 'clicks_30' => (int) ( $ag_row['clicks_30'] ?? 0 ), 'clicks_all_time' => (int) ( $ag_row['clicks_all_time'] ?? 0 ), 'offer_mapping_sources' => array_values( array_keys( $offer_mapping_sources ) ), 'source' => 'cron_campaigns_sync_missing_mapping' ] ) ) { $inserted++; } $problematic_scopes_by_type['ad_group_without_detected_product'][ $scope_key ] = true; } } else if ( $run_missing_mapping_alerts ) { self::log_product_alerts_debug( 'scope:skip_without_detected_product_unreliable_source', [ 'sources' => array_values( array_keys( $offer_mapping_sources ) ), 'missing_mapping_source_reliable' => $missing_mapping_source_reliable ? 1 : 0 ] ); } // Czysci alerty, ktore byly prawdziwe wczesniej, ale w aktualnym syncu // nie sa juz problematyczne albo grupa nie jest juz aktywna/pobrana. $active_scope_map = []; foreach ( $shopping_ad_groups_by_scope as $scope_key => $ag_row ) { $active_scope_map[ $scope_key ] = true; } $alert_types_for_cleanup = array_keys( $problematic_scopes_by_type ); $existing_product_alerts = []; if ( !empty( $alert_types_for_cleanup ) ) { $existing_product_alerts = $mdb -> select( 'campaign_alerts', [ 'id', 'campaign_external_id', 'ad_group_external_id', 'alert_type' ], [ 'AND' => [ 'client_id' => $client_id, 'alert_type' => $alert_types_for_cleanup ] ] ); } $cleaned = 0; foreach ( (array) $existing_product_alerts as $existing_alert ) { $alert_id = (int) ( $existing_alert['id'] ?? 0 ); if ( $alert_id <= 0 ) { continue; } $alert_type = (string) ( $existing_alert['alert_type'] ?? '' ); if ( $alert_type === '' || !isset( $problematic_scopes_by_type[ $alert_type ] ) ) { continue; } $scope_key = (string) ( (int) ( $existing_alert['campaign_external_id'] ?? 0 ) ) . '|' . (string) ( (int) ( $existing_alert['ad_group_external_id'] ?? 0 ) ); if ( !isset( $active_scope_map[ $scope_key ] ) ) { $mdb -> delete( 'campaign_alerts', [ 'id' => $alert_id ] ); $cleaned++; continue; } if ( !isset( $problematic_scopes_by_type[ $alert_type ][ $scope_key ] ) ) { $mdb -> delete( 'campaign_alerts', [ 'id' => $alert_id ] ); $cleaned++; } } self::log_product_alerts_debug( 'alerts:cleanup', [ 'existing_count' => count( (array) $existing_product_alerts ), 'cleaned_count' => $cleaned, 'run_missing_mapping_alerts' => $run_missing_mapping_alerts ? 1 : 0, 'run_merchant_validation_alerts' => $run_merchant_validation_alerts ? 1 : 0, 'problematic_without_active_product' => count( (array) ( $problematic_scopes_by_type['ad_group_without_active_product'] ?? [] ) ), 'problematic_with_orphaned_offers' => count( (array) ( $problematic_scopes_by_type['ad_group_with_orphaned_offers'] ?? [] ) ), 'problematic_without_detected_product' => count( (array) ( $problematic_scopes_by_type['ad_group_without_detected_product'] ?? [] ) ) ] ); return [ 'count' => $inserted, 'errors' => [] ]; } static private function log_product_alerts_debug( $stage, $context = [] ) { $stage = trim( (string) $stage ); if ( $stage === '' ) { $stage = 'unknown_stage'; } $log_file = dirname( __DIR__, 2 ) . '/tmp/campaign_alerts_debug.log'; $payload = [ 'ts' => date( 'Y-m-d H:i:s' ), 'stage' => $stage, 'context' => is_array( $context ) ? $context : [ 'value' => $context ] ]; $json = json_encode( $payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ); if ( $json === false ) { $json = '{"ts":"' . date( 'Y-m-d H:i:s' ) . '","stage":"json_encode_failed"}'; } if ( strlen( $json ) > 30000 ) { $json = substr( $json, 0, 30000 ) . '...TRUNCATED'; } @file_put_contents( $log_file, $json . PHP_EOL, FILE_APPEND | LOCK_EX ); } static private function merge_shopping_ad_group_offer_rows( $base_rows, $rows_to_merge ) { $scope_map = []; foreach ( (array) $base_rows as $row ) { $campaign_id = (int) ( $row['campaign_id'] ?? 0 ); $ad_group_id = (int) ( $row['ad_group_id'] ?? 0 ); if ( $campaign_id <= 0 || $ad_group_id <= 0 ) { continue; } $scope_key = $campaign_id . '|' . $ad_group_id; $scope_map[ $scope_key ] = [ 'campaign_id' => $campaign_id, 'campaign_name' => trim( (string) ( $row['campaign_name'] ?? '' ) ), 'ad_group_id' => $ad_group_id, 'ad_group_name' => trim( (string) ( $row['ad_group_name'] ?? '' ) ), 'offer_ids' => [] ]; foreach ( (array) ( $row['offer_ids'] ?? [] ) as $offer_id ) { $offer_id_norm = self::normalize_offer_id_for_lookup( $offer_id ); if ( $offer_id_norm === '' ) { continue; } $scope_map[ $scope_key ]['offer_ids'][ $offer_id_norm ] = true; } } foreach ( (array) $rows_to_merge as $row ) { $campaign_id = (int) ( $row['campaign_id'] ?? 0 ); $ad_group_id = (int) ( $row['ad_group_id'] ?? 0 ); if ( $campaign_id <= 0 || $ad_group_id <= 0 ) { continue; } $scope_key = $campaign_id . '|' . $ad_group_id; if ( !isset( $scope_map[ $scope_key ] ) ) { $scope_map[ $scope_key ] = [ 'campaign_id' => $campaign_id, 'campaign_name' => trim( (string) ( $row['campaign_name'] ?? '' ) ), 'ad_group_id' => $ad_group_id, 'ad_group_name' => trim( (string) ( $row['ad_group_name'] ?? '' ) ), 'offer_ids' => [] ]; } if ( trim( (string) $scope_map[ $scope_key ]['campaign_name'] ) === '' ) { $scope_map[ $scope_key ]['campaign_name'] = trim( (string) ( $row['campaign_name'] ?? '' ) ); } if ( trim( (string) $scope_map[ $scope_key ]['ad_group_name'] ) === '' ) { $scope_map[ $scope_key ]['ad_group_name'] = trim( (string) ( $row['ad_group_name'] ?? '' ) ); } foreach ( (array) ( $row['offer_ids'] ?? [] ) as $offer_id ) { $offer_id_norm = self::normalize_offer_id_for_lookup( $offer_id ); if ( $offer_id_norm === '' ) { continue; } $scope_map[ $scope_key ]['offer_ids'][ $offer_id_norm ] = true; } } $rows = []; foreach ( $scope_map as $scope_row ) { $offer_ids = array_values( array_keys( (array) ( $scope_row['offer_ids'] ?? [] ) ) ); if ( empty( $offer_ids ) ) { continue; } $scope_row['offer_ids'] = $offer_ids; $rows[] = $scope_row; } return $rows; } static private function normalize_offer_id_for_lookup( $offer_id ) { $offer_id = trim( (string) $offer_id ); if ( $offer_id === '' ) { return ''; } $offer_id = trim( $offer_id, " \t\n\r\0\x0B'\"" ); // Google Ads moze zwracac format channel:language:country:offer_id. // W Merchant API do products.list kluczem jest offer_id. if ( preg_match( '/^[a-z_]+:[a-z]{2,8}:[a-z]{2,8}:(.+)$/i', $offer_id, $matches ) ) { $offer_id = trim( (string) ( $matches[1] ?? '' ) ); } return $offer_id; } static private function normalize_offer_id_for_compare( $offer_id ) { $offer_id = self::normalize_offer_id_for_lookup( $offer_id ); if ( $offer_id === '' ) { return ''; } $offer_id = strtolower( $offer_id ); return $offer_id; } static private function build_offer_id_lookup_variants( $offer_id ) { $offer_id = self::normalize_offer_id_for_lookup( $offer_id ); if ( $offer_id === '' ) { return []; } $variants = [ $offer_id ]; // Shopify: spotykane roznice zapisu kraju w prefiksie (pl vs PL). if ( preg_match( '/^shopify_([a-z]{2})_(.+)$/i', $offer_id, $matches ) ) { $country = (string) ( $matches[1] ?? '' ); $rest = (string) ( $matches[2] ?? '' ); if ( $rest !== '' ) { $variants[] = 'shopify_' . strtolower( $country ) . '_' . $rest; $variants[] = 'shopify_' . strtoupper( $country ) . '_' . $rest; } } return array_values( array_unique( array_filter( $variants ) ) ); } static private function get_shopping_ad_group_offer_ids_from_history( $client_id, $shopping_campaign_db_ids ) { global $mdb; $client_id = (int) $client_id; $shopping_campaign_db_ids = array_values( array_unique( array_map( 'intval', (array) $shopping_campaign_db_ids ) ) ); if ( $client_id <= 0 || empty( $shopping_campaign_db_ids ) ) { return []; } $campaign_ids_sql = implode( ',', $shopping_campaign_db_ids ); $rows = $mdb -> query( "SELECT c.id AS campaign_db_id, c.campaign_id AS campaign_external_id, c.campaign_name, ag.id AS ad_group_db_id, ag.ad_group_id AS ad_group_external_id, ag.ad_group_name, p.offer_id FROM campaign_ad_groups AS ag INNER JOIN campaigns AS c ON c.id = ag.campaign_id INNER JOIN products_history AS ph ON ph.campaign_id = c.id AND ph.ad_group_id = ag.id INNER JOIN products AS p ON p.id = ph.product_id WHERE c.client_id = :client_id AND c.id IN (" . $campaign_ids_sql . ") AND ag.ad_group_id > 0 AND TRIM( COALESCE( p.offer_id, '' ) ) <> ''", [ ':client_id' => $client_id ] ) -> fetchAll( \PDO::FETCH_ASSOC ); if ( !is_array( $rows ) || empty( $rows ) ) { return []; } $scopes = []; foreach ( $rows as $row ) { $campaign_external_id = (int) ( $row['campaign_external_id'] ?? 0 ); $ad_group_external_id = (int) ( $row['ad_group_external_id'] ?? 0 ); $offer_id = trim( (string) ( $row['offer_id'] ?? '' ) ); if ( $campaign_external_id <= 0 || $ad_group_external_id <= 0 || $offer_id === '' ) { continue; } $scope_key = $campaign_external_id . '|' . $ad_group_external_id; if ( !isset( $scopes[ $scope_key ] ) ) { $scopes[ $scope_key ] = [ 'campaign_id' => $campaign_external_id, 'campaign_name' => trim( (string) ( $row['campaign_name'] ?? '' ) ), 'ad_group_id' => $ad_group_external_id, 'ad_group_name' => trim( (string) ( $row['ad_group_name'] ?? '' ) ), 'offer_ids' => [] ]; } $scopes[ $scope_key ]['offer_ids'][ $offer_id ] = true; } foreach ( $scopes as &$scope ) { $scope['offer_ids'] = array_values( array_keys( (array) $scope['offer_ids'] ) ); } unset( $scope ); return array_values( $scopes ); } static private function sync_campaign_ad_groups_for_client( $campaigns_db_map, $customer_id, $api, $date_sync, $as_of_date = null ) { global $mdb; $campaign_db_ids = array_values( array_unique( array_map( 'intval', array_values( $campaigns_db_map ) ) ) ); if ( empty( $campaign_db_ids ) ) { return [ 'count' => 0, 'ad_group_map' => [], 'errors' => [] ]; } $as_of_date = $as_of_date ? date( 'Y-m-d', strtotime( $as_of_date ) ) : date( 'Y-m-d', strtotime( $date_sync ) ); $ad_groups_30 = $api -> get_ad_groups_30_days( $customer_id, $as_of_date ); if ( $ad_groups_30 === false ) { $last_err = \services\GoogleAdsApi::get_setting( 'google_ads_last_error' ); return [ 'count' => 0, 'ad_group_map' => [], 'errors' => [ 'Blad pobierania grup reklam (30 dni): ' . $last_err ] ]; } $ad_groups_all_time = $api -> get_ad_groups_all_time( $customer_id, $as_of_date ); if ( $ad_groups_all_time === false ) { $last_err = \services\GoogleAdsApi::get_setting( 'google_ads_last_error' ); return [ 'count' => 0, 'ad_group_map' => [], 'errors' => [ 'Blad pobierania grup reklam (all time): ' . $last_err ] ]; } if ( !is_array( $ad_groups_30 ) ) { $ad_groups_30 = []; } if ( !is_array( $ad_groups_all_time ) ) { $ad_groups_all_time = []; } $map_30 = []; foreach ( $ad_groups_30 as $row ) { $campaign_external_id = isset( $row['campaign_id'] ) ? (string) $row['campaign_id'] : ''; $ad_group_external_id = isset( $row['ad_group_id'] ) ? (string) $row['ad_group_id'] : ''; if ( $campaign_external_id === '' || $ad_group_external_id === '' ) { continue; } $map_30[ $campaign_external_id . '|' . $ad_group_external_id ] = $row; } $map_all_time = []; foreach ( $ad_groups_all_time as $row ) { $campaign_external_id = isset( $row['campaign_id'] ) ? (string) $row['campaign_id'] : ''; $ad_group_external_id = isset( $row['ad_group_id'] ) ? (string) $row['ad_group_id'] : ''; if ( $campaign_external_id === '' || $ad_group_external_id === '' ) { continue; } $map_all_time[ $campaign_external_id . '|' . $ad_group_external_id ] = $row; } $keys = array_values( array_unique( array_merge( array_keys( $map_30 ), array_keys( $map_all_time ) ) ) ); $ad_group_db_map = []; $seen_db_ids = []; $count = 0; foreach ( $keys as $key ) { $parts = explode( '|', $key, 2 ); $campaign_external_id = $parts[0] ?? ''; $ad_group_external_id = $parts[1] ?? ''; $db_campaign_id = (int) ( $campaigns_db_map[ $campaign_external_id ] ?? 0 ); if ( $db_campaign_id <= 0 || $ad_group_external_id === '' ) { continue; } $row_30 = $map_30[ $key ] ?? []; $row_all_time = $map_all_time[ $key ] ?? []; $ad_group_name = trim( (string) ( $row_30['ad_group_name'] ?? ( $row_all_time['ad_group_name'] ?? '' ) ) ); if ( $ad_group_name === '' ) { $ad_group_name = 'Ad group #' . $ad_group_external_id; } $data = [ 'ad_group_name' => $ad_group_name, 'impressions_30' => (int) ( $row_30['impressions'] ?? 0 ), 'clicks_30' => (int) ( $row_30['clicks'] ?? 0 ), 'cost_30' => (float) ( $row_30['cost'] ?? 0 ), 'conversions_30' => (float) ( $row_30['conversions'] ?? 0 ), 'conversion_value_30' => (float) ( $row_30['conversion_value'] ?? 0 ), 'roas_30' => (float) ( $row_30['roas'] ?? 0 ), 'impressions_all_time' => (int) ( $row_all_time['impressions'] ?? 0 ), 'clicks_all_time' => (int) ( $row_all_time['clicks'] ?? 0 ), 'cost_all_time' => (float) ( $row_all_time['cost'] ?? 0 ), 'conversions_all_time' => (float) ( $row_all_time['conversions'] ?? 0 ), 'conversion_value_all_time' => (float) ( $row_all_time['conversion_value'] ?? 0 ), 'roas_all_time' => (float) ( $row_all_time['roas'] ?? 0 ), 'date_sync' => $date_sync ]; // Upsert: zachowaj istniejace DB ID (nie kasuj, bo CASCADE usunalby historie) $existing_id = (int) $mdb -> get( 'campaign_ad_groups', 'id', [ 'AND' => [ 'campaign_id' => $db_campaign_id, 'ad_group_id' => (int) $ad_group_external_id ] ] ); $data['status'] = 'active'; if ( $existing_id > 0 ) { $mdb -> update( 'campaign_ad_groups', $data, [ 'id' => $existing_id ] ); $db_ad_group_id = $existing_id; } else { $data['campaign_id'] = $db_campaign_id; $data['ad_group_id'] = (int) $ad_group_external_id; $mdb -> insert( 'campaign_ad_groups', $data ); $db_ad_group_id = (int) $mdb -> id(); } if ( $db_ad_group_id > 0 ) { $ad_group_db_map[ $key ] = $db_ad_group_id; $seen_db_ids[] = $db_ad_group_id; $count++; } } // Oznacz ad_groups ktore nie pojawiaja sie juz w API jako paused (zachowaj PMax placeholder ad_group_id=0). if ( !empty( $campaign_db_ids ) ) { $pause_where = [ 'campaign_id' => $campaign_db_ids, 'ad_group_id[!]' => 0 ]; if ( !empty( $seen_db_ids ) ) { $pause_where['id[!]'] = $seen_db_ids; } $mdb -> update( 'campaign_ad_groups', [ 'status' => 'paused' ], $pause_where ); } return [ 'count' => $count, 'ad_group_map' => $ad_group_db_map, 'errors' => [] ]; } static private function sync_campaign_search_terms_for_client( $campaigns_db_map, $ad_group_db_map, $customer_id, $api, $date_sync ) { global $mdb; $campaign_db_ids = array_values( array_unique( array_map( 'intval', array_values( $campaigns_db_map ) ) ) ); if ( empty( $campaign_db_ids ) ) { return [ 'count' => 0, 'errors' => [] ]; } $search_terms_30 = $api -> get_search_terms_30_days( $customer_id ); if ( $search_terms_30 === false ) { $last_err = \services\GoogleAdsApi::get_setting( 'google_ads_last_error' ); return [ 'count' => 0, 'errors' => [ 'Blad pobierania fraz wyszukiwanych (30 dni): ' . $last_err ] ]; } $search_terms_all_time = $api -> get_search_terms_all_time( $customer_id ); if ( $search_terms_all_time === false ) { $last_err = \services\GoogleAdsApi::get_setting( 'google_ads_last_error' ); return [ 'count' => 0, 'errors' => [ 'Blad pobierania fraz wyszukiwanych (all time): ' . $last_err ] ]; } if ( !is_array( $search_terms_30 ) ) { $search_terms_30 = []; } if ( !is_array( $search_terms_all_time ) ) { $search_terms_all_time = []; } $map_30 = []; foreach ( $search_terms_30 as $row ) { $campaign_external_id = isset( $row['campaign_id'] ) ? (string) $row['campaign_id'] : ''; $ad_group_external_id = isset( $row['ad_group_id'] ) ? (string) $row['ad_group_id'] : ''; $search_term = trim( (string) ( $row['search_term'] ?? '' ) ); if ( $campaign_external_id === '' || $ad_group_external_id === '' || $search_term === '' ) { continue; } $map_30[ $campaign_external_id . '|' . $ad_group_external_id . '|' . strtolower( $search_term ) ] = $row; } $map_all_time = []; foreach ( $search_terms_all_time as $row ) { $campaign_external_id = isset( $row['campaign_id'] ) ? (string) $row['campaign_id'] : ''; $ad_group_external_id = isset( $row['ad_group_id'] ) ? (string) $row['ad_group_id'] : ''; $search_term = trim( (string) ( $row['search_term'] ?? '' ) ); if ( $campaign_external_id === '' || $ad_group_external_id === '' || $search_term === '' ) { continue; } $map_all_time[ $campaign_external_id . '|' . $ad_group_external_id . '|' . strtolower( $search_term ) ] = $row; } $mdb -> delete( 'campaign_search_terms', [ 'campaign_id' => $campaign_db_ids ] ); $keys = array_values( array_unique( array_merge( array_keys( $map_30 ), array_keys( $map_all_time ) ) ) ); $count = 0; foreach ( $keys as $key ) { $parts = explode( '|', $key, 3 ); $campaign_external_id = $parts[0] ?? ''; $ad_group_external_id = $parts[1] ?? ''; $db_campaign_id = (int) ( $campaigns_db_map[ $campaign_external_id ] ?? 0 ); $db_ad_group_id = (int) ( $ad_group_db_map[ $campaign_external_id . '|' . $ad_group_external_id ] ?? 0 ); if ( $db_campaign_id > 0 && $db_ad_group_id <= 0 && $ad_group_external_id === '0' ) { $db_ad_group_id = self::ensure_campaign_level_ad_group( $db_campaign_id, $date_sync ); if ( $db_ad_group_id > 0 ) { $ad_group_db_map[ $campaign_external_id . '|0' ] = $db_ad_group_id; } } if ( $db_campaign_id <= 0 || $db_ad_group_id <= 0 ) { continue; } $row_30 = $map_30[ $key ] ?? []; $row_all_time = $map_all_time[ $key ] ?? []; $search_term = trim( (string) ( $row_30['search_term'] ?? ( $row_all_time['search_term'] ?? '' ) ) ); if ( $search_term === '' ) { continue; } $clicks_30 = (int) ( $row_30['clicks'] ?? 0 ); $clicks_all_time = (int) ( $row_all_time['clicks'] ?? 0 ); if ( $clicks_30 <= 0 && $clicks_all_time <= 0 ) { continue; } $mdb -> insert( 'campaign_search_terms', [ 'campaign_id' => $db_campaign_id, 'ad_group_id' => $db_ad_group_id, 'search_term' => $search_term, 'impressions_30' => (int) ( $row_30['impressions'] ?? 0 ), 'clicks_30' => $clicks_30, 'cost_30' => (float) ( $row_30['cost'] ?? 0 ), 'conversions_30' => (float) ( $row_30['conversions'] ?? 0 ), 'conversion_value_30' => (float) ( $row_30['conversion_value'] ?? 0 ), 'roas_30' => (float) ( $row_30['roas'] ?? 0 ), 'impressions_all_time' => (int) ( $row_all_time['impressions'] ?? 0 ), 'clicks_all_time' => $clicks_all_time, 'cost_all_time' => (float) ( $row_all_time['cost'] ?? 0 ), 'conversions_all_time' => (float) ( $row_all_time['conversions'] ?? 0 ), 'conversion_value_all_time' => (float) ( $row_all_time['conversion_value'] ?? 0 ), 'roas_all_time' => (float) ( $row_all_time['roas'] ?? 0 ), 'date_sync' => $date_sync ] ); $count++; } return [ 'count' => $count, 'errors' => [] ]; } static private function build_ad_group_db_map_from_db( $campaigns_db_map ) { global $mdb; $ad_group_db_map = []; $campaign_db_ids = array_values( array_unique( array_map( 'intval', array_values( $campaigns_db_map ) ) ) ); if ( empty( $campaign_db_ids ) ) { return $ad_group_db_map; } $ag_rows = $mdb -> query( "SELECT id, campaign_id, ad_group_id FROM campaign_ad_groups WHERE campaign_id IN (" . implode( ',', $campaign_db_ids ) . ")" ) -> fetchAll( \PDO::FETCH_ASSOC ); $reverse_campaign_map = array_flip( $campaigns_db_map ); if ( is_array( $ag_rows ) ) { foreach ( $ag_rows as $ag_row ) { $ext_campaign_id = $reverse_campaign_map[ (int) $ag_row['campaign_id'] ] ?? ''; if ( $ext_campaign_id !== '' ) { $ad_group_db_map[ $ext_campaign_id . '|' . $ag_row['ad_group_id'] ] = (int) $ag_row['id']; } } } return $ad_group_db_map; } static private function sync_campaign_search_terms_daily( $campaigns_db_map, $ad_group_db_map, $customer_id, $api, $date ) { global $mdb; $date = date( 'Y-m-d', strtotime( $date ) ); $campaign_db_ids = array_values( array_unique( array_map( 'intval', array_values( $campaigns_db_map ) ) ) ); if ( empty( $campaign_db_ids ) ) { return [ 'count' => 0, 'errors' => [] ]; } $terms = $api -> get_search_terms_for_date( $customer_id, $date ); if ( $terms === false ) { $last_err = \services\GoogleAdsApi::get_setting( 'google_ads_last_error' ); return [ 'count' => 0, 'errors' => [ 'Blad pobierania fraz wyszukiwanych za ' . $date . ': ' . $last_err ] ]; } if ( !is_array( $terms ) ) { $terms = []; } $count = 0; foreach ( $terms as $row ) { $campaign_external_id = isset( $row['campaign_id'] ) ? (string) $row['campaign_id'] : ''; $ad_group_external_id = isset( $row['ad_group_id'] ) ? (string) $row['ad_group_id'] : ''; $search_term = trim( (string) ( $row['search_term'] ?? '' ) ); if ( $campaign_external_id === '' || $search_term === '' ) { continue; } $clicks = (int) ( $row['clicks'] ?? 0 ); if ( $clicks <= 0 ) { continue; } $db_campaign_id = (int) ( $campaigns_db_map[ $campaign_external_id ] ?? 0 ); $db_ad_group_id = (int) ( $ad_group_db_map[ $campaign_external_id . '|' . $ad_group_external_id ] ?? 0 ); if ( $db_campaign_id > 0 && $db_ad_group_id <= 0 && $ad_group_external_id === '0' ) { $db_ad_group_id = self::ensure_campaign_level_ad_group( $db_campaign_id, $date ); if ( $db_ad_group_id > 0 ) { $ad_group_db_map[ $campaign_external_id . '|0' ] = $db_ad_group_id; } } if ( $db_campaign_id <= 0 || $db_ad_group_id <= 0 ) { continue; } $data = [ 'impressions' => (int) ( $row['impressions'] ?? 0 ), 'clicks' => $clicks, 'cost' => (float) ( $row['cost'] ?? 0 ), 'conversions' => (float) ( $row['conversions'] ?? 0 ), 'conversion_value' => (float) ( $row['conversion_value'] ?? 0 ), ]; $existing_id = (int) $mdb -> get( 'campaign_search_terms_history', 'id', [ 'AND' => [ 'campaign_id' => $db_campaign_id, 'ad_group_id' => $db_ad_group_id, 'search_term' => $search_term, 'date_add' => $date ] ] ); if ( $existing_id > 0 ) { $mdb -> update( 'campaign_search_terms_history', $data, [ 'id' => $existing_id ] ); } else { $data['campaign_id'] = $db_campaign_id; $data['ad_group_id'] = $db_ad_group_id; $data['search_term'] = $search_term; $data['date_add'] = $date; $mdb -> insert( 'campaign_search_terms_history', $data ); } $count++; } return [ 'count' => $count, 'errors' => [] ]; } static private function aggregate_campaign_search_terms_for_client( $client_id, $date_sync ) { global $mdb; $client_id = (int) $client_id; $date_sync = date( 'Y-m-d', strtotime( $date_sync ) ); $date_30_ago = date( 'Y-m-d', strtotime( $date_sync . ' -30 days' ) ); $campaign_db_ids = $mdb -> select( 'campaigns', 'id', [ 'client_id' => $client_id ] ); if ( empty( $campaign_db_ids ) ) { return 0; } $ids_list = implode( ',', array_map( 'intval', $campaign_db_ids ) ); $rows_30 = $mdb -> query( "SELECT campaign_id, ad_group_id, search_term, SUM(impressions) AS impressions, SUM(clicks) AS clicks, SUM(cost) AS cost, SUM(conversions) AS conversions, SUM(conversion_value) AS conversion_value FROM campaign_search_terms_history WHERE campaign_id IN ({$ids_list}) AND date_add > :date_30_ago AND date_add <= :date_sync GROUP BY campaign_id, ad_group_id, search_term HAVING SUM(clicks) > 0", [ ':date_30_ago' => $date_30_ago, ':date_sync' => $date_sync ] ) -> fetchAll( \PDO::FETCH_ASSOC ); $rows_all_time = $mdb -> query( "SELECT campaign_id, ad_group_id, search_term, SUM(impressions) AS impressions, SUM(clicks) AS clicks, SUM(cost) AS cost, SUM(conversions) AS conversions, SUM(conversion_value) AS conversion_value FROM campaign_search_terms_history WHERE campaign_id IN ({$ids_list}) GROUP BY campaign_id, ad_group_id, search_term HAVING SUM(clicks) > 0", [] ) -> fetchAll( \PDO::FETCH_ASSOC ); $map_30 = []; if ( is_array( $rows_30 ) ) { foreach ( $rows_30 as $r ) { $key = $r['campaign_id'] . '|' . $r['ad_group_id'] . '|' . strtolower( $r['search_term'] ); $map_30[ $key ] = $r; } } $map_all_time = []; if ( is_array( $rows_all_time ) ) { foreach ( $rows_all_time as $r ) { $key = $r['campaign_id'] . '|' . $r['ad_group_id'] . '|' . strtolower( $r['search_term'] ); $map_all_time[ $key ] = $r; } } $mdb -> delete( 'campaign_search_terms', [ 'campaign_id' => array_map( 'intval', $campaign_db_ids ) ] ); $keys = array_values( array_unique( array_merge( array_keys( $map_30 ), array_keys( $map_all_time ) ) ) ); $count = 0; foreach ( $keys as $key ) { $r30 = $map_30[ $key ] ?? null; $rall = $map_all_time[ $key ] ?? null; $ref = $r30 ?? $rall; $cost_30 = (float) ( $r30['cost'] ?? 0 ); $cv_30 = (float) ( $r30['conversion_value'] ?? 0 ); $roas_30 = ( $cost_30 > 0 ) ? round( ( $cv_30 / $cost_30 ) * 100, 2 ) : 0; $cost_all = (float) ( $rall['cost'] ?? 0 ); $cv_all = (float) ( $rall['conversion_value'] ?? 0 ); $roas_all = ( $cost_all > 0 ) ? round( ( $cv_all / $cost_all ) * 100, 2 ) : 0; $mdb -> insert( 'campaign_search_terms', [ 'campaign_id' => (int) $ref['campaign_id'], 'ad_group_id' => (int) $ref['ad_group_id'], 'search_term' => $ref['search_term'], 'impressions_30' => (int) ( $r30['impressions'] ?? 0 ), 'clicks_30' => (int) ( $r30['clicks'] ?? 0 ), 'cost_30' => $cost_30, 'conversions_30' => (float) ( $r30['conversions'] ?? 0 ), 'conversion_value_30' => $cv_30, 'roas_30' => $roas_30, 'impressions_all_time' => (int) ( $rall['impressions'] ?? 0 ), 'clicks_all_time' => (int) ( $rall['clicks'] ?? 0 ), 'cost_all_time' => $cost_all, 'conversions_all_time' => (float) ( $rall['conversions'] ?? 0 ), 'conversion_value_all_time' => $cv_all, 'roas_all_time' => $roas_all, 'date_sync' => $date_sync ] ); $count++; } return $count; } static private function ensure_campaign_level_ad_group( $db_campaign_id, $date_sync ) { global $mdb; $db_campaign_id = (int) $db_campaign_id; if ( $db_campaign_id <= 0 ) { return 0; } $existing_id = (int) $mdb -> get( 'campaign_ad_groups', 'id', [ 'AND' => [ 'campaign_id' => $db_campaign_id, 'ad_group_id' => 0 ] ] ); if ( $existing_id > 0 ) { return $existing_id; } $mdb -> insert( 'campaign_ad_groups', [ 'campaign_id' => $db_campaign_id, 'ad_group_id' => 0, 'ad_group_name' => 'PMax (bez grup reklam)', 'impressions_30' => 0, 'clicks_30' => 0, 'cost_30' => 0, 'conversions_30' => 0, 'conversion_value_30' => 0, 'roas_30' => 0, 'impressions_all_time' => 0, 'clicks_all_time' => 0, 'cost_all_time' => 0, 'conversions_all_time' => 0, 'conversion_value_all_time' => 0, 'roas_all_time' => 0, 'date_sync' => $date_sync ] ); return (int) $mdb -> id(); } static private function sync_campaign_keywords_for_client( $campaigns_db_map, $ad_group_db_map, $customer_id, $api, $date_sync ) { global $mdb; $campaign_db_ids = array_values( array_unique( array_map( 'intval', array_values( $campaigns_db_map ) ) ) ); if ( empty( $campaign_db_ids ) ) { return [ 'count' => 0, 'errors' => [] ]; } $keywords_30 = $api -> get_ad_keywords_30_days( $customer_id ); if ( $keywords_30 === false ) { $last_err = \services\GoogleAdsApi::get_setting( 'google_ads_last_error' ); return [ 'count' => 0, 'errors' => [ 'Blad pobierania slow kluczowych (30 dni): ' . $last_err ] ]; } $keywords_all_time = $api -> get_ad_keywords_all_time( $customer_id ); if ( $keywords_all_time === false ) { $last_err = \services\GoogleAdsApi::get_setting( 'google_ads_last_error' ); return [ 'count' => 0, 'errors' => [ 'Blad pobierania slow kluczowych (all time): ' . $last_err ] ]; } if ( !is_array( $keywords_30 ) ) { $keywords_30 = []; } if ( !is_array( $keywords_all_time ) ) { $keywords_all_time = []; } $map_30 = []; foreach ( $keywords_30 as $row ) { $campaign_external_id = isset( $row['campaign_id'] ) ? (string) $row['campaign_id'] : ''; $ad_group_external_id = isset( $row['ad_group_id'] ) ? (string) $row['ad_group_id'] : ''; $keyword_text = trim( (string) ( $row['keyword_text'] ?? '' ) ); $match_type = trim( (string) ( $row['match_type'] ?? '' ) ); if ( $campaign_external_id === '' || $ad_group_external_id === '' || $keyword_text === '' ) { continue; } $map_30[ $campaign_external_id . '|' . $ad_group_external_id . '|' . strtolower( $keyword_text ) . '|' . strtolower( $match_type ) ] = $row; } $map_all_time = []; foreach ( $keywords_all_time as $row ) { $campaign_external_id = isset( $row['campaign_id'] ) ? (string) $row['campaign_id'] : ''; $ad_group_external_id = isset( $row['ad_group_id'] ) ? (string) $row['ad_group_id'] : ''; $keyword_text = trim( (string) ( $row['keyword_text'] ?? '' ) ); $match_type = trim( (string) ( $row['match_type'] ?? '' ) ); if ( $campaign_external_id === '' || $ad_group_external_id === '' || $keyword_text === '' ) { continue; } $map_all_time[ $campaign_external_id . '|' . $ad_group_external_id . '|' . strtolower( $keyword_text ) . '|' . strtolower( $match_type ) ] = $row; } $mdb -> delete( 'campaign_keywords', [ 'campaign_id' => $campaign_db_ids ] ); $keys = array_values( array_unique( array_merge( array_keys( $map_30 ), array_keys( $map_all_time ) ) ) ); $count = 0; foreach ( $keys as $key ) { $parts = explode( '|', $key, 4 ); $campaign_external_id = $parts[0] ?? ''; $ad_group_external_id = $parts[1] ?? ''; $db_campaign_id = (int) ( $campaigns_db_map[ $campaign_external_id ] ?? 0 ); $db_ad_group_id = (int) ( $ad_group_db_map[ $campaign_external_id . '|' . $ad_group_external_id ] ?? 0 ); if ( $db_campaign_id <= 0 || $db_ad_group_id <= 0 ) { continue; } $row_30 = $map_30[ $key ] ?? []; $row_all_time = $map_all_time[ $key ] ?? []; $keyword_text = trim( (string) ( $row_30['keyword_text'] ?? ( $row_all_time['keyword_text'] ?? '' ) ) ); if ( $keyword_text === '' ) { continue; } $match_type = trim( (string) ( $row_30['match_type'] ?? ( $row_all_time['match_type'] ?? '' ) ) ); $status = trim( (string) ( $row_all_time['status'] ?? ( $row_30['status'] ?? 'ENABLED' ) ) ); $clicks_30 = (int) ( $row_30['clicks'] ?? 0 ); $clicks_all_time = (int) ( $row_all_time['clicks'] ?? 0 ); if ( $clicks_30 <= 0 && $clicks_all_time <= 0 ) { continue; } $mdb -> insert( 'campaign_keywords', [ 'campaign_id' => $db_campaign_id, 'ad_group_id' => $db_ad_group_id, 'keyword_text' => $keyword_text, 'match_type' => $match_type, 'status' => $status, 'impressions_30' => (int) ( $row_30['impressions'] ?? 0 ), 'clicks_30' => $clicks_30, 'cost_30' => (float) ( $row_30['cost'] ?? 0 ), 'conversions_30' => (float) ( $row_30['conversions'] ?? 0 ), 'conversion_value_30' => (float) ( $row_30['conversion_value'] ?? 0 ), 'roas_30' => (float) ( $row_30['roas'] ?? 0 ), 'impressions_all_time' => (int) ( $row_all_time['impressions'] ?? 0 ), 'clicks_all_time' => $clicks_all_time, 'cost_all_time' => (float) ( $row_all_time['cost'] ?? 0 ), 'conversions_all_time' => (float) ( $row_all_time['conversions'] ?? 0 ), 'conversion_value_all_time' => (float) ( $row_all_time['conversion_value'] ?? 0 ), 'roas_all_time' => (float) ( $row_all_time['roas'] ?? 0 ), 'date_sync' => $date_sync ] ); $count++; } return [ 'count' => $count, 'errors' => [] ]; } static private function sync_campaign_negative_keywords_for_client( $campaigns_db_map, $ad_group_db_map, $customer_id, $api, $date_sync ) { global $mdb; $campaign_db_ids = array_values( array_unique( array_map( 'intval', array_values( $campaigns_db_map ) ) ) ); if ( empty( $campaign_db_ids ) ) { return [ 'count' => 0, 'errors' => [] ]; } $negatives = $api -> get_negative_keywords( $customer_id ); if ( $negatives === false ) { $last_err = \services\GoogleAdsApi::get_setting( 'google_ads_last_error' ); return [ 'count' => 0, 'errors' => [ 'Blad pobierania fraz wykluczajacych: ' . $last_err ] ]; } if ( !is_array( $negatives ) ) { $negatives = []; } $mdb -> delete( 'campaign_negative_keywords', [ 'campaign_id' => $campaign_db_ids ] ); $count = 0; $seen = []; foreach ( $negatives as $row ) { $campaign_external_id = isset( $row['campaign_id'] ) ? (string) $row['campaign_id'] : ''; $db_campaign_id = (int) ( $campaigns_db_map[ $campaign_external_id ] ?? 0 ); if ( $db_campaign_id <= 0 ) { continue; } $scope = ( $row['scope'] ?? '' ) === 'ad_group' ? 'ad_group' : 'campaign'; $db_ad_group_id = null; if ( $scope === 'ad_group' ) { $ad_group_external_id = isset( $row['ad_group_id'] ) ? (string) $row['ad_group_id'] : ''; $mapped_ad_group_id = (int) ( $ad_group_db_map[ $campaign_external_id . '|' . $ad_group_external_id ] ?? 0 ); if ( $mapped_ad_group_id <= 0 ) { continue; } $db_ad_group_id = $mapped_ad_group_id; } $keyword_text = trim( (string) ( $row['keyword_text'] ?? '' ) ); if ( $keyword_text === '' ) { continue; } $match_type = trim( (string) ( $row['match_type'] ?? '' ) ); $uniq_key = $db_campaign_id . '|' . (int) $db_ad_group_id . '|' . $scope . '|' . strtolower( $keyword_text ) . '|' . strtolower( $match_type ); if ( isset( $seen[ $uniq_key ] ) ) { continue; } $seen[ $uniq_key ] = true; $mdb -> insert( 'campaign_negative_keywords', [ 'campaign_id' => $db_campaign_id, 'ad_group_id' => $db_ad_group_id, 'scope' => $scope, 'keyword_text' => $keyword_text, 'match_type' => $match_type, 'date_sync' => $date_sync ] ); $count++; } return [ 'count' => $count, 'errors' => [] ]; } static private function pick_clients_batch_by_cursor( $clients, $limit, $cursor_client_id = 0 ) { $clients = is_array( $clients ) ? array_values( $clients ) : []; if ( empty( $clients ) ) { return []; } $limit = max( 1, (int) $limit ); $total = count( $clients ); if ( $limit >= $total ) { return $clients; } $start_index = 0; $cursor_client_id = (int) $cursor_client_id; if ( $cursor_client_id > 0 ) { $found_next = false; foreach ( $clients as $idx => $client ) { $current_id = (int) ( $client['id'] ?? 0 ); if ( $current_id > $cursor_client_id ) { $start_index = $idx; $found_next = true; break; } } if ( !$found_next ) { $start_index = 0; } } $batch = []; for ( $i = 0; $i < $limit; $i++ ) { $batch[] = $clients[( $start_index + $i ) % $total]; } return $batch; } // =========================== // CRON SYNC STATUS HELPERS // =========================== static private function ensure_sync_rows( $pipeline, $sync_dates, $client_ids ) { global $mdb; if ( empty( $client_ids ) || empty( $sync_dates ) ) { return; } $stmt = $mdb -> pdo -> prepare( "INSERT INTO cron_sync_status (client_id, pipeline, sync_date, phase) VALUES (:client_id, :pipeline, :sync_date, 'pending') ON DUPLICATE KEY UPDATE phase = IF(phase = 'done' AND completed_at < CURDATE(), 'pending', phase), started_at = IF(phase = 'done' AND completed_at < CURDATE(), NULL, started_at), completed_at = IF(phase = 'done' AND completed_at < CURDATE(), NULL, completed_at), error_message = IF(phase = 'done' AND completed_at < CURDATE(), NULL, error_message)" ); foreach ( $sync_dates as $date ) { foreach ( $client_ids as $cid ) { $stmt -> execute( [ ':client_id' => (int) $cid, ':pipeline' => $pipeline, ':sync_date' => $date ] ); } } } static private function get_active_sync_date( $pipeline ) { global $mdb; $row = $mdb -> query( "SELECT sync_date FROM cron_sync_status WHERE pipeline = :pipeline AND phase != 'done' AND sync_date >= DATE_SUB(CURDATE(), INTERVAL 90 DAY) ORDER BY sync_date ASC LIMIT 1", [ ':pipeline' => $pipeline ] ) -> fetch( \PDO::FETCH_ASSOC ); return $row ? $row['sync_date'] : null; } static private function get_pending_clients( $pipeline, $sync_date, $source_phase, $limit ) { global $mdb; $rows = $mdb -> query( "SELECT cs.client_id FROM cron_sync_status cs INNER JOIN clients c ON cs.client_id = c.id AND c.deleted = 0 WHERE cs.pipeline = :pipeline AND cs.sync_date = :sync_date AND cs.phase = :phase ORDER BY cs.client_id ASC LIMIT " . (int) $limit, [ ':pipeline' => $pipeline, ':sync_date' => $sync_date, ':phase' => $source_phase ] ) -> fetchAll( \PDO::FETCH_COLUMN ); return is_array( $rows ) ? array_map( 'intval', $rows ) : []; } static private function mark_sync_phase( $pipeline, $sync_date, $client_id, $new_phase, $error_msg = null ) { global $mdb; $update_data = [ 'phase' => $new_phase ]; if ( $new_phase === 'done' ) { $update_data['completed_at'] = date( 'Y-m-d H:i:s' ); } else if ( $new_phase !== 'pending' ) { $update_data['started_at'] = date( 'Y-m-d H:i:s' ); } if ( $error_msg !== null ) { $update_data['error_message'] = $error_msg; } $mdb -> update( 'cron_sync_status', $update_data, [ 'AND' => [ 'client_id' => (int) $client_id, 'pipeline' => $pipeline, 'sync_date' => $sync_date ] ] ); } static private function determine_products_phase( $sync_date ) { global $mdb; $phases_order = [ 'pending', 'fetch', 'aggregate_30' ]; foreach ( $phases_order as $phase ) { $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.sync_date = :sync_date AND cs.phase = :phase", [ ':sync_date' => $sync_date, ':phase' => $phase ] ) -> fetchColumn(); if ( $count > 0 ) { return $phase; } } return null; } static private function get_active_client( $pipeline ) { global $mdb; $clients_not_deleted_sql_c = self::sql_clients_not_deleted( 'c' ); $row = $mdb -> query( "SELECT DISTINCT cs.client_id FROM cron_sync_status cs INNER JOIN clients c ON cs.client_id = c.id AND " . $clients_not_deleted_sql_c . " WHERE cs.pipeline = :pipeline AND cs.phase != 'done' AND cs.sync_date >= DATE_SUB(CURDATE(), INTERVAL 90 DAY) ORDER BY cs.client_id ASC LIMIT 1", [ ':pipeline' => $pipeline ] ) -> fetch( \PDO::FETCH_ASSOC ); return $row ? (int) $row['client_id'] : null; } static private function get_pending_dates_for_client( $pipeline, $client_id, $phase, $limit ) { global $mdb; $rows = $mdb -> query( "SELECT cs.sync_date FROM cron_sync_status cs WHERE cs.pipeline = :pipeline AND cs.client_id = :client_id AND cs.phase = :phase AND cs.sync_date >= DATE_SUB(CURDATE(), INTERVAL 90 DAY) ORDER BY cs.sync_date ASC LIMIT " . (int) $limit, [ ':pipeline' => $pipeline, ':client_id' => (int) $client_id, ':phase' => $phase ] ) -> fetchAll( \PDO::FETCH_COLUMN ); return is_array( $rows ) ? $rows : []; } static private function determine_client_products_phase( $client_id ) { global $mdb; $phases_order = [ 'pending', 'fetch', 'aggregate_30' ]; foreach ( $phases_order as $phase ) { $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 = :phase AND cs.sync_date >= DATE_SUB(CURDATE(), INTERVAL 90 DAY)", [ ':client_id' => (int) $client_id, ':phase' => $phase ] ) -> fetchColumn(); if ( $count > 0 ) { return $phase; } } return null; } static public function cron_facebook_ads() { self::$current_cron_action = __FUNCTION__; 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_db = trim( (string) \services\FacebookAdsApi::get_setting( 'facebook_ads_token' ) ); $token = $token_db !== '' ? $token_db : 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 tokenu Facebook Ads. Skonfiguruj go w Ustawieniach.', 'success' => false ]; } $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 ]; } // Blokada ponownego pobrania w tym samym dniu (chyba ze wymuszone lub konkretny klient) $is_incremental_sync = false; 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 ) { // Sprawdz czy pojawili sie nowi klienci nieobecni w ostatniej synchronizacji $last_ids_str = (string) \services\FacebookAdsApi::get_setting( 'cron_facebook_ads_last_synced_client_ids' ); $last_ids = array_filter( array_map( 'intval', explode( ',', $last_ids_str ) ) ); $current_ids = array_map( function ( $c ) { return (int) ( $c['id'] ?? 0 ); }, $clients ); $new_ids = array_values( array_diff( $current_ids, $last_ids ) ); if ( empty( $new_ids ) ) { 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 ]; } // Sa nowi klienci - ogranicz synchronizacje tylko do nich $clients = array_values( array_filter( $clients, function ( $c ) use ( $new_ids ) { return in_array( (int) ( $c['id'] ?? 0 ), $new_ids ); } ) ); $is_incremental_sync = true; } } $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' ) ); } if ( $is_incremental_sync ) { $prev_ids_str = (string) \services\FacebookAdsApi::get_setting( 'cron_facebook_ads_last_synced_client_ids' ); $prev_ids = array_filter( array_map( 'intval', explode( ',', $prev_ids_str ) ) ); $synced_ids = array_map( function ( $item ) { return (int) ( $item['client_id'] ?? 0 ); }, $items ); $all_ids = array_values( array_unique( array_merge( $prev_ids, $synced_ids ) ) ); \services\FacebookAdsApi::set_setting( 'cron_facebook_ads_last_synced_client_ids', implode( ',', $all_ids ) ); } else { $synced_ids = array_map( function ( $item ) { return (int) ( $item['client_id'] ?? 0 ); }, $items ); \services\FacebookAdsApi::set_setting( 'cron_facebook_ads_last_synced_client_ids', implode( ',', $synced_ids ) ); } \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; $clients_deleted_sql_c = self::sql_clients_deleted( 'c' ); $mdb -> query( "DELETE FROM cron_sync_status WHERE phase = 'done' AND completed_at < DATE_SUB(NOW(), INTERVAL :days DAY)", [ ':days' => (int) $days ] ); $mdb -> query( "DELETE cs FROM cron_sync_status cs LEFT JOIN clients c ON cs.client_id = c.id WHERE c.id IS NULL OR " . $clients_deleted_sql_c ); } static private function get_conversion_window_days( $prefer_config = false ) { global $settings; $request_value = (int) \S::get( 'conversion_window_days' ); if ( $request_value > 0 ) { return min( 90, $request_value ); } if ( $prefer_config ) { $config_value = (int) ( $settings['google_ads_conversion_window_days'] ?? 0 ); if ( $config_value > 0 ) { return min( 90, $config_value ); } } $setting_value = (int) self::get_setting_value( 'google_ads_conversion_window_days', 7 ); if ( $setting_value <= 0 ) { $config_value = (int) ( $settings['google_ads_conversion_window_days'] ?? 7 ); if ( $config_value <= 0 ) { $config_value = 7; } return min( 90, $config_value ); } 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; $pipeline = trim( (string) $pipeline ); if ( $pipeline === '' ) { return; } $sync_dates = array_values( array_filter( array_map( function( $item ) { $date = date( 'Y-m-d', strtotime( (string) $item ) ); return $date ?: null; }, (array) $sync_dates ) ) ); if ( empty( $sync_dates ) ) { return; } $quoted_dates = array_map( function( $d ) use ( $mdb ) { return $mdb -> pdo -> quote( $d ); }, $sync_dates ); $mdb -> query( "DELETE FROM cron_sync_status WHERE pipeline = :pipeline AND sync_date NOT IN (" . implode( ',', $quoted_dates ) . ")", [ ':pipeline' => $pipeline ] ); } static private function build_backfill_dates( $end_date, $window_days ) { $end_timestamp = strtotime( $end_date ); if ( !$end_timestamp ) { $end_timestamp = strtotime( date( 'Y-m-d' ) ); } $window_days = max( 1, min( 90, (int) $window_days ) ); $dates = []; for ( $i = $window_days - 1; $i >= 0; $i-- ) { $dates[] = date( 'Y-m-d', strtotime( '-' . $i . ' days', $end_timestamp ) ); } return $dates; } static private function is_debug_requested() { $raw = \S::get( 'debug' ); if ( is_bool( $raw ) ) { return $raw; } $value = strtolower( trim( (string) $raw ) ); return in_array( $value, [ '1', 'true', 'yes', 'on' ], true ); } static private function output_cron_response( $payload ) { $payload = is_array( $payload ) ? $payload : [ 'result' => (string) $payload ]; // --- Logowanie do tabeli logs --- try { $has_errors = !empty( $payload['errors'] ); $log_level = $has_errors ? 'error' : 'info'; $log_source = self::$current_cron_action ?? 'cron'; $log_client_id = $payload['active_client_id'] ?? $payload['client_id'] ?? null; $log_message = (string) ( $payload['result'] ?? 'Brak komunikatu' ); \factory\Logs::add( $log_level, $log_source, $log_message, $payload, $log_client_id ); \factory\Logs::cleanup_old( 30 ); } catch ( \Throwable $e ) { $payload['_log_error'] = $e -> getMessage(); } if ( self::is_debug_requested() ) { header( 'Content-Type: text/html; charset=utf-8' ); $pretty = json_encode( $payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT ); if ( $pretty === false ) { $pretty = '{}'; } $result_text = htmlspecialchars( (string) ( $payload['result'] ?? 'Brak komunikatu' ), ENT_QUOTES, 'UTF-8' ); $active_client_id = isset( $payload['active_client_id'] ) ? (string) $payload['active_client_id'] : ( isset( $payload['client_id'] ) ? (string) $payload['client_id'] : '-' ); $active_date = htmlspecialchars( (string) ( $payload['active_date'] ?? ( $payload['date'] ?? '-' ) ), ENT_QUOTES, 'UTF-8' ); $errors_count = is_array( $payload['errors'] ?? null ) ? count( $payload['errors'] ) : 0; echo '
' . htmlspecialchars( $pretty, ENT_QUOTES, 'UTF-8' ) . '