feat: add campaign alerts feature with alerts management and UI integration

- Introduced a new `CampaignAlerts` class for handling alerts logic.
- Added database migration for `campaign_alerts` table creation.
- Implemented methods for fetching, marking, and deleting alerts in the `CampaignAlerts` factory class.
- Created a new view for displaying campaign alerts with filtering options.
- Updated the main client view to include a badge for the number of alerts.
- Enhanced sync functionality to support campaigns and products separately.
- Adjusted styles for alert badges in the UI.
This commit is contained in:
2026-02-20 01:33:53 +01:00
parent 2c331fda07
commit 0024a25bfb
17 changed files with 1394 additions and 31 deletions

View File

@@ -15,7 +15,9 @@
"Bash(git push:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(php:*)"
"Bash(php:*)",
"WebFetch(domain:adspro.projectpro.pl)",
"mcp__ide__getDiagnostics"
]
}
}

View File

@@ -66,6 +66,11 @@ class Tpl
$this -> vars[ $name ] = $value;
}
public function __isset( $name )
{
return isset( $this -> vars[ $name ] );
}
public function __get( $name )
{
return $this -> vars[ $name ];

View File

@@ -0,0 +1,34 @@
<?php
namespace controls;
class CampaignAlerts
{
static public function main_view()
{
$client_id = (int) \S::get( 'client_id' );
$page = max( 1, (int) \S::get( 'page' ) );
$per_page = 15;
$offset = ( $page - 1 ) * $per_page;
\factory\CampaignAlerts::mark_all_seen();
\factory\CampaignAlerts::delete_old_alerts( 30 );
$total = \factory\CampaignAlerts::get_alerts_count( $client_id );
$total_pages = max( 1, (int) ceil( $total / $per_page ) );
if ( $page > $total_pages )
{
$page = $total_pages;
$offset = ( $page - 1 ) * $per_page;
}
return \Tpl::view( 'campaign_alerts/main_view', [
'clients' => \factory\CampaignAlerts::get_clients(),
'alerts' => \factory\CampaignAlerts::get_alerts( $client_id, $per_page, $offset ),
'selected_client_id' => $client_id,
'page' => $page,
'total_pages' => $total_pages,
'total' => $total
] );
}
}

View File

@@ -108,7 +108,8 @@ class Clients
{
global $mdb;
$id = (int) \S::get( 'id' );
$id = (int) \S::get( 'id' );
$pipeline = \S::get( 'pipeline' );
if ( !$id )
{
@@ -116,9 +117,16 @@ class Clients
exit;
}
$mdb -> delete( 'cron_sync_status', [ 'client_id' => $id ] );
$where = [ 'client_id' => $id ];
echo json_encode( [ 'success' => true ] );
if ( in_array( $pipeline, [ 'campaigns', 'products' ] ) )
{
$where['pipeline'] = $pipeline;
}
$mdb -> delete( 'cron_sync_status', $where );
echo json_encode( [ 'success' => true, 'pipeline' => $pipeline ?: 'all' ] );
exit;
}
}

View File

