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.
This commit is contained in:
2026-02-20 17:50:14 +01:00
parent 0024a25bfb
commit 167ced3573
31 changed files with 5697 additions and 1227 deletions

View File

@@ -53,8 +53,8 @@
}, },
"class.Cron.php": { "class.Cron.php": {
"type": "-", "type": "-",
"size": 114130, "size": 116612,
"lmtime": 1771496221250, "lmtime": 1771511676177,
"modified": false "modified": false
}, },
"class.Products.php": { "class.Products.php": {

View File

@@ -448,7 +448,7 @@ class Api
$history_30_rows++; $history_30_rows++;
} }
$temp_rows = \controls\Cron::rebuild_products_temp_for_client( $client_id ); $aggregate_rows = \controls\Cron::rebuild_products_temp_for_client( $client_id );
echo json_encode( [ echo json_encode( [
'status' => 'ok', 'status' => 'ok',
@@ -457,7 +457,7 @@ class Api
'processed' => $processed, 'processed' => $processed,
'skipped' => $skipped, 'skipped' => $skipped,
'history_30_products' => $history_30_rows, 'history_30_products' => $history_30_rows,
'products_temp_rows' => $temp_rows 'products_aggregate_rows' => $aggregate_rows
] ); ] );
exit; exit;
} }

View File

@@ -3,6 +3,33 @@ namespace controls;
class CampaignAlerts class CampaignAlerts
{ {
static private function redirect_to_main_view( $client_id = 0, $page = 1 )
{
$client_id = (int) $client_id;
$page = max( 1, (int) $page );
$query = [];
if ( $client_id > 0 )
{
$query[] = 'client_id=' . $client_id;
}
if ( $page > 1 )
{
$query[] = 'page=' . $page;
}
$url = '/campaign_alerts';
if ( !empty( $query ) )
{
$url .= '?' . implode( '&', $query );
}
header( 'Location: ' . $url );
exit;
}
static public function main_view() static public function main_view()
{ {
$client_id = (int) \S::get( 'client_id' ); $client_id = (int) \S::get( 'client_id' );
@@ -31,4 +58,30 @@ class CampaignAlerts
'total' => $total 'total' => $total
] ); ] );
} }
static public function delete_selected()
{
$client_id = (int) \S::get( 'client_id' );
$page = max( 1, (int) \S::get( 'page' ) );
$alert_ids = \S::get( 'alert_ids' );
if ( !is_array( $alert_ids ) || empty( $alert_ids ) )
{
\S::alert( 'Nie zaznaczono alertow do usuniecia.' );
self::redirect_to_main_view( $client_id, $page );
}
$deleted = \factory\CampaignAlerts::delete_alerts( $alert_ids );
if ( $deleted > 0 )
{
\S::alert( $deleted === 1 ? 'Usunieto 1 alert.' : 'Usunieto ' . $deleted . ' alertow.' );
}
else
{
\S::alert( 'Nie udalo sie usunac zaznaczonych alertow.' );
}
self::redirect_to_main_view( $client_id, $page );
}
} }

View File

@@ -3,6 +3,51 @@ namespace controls;
class Clients class Clients
{ {
static private function clients_has_deleted_column()
{
global $mdb;
static $has_deleted = null;
if ( $has_deleted !== null )
{
return (bool) $has_deleted;
}
try
{
$stmt = $mdb -> pdo -> prepare( 'SHOW COLUMNS FROM clients LIKE :column_name' );
if ( $stmt )
{
$stmt -> bindValue( ':column_name', 'deleted', \PDO::PARAM_STR );
$stmt -> execute();
$has_deleted = $stmt -> fetch( \PDO::FETCH_ASSOC ) ? 1 : 0;
}
else
{
$has_deleted = 0;
}
}
catch ( \Throwable $e )
{
$has_deleted = 0;
}
return (bool) $has_deleted;
}
static private function sql_clients_not_deleted( $alias = '' )
{
$alias = trim( (string) $alias );
$prefix = $alias !== '' ? $alias . '.' : '';
if ( self::clients_has_deleted_column() )
{
return 'COALESCE(' . $prefix . 'deleted, 0) = 0';
}
return '1=1';
}
static public function main_view() static public function main_view()
{ {
return \view\Clients::main_view( return \view\Clients::main_view(
@@ -16,6 +61,8 @@ class Clients
$name = trim( \S::get( 'name' ) ); $name = trim( \S::get( 'name' ) );
$google_ads_customer_id = trim( \S::get( 'google_ads_customer_id' ) ); $google_ads_customer_id = trim( \S::get( 'google_ads_customer_id' ) );
$google_merchant_account_id = trim( \S::get( 'google_merchant_account_id' ) ); $google_merchant_account_id = trim( \S::get( 'google_merchant_account_id' ) );
$active_raw = \S::get( 'active' );
$active = (string) $active_raw === '0' ? 0 : 1;
if ( !$name ) if ( !$name )
{ {
@@ -31,6 +78,7 @@ class Clients
'google_ads_customer_id' => $google_ads_customer_id ?: null, 'google_ads_customer_id' => $google_ads_customer_id ?: null,
'google_merchant_account_id' => $google_merchant_account_id ?: null, 'google_merchant_account_id' => $google_merchant_account_id ?: null,
'google_ads_start_date' => $google_ads_start_date ?: null, 'google_ads_start_date' => $google_ads_start_date ?: null,
'active' => $active,
]; ];
if ( $id ) if ( $id )
@@ -48,6 +96,36 @@ class Clients
exit; exit;
} }
static public function set_active()
{
$id = (int) \S::get( 'id' );
$active = (int) \S::get( 'active' ) === 1 ? 1 : 0;
if ( $id <= 0 )
{
echo json_encode( [ 'success' => false, 'message' => 'Brak ID klienta.' ] );
exit;
}
$client = \factory\Clients::get( $id );
if ( !$client )
{
echo json_encode( [ 'success' => false, 'message' => 'Nie znaleziono klienta.' ] );
exit;
}
if ( (int) ( $client['deleted'] ?? 0 ) === 1 )
{
echo json_encode( [ 'success' => false, 'message' => 'Nie mozna zmienic statusu usunietego klienta.' ] );
exit;
}
\factory\Clients::update( $id, [ 'active' => $active ] );
echo json_encode( [ 'success' => true, 'id' => $id, 'active' => $active ] );
exit;
}
static public function delete() static public function delete()
{ {
$id = \S::get( 'id' ); $id = \S::get( 'id' );
@@ -73,6 +151,7 @@ class Clients
static public function sync_status() static public function sync_status()
{ {
global $mdb; global $mdb;
$clients_not_deleted_sql = self::sql_clients_not_deleted();
// Kampanie: 1 work unit per row (pending=0, done=1) // Kampanie: 1 work unit per row (pending=0, done=1)
$campaigns_raw = $mdb->query( $campaigns_raw = $mdb->query(
@@ -100,6 +179,32 @@ class Clients
$data[ $row['client_id'] ]['products'] = [ (int) $row['done'], (int) $row['total'] ]; $data[ $row['client_id'] ]['products'] = [ (int) $row['done'], (int) $row['total'] ];
} }
// Walidacja Merchant (cron_campaigns_product_alerts_merchant) dziala na kursorze klienta.
// Pokazujemy postep per klient jako 0/1 albo 1/1 w aktualnym cyklu.
$merchant_cursor_client_id = (int) \services\GoogleAdsApi::get_setting( 'cron_campaigns_product_alerts_last_client_id' );
$merchant_clients_ids = $mdb -> query(
"SELECT id
FROM clients
WHERE " . $clients_not_deleted_sql . "
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 <> ''
ORDER BY id ASC"
) -> fetchAll( \PDO::FETCH_COLUMN );
foreach ( (array) $merchant_clients_ids as $merchant_client_id )
{
$merchant_client_id = (int) $merchant_client_id;
if ( $merchant_client_id <= 0 )
{
continue;
}
$done = ( $merchant_cursor_client_id > 0 && $merchant_client_id <= $merchant_cursor_client_id ) ? 1 : 0;
$data[ $merchant_client_id ]['merchant'] = [ $done, 1 ];
}
echo json_encode( [ 'status' => 'ok', 'data' => $data ] ); echo json_encode( [ 'status' => 'ok', 'data' => $data ] );
exit; exit;
} }
@@ -107,9 +212,10 @@ class Clients
static public function force_sync() static public function force_sync()
{ {
global $mdb; global $mdb;
$clients_not_deleted_sql = self::sql_clients_not_deleted();
$id = (int) \S::get( 'id' ); $id = (int) \S::get( 'id' );
$pipeline = \S::get( 'pipeline' ); $pipeline = trim( (string) \S::get( 'pipeline' ) );
if ( !$id ) if ( !$id )
{ {
@@ -117,14 +223,62 @@ class Clients
exit; exit;
} }
$where = [ 'client_id' => $id ]; $deleted_select = self::clients_has_deleted_column() ? 'COALESCE(deleted, 0) AS deleted' : '0 AS deleted';
$client = $mdb -> query(
"SELECT id, COALESCE(active, 0) AS active, " . $deleted_select . ", google_ads_customer_id, google_merchant_account_id
FROM clients
WHERE id = :id
LIMIT 1",
[ ':id' => $id ]
) -> fetch( \PDO::FETCH_ASSOC );
if ( in_array( $pipeline, [ 'campaigns', 'products' ] ) ) if ( !$client || (int) ( $client['deleted'] ?? 0 ) === 1 )
{ {
$where['pipeline'] = $pipeline; echo json_encode( [ 'success' => false, 'message' => 'Nie znaleziono klienta.' ] );
exit;
} }
$mdb -> delete( 'cron_sync_status', $where ); if ( (int) ( $client['active'] ?? 0 ) !== 1 )
{
echo json_encode( [ 'success' => false, 'message' => 'Klient jest nieaktywny. Aktywuj klienta przed synchronizacja.' ] );
exit;
}
if ( in_array( $pipeline, [ 'campaigns', 'products' ], true ) )
{
$where = [ 'client_id' => $id ];
$where['pipeline'] = $pipeline;
$mdb -> delete( 'cron_sync_status', $where );
}
else if ( $pipeline === 'campaigns_product_alerts_merchant' )
{
$has_ads_id = trim( (string) ( $client['google_ads_customer_id'] ?? '' ) ) !== '';
$has_merchant_id = trim( (string) ( $client['google_merchant_account_id'] ?? '' ) ) !== '';
if ( !$has_ads_id || !$has_merchant_id )
{
echo json_encode( [ 'success' => false, 'message' => 'Klient nie ma kompletnego Google Ads Customer ID i Merchant Account ID.' ] );
exit;
}
$previous_eligible_id = (int) $mdb -> query(
"SELECT MAX(id)
FROM clients
WHERE " . $clients_not_deleted_sql . "
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 <> ''
AND id < :client_id",
[ ':client_id' => $id ]
) -> fetchColumn();
\services\GoogleAdsApi::set_setting( 'cron_campaigns_product_alerts_last_client_id', (string) max( 0, $previous_eligible_id ) );
}
else
{
// Domyslny reset (wszystkie pipeline oparte o cron_sync_status).
$mdb -> delete( 'cron_sync_status', [ 'client_id' => $id ] );
}
echo json_encode( [ 'success' => true, 'pipeline' => $pipeline ?: 'all' ] ); echo json_encode( [ 'success' => true, 'pipeline' => $pipeline ?: 'all' ] );
exit; exit;

File diff suppressed because it is too large Load Diff

View File

@@ -201,7 +201,15 @@ class Products
static public function get_campaigns_list() static public function get_campaigns_list()
{ {
$client_id = (int) \S::get( 'client_id' ); $client_id = (int) \S::get( 'client_id' );
echo json_encode( [ 'campaigns' => \factory\Campaigns::get_campaigns_list( $client_id, true ) ] ); $campaigns = \factory\Campaigns::get_campaigns_list( $client_id, true );
$allowed_channel_types = [ 'SHOPPING', 'PERFORMANCE_MAX' ];
$campaigns = array_values( array_filter( (array) $campaigns, function( $row ) use ( $allowed_channel_types ) {
$channel_type = strtoupper( trim( (string) ( $row['advertising_channel_type'] ?? '' ) ) );
return in_array( $channel_type, $allowed_channel_types, true );
} ) );
echo json_encode( [ 'campaigns' => $campaigns ] );
exit; exit;
} }
@@ -219,6 +227,169 @@ class Products
exit; exit;
} }
static public function get_scope_alerts()
{
$client_id = (int) \S::get( 'client_id' );
$campaign_id = (int) \S::get( 'campaign_id' );
$ad_group_id = (int) \S::get( 'ad_group_id' );
$alerts = \factory\Products::get_scope_alerts( $client_id, $campaign_id, $ad_group_id, 80 );
echo json_encode( [
'status' => 'ok',
'alerts' => $alerts,
'count' => count( $alerts )
] );
exit;
}
static public function get_products_without_impressions_30()
{
$client_id = (int) \S::get( 'client_id' );
$campaign_id = (int) \S::get( 'campaign_id' );
$ad_group_id = (int) \S::get( 'ad_group_id' );
if ( $client_id <= 0 || $campaign_id <= 0 )
{
echo json_encode( [
'status' => 'ok',
'products' => [],
'count' => 0
] );
exit;
}
$rows = \factory\Products::get_products_without_impressions_30( $client_id, $campaign_id, $ad_group_id, 1000 );
$products = [];
foreach ( (array) $rows as $row )
{
$product_id = (int) ( $row['product_id'] ?? 0 );
if ( $product_id <= 0 )
{
continue;
}
$offer_id = trim( (string) ( $row['offer_id'] ?? '' ) );
$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 );
}
$products[] = [
'product_id' => $product_id,
'offer_id' => $offer_id,
'name' => $product_name
];
}
echo json_encode( [
'status' => 'ok',
'products' => $products,
'count' => count( $products )
] );
exit;
}
static public function delete_campaign_ad_group()
{
$campaign_id = (int) \S::get( 'campaign_id' );
$ad_group_id = (int) \S::get( 'ad_group_id' );
$delete_scope = trim( (string) \S::get( 'delete_scope' ) );
if ( $ad_group_id <= 0 )
{
echo json_encode( [ 'status' => 'error', 'message' => 'Nie wybrano grupy reklam do usuniecia.' ] );
exit;
}
if ( !in_array( $delete_scope, [ 'local', 'google' ], true ) )
{
$delete_scope = 'local';
}
$context = \factory\Products::get_ad_group_delete_context( $ad_group_id );
if ( !$context )
{
echo json_encode( [ 'status' => 'ok', 'message' => 'Grupa reklam byla juz usunieta lokalnie.' ] );
exit;
}
$local_campaign_id = (int) ( $context['local_campaign_id'] ?? 0 );
if ( $campaign_id > 0 && $campaign_id !== $local_campaign_id )
{
echo json_encode( [ 'status' => 'error', 'message' => 'Wybrana grupa reklam nie nalezy do wskazanej kampanii.' ] );
exit;
}
$channel_type = strtoupper( trim( (string) ( $context['advertising_channel_type'] ?? '' ) ) );
if ( $channel_type !== 'SHOPPING' )
{
echo json_encode( [ 'status' => 'error', 'message' => 'Usuwanie grup reklam jest dostepne tylko dla kampanii produktowych (Shopping).' ] );
exit;
}
if ( $delete_scope === 'google' )
{
$customer_id = preg_replace( '/\D+/', '', (string) ( $context['google_ads_customer_id'] ?? '' ) );
$external_ad_group_id = (int) ( $context['external_ad_group_id'] ?? 0 );
if ( $customer_id === '' || $external_ad_group_id <= 0 )
{
echo json_encode( [ 'status' => 'error', 'message' => 'Brak danych Google Ads (customer_id lub ad_group_id).' ] );
exit;
}
$google_ads_api = new \services\GoogleAdsApi();
if ( !$google_ads_api -> is_configured() )
{
echo json_encode( [ 'status' => 'error', 'message' => 'Google Ads API nie jest skonfigurowane.' ] );
exit;
}
$google_result = $google_ads_api -> remove_ad_group( $customer_id, $external_ad_group_id );
if ( empty( $google_result['success'] ) )
{
$error_message = trim( (string) ( $google_result['error'] ?? \services\GoogleAdsApi::get_setting( 'google_ads_last_error' ) ) );
if ( $error_message === '' )
{
$error_message = 'Nie udalo sie usunac grupy reklam w Google Ads.';
}
echo json_encode( [ 'status' => 'error', 'message' => $error_message ] );
exit;
}
}
if ( !\factory\Products::delete_ad_group_local( $ad_group_id ) )
{
$still_exists_local = \factory\Products::get_ad_group_scope_context( $ad_group_id );
if ( !$still_exists_local )
{
echo json_encode( [ 'status' => 'ok', 'message' => 'Grupa reklam zostala usunieta lokalnie.' ] );
exit;
}
echo json_encode( [ 'status' => 'error', 'message' => 'Nie udalo sie usunac grupy reklam lokalnie.' ] );
exit;
}
if ( $delete_scope === 'google' )
{
echo json_encode( [ 'status' => 'ok', 'message' => 'Usunieto grupe reklam lokalnie oraz w Google Ads.' ] );
}
else
{
echo json_encode( [ 'status' => 'ok', 'message' => 'Usunieto grupe reklam lokalnie.' ] );
}
exit;
}
static public function assign_product_scope() static public function assign_product_scope()
{ {
$product_id = (int) \S::get( 'product_id' ); $product_id = (int) \S::get( 'product_id' );

View File

@@ -154,7 +154,19 @@ class Users
global $mdb; global $mdb;
$base_url = self::get_base_url(); $base_url = self::get_base_url();
$clients_total = (int) $mdb -> query( "SELECT COUNT(*) FROM clients WHERE deleted = 0 AND google_ads_customer_id IS NOT NULL AND google_ads_customer_id <> ''" ) -> fetchColumn(); $clients_total = (int) $mdb -> query(
"SELECT COUNT(*)
FROM clients
WHERE COALESCE( active, 0 ) = 1
AND TRIM( COALESCE( google_ads_customer_id, '' ) ) <> ''"
) -> fetchColumn();
$merchant_clients_total = (int) $mdb -> query(
"SELECT COUNT(*)
FROM clients
WHERE COALESCE( active, 0 ) = 1
AND TRIM( COALESCE( google_ads_customer_id, '' ) ) <> ''
AND TRIM( COALESCE( google_merchant_account_id, '' ) ) <> ''"
) -> fetchColumn();
// --- Kampanie --- // --- Kampanie ---
$campaign_stats = $mdb -> query( $campaign_stats = $mdb -> query(
@@ -203,7 +215,7 @@ class Users
if ( $products_active_date ) if ( $products_active_date )
{ {
$current_phase = $mdb -> query( $current_phase = $mdb -> query(
"SELECT phase 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 != 'done' ORDER BY FIELD(cs.phase, 'pending', 'fetch', 'aggregate_30') ASC LIMIT 1", "SELECT phase FROM cron_sync_status cs INNER JOIN clients c ON cs.client_id = c.id AND COALESCE(c.active, 0) = 1 WHERE cs.pipeline = 'products' AND cs.sync_date = :sync_date AND cs.phase != 'done' ORDER BY FIELD(cs.phase, 'pending', 'fetch', 'aggregate_30') ASC LIMIT 1",
[ ':sync_date' => $products_active_date ] [ ':sync_date' => $products_active_date ]
) -> fetchColumn(); ) -> fetchColumn();
@@ -222,16 +234,45 @@ class Users
$products_meta .= ', ' . $products_eta_meta; $products_meta .= ', ' . $products_eta_meta;
} }
// --- Walidacja Merchant dla alertow kampanii ---
$merchant_cursor_client_id = (int) \services\GoogleAdsApi::get_setting( 'cron_campaigns_product_alerts_last_client_id' );
$merchant_processed = 0;
if ( $merchant_cursor_client_id > 0 && $merchant_clients_total > 0 )
{
$merchant_processed = (int) $mdb -> query(
"SELECT COUNT(*)
FROM clients
WHERE COALESCE( active, 0 ) = 1
AND TRIM( COALESCE( google_ads_customer_id, '' ) ) <> ''
AND TRIM( COALESCE( google_merchant_account_id, '' ) ) <> ''
AND id <= :cursor_client_id",
[ ':cursor_client_id' => $merchant_cursor_client_id ]
) -> fetchColumn();
}
$merchant_processed = min( $merchant_clients_total, max( 0, $merchant_processed ) );
$merchant_remaining = max( 0, $merchant_clients_total - $merchant_processed );
$merchant_meta = 'Kursor klienta: ' . ( $merchant_cursor_client_id > 0 ? '#' . $merchant_cursor_client_id : '-' ) . ', klienci z Merchant ID: ' . $merchant_clients_total;
$merchant_eta_meta = self::build_eta_meta( 'cron_campaigns_product_alerts_merchant', $merchant_remaining );
if ( $merchant_eta_meta !== '' )
{
$merchant_meta .= ', ' . $merchant_eta_meta;
}
$cron_schedule = [];
// --- Endpointy CRON --- // --- Endpointy CRON ---
$cron_endpoints = [ $cron_endpoints = [
[ 'name' => 'Legacy CRON', 'path' => '/cron.php', 'action' => 'cron_legacy' ], [ 'name' => 'Legacy CRON', 'path' => '/cron.php', 'action' => 'cron_legacy', 'plan' => '' ],
[ 'name' => 'Cron kampanii', 'path' => '/cron/cron_campaigns', 'action' => 'cron_campaigns' ], [ 'name' => 'Cron zbiorczy (K->P->GMC)', 'path' => '/cron/cron_clients_bundle', 'action' => 'cron_clients_bundle', 'plan' => 'Co 1 min: klient -> kampanie (-7..-1) -> produkty (-7..-1) -> GMC' ],
[ 'name' => 'Cron produktów', 'path' => '/cron/cron_products', 'action' => 'cron_products' ], [ 'name' => 'Cron kampanii', 'path' => '/cron/cron_campaigns', 'action' => 'cron_campaigns', 'plan' => 'Krok 1/2, co 15 min' ],
[ 'name' => 'Cron URL produktów (Merchant)', 'path' => '/cron/cron_products_urls', 'action' => 'cron_products_urls' ], [ 'name' => 'Cron alertow kampanii (Merchant)', 'path' => '/cron/cron_campaigns_product_alerts_merchant', 'action' => 'cron_campaigns_product_alerts_merchant', 'plan' => 'Krok 2/2, 2-5 min po Cron kampanii, co 15 min' ],
[ 'name' => 'Cron fraz', 'path' => '/cron/cron_phrases', 'action' => 'cron_phrases' ], [ 'name' => 'Cron produktów', 'path' => '/cron/cron_products', 'action' => 'cron_products', 'plan' => '' ],
[ 'name' => 'Historia 30 dni produktów', 'path' => '/cron/cron_products_history_30', 'action' => 'cron_products_history_30' ], [ 'name' => 'Cron URL produktów (Merchant)', 'path' => '/cron/cron_products_urls', 'action' => 'cron_products_urls', 'plan' => '' ],
[ 'name' => 'Historia 30 dni fraz', 'path' => '/cron/cron_phrases_history_30', 'action' => 'cron_phrases_history_30' ], [ 'name' => 'Cron fraz', 'path' => '/cron/cron_phrases', 'action' => 'cron_phrases', 'plan' => '' ],
[ 'name' => 'Eksport XML', 'path' => '/cron/cron_xml', 'action' => 'cron_xml' ], [ 'name' => 'Historia 30 dni produktów', 'path' => '/cron/cron_products_history_30', 'action' => 'cron_products_history_30', 'plan' => '' ],
[ 'name' => 'Historia 30 dni fraz', 'path' => '/cron/cron_phrases_history_30', 'action' => 'cron_phrases_history_30', 'plan' => '' ],
[ 'name' => 'Eksport XML', 'path' => '/cron/cron_xml', 'action' => 'cron_xml', 'plan' => '' ],
]; ];
$urls = []; $urls = [];
@@ -242,6 +283,7 @@ class Users
'name' => $endpoint['name'], 'name' => $endpoint['name'],
'url' => $base_url . $endpoint['path'], 'url' => $base_url . $endpoint['path'],
'last_invoked_at' => self::format_datetime( \services\GoogleAdsApi::get_setting( $last_key ) ), 'last_invoked_at' => self::format_datetime( \services\GoogleAdsApi::get_setting( $last_key ) ),
'plan' => (string) ( $endpoint['plan'] ?? '' ),
]; ];
} }
@@ -263,7 +305,15 @@ class Users
'percent' => self::progress_percent( $products_processed, $products_total ), 'percent' => self::progress_percent( $products_processed, $products_total ),
'meta' => $products_meta 'meta' => $products_meta
], ],
[
'name' => 'Walidacja Merchant',
'processed' => $merchant_processed,
'total' => $merchant_clients_total,
'percent' => self::progress_percent( $merchant_processed, $merchant_clients_total ),
'meta' => $merchant_meta
],
], ],
'schedule' => $cron_schedule,
'urls' => $urls 'urls' => $urls
]; ];
} }

