Files
adsPRO/autoload/controls/class.Cron.php
Jacek Pyziak 167ced3573 feat: Enhance user settings with cron URL plan display
- Added a new field to display the cron URL plan in user settings.
- Updated JavaScript to handle the new plan data.

refactor: Unify product model and migrate data

- Migrated product data from `products_data` to `products` table.
- Added new columns to `products` for better data organization.
- Created `products_aggregate` table for storing aggregated product metrics.

chore: Drop deprecated products_data table

- Removed `products_data` table as data is now stored in `products`.

feat: Add merchant URL flags to products

- Introduced flags for tracking merchant URL status in `products` table.
- Normalized product URLs to handle empty or invalid values.

feat: Link campaign alerts to specific products

- Added `product_id` column to `campaign_alerts` table for better tracking.
- Created an index for efficient querying of alerts by product.

chore: Add debug scripts for client data inspection

- Created debug scripts to inspect client data from local and remote databases.
- Included error handling and output formatting for better readability.
2026-02-20 17:50:14 +01:00

6660 lines
230 KiB
PHP

<?php
namespace controls;
class Cron
{
// Uniwersalny CRON pipeline.
// Jedno wywolanie = jeden klient + jeden dzien: kampanie -> produkty.
static public function cron_universal()
{
global $mdb;
self::touch_cron_invocation( __FUNCTION__ );
$clients_not_deleted_sql = self::sql_clients_not_deleted();
$clients_not_deleted_sql_c = self::sql_clients_not_deleted( 'c' );
$clients_deleted_sql_c = self::sql_clients_deleted( 'c' );
$api = new \services\GoogleAdsApi();
if ( !$api -> is_configured() )
{
self::output_cron_response( [ 'result' => 'Google Ads API nie jest skonfigurowane. Uzupelnij dane w Ustawieniach.' ] );
}
$conversion_window_days = self::get_conversion_window_days( true );
$default_end_date = date( 'Y-m-d', strtotime( '-2 days' ) );
$requested_date_raw = trim( (string) \S::get( 'date' ) );
$requested_ts = $requested_date_raw !== '' ? strtotime( $requested_date_raw ) : false;
$sync_date_raw = $requested_ts ? date( 'Y-m-d', $requested_ts ) : $default_end_date;
$sync_date = ( strtotime( $sync_date_raw ) > strtotime( $default_end_date ) ) ? $default_end_date : $sync_date_raw;
if ( !$sync_date || strtotime( $sync_date ) === false )
{
$sync_date = $default_end_date;
}
$sync_dates = self::build_backfill_dates( $sync_date, $conversion_window_days );
$client_id = (int) \S::get( 'client_id' );
if ( $client_id > 0 )
{
$client = $mdb -> query(
"SELECT *
FROM clients
WHERE id = :id
AND COALESCE(active, 0) = 1
AND " . $clients_not_deleted_sql . "
LIMIT 1",
[ ':id' => $client_id ]
) -> fetch( \PDO::FETCH_ASSOC );
if ( !$client || trim( (string) ( $client['google_ads_customer_id'] ?? '' ) ) === '' )
{
self::output_cron_response( [ 'result' => 'Nie znaleziono aktywnego klienta z poprawnym Google Ads Customer ID.', 'client_id' => $client_id ] );
}
$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_date ]
);
$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', $sync_dates, $client_ids );
self::ensure_sync_rows( 'products', $sync_dates, $client_ids );
self::cleanup_pipeline_rows_outside_window( 'campaigns', $sync_dates );
self::cleanup_pipeline_rows_outside_window( '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')
)
)
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
] );
}
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,
[ $active_date ]
);
$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'] );
}
}
$pending_dates_before_finalize = self::count_pending_campaign_dates_for_client( $active_client_id );
$is_last_pending_chunk_for_client = $pending_dates_before_finalize <= 1;
if ( empty( $campaign_errors ) && $is_last_pending_chunk_for_client )
{
$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()
{
global $mdb, $settings;
self::touch_cron_invocation( __FUNCTION__ );
$api = new \services\GoogleAdsApi();
if ( !$api -> is_configured() )
{
echo json_encode( [ 'result' => 'Google Ads API nie jest skonfigurowane. Uzupelnij dane w Ustawieniach.' ] );
exit;
}
$date = \S::get( 'date' ) ? date( 'Y-m-d', strtotime( \S::get( 'date' ) ) ) : date( 'Y-m-d', strtotime( '-1 days' ) );
$conversion_window_days = self::get_conversion_window_days();
$import_dates = self::build_backfill_dates( $date, $conversion_window_days );
$client_id = (int) \S::get( 'client_id' );
if ( $client_id > 0 )
{
$client = $mdb -> get( 'clients', '*', [ 'AND' => [
'id' => $client_id,
'google_ads_customer_id[!]' => null,
'deleted' => 0
] ] );
if ( !$client || trim( (string) $client['google_ads_customer_id'] ) === '' )
{
echo json_encode( [ 'result' => 'Nie znaleziono klienta z poprawnym Google Ads Customer ID.', 'client_id' => $client_id ] );
exit;
}
$sync = self::sync_products_fetch_for_client( $client, $api, $date );
$history_30 = self::aggregate_products_history_30_for_client( (int) $client['id'], $date );
$temp_rows = self::rebuild_products_temp_for_client( (int) $client['id'] );
echo json_encode( [
'result' => empty( $sync['errors'] ) ? 'Synchronizacja produktow zakonczona.' : 'Synchronizacja produktow zakonczona z bledami.',
'client_id' => (int) $client['id'],
'date' => $date,
'active_date' => $date,
'phase' => 'single_full',
'processed_products' => (int) $sync['processed_products'],
'skipped' => (int) $sync['skipped'],
'fetch_skipped_reason' => isset( $sync['fetch_skipped_reason'] ) ? (string) $sync['fetch_skipped_reason'] : '',
'history_30_products' => (int) $history_30,
'products_temp_rows' => (int) $temp_rows,
'errors' => $sync['errors']
] );
exit;
}
self::cleanup_old_sync_rows( 30 );
$client_ids = $mdb -> query( "SELECT c.id
FROM clients c
WHERE c.deleted = 0
AND c.google_ads_customer_id IS NOT NULL
AND c.google_ads_customer_id <> ''
AND (
NOT EXISTS ( SELECT 1 FROM campaigns cp WHERE cp.client_id = c.id )
OR EXISTS (
SELECT 1
FROM campaigns cp
WHERE cp.client_id = c.id
AND cp.campaign_id > 0
AND ( cp.campaign_name IS NULL OR cp.campaign_name <> '--- konto ---' )
)
)
ORDER BY c.id ASC" ) -> fetchAll( \PDO::FETCH_COLUMN );
$client_ids = array_values( array_unique( array_map( 'intval', $client_ids ) ) );
if ( empty( $client_ids ) )
{
echo json_encode( [ 'result' => 'Brak klientow z ustawionym Google Ads Customer ID do przetworzenia.', 'processed_clients' => 0, 'errors' => [] ] );
exit;
}
self::ensure_sync_rows( 'products', $import_dates, $client_ids );
$active_client_id = self::get_active_client( 'products' );
if ( !$active_client_id )
{
echo json_encode( [
'result' => 'Pipeline cron_products jest zakonczony dla calego okna dat.',
'date' => $date,
'conversion_window_days' => $conversion_window_days,
'dates_synced' => $import_dates,
'phase' => 'done',
'total_clients' => count( $client_ids )
] );
exit;
}
$source_phase = self::determine_client_products_phase( $active_client_id );
if ( !$source_phase )
{
echo json_encode( [
'result' => 'Brak dat do przetworzenia dla klienta ID: ' . $active_client_id,
'date' => $date,
'active_client_id' => $active_client_id,
'phase' => 'done',
'total_clients' => count( $client_ids )
] );
exit;
}
$phase_next_map = [
'pending' => 'fetch',
'fetch' => 'aggregate_30',
'aggregate_30' => 'done'
];
$next_phase = $phase_next_map[ $source_phase ] ?? 'done';
$dates_per_run_default = (int) ( $settings['cron_products_clients_per_run'] ?? 10 );
if ( $dates_per_run_default <= 0 )
{
$dates_per_run_default = 10;
}
$dates_per_run = (int) \S::get( 'clients_per_run' );
if ( $dates_per_run <= 0 )
{
$dates_per_run = $dates_per_run_default;
}
$dates_per_run = min( 100, $dates_per_run );
$dates_batch = self::get_pending_dates_for_client( 'products', $active_client_id, $source_phase, $dates_per_run );
if ( empty( $dates_batch ) )
{
echo json_encode( [
'result' => 'Faza zakonczona dla klienta. Kolejne wywolanie przejdzie do nastepnej fazy lub klienta.',
'date' => $date,
'active_client_id' => $active_client_id,
'conversion_window_days' => $conversion_window_days,
'phase' => $source_phase,
'total_clients' => count( $client_ids )
] );
exit;
}
$selected_client = $mdb -> get( 'clients', '*', [ 'id' => $active_client_id ] );
if ( !$selected_client )
{
echo json_encode( [
'result' => 'Nie udalo sie znalezc klienta do synchronizacji produktow. ID: ' . $active_client_id,
'active_client_id' => $active_client_id,
'errors' => [ 'Klient ID ' . $active_client_id . ' nie znaleziony.' ]
] );
exit;
}
$dates_processed_in_call = [];
$errors = [];
$processed_products_total = 0;
$skipped_total = 0;
$fetch_skipped_reasons = [];
$history_30_products_total = 0;
$products_temp_rows_total = 0;
if ( $source_phase === 'aggregate_30' )
{
$products_temp_rows_total += (int) self::rebuild_products_temp_for_client( $active_client_id );
foreach ( $dates_batch as $active_date )
{
self::mark_sync_phase( 'products', $active_date, $active_client_id, 'done' );
$dates_processed_in_call[] = $active_date;
}
}
else
{
foreach ( $dates_batch as $active_date )
{
$error_msg = null;
if ( $source_phase === 'pending' )
{
$sync = self::sync_products_fetch_for_client( $selected_client, $api, $active_date );
$processed_products_total += (int) ( $sync['processed_products'] ?? 0 );
$skipped_total += (int) ( $sync['skipped'] ?? 0 );
$fetch_skipped_reason = trim( (string) ( $sync['fetch_skipped_reason'] ?? '' ) );
if ( $fetch_skipped_reason !== '' )
{
$fetch_skipped_reasons[ $fetch_skipped_reason ] = true;
}
if ( !empty( $sync['errors'] ) )
{
$errors = array_merge( $errors, (array) $sync['errors'] );
$error_msg = implode( '; ', (array) $sync['errors'] );
}
}
else if ( $source_phase === 'fetch' )
{
$history_30_products_total += (int) self::aggregate_products_history_30_for_client( $active_client_id, $active_date );
}
self::mark_sync_phase( 'products', $active_date, $active_client_id, $next_phase, $error_msg );
$dates_processed_in_call[] = $active_date;
}
}
$done_count = (int) $mdb -> query(
"SELECT COUNT(*) FROM cron_sync_status cs
INNER JOIN clients c ON cs.client_id = c.id AND c.deleted = 0
WHERE cs.pipeline = 'products' AND cs.client_id = :client_id AND cs.phase = 'done'
AND cs.sync_date >= DATE_SUB(CURDATE(), INTERVAL 90 DAY)",
[ ':client_id' => $active_client_id ]
) -> fetchColumn();
$total_count = (int) $mdb -> query(
"SELECT COUNT(*) FROM cron_sync_status cs
INNER JOIN clients c ON cs.client_id = c.id AND c.deleted = 0
WHERE cs.pipeline = 'products' AND cs.client_id = :client_id
AND cs.sync_date >= DATE_SUB(CURDATE(), INTERVAL 90 DAY)",
[ ':client_id' => $active_client_id ]
) -> fetchColumn();
$remaining_dates = max( 0, $total_count - $done_count );
$estimated_calls_remaining = (int) ceil( $remaining_dates / max( 1, $dates_per_run ) );
$phase_labels = [
'pending' => 'fetch',
'fetch' => 'aggregate_30',
'aggregate_30' => 'aggregate_temp'
];
$display_phase = $phase_labels[ $source_phase ] ?? $source_phase;
$result_text = 'Brak dat do przetworzenia w tej fazie.';
if ( $source_phase === 'pending' )
{
$result_text = empty( $errors ) ? 'Pobieranie produktow zakonczone.' : 'Pobieranie produktow zakonczone z bledami.';
}
else if ( $source_phase === 'fetch' )
{
$result_text = 'Pierwsza agregacja (history_30) zakonczona.';
}
else if ( $source_phase === 'aggregate_30' )
{
$result_text = 'Druga agregacja (products_temp) zakonczona.';
}
echo json_encode( [
'result' => $result_text,
'date' => $date,
'active_client_id' => $active_client_id,
'conversion_window_days' => $conversion_window_days,
'dates_synced' => $import_dates,
'phase' => $display_phase,
'dates_per_run' => $dates_per_run,
'dates_processed_in_call' => count( $dates_processed_in_call ),
'processed_dates' => $dates_processed_in_call,
'remaining_dates' => $remaining_dates,
'estimated_calls_remaining' => $estimated_calls_remaining,
'total_clients' => count( $client_ids ),
'processed_products' => $processed_products_total,
'skipped' => $skipped_total,
'fetch_skipped_reasons' => array_keys( $fetch_skipped_reasons ),
'history_30_products' => $history_30_products_total,
'products_temp_rows' => $products_temp_rows_total,
'errors' => $errors
] );
exit;
}
static public function cron_products_urls()
{
global $mdb, $settings;
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 [];
}
$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 p.product_url IS NULL '
. 'AND COALESCE( p.merchant_url_not_found, 0 ) = 0 '
. 'ORDER BY 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 product_url IS 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_campaign_histories = $mdb -> query(
'SELECT ch.campaign_id
FROM campaigns_history AS ch
INNER JOIN campaigns AS c ON c.id = ch.campaign_id
WHERE c.client_id = :client_id
AND ch.date_add = :date_add',
[
':client_id' => $client_id,
':date_add' => $date
]
) -> fetchAll( \PDO::FETCH_COLUMN );
$campaign_history_exists = [];
foreach ( (array) $existing_campaign_histories as $history_campaign_id )
{
$campaign_history_exists[ (int) $history_campaign_id ] = true;
}
$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, &$campaign_history_exists, &$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 && !isset( $campaign_history_exists[ $db_campaign_id ] ) )
{
$mdb -> insert( 'campaigns_history', [
'campaign_id' => $db_campaign_id,
'roas_30_days' => 0,
'roas_all_time' => 0,
'budget' => 0,
'money_spent' => 0,
'conversion_value' => 0,
'bidding_strategy' => '',
'date_add' => $date
] );
$campaign_history_exists[ $db_campaign_id ] = true;
}
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,
'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 );
$offer_current_name = (string) ( $existing_product['name'] ?? '' );
if ( $offer_current_name != $product_title and $date == date( 'Y-m-d', strtotime( '-1 days' ) ) )
{
$mdb -> update( 'products', [ 'name' => $product_title ], [ 'AND' => [ 'client_id' => $client_id, 'offer_id' => $offer_external_id ] ] );
$products_by_offer_id[ $offer_external_id ]['name'] = $product_title;
}
}
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();
if ( $db_campaign_id > 0 && $date_sync )
{
if ( !$mdb -> count( 'campaigns_history', [ 'AND' => [ 'campaign_id' => $db_campaign_id, 'date_add' => $date_sync ] ] ) )
{
$mdb -> insert( 'campaigns_history', [
'campaign_id' => $db_campaign_id,
'roas_30_days' => 0,
'roas_all_time' => 0,
'budget' => 0,
'money_spent' => 0,
'conversion_value' => 0,
'bidding_strategy' => '',
'date_add' => $date_sync
] );
}
}
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,
'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.
// Zachowujemy logike aktualizacji custom_label_4 oparta o products_aggregate.
$client_bestseller_min_roas = (int) \factory\Products::get_client_bestseller_min_roas( $client_id );
$global_totals = $mdb -> query(
'SELECT
pa.product_id,
COALESCE( SUM( pa.cost_all_time ), 0 ) AS cost,
COALESCE( SUM( pa.conversions_all_time ), 0 ) AS conversions,
COALESCE( SUM( pa.conversion_value_all_time ), 0 ) AS conversions_value
FROM products_aggregate AS pa
INNER JOIN products AS p ON p.id = pa.product_id
WHERE p.client_id = :client_id
GROUP BY pa.product_id',
[ ':client_id' => $client_id ]
) -> fetchAll( \PDO::FETCH_ASSOC );
foreach ( (array) $global_totals as $total )
{
$product_id = (int) ( $total['product_id'] ?? 0 );
if ( $product_id <= 0 )
{
continue;
}
$total_cost = (float) ( $total['cost'] ?? 0 );
$total_conversions = (float) ( $total['conversions'] ?? 0 );
$total_conversion_value = (float) ( $total['conversions_value'] ?? 0 );
$total_roas = ( $total_conversions > 0 && $total_cost > 0 ) ? round( $total_conversion_value / $total_cost, 2 ) * 100 : 0;
$custom_label_4 = (string) $mdb -> get( 'products', 'custom_label_4', [ 'id' => $product_id ] );
if ( $custom_label_4 == null || ( $custom_label_4 == 'bestseller' && $client_bestseller_min_roas > 0 ) )
{
$new_custom_label_4 = ( $total_roas > $client_bestseller_min_roas && $total_conversions > 10 ) ? 'bestseller' : null;
$old_custom_label_4 = (string) $custom_label_4;
if ( $old_custom_label_4 !== (string) $new_custom_label_4 )
{
$mdb -> update( 'products', [ 'custom_label_4' => $new_custom_label_4 ], [ 'id' => $product_id ] );
\factory\Products::set_product_data( $product_id, 'custom_label_4', $new_custom_label_4 );
$mdb -> insert( 'products_comments', [
'product_id' => $product_id,
'comment' => 'Zmiana pola "custom_label_4" na: ' . (string) $new_custom_label_4,
'type' => 1,
'date_add' => date( 'Y-m-d' )
] );
\controls\Products::sync_product_fields_to_merchant( $product_id, [
'custom_label_4' => [
'old' => $old_custom_label_4,
'new' => (string) $new_custom_label_4
]
], 'cron_products' );
}
}
}
// Zwracamy liczbe scope z tabeli products_aggregate (dla diagnostyki odpowiedzi cron).
return (int) $mdb -> query(
'SELECT COUNT(*)
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 cron_products_history_30()
{
global $mdb;
self::touch_cron_invocation( __FUNCTION__ );
$start_time = microtime(true);
$client_id = \S::get( 'client_id' );
if ( !$client_id )
{
echo json_encode( [ 'result' => "Nie podano ID klienta." ] );
exit;
}
if ( !$mdb -> count( 'clients', [ 'id' => $client_id ] ) )
{
echo json_encode( [ 'result' => "Nie znaleziono klienta o podanym ID.", "client" => "Nie istnieje" ] );
exit;
}
$products = $mdb -> select( 'products', 'id', [ 'client_id' => $client_id ] );
foreach ( $products as $product )
{
$scopes = $mdb -> query( 'SELECT DISTINCT campaign_id, ad_group_id, date_add FROM products_history WHERE product_id = ' . $product . ' AND campaign_id > 0 AND updated = 1 ORDER BY date_add DESC' ) -> fetchAll( \PDO::FETCH_ASSOC );
foreach ( $scopes as $scope )
{
$campaign_id = (int) ( $scope['campaign_id'] ?? 0 );
$ad_group_id = (int) ( $scope['ad_group_id'] ?? 0 );
$date_add = $scope['date_add'] ?? '';
self::cron_product_history_30_save( $product, $date_add, $campaign_id, $ad_group_id );
$mdb -> update( 'products_history', [ 'updated' => 0 ], [ 'AND' => [
'product_id' => $product,
'campaign_id' => $campaign_id,
'ad_group_id' => $ad_group_id,
'date_add' => $date_add
] ] );
}
}
$end_time = microtime(true);
$execution_time = $end_time - $start_time;
echo json_encode( [ 'result' => "Agregacja zakonczona, dane zapisane do offers_history_30. Czas wykonania skryptu: " . round($execution_time, 4) . " sekund." ] );
exit;
}
static public function get_roas_all_time( $product_id, $date_to, $campaign_id = 0, $ad_group_id = 0 )
{
global $mdb;
$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_xml()
{
$result = self::generate_custom_feed_for_client( \S::get( 'client_id' ), true );
if ( ( $result['status'] ?? '' ) !== 'ok' )
{
$response = [ 'result' => $result['message'] ?? 'Nie udalo sie wygenerowac pliku XML.' ];
if ( !empty( $result['client'] ) )
{
$response['client'] = $result['client'];
}
echo json_encode( $response );
exit;
}
$url = (string) ( $result['url'] ?? '' );
echo json_encode( [ 'result' => 'Plik XML zostal wygenerowany <a href="' . $url . '">' . $url . '</a>.' ] );
exit;
}
static public function generate_custom_feed_for_client( $client_id, $touch_invocation = true )
{
global $mdb;
$client_id = (int) $client_id;
if ( $touch_invocation )
{
self::touch_cron_invocation( 'cron_xml' );
}
if ( $client_id <= 0 )
{
return [ 'status' => 'error', 'message' => 'Nie podano ID klienta.' ];
}
if ( !$mdb -> count( 'clients', [ 'id' => $client_id ] ) )
{
return [ 'status' => 'error', 'message' => 'Nie znaleziono klienta o podanym ID.', 'client' => 'Nie istnieje' ];
}
$results = $mdb -> query(
'SELECT
p.id AS product_id,
p.offer_id,
p.title,
p.description,
p.custom_label_4,
p.custom_label_3,
p.google_product_category,
p.product_url
FROM products AS p
WHERE p.client_id = :client_id',
[ ':client_id' => $client_id ]
) -> fetchAll( \PDO::FETCH_ASSOC );
if ( empty( $results ) )
{
return [ 'status' => 'error', 'message' => 'Brak produktow do wygenerowania pliku XML.' ];
}
$doc = new \DOMDocument( '1.0', 'UTF-8' );
$xmlRoot = $doc -> createElement( 'rss' );
$xmlRoot = $doc -> appendChild( $xmlRoot );
$xmlRoot -> setAttribute( 'version', '2.0' );
$xmlRoot -> setAttributeNS( 'http://www.w3.org/2000/xmlns/', 'xmlns:g', 'http://base.google.com/ns/1.0' );
$channelNode = $xmlRoot -> appendChild( $doc -> createElement( 'channel' ) );
$channelNode -> appendChild( $doc -> createElement( 'title', 'Custom Feed' ) );
$channelNode -> appendChild( $doc -> createElement( 'link', 'https://ads.pagedev.pl' ) );
$fieldMappings = [
'title' => 'g:title',
'description' => 'g:description',
'custom_label_4' => 'g:custom_label_4',
'custom_label_3' => 'g:custom_label_3',
'google_product_category' => 'g:google_product_category'
];
foreach ( $results as $row )
{
$hasValidField = false;
foreach ( $fieldMappings as $dbField => $xmlTag )
{
if ( !empty( $row[ $dbField ] ) )
{
$hasValidField = true;
break;
}
}
if ( !$hasValidField )
{
continue;
}
$itemNode = $channelNode -> appendChild( $doc -> createElement( 'item' ) );
$offer_id = $mdb -> get( 'products', 'offer_id', [ 'id' => $row['product_id'] ] );
$offer_id = str_replace( 'shopify_pl', 'shopify_PL', $offer_id );
$itemNode -> appendChild( $doc -> createElement( 'id', $offer_id ) );
foreach ( $fieldMappings as $dbField => $xmlTag )
{
if ( !empty( $row[ $dbField ] ) )
{
$itemNode -> appendChild( $doc -> createElement( $xmlTag, $row[ $dbField ] ) );
}
}
}
$xml_dir = dirname( __DIR__, 2 ) . DIRECTORY_SEPARATOR . 'xml';
if ( !is_dir( $xml_dir ) )
{
@mkdir( $xml_dir, 0777, true );
}
$file_path = $xml_dir . DIRECTORY_SEPARATOR . 'custom-feed-' . $client_id . '.xml';
$save_result = @file_put_contents( $file_path, $doc -> saveXML() );
if ( $save_result === false )
{
return [ 'status' => 'error', 'message' => 'Nie udalo sie zapisac pliku XML na serwerze.' ];
}
$scheme = ( !empty( $_SERVER['HTTPS'] ) && $_SERVER['HTTPS'] !== 'off' ) ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? ( $_SERVER['SERVER_NAME'] ?? 'localhost' );
$url = $scheme . '://' . $host . '/xml/custom-feed-' . $client_id . '.xml';
return [
'status' => 'ok',
'message' => 'Plik XML zostal wygenerowany.',
'url' => $url,
'client_id' => $client_id
];
}
static public function cron_phrases()
{
global $mdb;
self::touch_cron_invocation( __FUNCTION__ );
if ( !$client_id = \S::get( 'client_id' ) )
{
echo json_encode( [ 'result' => "Nie podano ID klienta." ] );
exit;
}
if ( !$mdb -> count( 'clients', [ 'id' => $client_id ] ) )
{
echo json_encode( [ 'result' => "Nie znaleziono klienta o podanym ID.", "client" => "Nie istnieje" ] );
exit;
}
$data = $mdb -> query( 'SELECT * FROM phrases AS p INNER JOIN phrases_history AS ph ON p.id = ph.phrase_id WHERE p.client_id = ' . $client_id ) -> fetchAll( \PDO::FETCH_ASSOC );
$aggregated_data = [];
foreach ( $data as $row )
{
$phrase_id = $row['phrase_id'];
if ( !isset( $aggregated_data[$client_id] ) )
{
$aggregated_data[$client_id] = [];
}
if ( !isset( $aggregated_data[$client_id][$phrase_id] ) )
{
$aggregated_data[$client_id][$phrase_id] = [
'phrase_id' => $phrase_id,
'phrase' => $row['phrase'],
'impressions' => 0,
'clicks' => 0,
'cost' => 0.0,
'conversions' => 0,
'conversions_value' => 0.0
];
}
$aggregated_data[$client_id][$phrase_id]['impressions'] += $row['impressions'];
$aggregated_data[$client_id][$phrase_id]['clicks'] += $row['clicks'];
$aggregated_data[$client_id][$phrase_id]['cost'] += $row['cost'];
$aggregated_data[$client_id][$phrase_id]['conversions'] += $row['conversions'];
$aggregated_data[$client_id][$phrase_id]['conversions_value'] += $row['conversions_value'];
}
$phrases_ids = $mdb -> select( 'phrases', 'id', [ 'client_id' => $client_id ] );
foreach ( $phrases_ids as $phrase_id )
{
$phrases_ids_array[] = $phrase_id -> id;
}
$mdb -> delete( 'phrases_temp', [ 'phrase_id' => $phrases_ids_array ] );
foreach ( $aggregated_data as $client_phrases )
{
foreach ( $client_phrases as $phrase_data )
{
$cpc = $phrase_data['clicks'] > 0 ? round( $phrase_data['cost'] / $phrase_data['clicks'], 6 ) : 0;
$roas = ( $phrase_data['conversions'] > 0 and $phrase_data['cost'] ) ? round( $phrase_data['conversions_value'] / $phrase_data['cost'], 2 ) * 100 : 0;
$mdb -> insert( 'phrases_temp', [
'phrase_id' => $phrase_data['phrase_id'],
'phrase' => $phrase_data['phrase'],
'impressions' => $phrase_data['impressions'],
'clicks' => $phrase_data['clicks'],
'cost' => $phrase_data['cost'],
'conversions' => $phrase_data['conversions'],
'conversions_value' => $phrase_data['conversions_value'],
'cpc' => $cpc,
'roas' => $roas,
] );
}
}
echo json_encode( [ 'result' => "Agregacja zakonczona, dane zapisane do phrases_temp." ] );
exit;
}
static public function cron_phrases_history_30()
{
global $mdb;
self::touch_cron_invocation( __FUNCTION__ );
$start_time = microtime( true ); // Rozpoczcie mierzenia czasu
$client_id = \S::get( 'client_id' ); // Pobranie ID klienta
if ( !$client_id ) // Jeli nie podano ID klienta
{
echo json_encode( [ 'result' => "Nie podano ID klienta." ] ); // Wyswietlenie komunikatu
exit; // Zakonczenie dziaania skryptu
}
if ( !$mdb -> count( 'clients', [ 'id' => $client_id ] ) ) // Sprawdzenie, czy klient istnieje
{
echo json_encode( [ 'result' => "Nie znaleziono klienta o podanym ID.", "client" => "Nie istnieje" ] ); // Wyswietlenie komunikatu
exit; // Zakonczenie dziaania skryptu
}
// Pobranie biecej daty i daty sprzed 30 dni
$phrases = $mdb -> query( 'SELECT * FROM phrases WHERE client_id = ' . $client_id ) -> fetchAll( \PDO::FETCH_ASSOC ); // Pobranie fraz dla danego klienta
foreach ( $phrases as $phrase )
{
for ( $i = 0; $i < 30; $i++ )
{
$date_to = date( 'Y-m-d', strtotime( '-' . ( 1 + $i ) . ' days' ) );
$date_from = date( 'Y-m-d', strtotime( '-' . ( 31 + $i ) . ' days' ) );
$data_updated = false;
if ( $mdb -> count( 'phrases_history', [ 'AND' => [ 'phrase_id' => $phrase['id'], 'date_add[>=]' => $date_from, 'date_add[<=]' => $date_to, 'updated' => 1 ] ] ) > 0 )
{
$data_updated = true;
}
if ( $data_updated )
{
self::cron_phrase_history_30_save( $phrase['id'], $date_from, $date_to );
}
}
$mdb -> update( 'phrases_history', [ 'updated' => 0 ], [ 'AND' => [ 'phrase_id' => $phrase['id'], 'updated' => 1 ] ] );
}
$end_time = microtime( true ); // Zakonczenie mierzenia czasu
$execution_time = $end_time - $start_time; // Obliczenie czasu wykonania
echo json_encode( [ 'result' => "Agregacja zakonczona, dane zapisane do phrases_history_30. Czas wykonania skryptu: " . round( $execution_time, 4 ) . " sekund.", 'client' => \factory\Campaigns::get_client_name( $client_id ) ] ); // Wyswietlenie komunikatu
exit;
}
// ===========================
// KAMPANIE - Google Ads API
// ===========================
static public function cron_campaigns()
{
global $mdb, $settings;
self::touch_cron_invocation( __FUNCTION__ );
$clients_not_deleted_sql = self::sql_clients_not_deleted();
$clients_not_deleted_sql_c = self::sql_clients_not_deleted( 'c' );
$clients_deleted_sql_c = self::sql_clients_deleted( 'c' );
$api = new \services\GoogleAdsApi();
if ( !$api -> is_configured() )
{
self::output_cron_response( [ 'result' => 'Google Ads API nie jest skonfigurowane. Uzupelnij dane w Ustawieniach.' ] );
}
$conversion_window_days = self::get_conversion_window_days( true );
$default_end_date = date( 'Y-m-d', strtotime( '-2 days' ) );
$requested_date_raw = trim( (string) \S::get( 'date' ) );
$requested_ts = $requested_date_raw !== '' ? strtotime( $requested_date_raw ) : false;
$sync_date_raw = $requested_ts ? date( 'Y-m-d', $requested_ts ) : $default_end_date;
$sync_date = ( strtotime( $sync_date_raw ) > strtotime( $default_end_date ) ) ? $default_end_date : $sync_date_raw;
if ( !$sync_date || strtotime( $sync_date ) === false )
{
$sync_date = $default_end_date;
}
$sync_dates = self::build_backfill_dates( $sync_date, $conversion_window_days );
$client_id = (int) \S::get( 'client_id' );
if ( $client_id > 0 )
{
$client = $mdb -> query(
"SELECT *
FROM clients
WHERE id = :id
AND COALESCE(active, 0) = 1
AND " . $clients_not_deleted_sql . "
LIMIT 1",
[ ':id' => $client_id ]
) -> fetch( \PDO::FETCH_ASSOC );
if ( !$client || trim( (string) ( $client['google_ads_customer_id'] ?? '' ) ) === '' )
{
self::output_cron_response( [ 'result' => 'Nie znaleziono aktywnego klienta z poprawnym Google Ads Customer ID.', 'client_id' => $client_id ] );
}
$processed_records_total = 0;
$ad_groups_synced_total = 0;
$search_terms_history_synced_total = 0;
$search_terms_aggregated_total = 0;
$keywords_synced_total = 0;
$negative_keywords_synced_total = 0;
$errors = [];
$processed_dates = [];
$dates_processed_in_call = [ $sync_date ];
foreach ( $dates_processed_in_call as $active_date )
{
$sync = self::sync_campaigns_snapshot_for_client( $client, $api, $active_date );
$processed_records_total += (int) ( $sync['processed_records'] ?? 0 );
$ad_groups_synced_total += (int) ( $sync['ad_groups_synced'] ?? 0 );
if ( !empty( $sync['errors'] ) )
{
$errors = array_merge( $errors, (array) $sync['errors'] );
}
$processed_dates[] = $active_date;
}
if ( empty( $errors ) )
{
$terms_sync = self::sync_campaign_terms_backfill_for_client(
(int) $client['id'],
(string) ( $client['google_ads_customer_id'] ?? '' ),
$api,
$dates_processed_in_call
);
$search_terms_history_synced_total = (int) ( $terms_sync['history_synced'] ?? 0 );
$search_terms_aggregated_total = (int) ( $terms_sync['aggregated'] ?? 0 );
if ( !empty( $terms_sync['errors'] ) )
{
$errors = array_merge( $errors, (array) $terms_sync['errors'] );
}
}
$last_day_in_window = end( $sync_dates );
if ( empty( $errors ) && $last_day_in_window && $sync_date === $last_day_in_window )
{
$kw_sync = self::sync_campaign_keywords_and_negatives_for_client(
(int) $client['id'],
(string) ( $client['google_ads_customer_id'] ?? '' ),
$api,
$sync_date
);
$keywords_synced_total = (int) ( $kw_sync['keywords_synced'] ?? 0 );
$negative_keywords_synced_total = (int) ( $kw_sync['negative_keywords_synced'] ?? 0 );
if ( !empty( $kw_sync['errors'] ) )
{
$errors = array_merge( $errors, (array) $kw_sync['errors'] );
}
}
self::output_cron_response( [
'result' => empty( $errors ) ? 'Synchronizacja kampanii zakonczona.' : 'Synchronizacja kampanii zakonczona z bledami.',
'client_id' => (int) $client['id'],
'active_date' => $sync_date,
'conversion_window_days' => $conversion_window_days,
'dates_synced' => $sync_dates,
'processed_dates' => $processed_dates,
'processed_records' => $processed_records_total,
'ad_groups_synced' => $ad_groups_synced_total,
'search_terms_history_synced' => $search_terms_history_synced_total,
'search_terms_aggregated' => $search_terms_aggregated_total,
'keywords_synced' => $keywords_synced_total,
'negative_keywords_synced' => $negative_keywords_synced_total,
'errors' => $errors
] );
}
self::cleanup_old_sync_rows( 30 );
$mdb -> query(
"DELETE cs FROM cron_sync_status cs
LEFT JOIN clients c ON cs.client_id = c.id
WHERE cs.pipeline = 'campaigns'
AND ( c.id IS NULL OR " . $clients_deleted_sql_c . " OR COALESCE(c.active, 0) <> 1 )"
);
$client_ids = $mdb -> query(
"SELECT id
FROM clients
WHERE " . $clients_not_deleted_sql . "
AND COALESCE(active, 0) = 1
AND TRIM(COALESCE(google_ads_customer_id, '')) <> ''
ORDER BY id ASC"
) -> fetchAll( \PDO::FETCH_COLUMN );
$client_ids = array_values( array_unique( array_map( 'intval', $client_ids ) ) );
if ( empty( $client_ids ) )
{
self::output_cron_response( [ 'result' => 'Brak aktywnych klientow z ustawionym Google Ads Customer ID.' ] );
}
self::ensure_sync_rows( 'campaigns', $sync_dates, $client_ids );
self::cleanup_pipeline_rows_outside_window( 'campaigns', $sync_dates );
$active_client_id = self::get_active_client( 'campaigns' );
if ( !$active_client_id )
{
self::output_cron_response( [
'result' => 'Wszyscy aktywni klienci kampanii zostali przetworzeni dla calego okna dat.',
'active_date' => $sync_date,
'conversion_window_days' => $conversion_window_days,
'dates_synced' => $sync_dates,
'processed_clients' => count( $client_ids ),
'total_clients' => count( $client_ids )
] );
}
$dates_per_run = 1;
$dates_batch = self::get_pending_dates_for_client( 'campaigns', $active_client_id, 'pending', 1 );
if ( empty( $dates_batch ) )
{
self::output_cron_response( [
'result' => 'Wszystkie daty klienta przetworzone. Kolejne wywolanie przejdzie do nastepnego klienta.',
'active_client_id' => $active_client_id
] );
}
$selected_client = $mdb -> query(
"SELECT *
FROM clients
WHERE id = :id
AND COALESCE(active, 0) = 1
AND " . $clients_not_deleted_sql . "
LIMIT 1",
[ ':id' => $active_client_id ]
) -> fetch( \PDO::FETCH_ASSOC );
if ( !$selected_client || trim( (string) ( $selected_client['google_ads_customer_id'] ?? '' ) ) === '' )
{
self::output_cron_response( [
'result' => 'Nie udalo sie znalezc aktywnego klienta do synchronizacji kampanii. ID: ' . $active_client_id,
'active_client_id' => $active_client_id,
'errors' => [ 'Klient ID ' . $active_client_id . ' nie znaleziony lub nieaktywny.' ]
] );
}
$dates_processed_in_call = [];
$errors = [];
$processed_records_total = 0;
$ad_groups_synced_total = 0;
$search_terms_history_synced_total = 0;
$search_terms_aggregated_total = 0;
$keywords_synced_total = 0;
$negative_keywords_synced_total = 0;
foreach ( $dates_batch as $active_date )
{
$sync = self::sync_campaigns_snapshot_for_client( $selected_client, $api, $active_date );
$processed_records_total += (int) ( $sync['processed_records'] ?? 0 );
$ad_groups_synced_total += (int) ( $sync['ad_groups_synced'] ?? 0 );
$error_msg = null;
if ( !empty( $sync['errors'] ) )
{
$errors = array_merge( $errors, (array) $sync['errors'] );
}
$dates_processed_in_call[] = $active_date;
}
if ( empty( $errors ) )
{
$terms_sync = self::sync_campaign_terms_backfill_for_client(
(int) $selected_client['id'],
(string) ( $selected_client['google_ads_customer_id'] ?? '' ),
$api,
$dates_batch
);
$search_terms_history_synced_total = (int) ( $terms_sync['history_synced'] ?? 0 );
$search_terms_aggregated_total = (int) ( $terms_sync['aggregated'] ?? 0 );
if ( !empty( $terms_sync['errors'] ) )
{
$errors = array_merge( $errors, (array) $terms_sync['errors'] );
}
}
$pending_dates_before_finalize = self::count_pending_campaign_dates_for_client( $active_client_id );
$is_last_pending_chunk_for_client = $pending_dates_before_finalize <= count( $dates_batch );
if ( empty( $errors ) && $is_last_pending_chunk_for_client )
{
$kw_sync = self::sync_campaign_keywords_and_negatives_for_client(
(int) $selected_client['id'],
(string) ( $selected_client['google_ads_customer_id'] ?? '' ),
$api,
(string) ( $dates_batch[ count( $dates_batch ) - 1 ] ?? $sync_date )
);
$keywords_synced_total = (int) ( $kw_sync['keywords_synced'] ?? 0 );
$negative_keywords_synced_total = (int) ( $kw_sync['negative_keywords_synced'] ?? 0 );
if ( !empty( $kw_sync['errors'] ) )
{
$errors = array_merge( $errors, (array) $kw_sync['errors'] );
}
}
$final_phase = empty( $errors ) ? 'done' : 'pending';
$final_error = empty( $errors ) ? null : implode( '; ', array_values( array_unique( array_map( 'strval', $errors ) ) ) );
foreach ( $dates_batch as $active_date )
{
self::mark_sync_phase( 'campaigns', $active_date, $active_client_id, $final_phase, $final_error );
}
$done_count = (int) $mdb -> query(
"SELECT COUNT(*)
FROM cron_sync_status cs
INNER JOIN clients c ON cs.client_id = c.id
WHERE cs.pipeline = 'campaigns'
AND cs.client_id = :client_id
AND cs.phase = 'done'
AND " . $clients_not_deleted_sql_c . "
AND COALESCE(c.active, 0) = 1
AND cs.sync_date >= DATE_SUB(CURDATE(), INTERVAL 90 DAY)",
[ ':client_id' => $active_client_id ]
) -> fetchColumn();
$total_count = (int) $mdb -> query(
"SELECT COUNT(*)
FROM cron_sync_status cs
INNER JOIN clients c ON cs.client_id = c.id
WHERE cs.pipeline = 'campaigns'
AND cs.client_id = :client_id
AND " . $clients_not_deleted_sql_c . "
AND COALESCE(c.active, 0) = 1
AND cs.sync_date >= DATE_SUB(CURDATE(), INTERVAL 90 DAY)",
[ ':client_id' => $active_client_id ]
) -> fetchColumn();
$remaining_dates = max( 0, $total_count - $done_count );
$estimated_calls_remaining = (int) ceil( $remaining_dates / max( 1, $dates_per_run ) );
self::output_cron_response( [
'result' => empty( $errors ) ? 'Synchronizacja kampanii zakonczona.' : 'Synchronizacja kampanii zakonczona z bledami.',
'active_client_id' => $active_client_id,
'dates_per_run' => $dates_per_run,
'dates_processed_in_call' => count( $dates_processed_in_call ),
'processed_dates' => $dates_processed_in_call,
'remaining_dates' => $remaining_dates,
'estimated_calls_remaining' => $estimated_calls_remaining,
'active_date' => $sync_date,
'conversion_window_days' => $conversion_window_days,
'dates_synced' => $sync_dates,
'processed_records' => $processed_records_total,
'ad_groups_synced' => $ad_groups_synced_total,
'search_terms_history_synced' => $search_terms_history_synced_total,
'search_terms_aggregated' => $search_terms_aggregated_total,
'keywords_synced' => $keywords_synced_total,
'negative_keywords_synced' => $negative_keywords_synced_total,
'total_clients' => count( $client_ids ),
'errors' => $errors
] );
}
// ARCHIWUM: stara wersja cron_campaigns pozostawiona do porownan i ewentualnego rollbacku.
static public function cron_campaigns_archive()
{
global $mdb, $settings;
self::touch_cron_invocation( __FUNCTION__ );
$api = new \services\GoogleAdsApi();
if ( !$api -> is_configured() )
{
echo json_encode( [ 'result' => 'Google Ads API nie jest skonfigurowane. Uzupelnij dane w Ustawieniach.' ] );
exit;
}
$sync_date = \S::get( 'date' ) ? date( 'Y-m-d', strtotime( \S::get( 'date' ) ) ) : date( 'Y-m-d' );
$conversion_window_days = self::get_conversion_window_days();
$sync_dates = self::build_backfill_dates( $sync_date, $conversion_window_days );
$client_id = (int) \S::get( 'client_id' );
if ( $client_id > 0 )
{
$client = $mdb -> get( 'clients', '*', [ 'AND' => [
'id' => $client_id,
'google_ads_customer_id[!]' => null,
'deleted' => 0
] ] );
if ( !$client )
{
echo json_encode( [ 'result' => 'Nie znaleziono klienta z poprawnym Google Ads Customer ID.', 'client_id' => $client_id ] );
exit;
}
$sync = self::sync_campaigns_for_client( $client, $api, $sync_date, true );
echo json_encode( [
'result' => empty( $sync['errors'] ) ? 'Synchronizacja kampanii zakonczona.' : 'Synchronizacja kampanii zakonczona z bledami.',
'client_id' => (int) $client['id'],
'date' => $sync_date,
'active_date' => $sync_date,
'processed_records' => (int) $sync['processed_records'],
'ad_groups_synced' => (int) ( $sync['ad_groups_synced'] ?? 0 ),
'search_terms_synced' => (int) ( $sync['search_terms_synced'] ?? 0 ),
'keywords_synced' => (int) ( $sync['keywords_synced'] ?? 0 ),
'negative_keywords_synced' => (int) ( $sync['negative_keywords_synced'] ?? 0 ),
'alerts_synced' => (int) ( $sync['alerts_synced'] ?? 0 ),
'errors' => $sync['errors']
] );
exit;
}
self::cleanup_old_sync_rows( 30 );
$client_ids = $mdb -> query( "SELECT id FROM clients WHERE deleted = 0 AND google_ads_customer_id IS NOT NULL AND 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 ) )
{
echo json_encode( [ 'result' => 'Brak klientow z ustawionym Google Ads Customer ID.' ] );
exit;
}
$clients_map = [];
$clients = $mdb -> select( 'clients', '*', [
'AND' => [
'google_ads_customer_id[!]' => null,
'deleted' => 0
],
'ORDER' => [ 'id' => 'ASC' ]
] );
foreach ( $clients as $c )
{
$clients_map[ (int) $c['id'] ] = $c;
}
self::ensure_sync_rows( 'campaigns', $sync_dates, $client_ids );
$active_client_id = self::get_active_client( 'campaigns' );
if ( !$active_client_id )
{
echo json_encode( [
'result' => 'Wszyscy klienci kampanii zostali juz przetworzeni dla calego okna dat.',
'date' => $sync_date,
'active_date' => $sync_date,
'conversion_window_days' => $conversion_window_days,
'dates_synced' => $sync_dates,
'processed_clients' => count( $client_ids ),
'total_clients' => count( $client_ids )
] );
exit;
}
$dates_per_run_default = (int) ( $settings['cron_campaigns_clients_per_run'] ?? 2 );
if ( $dates_per_run_default <= 0 )
{
$dates_per_run_default = 2;
}
$dates_per_run = (int) \S::get( 'clients_per_run' );
if ( $dates_per_run <= 0 )
{
$dates_per_run = $dates_per_run_default;
}
$dates_per_run = min( 20, $dates_per_run );
$dates_batch = self::get_pending_dates_for_client( 'campaigns', $active_client_id, 'pending', $dates_per_run );
if ( empty( $dates_batch ) )
{
echo json_encode( [
'result' => 'Wszystkie daty klienta przetworzone. Kolejne wywolanie przejdzie do nastepnego klienta.',
'date' => $sync_date,
'active_client_id' => $active_client_id,
'conversion_window_days' => $conversion_window_days,
'dates_synced' => $sync_dates,
'processed_clients' => count( $client_ids ),
'total_clients' => count( $client_ids )
] );
exit;
}
$selected_client = $clients_map[ $active_client_id ] ?? null;
if ( !$selected_client )
{
echo json_encode( [
'result' => 'Nie udalo sie znalezc klienta do synchronizacji kampanii. ID: ' . $active_client_id,
'active_client_id' => $active_client_id,
'errors' => [ 'Klient ID ' . $active_client_id . ' nie znaleziony w clients_map.' ]
] );
exit;
}
$dates_processed_in_call = [];
$errors = [];
$processed_records_total = 0;
$ad_groups_synced_total = 0;
$search_terms_synced_total = 0;
$keywords_synced_total = 0;
$negative_keywords_synced_total = 0;
$alerts_synced_total = 0;
foreach ( $dates_batch as $active_date )
{
$sync_details = ( $active_date === $sync_date );
$sync = self::sync_campaigns_for_client( $selected_client, $api, $active_date, $sync_details );
$processed_records_total += (int) ( $sync['processed_records'] ?? 0 );
$ad_groups_synced_total += (int) ( $sync['ad_groups_synced'] ?? 0 );
$search_terms_synced_total += (int) ( $sync['search_terms_synced'] ?? 0 );
$keywords_synced_total += (int) ( $sync['keywords_synced'] ?? 0 );
$negative_keywords_synced_total += (int) ( $sync['negative_keywords_synced'] ?? 0 );
$alerts_synced_total += (int) ( $sync['alerts_synced'] ?? 0 );
$error_msg = null;
if ( !empty( $sync['errors'] ) )
{
$errors = array_merge( $errors, (array) $sync['errors'] );
$error_msg = implode( '; ', (array) $sync['errors'] );
}
self::mark_sync_phase( 'campaigns', $active_date, $active_client_id, 'done', $error_msg );
$dates_processed_in_call[] = $active_date;
}
$done_count = (int) $mdb -> query(
"SELECT COUNT(*) FROM cron_sync_status WHERE pipeline = 'campaigns' AND client_id = :client_id AND phase = 'done'
AND sync_date >= DATE_SUB(CURDATE(), INTERVAL 90 DAY)",
[ ':client_id' => $active_client_id ]
) -> fetchColumn();
$total_count = (int) $mdb -> query(
"SELECT COUNT(*) FROM cron_sync_status WHERE pipeline = 'campaigns' AND client_id = :client_id
AND sync_date >= DATE_SUB(CURDATE(), INTERVAL 90 DAY)",
[ ':client_id' => $active_client_id ]
) -> fetchColumn();
$remaining_dates = max( 0, $total_count - $done_count );
$estimated_calls_remaining = (int) ceil( $remaining_dates / max( 1, $dates_per_run ) );
echo json_encode( [
'result' => empty( $errors ) ? 'Synchronizacja kampanii zakonczona.' : 'Synchronizacja kampanii zakonczona z bledami.',
'active_client_id' => $active_client_id,
'dates_per_run' => $dates_per_run,
'dates_processed_in_call' => count( $dates_processed_in_call ),
'processed_dates' => $dates_processed_in_call,
'remaining_dates' => $remaining_dates,
'estimated_calls_remaining' => $estimated_calls_remaining,
'date' => $sync_date,
'conversion_window_days' => $conversion_window_days,
'dates_synced' => $sync_dates,
'processed_records' => $processed_records_total,
'ad_groups_synced' => $ad_groups_synced_total,
'search_terms_synced' => $search_terms_synced_total,
'keywords_synced' => $keywords_synced_total,
'negative_keywords_synced' => $negative_keywords_synced_total,
'alerts_synced' => $alerts_synced_total,
'total_clients' => count( $client_ids ),
'errors' => $errors
] );
exit;
}
static public function cron_campaigns_product_alerts_merchant()
{
global $mdb, $settings;
self::touch_cron_invocation( __FUNCTION__ );
$api = new \services\GoogleAdsApi();
if ( !$api -> is_configured() )
{
echo json_encode( [ 'result' => 'Google Ads API nie jest skonfigurowane. Uzupelnij dane w Ustawieniach.' ] );
exit;
}
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 public function cron_clients_bundle()
{
global $mdb;
self::touch_cron_invocation( __FUNCTION__ );
$api = new \services\GoogleAdsApi();
if ( !$api -> is_configured() )
{
echo json_encode( [ 'result' => 'Google Ads API nie jest skonfigurowane. Uzupelnij dane w Ustawieniach.' ] );
exit;
}
$sync_date_raw = \S::get( 'date' ) ? date( 'Y-m-d', strtotime( \S::get( 'date' ) ) ) : date( 'Y-m-d' );
$today_date = date( 'Y-m-d' );
$yesterday_date = date( 'Y-m-d', strtotime( '-1 days', strtotime( $today_date ) ) );
$sync_date = ( strtotime( $sync_date_raw ) >= strtotime( $today_date ) ) ? $yesterday_date : $sync_date_raw;
if ( strtotime( $sync_date ) === false )
{
$sync_date = $yesterday_date;
}
$conversion_window_days = (int) \S::get( 'window_days' );
if ( $conversion_window_days <= 0 )
{
$conversion_window_days = (int) self::get_setting_value( 'cron_clients_bundle_window_days', 7 );
}
if ( $conversion_window_days <= 0 )
{
$conversion_window_days = 7;
}
$conversion_window_days = min( 90, max( 1, $conversion_window_days ) );
$sync_dates = self::build_backfill_dates( $sync_date, $conversion_window_days );
if ( empty( $sync_dates ) )
{
$sync_dates = [ $sync_date ];
}
$dates_total = count( $sync_dates );
$forced_client_id = (int) \S::get( 'client_id' );
$reset_state = (int) \S::get( 'reset' ) === 1;
$max_stage_retries = (int) \S::get( 'max_retries' );
if ( $max_stage_retries <= 0 )
{
$max_stage_retries = (int) self::get_setting_value( 'cron_clients_bundle_max_stage_retries', 3 );
}
if ( $max_stage_retries <= 0 )
{
$max_stage_retries = 3;
}
$max_stage_retries = min( 20, $max_stage_retries );
$where = "deleted = 0
AND google_ads_customer_id IS NOT NULL
AND google_ads_customer_id <> ''";
if ( $forced_client_id > 0 )
{
$where .= ' AND id = ' . $forced_client_id;
}
$clients = $mdb -> query(
"SELECT id, name, google_ads_customer_id, google_merchant_account_id
FROM clients
WHERE " . $where . "
ORDER BY id ASC"
) -> fetchAll( \PDO::FETCH_ASSOC );
if ( !is_array( $clients ) || empty( $clients ) )
{
self::set_setting_value( 'cron_clients_bundle_stage', 'campaigns' );
self::set_setting_value( 'cron_clients_bundle_client_id', '0' );
self::set_setting_value( 'cron_clients_bundle_date_index', '0' );
echo json_encode( [
'result' => 'Brak klientow z Google Ads Customer ID do przetworzenia.',
'date' => $sync_date,
'processed_stage' => 'none',
'errors' => []
] );
exit;
}
$stage_order = [ 'campaigns', 'products', 'merchant' ];
$saved_stage = trim( (string) self::get_setting_value( 'cron_clients_bundle_stage', 'campaigns' ) );
if ( !in_array( $saved_stage, $stage_order, true ) )
{
$saved_stage = 'campaigns';
}
$saved_client_id = (int) self::get_setting_value( 'cron_clients_bundle_client_id', 0 );
$saved_date_index = (int) self::get_setting_value( 'cron_clients_bundle_date_index', 0 );
$client_ids = array_values( array_map( function( $row )
{
return (int) ( $row['id'] ?? 0 );
}, (array) $clients ) );
self::cleanup_old_sync_rows( 30 );
self::ensure_sync_rows( 'campaigns', $sync_dates, $client_ids );
self::ensure_sync_rows( 'products', $sync_dates, $client_ids );
if ( !in_array( $saved_client_id, $client_ids, true ) )
{
$saved_client_id = (int) ( $client_ids[0] ?? 0 );
$saved_stage = 'campaigns';
$saved_date_index = 0;
}
if ( $reset_state )
{
$saved_stage = 'campaigns';
$saved_client_id = $forced_client_id > 0 ? $forced_client_id : (int) ( $client_ids[0] ?? 0 );
$saved_date_index = 0;
if ( !in_array( $saved_client_id, $client_ids, true ) )
{
$saved_client_id = (int) ( $client_ids[0] ?? 0 );
}
self::set_setting_value( 'cron_clients_bundle_stage', $saved_stage );
self::set_setting_value( 'cron_clients_bundle_client_id', (string) $saved_client_id );
self::set_setting_value( 'cron_clients_bundle_date_index', '0' );
self::set_setting_value( 'cron_clients_bundle_retry_signature', '' );
self::set_setting_value( 'cron_clients_bundle_retry_count', '0' );
self::set_setting_value( 'cron_clients_bundle_last_error', '' );
}
$current_client = null;
foreach ( (array) $clients as $client_row )
{
if ( (int) ( $client_row['id'] ?? 0 ) === $saved_client_id )
{
$current_client = $client_row;
break;
}
}
if ( !$current_client )
{
$current_client = $clients[0];
$saved_client_id = (int) ( $current_client['id'] ?? 0 );
$saved_stage = 'campaigns';
$saved_date_index = 0;
}
if ( in_array( $saved_stage, [ 'campaigns', 'products' ], true ) )
{
$saved_date_index = max( 0, min( $dates_total - 1, $saved_date_index ) );
}
else
{
$saved_date_index = 0;
}
$current_sync_date = in_array( $saved_stage, [ 'campaigns', 'products' ], true )
? (string) ( $sync_dates[ $saved_date_index ] ?? $sync_date )
: $sync_date;
$stage_result = self::run_clients_bundle_stage_for_client( $saved_stage, $current_client, $api, $current_sync_date );
$errors = (array) ( $stage_result['errors'] ?? [] );
$can_advance = empty( $errors );
$forced_advance_due_errors = false;
$stage_retry_attempt = 0;
$stage_signature = (int) ( $current_client['id'] ?? 0 ) . '|' . $saved_stage . '|' . ( in_array( $saved_stage, [ 'campaigns', 'products' ], true ) ? $current_sync_date : 'merchant' );
$saved_retry_signature = trim( (string) self::get_setting_value( 'cron_clients_bundle_retry_signature', '' ) );
$saved_retry_count = (int) self::get_setting_value( 'cron_clients_bundle_retry_count', 0 );
if ( !$can_advance )
{
if ( $saved_retry_signature === $stage_signature )
{
$stage_retry_attempt = $saved_retry_count + 1;
}
else
{
$stage_retry_attempt = 1;
}
self::set_setting_value( 'cron_clients_bundle_retry_signature', $stage_signature );
self::set_setting_value( 'cron_clients_bundle_retry_count', (string) $stage_retry_attempt );
self::set_setting_value( 'cron_clients_bundle_last_error', implode( ' | ', array_slice( $errors, 0, 3 ) ) );
if ( $stage_retry_attempt >= $max_stage_retries )
{
$can_advance = true;
$forced_advance_due_errors = true;
}
}
else
{
self::set_setting_value( 'cron_clients_bundle_retry_signature', '' );
self::set_setting_value( 'cron_clients_bundle_retry_count', '0' );
self::set_setting_value( 'cron_clients_bundle_last_error', '' );
}
$next_stage = $saved_stage;
$next_client_id = $saved_client_id;
$next_date_index = $saved_date_index;
$advanced = false;
$cycle_completed = false;
if ( $can_advance )
{
if ( $saved_stage === 'merchant' )
{
// Dla wspolnego dashboardu/klientow aktualizujemy ten sam kursor co dedykowany cron Merchant.
self::set_setting_value( 'cron_campaigns_product_alerts_last_client_id', (string) $saved_client_id );
}
if ( $saved_stage === 'campaigns' )
{
if ( $saved_date_index < ( $dates_total - 1 ) )
{
$next_date_index = $saved_date_index + 1;
}
else
{
$next_stage = 'products';
$next_date_index = 0;
}
}
else if ( $saved_stage === 'products' )
{
if ( $saved_date_index < ( $dates_total - 1 ) )
{
$next_date_index = $saved_date_index + 1;
}
else
{
$next_stage = 'merchant';
$next_date_index = 0;
}
}
else
{
$next_stage = 'campaigns';
$next_date_index = 0;
$next_client_id = self::get_next_client_id_in_order( $client_ids, $saved_client_id );
$advanced = true;
$last_client_id = (int) end( $client_ids );
if ( $last_client_id > 0 && $saved_client_id === $last_client_id && $next_client_id === (int) ( $client_ids[0] ?? 0 ) )
{
$cycle_completed = true;
self::set_setting_value( 'cron_clients_bundle_last_cycle_completed_at', date( 'Y-m-d H:i:s' ) );
}
}
}
self::set_setting_value( 'cron_clients_bundle_stage', $next_stage );
self::set_setting_value( 'cron_clients_bundle_client_id', (string) $next_client_id );
self::set_setting_value( 'cron_clients_bundle_date_index', (string) $next_date_index );
if ( $can_advance )
{
self::set_setting_value( 'cron_clients_bundle_retry_signature', '' );
self::set_setting_value( 'cron_clients_bundle_retry_count', '0' );
}
self::set_setting_value( 'cron_clients_bundle_last_processed_client_id', (string) (int) ( $current_client['id'] ?? 0 ) );
self::set_setting_value( 'cron_clients_bundle_last_processed_stage', $saved_stage );
self::set_setting_value( 'cron_clients_bundle_last_processed_date', (string) $current_sync_date );
self::set_setting_value( 'cron_clients_bundle_last_processed_status', empty( $errors ) ? 'ok' : ( $forced_advance_due_errors ? 'forced_advance' : 'error' ) );
self::set_setting_value( 'cron_clients_bundle_last_processed_at', date( 'Y-m-d H:i:s' ) );
$next_sync_date = in_array( $next_stage, [ 'campaigns', 'products' ], true )
? (string) ( $sync_dates[ $next_date_index ] ?? $sync_date )
: $sync_date;
echo json_encode( [
'result' => empty( $errors ) ? 'Cron zbiorczy: etap zakonczony.' : ( $forced_advance_due_errors ? 'Cron zbiorczy: przekroczono limit prob, przejscie do kolejnego etapu.' : 'Cron zbiorczy: etap zakonczony z bledami.' ),
'date' => $sync_date,
'today_date' => $today_date,
'window_days' => $conversion_window_days,
'dates_window' => $sync_dates,
'current_client_id' => (int) ( $current_client['id'] ?? 0 ),
'current_client_name' => (string) ( $current_client['name'] ?? '' ),
'processed_stage' => $saved_stage,
'processed_date_index' => $saved_date_index,
'processed_sync_date' => $current_sync_date,
'next_stage' => $next_stage,
'next_client_id' => $next_client_id,
'next_date_index' => $next_date_index,
'next_sync_date' => $next_sync_date,
'can_advance' => $can_advance ? 1 : 0,
'stage_retry_attempt' => $stage_retry_attempt,
'stage_max_retries' => $max_stage_retries,
'forced_advance_due_errors' => $forced_advance_due_errors ? 1 : 0,
'advanced_to_next_client' => $advanced ? 1 : 0,
'cycle_completed' => $cycle_completed ? 1 : 0,
'clients_total' => count( $client_ids ),
'stage_details' => $stage_result,
'errors' => $errors
] );
exit;
}
static private function run_clients_bundle_stage_for_client( $stage, $client, $api, $sync_date )
{
$stage = trim( (string) $stage );
$sync_date = date( 'Y-m-d', strtotime( $sync_date ) );
$client_id = (int) ( $client['id'] ?? 0 );
if ( $client_id <= 0 )
{
return [
'stage' => $stage,
'count' => 0,
'errors' => [ 'Brak poprawnego klienta do przetworzenia.' ]
];
}
if ( $stage === 'campaigns' )
{
$phase = self::get_sync_phase_for_client_date( 'campaigns', $sync_date, $client_id );
if ( $phase === 'done' )
{
return [
'stage' => 'campaigns',
'count' => 0,
'meta' => [
'sync_date' => $sync_date,
'already_done' => 1
],
'errors' => []
];
}
$sync = self::sync_campaigns_for_client( $client, $api, $sync_date, true );
$errors = (array) ( $sync['errors'] ?? [] );
if ( empty( $errors ) )
{
self::mark_sync_phase( 'campaigns', $sync_date, $client_id, 'done' );
}
else
{
self::mark_sync_phase( 'campaigns', $sync_date, $client_id, 'pending', implode( '; ', $errors ) );
}
return [
'stage' => 'campaigns',
'count' => (int) ( $sync['processed_records'] ?? 0 ),
'meta' => [
'sync_date' => $sync_date,
'already_done' => 0,
'ad_groups_synced' => (int) ( $sync['ad_groups_synced'] ?? 0 ),
'search_terms_synced' => (int) ( $sync['search_terms_synced'] ?? 0 ),
'keywords_synced' => (int) ( $sync['keywords_synced'] ?? 0 ),
'negative_keywords_synced' => (int) ( $sync['negative_keywords_synced'] ?? 0 ),
'alerts_synced' => (int) ( $sync['alerts_synced'] ?? 0 )
],
'errors' => $errors
];
}
if ( $stage === 'products' )
{
$phase = self::get_sync_phase_for_client_date( 'products', $sync_date, $client_id );
if ( $phase === 'done' )
{
return [
'stage' => 'products',
'count' => 0,
'meta' => [
'sync_date' => $sync_date,
'already_done' => 1
],
'errors' => []
];
}
$sync = self::sync_products_fetch_for_client( $client, $api, $sync_date );
$history_30 = (int) self::aggregate_products_history_30_for_client( $client_id, $sync_date );
$temp_rows = (int) self::rebuild_products_temp_for_client( $client_id );
$errors = (array) ( $sync['errors'] ?? [] );
if ( empty( $errors ) )
{
self::mark_sync_phase( 'products', $sync_date, $client_id, 'done' );
}
else
{
self::mark_sync_phase( 'products', $sync_date, $client_id, 'pending', implode( '; ', $errors ) );
}
return [
'stage' => 'products',
'count' => (int) ( $sync['processed_products'] ?? 0 ),
'meta' => [
'sync_date' => $sync_date,
'already_done' => 0,
'skipped' => (int) ( $sync['skipped'] ?? 0 ),
'history_30_products' => $history_30,
'products_temp_rows' => $temp_rows
],
'errors' => $errors
];
}
if ( $stage === 'merchant' )
{
$merchant_account_id = trim( (string) ( $client['google_merchant_account_id'] ?? '' ) );
if ( $merchant_account_id === '' )
{
return [
'stage' => 'merchant',
'count' => 0,
'meta' => [ 'sync_date' => $sync_date, 'skipped' => 1, 'reason' => 'Klient nie ma Merchant Account ID.' ],
'errors' => []
];
}
$campaigns_db_map = self::build_campaigns_db_map_for_client( $client_id );
if ( empty( $campaigns_db_map ) )
{
return [
'stage' => 'merchant',
'count' => 0,
'meta' => [ 'sync_date' => $sync_date, 'skipped' => 1, 'reason' => 'Brak kampanii klienta w bazie.' ],
'errors' => []
];
}
$ad_group_db_map = self::build_ad_group_db_map_from_db( $campaigns_db_map );
$sync = self::sync_product_campaign_alerts_for_client(
$client,
$campaigns_db_map,
$ad_group_db_map,
(string) ( $client['google_ads_customer_id'] ?? '' ),
$api,
$sync_date,
[
'run_missing_mapping_alerts' => false,
'run_merchant_validation_alerts' => true
]
);
return [
'stage' => 'merchant',
'count' => (int) ( $sync['count'] ?? 0 ),
'meta' => [ 'sync_date' => $sync_date ],
'errors' => (array) ( $sync['errors'] ?? [] )
];
}
return [
'stage' => $stage,
'count' => 0,
'errors' => [ 'Nieznany etap crona zbiorczego: ' . $stage ]
];
}
static private function get_sync_phase_for_client_date( $pipeline, $sync_date, $client_id )
{
global $mdb;
$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 = [];
}
$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 count_pending_campaign_dates_for_client( $client_id )
{
global $mdb;
return (int) $mdb -> query(
"SELECT COUNT(*)
FROM cron_sync_status
WHERE pipeline = 'campaigns'
AND client_id = :client_id
AND phase = 'pending'
AND sync_date >= DATE_SUB(CURDATE(), INTERVAL 90 DAY)",
[ ':client_id' => (int) $client_id ]
) -> fetchColumn();
}
static private function sync_campaigns_for_client( $client, $api, $as_of_date = null, $sync_details = true )
{
global $mdb;
$as_of_date = $as_of_date ? date( 'Y-m-d', strtotime( $as_of_date ) ) : date( 'Y-m-d' );
$sync_details = (bool) $sync_details;
$processed = 0;
$errors = [];
$customer_id = $client['google_ads_customer_id'];
$campaigns_db_map = [];
$campaigns_30 = $api -> get_campaigns_30_days( $customer_id, $as_of_date );
if ( $campaigns_30 === false )
{
$last_err = \services\GoogleAdsApi::get_setting( 'google_ads_last_error' );
$errors[] = 'Blad API dla klienta ' . $client['name'] . ' (ID: ' . $customer_id . '): ' . $last_err;
return [
'processed_records' => 0,
'ad_groups_synced' => 0,
'search_terms_synced' => 0,
'keywords_synced' => 0,
'negative_keywords_synced' => 0,
'alerts_synced' => 0,
'errors' => $errors
];
}
if ( !is_array( $campaigns_30 ) )
{
$campaigns_30 = [];
}
$campaigns_all_time = $api -> get_campaigns_all_time( $customer_id, $as_of_date );
if ( $campaigns_all_time === false )
{
$last_err = \services\GoogleAdsApi::get_setting( 'google_ads_last_error' );
$errors[] = 'Blad pobierania danych all time dla klienta ' . $client['name'] . ' (ID: ' . $customer_id . '): ' . $last_err;
return [
'processed_records' => 0,
'ad_groups_synced' => 0,
'search_terms_synced' => 0,
'keywords_synced' => 0,
'negative_keywords_synced' => 0,
'alerts_synced' => 0,
'errors' => $errors
];
}
$all_time_map = [];
$all_time_totals = [
'cost' => 0.0,
'conversion_value' => 0.0,
];
if ( is_array( $campaigns_all_time ) )
{
foreach ( $campaigns_all_time as $cat )
{
$all_time_map[ (string) ( $cat['campaign_id'] ?? '' ) ] = (float) ( $cat['roas_all_time'] ?? 0 );
$all_time_totals['cost'] += (float) ( $cat['cost_all_time'] ?? 0 );
$all_time_totals['conversion_value'] += (float) ( $cat['conversion_value_all_time'] ?? 0 );
}
}
$account_30_totals = [
'budget' => 0.0,
'money_spent' => 0.0,
'conversion_value' => 0.0,
];
foreach ( $campaigns_30 as $campaign )
{
$external_campaign_id = isset( $campaign['campaign_id'] ) ? (string) $campaign['campaign_id'] : '';
if ( $external_campaign_id === '' )
{
continue;
}
$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 );
if ( !$mdb -> count( 'campaigns', [ 'AND' => [
'client_id' => $client['id'],
'campaign_id' => $external_campaign_id
] ] ) )
{
$mdb -> insert( 'campaigns', [
'client_id' => $client['id'],
'campaign_id' => $external_campaign_id,
'campaign_name' => $campaign['campaign_name'],
'advertising_channel_type' => $advertising_channel_type !== '' ? $advertising_channel_type : null
] );
$db_campaign_id = $mdb -> id();
}
else
{
$db_campaign_id = $mdb -> get( 'campaigns', 'id', [ 'AND' => [
'client_id' => $client['id'],
'campaign_id' => $external_campaign_id
] ] );
$mdb -> update( 'campaigns', [
'campaign_name' => $campaign['campaign_name'],
'advertising_channel_type' => $advertising_channel_type !== '' ? $advertising_channel_type : null
], [ 'id' => $db_campaign_id ] );
}
$bidding_strategy = self::format_bidding_strategy(
$campaign['bidding_strategy'],
$campaign['target_roas'] ?? 0
);
$history_data = [
'roas_30_days' => $campaign['roas_30_days'],
'roas_all_time' => $all_time_map[ $external_campaign_id ] ?? 0,
'budget' => $campaign['budget'],
'money_spent' => $campaign['money_spent'],
'conversion_value' => $campaign['conversion_value'],
'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 ] = (int) $db_campaign_id;
$processed++;
}
$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;
if ( !$mdb -> count( 'campaigns', [ 'AND' => [
'client_id' => $client['id'],
'campaign_id' => 0
] ] ) )
{
$mdb -> insert( 'campaigns', [
'client_id' => $client['id'],
'campaign_id' => 0,
'campaign_name' => '--- konto ---',
'advertising_channel_type' => null
] );
$db_account_campaign_id = $mdb -> id();
}
else
{
$db_account_campaign_id = $mdb -> get( 'campaigns', 'id', [ 'AND' => [
'client_id' => $client['id'],
'campaign_id' => 0
] ] );
$mdb -> update( 'campaigns', [
'campaign_name' => '--- konto ---',
'advertising_channel_type' => null
], [ 'id' => $db_account_campaign_id ] );
}
$account_history_data = [
'roas_30_days' => $account_roas_30,
'roas_all_time' => $account_roas_all_time,
'budget' => $account_30_totals['budget'],
'money_spent' => $account_30_totals['money_spent'],
'conversion_value' => $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++;
if ( !$sync_details )
{
// Daty historyczne: buduj ad_group_db_map z bazy i pobierz search terms za te date
$ad_group_db_map = self::build_ad_group_db_map_from_db( $campaigns_db_map );
$search_terms_daily = self::sync_campaign_search_terms_daily( $campaigns_db_map, $ad_group_db_map, $customer_id, $api, $as_of_date );
$errors = array_merge( $errors, $search_terms_daily['errors'] );
return [
'processed_records' => $processed,
'ad_groups_synced' => 0,
'search_terms_synced' => (int) $search_terms_daily['count'],
'keywords_synced' => 0,
'negative_keywords_synced' => 0,
'alerts_synced' => 0,
'errors' => $errors
];
}
// Dzisiejsza data: najpierw sync ad_groups (DELETE + INSERT), potem search terms daily ze swiezym mapem
$ad_groups_sync = self::sync_campaign_ad_groups_for_client( $campaigns_db_map, $customer_id, $api, $as_of_date );
$errors = array_merge( $errors, $ad_groups_sync['errors'] );
$search_terms_daily = self::sync_campaign_search_terms_daily( $campaigns_db_map, $ad_groups_sync['ad_group_map'], $customer_id, $api, $as_of_date );
$errors = array_merge( $errors, $search_terms_daily['errors'] );
$aggregate_count = self::aggregate_campaign_search_terms_for_client( (int) $client['id'], $as_of_date );
$keywords_sync = self::sync_campaign_keywords_for_client( $campaigns_db_map, $ad_groups_sync['ad_group_map'], $customer_id, $api, $as_of_date );
$negative_keywords_sync = self::sync_campaign_negative_keywords_for_client( $campaigns_db_map, $ad_groups_sync['ad_group_map'], $customer_id, $api, $as_of_date );
$alerts_sync = self::sync_product_campaign_alerts_for_client(
$client,
$campaigns_db_map,
$ad_groups_sync['ad_group_map'],
$customer_id,
$api,
$as_of_date,
[
'run_missing_mapping_alerts' => true,
'run_merchant_validation_alerts' => false
]
);
$errors = array_merge( $errors, $keywords_sync['errors'], $negative_keywords_sync['errors'], $alerts_sync['errors'] );
return [
'processed_records' => $processed,
'ad_groups_synced' => (int) $ad_groups_sync['count'],
'search_terms_synced' => (int) $search_terms_daily['count'] + $aggregate_count,
'keywords_synced' => (int) $keywords_sync['count'],
'negative_keywords_synced' => (int) $negative_keywords_sync['count'],
'alerts_synced' => (int) ( $alerts_sync['count'] ?? 0 ),
'errors' => $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
] ] );
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++;
}
}
// Usun ad_groups ktore nie pojawiaja sie juz w API (zachowaj PMax placeholder ad_group_id=0).
// Gdy API zwroci 0 aktywnych grup, usuwamy wszystkie historyczne grupy dla kampanii.
if ( !empty( $campaign_db_ids ) )
{
$delete_where = [
'campaign_id' => $campaign_db_ids,
'ad_group_id[!]' => 0
];
if ( !empty( $seen_db_ids ) )
{
$delete_where['id[!]'] = $seen_db_ids;
}
$mdb -> delete( 'campaign_ad_groups', $delete_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'] ?? '' ) ) );
$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,
'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 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 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 ];
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 '<!doctype html><html lang="pl"><head><meta charset="utf-8"><title>CRON debug</title>';
echo '<style>body{font-family:Arial,sans-serif;background:#f6f8fb;color:#1f2937;margin:0;padding:24px;}';
echo '.card{background:#fff;border:1px solid #dbe3ee;border-radius:10px;padding:16px;margin-bottom:14px;}';
echo 'h1{font-size:20px;margin:0 0 12px 0;} .meta{display:grid;grid-template-columns:180px 1fr;gap:8px 12px;font-size:14px;}';
echo 'code,pre{font-family:Consolas,Monaco,monospace;} pre{background:#0b1020;color:#e5e7eb;padding:14px;border-radius:8px;overflow:auto;font-size:12px;}';
echo '.ok{color:#0f766e;font-weight:700;} .err{color:#b91c1c;font-weight:700;}</style></head><body>';
echo '<div class="card"><h1>CRON debug</h1><div class="meta">';
echo '<div>Wynik</div><div>' . $result_text . '</div>';
echo '<div>Klient ID</div><div>' . htmlspecialchars( $active_client_id, ENT_QUOTES, 'UTF-8' ) . '</div>';
echo '<div>Data aktywna</div><div>' . $active_date . '</div>';
echo '<div>Bledy</div><div class="' . ( $errors_count > 0 ? 'err' : 'ok' ) . '">' . $errors_count . '</div>';
echo '</div></div>';
echo '<div class="card"><pre>' . htmlspecialchars( $pretty, ENT_QUOTES, 'UTF-8' ) . '</pre></div>';
echo '</body></html>';
exit;
}
header( 'Content-Type: application/json; charset=utf-8' );
echo json_encode( $payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES );
exit;
}
static private function clients_has_column( $column_name )
{
global $mdb;
static $cache = [];
$column_name = trim( (string) $column_name );
if ( $column_name === '' )
{
return false;
}
if ( isset( $cache[ $column_name ] ) )
{
return (bool) $cache[ $column_name ];
}
$exists = false;
try
{
$stmt = $mdb -> pdo -> prepare( 'SHOW COLUMNS FROM clients LIKE :column_name' );
if ( $stmt )
{
$stmt -> bindValue( ':column_name', $column_name, \PDO::PARAM_STR );
if ( $stmt -> execute() )
{
$exists = (bool) $stmt -> fetch( \PDO::FETCH_ASSOC );
}
}
}
catch ( \Throwable $e )
{
$exists = false;
}
$cache[ $column_name ] = $exists ? 1 : 0;
return $exists;
}
static private function sql_clients_not_deleted( $alias = '' )
{
$alias = trim( (string) $alias );
$prefix = $alias !== '' ? $alias . '.' : '';
if ( self::clients_has_column( 'deleted' ) )
{
return 'COALESCE(' . $prefix . 'deleted, 0) = 0';
}
return '1=1';
}
static private function sql_clients_deleted( $alias = '' )
{
$alias = trim( (string) $alias );
$prefix = $alias !== '' ? $alias . '.' : '';
if ( self::clients_has_column( 'deleted' ) )
{
return 'COALESCE(' . $prefix . 'deleted, 0) = 1';
}
return '0=1';
}
static private function get_setting_value( $setting_key, $default = null )
{
global $mdb;
$value = $mdb -> get( 'settings', 'setting_value', [ 'setting_key' => $setting_key ] );
if ( $value === null || $value === false )
{
return $default;
}
return $value;
}
static private function set_setting_value( $setting_key, $setting_value )
{
global $mdb;
if ( $mdb -> count( 'settings', [ 'setting_key' => $setting_key ] ) )
{
$mdb -> update( 'settings', [ 'setting_value' => $setting_value ], [ 'setting_key' => $setting_key ] );
return;
}
$mdb -> insert( 'settings', [
'setting_key' => $setting_key,
'setting_value' => $setting_value
] );
}
static private function touch_cron_invocation( $action_name )
{
$now_timestamp = time();
$now = date( 'Y-m-d H:i:s', $now_timestamp );
$last_action_invoked_at = self::get_setting_value( 'cron_last_invoked_' . $action_name . '_at', '' );
$last_action_timestamp = strtotime( (string) $last_action_invoked_at );
if ( $last_action_timestamp )
{
$interval_seconds = $now_timestamp - $last_action_timestamp;
// Pomijamy skrajne wartosci (np. pierwsze uruchomienie po dluzszej przerwie).
if ( $interval_seconds >= 1 && $interval_seconds <= 21600 )
{
$avg_key = 'cron_avg_interval_' . $action_name . '_sec';
$samples_key = 'cron_avg_interval_' . $action_name . '_samples';
$avg_interval = (float) self::get_setting_value( $avg_key, 0 );
$samples = (int) self::get_setting_value( $samples_key, 0 );
$weight = min( 99, max( 0, $samples ) );
if ( $weight <= 0 || $avg_interval <= 0 )
{
$new_avg = (float) $interval_seconds;
$new_samples = 1;
}
else
{
$new_avg = ( ( $avg_interval * $weight ) + $interval_seconds ) / ( $weight + 1 );
$new_samples = min( 100, $weight + 1 );
}
self::set_setting_value( $avg_key, (string) round( $new_avg, 2 ) );
self::set_setting_value( $samples_key, (string) $new_samples );
self::set_setting_value( 'cron_last_interval_' . $action_name . '_sec', (string) (int) $interval_seconds );
}
}
self::set_setting_value( 'cron_last_invoked_at', $now );
self::set_setting_value( 'cron_last_invoked_' . $action_name . '_at', $now );
}
static private function format_bidding_strategy( $strategy_type, $target_roas = 0 )
{
$map = [
'MAXIMIZE_CONVERSIONS' => 'Maksymalizacja liczby konwersji',
'MAXIMIZE_CONVERSION_VALUE' => 'Maksymalizacja wartosci konwersji',
'TARGET_ROAS' => 'Docelowy ROAS',
'TARGET_CPA' => 'Docelowy CPA',
'MANUAL_CPC' => 'Reczny CPC',
'MANUAL_CPM' => 'Reczny CPM',
'TARGET_IMPRESSION_SHARE' => 'Docelowy udzial w wyswietleniach',
];
$label = $map[ $strategy_type ] ?? $strategy_type ?? 'brak';
if ( $target_roas > 0 )
{
$label .= ' | docelowy ROAS: ' . round( $target_roas * 100 ) . '%';
}
return $label;
}
// ===========================
// FRAZY - history 30
// ===========================
static public function cron_phrase_history_30_save( $phrase_id, $date_from, $date_to )
{
global $mdb;
$data = $mdb -> query( 'SELECT * FROM phrases_history WHERE phrase_id = ' . $phrase_id . ' AND date_add >= \'' . $date_from . '\' AND date_add <= \'' . $date_to . '\' ORDER BY date_add ASC' ) -> fetchAll( \PDO::FETCH_ASSOC );
// Inicjalizacja tablic do przechowywania danych
$phrases_data = [];
// Grupowanie danych wedug fraz
foreach ( $data as $entry )
{
$phrase_id = $entry['phrase_id'];
if ( !isset( $phrases_data[$phrase_id] ) )
{
$phrases_data[$phrase_id] = [
'impressions' => 0,
'clicks' => 0,
'cost' => 0.0,
'conversions' => 0,
'conversions_value' => 0.0,
'roas' => 0,
'days_counted' => []
];
}
// Sumowanie danych wedug fraz
$phrases_data[$phrase_id]['impressions'] += $entry['impressions'];
$phrases_data[$phrase_id]['clicks'] += $entry['clicks'];
$phrases_data[$phrase_id]['cost'] += $entry['cost'];
$phrases_data[$phrase_id]['conversions'] += $entry['conversions'];
$phrases_data[$phrase_id]['conversions_value'] += $entry['conversions_value'];
$phrases_data[$phrase_id]['days_counted'][] = $entry['date_add'];
}
foreach ( $phrases_data as $phrase )
{
$day_count = count( $phrase['days_counted'] );
$impressions = $phrase['impressions'] / $day_count;
$clicks = $phrase['clicks'] / $day_count;
$cost = $phrase['cost'] / $day_count;
$conversions = $phrase['conversions'] / $day_count;
$conversions_value = $phrase['conversions_value'] / $day_count;
$roas = ( $conversions_value > 0 and $cost ) ? round( $conversions_value / $cost, 2 ) * 100 : 0;
if ( $day_count > 14 )
{
if ( $mdb -> count( 'phrases_history_30', [ 'AND' => [ 'phrase_id' => $phrase_id, 'date_add' => $date_to ] ] ) > 0 )
{
$mdb -> update( 'phrases_history_30', [
'impressions' => $impressions,
'clicks' => $clicks,
'cost' => $cost,
'conversions' => $conversions,
'conversions_value' => $conversions_value,
'roas' => $roas
], [ 'AND' => [ 'phrase_id' => $phrase_id, 'date_add' => $date_to ] ] );
}
else
{
$mdb -> insert( 'phrases_history_30', [
'phrase_id' => $phrase_id,
'impressions' => $impressions,
'clicks' => $clicks,
'cost' => $cost,
'conversions' => $conversions,
'conversions_value' => $conversions_value,
'roas' => $roas,
'date_add' => $date_to
] );
}
}
}
}
}