@@ -1955,6 +1955,7 @@ class Cron
'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;
@@ -2049,6 +2050,7 @@ class Cron
$search_terms_synced_total = 0;
$keywords_synced_total = 0;
$negative_keywords_synced_total = 0;
$alerts_synced_total = 0;
foreach ( $dates_batch as $active_date )
{
@@ -2060,6 +2062,7 @@ class Cron
$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'] ) )
@@ -2102,6 +2105,7 @@ class Cron
'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
] );
@@ -2131,6 +2135,7 @@ class Cron
'search_terms_synced' => 0,
'keywords_synced' => 0,
'negative_keywords_synced' => 0,
'alerts_synced' => 0,
'errors' => $errors
];
}
@@ -2152,6 +2157,7 @@ class Cron
'search_terms_synced' => 0,
'keywords_synced' => 0,
'negative_keywords_synced' => 0,
'alerts_synced' => 0,
'errors' => $errors
];
}
@@ -2317,33 +2323,470 @@ class Cron
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' => 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 );
$search_terms_sync = self::sync_campaign_search_terms_for_client( $campaigns_db_map, $ad_groups_sync['ad_group_map'], $customer_id, $api, $as_of_date );
$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 );
$errors = array_merge( $errors, $ad_groups_sync['errors'], $search_terms_sync['errors'], $keywords_sync['errors'], $negative_keywords_sync['errors'] );
$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_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 )
{
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' => [] ];
}
$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 ( $merchant_account_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 ) )
{
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 ) )
{
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 = $api -> get_shopping_ad_group_offer_ids( $customer_id );
if ( $ad_groups_offer_ids === false )
{
$ad_groups_offer_ids = $api -> get_shopping_ad_group_offer_ids_from_performance( $customer_id );
}
if ( $ad_groups_offer_ids === false )
{
$ad_groups_offer_ids = self::get_shopping_ad_group_offer_ids_from_history( $client_id, array_keys( $shopping_campaign_names_by_db_id ) );
}
if ( !is_array( $ad_groups_offer_ids ) || empty( $ad_groups_offer_ids ) )
{
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 trim( (string) $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
];
foreach ( $offer_ids as $offer_id )
{
$offer_ids_to_verify[ $offer_id ] = true;
}
}
$merchant_items_map = [];
if ( !empty( $offer_ids_to_verify ) )
{
$merchant_items_map = $api -> get_merchant_products_for_offer_ids( $merchant_account_id, array_keys( $offer_ids_to_verify ) );
if ( $merchant_items_map === false )
{
$merchant_items_map = [];
}
}
if ( !is_array( $merchant_items_map ) )
{
$merchant_items_map = [];
}
$inserted = 0;
$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;
};
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 = [];
foreach ( $offer_ids as $offer_id )
{
if ( isset( $merchant_items_map[ $offer_id ] ) )
{
$active_offer_count++;
}
else
{
$orphaned_offer_ids[] = $offer_id;
}
}
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 )
{
$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'
]
) )
{
$inserted++;
}
}
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'
]
) )
{
$inserted++;
}
}
}
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 ),
'source' => 'cron_campaigns_sync_missing_mapping'
]
) )
{
$inserted++;
}
}
return [
'count' => $inserted,
'errors' => []
];
}
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 )
{
global $mdb;
@@ -2402,10 +2845,9 @@ class Cron
$map_all_time[ $campaign_external_id . '|' . $ad_group_external_id ] = $row;
}
$mdb -> delete( 'campaign_ad_groups', [ 'campaign_id' => $campaign_db_ids ] );
$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 )
@@ -2429,9 +2871,7 @@ class Cron
$ad_group_name = 'Ad group #' . $ad_group_external_id;
}
$mdb -> insert( 'campaign_ad_groups', [
'campaign_id' => $db_campaign_id,
'ad_group_id' => (int) $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 ),
@@ -2446,16 +2886,44 @@ class Cron
'conversion_value_all_time' => (float) ( $row_all_time['conversion_value'] ?? 0 ),
'roas_all_time' => (float) ( $row_all_time['roas'] ?? 0 ),
'date_sync' => $date_sync
] );
];
$db_ad_group_id = (int) $mdb -> id();
// 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)
if ( !empty( $seen_db_ids ) && !empty( $campaign_db_ids ) )
{
$mdb -> delete( 'campaign_ad_groups', [ 'AND' => [
'campaign_id' => $campaign_db_ids,
'id[!]' => $seen_db_ids,
'ad_group_id[!]' => 0
] ] );
}
return [ 'count' => $count, 'ad_group_map' => $ad_group_db_map, 'errors' => [] ];
}
@@ -2587,6 +3055,243 @@ class Cron
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;

View File