View File

@@ -57,6 +57,7 @@ class CampaignAlerts
ca.campaign_external_id, ca.campaign_external_id,
ca.ad_group_id, ca.ad_group_id,
ca.ad_group_external_id, ca.ad_group_external_id,
ca.product_id,
ca.alert_type, ca.alert_type,
ca.message, ca.message,
ca.meta_json, ca.meta_json,
@@ -109,4 +110,29 @@ class CampaignAlerts
'date_detected[<]' => date( 'Y-m-d', strtotime( '-' . (int) $days . ' days' ) ) 'date_detected[<]' => date( 'Y-m-d', strtotime( '-' . (int) $days . ' days' ) )
] ); ] );
} }
static public function delete_alerts( array $ids )
{
global $mdb;
$ids = array_map( 'intval', $ids );
$ids = array_filter( $ids, function( $id ) { return $id > 0; } );
if ( empty( $ids ) )
{
return 0;
}
$before = (int) $mdb -> count( 'campaign_alerts', [ 'id' => $ids ] );
if ( $before <= 0 )
{
return 0;
}
$mdb -> delete( 'campaign_alerts', [ 'id' => $ids ] );
$after = (int) $mdb -> count( 'campaign_alerts', [ 'id' => $ids ] );
return max( 0, $before - $after );
}
} }

View File

