Files
adsPRO/autoload/controls/class.Products.php

1368 lines
48 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace controls;
class Products
{
static private function normalize_keyword_source_url( $url )
{
$url = trim( (string) $url );
if ( $url === '' )
{
return '';
}
$parts = parse_url( $url );
if ( !is_array( $parts ) || empty( $parts['scheme'] ) || empty( $parts['host'] ) )
{
return $url;
}
$normalized = strtolower( (string) $parts['scheme'] ) . '://' . strtolower( (string) $parts['host'] );
if ( isset( $parts['port'] ) )
{
$normalized .= ':' . (int) $parts['port'];
}
$normalized .= isset( $parts['path'] ) ? (string) $parts['path'] : '/';
if ( isset( $parts['query'] ) && $parts['query'] !== '' )
{
$normalized .= '?' . (string) $parts['query'];
}
return $normalized;
}
static public function sync_product_fields_to_merchant( $product_id, $changed_fields, $sync_source = 'products_ui' )
{
$product_id = (int) $product_id;
$changed_fields = is_array( $changed_fields ) ? $changed_fields : [];
$sync_source = trim( (string) $sync_source ) ?: 'products_ui';
if ( $product_id <= 0 || empty( $changed_fields ) )
{
return [ 'status' => 'skipped', 'message' => 'Brak zmian do synchronizacji.' ];
}
$supported_fields = [ 'title', 'description', 'google_product_category' ];
$normalized_changes = [];
foreach ( $changed_fields as $field => $change )
{
if ( !in_array( $field, $supported_fields, true ) )
{
continue;
}
$old_value = trim( (string) ( $change['old'] ?? '' ) );
$new_value = trim( (string) ( $change['new'] ?? '' ) );
if ( $old_value === $new_value )
{
continue;
}
$normalized_changes[ $field ] = [
'old' => $old_value,
'new' => $new_value
];
}
if ( empty( $normalized_changes ) )
{
return [ 'status' => 'skipped', 'message' => 'Brak rzeczywistych zmian pól.' ];
}
$merchant_context = \factory\Products::get_product_merchant_context( $product_id );
$merchant_account_id = trim( (string) ( $merchant_context['google_merchant_account_id'] ?? '' ) );
$offer_id = trim( (string) ( $merchant_context['offer_id'] ?? '' ) );
if ( !$merchant_context || $merchant_account_id === '' || $offer_id === '' )
{
$reason = 'Brak merchant_account_id lub offer_id dla produktu.';
foreach ( $normalized_changes as $field => $change )
{
\factory\Products::add_product_merchant_sync_log( [
'product_id' => $product_id,
'field_name' => $field,
'old_value' => $change['old'],
'new_value' => $change['new'],
'sync_status' => 'skipped',
'sync_source' => $sync_source,
'merchant_account_id' => $merchant_account_id !== '' ? $merchant_account_id : null,
'offer_id' => $offer_id !== '' ? $offer_id : null,
'error_message' => $reason
] );
}
return [ 'status' => 'skipped', 'message' => $reason ];
}
$merchant_api = new \services\GoogleAdsApi();
if ( !$merchant_api -> is_merchant_configured() )
{
$reason = 'Merchant API nie jest skonfigurowane.';
foreach ( $normalized_changes as $field => $change )
{
\factory\Products::add_product_merchant_sync_log( [
'product_id' => $product_id,
'field_name' => $field,
'old_value' => $change['old'],
'new_value' => $change['new'],
'sync_status' => 'skipped',
'sync_source' => $sync_source,
'merchant_account_id' => $merchant_account_id,
'offer_id' => $offer_id,
'error_message' => $reason
] );
}
return [ 'status' => 'skipped', 'message' => $reason ];
}
$payload_fields = [];
foreach ( $normalized_changes as $field => $change )
{
$payload_fields[ $field ] = $change['new'];
}
$sync_result = $merchant_api -> update_merchant_product_fields_by_offer_id( $merchant_account_id, $offer_id, $payload_fields );
$sync_success = !empty( $sync_result['success'] );
$sync_status = $sync_success ? 'success' : 'error';
$sync_error = trim( (string) ( $sync_result['error'] ?? '' ) );
$merchant_product_id = trim( (string) ( $sync_result['merchant_product_id'] ?? '' ) );
$api_response = null;
if ( isset( $sync_result['response'] ) )
{
$api_response = is_string( $sync_result['response'] )
? $sync_result['response']
: json_encode( $sync_result['response'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES );
}
foreach ( $normalized_changes as $field => $change )
{
\factory\Products::add_product_merchant_sync_log( [
'product_id' => $product_id,
'field_name' => $field,
'old_value' => $change['old'],
'new_value' => $change['new'],
'sync_status' => $sync_status,
'sync_source' => $sync_source,
'merchant_account_id' => $merchant_account_id,
'merchant_product_id' => $merchant_product_id !== '' ? $merchant_product_id : null,
'offer_id' => $offer_id,
'api_response' => $api_response,
'error_message' => $sync_error !== '' ? $sync_error : null
] );
}
return [
'status' => $sync_status,
'message' => $sync_success ? 'Synchronizacja Merchant API zakończona sukcesem.' : ( $sync_error !== '' ? $sync_error : 'Błąd synchronizacji Merchant API.' ),
'result' => $sync_result
];
}
static public function main_view()
{
return \Tpl::view( 'products/main_view', [
'clients' => \factory\Campaigns::get_clients(),
] );
}
static public function get_campaigns_list()
{
$client_id = (int) \S::get( 'client_id' );
$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;
}
static public function get_campaign_ad_groups()
{
$campaign_id = (int) \S::get( 'campaign_id' );
if ( $campaign_id <= 0 )
{
echo json_encode( [ 'ad_groups' => [] ] );
exit;
}
echo json_encode( [ 'ad_groups' => \factory\Campaigns::get_campaign_ad_groups( $campaign_id ) ] );
exit;
}
static public function get_client_bestseller_settings()
{
$client_id = (int) \S::get( 'client_id' );
if ( $client_id <= 0 )
{
echo json_encode( [
'status' => 'error',
'message' => 'Nieprawidlowe ID klienta.'
] );
exit;
}
$settings = \factory\Products::get_client_bestseller_settings( $client_id );
echo json_encode( [
'status' => 'ok',
'settings' => $settings
] );
exit;
}
static public function save_client_bestseller_settings()
{
$client_id = (int) \S::get( 'client_id' );
if ( $client_id <= 0 )
{
echo json_encode( [ 'status' => 'error', 'message' => 'Nieprawidlowe ID klienta.' ] );
exit;
}
$settings = [
'bestseller_roas_entry' => \S::get( 'bestseller_roas_entry' ),
'bestseller_roas_exit' => \S::get( 'bestseller_roas_exit' ),
'min_conversions' => \S::get( 'min_conversions' ),
'cooldown_period' => \S::get( 'cooldown_period' )
];
$saved = \factory\Products::save_client_bestseller_settings( $client_id, $settings );
echo json_encode( [
'status' => $saved ? 'ok' : 'error',
'settings' => \factory\Products::get_client_bestseller_settings( $client_id )
] );
exit;
}
static public function preview_client_bestseller_settings()
{
$client_id = (int) \S::get( 'client_id' );
if ( $client_id <= 0 )
{
echo json_encode( [ 'status' => 'error', 'message' => 'Nieprawidlowe ID klienta.' ] );
exit;
}
$settings = [
'bestseller_roas_entry' => \S::get( 'bestseller_roas_entry' ),
'bestseller_roas_exit' => \S::get( 'bestseller_roas_exit' ),
'min_conversions' => \S::get( 'min_conversions' ),
'cooldown_period' => \S::get( 'cooldown_period' )
];
$stats = \factory\Products::get_client_bestseller_preview_stats( $client_id, $settings );
echo json_encode( [
'status' => 'ok',
'count' => (int) ( $stats['total_count'] ?? 0 ),
'entry_count' => (int) ( $stats['entry_count'] ?? 0 ),
'cooldown_count' => (int) ( $stats['cooldown_count'] ?? 0 ),
'total_count' => (int) ( $stats['total_count'] ?? 0 )
] );
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()
{
$product_id = (int) \S::get( 'product_id' );
$campaign_mode = trim( (string) \S::get( 'campaign_mode' ) );
$campaign_id = (int) \S::get( 'campaign_id' );
$campaign_name = trim( (string) \S::get( 'campaign_name' ) );
$ad_group_mode = trim( (string) \S::get( 'ad_group_mode' ) );
$ad_group_id = (int) \S::get( 'ad_group_id' );
$ad_group_name = trim( (string) \S::get( 'ad_group_name' ) );
$campaign_daily_budget = (float) \S::get( 'campaign_daily_budget' );
$default_cpc = (float) \S::get( 'default_cpc' );
if ( $campaign_daily_budget <= 0 )
{
$campaign_daily_budget = 50.0;
}
if ( $default_cpc <= 0 )
{
$default_cpc = 1.0;
}
if ( $product_id <= 0 )
{
echo json_encode( [ 'status' => 'error', 'message' => 'Nieprawidłowy produkt.' ] );
exit;
}
$product_context = \factory\Products::get_product_scope_context( $product_id );
if ( !$product_context )
{
echo json_encode( [ 'status' => 'error', 'message' => 'Nie znaleziono produktu.' ] );
exit;
}
$client_id = (int) ( $product_context['client_id'] ?? 0 );
$offer_id = trim( (string) ( $product_context['offer_id'] ?? '' ) );
$customer_id = preg_replace( '/\D+/', '', (string) ( $product_context['google_ads_customer_id'] ?? '' ) );
$merchant_account_id = preg_replace( '/\D+/', '', (string) ( $product_context['google_merchant_account_id'] ?? '' ) );
if ( $offer_id === '' )
{
echo json_encode( [ 'status' => 'error', 'message' => 'Produkt nie ma offer_id (ID oferty).' ] );
exit;
}
if ( $customer_id === '' )
{
echo json_encode( [ 'status' => 'error', 'message' => 'Brak Google Ads Customer ID u klienta.' ] );
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;
}
$external_campaign_id = 0;
$external_ad_group_id = 0;
$resolved_campaign_name = '';
$resolved_ad_group_name = '';
if ( $campaign_mode === 'new' )
{
if ( $campaign_name === '' )
{
echo json_encode( [ 'status' => 'error', 'message' => 'Podaj nazwę nowej kampanii.' ] );
exit;
}
if ( $merchant_account_id === '' )
{
echo json_encode( [ 'status' => 'error', 'message' => 'Brak Google Merchant Account ID u klienta (wymagane dla kampanii Shopping).' ] );
exit;
}
$campaign_result = $google_ads_api -> create_standard_shopping_campaign( $customer_id, $merchant_account_id, $campaign_name, $campaign_daily_budget );
if ( empty( $campaign_result['success'] ) )
{
$error_message = trim( (string) ( $campaign_result['error'] ?? \services\GoogleAdsApi::get_setting( 'google_ads_last_error' ) ) );
echo json_encode( [ 'status' => 'error', 'message' => $error_message !== '' ? $error_message : 'Nie udało się utworzyć kampanii Standard Shopping w Google Ads.' ] );
exit;
}
$external_campaign_id = (int) ( $campaign_result['campaign_id'] ?? 0 );
$resolved_campaign_name = trim( (string) ( $campaign_result['campaign_name'] ?? $campaign_name ) );
}
else
{
if ( $campaign_id <= 0 )
{
echo json_encode( [ 'status' => 'error', 'message' => 'Wybierz kampanię.' ] );
exit;
}
$campaign_scope = \factory\Products::get_campaign_scope_context( $campaign_id );
if ( !$campaign_scope )
{
echo json_encode( [ 'status' => 'error', 'message' => 'Nie znaleziono wybranej kampanii.' ] );
exit;
}
if ( (int) ( $campaign_scope['client_id'] ?? 0 ) !== $client_id )
{
echo json_encode( [ 'status' => 'error', 'message' => 'Wybrana kampania nie należy do klienta produktu.' ] );
exit;
}
$external_campaign_id = (int) ( $campaign_scope['campaign_id'] ?? 0 );
$resolved_campaign_name = trim( (string) ( $campaign_scope['campaign_name'] ?? '' ) );
if ( $external_campaign_id <= 0 )
{
echo json_encode( [ 'status' => 'error', 'message' => 'Wybrana kampania nie ma ID Google Ads. Wybierz kampanię zsynchronizowaną z Google Ads.' ] );
exit;
}
}
if ( $ad_group_mode === 'new' )
{
if ( $ad_group_name === '' )
{
echo json_encode( [ 'status' => 'error', 'message' => 'Podaj nazwę nowej grupy reklam.' ] );
exit;
}
$ad_group_result = $google_ads_api -> create_standard_shopping_ad_group_with_offer( $customer_id, $external_campaign_id, $ad_group_name, $offer_id, $default_cpc );
if ( empty( $ad_group_result['success'] ) )
{
$error_message = trim( (string) ( $ad_group_result['error'] ?? \services\GoogleAdsApi::get_setting( 'google_ads_last_error' ) ) );
echo json_encode( [ 'status' => 'error', 'message' => $error_message !== '' ? $error_message : 'Nie udało się utworzyć grupy reklam i przypisać produktu w Google Ads.' ] );
exit;
}
$external_ad_group_id = (int) ( $ad_group_result['ad_group_id'] ?? 0 );
$resolved_ad_group_name = trim( (string) ( $ad_group_result['ad_group_name'] ?? $ad_group_name ) );
}
else
{
if ( $ad_group_id <= 0 )
{
echo json_encode( [ 'status' => 'error', 'message' => 'Wybierz grupę reklam.' ] );
exit;
}
$ad_group_scope = \factory\Products::get_ad_group_scope_context( $ad_group_id );
if ( !$ad_group_scope )
{
echo json_encode( [ 'status' => 'error', 'message' => 'Nie znaleziono wybranej grupy reklam.' ] );
exit;
}
if ( (int) ( $ad_group_scope['campaign_id'] ?? 0 ) !== (int) $campaign_id && $campaign_mode !== 'new' )
{
echo json_encode( [ 'status' => 'error', 'message' => 'Wybrana grupa reklam nie należy do wskazanej kampanii.' ] );
exit;
}
$external_ad_group_id = (int) ( $ad_group_scope['ad_group_id'] ?? 0 );
$resolved_ad_group_name = trim( (string) ( $ad_group_scope['ad_group_name'] ?? '' ) );
if ( $external_ad_group_id <= 0 )
{
echo json_encode( [ 'status' => 'error', 'message' => 'Wybrana grupa reklam nie ma ID Google Ads. Wybierz grupę zsynchronizowaną z Google Ads.' ] );
exit;
}
$offer_result = $google_ads_api -> ensure_standard_shopping_offer_in_ad_group( $customer_id, $external_ad_group_id, $offer_id, $default_cpc );
if ( empty( $offer_result['success'] ) )
{
$error_message = trim( (string) ( $offer_result['error'] ?? \services\GoogleAdsApi::get_setting( 'google_ads_last_error' ) ) );
echo json_encode( [ 'status' => 'error', 'message' => $error_message !== '' ? $error_message : 'Nie udało się przypisać produktu do wybranej grupy reklam w Google Ads.' ] );
exit;
}
}
if ( $external_campaign_id <= 0 || $external_ad_group_id <= 0 )
{
echo json_encode( [ 'status' => 'error', 'message' => 'Nie udało się przygotować docelowego scope Google Ads.' ] );
exit;
}
$resolved_scope = \controls\Cron::resolve_products_scope_ids(
$client_id,
$external_campaign_id,
$resolved_campaign_name,
$external_ad_group_id,
$resolved_ad_group_name,
date( 'Y-m-d' )
);
$local_campaign_id = (int) ( $resolved_scope['campaign_id'] ?? 0 );
$local_ad_group_id = (int) ( $resolved_scope['ad_group_id'] ?? 0 );
if ( $local_campaign_id <= 0 || $local_ad_group_id <= 0 )
{
echo json_encode( [ 'status' => 'error', 'message' => 'Utworzono scope w Google Ads, ale nie udało się zsynchronizować mapowania lokalnego.' ] );
exit;
}
if ( !\factory\Products::assign_product_scope( $product_id, $local_campaign_id, $local_ad_group_id ) )
{
echo json_encode( [ 'status' => 'error', 'message' => 'Produkt dodano do Google Ads, ale nie udało się zapisać przypisania lokalnie.' ] );
exit;
}
\factory\Products::add_product_comment(
$product_id,
'Przypisano produkt do Google Ads: kampania #' . $external_campaign_id . ' (' . $resolved_campaign_name . '), grupa reklam #' . $external_ad_group_id . ' (' . $resolved_ad_group_name . ').'
);
echo json_encode( [
'status' => 'ok',
'campaign_id' => $local_campaign_id,
'ad_group_id' => $local_ad_group_id,
'external_campaign_id' => $external_campaign_id,
'external_ad_group_id' => $external_ad_group_id
] );
exit;
}
static public function comment_add()
{
$product_id = \S::get( 'product_id' );
$date = \S::get( 'date' );
$comment = \S::get( 'comment' );
if ( \factory\Products::add_product_comment( $product_id, $comment, $date ) )
{
echo json_encode( [ 'status' => 'ok' ] );
}
else
echo json_encode( [ 'status' => 'error' ] );
exit;
}
static public function comment_delete()
{
$comment_id = \S::get( 'comment_id' );
if ( \factory\Products::delete_product_comment( $comment_id ) )
{
echo json_encode( [ 'status' => 'ok' ] );
}
else
echo json_encode( [ 'status' => 'error' ] );
exit;
}
static public function get_product_data() {
$product_id = \S::get( 'product_id' );
$product_name = \factory\Products::get_product_name( $product_id );
$product_title = \factory\Products::get_product_data( $product_id, 'title' );
$product_description = \factory\Products::get_product_data( $product_id, 'description' );
$google_product_category = \factory\Products::get_product_data( $product_id, 'google_product_category' );
$product_url = \factory\Products::get_product_data( $product_id, 'product_url' );
echo json_encode( [ 'status' => 'ok', 'product_details' => [
'name' => $product_name,
'title' => $product_title,
'description' => $product_description,
'google_product_category' => $google_product_category,
'product_url' => $product_url
] ] );
exit;
}
static public function get_product_merchant_sync_logs()
{
$product_id = (int) \S::get( 'product_id' );
$limit = (int) \S::get( 'limit' );
if ( $product_id <= 0 )
{
echo json_encode( [ 'status' => 'error', 'message' => 'Nieprawidłowe ID produktu.' ] );
exit;
}
if ( $limit <= 0 )
{
$limit = 50;
}
$logs = \factory\Products::get_product_merchant_sync_logs( $product_id, $limit );
echo json_encode( [
'status' => 'ok',
'logs' => $logs
] );
exit;
}
static public function delete_product_merchant_sync_log()
{
$log_id = (int) \S::get( 'log_id' );
if ( $log_id <= 0 )
{
echo json_encode( [ 'status' => 'error', 'message' => 'Nieprawidłowe ID logu.' ] );
exit;
}
$result = \factory\Products::delete_product_merchant_sync_log( $log_id );
echo json_encode( [ 'status' => $result ? 'ok' : 'error' ] );
exit;
}
static public function ai_suggest()
{
$product_id = \S::get( 'product_id' );
$field = \S::get( 'field' );
$provider = \S::get( 'provider' ) ?: 'openai';
if ( $provider === 'claude' )
{
if ( \services\GoogleAdsApi::get_setting( 'claude_enabled' ) === '0' )
{
echo json_encode( [ 'status' => 'error', 'message' => 'Claude jest wyłączony. Włącz go w Ustawieniach.' ] );
exit;
}
if ( !\services\ClaudeApi::is_configured() )
{
echo json_encode( [ 'status' => 'error', 'message' => 'Klucz API Claude nie jest skonfigurowany. Przejdź do Ustawień.' ] );
exit;
}
}
else if ( $provider === 'gemini' )
{
if ( \services\GoogleAdsApi::get_setting( 'gemini_enabled' ) === '0' )
{
echo json_encode( [ 'status' => 'error', 'message' => 'Gemini jest wyłączony. Włącz go w Ustawieniach.' ] );
exit;
}
if ( !\services\GeminiApi::is_configured() )
{
echo json_encode( [ 'status' => 'error', 'message' => 'Klucz API Gemini nie jest skonfigurowany. Przejdź do Ustawień.' ] );
exit;
}
}
else
{
if ( \services\GoogleAdsApi::get_setting( 'openai_enabled' ) === '0' )
{
echo json_encode( [ 'status' => 'error', 'message' => 'OpenAI jest wyłączony. Włącz go w Ustawieniach.' ] );
exit;
}
if ( !\services\OpenAiApi::is_configured() )
{
echo json_encode( [ 'status' => 'error', 'message' => 'Klucz API OpenAI nie jest skonfigurowany. Przejdź do Ustawień.' ] );
exit;
}
}
$product = \factory\Products::get_product_full_context( $product_id );
if ( !$product )
{
echo json_encode( [ 'status' => 'error', 'message' => 'Nie znaleziono produktu.' ] );
exit;
}
// Pobierz treść strony produktu jeśli podano URL
$product_url = \S::get( 'product_url' );
$keyword_source_url = self::normalize_keyword_source_url( $product_url );
$page_content = '';
if ( $product_url && filter_var( $product_url, FILTER_VALIDATE_URL ) )
{
$page_content = \services\OpenAiApi::fetch_page_content( $product_url );
}
$keyword_terms = [];
$warnings = [];
$should_enrich_with_keyword_planner = in_array( $field, [ 'title', 'description' ], true )
&& in_array( $provider, [ 'openai', 'claude', 'gemini' ], true );
if ( $should_enrich_with_keyword_planner && $keyword_source_url !== '' && filter_var( $keyword_source_url, FILTER_VALIDATE_URL ) )
{
$keyword_terms = \factory\Products::get_cached_keyword_planner_terms( $product_id, $keyword_source_url, 15 );
if ( empty( $keyword_terms ) )
{
$ads_context = \factory\Products::get_product_ads_keyword_context( $product_id );
$customer_id = trim( (string) ( $ads_context['google_ads_customer_id'] ?? '' ) );
if ( $customer_id !== '' )
{
$google_ads_api = new \services\GoogleAdsApi();
$fetched_terms = $google_ads_api -> generate_keyword_ideas_from_url( $customer_id, $keyword_source_url, 40 );
if ( $fetched_terms !== false )
{
\factory\Products::replace_keyword_planner_terms( $product_id, $keyword_source_url, $customer_id, $fetched_terms );
$keyword_terms = \factory\Products::get_cached_keyword_planner_terms( $product_id, $keyword_source_url, 15 );
}
else
{
$last_error = trim( (string) \services\GoogleAdsApi::get_setting( 'google_ads_last_error' ) );
$warnings[] = 'Nie udało się pobrać fraz z Google Ads Keyword Planner. ' . ( $last_error !== '' ? 'Szczegóły: ' . $last_error : '' );
}
}
else
{
$warnings[] = 'Brak Google Ads Customer ID u klienta — pominięto frazy z Keyword Planner.';
}
}
}
$context = [
'original_name' => $product['name'],
'current_title' => \factory\Products::get_product_data( $product_id, 'title' ),
'current_description' => \factory\Products::get_product_data( $product_id, 'description' ),
'current_category' => \factory\Products::get_product_data( $product_id, 'google_product_category' ),
'offer_id' => $product['offer_id'],
'impressions_30' => $product['impressions_30'] ?? 0,
'clicks_30' => $product['clicks_30'] ?? 0,
'ctr' => $product['ctr'] ?? 0,
'cost' => $product['cost'] ?? 0,
'conversions' => $product['conversions'] ?? 0,
'conversions_value' => $product['conversions_value'] ?? 0,
'roas' => $product['roas'] ?? 0,
'custom_label_4' => \factory\Products::get_product_data( $product_id, 'custom_label_4' ),
'page_content' => $page_content,
'keyword_planner_terms' => $keyword_terms,
];
$api_map = [
'claude' => \services\ClaudeApi::class,
'gemini' => \services\GeminiApi::class,
];
$api = $api_map[ $provider ] ?? \services\OpenAiApi::class;
switch ( $field )
{
case 'title':
$result = $api::suggest_title( $context );
break;
case 'description':
$result = $api::suggest_description( $context );
break;
case 'category':
$result = $api::suggest_category( $context );
break;
default:
$result = [ 'status' => 'error', 'message' => 'Nieznane pole: ' . $field ];
}
$result['provider'] = $provider;
if ( $product_url && !$page_content )
$warnings[] = 'Nie udało się pobrać treści ze strony produktu (strona może blokować dostęp). Sugestia oparta tylko na nazwie produktu.';
elseif ( $page_content )
$result['page_fetched'] = true;
if ( !empty( $warnings ) )
$result['warning'] = implode( ' ', array_filter( $warnings ) );
if ( !empty( $keyword_terms ) )
{
$result['keyword_planner_terms_used'] = true;
$result['keyword_planner_terms_count'] = count( $keyword_terms );
}
echo json_encode( $result );
exit;
}
static public function get_products()
{
$client_id = \S::get( 'client_id' );
$campaign_id = (int) \S::get( 'campaign_id' );
$ad_group_id = (int) \S::get( 'ad_group_id' );
$limit = \S::get( 'length' ) ? \S::get( 'length' ) : 10;
$start = \S::get( 'start' ) ? \S::get( 'start' ) : 0;
$order_dir = $_POST['order'][0]['dir'] ? strtoupper( $_POST['order'][0]['dir'] ) : 'DESC';
$order_name = $_POST['order'][0]['name'] ? $_POST['order'][0]['name'] : 'clicks';
$search = trim( (string) \S::get( 'search_text' ) );
$filter_cl4 = trim( (string) \S::get( 'filter_cl4' ) );
// ➊ MIN/MAX ROAS dla kontekstu klienta (opcjonalnie z filtrem search)
$bounds = \factory\Products::get_roas_bounds( (int) $client_id, $search, $campaign_id, $ad_group_id, $filter_cl4 );
$roas_min = (float)$bounds['min'];
$roas_max = (float)$bounds['max'];
// zabezpieczenie przed dzieleniem przez 0
if ($roas_min === $roas_max) {
$roas_max = $roas_min + 0.000001;
}
// ➋ Helper do paska performance (lokalna funkcja)
$renderPerfBar = function (float $value, float $min, float $max): string
{
// normalizacja 0..1
$t = ($value - $min) / ($max - $min);
if ($t < 0) $t = 0;
if ($t > 1) $t = 1;
// szerokości
$minPx = 20; // minimalna długość paska
$maxPx = 120; // szerokość „toru”
$fill = (int)round($minPx + $t * ($maxPx - $minPx));
// kolor od #E74C3C (czerwony) do #2ECC71 (zielony)
$from = [231, 76, 60];
$to = [ 46,204,113];
$r = (int)round($from[0] + ($to[0] - $from[0]) * $t);
$g = (int)round($from[1] + ($to[1] - $from[1]) * $t);
$b = (int)round($from[2] + ($to[2] - $from[2]) * $t);
$hex = sprintf('#%02X%02X%02X', $r, $g, $b);
// prosty pasek (tor + wypełnienie)
return '<div class="roas-perf-wrap" title="Min: '.number_format($min,2).' | Max: '.number_format($max,2).'" style="margin-top:4px;width:'.$maxPx.'px;height:8px;background:#eee;border-radius:4px;overflow:hidden;">
<div class="roas-perf-fill" style="height:100%;width:'.$fill.'px;background:'.$hex.';"></div>
</div>';
};
$db_results = \factory\Products::get_products( $client_id, $search, $limit, $start, $order_name, $order_dir, $campaign_id, $ad_group_id, $filter_cl4 );
$recordsTotal = \factory\Products::get_records_total_products( $client_id, $search, $campaign_id, $ad_group_id, $filter_cl4 );
// Sredni CR konta — do obliczenia progu klikniec
$account_cr = \factory\Products::get_account_conversion_rate( (int) $client_id );
if ( $account_cr <= 0 ) $account_cr = 0.02; // fallback 2%
$data['draw'] = \S::get( 'draw' );
$data['recordsTotal'] = $recordsTotal;
$data['recordsFiltered'] = $recordsTotal;
$data['data'] = [];
foreach ( $db_results as $row )
{
$custom_class = '';
$custom_label_4 = \factory\Products::get_product_data( $row['product_id'], 'custom_label_4' );
$custom_name = \factory\Products::get_product_data( $row['product_id'], 'title' );
$product_url = trim( (string) \factory\Products::get_product_data( $row['product_id'], 'product_url' ) );
if ( $custom_name )
{
$row['name'] = $custom_name;
$custom_class = 'custom_name';
}
if ( $custom_label_4 == 'deleted' )
$custom_class = 'text-danger';
$custom_label_4_color = '';
if ( $custom_label_4 == 'bestseller' )
$custom_label_4_color = 'background-color:rgb(96, 119, 102); color: #FFF;';
else if ( $custom_label_4 == 'deleted' )
$custom_label_4_color = 'background-color:rgb(255, 0, 0); color: #FFF;';
else if ( $custom_label_4 == 'zombie' )
$custom_label_4_color = 'background-color:rgb(58, 58, 58); color: #FFF;';
else if ( $custom_label_4 == 'pla_single' )
$custom_label_4_color = 'background-color:rgb(49, 184, 9); color: #FFF;';
else if ( $custom_label_4 == 'pla' )
$custom_label_4_color = 'background-color:rgb(74, 63, 136); color: #FFF;';
else if ( $custom_label_4 == 'paused' )
$custom_label_4_color = 'background-color:rgb(143, 143, 143); color: #FFF;';
// ➌ ROAS liczba + pasek performance
$roasValue = (float)$row['roas'];
$roasDisplay = (int) round( $roasValue, 0 );
$roasNumeric = ($roasValue <= (float)$row['min_roas'])
? '<span class="text-danger text-bold">' . $roasDisplay . '</span>'
: $roasDisplay;
$roasPerfBar = $renderPerfBar($roasValue, $roas_min, $roas_max);
$roasCellHtml = '<div class="roas-cell">'.$roasNumeric.$roasPerfBar.'</div>';
$product_url_html = $product_url !== ''
? '<a href="' . htmlspecialchars( $product_url ) . '" target="_blank" rel="noopener noreferrer" title="' . htmlspecialchars( $product_url ) . '"><i class="fa-solid fa-up-right-from-square"></i> Otworz</a>'
: '';
// Algorytm ostrzezen produktowych
$warnings = [];
$clicks = (int) $row['clicks'];
$conversions = (float) $row['conversions'];
$ctr = (float) $row['ctr'];
$min_roas_val = (float) $row['min_roas'];
$click_threshold = (int) ceil( 3 * ( 1 / $account_cr ) );
// 1. Niski CTR po 30+ klikniec
if ( $clicks >= 30 && $ctr < 0.5 )
{
$warnings[] = 'Niski CTR (' . round( $ctr, 2 ) . '%) po ' . $clicks . ' kliknieciach — prawdopodobny problem z tytulem, zdjeciem lub cena.';
}
// 2. Zero konwersji po progu klikniec
if ( $clicks >= $click_threshold && $conversions == 0 )
{
$warnings[] = 'Brak konwersji po ' . $clicks . ' kliknieciach (prog: ' . $click_threshold . '). Produkt prawdopodobnie nie sprzedaje sie z reklam.';
}
// 3. ROAS ponizej progu rentownosci
if ( $clicks >= $click_threshold && $conversions > 0 && $min_roas_val > 0 && $roasValue < $min_roas_val )
{
$warnings[] = 'ROAS ' . $roasDisplay . '% ponizej progu rentownosci (' . (int) $min_roas_val . '%). Rozważ optymalizacje lub wylaczenie.';
}
// 4. Strefa zolta — ROAS bliski progu (80-100% min_roas)
if ( $clicks >= $click_threshold && $conversions > 0 && $min_roas_val > 0 && $roasValue >= $min_roas_val && $roasValue < $min_roas_val * 1.5 )
{
$warnings[] = 'ROAS ' . $roasDisplay . '% — blisko progu rentownosci (' . (int) $min_roas_val . '%). Daj jeszcze 50-100 klikniec lub zoptymalizuj listing.';
}
$warningHtml = '';
if ( !empty( $warnings ) )
{
$warningTitle = htmlspecialchars( implode( "\n", $warnings ), ENT_QUOTES );
$warningHtml = '<span class="product-warning-icon" data-warnings="' . $warningTitle . '" title="Produkt ma problemy"><i class="fa-solid fa-triangle-exclamation"></i></span>';
}
$data['data'][] = [
'', // checkbox column
$row['product_id'],
$row['offer_id'],
htmlspecialchars( (string) ( $row['campaign_name'] ?? '' ) ),
htmlspecialchars( (string) ( $row['ad_group_name'] ?? '' ) ),
$product_url_html,
'<div class="table-product-title" product_id="' . $row['product_id'] . '">
<a href="/products/product_history/client_id=' . $client_id . '&product_id=' . $row['product_id'] . '&campaign_id=' . (int) ( $row['campaign_id'] ?? 0 ) . '&ad_group_id=' . (int) ( $row['ad_group_id'] ?? 0 ) . '" target="_blank" class="' . $custom_class . '">
' . $row['name'] . '
</a>
<span class="edit-product-title" product_id="' . $row['product_id'] . '">
<i class="fa fa-pencil"></i>
</span>
</div>',
$warningHtml,
$row['impressions'],
$row['impressions_30'],
$row['clicks'],
$row['clicks_30'],
round( $row['ctr'], 2 ) . '%',
\S::number_display( $row['cost'] ),
\S::number_display( $row['cpc'] ),
round( $row['conversions'], 2 ),
\S::number_display( $row['conversions_value'] ),
$roasCellHtml,
'<input type="text" class="form-control min_roas" product_id="' . $row['product_id'] . '" value="' . $row['min_roas'] . '" style="width: 100px;">',
'',
'<input type="text" class="form-control custom_label_4" product_id="' . $row['product_id'] . '" value="' . $custom_label_4 . '" style="' . $custom_label_4_color . '">',
'<div class="btn-group btn-group-sm products-row-actions" role="group">'
. '<button type="button" class="btn btn-primary assign-product-scope" product_id="' . $row['product_id'] . '" title="Dodaj produkt do kampanii/grupy"><i class="fa-solid fa-diagram-project"></i></button>'
. '<button type="button" class="btn btn-secondary view-merchant-logs" product_id="' . $row['product_id'] . '" title="Pokaż logi synchronizacji Merchant"><i class="fa-solid fa-clock-rotate-left"></i></button>'
. '<button type="button" class="btn btn-danger delete-product" product_id="' . $row['product_id'] . '" title="Usuń produkt"><i class="fa-solid fa-trash"></i></button>'
. '</div>'
];
}
echo json_encode( $data );
exit;
}
static public function delete_product() {
$product_id = \S::get( 'product_id' );
if ( \factory\Products::delete_product( $product_id ) )
echo json_encode( [ 'status' => 'ok' ] );
else
echo json_encode( [ 'status' => 'error' ] );
exit;
}
static public function delete_products() {
$product_ids = \S::get( 'product_ids' );
if ( !is_array( $product_ids ) || empty( $product_ids ) ) {
echo json_encode( [ 'status' => 'error', 'message' => 'Brak produktów do usunięcia' ] );
exit;
}
if ( \factory\Products::delete_products( $product_ids ) )
echo json_encode( [ 'status' => 'ok' ] );
else
echo json_encode( [ 'status' => 'error', 'message' => 'Błąd podczas usuwania produktów' ] );
exit;
}
static public function save_min_roas()
{
$product_id = \S::get( 'product_id' );
$min_roas = \S::get( 'min_roas' );
if ( \factory\Products::save_min_roas( $product_id, $min_roas ) )
{
echo json_encode( [ 'status' => 'ok' ] );
}
else
echo json_encode( [ 'status' => 'error' ] );
exit;
}
static public function get_distinct_cl4()
{
$client_id = (int) \S::get( 'client_id' );
$values = \factory\Products::get_distinct_custom_label_4( $client_id );
echo json_encode( [ 'status' => 'ok', 'values' => $values ] );
exit;
}
static public function save_custom_label_4()
{
$product_id = \S::get( 'product_id' );
$custom_label_4 = \S::get( 'custom_label_4' );
if ( \factory\Products::set_product_data( $product_id, 'custom_label_4', $custom_label_4 ) )
{
\factory\Products::add_product_comment( $product_id, 'Zmiana etykiety 4 na: ' . $custom_label_4 );
echo json_encode( [ 'status' => 'ok' ] );
}
else
echo json_encode( [ 'status' => 'error' ] );
exit;
}
static public function product_history()
{
$client_id = \S::get( 'client_id' );
$product_id = \S::get( 'product_id' );
$campaign_id = (int) \S::get( 'campaign_id' );
$ad_group_id = (int) \S::get( 'ad_group_id' );
return \Tpl::view( 'products/product_history', [
'client_id' => $client_id,
'product_id' => $product_id,
'campaign_id' => $campaign_id,
'ad_group_id' => $ad_group_id,
'min_roas' => \factory\Products::get_min_roas( $product_id )
] );
}
static public function get_product_history_table()
{
$client_id= \S::get( 'client_id' );
$product_id = \S::get( 'product_id' );
$campaign_id = (int) \S::get( 'campaign_id' );
$ad_group_id = (int) \S::get( 'ad_group_id' );
$start = \S::get( 'start' ) ? \S::get( 'start' ) : 0;
$limit = \S::get( 'length' ) ? \S::get( 'length' ) : 10;
$db_results = \factory\Products::get_product_history( $client_id, $product_id, $start, $limit, $campaign_id, $ad_group_id );
$recordsTotal = \factory\Products::get_records_total_product_history( $client_id, $product_id, $campaign_id, $ad_group_id );
$data['draw'] = \S::get( 'draw' );
$data['recordsTotal'] = $recordsTotal;
$data['recordsFiltered'] = $recordsTotal;
$data['data'] = [];
foreach ( $db_results as $row )
{
$roas_value = ( $row['cost'] > 0) ? ( $row['conversions_value'] / $row['cost'] ) * 100 : 0;
$roas = number_format( $roas_value, 0, '.', '' ) . '%';
$comment_data = \factory\Products::get_product_comment_by_date( $product_id, $row['date_add'] );
$comment_html = '';
if ( $comment_data )
{
$comment_html = '<div class="comment-cell">
<span class="comment-text">' . htmlspecialchars( $comment_data['comment'] ) . '</span>
<a href="#" class="delete-comment" data-comment-id="' . $comment_data['id'] . '">Usuń</a>
</div>';
}
$data['data'][] = [
$row['id'],
$row['impressions'],
$row['clicks'],
round( $row['ctr'], 2 ) . '%',
\S::number_display( $row['cost'] ),
$row['conversions'],
\S::number_display( $row['conversions_value'] ),
$roas,
$row['date_add'],
$comment_html,
];
}
echo json_encode( $data );
exit;
}
static public function get_product_history_table_chart()
{
$client_id = \S::get( 'client_id' );
$product_id = \S::get( 'product_id' );
$campaign_id = (int) \S::get( 'campaign_id' );
$ad_group_id = (int) \S::get( 'ad_group_id' );
$limit = \S::get( 'length' ) ? \S::get( 'length' ) : 360;
$start = \S::get( 'start' ) ? \S::get( 'start' ) : 0;
$db_results = \factory\Products::get_product_history_30( $client_id, $product_id, $start, $limit, $campaign_id, $ad_group_id );
$impressions = [];
$clicks = [];
$ctr = [];
$cost = [];
$conversions = [];
$conversions_value = [];
$roas = [];
$dates = [];
foreach ( $db_results as $row )
{
$impressions[] = (int)$row['impressions'];
$clicks[] = (int)$row['clicks'];
$ctr[] = (float)$row['ctr'];
$cost[] = (float)$row['cost'];
$conversions[] = (int)$row['conversions'];
$conversions_value[] = (float)$row['conversions_value'];
$roas[] = (float)$row['roas_all_time'];
$dates[] = $row['date_add'];
}
$chart_data = [
[
'name' => 'Wyświetlenia',
'data' => $impressions,
'visible' => false
], [
'name' => 'Kliknięcia',
'data' => $clicks,
'visible' => false
], [
'name' => 'CTR',
'data' => $ctr,
'visible' => false
], [
'name' => 'Koszt',
'data' => $cost,
'visible' => false
], [
'name' => 'Konwersje',
'data' => $conversions,
'visible' => false
], [
'name' => 'Wartość konwersji',
'data' => $conversions_value,
'visible' => false
], [
'name' => 'ROAS',
'data' => $roas
]
];
echo json_encode([
'chart_data' => $chart_data,
'dates' => $dates,
'comments' => \factory\Products::get_product_comments( $product_id ),
]);
exit;
}
static public function save_product_data()
{
$product_id = \S::get( 'product_id' );
$custom_title = \S::get( 'custom_title' );
$custom_description = \S::get( 'custom_description' );
$google_product_category = \S::get( 'google_product_category' );
$product_url = \S::get( 'product_url' );
$old_title = (string) \factory\Products::get_product_data( $product_id, 'title' );
$old_description = (string) \factory\Products::get_product_data( $product_id, 'description' );
$old_category = (string) \factory\Products::get_product_data( $product_id, 'google_product_category' );
$changed_for_merchant = [];
if ( $product_id )
{
if ( $custom_title )
{
\factory\Products::set_product_data( $product_id, 'title', $custom_title );
$changed_for_merchant['title'] = [ 'old' => $old_title, 'new' => (string) $custom_title ];
}
if ( $custom_description )
{
\factory\Products::set_product_data( $product_id, 'description', $custom_description );
$changed_for_merchant['description'] = [ 'old' => $old_description, 'new' => (string) $custom_description ];
}
if ( $google_product_category )
{
\factory\Products::set_product_data( $product_id, 'google_product_category', $google_product_category );
$changed_for_merchant['google_product_category'] = [ 'old' => $old_category, 'new' => (string) $google_product_category ];
}
\factory\Products::set_product_data( $product_id, 'product_url', $product_url ?: '' );
foreach ( $changed_for_merchant as $field => $change )
{
if ( trim( $change['old'] ) === trim( $change['new'] ) )
{
continue;
}
$log_old = $change['old'];
$log_new = $change['new'];
if ( $field === 'description' )
{
$log_old = $log_old !== '' ? '(zmieniono)' : '';
$log_new = '(zmieniono)';
}
\factory\Products::add_product_merchant_sync_log( [
'product_id' => $product_id,
'field_name' => $field,
'old_value' => $log_old,
'new_value' => $log_new,
'sync_status' => 'local',
'sync_source' => 'products_ui'
] );
}
}
\factory\Products::add_product_comment( $product_id, 'Zmiana tytułu i opisu produktu.' );
echo json_encode( [ 'status' => 'ok' ] );
exit;
}
}