@@ -0,0 +1,112 @@
<?php
namespace factory;
class CampaignAlerts
{
static public function get_alerts_count( $client_id = 0 )
{
global $mdb;
$where = [];
$client_id = (int) $client_id;
if ( $client_id > 0 )
{
$where['client_id'] = $client_id;
}
return (int) $mdb -> count( 'campaign_alerts', $where );
}
static public function get_unseen_count()
{
global $mdb;
return (int) $mdb -> count( 'campaign_alerts', [ 'unseen' => 1 ] );
}
static public function mark_all_seen()
{
global $mdb;
$mdb -> update( 'campaign_alerts', [ 'unseen' => 0 ], [ 'unseen' => 1 ] );
}
static public function get_clients()
{
global $mdb;
return $mdb -> select( 'clients', [ 'id', 'name' ], [
'deleted' => 0,
'ORDER' => [ 'name' => 'ASC' ]
] );
}
static public function get_alerts( $client_id = 0, $limit = 15, $offset = 0 )
{
global $mdb;
$client_id = (int) $client_id;
$limit = max( 1, (int) $limit );
$offset = max( 0, (int) $offset );
$sql = 'SELECT
ca.id,
ca.client_id,
ca.campaign_id,
ca.campaign_external_id,
ca.ad_group_id,
ca.ad_group_external_id,
ca.alert_type,
ca.message,
ca.meta_json,
ca.date_detected,
ca.date_add AS date_created,
cl.name AS client_name,
c.campaign_name,
ag.ad_group_name
FROM campaign_alerts AS ca
LEFT JOIN clients AS cl ON cl.id = ca.client_id
LEFT JOIN campaigns AS c ON c.id = ca.campaign_id
LEFT JOIN campaign_ad_groups AS ag ON ag.id = ca.ad_group_id';
if ( $client_id > 0 )
{
$sql .= ' WHERE ca.client_id = :client_id';
}
$sql .= ' ORDER BY ca.date_detected DESC, ca.id DESC LIMIT ' . $offset . ', ' . $limit;
$stmt = $mdb -> pdo -> prepare( $sql );
if ( !$stmt )
{
return [];
}
if ( $client_id > 0 )
{
$stmt -> bindValue( ':client_id', $client_id, \PDO::PARAM_INT );
}
$ok = $stmt -> execute();
if ( !$ok )
{
return [];
}
$rows = $stmt -> fetchAll( \PDO::FETCH_ASSOC );
return is_array( $rows ) ? $rows : [];
}
static public function delete_old_alerts( $days = 30 )
{
global $mdb;
$mdb -> delete( 'campaign_alerts', [
'date_detected[<]' => date( 'Y-m-d', strtotime( '-' . (int) $days . ' days' ) )
] );
}
}

View File