@@ -2,6 +2,18 @@
namespace factory; namespace factory;
class Products class Products
{ {
static private function is_product_core_field( $field )
{
return in_array( (string) $field, [
'custom_label_4',
'custom_label_3',
'title',
'description',
'google_product_category',
'product_url'
], true );
}
static public function delete_product( $product_id ) { static public function delete_product( $product_id ) {
global $mdb; global $mdb;
$mdb -> delete( 'products', [ 'id' => $product_id ] ); $mdb -> delete( 'products', [ 'id' => $product_id ] );
@@ -31,12 +43,162 @@ class Products
return $mdb -> delete( 'products_comments', [ 'id' => $comment_id ] ); return $mdb -> delete( 'products_comments', [ 'id' => $comment_id ] );
} }
static public function get_ad_group_delete_context( $ad_group_id )
{
global $mdb;
return $mdb -> query(
'SELECT
ag.id AS local_ad_group_id,
ag.campaign_id AS local_campaign_id,
ag.ad_group_id AS external_ad_group_id,
ag.ad_group_name,
c.client_id,
c.campaign_id AS external_campaign_id,
c.campaign_name,
c.advertising_channel_type,
cl.google_ads_customer_id
FROM campaign_ad_groups AS ag
INNER JOIN campaigns AS c ON c.id = ag.campaign_id
INNER JOIN clients AS cl ON cl.id = c.client_id
WHERE ag.id = :ad_group_id
LIMIT 1',
[ ':ad_group_id' => (int) $ad_group_id ]
) -> fetch( \PDO::FETCH_ASSOC );
}
static public function delete_ad_group_local( $ad_group_id )
{
global $mdb;
$ad_group_id = (int) $ad_group_id;
if ( $ad_group_id <= 0 )
{
return false;
}
$mdb -> delete( 'campaign_ad_groups', [ 'id' => $ad_group_id ] );
if ( (int) $mdb -> rowCount() > 0 )
{
return true;
}
// Traktuj jako sukces, jeżeli wpis i tak już nie istnieje.
$exists = (int) $mdb -> count( 'campaign_ad_groups', [ 'id' => $ad_group_id ] );
return $exists === 0;
}
static public function get_product_comment_by_date( $product_id, $date ) static public function get_product_comment_by_date( $product_id, $date )
{ {
global $mdb; global $mdb;
return $mdb -> get( 'products_comments', [ 'id', 'comment' ], [ 'AND' => [ 'product_id' => $product_id, 'date_add' => $date ] ] ); return $mdb -> get( 'products_comments', [ 'id', 'comment' ], [ 'AND' => [ 'product_id' => $product_id, 'date_add' => $date ] ] );
} }
static public function get_scope_alerts( $client_id, $campaign_id, $ad_group_id, $limit = 50 )
{
global $mdb;
$client_id = (int) $client_id;
$campaign_id = (int) $campaign_id;
$ad_group_id = (int) $ad_group_id;
$limit = max( 1, (int) $limit );
if ( $client_id <= 0 || $campaign_id <= 0 )
{
return [];
}
$where = [
'client_id' => $client_id,
'campaign_id' => $campaign_id
];
if ( $ad_group_id > 0 )
{
$where['ad_group_id'] = $ad_group_id;
}
$rows = $mdb -> select( 'campaign_alerts', [
'id',
'alert_type',
'message',
'date_detected',
'date_add'
], [
'AND' => $where,
'ORDER' => [
'date_detected' => 'DESC',
'id' => 'DESC'
],
'LIMIT' => $limit
] );
return is_array( $rows ) ? $rows : [];
}
static public function get_products_without_impressions_30( $client_id, $campaign_id, $ad_group_id = 0, $limit = 500 )
{
global $mdb;
$client_id = (int) $client_id;
$campaign_id = (int) $campaign_id;
$ad_group_id = (int) $ad_group_id;
$limit = max( 1, (int) $limit );
$limit = min( 2000, $limit );
if ( $client_id <= 0 || $campaign_id <= 0 )
{
return [];
}
$params = [
':client_id' => $client_id,
':campaign_id' => $campaign_id
];
$sql = 'SELECT
p.id AS product_id,
p.offer_id,
p.name,
p.title,
SUM( pa.impressions_30 ) AS impressions_30
FROM products_aggregate AS pa
INNER JOIN products AS p ON p.id = pa.product_id
WHERE p.client_id = :client_id
AND pa.campaign_id = :campaign_id';
if ( $ad_group_id > 0 )
{
$sql .= ' AND pa.ad_group_id = :ad_group_id';
$params[':ad_group_id'] = $ad_group_id;
}
$sql .= '
GROUP BY p.id, p.offer_id, p.name, p.title
HAVING COALESCE( SUM( pa.impressions_30 ), 0 ) = 0
ORDER BY COALESCE( NULLIF( TRIM( p.title ), \'\' ), NULLIF( TRIM( p.name ), \'\' ), p.offer_id ) ASC, p.id ASC
LIMIT ' . $limit;
try
{
$statement = $mdb -> query( $sql, $params );
if ( !$statement )
{
error_log( '[products] get_products_without_impressions_30 query returned no statement.' );
return [];
}
$rows = $statement -> fetchAll( \PDO::FETCH_ASSOC );
return is_array( $rows ) ? $rows : [];
}
catch ( \Throwable $e )
{
error_log( '[products] get_products_without_impressions_30 query error: ' . $e -> getMessage() );
return [];
}
}
static public function get_min_roas( $product_id ) static public function get_min_roas( $product_id )
{ {
global $mdb; global $mdb;
@@ -68,13 +230,13 @@ class Products
if ( $campaign_id > 0 ) if ( $campaign_id > 0 )
{ {
$sql .= ' AND pt.campaign_id = :campaign_id'; $sql .= ' AND pa.campaign_id = :campaign_id';
$params[':campaign_id'] = $campaign_id; $params[':campaign_id'] = $campaign_id;
} }
if ( $ad_group_id > 0 ) if ( $ad_group_id > 0 )
{ {
$sql .= ' AND pt.ad_group_id = :ad_group_id'; $sql .= ' AND pa.ad_group_id = :ad_group_id';
$params[':ad_group_id'] = $ad_group_id; $params[':ad_group_id'] = $ad_group_id;
} }
} }
@@ -112,44 +274,44 @@ class Products
p.id AS product_id, p.id AS product_id,
p.offer_id, p.offer_id,
p.min_roas, p.min_roas,
pt.campaign_id, pa.campaign_id,
CASE CASE
WHEN COUNT( DISTINCT pt.campaign_id ) > 1 THEN \'--- wiele kampanii ---\' WHEN COUNT( DISTINCT pa.campaign_id ) > 1 THEN \'--- wiele kampanii ---\'
ELSE COALESCE( MAX( c.campaign_name ), \'--- brak kampanii ---\' ) ELSE COALESCE( MAX( c.campaign_name ), \'--- brak kampanii ---\' )
END AS campaign_name, END AS campaign_name,
CASE CASE
WHEN COUNT( DISTINCT pt.ad_group_id ) > 1 THEN \'--- wiele grup reklam ---\' WHEN COUNT( DISTINCT pa.ad_group_id ) > 1 THEN \'--- wiele grup reklam ---\'
WHEN MAX( pt.ad_group_id ) = 0 THEN \'PMax (bez grup reklam)\' WHEN MAX( pa.ad_group_id ) = 0 THEN \'PMax (bez grup reklam)\'
ELSE COALESCE( MAX( ag.ad_group_name ), \'--- brak grupy reklam ---\' ) ELSE COALESCE( MAX( ag.ad_group_name ), \'--- brak grupy reklam ---\' )
END AS ad_group_name, END AS ad_group_name,
CASE CASE
WHEN COUNT( DISTINCT pt.ad_group_id ) = 1 THEN MAX( pt.ad_group_id ) WHEN COUNT( DISTINCT pa.ad_group_id ) = 1 THEN MAX( pa.ad_group_id )
ELSE 0 ELSE 0
END AS ad_group_id, END AS ad_group_id,
MAX( pt.name ) AS name, COALESCE( NULLIF( TRIM( p.title ), \'\' ), NULLIF( TRIM( p.name ), \'\' ), p.offer_id ) AS name,
SUM( pt.impressions ) AS impressions, SUM( pa.impressions_all_time ) AS impressions,
SUM( pt.impressions_30 ) AS impressions_30, SUM( pa.impressions_30 ) AS impressions_30,
SUM( pt.clicks ) AS clicks, SUM( pa.clicks_all_time ) AS clicks,
SUM( pt.clicks_30 ) AS clicks_30, SUM( pa.clicks_30 ) AS clicks_30,
CASE CASE
WHEN SUM( pt.impressions ) > 0 THEN ROUND( SUM( pt.clicks ) / SUM( pt.impressions ) * 100, 2 ) WHEN SUM( pa.impressions_all_time ) > 0 THEN ROUND( SUM( pa.clicks_all_time ) / SUM( pa.impressions_all_time ) * 100, 2 )
ELSE 0 ELSE 0
END AS ctr, END AS ctr,
SUM( pt.cost ) AS cost, SUM( pa.cost_all_time ) AS cost,
CASE CASE
WHEN SUM( pt.clicks ) > 0 THEN ROUND( SUM( pt.cost ) / SUM( pt.clicks ), 6 ) WHEN SUM( pa.clicks_all_time ) > 0 THEN ROUND( SUM( pa.cost_all_time ) / SUM( pa.clicks_all_time ), 6 )
ELSE 0 ELSE 0
END AS cpc, END AS cpc,
SUM( pt.conversions ) AS conversions, SUM( pa.conversions_all_time ) AS conversions,
SUM( pt.conversions_value ) AS conversions_value, SUM( pa.conversion_value_all_time ) AS conversions_value,
CASE CASE
WHEN SUM( pt.cost ) > 0 THEN ROUND( SUM( pt.conversions_value ) / SUM( pt.cost ) * 100, 2 ) WHEN SUM( pa.cost_all_time ) > 0 THEN ROUND( SUM( pa.conversion_value_all_time ) / SUM( pa.cost_all_time ) * 100, 2 )
ELSE 0 ELSE 0
END AS roas END AS roas
FROM products_temp AS pt FROM products_aggregate AS pa
INNER JOIN products AS p ON p.id = pt.product_id INNER JOIN products AS p ON p.id = pa.product_id
LEFT JOIN campaigns AS c ON c.id = pt.campaign_id LEFT JOIN campaigns AS c ON c.id = pa.campaign_id
LEFT JOIN campaign_ad_groups AS ag ON ag.id = pt.ad_group_id LEFT JOIN campaign_ad_groups AS ag ON ag.id = pa.ad_group_id
WHERE p.client_id = :client_id'; WHERE p.client_id = :client_id';
self::build_scope_filters( $sql, $params, $campaign_id, $ad_group_id ); self::build_scope_filters( $sql, $params, $campaign_id, $ad_group_id );
@@ -157,7 +319,8 @@ class Products
if ( $search ) if ( $search )
{ {
$sql .= ' AND ( $sql .= ' AND (
pt.name LIKE :search p.name LIKE :search
OR p.title LIKE :search
OR p.offer_id LIKE :search OR p.offer_id LIKE :search
OR c.campaign_name LIKE :search OR c.campaign_name LIKE :search
OR ag.ad_group_name LIKE :search OR ag.ad_group_name LIKE :search
@@ -165,7 +328,7 @@ class Products
$params[':search'] = '%' . $search . '%'; $params[':search'] = '%' . $search . '%';
} }
$sql .= ' GROUP BY p.id, p.offer_id, p.min_roas, pt.campaign_id'; $sql .= ' GROUP BY p.id, p.offer_id, p.min_roas, pa.campaign_id, p.name, p.title';
$sql .= ' ORDER BY ' . $order_sql . ' ' . $order_dir . ', product_id DESC LIMIT ' . $start . ', ' . $limit; $sql .= ' ORDER BY ' . $order_sql . ' ' . $order_dir . ', product_id DESC LIMIT ' . $start . ', ' . $limit;
return $mdb -> query( $sql, $params ) -> fetchAll( \PDO::FETCH_ASSOC ); return $mdb -> query( $sql, $params ) -> fetchAll( \PDO::FETCH_ASSOC );
@@ -177,20 +340,27 @@ class Products
$params = [ ':client_id' => $client_id ]; $params = [ ':client_id' => $client_id ];
$sql = 'SELECT MIN( p.min_roas ) AS min_roas, MAX( pt.roas ) AS max_roas $sql = 'SELECT MIN( p.min_roas ) AS min_roas,
FROM products_temp AS pt MAX(
INNER JOIN products AS p ON p.id = pt.product_id CASE
LEFT JOIN campaigns AS c ON c.id = pt.campaign_id WHEN COALESCE( pa.cost_all_time, 0 ) > 0 THEN ROUND( COALESCE( pa.conversion_value_all_time, 0 ) / pa.cost_all_time * 100, 2 )
LEFT JOIN campaign_ad_groups AS ag ON ag.id = pt.ad_group_id ELSE 0
END
) AS max_roas
FROM products_aggregate AS pa
INNER JOIN products AS p ON p.id = pa.product_id
LEFT JOIN campaigns AS c ON c.id = pa.campaign_id
LEFT JOIN campaign_ad_groups AS ag ON ag.id = pa.ad_group_id
WHERE p.client_id = :client_id WHERE p.client_id = :client_id
AND pt.conversions > 10'; AND pa.conversions_all_time > 10';
self::build_scope_filters( $sql, $params, $campaign_id, $ad_group_id ); self::build_scope_filters( $sql, $params, $campaign_id, $ad_group_id );
if ( $search ) if ( $search )
{ {
$sql .= ' AND ( $sql .= ' AND (
pt.name LIKE :search p.name LIKE :search
OR p.title LIKE :search
OR p.offer_id LIKE :search OR p.offer_id LIKE :search
OR c.campaign_name LIKE :search OR c.campaign_name LIKE :search
OR ag.ad_group_name LIKE :search OR ag.ad_group_name LIKE :search
@@ -213,11 +383,11 @@ class Products
$params = [ ':client_id' => (int) $client_id ]; $params = [ ':client_id' => (int) $client_id ];
$sql = 'SELECT COUNT(0) $sql = 'SELECT COUNT(0)
FROM ( FROM (
SELECT p.id, pt.campaign_id SELECT p.id, pa.campaign_id
FROM products_temp AS pt FROM products_aggregate AS pa
INNER JOIN products AS p ON p.id = pt.product_id INNER JOIN products AS p ON p.id = pa.product_id
LEFT JOIN campaigns AS c ON c.id = pt.campaign_id LEFT JOIN campaigns AS c ON c.id = pa.campaign_id
LEFT JOIN campaign_ad_groups AS ag ON ag.id = pt.ad_group_id LEFT JOIN campaign_ad_groups AS ag ON ag.id = pa.ad_group_id
WHERE p.client_id = :client_id'; WHERE p.client_id = :client_id';
self::build_scope_filters( $sql, $params, $campaign_id, $ad_group_id ); self::build_scope_filters( $sql, $params, $campaign_id, $ad_group_id );
@@ -225,7 +395,8 @@ class Products
if ( $search ) if ( $search )
{ {
$sql .= ' AND ( $sql .= ' AND (
pt.name LIKE :search p.name LIKE :search
OR p.title LIKE :search
OR p.offer_id LIKE :search OR p.offer_id LIKE :search
OR c.campaign_name LIKE :search OR c.campaign_name LIKE :search
OR ag.ad_group_name LIKE :search OR ag.ad_group_name LIKE :search
@@ -233,7 +404,7 @@ class Products
$params[':search'] = '%' . $search . '%'; $params[':search'] = '%' . $search . '%';
} }
$sql .= ' GROUP BY p.id, pt.campaign_id $sql .= ' GROUP BY p.id, pa.campaign_id
) AS grouped_rows'; ) AS grouped_rows';
return $mdb -> query( $sql, $params ) -> fetchColumn(); return $mdb -> query( $sql, $params ) -> fetchColumn();
@@ -248,30 +419,30 @@ class Products
p.offer_id, p.offer_id,
p.name, p.name,
p.min_roas, p.min_roas,
COALESCE( SUM( pt.impressions ), 0 ) AS impressions, COALESCE( SUM( pa.impressions_all_time ), 0 ) AS impressions,
COALESCE( SUM( pt.impressions_30 ), 0 ) AS impressions_30, COALESCE( SUM( pa.impressions_30 ), 0 ) AS impressions_30,
COALESCE( SUM( pt.clicks ), 0 ) AS clicks, COALESCE( SUM( pa.clicks_all_time ), 0 ) AS clicks,
COALESCE( SUM( pt.clicks_30 ), 0 ) AS clicks_30, COALESCE( SUM( pa.clicks_30 ), 0 ) AS clicks_30,
CASE CASE
WHEN COALESCE( SUM( pt.impressions ), 0 ) > 0 WHEN COALESCE( SUM( pa.impressions_all_time ), 0 ) > 0
THEN ROUND( COALESCE( SUM( pt.clicks ), 0 ) / COALESCE( SUM( pt.impressions ), 0 ) * 100, 2 ) THEN ROUND( COALESCE( SUM( pa.clicks_all_time ), 0 ) / COALESCE( SUM( pa.impressions_all_time ), 0 ) * 100, 2 )
ELSE 0 ELSE 0
END AS ctr, END AS ctr,
COALESCE( SUM( pt.cost ), 0 ) AS cost, COALESCE( SUM( pa.cost_all_time ), 0 ) AS cost,
CASE CASE
WHEN COALESCE( SUM( pt.clicks ), 0 ) > 0 WHEN COALESCE( SUM( pa.clicks_all_time ), 0 ) > 0
THEN ROUND( COALESCE( SUM( pt.cost ), 0 ) / COALESCE( SUM( pt.clicks ), 0 ), 6 ) THEN ROUND( COALESCE( SUM( pa.cost_all_time ), 0 ) / COALESCE( SUM( pa.clicks_all_time ), 0 ), 6 )
ELSE 0 ELSE 0
END AS cpc, END AS cpc,
COALESCE( SUM( pt.conversions ), 0 ) AS conversions, COALESCE( SUM( pa.conversions_all_time ), 0 ) AS conversions,
COALESCE( SUM( pt.conversions_value ), 0 ) AS conversions_value, COALESCE( SUM( pa.conversion_value_all_time ), 0 ) AS conversions_value,
CASE CASE
WHEN COALESCE( SUM( pt.cost ), 0 ) > 0 WHEN COALESCE( SUM( pa.cost_all_time ), 0 ) > 0
THEN ROUND( COALESCE( SUM( pt.conversions_value ), 0 ) / COALESCE( SUM( pt.cost ), 0 ) * 100, 2 ) THEN ROUND( COALESCE( SUM( pa.conversion_value_all_time ), 0 ) / COALESCE( SUM( pa.cost_all_time ), 0 ) * 100, 2 )
ELSE 0 ELSE 0
END AS roas END AS roas
FROM products AS p FROM products AS p
LEFT JOIN products_temp AS pt ON pt.product_id = p.id LEFT JOIN products_aggregate AS pa ON pa.product_id = p.id
WHERE p.id = :pid WHERE p.id = :pid
GROUP BY p.id, p.offer_id, p.name, p.min_roas', GROUP BY p.id, p.offer_id, p.name, p.min_roas',
[ ':pid' => $product_id ] [ ':pid' => $product_id ]
@@ -281,7 +452,21 @@ class Products
static public function get_product_data( $product_id, $field ) static public function get_product_data( $product_id, $field )
{ {
global $mdb; global $mdb;
return $mdb -> get( 'products_data', $field, [ 'product_id' => $product_id ] );
$product_id = (int) $product_id;
$field = trim( (string) $field );
if ( $product_id <= 0 || $field === '' )
{
return null;
}
if ( !self::is_product_core_field( $field ) )
{
return null;
}
return $mdb -> get( 'products', $field, [ 'id' => $product_id ] );
} }
static public function get_product_merchant_context( $product_id ) static public function get_product_merchant_context( $product_id )
@@ -459,10 +644,36 @@ class Products
{ {
global $mdb; global $mdb;
if ( !$mdb -> count( 'products_data', [ 'product_id' => $product_id ] ) ) $product_id = (int) $product_id;
$result = $mdb -> insert( 'products_data', [ 'product_id' => $product_id, $field => $value ] ); $field = trim( (string) $field );
else
$result = $mdb -> update( 'products_data', [ $field => $value ], [ 'product_id' => $product_id ] ); if ( $product_id <= 0 || $field === '' )
{
return false;
}
$result = false;
if ( self::is_product_core_field( $field ) )
{
$update_data = [ $field => $value ];
if ( $field === 'product_url' )
{
$product_url = trim( (string) $value );
if ( $product_url === '' || in_array( strtolower( $product_url ), [ '0', '-', 'null' ], true ) )
{
$update_data['product_url'] = null;
}
else
{
$update_data['merchant_url_not_found'] = 0;
}
$update_data['merchant_url_last_check'] = date( 'Y-m-d H:i:s' );
}
$result = $mdb -> update( 'products', $update_data, [ 'id' => $product_id ] );
}
return $result; return $result;
} }
@@ -810,7 +1021,7 @@ class Products
return false; return false;
} }
$scope_exists = (int) $mdb -> count( 'products_temp', [ $scope_exists = (int) $mdb -> count( 'products_aggregate', [
'AND' => [ 'AND' => [
'product_id' => $product_id, 'product_id' => $product_id,
'campaign_id' => $campaign_id, 'campaign_id' => $campaign_id,
@@ -820,34 +1031,28 @@ class Products
if ( $scope_exists ) if ( $scope_exists )
{ {
$mdb -> update( 'products_temp', [
'name' => $product['name']
], [
'AND' => [
'product_id' => $product_id,
'campaign_id' => $campaign_id,
'ad_group_id' => $ad_group_id
]
] );
return true; return true;
} }
return $mdb -> insert( 'products_temp', [ return $mdb -> insert( 'products_aggregate', [
'product_id' => $product_id, 'product_id' => $product_id,
'campaign_id' => $campaign_id, 'campaign_id' => $campaign_id,
'ad_group_id' => $ad_group_id, 'ad_group_id' => $ad_group_id,
'name' => $product['name'],
'impressions' => 0,
'impressions_30' => 0, 'impressions_30' => 0,
'clicks' => 0,
'clicks_30' => 0, 'clicks_30' => 0,
'ctr' => 0, 'ctr_30' => 0,
'cost' => 0, 'cost_30' => 0,
'conversions' => 0, 'conversions_30' => 0,
'conversions_value' => 0, 'conversion_value_30' => 0,
'cpc' => 0, 'roas_30' => 0,
'roas' => 0 'impressions_all_time' => 0,
'clicks_all_time' => 0,
'ctr_all_time' => 0,
'cost_all_time' => 0,
'conversions_all_time' => 0,
'conversion_value_all_time' => 0,
'roas_all_time' => 0,
'date_sync' => date( 'Y-m-d' )
] ); ] );
} }
} }

View File

@@ -1023,6 +1023,58 @@ class GoogleAdsApi
return $this -> mutate( $customer_id, [ $operation ] ) !== false; return $this -> mutate( $customer_id, [ $operation ] ) !== false;
} }
public function remove_ad_group( $customer_id, $ad_group_id )
{
$customer_id = $this -> normalize_ads_customer_id( $customer_id );
$ad_group_id = (int) $ad_group_id;
if ( $customer_id === '' || $ad_group_id <= 0 )
{
self::set_setting( 'google_ads_last_error', 'Brak danych do usuniecia grupy reklam.' );
return [ 'success' => false, 'removed' => 0 ];
}
$resource_name = 'customers/' . $customer_id . '/adGroups/' . $ad_group_id;
$operation = [
'adGroupOperation' => [
'remove' => $resource_name
]
];
$mutate_result = $this -> mutate( $customer_id, [ $operation ] );
if ( $mutate_result === false )
{
$last_error = (string) self::get_setting( 'google_ads_last_error' );
$is_not_found = stripos( $last_error, 'NOT_FOUND' ) !== false
|| stripos( $last_error, 'RESOURCE_NOT_FOUND' ) !== false;
if ( $is_not_found )
{
return [
'success' => true,
'removed' => 0,
'not_found' => true,
'resource_name' => $resource_name
];
}
return [
'success' => false,
'removed' => 0,
'error' => $last_error,
'resource_name' => $resource_name
];
}
return [
'success' => true,
'removed' => 1,
'response' => $mutate_result,
'resource_name' => $resource_name
];
}
private function get_root_listing_group_resource_name( $customer_id, $ad_group_id ) private function get_root_listing_group_resource_name( $customer_id, $ad_group_id )
{ {
$customer_id = $this -> normalize_ads_customer_id( $customer_id ); $customer_id = $this -> normalize_ads_customer_id( $customer_id );
@@ -2468,8 +2520,11 @@ class GoogleAdsApi
return array_values( $campaigns ); return array_values( $campaigns );
} }
public function get_ad_groups_30_days( $customer_id ) public function get_ad_groups_30_days( $customer_id, $as_of_date = null )
{ {
$as_of_date = $as_of_date ? date( 'Y-m-d', strtotime( $as_of_date ) ) : date( 'Y-m-d' );
$date_from = date( 'Y-m-d', strtotime( '-29 days', strtotime( $as_of_date ) ) );
$gaql = "SELECT " $gaql = "SELECT "
. "campaign.id, " . "campaign.id, "
. "ad_group.id, " . "ad_group.id, "
@@ -2482,7 +2537,8 @@ class GoogleAdsApi
. "FROM ad_group " . "FROM ad_group "
. "WHERE campaign.status = 'ENABLED' " . "WHERE campaign.status = 'ENABLED' "
. "AND ad_group.status = 'ENABLED' " . "AND ad_group.status = 'ENABLED' "
. "AND segments.date DURING LAST_30_DAYS"; . "AND segments.date >= '" . $date_from . "' "
. "AND segments.date <= '" . $as_of_date . "'";
$results = $this -> search_stream( $customer_id, $gaql ); $results = $this -> search_stream( $customer_id, $gaql );
if ( $results === false ) return false; if ( $results === false ) return false;
@@ -2490,7 +2546,7 @@ class GoogleAdsApi
return $this -> aggregate_ad_groups( $results ); return $this -> aggregate_ad_groups( $results );
} }
public function get_ad_groups_all_time( $customer_id ) public function get_ad_groups_all_time( $customer_id, $as_of_date = null )
{ {
$gaql = "SELECT " $gaql = "SELECT "
. "campaign.id, " . "campaign.id, "
@@ -2505,7 +2561,24 @@ class GoogleAdsApi
. "WHERE campaign.status = 'ENABLED' " . "WHERE campaign.status = 'ENABLED' "
. "AND ad_group.status = 'ENABLED'"; . "AND ad_group.status = 'ENABLED'";
$results = $this -> search_stream( $customer_id, $gaql ); $results = false;
if ( $as_of_date )
{
$as_of_date = date( 'Y-m-d', strtotime( $as_of_date ) );
$gaql_with_date = $gaql . " AND segments.date <= '" . $as_of_date . "'";
$results = $this -> search_stream( $customer_id, $gaql_with_date );
// Fallback gdy konto nie akceptuje filtra daty na all-time.
if ( $results === false )
{
$results = $this -> search_stream( $customer_id, $gaql );
}
}
else
{
$results = $this -> search_stream( $customer_id, $gaql );
}
if ( $results === false ) return false; if ( $results === false ) return false;
return $this -> aggregate_ad_groups( $results ); return $this -> aggregate_ad_groups( $results );
@@ -2688,6 +2761,72 @@ class GoogleAdsApi
return array_values( $scopes ); return array_values( $scopes );
} }
public function get_shopping_ad_group_offer_ids_from_shopping_product( $customer_id )
{
$gaql = "SELECT "
. "campaign.id, "
. "campaign.name, "
. "campaign.status, "
. "campaign.advertising_channel_type, "
. "ad_group.id, "
. "ad_group.name, "
. "ad_group.status, "
. "shopping_product.item_id "
. "FROM shopping_product "
. "WHERE campaign.status != 'REMOVED' "
. "AND ad_group.status != 'REMOVED' "
. "AND campaign.advertising_channel_type = 'SHOPPING' "
. "AND segments.date DURING LAST_30_DAYS";
$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['shoppingProduct']['itemId'] ?? '' ) );
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_shopping_ad_group_offer_ids_from_performance( $customer_id ) public function get_shopping_ad_group_offer_ids_from_performance( $customer_id )
{ {
$gaql = "SELECT " $gaql = "SELECT "

View File

@@ -12,5 +12,5 @@ $settings['email_password'] = 'ProjectPro2025!';
$settings['cron_products_clients_per_run'] = 1; $settings['cron_products_clients_per_run'] = 1;
$settings['cron_campaigns_clients_per_run'] = 1; $settings['cron_campaigns_clients_per_run'] = 1;
$settings['cron_products_urls_limit_per_client'] = 10; $settings['cron_products_urls_limit_per_client'] = 100;
$settings['google_ads_conversion_window_days'] = 7; $settings['google_ads_conversion_window_days'] = 7;

View File

@@ -1,280 +0,0 @@
# adsPRO - System Zarządzania Reklamami Google ADS & Facebook ADS
## Opis projektu
adsPRO to narzędzie webowe (PHP) do zarządzania i automatyzacji kampanii reklamowych Google ADS (priorytet) oraz Facebook ADS (planowane). System umożliwia monitorowanie kampanii, zarządzanie produktami, analizę wydajności (ROAS, CTR, CPC) oraz automatyczne etykietowanie produktów (bestsellery, zombie itp.).
**URL:** https://adspro.projectpro.pl
**Hosting:** Hostido (shared hosting)
## Stack technologiczny
- **PHP 8.x** - czyste PHP z własną strukturą MVC (bez frameworka)
- **MySQL/MariaDB** - baza danych (Medoo ORM)
- **Google ADS API** - pobieranie danych kampanii i produktów (CRON)
- **Facebook ADS API** - planowane w przyszłości
- **CRON** - automatyczna synchronizacja danych
- **SCSS** - stylowanie (kompilacja do CSS)
- **jQuery 3.6** - interaktywność frontend
- **DataTables 2.1.7** - tabele z sortowaniem, filtrowaniem, paginacją
- **Highcharts** - wykresy wydajności
- **Select2** - zaawansowane selecty
- **Font Awesome** - ikony
## Struktura katalogów (nowa)
```
public_html/
├── index.php # Front controller + nowy router
├── .htaccess # Rewrite rules
├── .env # Konfiguracja (przyszłość - migracja z config.php)
├── config.php # Konfiguracja DB (obecna)
├── ajax.php # Ajax handler
├── api.php # API handler (Google ADS webhook)
├── cron.php # CRON handler
├── robots.txt
├── layout/
│ ├── favicon.png
│ ├── style.scss # Główne style (SCSS)
│ └── style.css # Skompilowane style
├── libraries/ # Biblioteki zewnętrzne
│ ├── medoo/
│ ├── phpmailer/
│ ├── select2/
│ ├── jquery-confirm/
│ ├── functions.js # Globalne funkcje JS
│ └── framework/ # Framework UI (skin, pluginy)
├── autoload/ # Kod PHP (MVC)
│ ├── class.S.php # Helper: sesje, requesty, narzędzia
│ ├── class.Tpl.php # Template engine
│ ├── class.Cache.php # Cache
│ ├── class.DbModel.php # Bazowy model DB
│ ├── class.Html.php # HTML helper
│ ├── controls/ # Kontrolery
│ │ ├── class.Site.php # Router (do przebudowy)
│ │ ├── class.Users.php # Logowanie, ustawienia
│ │ ├── class.Dashboard.php # NOWY - Dashboard główny
│ │ ├── class.Campaigns.php # Kampanie Google ADS
│ │ ├── class.Products.php # Produkty
│ │ ├── class.Allegro.php # Import Allegro
│ │ ├── class.Reports.php # NOWY - Raporty i analityka
│ │ ├── class.Api.php # Endpointy API
│ │ └── class.Cron.php # CRON joby
│ ├── factory/ # Modele danych (DB queries)
│ │ ├── class.Users.php
│ │ ├── class.Campaigns.php
│ │ ├── class.Products.php
│ │ └── class.Cron.php
│ └── view/ # View helpers
│ ├── class.Site.php # Renderer layoutu
│ ├── class.Users.php
│ └── class.Cron.php
├── templates/ # Szablony PHP
│ ├── site/
│ │ ├── layout-logged.php # Layout z sidebar (PRZEBUDOWA)
│ │ └── layout-unlogged.php # Layout logowania (PRZEBUDOWA)
│ ├── auth/
│ │ └── login.php # NOWY ekran logowania
│ ├── dashboard/
│ │ └── index.php # NOWY dashboard
│ ├── campaigns/
│ │ └── main_view.php # Widok kampanii
│ ├── products/
│ │ ├── main_view.php # Lista produktów
│ │ └── product_history.php # Historia produktu
│ ├── allegro/
│ │ └── main_view.php # Import Allegro
│ ├── reports/ # NOWE
│ │ └── index.php # Raporty
│ ├── users/
│ │ ├── login-form.php # Stary login (do usunięcia)
│ │ └── settings.php # Ustawienia użytkownika
│ └── html/ # Komponenty HTML
│ ├── button.php
│ ├── input.php
│ ├── select.php
│ └── ...
├── tools/
│ └── google-taxonomy.php
├── tmp/
└── docs/
└── PLAN.md
```
## Nowy system routingu
### Zasada działania
Zamiast obecnego `?module=X&action=Y` → czyste URLe obsługiwane przez `.htaccess` + nowy router w `class.Site.php`.
### Mapa URL
| URL | Kontroler | Metoda | Opis |
|-----|-----------|--------|------|
| `/login` | Users | login_form | Ekran logowania |
| `/logout` | Users | logout | Wylogowanie |
| `/` | Dashboard | index | Dashboard główny |
| `/campaigns` | Campaigns | main_view | Lista kampanii |
| `/campaigns/history/{id}` | Campaigns | history | Historia kampanii |
| `/products` | Products | main_view | Lista produktów |
| `/products/history/{id}` | Products | product_history | Historia produktu |
| `/allegro` | Allegro | main_view | Import Allegro |
| `/reports` | Reports | index | Raporty |
| `/settings` | Users | settings | Ustawienia konta |
| `/api/*` | Api | * | Endpointy API |
| `/cron/*` | Cron | * | CRON joby |
### Nowy .htaccess
```apache
RewriteEngine On
RewriteBase /
# Statyczne zasoby - pomijaj
RewriteCond %{REQUEST_URI} ^/(libraries|layout|upload|temp)/ [NC]
RewriteRule ^ - [L]
# Wszystko inne → index.php
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [L,QSA]
```
### Nowy router (class.Site.php)
```php
// Parsowanie URL z $_SERVER['REQUEST_URI']
// Mapowanie: /segment1/segment2/segment3 → kontroler/akcja/parametry
// Fallback na dashboard dla zalogowanych, login dla niezalogowanych
```
## Główne funkcje
### 1. Nowy ekran logowania
- Nowoczesny design: podzielony ekran (lewa strona - branding/grafika, prawa - formularz)
- Logo "adsPRO" z subtitlem
- Pola: email + hasło
- Checkbox "Zapamiętaj mnie"
- Walidacja AJAX
- Animacje przejścia
- Responsywność (mobile: tylko formularz)
### 2. Nowy layout z menu bocznym (sidebar)
- **Sidebar (lewa strona, 260px):**
- Logo "adsPRO" na górze
- Menu nawigacyjne z ikonami Font Awesome:
- 📊 Dashboard (`/`)
- 📢 Kampanie (`/campaigns`)
- 📦 Produkty (`/products`)
- 📥 Allegro import (`/allegro`)
- 📈 Raporty (`/reports`)
- ⚙️ Ustawienia (`/settings`)
- Aktywny element podświetlony
- Możliwość zwijania sidebar (collapsed → same ikony, 60px)
- Na dole: info o zalogowanym użytkowniku + przycisk wylogowania
- **Top bar (nad contentem):**
- Przycisk hamburger (toggle sidebar)
- Breadcrumbs (ścieżka nawigacji)
- Szybkie akcje / notyfikacje (przyszłość)
- **Content area:**
- Pełna szerokość minus sidebar
- Padding 25px
- Tło #F4F6F9 (jaśniejsze od obecnego)
### 3. Dashboard (NOWY)
- Kafelki podsumowujące (karty):
- Łączna liczba kampanii
- Łączna liczba produktów
- Średni ROAS (30 dni)
- Łączne wydatki (30 dni)
- Wykres trendu ROAS (ostatnie 30 dni)
- Lista ostatnio zmodyfikowanych kampanii
- Produkty wymagające uwagi (niski ROAS, zombie)
### 4. Zarządzanie kampaniami Google ADS
- Wybór klienta (select)
- Lista kampanii z metrykami (DataTables)
- Historia kampanii z wykresem Highcharts
- Metryki: ROAS, budżet, wydatki, wartość konwersji, strategia bidding
- Usuwanie kampanii i wpisów historii
- Komentarze do kampanii
### 5. Zarządzanie produktami
- Wybór klienta
- Konfiguracja min. ROAS dla bestsellerów
- Tabela produktów z metrykami:
- Wyświetlenia, kliknięcia, CTR, koszt, CPC
- Konwersje, wartość konwersji, ROAS
- Custom labels (bestseller/zombie/deleted/pla/paused)
- Edycja inline (min_roas, custom_label)
- Edycja produktu w modalu (tytuł, opis, kategoria Google)
- Historia produktu z wykresem
- Bulk delete zaznaczonych produktów
### 6. Import Allegro
- Upload pliku CSV
- Automatyczne mapowanie ofert
- Raport importu (dodane, zaktualizowane)
### 7. Raporty (NOWY - przyszłość)
- Raport wydajności kampanii
- Raport produktów (bestsellery vs zombie)
- Eksport do Excel
- Porównanie okresów
### 8. Ustawienia
- Dane konta (email)
- Zmiana hasła
- Konfiguracja Pushover (powiadomienia)
- Klucze API (przyszłość: Google ADS, Facebook ADS)
### 9. CRON - synchronizacja danych
- `cron_products` - synchronizacja produktów z Google ADS
- `cron_products_history_30` - historia 30-dniowa produktów
- `cron_xml` - generowanie XML
- `cron_phrases` - synchronizacja fraz
- `cron_phrases_history_30` - historia 30-dniowa fraz
## Plan implementacji
| Etap | Zakres | Priorytet | Pliki |
|------|--------|-----------|-------|
| **1. Nowy routing** | Przebudowa routera, nowy .htaccess, parsowanie czystych URL | 🔴 Wysoki | `.htaccess`, `index.php`, `controls/class.Site.php` |
| **2. Nowy layout (sidebar)** | Layout z bocznym menu, top bar, responsywność | 🔴 Wysoki | `templates/site/layout-logged.php`, `layout/style.scss` |
| **3. Nowy ekran logowania** | Nowoczesny split-screen login, nowy layout-unlogged | 🔴 Wysoki | `templates/site/layout-unlogged.php`, `templates/auth/login.php`, `layout/style.scss` |
| **4. Dashboard** | Nowa strona startowa z podsumowaniem | 🟡 Średni | `controls/class.Dashboard.php`, `templates/dashboard/index.php` |
| **5. Migracja kampanii** | Dostosowanie widoku kampanii do nowego routingu | 🟡 Średni | `controls/class.Campaigns.php`, `templates/campaigns/*` |
| **6. Migracja produktów** | Dostosowanie widoku produktów do nowego routingu | 🟡 Średni | `controls/class.Products.php`, `templates/products/*` |
| **7. Migracja Allegro** | Dostosowanie importu Allegro | 🟢 Niski | `controls/class.Allegro.php`, `templates/allegro/*` |
| **8. Moduł raportów** | Nowy moduł analityczny | 🟢 Niski | `controls/class.Reports.php`, `templates/reports/*` |
| **9. Facebook ADS** | Integracja z Facebook ADS API | 🔵 Przyszłość | Nowe kontrolery, factory, szablony |
## Kolorystyka i design
### Paleta kolorów
- **Primary (akcent):** `#6690F4` (niebieski - obecny)
- **Sidebar tło:** `#1E2A3A` (ciemny granat)
- **Sidebar tekst:** `#A8B7C7` (jasny szary)
- **Sidebar active:** `#6690F4` (primary)
- **Content tło:** `#F4F6F9` (jasnoszary)
- **Karty:** `#FFFFFF`
- **Tekst:** `#4E5E6A` (obecny)
- **Success:** `#57B951`
- **Danger:** `#CC0000`
- **Warning:** `#FF8C00`
### Typografia
- Font: Open Sans (obecny - zachowany)
- Rozmiar bazowy: 14px (sidebar), 15px (content)
## Bezpieczeństwo
- Hasła hashowane MD5 (obecne) → **TODO: migracja na bcrypt**
- Sesje PHP + cookie "zapamiętaj mnie"
- Prepared statements (Medoo ORM)
- htmlspecialchars() w szablonach
- **TODO: CSRF tokeny w formularzach**
- **TODO: migracja config.php → .env (z .htaccess deny)**
## Przyszłe rozszerzenia
- Facebook ADS API - zarządzanie kampaniami FB
- System powiadomień (Pushover + in-app)
- Wielojęzyczność (PL/EN)
- Role użytkowników (admin, manager, viewer)
- Automatyczne reguły (np. "jeśli ROAS < X → zmień label na zombie")
- Integracja z Google Merchant Center
- API REST do integracji z innymi systemami

View File

@@ -1,44 +1,24 @@
-- -------------------------------------------------------- -- Zrzut struktury tabela host700513_adspro.clients
-- Host: host700513.hostido.net.pl CREATE TABLE IF NOT EXISTS `clients` (
-- Wersja serwera: 10.11.15-MariaDB-cll-lve - MariaDB Server `id` int(11) NOT NULL AUTO_INCREMENT,
-- Serwer OS: Linux `name` varchar(255) NOT NULL DEFAULT '0',
-- HeidiSQL Wersja: 12.6.0.6765 `google_ads_customer_id` varchar(20) DEFAULT NULL,
-- -------------------------------------------------------- `google_merchant_account_id` varchar(32) DEFAULT NULL,
`google_ads_start_date` date DEFAULT NULL,
`active` int(11) DEFAULT 0,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=48 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET NAMES utf8 */;
/*!50503 SET NAMES utf8mb4 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
-- Zrzut struktury tabela host700513_adspro.campaigns
CREATE TABLE IF NOT EXISTS `campaigns` ( CREATE TABLE IF NOT EXISTS `campaigns` (
`id` int(11) NOT NULL AUTO_INCREMENT, `id` int(11) NOT NULL AUTO_INCREMENT,
`client_id` int(11) NOT NULL DEFAULT 0, `client_id` int(11) NOT NULL DEFAULT 0,
`campaign_id` bigint(20) NOT NULL DEFAULT 0, `campaign_id` bigint(20) NOT NULL DEFAULT 0,
`campaign_name` varchar(255) NOT NULL DEFAULT '0', `campaign_name` varchar(255) NOT NULL DEFAULT '0',
`advertising_channel_type` varchar(40) DEFAULT NULL,
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
KEY `client_id` (`client_id`), KEY `client_id` (`client_id`),
CONSTRAINT `FK__clients` FOREIGN KEY (`client_id`) REFERENCES `clients` (`id`) ON DELETE CASCADE ON UPDATE CASCADE CONSTRAINT `FK__clients` FOREIGN KEY (`client_id`) REFERENCES `clients` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=123 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB AUTO_INCREMENT=56 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Eksport danych został odznaczony.
-- Zrzut struktury tabela host700513_adspro.campaigns_comments
CREATE TABLE IF NOT EXISTS `campaigns_comments` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`campaign_id` int(11) NOT NULL,
`comment` text NOT NULL,
`date_add` date NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`) USING BTREE,
KEY `campaign_id` (`campaign_id`),
CONSTRAINT `FK_campaigns_comments_campaigns` FOREIGN KEY (`campaign_id`) REFERENCES `campaigns` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
-- Eksport danych został odznaczony.
-- Zrzut struktury tabela host700513_adspro.campaigns_history -- Zrzut struktury tabela host700513_adspro.campaigns_history
CREATE TABLE IF NOT EXISTS `campaigns_history` ( CREATE TABLE IF NOT EXISTS `campaigns_history` (
@@ -54,235 +34,7 @@ CREATE TABLE IF NOT EXISTS `campaigns_history` (
PRIMARY KEY (`id`) USING BTREE, PRIMARY KEY (`id`) USING BTREE,
KEY `offer_id` (`campaign_id`) USING BTREE, KEY `offer_id` (`campaign_id`) USING BTREE,
CONSTRAINT `FK_campaigns_history_campaigns` FOREIGN KEY (`campaign_id`) REFERENCES `campaigns` (`id`) ON DELETE CASCADE ON UPDATE CASCADE CONSTRAINT `FK_campaigns_history_campaigns` FOREIGN KEY (`campaign_id`) REFERENCES `campaigns` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=4400 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; ) ENGINE=InnoDB AUTO_INCREMENT=381 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
-- Eksport danych został odznaczony.
-- Zrzut struktury tabela host700513_adspro.clients
CREATE TABLE IF NOT EXISTS `clients` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) NOT NULL DEFAULT '0',
`google_ads_customer_id` varchar(20) DEFAULT NULL,
`google_merchant_account_id` varchar(32) DEFAULT NULL,
`google_ads_start_date` date DEFAULT NULL,
`deleted` int(11) DEFAULT 0,
`bestseller_min_roas` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=46 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Eksport danych został odznaczony.
-- Zrzut struktury tabela host700513_adspro.phrases
CREATE TABLE IF NOT EXISTS `phrases` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`client_id` int(11) NOT NULL DEFAULT 0,
`phrase` varchar(255) NOT NULL DEFAULT '0',
PRIMARY KEY (`id`),
KEY `FK_phrases_clients` (`client_id`),
CONSTRAINT `FK_phrases_clients` FOREIGN KEY (`client_id`) REFERENCES `clients` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=5512 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Eksport danych został odznaczony.
-- Zrzut struktury tabela host700513_adspro.phrases_history
CREATE TABLE IF NOT EXISTS `phrases_history` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`phrase_id` int(11) NOT NULL DEFAULT 0,
`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,
`conversions_value` decimal(20,6) NOT NULL DEFAULT 0.000000,
`date_add` date NOT NULL DEFAULT '0000-00-00',
`updated` int(11) NOT NULL DEFAULT 0,
`deleted` int(11) DEFAULT 0,
PRIMARY KEY (`id`) USING BTREE,
KEY `offer_id` (`phrase_id`) USING BTREE,
CONSTRAINT `FK_phrases_history_phrases` FOREIGN KEY (`phrase_id`) REFERENCES `phrases` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=13088 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
-- Eksport danych został odznaczony.
-- Zrzut struktury tabela host700513_adspro.phrases_history_30
CREATE TABLE IF NOT EXISTS `phrases_history_30` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`phrase_id` int(11) NOT NULL,
`impressions` int(11) NOT NULL,
`clicks` int(11) NOT NULL,
`cost` decimal(20,6) NOT NULL,
`conversions` decimal(20,6) NOT NULL DEFAULT 0.000000,
`conversions_value` decimal(20,6) NOT NULL,
`roas` decimal(20,6) NOT NULL,
`date_add` date NOT NULL DEFAULT '0000-00-00',
`deleted` int(11) DEFAULT 0,
PRIMARY KEY (`id`) USING BTREE,
KEY `offer_id` (`phrase_id`) USING BTREE,
CONSTRAINT `FK_phrases_history_30_phrases` FOREIGN KEY (`phrase_id`) REFERENCES `phrases` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=1795 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
-- Eksport danych został odznaczony.
-- Zrzut struktury tabela host700513_adspro.phrases_temp
CREATE TABLE IF NOT EXISTS `phrases_temp` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`phrase_id` int(11) DEFAULT NULL,
`phrase` varchar(255) DEFAULT NULL,
`impressions` int(11) DEFAULT NULL,
`clicks` int(11) DEFAULT NULL,
`cost` decimal(20,6) DEFAULT NULL,
`conversions` decimal(20,6) DEFAULT NULL,
`conversions_value` decimal(20,6) DEFAULT NULL,
`cpc` decimal(20,6) DEFAULT NULL,
`roas` decimal(20,0) DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE,
KEY `offer_id` (`phrase_id`) USING BTREE,
CONSTRAINT `FK_phrases_temp_phrases` FOREIGN KEY (`phrase_id`) REFERENCES `phrases` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=353973 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
-- Eksport danych został odznaczony.
-- Zrzut struktury tabela host700513_adspro.products
CREATE TABLE IF NOT EXISTS `products` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`client_id` int(11) NOT NULL DEFAULT 0,
`offer_id` varchar(50) NOT NULL DEFAULT '0',
`name` varchar(255) NOT NULL DEFAULT '0',
`min_roas` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `FK_offers_clients` (`client_id`),
CONSTRAINT `FK_offers_clients` FOREIGN KEY (`client_id`) REFERENCES `clients` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=5927 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Eksport danych został odznaczony.
-- Zrzut struktury tabela host700513_adspro.products_comments
CREATE TABLE IF NOT EXISTS `products_comments` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`product_id` int(11) NOT NULL,
`comment` text NOT NULL,
`date_add` date NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
KEY `product_id` (`product_id`) USING BTREE,
CONSTRAINT `FK_products_comments_products` FOREIGN KEY (`product_id`) REFERENCES `products` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=118 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Eksport danych został odznaczony.
-- Zrzut struktury tabela host700513_adspro.products_data
CREATE TABLE IF NOT EXISTS `products_data` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`product_id` int(11) DEFAULT NULL,
`custom_label_4` varchar(255) DEFAULT NULL,
`custom_label_3` varchar(255) DEFAULT NULL,
`title` varchar(255) DEFAULT NULL,
`description` text DEFAULT NULL,
`google_product_category` text DEFAULT NULL,
`product_url` varchar(500) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `product_id` (`product_id`) USING BTREE,
CONSTRAINT `FK_products_data_products` FOREIGN KEY (`product_id`) REFERENCES `products` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=118 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Eksport danych został odznaczony.
-- Zrzut struktury tabela host700513_adspro.products_history
CREATE TABLE IF NOT EXISTS `products_history` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`product_id` int(11) NOT NULL DEFAULT 0,
`impressions` int(11) NOT NULL DEFAULT 0,
`clicks` int(11) NOT NULL DEFAULT 0,
`ctr` decimal(20,6) NOT NULL DEFAULT 0.000000,
`cost` decimal(20,6) NOT NULL DEFAULT 0.000000,
`conversions` decimal(20,6) NOT NULL DEFAULT 0.000000,
`conversions_value` decimal(20,6) NOT NULL DEFAULT 0.000000,
`date_add` date NOT NULL DEFAULT '0000-00-00',
`updated` int(11) NOT NULL DEFAULT 0,
`deleted` int(11) DEFAULT 0,
PRIMARY KEY (`id`) USING BTREE,
KEY `product_id` (`product_id`) USING BTREE,
CONSTRAINT `FK_products_history_products` FOREIGN KEY (`product_id`) REFERENCES `products` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=63549 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
-- Eksport danych został odznaczony.
-- Zrzut struktury tabela host700513_adspro.products_history_30
CREATE TABLE IF NOT EXISTS `products_history_30` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`product_id` int(11) NOT NULL,
`impressions` int(11) NOT NULL,
`clicks` int(11) NOT NULL,
`ctr` decimal(20,6) NOT NULL DEFAULT 0.000000,
`cost` decimal(20,6) NOT NULL,
`conversions` decimal(20,6) NOT NULL DEFAULT 0.000000,
`conversions_value` decimal(20,6) NOT NULL,
`roas` decimal(20,6) NOT NULL,
`roas_all_time` decimal(20,6) NOT NULL,
`date_add` date NOT NULL DEFAULT '0000-00-00',
`deleted` int(11) DEFAULT 0,
PRIMARY KEY (`id`),
KEY `product_id` (`product_id`) USING BTREE,
CONSTRAINT `FK_products_history_30_products` FOREIGN KEY (`product_id`) REFERENCES `products` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=27655 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Eksport danych został odznaczony.
-- Zrzut struktury tabela host700513_adspro.products_temp
CREATE TABLE IF NOT EXISTS `products_temp` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`product_id` int(11) DEFAULT NULL,
`name` varchar(255) DEFAULT NULL,
`impressions` int(11) DEFAULT NULL,
`impressions_30` int(11) DEFAULT NULL,
`clicks` int(11) DEFAULT NULL,
`clicks_30` int(11) DEFAULT NULL,
`ctr` decimal(20,6) DEFAULT NULL,
`cost` decimal(20,6) DEFAULT NULL,
`conversions` decimal(20,6) DEFAULT NULL,
`conversions_value` decimal(20,6) DEFAULT NULL,
`cpc` decimal(20,6) DEFAULT NULL,
`roas` decimal(20,0) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `product_id` (`product_id`) USING BTREE,
CONSTRAINT `FK_products_temp_products` FOREIGN KEY (`product_id`) REFERENCES `products` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=298845 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Eksport danych został odznaczony.
-- Zrzut struktury tabela host700513_adspro.settings
CREATE TABLE IF NOT EXISTS `settings` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`setting_key` varchar(100) NOT NULL,
`setting_value` text DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_setting_key` (`setting_key`)
) ENGINE=InnoDB AUTO_INCREMENT=15 DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci;
-- Eksport danych został odznaczony.
-- Zrzut struktury tabela host700513_adspro.users
CREATE TABLE IF NOT EXISTS `users` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`email` varchar(255) NOT NULL,
`password` varchar(255) NOT NULL,
`name` varchar(255) DEFAULT NULL,
`surname` varchar(255) DEFAULT NULL,
`default_project` int(11) DEFAULT NULL,
`color` varchar(50) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `email` (`email`)
) ENGINE=InnoDB AUTO_INCREMENT=11 DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_polish_ci;
-- Eksport danych został odznaczony.
/*!40103 SET TIME_ZONE=IFNULL(@OLD_TIME_ZONE, 'system') */;
/*!40101 SET SQL_MODE=IFNULL(@OLD_SQL_MODE, '') */;
/*!40014 SET FOREIGN_KEY_CHECKS=IFNULL(@OLD_FOREIGN_KEY_CHECKS, 1) */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40111 SET SQL_NOTES=IFNULL(@OLD_SQL_NOTES, 1) */;
-- ================================
-- DODANE: struktury kampanie > grupy/frazy
-- ================================
CREATE TABLE IF NOT EXISTS `campaign_ad_groups` ( CREATE TABLE IF NOT EXISTS `campaign_ad_groups` (
`id` int(11) NOT NULL AUTO_INCREMENT, `id` int(11) NOT NULL AUTO_INCREMENT,
@@ -304,67 +56,122 @@ CREATE TABLE IF NOT EXISTS `campaign_ad_groups` (
`date_sync` date DEFAULT NULL, `date_sync` date DEFAULT NULL,
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
UNIQUE KEY `uk_campaign_ad_groups_campaign_ad_group` (`campaign_id`,`ad_group_id`), UNIQUE KEY `uk_campaign_ad_groups_campaign_ad_group` (`campaign_id`,`ad_group_id`),
KEY `idx_campaign_ad_groups_campaign_id` (`campaign_id`) KEY `idx_campaign_ad_groups_campaign_id` (`campaign_id`),
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; CONSTRAINT `FK_campaign_ad_groups_campaigns` FOREIGN KEY (`campaign_id`) REFERENCES `campaigns` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=125 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `campaign_search_terms` ( CREATE TABLE IF NOT EXISTS `campaign_alerts` (
`id` int(11) NOT NULL AUTO_INCREMENT, `id` int(11) NOT NULL AUTO_INCREMENT,
`campaign_id` int(11) NOT NULL, `client_id` int(11) NOT NULL,
`ad_group_id` int(11) NOT NULL, `campaign_id` int(11) DEFAULT NULL,
`search_term` varchar(255) NOT NULL, `campaign_external_id` bigint(20) DEFAULT NULL,
`impressions_30` int(11) NOT NULL DEFAULT 0,
`clicks_30` int(11) NOT NULL DEFAULT 0,
`cost_30` decimal(20,6) NOT NULL DEFAULT 0.000000,
`conversions_30` decimal(20,6) NOT NULL DEFAULT 0.000000,
`conversion_value_30` decimal(20,6) NOT NULL DEFAULT 0.000000,
`roas_30` decimal(20,6) NOT NULL DEFAULT 0.000000,
`impressions_all_time` int(11) NOT NULL DEFAULT 0,
`clicks_all_time` int(11) NOT NULL DEFAULT 0,
`cost_all_time` decimal(20,6) NOT NULL DEFAULT 0.000000,
`conversions_all_time` decimal(20,6) NOT NULL DEFAULT 0.000000,
`conversion_value_all_time` decimal(20,6) NOT NULL DEFAULT 0.000000,
`roas_all_time` decimal(20,6) NOT NULL DEFAULT 0.000000,
`date_sync` date DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_campaign_search_terms` (`campaign_id`,`ad_group_id`,`search_term`),
KEY `idx_campaign_search_terms_campaign_id` (`campaign_id`),
KEY `idx_campaign_search_terms_ad_group_id` (`ad_group_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `campaign_keywords` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`campaign_id` int(11) NOT NULL,
`ad_group_id` int(11) NOT NULL,
`keyword_text` varchar(255) NOT NULL,
`match_type` varchar(40) DEFAULT NULL,
`impressions_30` int(11) NOT NULL DEFAULT 0,
`clicks_30` int(11) NOT NULL DEFAULT 0,
`cost_30` decimal(20,6) NOT NULL DEFAULT 0.000000,
`conversions_30` decimal(20,6) NOT NULL DEFAULT 0.000000,
`conversion_value_30` decimal(20,6) NOT NULL DEFAULT 0.000000,
`roas_30` decimal(20,6) NOT NULL DEFAULT 0.000000,
`impressions_all_time` int(11) NOT NULL DEFAULT 0,
`clicks_all_time` int(11) NOT NULL DEFAULT 0,
`cost_all_time` decimal(20,6) NOT NULL DEFAULT 0.000000,
`conversions_all_time` decimal(20,6) NOT NULL DEFAULT 0.000000,
`conversion_value_all_time` decimal(20,6) NOT NULL DEFAULT 0.000000,
`roas_all_time` decimal(20,6) NOT NULL DEFAULT 0.000000,
`date_sync` date DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_campaign_keywords` (`campaign_id`,`ad_group_id`,`keyword_text`(191),`match_type`),
KEY `idx_campaign_keywords_campaign_id` (`campaign_id`),
KEY `idx_campaign_keywords_ad_group_id` (`ad_group_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `campaign_negative_keywords` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`campaign_id` int(11) NOT NULL,
`ad_group_id` int(11) DEFAULT NULL, `ad_group_id` int(11) DEFAULT NULL,
`scope` varchar(20) NOT NULL DEFAULT 'campaign', `ad_group_external_id` bigint(20) DEFAULT NULL,
`keyword_text` varchar(255) NOT NULL, `product_id` int(11) DEFAULT NULL,
`match_type` varchar(40) DEFAULT NULL, `alert_type` varchar(120) NOT NULL,
`date_sync` date DEFAULT NULL, `message` text NOT NULL,
`meta_json` text DEFAULT NULL,
`date_detected` date NOT NULL,
`date_add` datetime NOT NULL DEFAULT current_timestamp(),
`unseen` tinyint(1) NOT NULL DEFAULT 1,
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
KEY `idx_campaign_negative_keywords_campaign_id` (`campaign_id`), UNIQUE KEY `uniq_alert_daily` (`client_id`,`campaign_external_id`,`ad_group_external_id`,`alert_type`,`date_detected`),
KEY `idx_campaign_negative_keywords_ad_group_id` (`ad_group_id`) 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`),
KEY `idx_alert_unseen` (`unseen`),
KEY `idx_alert_product` (`product_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci;
CREATE TABLE IF NOT EXISTS `products` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`client_id` int(11) NOT NULL DEFAULT 0,
`offer_id` varchar(50) NOT NULL DEFAULT '0',
`name` varchar(255) NOT NULL DEFAULT '0',
`min_roas` int(11) DEFAULT NULL,
`custom_label_4` varchar(255) DEFAULT NULL,
`custom_label_3` varchar(255) DEFAULT NULL,
`title` varchar(255) DEFAULT NULL,
`description` text DEFAULT NULL,
`google_product_category` text DEFAULT NULL,
`product_url` varchar(500) DEFAULT NULL,
`merchant_url_not_found` tinyint(1) NOT NULL DEFAULT 0,
`merchant_url_last_check` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `FK_offers_clients` (`client_id`),
CONSTRAINT `FK_offers_clients` FOREIGN KEY (`client_id`) REFERENCES `clients` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=8482 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `products_history` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`product_id` int(11) NOT NULL DEFAULT 0,
`campaign_id` int(11) NOT NULL DEFAULT 0,
`ad_group_id` int(11) NOT NULL DEFAULT 0,
`impressions` int(11) NOT NULL DEFAULT 0,
`clicks` int(11) NOT NULL DEFAULT 0,
`ctr` decimal(20,6) NOT NULL DEFAULT 0.000000,
`cost` decimal(20,6) NOT NULL DEFAULT 0.000000,
`conversions` decimal(20,6) NOT NULL DEFAULT 0.000000,
`conversions_value` decimal(20,6) NOT NULL DEFAULT 0.000000,
`date_add` date NOT NULL DEFAULT '0000-00-00',
`updated` int(11) NOT NULL DEFAULT 0,
`deleted` int(11) DEFAULT 0,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `uk_products_history_scope_day` (`product_id`,`campaign_id`,`ad_group_id`,`date_add`),
KEY `product_id` (`product_id`) USING BTREE,
KEY `idx_products_history_campaign_id` (`campaign_id`),
KEY `idx_products_history_ad_group_id` (`ad_group_id`),
CONSTRAINT `FK_products_history_products` FOREIGN KEY (`product_id`) REFERENCES `products` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=37033 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
CREATE TABLE IF NOT EXISTS `products_history_30` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`product_id` int(11) NOT NULL,
`campaign_id` int(11) NOT NULL DEFAULT 0,
`ad_group_id` int(11) NOT NULL DEFAULT 0,
`impressions` int(11) NOT NULL,
`clicks` int(11) NOT NULL,
`ctr` decimal(20,6) NOT NULL DEFAULT 0.000000,
`cost` decimal(20,6) NOT NULL,
`conversions` decimal(20,6) NOT NULL DEFAULT 0.000000,
`conversions_value` decimal(20,6) NOT NULL,
`roas` decimal(20,6) NOT NULL,
`roas_all_time` decimal(20,6) NOT NULL,
`date_add` date NOT NULL DEFAULT '0000-00-00',
`deleted` int(11) DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_products_history_30_scope_day` (`product_id`,`campaign_id`,`ad_group_id`,`date_add`),
KEY `product_id` (`product_id`) USING BTREE,
KEY `idx_products_history_30_campaign_id` (`campaign_id`),
KEY `idx_products_history_30_ad_group_id` (`ad_group_id`),
CONSTRAINT `FK_products_history_30_products` FOREIGN KEY (`product_id`) REFERENCES `products` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `products_aggregate` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`product_id` int(11) NOT NULL,
`campaign_id` int(11) NOT NULL DEFAULT 0,
`ad_group_id` int(11) NOT NULL DEFAULT 0,
`impressions_30` int(11) NOT NULL DEFAULT 0,
`clicks_30` int(11) NOT NULL DEFAULT 0,
`ctr_30` decimal(20,6) NOT NULL DEFAULT 0.000000,
`cost_30` decimal(20,6) NOT NULL DEFAULT 0.000000,
`conversions_30` decimal(20,6) NOT NULL DEFAULT 0.000000,
`conversion_value_30` decimal(20,6) NOT NULL DEFAULT 0.000000,
`roas_30` decimal(20,6) NOT NULL DEFAULT 0.000000,
`impressions_all_time` int(11) NOT NULL DEFAULT 0,
`clicks_all_time` int(11) NOT NULL DEFAULT 0,
`ctr_all_time` decimal(20,6) NOT NULL DEFAULT 0.000000,
`cost_all_time` decimal(20,6) NOT NULL DEFAULT 0.000000,
`conversions_all_time` decimal(20,6) NOT NULL DEFAULT 0.000000,
`conversion_value_all_time` decimal(20,6) NOT NULL DEFAULT 0.000000,
`roas_all_time` decimal(20,6) NOT NULL DEFAULT 0.000000,
`date_sync` date NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_products_aggregate_scope` (`product_id`,`campaign_id`,`ad_group_id`),
KEY `idx_products_aggregate_campaign_id` (`campaign_id`),
KEY `idx_products_aggregate_ad_group_id` (`ad_group_id`),
KEY `idx_products_aggregate_date_sync` (`date_sync`),
CONSTRAINT `FK_products_aggregate_products` FOREIGN KEY (`product_id`) REFERENCES `products` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -1,55 +1,397 @@
# adsPRO - Pamiec projektu # 2026-02-20 - Obsluga statusu ACTIVE dla klientow
Ten plik sluzy jako trwala pamiec dla Claude Code. Zapisuj tu wzorce, decyzje i ustalenia potwierdzone w trakcie pracy nad projektem. ## Zmienione pliki
## Architektura - `autoload/controls/class.Clients.php`
- `save()` zapisuje teraz pole `active` (domyslnie `1`, gdy brak wartosci z formularza).
- Dodana nowa akcja `set_active()` pod endpoint `/clients/set_active` do szybkiej zmiany statusu klienta AJAX-em.
- `force_sync()` ma dodatkowa walidacje:
- nie pozwala kolejkowac synchronizacji dla klienta nieaktywnego (`active != 1`),
- nadal blokuje klienta usunietego (`deleted = 1`) i klienta bez wymaganych ID.
- Kompatybilnosc schematu `clients` bez kolumny `deleted`:
- helpery `clients_has_deleted_column()` i `sql_clients_not_deleted()`,
- `force_sync()` i `sync_status()` nie wywalaja sie, gdy w bazie nie ma kolumny `deleted`.
- Custom MVC: Controllers (`\controls`) -> Factories (`\factory`) -> Medoo ORM (`$mdb`) - `templates/clients/main_view.php`
- Autoload PSR-0: `\controls\Foo` -> `autoload/controls/class.Foo.php` - Tabela klientow ma nowa kolumne `Status` (Aktywny/Nieaktywny).
- Routing w `index.php`: URL `/module/action/` -> `\controls\Module::action()` - Wiersz klienta trzyma `data-active` do obslugi UI i synchronizacji.
- Szablony w `templates/`, zmienne przez `$this->varName` - Dodany przycisk toggle (ikona `fa-toggle-on/off`) do natychmiastowej aktywacji/dezaktywacji.
- Serwisy API: `\services\GoogleAdsApi`, `\services\ClaudeApi`, `\services\OpenAiApi` - Przyciski synchronizacji (kampanie/produkty/merchant) sa blokowane (`disabled`) dla nieaktywnego klienta i odblokowywane po aktywacji.
- Formularz Dodaj/Edytuj klienta ma nowe pole `Status klienta` (`active`).
- JS:
- `toggleClientActive()` wysyla POST na `/clients/set_active`,
- `updateClientStatusUI()` odswieza status i stan komorki Sync bez przeladowania strony,
- `loadSyncStatus()` pomija paski postepu dla nieaktywnych klientow i pokazuje `nieaktywny`.
## Styl kodu ## Gdzie to jest wykorzystywane
- Spacje w nawiasach: `if ( $x )`, `function( $a, $b )` - Zarzadzanie statusem klienta:
- Klamry w nowej linii - UI listy i formularza: `templates/clients/main_view.php`
- Wszystkie metody kontrolerow i fabryk: `static public function` - Backend zapisu i toggle: `autoload/controls/class.Clients.php`
- Endpointy JSON: `echo json_encode([...]); exit;` - Ograniczenie recznego wymuszenia synchronizacji do klientow aktywnych:
- Commity po polsku z prefixem: `feat:`, `fix:`, `update:` - `autoload/controls/class.Clients.php` (`force_sync()`)
## Frontend # 2026-02-20 - CRON kampanii (nowy przebieg, stare jako archiwum)
- jQuery 3.6, DataTables 2.1, Bootstrap 4, Select2 4.1, Highcharts ## Zmienione pliki
- jquery-confirm do modali/dialogow
- Font Awesome 6.5 do ikon
- SASS: `layout/style.scss` -> auto-kompilacja przez Live Sass Compiler
## Deployment - `autoload/controls/class.Cron.php`
- Dodany nowy `cron_campaigns()` jako glowny endpoint pod nowy przeplyw.
- Stary kod zostal zachowany jako archiwum: `cron_campaigns_archive()`.
- Nowy przebieg:
- bierze tylko aktywnych klientow (`active = 1`) z Google Ads Customer ID,
- liczy okno dat na podstawie `google_ads_conversion_window_days` z `config.php` (z fallbackiem),
- konczy okno na `przedwczoraj` (bez pobierania danych dzisiejszych),
- przechodzi po datach dzien po dniu (rosnaco),
- zapisuje/aktualizuje kampanie do `campaigns`,
- zapisuje/aktualizuje historie dzienne do `campaigns_history` (upsert po `campaign_id + date_add`),
- zapisuje grupy reklam / groupy PMAX do `campaign_ad_groups`.
- po zakonczeniu kampanii + ad groups dla klienta, dla calego okna dat pobiera search terms dzienne do `campaign_search_terms_history`,
- po pobraniu historii search terms wykonuje agregacje do `campaign_search_terms` (zanim przejdzie do kolejnego klienta).
- Dodany krok syncu fraz dodanych i wykluczonych:
- tabele docelowe: `campaign_keywords` i `campaign_negative_keywords`,
- uruchamiany raz na cykl klienta (po ostatnim dniu okna), nie x razy dla kazdego dnia.
- Kampanie produktowe / PMAX:
- nie maja fraz dodanych, wiec w `campaign_keywords` moga miec 0 rekordow,
- frazy wykluczone sa dalej synchronizowane do `campaign_negative_keywords`.
- FTP auto-upload przez VS Code FTP-Kr # 2026-02-20 - Produkty: przygotowanie schematu bazy
- Brak kroku budowania - pliki laduja bezposrednio na serwer
- Migracje: `php install.php` (idempotentne, sledzenie w `schema_migrations`)
## Decyzje projektowe ## Zmienione pliki
- Frazy wyszukiwane dodane do wykluczonych oznaczane czerwonym kolorem (klasa CSS `term-is-negative`) - `migrations/016_products_model_unification.sql`
- Negatywne slowa kluczowe dodawane przez Google Ads API i zapisywane lokalnie w `campaign_negative_keywords` - Dodane kolumny produktowe bezposrednio do `products`:
- Klucze API przechowywane w tabeli `settings` (key-value) - `custom_label_4`, `custom_label_3`, `title`, `description`, `google_product_category`, `product_url`.
- Frazy z Google Ads Keyword Planner dla URL produktu sa cachowane w `products_keyword_planner_terms` i ponownie uzywane przy generowaniu tytulu AI - Backfill danych z `products_data` -> `products` (tylko gdy pole w `products` jest puste).
- Zmiany produktowe (`title`, `description`, `google_product_category`, `custom_label_4`) sa synchronizowane bezposrednio do Merchant API i logowane per pole w `products_merchant_sync_log` - Dodana nowa tabela agregacyjna `products_aggregate`:
- CRON dziala w trybie **klient po kliencie** (client-first): konczy WSZYSTKIE daty jednego klienta, potem przechodzi do nastepnego. Dzieki temu paski postepu na `/clients` roznia sie miedzy klientami. - scope: `product_id + campaign_id + ad_group_id` (unikalne),
- `cron_products` iteruje po datach per klient (`dates_per_run` z parametru `clients_per_run`), domyslnie `10` (max `100`); faza `aggregate_30` wywoluje `rebuild_products_temp` RAZ per klient - metryki `*_30` i `*_all_time`,
- `cron_campaigns` iteruje po datach per klient (`dates_per_run` z parametru `clients_per_run`), domyslnie `2` (max `20`) - `date_sync` (kiedy agregat byl przeliczony).
- Helpery: `get_active_client($pipeline)` -> pierwszy klient z niezakonczona praca; `get_pending_dates_for_client()` -> daty do przetworzenia; `determine_client_products_phase()` -> faza per klient
- Stan CRON przechowywany w tabeli `cron_sync_status` (wiersz = klient + pipeline + data + phase), zamiast JSON w `settings` (migracja 012)
- Fazy produktow w `cron_sync_status`: pending -> fetch -> aggregate_30 -> done; kampanie: pending -> done
- Force sync klienta = DELETE z `cron_sync_status` (wiersze odtwarzane przez `ensure_sync_rows` w nastepnym cyklu CRON)
- Nowy klient/usuniety klient obslugiwany naturalnie: `ensure_sync_rows` dodaje brakujace, JOIN z `clients` pomija usunietych
- `cleanup_old_sync_rows(30)` czysci zakonczone wiersze starsze niz 30 dni i wiersze usunietych klientow
## Preferencje uzytkownika - `docs/database.sql`
- Zaktualizowana definicja `products` o nowe kolumny danych produktu.
- Dodana definicja tabeli `products_aggregate`.
- Komunikacja po polsku ## Ustalenie projektowe
- Zwiezle commity po polsku
- Git push tylko na wyrazna prosbe - `products` staje sie glowna tabela danych produktu.
- `products_data` zostaje tymczasowo dla kompatybilnosci starego kodu; dane sa migrowane do `products`.
- Agregaty dla widokow `/products` powinny docelowo byc czytane z `products_aggregate` zamiast liczenia w locie.
# 2026-02-20 - Produkty: przepiecie na `products` + agregaty
## Zmienione pliki
- `autoload/factory/class.Products.php`
- `get_product_data()`:
- najpierw czyta pola produktowe z `products` (`custom_label_4`, `custom_label_3`, `title`, `description`, `google_product_category`, `product_url`),
- fallback do `products_data` dla kompatybilnosci.
- `set_product_data()`:
- zapisuje pole glownie do `products`,
- rownolegle mirroruje zapis do `products_data` (kompatybilnosc starego kodu).
- `autoload/controls/class.Cron.php`
- `sync_products_fetch_for_client()`:
- import produktow zapisuje dane produktowe bezposrednio do `products` (w tym `title`, `product_url`),
- usuniete poleganie na `products_data` podczas samego fetchu.
- `aggregate_products_history_30_for_client()`:
- po przeliczeniu `products_history_30` odpala przebudowe agregatow `products_aggregate` dla klienta i dnia.
- Dodana metoda `rebuild_products_aggregate_for_client( $client_id, $date_sync )`:
- liczy metryki `*_30` i `*_all_time` z `products_history`,
- zapisuje scope (`product_id + campaign_id + ad_group_id`) do `products_aggregate`.
- `rebuild_products_temp_for_client()`:
- przestawione z liczenia bezposrednio po `products_history` na odczyt z `products_aggregate`,
- zmniejsza liczenie "w locie" dla widoku `/products`.
- `cron_product_history_30_save()`:
- `products_history_30` przechowuje teraz srednie dzienne wartosci z okna do 30 dni (zamiast sumy okna),
- nadal zapisuje `roas_all_time` dla danego dnia.
- `generate_custom_feed_for_client()`:
- zrodlo danych produktowych przepiete na `products` (bez wymaganego `INNER JOIN products_data`).
- diagnostyka i pobieranie brakujacych URL (`cron_products_urls`):
- logika "ma URL / brak URL" bierze pod uwage `products.product_url` z fallbackiem do `products_data`.
## Gdzie to jest wykorzystywane
- Pipeline produktowy:
- `/cron/cron_products`
- etap `fetch` -> `products_history`,
- etap agregacji -> `products_history_30` + `products_aggregate`,
- etap finalny -> `products_temp` budowane z `products_aggregate`.
- Widok tabeli produktow `/products`:
- dane nadal czytane z `products_temp`, ale `products_temp` jest teraz zasilane agregatami z `products_aggregate`.
- Dodany helper `sync_campaigns_snapshot_for_client()` dla nowego przebiegu kampanii.
- Dodany helper `sync_campaign_terms_backfill_for_client()` dla kroku fraz (history + agregacja).
- Tryb wykonania nowego pipeline kampanii: 1 dzien = 1 wywolanie CRON.
- Na jednym wywolaniu: kampanie + ad groups + search terms history + agregacja search terms dla jednego dnia.
- Kolejne wywolanie przechodzi do kolejnego dnia dla tego samego klienta.
- Tryb debug dla nowego CRON:
- `?debug=true` zwraca czytelny HTML (podsumowanie + pelny payload),
- bez debug zwracany jest standardowy JSON.
- Dodany helper `cleanup_pipeline_rows_outside_window()` aby pipeline kampanii trzymal tylko aktualne okno dat.
- Filtry klientow w nowym CRON kampanii sa odporne na stare dane (`NULL`): `COALESCE(active,0)`, `COALESCE(deleted,0)`, `TRIM(COALESCE(google_ads_customer_id,''))`.
- Dodana kompatybilnosc schematu `clients` bez kolumny `deleted`:
- helpery: `clients_has_column()`, `sql_clients_not_deleted()`, `sql_clients_deleted()`,
- nowy pipeline kampanii (`cron_campaigns`/`cron_universal`) nie wywala sie na bazie bez `deleted`.
- `get_conversion_window_days( $prefer_config = false )` uwzglednia teraz konfiguracje z `config.php`.
- `sync_campaign_ad_groups_for_client()` dostal parametr `as_of_date`.
- `autoload/services/class.GoogleAdsApi.php`
- `get_ad_groups_30_days()` wspiera teraz parametr `as_of_date` i zakres dat `[as_of_date-29, as_of_date]`.
- `get_ad_groups_all_time()` wspiera teraz parametr `as_of_date` (filtr `segments.date <= as_of_date` z fallbackiem).
## Gdzie to jest wykorzystywane
- Głowny CRON kampanii: `/cron/cron_campaigns` -> `\controls\Cron::cron_campaigns()`.
- Uniwersalny CRON pipeline (zalecany endpoint): `/cron/cron_universal` -> `\controls\Cron::cron_universal()` (aktualnie deleguje do kroku kampanii).
- Archiwalny CRON kampanii (stara logika): `/cron/cron_campaigns_archive`.
- Dane do wykresow/tabel kampanii pozostaja pobierane z `campaigns_history`.
# 2026-02-20 - CRON uniwersalny jako glowny endpoint (1 dzien na wywolanie)
## Zmienione pliki
- `autoload/controls/class.Cron.php`
- `cron_universal()` nie deleguje juz do `cron_campaigns()`.
- W jednym wywolaniu realizuje sekwencje:
- `kampanie` (snapshot + ad groups + search terms + agregacja),
- `produkty` (fetch + `products_history_30` + `products_aggregate` + `products_temp`).
- Tryb pracy pozostaje: `1 wywolanie = 1 klient + 1 dzien`.
- Status dnia jest zapisywany do `cron_sync_status` dla obu pipeline:
- `campaigns`,
- `products`.
- Gdy krok kampanii zwroci blad, krok produktow dla tego dnia jest pomijany (`products_sync_skipped_reason=campaigns_failed`).
## Gdzie to jest wykorzystywane
- Docelowy adres CRON:
- `/cron/cron_universal?debug=true`
- Stare endpointy (`/cron/cron_campaigns`, `/cron/cron_products`) pozostaja w kodzie, ale nie sa docelowa sciezka wykonywania.
# 2026-02-20 - Poprawka niezaleznosci pipeline w `cron_universal`
## Problem
- `campaigns` mialo juz 100% (`done`) i `cron_universal` konczyl wykonanie, mimo ze `products` mial jeszcze zalegle daty.
## Zmienione pliki
- `autoload/controls/class.Cron.php`
- `cron_universal()` wybiera teraz aktywnego klienta niezaleznie dla obu pipeline:
- `campaigns`,
- `products`.
- Zakonczenie "wszyscy przetworzeni" następuje dopiero, gdy **oba** pipeline nie maja juz aktywnych pozycji.
- Dodane osobne liczenie pozostalych dat:
- `campaigns_remaining_dates`,
- `products_remaining_dates`.
- Statusy `done/pending` sa zapisywane osobno dla kazdego pipeline; produkty nie sa juz blokowane przez sam fakt, ze kampanie sa skonczone globalnie.
- Ujednolicenie trybu `client_id`:
- kampanie i produkty wykonują sie niezaleznie (w tym samym wywolaniu), a bledy sa laczone tylko w odpowiedzi.
# 2026-02-20 - Usuniecie `products_data`
## Zmienione pliki
- `migrations/017_drop_products_data.sql`
- Dodana migracja usuwajaca tabele `products_data`.
- `autoload/factory/class.Products.php`
- `get_product_data()` czyta dane tylko z `products`.
- `set_product_data()` zapisuje dane tylko do `products`.
- `autoload/controls/class.Cron.php`
- diagnostyka URL i wybieranie produktow bez URL opiera sie juz tylko o `products.product_url`.
- `docs/database.sql`
- usunieta definicja tabeli `products_data`.
- `migrations/demo_data.sql`
- usuniete operacje `INSERT/DELETE` na `products_data`,
- etykiety demo (`custom_label_4`) sa ustawiane bezposrednio w `products`.
## Gdzie to jest wykorzystywane
- Dane produktowe (`title`, `description`, `google_product_category`, `custom_label_3`, `custom_label_4`, `product_url`) sa trzymane tylko w `products`.
# 2026-02-20 - Ostatni krok `cron_universal`: URL z Merchant + alerty brakow
## Zmienione pliki
- `autoload/controls/class.Cron.php`
- Dodany helper `sync_products_urls_and_alerts_for_client()`.
- Na koncu przebiegu `cron_universal` (zarowno tryb automatyczny, jak i `client_id`) wykonywany jest krok:
- pobranie URL produktow z Google Merchant Center dla produktow bez URL,
- zapis URL do `products.product_url`.
- Gdy `offer_id` nie istnieje w Merchant Center, tworzony/aktualizowany jest alert w `campaign_alerts`:
- `alert_type = products_missing_in_merchant_center`,
- scope techniczny: `campaign_external_id = 0`, `ad_group_external_id = 0`,
- `meta_json` zawiera m.in. listy `missing_offer_ids` i `missing_product_ids`.
- Gdy w danym dniu brak brakujacych produktow, dzienny alert tego typu jest czyszczony.
- Do odpowiedzi cron dodane pola diagnostyczne:
- `merchant_urls_checked`,
- `merchant_urls_updated`,
- `merchant_missing_in_mc_count`,
- `merchant_missing_offer_ids`.
- `cron_universal` ma dodatkowy fallback niezalezny od pipeline `campaigns/products`:
- gdy oba pipeline sa zakonczone, ale sa jeszcze produkty bez URL, uruchamia sam krok Merchant URL + alerty (`merchant_only=1`),
- dopiero brak takich produktow daje komunikat "Wszyscy aktywni klienci zostali przetworzeni...".
- Krok Merchant URL nie jest wykonywany dla kazdego dnia okna; dziala jako osobny etap po zakonczeniu `campaigns/products`.
- Do zapytan do GMC trafiaja tylko produkty z `products.product_url IS NULL` i `merchant_url_not_found = 0`.
- Na jedno wywolanie wykonywana jest jedna paczka sprawdzen (limit z `config.php`: `cron_products_urls_limit_per_client`, ustawiony na `100`).
- Produkty, ktorych GMC nie zwraca (brak URL), sa oznaczane:
- `products.merchant_url_not_found = 1`,
- `products.merchant_url_last_check = NOW()`,
- dzieki temu nie sa wysylane ponownie w nieskonczonosc.
- Alert `products_missing_in_merchant_center` jest liczony na podstawie calej aktualnej puli `merchant_url_not_found = 1` (nie tylko bieżącej paczki), wiec nie znika przy `checked_products = 0`.
- Alerty sa per produkt (1 alert = 1 produkt):
- dla kazdego produktu bez URL i z `merchant_url_not_found = 1` tworzony jest osobny wpis w `campaign_alerts`,
- tresc alertu zawiera nazwe produktu (fallback: `name`, dalej `offer_id`) i `offer_id`,
- technicznie: `campaign_external_id = products.id`, co stabilizuje unikalnosc wpisu.
- `migrations/018_products_merchant_url_flags.sql`
- Dodane kolumny w `products`:
- `merchant_url_not_found` (TINYINT, domyslnie 0),
- `merchant_url_last_check` (DATETIME).
- Normalizacja: puste/sztuczne `product_url` (`'', '0', '-', 'null'`) ustawiane na `NULL`.
- `autoload/factory/class.Products.php`
- Przy zapisie `product_url`:
- ustawiany jest `merchant_url_last_check`,
- dla poprawnego URL resetowane jest `merchant_url_not_found = 0`.
# 2026-02-20 - Alerty na stronie `/products` dla klient + kampania
## Zmienione pliki
- `autoload/factory/class.Products.php`
- `get_scope_alerts()` nie wymaga juz wybranej grupy reklam:
- minimalny scope: `client_id + campaign_id`,
- filtr `ad_group_id` jest stosowany tylko opcjonalnie (gdy grupa jest wybrana).
- `templates/products/main_view.php`
- `load_scope_alerts()` pobiera alerty juz dla kombinacji `klient + kampania`.
- Sekcja alertow ma zaktualizowany opis: kampania + opcjonalna grupa reklam.
## Gdzie to jest wykorzystywane
- `/products`
- Panel alertow pod filtrami pokazuje alerty:
- dla calej kampanii (gdy grupa reklam nie jest wybrana),
- lub zawezone do konkretnej grupy (gdy grupa reklam jest wybrana).
# 2026-02-20 - Etykietowanie alertow Merchant (bez falszywej kampanii)
## Zmienione pliki
- `autoload/controls/class.Cron.php`
- Dla alertu `products_missing_in_merchant_center` nie jest juz zapisywany `product_id` w `campaign_external_id`.
- Pola scope kampanii/grupy sa zapisywane jako `0` (alert produktowy, bez przypisania do kampanii).
- `templates/campaign_alerts/main_view.php`
- Dla alertu `products_missing_in_merchant_center` tabela alertow pokazuje:
- Kampania: `Produkt (Merchant Center)`,
- Grupa reklam: `---`.
- Dla pozostalych alertow fallback `Kampania #...` / `Grupa reklam #...` dziala tylko dla dodatnich external_id; dla `0` pokazuje neutralne etykiety.
# 2026-02-20 - Powiazanie `campaign_alerts` z `products`
## Zmienione pliki
- `migrations/019_campaign_alerts_product_id.sql`
- Dodana kolumna `campaign_alerts.product_id` (NULL) oraz indeks `idx_alert_product`.
- `autoload/controls/class.Cron.php`
- Alerty `products_missing_in_merchant_center` zapisuja `product_id` w tabeli `campaign_alerts`.
- Dla zachowania unikalnosci dziennej per produkt, techniczny `campaign_external_id` pozostaje rowny `product_id`.
- `autoload/factory/class.CampaignAlerts.php`
- `get_alerts()` zwraca teraz rowniez pole `product_id`.
- `docs/database.sql`
- Dodana aktualna definicja tabeli `campaign_alerts` z kolumna `product_id`.
# 2026-02-20 - CRON produktow: `title` nie jest uzupelniany automatycznie
## Zmienione pliki
- `autoload/controls/class.Cron.php`
- W syncu produktow do tabeli `products` CRON nie zapisuje juz pola `title`.
- Dla nowych produktow CRON zapisuje tylko `name` (bez `title`).
- Dla istniejacych produktow usunieto automatyczne uzupelnianie pustego `title`.
## Gdzie to jest wykorzystywane
- `/cron/cron_universal`
- automatyczny import produktow nie nadpisuje ani nie uzupelnia `products.title`,
- `title` pozostaje polem do recznej edycji i wysylki do GMC.
# 2026-02-20 - Lista produktow z 0 wyswietlen (30 dni) na `/products`
## Zmienione pliki
- `autoload/factory/class.Products.php`
- Dodana metoda `get_products_without_impressions_30( $client_id, $campaign_id, $limit )`.
- Zwraca produkty z wybranej kampanii, ktore maja sume `impressions_30 = 0` na podstawie `products_aggregate`.
- Dodatkowy filtr `ad_group_id` (opcjonalny), aby lista byla zgodna z aktualnym filtrem grupy reklam na widoku.
- `autoload/controls/class.Products.php`
- Dodany endpoint `get_products_without_impressions_30()`.
- Zwraca JSON: `status`, `products[]`, `count` i przyjmuje opcjonalnie `ad_group_id`.
- `templates/products/main_view.php`
- Dodana sekcja nad tabela produktow:
- "Produkty do sprawdzenia (0 wyswietlen w ostatnich 30 dniach)".
- Sekcja pojawia sie dla wybranego `klient + kampania`.
- Lista odswieza sie przy zmianie klienta/kampanii/grupy oraz po zaladowaniu strony.
## Gdzie to jest wykorzystywane
- `/products`
- pomocnicza lista produktow potencjalnie nieistniejacych / wymagajacych weryfikacji (0 wyswietlen w 30 dni dla wybranej kampanii).
# 2026-02-20 - Ustawienia CRON: poprawka licznika klientow + usuniecie "Krok 1/Krok 2"
## Zmienione pliki
- `autoload/controls/class.Users.php`
- Licznik `Klienci z Google Ads ID` liczy teraz klientow z:
- `COALESCE(active, 0) = 1`,
- `TRIM(COALESCE(google_ads_customer_id, '')) <> ''`.
- Analogicznie poprawione filtry dla klientow Merchant i zapytan pomocniczych (wg `active`).
- Harmonogram krokow (`Krok 1`, `Krok 2`) w danych dashboardu CRON jest pusty.
- `templates/users/settings.php`
- Usunieta sekcja wizualna harmonogramu krokow CRON (`Krok 1` / `Krok 2`).
- Usunieta obsluga renderowania tej sekcji w JS odswiezajacym status CRON.
## Gdzie to jest wykorzystywane
- `/settings?settings_tab=cron`
- licznik klientow z Google Ads ID pokazuje poprawna wartosc na podstawie aktywnych klientow (`active = 1`),
- brak sekcji "Krok 1 / Krok 2".
# 2026-02-20 - `/products` czyta bezposrednio z `products_aggregate`
## Zmienione pliki
- `autoload/factory/class.Products.php`
- Zapytania dla listy produktow i licznikow zostaly przepiete z `products_temp` na `products_aggregate`:
- `get_products()`,
- `get_roas_bounds()`,
- `get_records_total_products()`,
- `get_product_full_context()`.
- Metryki all-time sa liczone z pol:
- `impressions_all_time`, `clicks_all_time`, `cost_all_time`, `conversions_all_time`, `conversion_value_all_time`.
- Metryki 30d sa czytane z:
- `impressions_30`, `clicks_30`.
## Gdzie to jest wykorzystywane
- `/products`
- tabela i liczniki nie zaleza juz od `products_temp`; biora dane bezposrednio z `products_aggregate`.
# 2026-02-20 - `custom_label_4` tylko z tabeli `products`
## Ustalenie
- Etykieta `custom_label_4` jest czytana i zapisywana z tabeli `products`.
- Agregaty (`products_aggregate`) nie sa zrodlem dla pola `custom_label_4`.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1856,6 +1856,44 @@ table {
flex: 1 1 260px; flex: 1 1 260px;
} }
&.filter-group-ad-group {
.ad-group-filter-actions {
display: flex;
gap: 8px;
align-items: center;
.form-control {
flex: 1 1 auto;
min-width: 0;
}
}
#delete-products-ad-group {
min-width: 38px;
height: 38px;
border-radius: 6px;
margin: 0;
background: #dc3545;
border: 1px solid #dc3545;
color: #fff;
cursor: pointer;
&:hover:not(:disabled) {
background: #bb2d3b;
border-color: #bb2d3b;
color: #fff;
}
&:disabled {
opacity: 0.45;
cursor: default;
background: #dc3545;
border-color: #dc3545;
color: #fff;
}
}
}
&.filter-group-roas { &.filter-group-roas {
flex: 0 0 200px; flex: 0 0 200px;
} }
@@ -1866,6 +1904,73 @@ table {
} }
} }
.products-scope-alerts {
margin-bottom: 12px;
border: 1px solid #FECACA;
background: #FEF2F2;
border-radius: 8px;
overflow: hidden;
summary {
cursor: pointer;
list-style: none;
padding: 10px 12px;
font-size: 13px;
font-weight: 600;
color: #991B1B;
display: flex;
align-items: center;
gap: 8px;
&::-webkit-details-marker {
display: none;
}
}
.products-scope-alerts-list {
border-top: 1px solid #FECACA;
background: #FFF;
max-height: 260px;
overflow: auto;
}
.products-scope-alert-item {
padding: 10px 12px;
border-bottom: 1px solid #F1F5F9;
&:last-child {
border-bottom: none;
}
}
.products-scope-alert-meta {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
font-size: 11px;
color: #64748B;
}
.products-scope-alert-type {
display: inline-flex;
align-items: center;
padding: 2px 6px;
border-radius: 999px;
background: #EEF2FF;
color: #4338CA;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.products-scope-alert-message {
font-size: 13px;
color: $cTextDark;
line-height: 1.45;
}
}
.products-actions { .products-actions {
margin-bottom: 12px; margin-bottom: 12px;
@@ -2458,6 +2563,37 @@ table#products {
margin-bottom: 20px; margin-bottom: 20px;
} }
.cron-schedule-list {
margin-bottom: 20px;
}
.cron-schedule-item {
border: 1px solid #DFE7F0;
background: #F4F8FD;
border-radius: 8px;
padding: 9px 12px;
margin-bottom: 8px;
&:last-child {
margin-bottom: 0;
}
strong {
display: block;
color: $cTextDark;
font-size: 13px;
font-weight: 700;
margin-bottom: 2px;
}
small {
display: block;
color: #667788;
font-size: 12px;
line-height: 1.35;
}
}
.cron-progress-item { .cron-progress-item {
margin-bottom: 14px; margin-bottom: 14px;
@@ -2553,6 +2689,13 @@ table#products {
font-size: 12px; font-size: 12px;
overflow-x: auto; overflow-x: auto;
} }
.cron-url-plan {
display: block;
color: #6C7B8A;
font-size: 11px;
margin-bottom: 6px;
}
} }
@media (max-width: 1200px) { @media (max-width: 1200px) {

View File

@@ -0,0 +1,165 @@
-- Migracja: unifikacja modelu danych produktow
-- Cel:
-- 1) przeniesienie danych produktowych do tabeli products (kolumny dotad trzymane glownie w products_data)
-- 2) dodanie tabeli agregatow products_aggregate (30 dni + all-time)
-- ===========================
-- products: nowe kolumny danych produktu
-- ===========================
SET @sql = IF(
EXISTS (
SELECT 1
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'products'
AND COLUMN_NAME = 'custom_label_4'
),
'DO 1',
'ALTER TABLE `products` ADD COLUMN `custom_label_4` VARCHAR(255) NULL DEFAULT NULL AFTER `min_roas`'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql = IF(
EXISTS (
SELECT 1
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'products'
AND COLUMN_NAME = 'custom_label_3'
),
'DO 1',
'ALTER TABLE `products` ADD COLUMN `custom_label_3` VARCHAR(255) NULL DEFAULT NULL AFTER `custom_label_4`'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql = IF(
EXISTS (
SELECT 1
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'products'
AND COLUMN_NAME = 'title'
),
'DO 1',
'ALTER TABLE `products` ADD COLUMN `title` VARCHAR(255) NULL DEFAULT NULL AFTER `custom_label_3`'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql = IF(
EXISTS (
SELECT 1
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'products'
AND COLUMN_NAME = 'description'
),
'DO 1',
'ALTER TABLE `products` ADD COLUMN `description` TEXT NULL AFTER `title`'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql = IF(
EXISTS (
SELECT 1
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'products'
AND COLUMN_NAME = 'google_product_category'
),
'DO 1',
'ALTER TABLE `products` ADD COLUMN `google_product_category` TEXT NULL AFTER `description`'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql = IF(
EXISTS (
SELECT 1
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'products'
AND COLUMN_NAME = 'product_url'
),
'DO 1',
'ALTER TABLE `products` ADD COLUMN `product_url` VARCHAR(500) NULL DEFAULT NULL AFTER `google_product_category`'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- ===========================
-- Backfill products <- products_data
-- (zostawiamy products_data na razie dla kompatybilnosci)
-- ===========================
UPDATE `products` p
INNER JOIN `products_data` pd ON pd.product_id = p.id
SET
p.custom_label_4 = CASE
WHEN ( p.custom_label_4 IS NULL OR TRIM( p.custom_label_4 ) = '' ) AND pd.custom_label_4 IS NOT NULL AND TRIM( pd.custom_label_4 ) <> '' THEN pd.custom_label_4
ELSE p.custom_label_4
END,
p.custom_label_3 = CASE
WHEN ( p.custom_label_3 IS NULL OR TRIM( p.custom_label_3 ) = '' ) AND pd.custom_label_3 IS NOT NULL AND TRIM( pd.custom_label_3 ) <> '' THEN pd.custom_label_3
ELSE p.custom_label_3
END,
p.title = CASE
WHEN ( p.title IS NULL OR TRIM( p.title ) = '' ) AND pd.title IS NOT NULL AND TRIM( pd.title ) <> '' THEN pd.title
ELSE p.title
END,
p.description = CASE
WHEN ( p.description IS NULL OR TRIM( p.description ) = '' ) AND pd.description IS NOT NULL AND TRIM( pd.description ) <> '' THEN pd.description
ELSE p.description
END,
p.google_product_category = CASE
WHEN ( p.google_product_category IS NULL OR TRIM( p.google_product_category ) = '' ) AND pd.google_product_category IS NOT NULL AND TRIM( pd.google_product_category ) <> '' THEN pd.google_product_category
ELSE p.google_product_category
END,
p.product_url = CASE
WHEN ( p.product_url IS NULL OR TRIM( p.product_url ) = '' ) AND pd.product_url IS NOT NULL AND TRIM( pd.product_url ) <> '' THEN pd.product_url
ELSE p.product_url
END;
-- ===========================
-- Tabela agregatow produktow
-- ===========================
CREATE TABLE IF NOT EXISTS `products_aggregate` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`product_id` INT(11) NOT NULL,
`campaign_id` INT(11) NOT NULL DEFAULT 0,
`ad_group_id` INT(11) NOT NULL DEFAULT 0,
`impressions_30` INT(11) NOT NULL DEFAULT 0,
`clicks_30` INT(11) NOT NULL DEFAULT 0,
`ctr_30` DECIMAL(20,6) NOT NULL DEFAULT 0.000000,
`cost_30` DECIMAL(20,6) NOT NULL DEFAULT 0.000000,
`conversions_30` DECIMAL(20,6) NOT NULL DEFAULT 0.000000,
`conversion_value_30` DECIMAL(20,6) NOT NULL DEFAULT 0.000000,
`roas_30` DECIMAL(20,6) NOT NULL DEFAULT 0.000000,
`impressions_all_time` INT(11) NOT NULL DEFAULT 0,
`clicks_all_time` INT(11) NOT NULL DEFAULT 0,
`ctr_all_time` DECIMAL(20,6) NOT NULL DEFAULT 0.000000,
`cost_all_time` DECIMAL(20,6) NOT NULL DEFAULT 0.000000,
`conversions_all_time` DECIMAL(20,6) NOT NULL DEFAULT 0.000000,
`conversion_value_all_time` DECIMAL(20,6) NOT NULL DEFAULT 0.000000,
`roas_all_time` DECIMAL(20,6) NOT NULL DEFAULT 0.000000,
`date_sync` DATE NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_products_aggregate_scope` (`product_id`,`campaign_id`,`ad_group_id`),
KEY `idx_products_aggregate_campaign_id` (`campaign_id`),
KEY `idx_products_aggregate_ad_group_id` (`ad_group_id`),
KEY `idx_products_aggregate_date_sync` (`date_sync`),
CONSTRAINT `FK_products_aggregate_products`
FOREIGN KEY (`product_id`) REFERENCES `products` (`id`)
ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,5 @@
-- Migracja: usuniecie tabeli products_data
-- Dane produktowe sa juz przechowywane bezposrednio w tabeli products.
DROP TABLE IF EXISTS `products_data`;

View File

@@ -0,0 +1,38 @@
-- Flagi kontroli pobierania URL z Merchant Center
SET @sql = IF(
EXISTS (
SELECT 1
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'products'
AND COLUMN_NAME = 'merchant_url_not_found'
),
'DO 1',
'ALTER TABLE `products` ADD COLUMN `merchant_url_not_found` TINYINT(1) NOT NULL DEFAULT 0 AFTER `product_url`'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql = IF(
EXISTS (
SELECT 1
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'products'
AND COLUMN_NAME = 'merchant_url_last_check'
),
'DO 1',
'ALTER TABLE `products` ADD COLUMN `merchant_url_last_check` DATETIME NULL DEFAULT NULL AFTER `merchant_url_not_found`'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- Normalizacja: puste/sztuczne wartosci URL traktujemy jako brak URL.
UPDATE `products`
SET `product_url` = NULL
WHERE TRIM( COALESCE( `product_url`, '' ) ) = ''
OR LOWER( TRIM( `product_url` ) ) IN ( '0', '-', 'null' );

View File

@@ -0,0 +1,31 @@
-- Powiazanie alertow z konkretnym produktem
SET @sql = IF(
EXISTS (
SELECT 1
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'campaign_alerts'
AND COLUMN_NAME = 'product_id'
),
'DO 1',
'ALTER TABLE `campaign_alerts` ADD COLUMN `product_id` INT(11) NULL DEFAULT NULL AFTER `ad_group_external_id`'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @sql = IF(
EXISTS (
SELECT 1
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'campaign_alerts'
AND INDEX_NAME = 'idx_alert_product'
),
'DO 1',
'CREATE INDEX `idx_alert_product` ON `campaign_alerts` (`product_id`)'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@@ -182,18 +182,6 @@ WHERE p.client_id = 2
'shopify_PL_8901021','shopify_PL_8901022','shopify_PL_8901023','shopify_PL_8901024','shopify_PL_8901025' 'shopify_PL_8901021','shopify_PL_8901022','shopify_PL_8901023','shopify_PL_8901024','shopify_PL_8901025'
); );
DELETE pd
FROM products_data pd
JOIN products p ON p.id = pd.product_id
WHERE p.client_id = 2
AND p.offer_id IN (
'shopify_PL_8901001','shopify_PL_8901002','shopify_PL_8901003','shopify_PL_8901004','shopify_PL_8901005',
'shopify_PL_8901006','shopify_PL_8901007','shopify_PL_8901008','shopify_PL_8901009','shopify_PL_8901010',
'shopify_PL_8901011','shopify_PL_8901012','shopify_PL_8901013','shopify_PL_8901014','shopify_PL_8901015',
'shopify_PL_8901016','shopify_PL_8901017','shopify_PL_8901018','shopify_PL_8901019','shopify_PL_8901020',
'shopify_PL_8901021','shopify_PL_8901022','shopify_PL_8901023','shopify_PL_8901024','shopify_PL_8901025'
);
DELETE ph DELETE ph
FROM products_history ph FROM products_history ph
JOIN products p ON p.id = ph.product_id JOIN products p ON p.id = ph.product_id
@@ -387,26 +375,17 @@ GROUP BY p.id, p.name;
-- ============================================================ -- ============================================================
-- 6. PRODUCTS_DATA (custom labels) -- 6. PRODUCTS (custom labels)
-- ============================================================ -- ============================================================
-- Bestsellery (wysoki ROAS + dużo konwersji) -- Bestsellery (wysoki ROAS + dużo konwersji)
INSERT INTO products_data (product_id, custom_label_4) VALUES UPDATE products SET custom_label_4 = 'bestseller' WHERE offer_id IN ( 'shopify_PL_8901001', 'shopify_PL_8901003', 'shopify_PL_8901010', 'shopify_PL_8901023' ) AND client_id = 2;
((SELECT id FROM products WHERE offer_id = 'shopify_PL_8901001' AND client_id = 2), 'bestseller'),
((SELECT id FROM products WHERE offer_id = 'shopify_PL_8901003' AND client_id = 2), 'bestseller'),
((SELECT id FROM products WHERE offer_id = 'shopify_PL_8901010' AND client_id = 2), 'bestseller'),
((SELECT id FROM products WHERE offer_id = 'shopify_PL_8901023' AND client_id = 2), 'bestseller');
-- Produkty PLA (w kampaniach Shopping) -- Produkty PLA (w kampaniach Shopping)
INSERT INTO products_data (product_id, custom_label_4) VALUES UPDATE products SET custom_label_4 = 'pla' WHERE offer_id IN ( 'shopify_PL_8901005', 'shopify_PL_8901006', 'shopify_PL_8901015' ) AND client_id = 2;
((SELECT id FROM products WHERE offer_id = 'shopify_PL_8901005' AND client_id = 2), 'pla'),
((SELECT id FROM products WHERE offer_id = 'shopify_PL_8901006' AND client_id = 2), 'pla'),
((SELECT id FROM products WHERE offer_id = 'shopify_PL_8901015' AND client_id = 2), 'pla');
-- Zombie (bardzo niskie wyświetlenia) -- Zombie (bardzo niskie wyświetlenia)
INSERT INTO products_data (product_id, custom_label_4) VALUES UPDATE products SET custom_label_4 = 'zombie' WHERE offer_id IN ( 'shopify_PL_8901014', 'shopify_PL_8901024' ) AND client_id = 2;
((SELECT id FROM products WHERE offer_id = 'shopify_PL_8901014' AND client_id = 2), 'zombie'),
((SELECT id FROM products WHERE offer_id = 'shopify_PL_8901024' AND client_id = 2), 'zombie');
-- ============================================================ -- ============================================================

View File

@@ -17,97 +17,185 @@
</div> </div>
</form> </form>
<div class="campaigns-table-wrap"> <form method="post" action="/campaign_alerts/delete_selected" id="campaign_alerts_bulk_form">
<table class="table" id="campaign_alerts_table"> <input type="hidden" name="client_id" value="<?= (int) $this -> selected_client_id; ?>">
<thead> <input type="hidden" name="page" value="<?= (int) $this -> page; ?>">
<tr>
<th>Data</th> <div style="display:flex;justify-content:flex-end;margin-bottom:10px;">
<th>Klient</th> <button type="submit" id="delete_selected_alerts_btn" class="btn btn-danger btn-sm" disabled>
<th>Kampania</th> <i class="fa-solid fa-trash"></i> Usun zaznaczone (<span id="selected_alerts_count">0</span>)
<th>Grupa reklam</th> </button>
<th>Komunikat</th> </div>
</tr>
</thead> <div class="campaigns-table-wrap">
<tbody> <table class="table" id="campaign_alerts_table">
<?php if ( empty( $this -> alerts ) ): ?> <thead>
<tr> <tr>
<td colspan="5" class="text-center">Brak alertów.</td> <th style="width:40px;text-align:center;">
<input type="checkbox" id="alerts_select_all" aria-label="Zaznacz wszystkie alerty">
</th>
<th>Data</th>
<th>Klient</th>
<th style="white-space:nowrap">Kampania</th>
<th>Grupa reklam</th>
<th>Komunikat</th>
</tr> </tr>
<?php else: ?> </thead>
<?php foreach ( $this -> alerts as $row ): ?> <tbody>
<?php <?php if ( empty( $this -> alerts ) ): ?>
$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> <tr>
<td style="white-space:nowrap"><?= htmlspecialchars( (string) ( $row['date_detected'] ?? '' ) ); ?></td> <td colspan="6" class="text-center">Brak alertow.</td>
<td><?= htmlspecialchars( $client_name ); ?></td>
<td><?= htmlspecialchars( $campaign_name ); ?></td>
<td><?= htmlspecialchars( $ad_group_name ); ?></td>
<td><?= htmlspecialchars( (string) ( $row['message'] ?? '' ) ); ?></td>
</tr> </tr>
<?php endforeach; ?> <?php else: ?>
<?php endif; ?> <?php foreach ( $this -> alerts as $row ): ?>
</tbody> <?php
</table> $alert_type = trim( (string) ( $row['alert_type'] ?? '' ) );
$campaign_name = trim( (string) ( $row['campaign_name'] ?? '' ) );
$ad_group_name = trim( (string) ( $row['ad_group_name'] ?? '' ) );
<?php if ( (int) $this -> total_pages > 1 ): ?> if ( $alert_type === 'products_missing_in_merchant_center' )
<div class="dt-layout-row" style="display:flex !important;justify-content:flex-end;"> {
<div class="dt-paging"> $campaign_name = 'Produkt (Merchant Center)';
<nav> $ad_group_name = '---';
<ul class="pagination"> }
<?php else
$page = (int) $this -> page; {
$total_pages = (int) $this -> total_pages; if ( $campaign_name === '' )
$client_id = (int) $this -> selected_client_id; {
$qs = $client_id > 0 ? '&client_id=' . $client_id : ''; $campaign_external_id = (int) ( $row['campaign_external_id'] ?? 0 );
$campaign_name = $campaign_external_id > 0 ? ( 'Kampania #' . $campaign_external_id ) : '--- konto ---';
}
if ( $ad_group_name === '' )
{
$ad_group_external_id = (int) ( $row['ad_group_external_id'] ?? 0 );
$ad_group_name = $ad_group_external_id > 0 ? ( 'Grupa reklam #' . $ad_group_external_id ) : '---';
}
}
$client_name = trim( (string) ( $row['client_name'] ?? '' ) );
if ( $client_name === '' )
{
$client_name = 'Klient #' . (int) ( $row['client_id'] ?? 0 );
}
?> ?>
<li class="page-item <?= $page <= 1 ? 'disabled' : ''; ?>"> <tr>
<a class="page-link" href="/campaign_alerts?page=<?= $page - 1; ?><?= $qs; ?>">&laquo;</a> <td style="text-align:center;">
</li> <input type="checkbox" name="alert_ids[]" value="<?= (int) ( $row['id'] ?? 0 ); ?>" class="alert-select-row" aria-label="Zaznacz alert #<?= (int) ( $row['id'] ?? 0 ); ?>">
<?php </td>
$start_p = max( 1, $page - 2 ); <td style="white-space:nowrap"><?= htmlspecialchars( (string) ( $row['date_detected'] ?? '' ) ); ?></td>
$end_p = min( $total_pages, $page + 2 ); <td><?= htmlspecialchars( $client_name ); ?></td>
if ( $start_p > 1 ): <td style="white-space:nowrap"><?= htmlspecialchars( $campaign_name ); ?></td>
?> <td><?= htmlspecialchars( $ad_group_name ); ?></td>
<li class="page-item"><a class="page-link" href="/campaign_alerts?page=1<?= $qs; ?>">1</a></li> <td><?= htmlspecialchars( (string) ( $row['message'] ?? '' ) ); ?></td>
<?php if ( $start_p > 2 ): ?> </tr>
<li class="page-item disabled"><span class="page-link">...</span></li> <?php endforeach; ?>
<?php endif; ?> <?php endif; ?>
<?php endif; ?> </tbody>
<?php for ( $i = $start_p; $i <= $end_p; $i++ ): ?> </table>
<li class="page-item <?= $i === $page ? 'active' : ''; ?>">
<a class="page-link" href="/campaign_alerts?page=<?= $i; ?><?= $qs; ?>"><?= $i; ?></a> <?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> </li>
<?php endfor; ?> <?php
<?php if ( $end_p < $total_pages ): ?> $start_p = max( 1, $page - 2 );
<?php if ( $end_p < $total_pages - 1 ): ?> $end_p = min( $total_pages, $page + 2 );
<li class="page-item disabled"><span class="page-link">...</span></li> 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 endif; ?>
<li class="page-item"><a class="page-link" href="/campaign_alerts?page=<?= $total_pages; ?><?= $qs; ?>"><?= $total_pages; ?></a></li> <?php for ( $i = $start_p; $i <= $end_p; $i++ ): ?>
<?php endif; ?> <li class="page-item <?= $i === $page ? 'active' : ''; ?>">
<li class="page-item <?= $page >= $total_pages ? 'disabled' : ''; ?>"> <a class="page-link" href="/campaign_alerts?page=<?= $i; ?><?= $qs; ?>"><?= $i; ?></a>
<a class="page-link" href="/campaign_alerts?page=<?= $page + 1; ?><?= $qs; ?>">&raquo;</a> </li>
</li> <?php endfor; ?>
</ul> <?php if ( $end_p < $total_pages ): ?>
</nav> <?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> </div>
</div> <?php endif; ?>
<?php endif; ?> </div>
</div> </form>
</div> </div>
<script type="text/javascript">
$( function()
{
var form = $( '#campaign_alerts_bulk_form' );
var select_all = $( '#alerts_select_all' );
var row_checkboxes = form.find( '.alert-select-row' );
var delete_button = $( '#delete_selected_alerts_btn' );
var selected_count = $( '#selected_alerts_count' );
function update_selected_state()
{
var checked = row_checkboxes.filter( ':checked' ).length;
var all = row_checkboxes.length;
selected_count.text( checked );
delete_button.prop( 'disabled', checked === 0 );
if ( all === 0 )
{
select_all.prop( 'checked', false );
select_all.prop( 'indeterminate', false );
return;
}
select_all.prop( 'checked', checked === all );
select_all.prop( 'indeterminate', checked > 0 && checked < all );
}
select_all.on( 'change', function()
{
row_checkboxes.prop( 'checked', this.checked );
update_selected_state();
} );
row_checkboxes.on( 'change', function()
{
update_selected_state();
} );
form.on( 'submit', function( e )
{
var checked = row_checkboxes.filter( ':checked' ).length;
if ( checked === 0 )
{
e.preventDefault();
return;
}
if ( !confirm( 'Czy na pewno usunac zaznaczone alerty (' + checked + ')?' ) )
{
e.preventDefault();
}
} );
update_selected_state();
} );
</script>

View File

@@ -12,19 +12,28 @@
<tr> <tr>
<th style="width: 60px;">#ID</th> <th style="width: 60px;">#ID</th>
<th>Nazwa klienta</th> <th>Nazwa klienta</th>
<th style="width: 120px;">Status</th>
<th>Google Ads Customer ID</th> <th>Google Ads Customer ID</th>
<th>Merchant Account ID</th> <th>Merchant Account ID</th>
<th>Dane od</th> <th>Dane od</th>
<th style="width: 160px;">Sync</th> <th style="width: 190px;">Sync</th>
<th style="width: 160px; text-align: center;">Akcje</th> <th style="width: 250px; text-align: center;">Akcje</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php if ( $this -> clients ): ?> <?php if ( $this -> clients ): ?>
<?php foreach ( $this -> clients as $client ): ?> <?php foreach ( $this -> clients as $client ): ?>
<tr data-id="<?= $client['id']; ?>"> <?php $is_client_active = (int) ( $client['active'] ?? 0 ) === 1; ?>
<tr data-id="<?= $client['id']; ?>" data-active="<?= $is_client_active ? 1 : 0; ?>">
<td class="client-id"><?= $client['id']; ?></td> <td class="client-id"><?= $client['id']; ?></td>
<td class="client-name"><?= htmlspecialchars( $client['name'] ); ?></td> <td class="client-name"><?= htmlspecialchars( $client['name'] ); ?></td>
<td data-status-for="<?= $client['id']; ?>">
<?php if ( $is_client_active ): ?>
<span class="text-success"><strong>Aktywny</strong></span>
<?php else: ?>
<span class="text-danger"><strong>Nieaktywny</strong></span>
<?php endif; ?>
</td>
<td> <td>
<?php if ( $client['google_ads_customer_id'] ): ?> <?php if ( $client['google_ads_customer_id'] ): ?>
<span class="badge-id"><?= htmlspecialchars( $client['google_ads_customer_id'] ); ?></span> <span class="badge-id"><?= htmlspecialchars( $client['google_ads_customer_id'] ); ?></span>
@@ -48,13 +57,21 @@
</td> </td>
<td class="client-sync" data-sync-id="<?= $client['id']; ?>"><span class="text-muted">—</span></td> <td class="client-sync" data-sync-id="<?= $client['id']; ?>"><span class="text-muted">—</span></td>
<td class="actions-cell"> <td class="actions-cell">
<button type="button" class="btn-icon btn-icon-sync" onclick="toggleClientActive(<?= $client['id']; ?>, this)" title="<?= $is_client_active ? 'Dezaktywuj klienta' : 'Aktywuj klienta'; ?>">
<i class="fa-solid <?= $is_client_active ? 'fa-toggle-on' : 'fa-toggle-off'; ?>"></i>
</button>
<?php if ( $client['google_ads_customer_id'] ): ?> <?php if ( $client['google_ads_customer_id'] ): ?>
<button type="button" class="btn-icon btn-icon-sync" onclick="syncClient(<?= $client['id']; ?>, 'campaigns', this)" title="Odśwież kampanie"> <button type="button" class="btn-icon btn-icon-sync client-sync-action" onclick="syncClient(<?= $client['id']; ?>, 'campaigns', this)" title="Odswiez kampanie" <?= $is_client_active ? '' : 'disabled'; ?>>
<i class="fa-solid fa-bullhorn"></i> <i class="fa-solid fa-bullhorn"></i>
</button> </button>
<button type="button" class="btn-icon btn-icon-sync" onclick="syncClient(<?= $client['id']; ?>, 'products', this)" title="Odśwież produkty"> <button type="button" class="btn-icon btn-icon-sync client-sync-action" onclick="syncClient(<?= $client['id']; ?>, 'products', this)" title="Odswiez produkty" <?= $is_client_active ? '' : 'disabled'; ?>>
<i class="fa-solid fa-box-open"></i> <i class="fa-solid fa-box-open"></i>
</button> </button>
<?php if ( !empty( $client['google_merchant_account_id'] ) ): ?>
<button type="button" class="btn-icon btn-icon-sync client-sync-action" onclick="syncClient(<?= $client['id']; ?>, 'campaigns_product_alerts_merchant', this)" title="Odswiez walidacje Merchant" <?= $is_client_active ? '' : 'disabled'; ?>>
<i class="fa-solid fa-store"></i>
</button>
<?php endif; ?>
<?php endif; ?> <?php endif; ?>
<button type="button" class="btn-icon btn-icon-edit" onclick="editClient(<?= $client['id']; ?>)" title="Edytuj"> <button type="button" class="btn-icon btn-icon-edit" onclick="editClient(<?= $client['id']; ?>)" title="Edytuj">
<i class="fa-solid fa-pen"></i> <i class="fa-solid fa-pen"></i>
@@ -67,7 +84,7 @@
<?php endforeach; ?> <?php endforeach; ?>
<?php else: ?> <?php else: ?>
<tr> <tr>
<td colspan="7" class="empty-state"> <td colspan="8" class="empty-state">
<i class="fa-solid fa-building"></i> <i class="fa-solid fa-building"></i>
<p>Brak klientów. Dodaj pierwszego klienta.</p> <p>Brak klientów. Dodaj pierwszego klienta.</p>
</td> </td>
@@ -92,6 +109,14 @@
<label for="client-name">Nazwa klienta</label> <label for="client-name">Nazwa klienta</label>
<input type="text" id="client-name" name="name" class="form-control" required placeholder="np. Firma XYZ" /> <input type="text" id="client-name" name="name" class="form-control" required placeholder="np. Firma XYZ" />
</div> </div>
<div class="settings-field">
<label for="client-active">Status klienta</label>
<select id="client-active" name="active" class="form-control">
<option value="1">Aktywny</option>
<option value="0">Nieaktywny</option>
</select>
<small class="text-muted">Nieaktywny klient nie jest synchronizowany.</small>
</div>
<div class="settings-field"> <div class="settings-field">
<label for="client-gads-id">Google Ads Customer ID</label> <label for="client-gads-id">Google Ads Customer ID</label>
<input type="text" id="client-gads-id" name="google_ads_customer_id" class="form-control" placeholder="np. 123-456-7890 (opcjonalnie)" /> <input type="text" id="client-gads-id" name="google_ads_customer_id" class="form-control" placeholder="np. 123-456-7890 (opcjonalnie)" />
@@ -121,6 +146,7 @@ function openClientForm()
$( '#client-modal-title' ).text( 'Dodaj klienta' ); $( '#client-modal-title' ).text( 'Dodaj klienta' );
$( '#client-id' ).val( '' ); $( '#client-id' ).val( '' );
$( '#client-name' ).val( '' ); $( '#client-name' ).val( '' );
$( '#client-active' ).val( '1' );
$( '#client-gads-id' ).val( '' ); $( '#client-gads-id' ).val( '' );
$( '#client-gmc-id' ).val( '' ); $( '#client-gmc-id' ).val( '' );
$( '#client-gads-start' ).val( '' ); $( '#client-gads-start' ).val( '' );
@@ -138,6 +164,7 @@ function editClient( id )
$( '#client-modal-title' ).text( 'Edytuj klienta' ); $( '#client-modal-title' ).text( 'Edytuj klienta' );
$( '#client-id' ).val( data.id ); $( '#client-id' ).val( data.id );
$( '#client-name' ).val( data.name ); $( '#client-name' ).val( data.name );
$( '#client-active' ).val( parseInt( data.active, 10 ) === 0 ? '0' : '1' );
$( '#client-gads-id' ).val( data.google_ads_customer_id || '' ); $( '#client-gads-id' ).val( data.google_ads_customer_id || '' );
$( '#client-gmc-id' ).val( data.google_merchant_account_id || '' ); $( '#client-gmc-id' ).val( data.google_merchant_account_id || '' );
$( '#client-gads-start' ).val( data.google_ads_start_date || '' ); $( '#client-gads-start' ).val( data.google_ads_start_date || '' );
@@ -145,6 +172,67 @@ function editClient( id )
} ); } );
} }
function updateClientStatusUI( id, active, $btn )
{
var isActive = parseInt( active, 10 ) === 1;
var statusHtml = isActive
? '<span class="text-success"><strong>Aktywny</strong></span>'
: '<span class="text-danger"><strong>Nieaktywny</strong></span>';
$( 'tr[data-id="' + id + '"]' ).attr( 'data-active', isActive ? '1' : '0' );
$( 'td[data-status-for="' + id + '"]' ).html( statusHtml );
if ( $btn && $btn.length )
{
$btn.attr( 'title', isActive ? 'Dezaktywuj klienta' : 'Aktywuj klienta' );
$btn.find( 'i' ).attr( 'class', 'fa-solid ' + ( isActive ? 'fa-toggle-on' : 'fa-toggle-off' ) );
}
$( 'tr[data-id="' + id + '"] .client-sync-action' ).prop( 'disabled', !isActive );
var $syncCell = $( '.client-sync[data-sync-id="' + id + '"]' );
if ( !isActive )
{
$syncCell.html( '<span class="text-muted">nieaktywny</span>' );
}
else
{
loadSyncStatus();
}
}
function toggleClientActive( id, btn )
{
var $row = $( 'tr[data-id="' + id + '"]' );
var currentActive = parseInt( $row.attr( 'data-active' ), 10 ) === 1 ? 1 : 0;
var nextActive = currentActive === 1 ? 0 : 1;
var $btn = $( btn );
var $icon = $btn.find( 'i' );
var originalClass = $icon.attr( 'class' );
$btn.prop( 'disabled', true );
$icon.attr( 'class', 'fa-solid fa-spinner fa-spin' );
$.post( '/clients/set_active', { id: id, active: nextActive }, function( response ) {
var data = JSON.parse( response || '{}' );
$btn.prop( 'disabled', false );
$icon.attr( 'class', originalClass );
if ( data.success )
{
updateClientStatusUI( id, data.active, $btn );
return;
}
$.alert( {
title: 'Blad',
content: data.message || 'Nie udalo sie zmienic statusu klienta.',
type: 'red'
} );
} );
}
function syncClient( id, pipeline, btn ) function syncClient( id, pipeline, btn )
{ {
var $btn = $( btn ); var $btn = $( btn );
@@ -154,7 +242,11 @@ function syncClient( id, pipeline, btn )
$btn.prop( 'disabled', true ); $btn.prop( 'disabled', true );
$icon.attr( 'class', 'fa-solid fa-spinner fa-spin' ); $icon.attr( 'class', 'fa-solid fa-spinner fa-spin' );
var labels = { campaigns: 'kampanii', products: 'produktów' }; var labels = {
campaigns: 'kampanii',
products: 'produktow',
campaigns_product_alerts_merchant: 'walidacji Merchant'
};
$.post( '/clients/force_sync', { id: id, pipeline: pipeline }, function( response ) $.post( '/clients/force_sync', { id: id, pipeline: pipeline }, function( response )
{ {
@@ -250,6 +342,13 @@ function loadSyncStatus()
$( '.client-sync' ).each( function() $( '.client-sync' ).each( function()
{ {
var $cell = $( this ); var $cell = $( this );
var isActive = parseInt( $cell.closest( 'tr' ).attr( 'data-active' ), 10 ) === 1;
if ( !isActive )
{
$cell.html( '<span class="text-muted">nieaktywny</span>' );
return;
}
var id = $cell.data( 'sync-id' ); var id = $cell.data( 'sync-id' );
var info = resp.data[ id ]; var info = resp.data[ id ];
@@ -262,6 +361,7 @@ function loadSyncStatus()
var html = '<div class="client-sync-bars">'; var html = '<div class="client-sync-bars">';
if ( info.campaigns ) html += renderSyncBar( 'K:', info.campaigns[0], info.campaigns[1] ); if ( info.campaigns ) html += renderSyncBar( 'K:', info.campaigns[0], info.campaigns[1] );
if ( info.products ) html += renderSyncBar( 'P:', info.products[0], info.products[1] ); if ( info.products ) html += renderSyncBar( 'P:', info.products[0], info.products[1] );
if ( info.merchant ) html += renderSyncBar( 'M:', info.merchant[0], info.merchant[1] );
html += '</div>'; html += '</div>';
$cell.html( html ); $cell.html( html );
@@ -274,3 +374,4 @@ $( document ).ready( function() {
setInterval( loadSyncStatus, 15000 ); setInterval( loadSyncStatus, 15000 );
} ); } );
</script> </script>

View File

@@ -22,9 +22,14 @@
</div> </div>
<div class="filter-group filter-group-ad-group"> <div class="filter-group filter-group-ad-group">
<label for="products_ad_group_id"><i class="fa-solid fa-layer-group"></i> Grupa reklam</label> <label for="products_ad_group_id"><i class="fa-solid fa-layer-group"></i> Grupa reklam</label>
<select id="products_ad_group_id" name="products_ad_group_id" class="form-control"> <div class="ad-group-filter-actions">
<option value="">- wszystkie grupy -</option> <select id="products_ad_group_id" name="products_ad_group_id" class="form-control">
</select> <option value="">- wszystkie grupy -</option>
</select>
<button type="button" id="delete-products-ad-group" class="btn-icon btn-icon-delete" title="Usun wybrana grupe reklam" disabled>
<i class="fa-solid fa-trash"></i>
</button>
</div>
</div> </div>
<div class="filter-group filter-group-roas"> <div class="filter-group filter-group-roas">
<label for="bestseller_min_roas"><i class="fa-solid fa-star"></i> Bestseller min ROAS</label> <label for="bestseller_min_roas"><i class="fa-solid fa-star"></i> Bestseller min ROAS</label>
@@ -39,6 +44,26 @@
</div> </div>
</div> </div>
<details id="products_scope_alerts_box" class="products-scope-alerts hide">
<summary>
<i class="fa-solid fa-triangle-exclamation"></i>
Alerty dla wybranej kampanii (i opcjonalnie grupy reklam)
(<span id="products_scope_alerts_count">0</span>)
</summary>
<div class="products-scope-alerts-list" id="products_scope_alerts_list"></div>
</details>
<details id="products_zero_impressions_box" class="products-scope-alerts hide">
<summary>
<i class="fa-solid fa-magnifying-glass"></i>
Produkty do sprawdzenia (0 wyswietlen w ostatnich 30 dniach)
(<span id="products_zero_impressions_count">0</span>)
</summary>
<div class="products-scope-alerts-list">
<select id="products_zero_impressions_select" class="form-control" size="8"></select>
</div>
</details>
<!-- Akcje bulk --> <!-- Akcje bulk -->
<div class="products-actions"> <div class="products-actions">
<button type="button" class="btn btn-danger btn-sm" id="delete-selected-products" disabled> <button type="button" class="btn btn-danger btn-sm" id="delete-selected-products" disabled>
@@ -351,6 +376,151 @@ $( function()
products_table.ajax.reload( null, false ); products_table.ajax.reload( null, false );
} }
function submit_delete_campaign_ad_group( campaign_id, ad_group_id, delete_scope )
{
function parse_json_loose( raw )
{
if ( typeof raw !== 'string' || !raw )
{
return null;
}
var text = $.trim( raw );
if ( !text )
{
return null;
}
try
{
return JSON.parse( text );
}
catch ( e )
{
var start = text.indexOf( '{' );
var end = text.lastIndexOf( '}' );
if ( start !== -1 && end > start )
{
try
{
return JSON.parse( text.substring( start, end + 1 ) );
}
catch ( e2 ) {}
}
}
return null;
}
function handle_success( message )
{
show_toast( message || 'Grupa reklam zostala usunieta.', 'success' );
localStorage.removeItem( 'products_ad_group_id' );
load_products_ad_groups( campaign_id, '' ).done( function() {
$.when( load_scope_alerts(), load_zero_impressions_products() ).always( function() {
reload_products_table();
} );
} );
}
function check_if_ad_group_still_exists()
{
var deferred = $.Deferred();
if ( !campaign_id || !ad_group_id )
{
deferred.resolve( true );
return deferred.promise();
}
$.ajax({
url: '/products/get_campaign_ad_groups/campaign_id=' + campaign_id,
type: 'GET',
dataType: 'json'
}).done( function( res ) {
var still_exists = false;
( res.ad_groups || [] ).forEach( function( row ) {
if ( String( row.id || '' ) === String( ad_group_id ) )
{
still_exists = true;
}
} );
deferred.resolve( still_exists );
}).fail( function() {
deferred.resolve( true );
} );
return deferred.promise();
}
var request_data = {
campaign_id: campaign_id,
ad_group_id: ad_group_id,
delete_scope: delete_scope
};
$.ajax({
url: '/products/delete_campaign_ad_group/',
type: 'POST',
data: request_data,
success: function( response )
{
var res = ( typeof response === 'object' && response !== null )
? response
: parse_json_loose( response );
if ( res && res.status === 'ok' )
{
handle_success( res.message );
}
else
{
check_if_ad_group_still_exists().done( function( still_exists ) {
if ( !still_exists )
{
handle_success( ( res && res.message ) ? res.message : 'Grupa reklam zostala usunieta.' );
return;
}
$.alert({
title: 'Blad',
content: ( res && res.message ) ? res.message : 'Nie udalo sie usunac grupy reklam.',
type: 'red'
});
} );
}
},
error: function( jqXHR )
{
var res = parse_json_loose( jqXHR && jqXHR.responseText ? jqXHR.responseText : '' );
if ( res && res.status === 'ok' )
{
handle_success( res.message );
return;
}
check_if_ad_group_still_exists().done( function( still_exists ) {
if ( !still_exists )
{
handle_success( ( res && res.message ) ? res.message : 'Grupa reklam zostala usunieta.' );
return;
}
$.alert({
title: 'Blad',
content: ( res && res.message ) ? res.message : 'Wystapil blad podczas usuwania grupy reklam.',
type: 'red'
});
} );
}
});
return false;
}
function load_client_bestseller_min_roas( client_id ) function load_client_bestseller_min_roas( client_id )
{ {
if ( !client_id ) if ( !client_id )
@@ -386,16 +556,35 @@ $( function()
dataType: 'json' dataType: 'json'
}).done( function( res ) { }).done( function( res ) {
( res.campaigns || [] ).forEach( function( row ) { ( res.campaigns || [] ).forEach( function( row ) {
$campaign.append( '<option value="' + row.id + '">' + row.campaign_name + '</option>' ); var channel_type = String( row.advertising_channel_type || '' ).toUpperCase();
$campaign.append(
'<option value="' + row.id + '" data-channel-type="' + channel_type + '">' + escape_html( row.campaign_name || '' ) + '</option>'
);
} ); } );
if ( selected_campaign_id && $campaign.find( 'option[value="' + selected_campaign_id + '"]' ).length ) if ( selected_campaign_id && $campaign.find( 'option[value="' + selected_campaign_id + '"]' ).length )
{ {
$campaign.val( selected_campaign_id ); $campaign.val( selected_campaign_id );
} }
update_delete_ad_group_button_state();
} ); } );
} }
function get_selected_products_campaign_channel_type()
{
var campaign_id = $( '#products_campaign_id' ).val() || '';
if ( !campaign_id )
{
return '';
}
return String(
$( '#products_campaign_id option[value="' + campaign_id + '"]' ).attr( 'data-channel-type' ) || ''
).toUpperCase();
}
function load_products_ad_groups( campaign_id, selected_ad_group_id ) function load_products_ad_groups( campaign_id, selected_ad_group_id )
{ {
var $ad_group = $( '#products_ad_group_id' ); var $ad_group = $( '#products_ad_group_id' );
@@ -403,6 +592,7 @@ $( function()
if ( !campaign_id ) if ( !campaign_id )
{ {
update_delete_ad_group_button_state();
return $.Deferred().resolve().promise(); return $.Deferred().resolve().promise();
} }
@@ -419,6 +609,168 @@ $( function()
{ {
$ad_group.val( selected_ad_group_id ); $ad_group.val( selected_ad_group_id );
} }
update_delete_ad_group_button_state();
} );
}
function update_delete_ad_group_button_state()
{
var campaign_channel_type = get_selected_products_campaign_channel_type();
var ad_group_id = $( '#products_ad_group_id' ).val() || '';
var is_shopping_campaign = campaign_channel_type === 'SHOPPING';
var can_delete = is_shopping_campaign && ad_group_id !== '';
var $btn = $( '#delete-products-ad-group' );
$btn.prop( 'disabled', !can_delete );
if ( !is_shopping_campaign )
{
$btn.attr( 'title', 'Opcja dostepna tylko dla kampanii produktowych (Shopping)' );
}
else if ( ad_group_id === '' )
{
$btn.attr( 'title', 'Wybierz grupe reklam do usuniecia' );
}
else
{
$btn.attr( 'title', 'Usun wybrana grupe reklam' );
}
}
function render_scope_alerts_box( alerts )
{
var $box = $( '#products_scope_alerts_box' );
var $count = $( '#products_scope_alerts_count' );
var $list = $( '#products_scope_alerts_list' );
var rows = Array.isArray( alerts ) ? alerts : [];
if ( !rows.length )
{
$count.text( '0' );
$list.empty();
$box.addClass( 'hide' ).removeAttr( 'open' );
return;
}
var html = '';
rows.forEach( function( row ) {
var date_text = row.date_detected || row.date_add || '';
var type_text = row.alert_type ? String( row.alert_type ) : '';
var message_text = row.message ? String( row.message ) : '';
html += ''
+ '<div class="products-scope-alert-item">'
+ '<div class="products-scope-alert-meta">'
+ '<span class="products-scope-alert-date">' + escape_html( date_text ) + '</span>'
+ ( type_text ? '<span class="products-scope-alert-type">' + escape_html( type_text ) + '</span>' : '' )
+ '</div>'
+ '<div class="products-scope-alert-message">' + escape_html( message_text ) + '</div>'
+ '</div>';
} );
$count.text( rows.length );
$list.html( html );
$box.removeClass( 'hide' ).attr( 'open', 'open' );
}
function load_scope_alerts()
{
var client_id = $( '#client_id' ).val() || '';
var campaign_id = $( '#products_campaign_id' ).val() || '';
var ad_group_id = $( '#products_ad_group_id' ).val() || '';
if ( !client_id || !campaign_id )
{
render_scope_alerts_box( [] );
return $.Deferred().resolve().promise();
}
return $.ajax({
url: '/products/get_scope_alerts/',
type: 'POST',
dataType: 'json',
data: {
client_id: client_id,
campaign_id: campaign_id,
ad_group_id: ad_group_id
}
}).done( function( res ) {
if ( res && res.status === 'ok' )
{
render_scope_alerts_box( res.alerts || [] );
}
else
{
render_scope_alerts_box( [] );
}
}).fail( function() {
render_scope_alerts_box( [] );
} );
}
function render_zero_impressions_box( products )
{
var $box = $( '#products_zero_impressions_box' );
var $count = $( '#products_zero_impressions_count' );
var $select = $( '#products_zero_impressions_select' );
var rows = Array.isArray( products ) ? products : [];
$select.empty();
if ( !rows.length )
{
$count.text( '0' );
$box.addClass( 'hide' ).removeAttr( 'open' );
return;
}
rows.forEach( function( row ) {
var product_id = parseInt( row.product_id || 0, 10 );
var offer_id = row.offer_id ? String( row.offer_id ) : '';
var product_name = row.name ? String( row.name ) : '';
var label = '#' + product_id + ( offer_id ? ' | ' + offer_id : '' ) + ' | ' + product_name;
$select.append( '<option value="' + product_id + '">' + escape_html( label ) + '</option>' );
} );
$count.text( rows.length );
$box.removeClass( 'hide' );
}
function load_zero_impressions_products()
{
var client_id = $( '#client_id' ).val() || '';
var campaign_id = $( '#products_campaign_id' ).val() || '';
var ad_group_id = $( '#products_ad_group_id' ).val() || '';
if ( !client_id || !campaign_id )
{
render_zero_impressions_box( [] );
return $.Deferred().resolve().promise();
}
return $.ajax({
url: '/products/get_products_without_impressions_30/',
type: 'POST',
dataType: 'json',
data: {
client_id: client_id,
campaign_id: campaign_id,
ad_group_id: ad_group_id
}
}).done( function( res ) {
if ( res && res.status === 'ok' )
{
render_zero_impressions_box( res.products || [] );
}
else
{
render_zero_impressions_box( [] );
}
}).fail( function() {
render_zero_impressions_box( [] );
} ); } );
} }
@@ -730,11 +1082,15 @@ $( function()
localStorage.setItem( 'products_client_id', client_id ); localStorage.setItem( 'products_client_id', client_id );
localStorage.removeItem( 'products_campaign_id' ); localStorage.removeItem( 'products_campaign_id' );
localStorage.removeItem( 'products_ad_group_id' ); localStorage.removeItem( 'products_ad_group_id' );
update_delete_ad_group_button_state();
load_client_bestseller_min_roas( client_id ); load_client_bestseller_min_roas( client_id );
load_products_campaigns( client_id, '' ).done( function() { load_products_campaigns( client_id, '' ).done( function() {
load_products_ad_groups( '', '' ).done( function() { load_products_ad_groups( '', '' ).done( function() {
reload_products_table(); update_delete_ad_group_button_state();
$.when( load_scope_alerts(), load_zero_impressions_products() ).always( function() {
reload_products_table();
} );
} ); } );
} ); } );
}); });
@@ -744,9 +1100,12 @@ $( function()
var campaign_id = $( this ).val() || ''; var campaign_id = $( this ).val() || '';
localStorage.setItem( 'products_campaign_id', campaign_id ); localStorage.setItem( 'products_campaign_id', campaign_id );
localStorage.removeItem( 'products_ad_group_id' ); localStorage.removeItem( 'products_ad_group_id' );
update_delete_ad_group_button_state();
load_products_ad_groups( campaign_id, '' ).done( function() { load_products_ad_groups( campaign_id, '' ).done( function() {
reload_products_table(); $.when( load_scope_alerts(), load_zero_impressions_products() ).always( function() {
reload_products_table();
} );
} ); } );
}); });
@@ -754,7 +1113,71 @@ $( function()
{ {
var ad_group_id = $( this ).val() || ''; var ad_group_id = $( this ).val() || '';
localStorage.setItem( 'products_ad_group_id', ad_group_id ); localStorage.setItem( 'products_ad_group_id', ad_group_id );
reload_products_table(); update_delete_ad_group_button_state();
$.when( load_scope_alerts(), load_zero_impressions_products() ).always( function() {
reload_products_table();
} );
});
$( 'body' ).on( 'click', '#delete-products-ad-group', function( e )
{
e.preventDefault();
var campaign_id = $( '#products_campaign_id' ).val() || '';
var ad_group_id = $( '#products_ad_group_id' ).val() || '';
var campaign_channel_type = get_selected_products_campaign_channel_type();
if ( campaign_channel_type !== 'SHOPPING' )
{
$.alert({
title: 'Niedostepne',
content: 'Usuwanie grup reklam jest dostepne tylko dla kampanii produktowych (Shopping).',
type: 'orange'
});
return;
}
if ( !campaign_id || !ad_group_id )
{
$.alert({
title: 'Brak wyboru',
content: 'Wybierz kampanie i grupe reklam do usuniecia.',
type: 'orange'
});
return;
}
var ad_group_name = $.trim( $( '#products_ad_group_id option:selected' ).text() || '' );
var campaign_name = $.trim( $( '#products_campaign_id option:selected' ).text() || '' );
$.confirm({
title: 'Usuwanie grupy reklam',
content:
'Jak chcesz usunac grupe reklam <strong>' + escape_html( ad_group_name ) + '</strong> z kampanii <strong>' + escape_html( campaign_name ) + '</strong>?'
+ '<br><br><small>Opcja API usunie grupe reklam rowniez w Google Ads.</small>',
type: 'red',
buttons: {
local: {
text: 'Tylko lokalnie',
btnClass: 'btn-default',
action: function()
{
return submit_delete_campaign_ad_group( campaign_id, ad_group_id, 'local' );
}
},
google: {
text: 'Lokalnie + Google Ads',
btnClass: 'btn-red',
action: function()
{
return submit_delete_campaign_ad_group( campaign_id, ad_group_id, 'google' );
}
},
cancel: {
text: 'Anuluj'
}
}
});
}); });
var savedClient = localStorage.getItem( 'products_client_id' ) || ''; var savedClient = localStorage.getItem( 'products_client_id' ) || '';
@@ -770,7 +1193,10 @@ $( function()
load_products_campaigns( $( '#client_id' ).val() || '', savedCampaign ).done( function() { load_products_campaigns( $( '#client_id' ).val() || '', savedCampaign ).done( function() {
var selected_campaign_id = $( '#products_campaign_id' ).val() || ''; var selected_campaign_id = $( '#products_campaign_id' ).val() || '';
load_products_ad_groups( selected_campaign_id, savedAdGroup ).done( function() { load_products_ad_groups( selected_campaign_id, savedAdGroup ).done( function() {
reload_products_table(); update_delete_ad_group_button_state();
$.when( load_scope_alerts(), load_zero_impressions_products() ).always( function() {
reload_products_table();
} );
} ); } );
}); });

View File

@@ -58,6 +58,7 @@
<strong><?= htmlspecialchars( (string) ( $row['name'] ?? 'Cron' ) ); ?></strong> <strong><?= htmlspecialchars( (string) ( $row['name'] ?? 'Cron' ) ); ?></strong>
<small>Ostatnio: <span data-cron-url-last-invoked><?= htmlspecialchars( (string) ( $row['last_invoked_at'] ?? 'Brak danych' ) ); ?></span></small> <small>Ostatnio: <span data-cron-url-last-invoked><?= htmlspecialchars( (string) ( $row['last_invoked_at'] ?? 'Brak danych' ) ); ?></span></small>
</div> </div>
<small class="cron-url-plan" data-cron-url-plan><?= htmlspecialchars( (string) ( $row['plan'] ?? '' ) ); ?></small>
<code><?= htmlspecialchars( (string) ( $row['url'] ?? '' ) ); ?></code> <code><?= htmlspecialchars( (string) ( $row['url'] ?? '' ) ); ?></code>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
@@ -382,6 +383,12 @@
{ {
last_invoked.textContent = row && row.last_invoked_at ? row.last_invoked_at : 'Brak danych'; last_invoked.textContent = row && row.last_invoked_at ? row.last_invoked_at : 'Brak danych';
} }
var plan = container.querySelector( '[data-cron-url-plan]' );
if ( plan )
{
plan.textContent = row && row.plan ? row.plan : '';
}
} ); } );
} }

View File

@@ -0,0 +1,54 @@
{"ts":"2026-02-20 10:51:13","stage":"sync_product_campaign_alerts_for_client:start","context":{"client_id":2,"customer_id":"941-605-1782","date_sync":"2026-02-20","campaigns_db_map_count":6,"ad_group_db_map_count":20}}
{"ts":"2026-02-20 10:51:15","stage":"google_ads:get_shopping_ad_group_offer_ids","context":{"result_type":"false","rows_count":0,"sample":[]}}
{"ts":"2026-02-20 10:51:16","stage":"google_ads:get_shopping_ad_group_offer_ids_from_performance","context":{"result_type":"array","rows_count":147,"sample":[{"campaign_id":22389325355,"campaign_name":"[PLA] chrzest święty","ad_group_id":174195978930,"ad_group_name":"Pamiątka Pierwszej Komunii Świętej Pudełko na pieniądze - Kielich","offer_ids":[1773]},{"campaign_id":22389325355,"campaign_name":"[PLA] chrzest święty","ad_group_id":175963483182,"ad_group_name":"Pamiątka Pierwszej Komunii Świętej Pudełko na pieniądze - Dłonie","offer_ids":[1772]},{"campaign_id":22389325355,"campaign_name":"[PLA] chrzest święty","ad_group_id":177111077493,"ad_group_name":"Prośba o bycie Matką Chrzestną, grawerowana, z imieniem dziecka, Misiek - Kolor serduszka: czerwony","offer_ids":[921]}]}}
{"ts":"2026-02-20 10:51:16","stage":"merchant:offer_ids_to_verify","context":{"count":41,"sample":[1675,1467,1063,1678,910,1500,944,1652,1738,1072,1672,2025,934,928,1316,1319,1313,1715,1677,1767,1340,1778,1067,1721,1368,1339,949,1345,1824,1536]}}
{"ts":"2026-02-20 10:51:18","stage":"merchant:get_merchant_products_for_offer_ids","context":{"merchant_account_id":"729206752","request_count":41,"response_type":"array","response_count":41,"response_sample_keys":[1467,1767,1672,1652,1067,1368,1510,1675,1824,1536,1321,1340,1720,1063,1339,1316,949,1436,1715,1722,1778,1874,1723,928,1319,1345,1313,1738,1677,1678]}}
{"ts":"2026-02-20 10:51:18","stage":"db:local_offer_ids","context":{"count":592,"sample":[1778,2013,944,1536,2016,1928,2014,1720,1951,1064,1978,906,921,1993,1942,1812,1813,1061,1596,1509,911,1360,1569,1662,2080,1581,1783,1580,2071,1849]}}
{"ts":"2026-02-20 10:51:18","stage":"scope:verification","context":{"scope_key":"22855767992|178830027730","campaign_external_id":22855767992,"ad_group_external_id":178830027730,"offer_ids":["1675"],"active_offer_count":1,"orphaned_offer_ids":[],"local_known_offer_count":1}}
{"ts":"2026-02-20 10:51:18","stage":"scope:verification","context":{"scope_key":"22855767992|182023998383","campaign_external_id":22855767992,"ad_group_external_id":182023998383,"offer_ids":["1467"],"active_offer_count":1,"orphaned_offer_ids":[],"local_known_offer_count":1}}
{"ts":"2026-02-20 10:51:18","stage":"scope:verification","context":{"scope_key":"22855767992|182184163854","campaign_external_id":22855767992,"ad_group_external_id":182184163854,"offer_ids":["1063"],"active_offer_count":1,"orphaned_offer_ids":[],"local_known_offer_count":1}}
{"ts":"2026-02-20 10:51:18","stage":"scope:verification","context":{"scope_key":"22881177775|182329510303","campaign_external_id":22881177775,"ad_group_external_id":182329510303,"offer_ids":["1678"],"active_offer_count":1,"orphaned_offer_ids":[],"local_known_offer_count":1}}
{"ts":"2026-02-20 10:51:18","stage":"scope:verification","context":{"scope_key":"22855767992|182437144463","campaign_external_id":22855767992,"ad_group_external_id":182437144463,"offer_ids":["910"],"active_offer_count":1,"orphaned_offer_ids":[],"local_known_offer_count":1}}
{"ts":"2026-02-20 10:51:18","stage":"scope:verification","context":{"scope_key":"22855767992|183091891829","campaign_external_id":22855767992,"ad_group_external_id":183091891829,"offer_ids":["1500"],"active_offer_count":1,"orphaned_offer_ids":[],"local_known_offer_count":1}}
{"ts":"2026-02-20 10:51:18","stage":"scope:verification","context":{"scope_key":"22855767992|183523029996","campaign_external_id":22855767992,"ad_group_external_id":183523029996,"offer_ids":["944"],"active_offer_count":1,"orphaned_offer_ids":[],"local_known_offer_count":1}}
{"ts":"2026-02-20 10:51:18","stage":"scope:verification","context":{"scope_key":"22855767992|184090691740","campaign_external_id":22855767992,"ad_group_external_id":184090691740,"offer_ids":["1652"],"active_offer_count":1,"orphaned_offer_ids":[],"local_known_offer_count":1}}
{"ts":"2026-02-20 10:51:18","stage":"scope:verification","context":{"scope_key":"22881177775|184491635422","campaign_external_id":22881177775,"ad_group_external_id":184491635422,"offer_ids":["1738"],"active_offer_count":1,"orphaned_offer_ids":[],"local_known_offer_count":1}}
{"ts":"2026-02-20 10:51:18","stage":"scope:verification","context":{"scope_key":"22881177775|184781679245","campaign_external_id":22881177775,"ad_group_external_id":184781679245,"offer_ids":["1072"],"active_offer_count":1,"orphaned_offer_ids":[],"local_known_offer_count":0}}
{"ts":"2026-02-20 10:51:18","stage":"scope:verification","context":{"scope_key":"22881177775|185056281737","campaign_external_id":22881177775,"ad_group_external_id":185056281737,"offer_ids":["1672"],"active_offer_count":1,"orphaned_offer_ids":[],"local_known_offer_count":1}}
{"ts":"2026-02-20 10:51:18","stage":"scope:verification","context":{"scope_key":"22881177775|186478211005","campaign_external_id":22881177775,"ad_group_external_id":186478211005,"offer_ids":["2025"],"active_offer_count":1,"orphaned_offer_ids":[],"local_known_offer_count":1}}
{"ts":"2026-02-20 10:51:18","stage":"scope:verification","context":{"scope_key":"22855767992|187193909207","campaign_external_id":22855767992,"ad_group_external_id":187193909207,"offer_ids":["934"],"active_offer_count":1,"orphaned_offer_ids":[],"local_known_offer_count":1}}
{"ts":"2026-02-20 10:51:18","stage":"scope:verification","context":{"scope_key":"22855767992|187202566030","campaign_external_id":22855767992,"ad_group_external_id":187202566030,"offer_ids":["928"],"active_offer_count":1,"orphaned_offer_ids":[],"local_known_offer_count":1}}
{"ts":"2026-02-20 10:51:18","stage":"scope:verification","context":{"scope_key":"22881177775|187617501346","campaign_external_id":22881177775,"ad_group_external_id":187617501346,"offer_ids":["1316"],"active_offer_count":1,"orphaned_offer_ids":[],"local_known_offer_count":0}}
{"ts":"2026-02-20 10:51:18","stage":"scope:verification","context":{"scope_key":"22881177775|187617773426","campaign_external_id":22881177775,"ad_group_external_id":187617773426,"offer_ids":["1319"],"active_offer_count":1,"orphaned_offer_ids":[],"local_known_offer_count":0}}
{"ts":"2026-02-20 10:51:18","stage":"scope:verification","context":{"scope_key":"22881177775|187737512843","campaign_external_id":22881177775,"ad_group_external_id":187737512843,"offer_ids":["1313"],"active_offer_count":1,"orphaned_offer_ids":[],"local_known_offer_count":1}}
{"ts":"2026-02-20 10:51:18","stage":"scope:verification","context":{"scope_key":"22881177775|188437646135","campaign_external_id":22881177775,"ad_group_external_id":188437646135,"offer_ids":["1715"],"active_offer_count":1,"orphaned_offer_ids":[],"local_known_offer_count":0}}
{"ts":"2026-02-20 10:51:18","stage":"scope:verification","context":{"scope_key":"22881177775|188844670995","campaign_external_id":22881177775,"ad_group_external_id":188844670995,"offer_ids":["1677"],"active_offer_count":1,"orphaned_offer_ids":[],"local_known_offer_count":0}}
{"ts":"2026-02-20 10:51:18","stage":"scope:verification","context":{"scope_key":"23169295644|189029743564","campaign_external_id":23169295644,"ad_group_external_id":189029743564,"offer_ids":["1767"],"active_offer_count":1,"orphaned_offer_ids":[],"local_known_offer_count":1}}
{"ts":"2026-02-20 10:51:18","stage":"scope:verification","context":{"scope_key":"22881177775|189133168538","campaign_external_id":22881177775,"ad_group_external_id":189133168538,"offer_ids":["1340"],"active_offer_count":1,"orphaned_offer_ids":[],"local_known_offer_count":0}}
{"ts":"2026-02-20 10:51:18","stage":"scope:verification","context":{"scope_key":"22881177775|189174321780","campaign_external_id":22881177775,"ad_group_external_id":189174321780,"offer_ids":["1672"],"active_offer_count":1,"orphaned_offer_ids":[],"local_known_offer_count":1}}
{"ts":"2026-02-20 10:51:18","stage":"scope:verification","context":{"scope_key":"22855767992|189377483261","campaign_external_id":22855767992,"ad_group_external_id":189377483261,"offer_ids":["1778"],"active_offer_count":1,"orphaned_offer_ids":[],"local_known_offer_count":1}}
{"ts":"2026-02-20 10:51:18","stage":"scope:verification","context":{"scope_key":"22855767992|189393194528","campaign_external_id":22855767992,"ad_group_external_id":189393194528,"offer_ids":["1067"],"active_offer_count":1,"orphaned_offer_ids":[],"local_known_offer_count":1}}
{"ts":"2026-02-20 10:51:18","stage":"scope:verification","context":{"scope_key":"22881177775|189782782720","campaign_external_id":22881177775,"ad_group_external_id":189782782720,"offer_ids":["1721"],"active_offer_count":1,"orphaned_offer_ids":[],"local_known_offer_count":1}}
{"ts":"2026-02-20 10:51:18","stage":"scope:verification","context":{"scope_key":"22881177775|190157433529","campaign_external_id":22881177775,"ad_group_external_id":190157433529,"offer_ids":["1368"],"active_offer_count":1,"orphaned_offer_ids":[],"local_known_offer_count":0}}
{"ts":"2026-02-20 10:51:18","stage":"scope:verification","context":{"scope_key":"22855767992|190254577916","campaign_external_id":22855767992,"ad_group_external_id":190254577916,"offer_ids":["1738"],"active_offer_count":1,"orphaned_offer_ids":[],"local_known_offer_count":1}}
{"ts":"2026-02-20 10:51:18","stage":"scope:verification","context":{"scope_key":"22881177775|190981545071","campaign_external_id":22881177775,"ad_group_external_id":190981545071,"offer_ids":["1339"],"active_offer_count":1,"orphaned_offer_ids":[],"local_known_offer_count":1}}
{"ts":"2026-02-20 10:51:18","stage":"scope:verification","context":{"scope_key":"23554819089|191005617337","campaign_external_id":23554819089,"ad_group_external_id":191005617337,"offer_ids":["944"],"active_offer_count":1,"orphaned_offer_ids":[],"local_known_offer_count":1}}
{"ts":"2026-02-20 10:51:18","stage":"scope:verification","context":{"scope_key":"23554819089|191005617537","campaign_external_id":23554819089,"ad_group_external_id":191005617537,"offer_ids":["949"],"active_offer_count":1,"orphaned_offer_ids":[],"local_known_offer_count":1}}
{"ts":"2026-02-20 10:51:18","stage":"scope:verification","context":{"scope_key":"23554819089|191005617577","campaign_external_id":23554819089,"ad_group_external_id":191005617577,"offer_ids":["1345"],"active_offer_count":1,"orphaned_offer_ids":[],"local_known_offer_count":1}}
{"ts":"2026-02-20 10:51:18","stage":"scope:verification","context":{"scope_key":"23554819089|191005617737","campaign_external_id":23554819089,"ad_group_external_id":191005617737,"offer_ids":["1824"],"active_offer_count":1,"orphaned_offer_ids":[],"local_known_offer_count":1}}
{"ts":"2026-02-20 10:51:18","stage":"scope:verification","context":{"scope_key":"23554819089|191005618937","campaign_external_id":23554819089,"ad_group_external_id":191005618937,"offer_ids":["1536"],"active_offer_count":1,"orphaned_offer_ids":[],"local_known_offer_count":1}}
{"ts":"2026-02-20 10:51:18","stage":"scope:verification","context":{"scope_key":"22881177775|191170651834","campaign_external_id":22881177775,"ad_group_external_id":191170651834,"offer_ids":["1324"],"active_offer_count":1,"orphaned_offer_ids":[],"local_known_offer_count":0}}
{"ts":"2026-02-20 10:51:18","stage":"scope:verification","context":{"scope_key":"22881177775|191185839027","campaign_external_id":22881177775,"ad_group_external_id":191185839027,"offer_ids":["1321"],"active_offer_count":1,"orphaned_offer_ids":[],"local_known_offer_count":0}}
{"ts":"2026-02-20 10:51:18","stage":"scope:verification","context":{"scope_key":"22855767992|191596793445","campaign_external_id":22855767992,"ad_group_external_id":191596793445,"offer_ids":["2016"],"active_offer_count":1,"orphaned_offer_ids":[],"local_known_offer_count":1}}
{"ts":"2026-02-20 10:51:18","stage":"scope:verification","context":{"scope_key":"23554819089|191936594094","campaign_external_id":23554819089,"ad_group_external_id":191936594094,"offer_ids":["1928"],"active_offer_count":1,"orphaned_offer_ids":[],"local_known_offer_count":1}}
{"ts":"2026-02-20 10:51:18","stage":"scope:verification","context":{"scope_key":"22855767992|191938697986","campaign_external_id":22855767992,"ad_group_external_id":191938697986,"offer_ids":["949"],"active_offer_count":1,"orphaned_offer_ids":[],"local_known_offer_count":1}}
{"ts":"2026-02-20 10:51:18","stage":"scope:verification","context":{"scope_key":"22855767992|192480293437","campaign_external_id":22855767992,"ad_group_external_id":192480293437,"offer_ids":["1536"],"active_offer_count":1,"orphaned_offer_ids":[],"local_known_offer_count":1}}
{"ts":"2026-02-20 10:51:18","stage":"scope:verification","context":{"scope_key":"22855767992|192764842295","campaign_external_id":22855767992,"ad_group_external_id":192764842295,"offer_ids":["1824"],"active_offer_count":1,"orphaned_offer_ids":[],"local_known_offer_count":1}}
{"ts":"2026-02-20 10:51:18","stage":"scope:verification","context":{"scope_key":"23554819089|193141002373","campaign_external_id":23554819089,"ad_group_external_id":193141002373,"offer_ids":["1915"],"active_offer_count":1,"orphaned_offer_ids":[],"local_known_offer_count":1}}
{"ts":"2026-02-20 10:51:18","stage":"scope:verification","context":{"scope_key":"22881177775|193760921208","campaign_external_id":22881177775,"ad_group_external_id":193760921208,"offer_ids":["1723"],"active_offer_count":1,"orphaned_offer_ids":[],"local_known_offer_count":0}}
{"ts":"2026-02-20 10:51:18","stage":"scope:verification","context":{"scope_key":"23554819089|194005981900","campaign_external_id":23554819089,"ad_group_external_id":194005981900,"offer_ids":["1874"],"active_offer_count":1,"orphaned_offer_ids":[],"local_known_offer_count":1}}
{"ts":"2026-02-20 10:51:18","stage":"scope:verification","context":{"scope_key":"22881177775|194928085664","campaign_external_id":22881177775,"ad_group_external_id":194928085664,"offer_ids":["1722"],"active_offer_count":1,"orphaned_offer_ids":[],"local_known_offer_count":1}}
{"ts":"2026-02-20 10:51:18","stage":"scope:verification","context":{"scope_key":"22881177775|195763522708","campaign_external_id":22881177775,"ad_group_external_id":195763522708,"offer_ids":["1436"],"active_offer_count":1,"orphaned_offer_ids":[],"local_known_offer_count":1}}
{"ts":"2026-02-20 10:51:18","stage":"scope:verification","context":{"scope_key":"22855767992|197096047150","campaign_external_id":22855767992,"ad_group_external_id":197096047150,"offer_ids":["1720"],"active_offer_count":1,"orphaned_offer_ids":[],"local_known_offer_count":1}}
{"ts":"2026-02-20 10:51:18","stage":"scope:verification","context":{"scope_key":"23554819089|198928421011","campaign_external_id":23554819089,"ad_group_external_id":198928421011,"offer_ids":["1510"],"active_offer_count":1,"orphaned_offer_ids":[],"local_known_offer_count":1}}
{"ts":"2026-02-20 10:51:18","stage":"alerts:cleanup","context":{"existing_count":7,"cleaned_count":0,"problematic_without_active_product":0,"problematic_with_orphaned_offers":0,"problematic_without_detected_product":7}}

32
tmp/debug_clients.php Normal file
View File

@@ -0,0 +1,32 @@
<?php
error_reporting(E_ALL);
ini_set('display_errors', '1');
echo "START\n";
require 'config.php';
require 'libraries/medoo/medoo.php';
try {
$mdb = new medoo([
'database_type' => 'mysql',
'database_name' => $database['name'],
'server' => $database['host'],
'username' => $database['user'],
'password' => $database['password'],
'charset' => 'utf8'
]);
echo "CONNECTED\n";
$cols = $mdb->query("SHOW COLUMNS FROM clients")->fetchAll(PDO::FETCH_ASSOC);
echo "COLUMNS=" . count($cols) . "\n";
foreach ($cols as $c) {
echo ($c['Field'] ?? '') . "\n";
}
$data = $mdb->query("SELECT id, name, active, COALESCE(google_ads_customer_id,'') AS google_ads_customer_id FROM clients ORDER BY id ASC")->fetchAll(PDO::FETCH_ASSOC);
echo "ROWS=" . count($data) . "\n";
foreach ($data as $r) {
echo json_encode($r, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES) . "\n";
}
$eligible = $mdb->query("SELECT id FROM clients WHERE COALESCE(deleted,0)=0 AND active=1 AND google_ads_customer_id IS NOT NULL AND google_ads_customer_id<>'' ORDER BY id ASC")->fetchAll(PDO::FETCH_COLUMN);
echo "ELIGIBLE=" . json_encode($eligible) . "\n";
} catch (Throwable $e) {
echo "ERR: " . $e->getMessage() . "\n";
}
?>

View File

@@ -0,0 +1,35 @@
<?php
error_reporting(E_ALL);
ini_set('display_errors', '1');
require 'config.php';
require 'libraries/medoo/medoo.php';
$host = $database['remote_host'];
try {
$mdb = new medoo([
'database_type' => 'mysql',
'database_name' => $database['name'],
'server' => $host,
'username' => $database['user'],
'password' => $database['password'],
'charset' => 'utf8'
]);
echo "CONNECTED_REMOTE=" . $host . PHP_EOL;
$rows = $mdb->query("SELECT id, name, COALESCE(active,0) AS active, COALESCE(deleted,0) AS deleted, CONCAT('[', COALESCE(google_ads_customer_id,''), ']') AS google_ads_customer_id FROM clients ORDER BY id ASC")->fetchAll(PDO::FETCH_ASSOC);
echo "CLIENTS_TOTAL=" . count($rows) . PHP_EOL;
foreach ($rows as $r) {
echo json_encode($r, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES) . PHP_EOL;
}
$eligible = $mdb->query("SELECT id, name FROM clients WHERE COALESCE(deleted,0)=0 AND COALESCE(active,0)=1 AND TRIM(COALESCE(google_ads_customer_id,''))<>'' ORDER BY id ASC")->fetchAll(PDO::FETCH_ASSOC);
echo "ELIGIBLE_TOTAL=" . count($eligible) . PHP_EOL;
foreach ($eligible as $e) {
echo "ELIGIBLE=" . json_encode($e, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES) . PHP_EOL;
}
} catch (Throwable $e) {
echo "ERR=" . $e->getMessage() . PHP_EOL;
}
?>

View File

@@ -0,0 +1,20 @@
<?php
require 'config.php';
require 'libraries/medoo/medoo.php';
$mdb = new medoo([
'database_type' => 'mysql',
'database_name' => $database['name'],
'server' => $database['remote_host'],
'username' => $database['user'],
'password' => $database['password'],
'charset' => 'utf8'
]);
$cols = $mdb->query("SHOW COLUMNS FROM clients")->fetchAll(PDO::FETCH_ASSOC);
echo "COLUMNS:" . PHP_EOL;
foreach ($cols as $c) { echo $c['Field'] . PHP_EOL; }
$rows = $mdb->query("SELECT id, name, COALESCE(active,0) AS active, CONCAT('[', COALESCE(google_ads_customer_id,''), ']') AS google_ads_customer_id FROM clients ORDER BY id ASC")->fetchAll(PDO::FETCH_ASSOC);
echo "ROWS=" . count($rows) . PHP_EOL;
foreach ($rows as $r) { echo json_encode($r, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES) . PHP_EOL; }
$eligible = $mdb->query("SELECT id, name FROM clients WHERE COALESCE(active,0)=1 AND TRIM(COALESCE(google_ads_customer_id,''))<>'' ORDER BY id ASC")->fetchAll(PDO::FETCH_ASSOC);
echo "ELIGIBLE=" . json_encode($eligible, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES) . PHP_EOL;
?>

View File

@@ -0,0 +1,14 @@
<?php
require 'config.php';
require 'libraries/medoo/medoo.php';
$mdb = new medoo([
'database_type' => 'mysql',
'database_name' => $database['name'],
'server' => $database['remote_host'],
'username' => $database['user'],
'password' => $database['password'],
'charset' => 'utf8'
]);
$rows = $mdb->query("SELECT id, name FROM clients WHERE COALESCE(active,0)=1 AND TRIM(COALESCE(google_ads_customer_id,''))<>'' ORDER BY id ASC")->fetchAll(PDO::FETCH_ASSOC);
echo json_encode($rows, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES) . PHP_EOL;
?>