@@ -2511,6 +2511,249 @@ class GoogleAdsApi
return $this -> aggregate_ad_groups( $results );
}
public function get_shopping_ad_group_offer_ids( $customer_id )
{
$query_variants = [
"SELECT campaign.id, campaign.name, ad_group.id, ad_group.name, ad_group_criterion.listing_group.case_value FROM ad_group_criterion WHERE campaign.status != 'REMOVED' AND ad_group.status != 'REMOVED' AND campaign.advertising_channel_type = 'SHOPPING' AND ad_group_criterion.type = 'LISTING_GROUP'",
"SELECT campaign.id, campaign.name, ad_group.id, ad_group.name, ad_group_criterion.listing_group.case_value.product_item FROM ad_group_criterion WHERE campaign.status != 'REMOVED' AND ad_group.status != 'REMOVED' AND campaign.advertising_channel_type = 'SHOPPING' AND ad_group_criterion.type = 'LISTING_GROUP'",
"SELECT campaign.id, campaign.name, ad_group.id, ad_group.name, ad_group_criterion.listing_group.case_value.product_item.id FROM ad_group_criterion WHERE campaign.status != 'REMOVED' AND ad_group.status != 'REMOVED' AND campaign.advertising_channel_type = 'SHOPPING' AND ad_group_criterion.type = 'LISTING_GROUP'",
"SELECT campaign.id, campaign.name, ad_group.id, ad_group.name, ad_group_criterion.listing_group.case_value.product_item.value FROM ad_group_criterion WHERE campaign.status != 'REMOVED' AND ad_group.status != 'REMOVED' AND campaign.advertising_channel_type = 'SHOPPING' AND ad_group_criterion.type = 'LISTING_GROUP'",
"SELECT campaign.id, campaign.name, ad_group.id, ad_group.name, product_group_view.resource_name, ad_group_criterion.listing_group.case_value FROM product_group_view WHERE campaign.status != 'REMOVED' AND ad_group.status != 'REMOVED' AND campaign.advertising_channel_type = 'SHOPPING'",
"SELECT campaign.id, campaign.name, ad_group.id, ad_group.name, product_group_view.resource_name, ad_group_criterion.listing_group.case_value.product_item FROM product_group_view WHERE campaign.status != 'REMOVED' AND ad_group.status != 'REMOVED' AND campaign.advertising_channel_type = 'SHOPPING'"
];
$results = false;
$last_error = '';
$variant_errors = [];
foreach ( $query_variants as $index => $gaql )
{
$tmp = $this -> search_stream( $customer_id, $gaql );
if ( is_array( $tmp ) )
{
$results = $tmp;
break;
}
$last_error = (string) self::get_setting( 'google_ads_last_error' );
$variant_errors[] = 'V' . ( $index + 1 ) . ': ' . substr( $last_error, 0, 350 );
}
if ( !is_array( $results ) )
{
$diag = implode( ' || ', array_filter( $variant_errors ) );
$error_to_save = $diag !== '' ? $diag : $last_error;
if ( $error_to_save !== '' )
{
self::set_setting( 'google_ads_last_error', $error_to_save );
}
return false;
}
if ( !is_array( $results ) )
{
return [];
}
$collect_scalar_values = function( $value ) use ( &$collect_scalar_values )
{
$collected = [];
if ( is_array( $value ) )
{
foreach ( $value as $nested )
{
$collected = array_merge( $collected, $collect_scalar_values( $nested ) );
}
return $collected;
}
if ( is_scalar( $value ) )
{
$tmp = trim( (string) $value );
if ( $tmp !== '' )
{
$collected[] = $tmp;
}
}
return $collected;
};
$extract_offer_ids = function( $row ) use ( $collect_scalar_values )
{
$candidates = [];
$case_value = $row['productGroupView']['caseValue']
?? $row['product_group_view']['case_value']
?? $row['adGroupCriterion']['listingGroup']['caseValue']
?? $row['ad_group_criterion']['listing_group']['case_value']
?? [];
if ( is_array( $case_value ) )
{
if ( isset( $case_value['productItem'] ) )
{
$candidates = array_merge( $candidates, $collect_scalar_values( $case_value['productItem'] ) );
}
if ( isset( $case_value['product_item'] ) )
{
$candidates = array_merge( $candidates, $collect_scalar_values( $case_value['product_item'] ) );
}
if ( isset( $case_value['productItemId'] ) )
{
$candidates[] = trim( (string) $case_value['productItemId'] );
}
if ( isset( $case_value['product_item_id'] ) )
{
$candidates[] = trim( (string) $case_value['product_item_id'] );
}
}
$direct_candidates = [
$row['adGroupCriterion']['listingGroup']['caseValue']['productItem'] ?? null,
$row['ad_group_criterion']['listing_group']['case_value']['product_item'] ?? null,
$row['adGroupCriterion']['listingGroup']['caseValue']['productItemId'] ?? null,
$row['ad_group_criterion']['listing_group']['case_value']['product_item_id'] ?? null,
$row['productGroupView']['caseValue']['productItem'] ?? null,
$row['product_group_view']['case_value']['product_item'] ?? null,
];
foreach ( $direct_candidates as $dc )
{
if ( $dc === null )
{
continue;
}
$candidates = array_merge( $candidates, $collect_scalar_values( $dc ) );
}
$candidates = array_values( array_unique( array_filter( array_map( function( $item )
{
return trim( (string) $item );
}, $candidates ) ) ) );
return $candidates;
};
$scopes = [];
foreach ( $results as $row )
{
$campaign_id = (int) ( $row['campaign']['id'] ?? $row['campaignId'] ?? 0 );
$ad_group_id = (int) ( $row['adGroup']['id'] ?? $row['ad_group']['id'] ?? $row['adGroupId'] ?? 0 );
if ( $campaign_id <= 0 || $ad_group_id <= 0 )
{
continue;
}
$offer_ids = $extract_offer_ids( $row );
if ( empty( $offer_ids ) )
{
continue;
}
$scope_key = $campaign_id . '|' . $ad_group_id;
if ( !isset( $scopes[ $scope_key ] ) )
{
$scopes[ $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['adGroup']['name'] ?? $row['ad_group']['name'] ?? '' ) ),
'offer_ids' => []
];
}
foreach ( $offer_ids as $offer_id )
{
$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 );
}
public function get_shopping_ad_group_offer_ids_from_performance( $customer_id )
{
$gaql = "SELECT "
. "campaign.id, "
. "campaign.name, "
. "campaign.status, "
. "campaign.advertising_channel_type, "
. "ad_group.id, "
. "ad_group.name, "
. "ad_group.status, "
. "segments.product_item_id, "
. "metrics.impressions "
. "FROM shopping_performance_view "
. "WHERE campaign.status != 'REMOVED' "
. "AND ad_group.status != 'REMOVED' "
. "AND campaign.advertising_channel_type = 'SHOPPING'";
$results = $this -> search_stream( $customer_id, $gaql );
if ( $results === false )
{
return false;
}
if ( !is_array( $results ) )
{
return [];
}
$scopes = [];
foreach ( $results as $row )
{
$campaign_id = (int) ( $row['campaign']['id'] ?? 0 );
$ad_group_id = (int) ( $row['adGroup']['id'] ?? 0 );
$offer_id = trim( (string) ( $row['segments']['productItemId'] ?? '' ) );
if ( $campaign_id <= 0 || $ad_group_id <= 0 || $offer_id === '' )
{
continue;
}
$scope_key = $campaign_id . '|' . $ad_group_id;
if ( !isset( $scopes[ $scope_key ] ) )
{
$scopes[ $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['adGroup']['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 );
}
public function get_search_terms_30_days( $customer_id )
{
$gaql = "SELECT "
@@ -2586,10 +2829,9 @@ class GoogleAdsApi
. "metrics.cost_micros, "
. "metrics.conversions, "
. "metrics.conversions_value "
. "FROM ad_group_criterion "
. "FROM keyword_view "
. "WHERE campaign.status != 'REMOVED' "
. "AND ad_group.status != 'REMOVED' "
. "AND ad_group_criterion.type = 'KEYWORD' "
. "AND ad_group_criterion.negative = FALSE "
. "AND campaign.advertising_channel_type = 'SEARCH' "
. "AND metrics.clicks > 0 "
@@ -2613,10 +2855,9 @@ class GoogleAdsApi
. "metrics.cost_micros, "
. "metrics.conversions, "
. "metrics.conversions_value "
. "FROM ad_group_criterion "
. "FROM keyword_view "
. "WHERE campaign.status != 'REMOVED' "
. "AND ad_group.status != 'REMOVED' "
. "AND ad_group_criterion.type = 'KEYWORD' "
. "AND ad_group_criterion.negative = FALSE "
. "AND campaign.advertising_channel_type = 'SEARCH' "
. "AND metrics.clicks > 0";
@@ -2670,6 +2911,64 @@ class GoogleAdsApi
return $this -> aggregate_campaign_search_terms( $results );
}
public function get_search_terms_for_date( $customer_id, $date )
{
$date = date( 'Y-m-d', strtotime( $date ) );
$gaql = "SELECT "
. "campaign.id, "
. "ad_group.id, "
. "ad_group.name, "
. "search_term_view.search_term, "
. "metrics.impressions, "
. "metrics.clicks, "
. "metrics.cost_micros, "
. "metrics.conversions, "
. "metrics.conversions_value "
. "FROM search_term_view "
. "WHERE campaign.status != 'REMOVED' "
. "AND ad_group.status != 'REMOVED' "
. "AND metrics.clicks > 0 "
. "AND segments.date = '{$date}'";
$results = $this -> search_stream( $customer_id, $gaql );
if ( $results === false ) return false;
$terms = $this -> aggregate_search_terms( $results );
$pmax_terms = $this -> get_pmax_search_terms_for_date( $customer_id, $date );
if ( $pmax_terms !== false && is_array( $pmax_terms ) && !empty( $pmax_terms ) )
{
$terms = array_merge( $terms, $pmax_terms );
}
return $terms;
}
private function get_pmax_search_terms_for_date( $customer_id, $date )
{
$date = date( 'Y-m-d', strtotime( $date ) );
$gaql = "SELECT "
. "campaign.id, "
. "campaign_search_term_view.search_term, "
. "metrics.impressions, "
. "metrics.clicks, "
. "metrics.cost_micros, "
. "metrics.conversions, "
. "metrics.conversions_value "
. "FROM campaign_search_term_view "
. "WHERE campaign.status != 'REMOVED' "
. "AND campaign.advertising_channel_type = 'PERFORMANCE_MAX' "
. "AND metrics.clicks > 0 "
. "AND segments.date = '{$date}'";
$results = $this -> search_stream( $customer_id, $gaql );
if ( $results === false ) return false;
return $this -> aggregate_campaign_search_terms( $results );
}
public function get_negative_keywords( $customer_id )
{
$campaign_gaql = "SELECT "

View File

@@ -15,6 +15,7 @@ class Site
{
$tpl -> user = $user;
$tpl -> current_module = $current_module;
$tpl -> campaign_alerts_count = \factory\CampaignAlerts::get_unseen_count();
if ( $alert = \S::get_session( 'alert' ) )
{
$tpl -> alert = $alert;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -529,6 +529,22 @@ body.logged {
}
}
.badge-alerts-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 6px;
margin-left: 8px;
border-radius: 50%;
font-size: 11px;
font-weight: 600;
line-height: 1;
background: $cWhite;
color: $cPrimary;
}
.sidebar-footer {
padding: 16px 20px;
border-top: 1px solid rgba($cWhite, 0.08);

View File

@@ -0,0 +1,19 @@
CREATE TABLE IF NOT EXISTS `campaign_alerts` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`client_id` int(11) NOT NULL,
`campaign_id` int(11) DEFAULT NULL,
`campaign_external_id` bigint(20) DEFAULT NULL,
`ad_group_id` int(11) DEFAULT NULL,
`ad_group_external_id` bigint(20) DEFAULT NULL,
`alert_type` varchar(120) NOT NULL,
`message` text NOT NULL,
`meta_json` text DEFAULT NULL,
`date_detected` date NOT NULL,
`date_add` datetime NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_alert_daily` (`client_id`,`campaign_external_id`,`ad_group_external_id`,`alert_type`,`date_detected`),
KEY `idx_alert_date` (`date_detected`),
KEY `idx_alert_client` (`client_id`),
KEY `idx_alert_campaign` (`campaign_id`),
KEY `idx_alert_ad_group` (`ad_group_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci;

View File

@@ -0,0 +1,26 @@
-- Migracja: tabela historii dziennych fraz wyszukiwanych
-- Opis: przejscie z modelu snapshot (30d + all-time) na dane dzienne z agregacja w bazie
CREATE TABLE IF NOT EXISTS `campaign_search_terms_history` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`campaign_id` INT(11) NOT NULL,
`ad_group_id` INT(11) NOT NULL,
`search_term` VARCHAR(255) NOT NULL,
`impressions` INT(11) NOT NULL DEFAULT 0,
`clicks` INT(11) NOT NULL DEFAULT 0,
`cost` DECIMAL(20,6) NOT NULL DEFAULT 0.000000,
`conversions` DECIMAL(20,6) NOT NULL DEFAULT 0.000000,
`conversion_value` DECIMAL(20,6) NOT NULL DEFAULT 0.000000,
`date_add` DATE NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_search_terms_history` (`campaign_id`, `ad_group_id`, `search_term`, `date_add`),
KEY `idx_search_terms_history_campaign_id` (`campaign_id`),
KEY `idx_search_terms_history_ad_group_id` (`ad_group_id`),
KEY `idx_search_terms_history_date_add` (`date_add`),
CONSTRAINT `FK_search_terms_history_campaigns`
FOREIGN KEY (`campaign_id`) REFERENCES `campaigns` (`id`)
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `FK_search_terms_history_ad_groups`
FOREIGN KEY (`ad_group_id`) REFERENCES `campaign_ad_groups` (`id`)
ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,7 @@
-- Migracja: flaga unseen dla alertow kampanii
-- Opis: nowe alerty sa unseen=1, po wejsciu na strone alertow oznaczane jako unseen=0
ALTER TABLE `campaign_alerts`
ADD COLUMN IF NOT EXISTS `unseen` TINYINT(1) NOT NULL DEFAULT 1 AFTER `meta_json`;
CREATE INDEX IF NOT EXISTS `idx_alert_unseen` ON `campaign_alerts` (`unseen`);

View File

@@ -0,0 +1,113 @@
<div class="campaigns-page">
<div class="campaigns-header">
<h2><i class="fa-solid fa-triangle-exclamation"></i> Alerty</h2>
</div>
<form method="get" action="/campaign_alerts" class="campaigns-filters" style="margin-bottom:16px;">
<div class="filter-group">
<label for="client_id"><i class="fa-solid fa-building"></i> Klient</label>
<select id="client_id" name="client_id" class="form-control" onchange="this.form.submit()">
<option value="">- wszyscy klienci -</option>
<?php foreach ( $this -> clients as $client ): ?>
<option value="<?= (int) $client['id']; ?>" <?= (int) $this -> selected_client_id === (int) $client['id'] ? 'selected' : ''; ?>>
<?= htmlspecialchars( (string) $client['name'] ); ?>
</option>
<?php endforeach; ?>
</select>
</div>
</form>
<div class="campaigns-table-wrap">
<table class="table" id="campaign_alerts_table">
<thead>
<tr>
<th>Data</th>
<th>Klient</th>
<th>Kampania</th>
<th>Grupa reklam</th>
<th>Komunikat</th>
</tr>
</thead>
<tbody>
<?php if ( empty( $this -> alerts ) ): ?>
<tr>
<td colspan="5" class="text-center">Brak alertów.</td>
</tr>
<?php else: ?>
<?php foreach ( $this -> alerts as $row ): ?>
<?php
$campaign_name = trim( (string) ( $row['campaign_name'] ?? '' ) );
if ( $campaign_name === '' )
{
$campaign_name = 'Kampania #' . (int) ( $row['campaign_external_id'] ?? 0 );
}
$ad_group_name = trim( (string) ( $row['ad_group_name'] ?? '' ) );
if ( $ad_group_name === '' )
{
$ad_group_name = 'Grupa reklam #' . (int) ( $row['ad_group_external_id'] ?? 0 );
}
$client_name = trim( (string) ( $row['client_name'] ?? '' ) );
if ( $client_name === '' )
{
$client_name = 'Klient #' . (int) ( $row['client_id'] ?? 0 );
}
?>
<tr>
<td style="white-space:nowrap"><?= htmlspecialchars( (string) ( $row['date_detected'] ?? '' ) ); ?></td>
<td><?= htmlspecialchars( $client_name ); ?></td>
<td><?= htmlspecialchars( $campaign_name ); ?></td>
<td><?= htmlspecialchars( $ad_group_name ); ?></td>
<td><?= htmlspecialchars( (string) ( $row['message'] ?? '' ) ); ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
<?php if ( (int) $this -> total_pages > 1 ): ?>
<div class="dt-layout-row" style="display:flex !important;justify-content:flex-end;">
<div class="dt-paging">
<nav>
<ul class="pagination">
<?php
$page = (int) $this -> page;
$total_pages = (int) $this -> total_pages;
$client_id = (int) $this -> selected_client_id;
$qs = $client_id > 0 ? '&client_id=' . $client_id : '';
?>
<li class="page-item <?= $page <= 1 ? 'disabled' : ''; ?>">
<a class="page-link" href="/campaign_alerts?page=<?= $page - 1; ?><?= $qs; ?>">&laquo;</a>
</li>
<?php
$start_p = max( 1, $page - 2 );
$end_p = min( $total_pages, $page + 2 );
if ( $start_p > 1 ):
?>
<li class="page-item"><a class="page-link" href="/campaign_alerts?page=1<?= $qs; ?>">1</a></li>
<?php if ( $start_p > 2 ): ?>
<li class="page-item disabled"><span class="page-link">...</span></li>
<?php endif; ?>
<?php endif; ?>
<?php for ( $i = $start_p; $i <= $end_p; $i++ ): ?>
<li class="page-item <?= $i === $page ? 'active' : ''; ?>">
<a class="page-link" href="/campaign_alerts?page=<?= $i; ?><?= $qs; ?>"><?= $i; ?></a>
</li>
<?php endfor; ?>
<?php if ( $end_p < $total_pages ): ?>
<?php if ( $end_p < $total_pages - 1 ): ?>
<li class="page-item disabled"><span class="page-link">...</span></li>
<?php endif; ?>
<li class="page-item"><a class="page-link" href="/campaign_alerts?page=<?= $total_pages; ?><?= $qs; ?>"><?= $total_pages; ?></a></li>
<?php endif; ?>
<li class="page-item <?= $page >= $total_pages ? 'disabled' : ''; ?>">
<a class="page-link" href="/campaign_alerts?page=<?= $page + 1; ?><?= $qs; ?>">&raquo;</a>
</li>
</ul>
</nav>
</div>
</div>
<?php endif; ?>
</div>
</div>

View File

@@ -16,7 +16,7 @@
<th>Merchant Account ID</th>
<th>Dane od</th>
<th style="width: 160px;">Sync</th>
<th style="width: 120px; text-align: center;">Akcje</th>
<th style="width: 160px; text-align: center;">Akcje</th>
</tr>
</thead>
<tbody>
@@ -49,8 +49,11 @@
<td class="client-sync" data-sync-id="<?= $client['id']; ?>"><span class="text-muted">—</span></td>
<td class="actions-cell">
<?php if ( $client['google_ads_customer_id'] ): ?>
<button type="button" class="btn-icon btn-icon-sync" onclick="syncClient(<?= $client['id']; ?>, this)" title="Pobierz dane z Google Ads">
<i class="fa-solid fa-rotate"></i>
<button type="button" class="btn-icon btn-icon-sync" onclick="syncClient(<?= $client['id']; ?>, 'campaigns', this)" title="Odśwież kampanie">
<i class="fa-solid fa-bullhorn"></i>
</button>
<button type="button" class="btn-icon btn-icon-sync" onclick="syncClient(<?= $client['id']; ?>, 'products', this)" title="Odśwież produkty">
<i class="fa-solid fa-box-open"></i>
</button>
<?php endif; ?>
<button type="button" class="btn-icon btn-icon-edit" onclick="editClient(<?= $client['id']; ?>)" title="Edytuj">
@@ -142,20 +145,23 @@ function editClient( id )
} );
}
function syncClient( id, btn )
function syncClient( id, pipeline, btn )
{
var $btn = $( btn );
var $icon = $btn.find( 'i' );
var origClass = $icon.attr( 'class' );
$btn.prop( 'disabled', true );
$icon.removeClass( 'fa-rotate' ).addClass( 'fa-spinner fa-spin' );
$icon.attr( 'class', 'fa-solid fa-spinner fa-spin' );
$.post( '/clients/force_sync', { id: id }, function( response )
var labels = { campaigns: 'kampanii', products: 'produktów' };
$.post( '/clients/force_sync', { id: id, pipeline: pipeline }, function( response )
{
var data = JSON.parse( response );
$btn.prop( 'disabled', false );
$icon.removeClass( 'fa-spinner fa-spin' ).addClass( 'fa-rotate' );
$icon.attr( 'class', origClass );
if ( data.success )
{
@@ -163,7 +169,7 @@ function syncClient( id, btn )
$.alert({
title: 'Zakolejkowano',
content: 'Klient zostal oznaczony do ponownej synchronizacji. Przy najblizszym uruchomieniu CRON dane kampanii i produktow zostana pobrane od nowa dla calego okna konwersji.',
content: 'Synchronizacja ' + labels[ pipeline ] + ' zostala zakolejkowana. Dane zostana pobrane przy najblizszym uruchomieniu CRON.',
type: 'green',
autoClose: 'ok|3000'
});

View File

@@ -21,7 +21,7 @@
<script src="/libraries/framework/vendor/plugins/moment/pl.js"></script>
<script src="/libraries/framework/vendor/plugins/datepicker/js/bootstrap-datetimepicker.js"></script>
<script src="/libraries/framework/vendor/plugins/daterange/daterangepicker.js"></script>
<script src="/libraries/adspro-dialog.js">
<script src="/libraries/adspro-dialog.js"></script>
<script src="/libraries/select2/js/select2.full.min.js"></script>
<script src="/libraries/functions.js"></script>
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/2.1.7/css/dataTables.bootstrap5.min.css">
@@ -37,7 +37,7 @@
<body class="logged">
<?php
$module = $this -> current_module;
$google_ads_modules = [ 'campaigns', 'campaign_terms', 'products', 'clients', 'xml_files' ];
$google_ads_modules = [ 'campaigns', 'campaign_terms', 'products', 'campaign_alerts', 'clients', 'xml_files' ];
$is_google_ads_module = in_array( $module, $google_ads_modules, true );
?>
<!-- Sidebar -->
@@ -78,6 +78,15 @@
<span>Produkty</span>
</a>
</li>
<li class="<?= $module === 'campaign_alerts' ? 'active' : '' ?>">
<a href="/campaign_alerts">
<i class="fa-solid fa-triangle-exclamation"></i>
<span>Alerty</span>
<?php if ( (int) ( $this -> campaign_alerts_count ?? 0 ) > 0 ): ?>
<span class="badge-alerts-count"><?= (int) $this -> campaign_alerts_count; ?></span>
<?php endif; ?>
</a>
</li>
<li class="<?= $module === 'clients' ? 'active' : '' ?>">
<a href="/clients">
<i class="fa-solid fa-building"></i>
@@ -135,6 +144,7 @@
'campaigns' => 'Kampanie',
'campaign_terms' => 'Grupy i frazy',
'products' => 'Produkty',
'campaign_alerts' => 'Alerty',
'clients' => 'Klienci',
'xml_files' => 'Pliki XML',
'allegro' => 'Allegro import',