<?php
declare( strict_types=1 );
namespace Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\MerchantPriceBenchmarks;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\ContainerAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\Interfaces\ContainerAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\DB\Query\MerchantPriceBenchmarksQuery;
use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductHelper;
defined( 'ABSPATH' ) || exit;
/**
* Class PriceBenchmarks
*
* @package Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter
*/
class PriceBenchmarks implements ContainerAwareInterface, Service {
use ContainerAwareTrait;
/**
* Map of column names to their corresponding database fields.
*
* This is used to map the orderby parameter to the correct database column.
*
* @var array
*/
public const COLUMN_MAP = [
'effectiveness' => 'mc_insights_effectiveness',
'id' => 'product_id',
'benchmark_price' => 'mc_price_benchmark_price_micros',
'product_price' => 'mc_product_price_micros',
'suggested_price' => 'mc_insights_suggested_price_micros',
];
public const DEFAULT_ORDERBY = 'effectiveness';
/**
* Gets and maps the benchmark and price insights and performance data to the required API response format.
*
* @param array $args Query arguments.
* @return array Mapped response data.
*/
protected function get_price_benchmarks_response( $args ): array {
$mapped_data = [];
/** @var MerchantPriceBenchmarks $merchant */
$merchant = $this->container->get( MerchantPriceBenchmarks::class );
/** @var ProductHelper $product_helper */
$product_helper = $this->container->get( ProductHelper::class );
$benchmark_data = $merchant->get_price_comparisons_data( $args );
$price_insights_data = $merchant->get_price_insights_data( $args );
$performance_data = $merchant->get_merchant_performance_data( $args );
// Combine all data sets into $mapped_data keyed by product ID.
foreach ( $benchmark_data ?? [] as $benchmark_result ) {
$product_id = $product_helper->get_wc_product_id( (string) $benchmark_result['offer_id'] );
if ( $product_id === 0 ) {
continue; // Skip if product ID is not valid.
}
$mapped_data[ $product_id ] = [
'price_competitiveness' => $benchmark_result,
'price_insights' => [],
'performance' => [],
];
}
// Map price insights data to benchmarks data.
if ( ! empty( $price_insights_data ) ) {
// Price insights data.
foreach ( $price_insights_data as $price_insights_result ) {
$product_id = $product_helper->get_wc_product_id( (string) $price_insights_result['offer_id'] );
if ( isset( $mapped_data[ $product_id ] ) ) {
$mapped_data[ $product_id ]['price_insights'] = $price_insights_result;
}
}
// Performance.
foreach ( $performance_data as $performance_result ) {
$product_id = $product_helper->get_wc_product_id( (string) $performance_result['offer_id'] );
if ( isset( $mapped_data[ $product_id ] ) ) {
$mapped_data[ $product_id ]['performance'] = $performance_result;
}
}
}
// Transform $mapped_data into the desired response format using array_map.
$response_data = array_map(
function ( $data ) use ( $product_helper ) {
$price_competitiveness = $data['price_competitiveness'];
$price_insights = $data['price_insights'];
$performance = $data['performance'];
// Get the WooCommerce product ID and thumbnail.
$wc_product_id = $product_helper->get_wc_product_id( (string) $price_competitiveness['offer_id'] );
// Map the data to the required format.
return [
'product_id' => $wc_product_id,
'mc_product_id' => $price_competitiveness['id'] ?? '',
'mc_product_offer_id' => $price_competitiveness['offer_id'] ?? '',
'mc_price_country_code' => $price_competitiveness['country_code'] ?? '',
'mc_product_currency_code' => $price_competitiveness['benchmark_price_currency_code'] ?? '',
'mc_product_price_micros' => $price_competitiveness['price_micros'] ?? '',
'mc_price_benchmark_price_micros' => $price_competitiveness['benchmark_price_micros'] ?? '',
'mc_price_benchmark_price_currency_code' => $price_competitiveness['benchmark_price_currency_code'] ?? '',
'mc_insights_suggested_price_micros' => $price_insights['suggested_price_micros'] ?? '',
'mc_insights_suggested_price_currency_code' => $price_insights['suggested_price_currency_code'] ?? '',
'mc_insights_predicted_impressions_change_fraction' => $price_insights['predicted_impressions_change_fraction'] ?? 0,
'mc_insights_predicted_clicks_change_fraction' => $price_insights['predicted_clicks_change_fraction'] ?? 0,
'mc_insights_predicted_conversions_change_fraction' => $price_insights['predicted_conversions_change_fraction'] ?? 0,
'mc_insights_effectiveness' => isset( $price_insights['effectiveness'] ) ? $this->get_effectiveness( $price_insights['effectiveness'] ) : 0,
'mc_metrics_clicks' => $performance['clicks'] ?? 0,
'mc_metrics_impressions' => $performance['impressions'] ?? 0,
'mc_metrics_ctr' => $performance['ctr'] ?? 0,
'mc_metrics_conversions' => $performance['conversions'] ?? 0,
'price_compared_with_benchmark' => $this->price_compared_with_benchmark(
(int) $price_competitiveness['price_micros'],
(int) $price_competitiveness['benchmark_price_micros']
),
];
},
$mapped_data
);
return $response_data;
}
/**
* Retrieves the product thumbnail URL.
*
* @param int $product_id WooCommerce product ID.
* @return string Product thumbnail URL or an empty string if not found.
*/
protected function get_product_thumbnail( int $product_id ): ?string {
$thumbnail_id = get_post_thumbnail_id( $product_id );
if ( ! $thumbnail_id ) {
return '';
}
$thumbnail_url = wp_get_attachment_url( $thumbnail_id );
return $thumbnail_url ?? '';
}
/**
* Returns the key for the given effectiveness value.
*
* @param string $value Effectiveness value.
* @return int The corresponding key if value is found, otherwise 0.
*/
public function get_effectiveness( $value ) {
$effectiveness_map = [
'EFFECTIVENESS_UNSPECIFIED' => 0,
'LOW' => 1,
'MEDIUM' => 2,
'HIGH' => 3,
];
return $effectiveness_map[ $value ] ?? 0;
}
/**
* Update price benchmarks by querying the Google Content API and saving the data locally.
*/
public function update_price_benchmarks(): void {
try {
$benchmarks = $this->get_price_benchmarks_response( [] );
if ( empty( $benchmarks ) ) {
return;
}
/** @var MerchantPriceBenchmarksQuery $query */
$query = $this->container->get( MerchantPriceBenchmarksQuery::class );
// Clear existing data before updating.
$query->reload_data();
// Insert new benchmark data.
foreach ( $benchmarks as $benchmark_item ) {
$query->insert( $benchmark_item );
}
} catch ( \Exception $e ) {
do_action( 'woocommerce_gla_debug_message', $e->getMessage(), __METHOD__ );
}
}
/**
* Compares a given price with a benchmark price.
*
* This function takes two prices in micros (1,000,000 micros = 1 unit of currency)
* and performs a comparison to determine their relationship.
*
* @param int $price_micros The price to compare, in micros.
* @param int $benchmark_price_micros The benchmark price to compare against, in micros.
* @return bool Returns specific price compare group if the price meets the comparison criteria with the benchmark.
*/
private function price_compared_with_benchmark( $price_micros, $benchmark_price_micros ) {
if ( empty( $price_micros ) || empty( $benchmark_price_micros ) ) {
return 0;
} elseif ( abs( $price_micros - $benchmark_price_micros ) <= ( $benchmark_price_micros * 0.01 ) ) {
return 2;
} elseif ( $price_micros < $benchmark_price_micros ) {
return 1;
} elseif ( $price_micros > $benchmark_price_micros ) {
return 3;
}
}
/**
* Get a summary of price benchmarks.
*
* @return array
*/
public function get_summary(): array {
/** @var MerchantPriceBenchmarksQuery $query */
$query = $this->container->get( MerchantPriceBenchmarksQuery::class );
// Get counts for all price comparison groups in one query.
$benchmark_counts_result = $query->get_price_benchmark_counts();
// Convert raw DB results to an associative array with all groups.
$benchmark_counts = $this->get_price_benchmark_counts_data( $benchmark_counts_result );
return [
'total_products' => $benchmark_counts['total'] ?? 0, // Total products
'price_unknown' => $benchmark_counts[0] ?? 0, // Unknown/missing
'price_lower' => $benchmark_counts[1] ?? 0, // Lower price
'price_similar' => $benchmark_counts[2] ?? 0, // Similar price
'price_higher' => $benchmark_counts[3] ?? 0, // Higher price
];
}
/**
* Converts raw benchmark counts from the database to an associative array.
*
* @param array $rows Raw benchmark counts result from the database.
* @return array Associative array with counts for each price comparison group and total.
*/
public function get_price_benchmark_counts_data( array $rows ): array {
// Convert the results to a more usable format
$counts = [];
$total = 0;
foreach ( $rows as $row ) {
$price_compared_value = (int) $row['price_compared_with_benchmark'];
$counts[ $price_compared_value ] = (int) $row['count'];
$total += $counts[ $price_compared_value ];
}
// Make sure all possible values are represented (0, 1, 2, 3)
$all_values = [ 0, 1, 2, 3 ];
foreach ( $all_values as $value ) {
if ( ! isset( $counts[ $value ] ) ) {
$counts[ $value ] = 0;
}
}
$counts['total'] = $total;
return $counts;
}
/**
* Retrieves formatted price benchmarks data from the local database.
*
* @param array $args {
* Optional. Arguments to filter and paginate results.
*
* @type array|null $include List of product IDs to include. Default null.
* @type int $page Offset for the results. Default 1.
* @type int $per_page Maximum number of items returned. Default 10.
* @type string $search Search string to filter results. Default null.
* @type string $order Sort order: 'asc' or 'desc'. Default 'desc'.
* @type string $orderby Attribute to sort by. Default 'mc_insights_effectiveness'.
* }
* @return array {
* @type array $results List of formatted price benchmarks.
* @type int $total Total number of price benchmarks available.
* }
*/
public function get_price_benchmarks_data( array $args = [] ): array {
$defaults = [
'include' => null,
'page' => 1,
'per_page' => 10,
'search' => null,
'order' => 'desc',
'orderby' => self::DEFAULT_ORDERBY,
];
$args = wp_parse_args( array_intersect_key( $args, $defaults ), $defaults );
/** @var MerchantPriceBenchmarksQuery $query */
$query = $this->container->get( MerchantPriceBenchmarksQuery::class );
// Apply filters.
if ( ! empty( $args['include'] ) && is_array( $args['include'] ) ) {
$query->where( 'product_id', $args['include'], 'IN' );
}
if ( ! empty( $args['search'] ) ) {
$search = trim( $args['search'] );
$search_query = new \WP_Query(
[
'post_type' => [ 'product' ],
'post_status' => 'publish',
's' => $search,
'fields' => 'ids',
'posts_per_page' => -1,
]
);
$product_ids = $search_query->posts;
// If no products found, return empty results.
if ( empty( $product_ids ) ) {
return [
'results' => [],
'total' => 0,
];
}
$query->where( 'product_id', $product_ids, 'IN' );
}
// Set order and orderby.
$order = strtoupper( $args['order'] );
if ( ! in_array( $order, [ 'ASC', 'DESC' ], true ) ) {
$order = 'DESC';
}
$orderby = $this->map_orderby_to_db_value( $args['orderby'] );
$query->set_order( $orderby, $order );
// Set offset and limit.
$query->set_offset( (int) ( $args['page'] - 1 ) * $args['per_page'] );
$query->set_limit( (int) $args['per_page'] );
// Get total count before limiting.
$total = $query->get_count();
// Get results.
$rows = $query->get_results();
// Format results.
$results = [];
foreach ( $rows as $row ) {
$wc_product_id = (int) $row['product_id'];
$product = wc_get_product( $wc_product_id );
$thumbnail = $this->get_product_thumbnail( $wc_product_id );
$results[] = [
'product' => [
'id' => $wc_product_id,
'thumbnail' => $thumbnail,
'title' => $product instanceof \WC_Product ? $product->get_name() : '',
],
'effectiveness' => (int) $row['mc_insights_effectiveness'] ?? 0,
'country_code' => $row['mc_price_country_code'] ?? '',
'currency_code' => $row['mc_product_currency_code'] ?? '',
'product_price' => $this->micros_to_float( (int) $row['mc_product_price_micros'] ),
'benchmark_price' => $this->micros_to_float( (int) $row['mc_price_benchmark_price_micros'] ),
'benchmark_price_currency_code' => $row['mc_price_benchmark_price_currency_code'] ?? '',
'price_gap' => $this->micros_to_float( (int) $row['mc_price_benchmark_price_micros'] - (int) $row['mc_product_price_micros'] ),
'suggested_price' => $this->micros_to_float( (int) $row['mc_insights_suggested_price_micros'] ),
'suggested_price_currency_code' => $row['mc_insights_suggested_price_currency_code'] ?? '',
'predicted_impressions_change' => $row['mc_insights_predicted_impressions_change_fraction'] ?? '',
'predicted_clicks_change' => $row['mc_insights_predicted_clicks_change_fraction'] ?? '',
'predicted_conversions_change' => $row['mc_insights_predicted_conversions_change_fraction'] ?? '',
'clicks' => (int) $row['mc_metrics_clicks'] ?? 0,
'impressions' => (int) $row['mc_metrics_impressions'] ?? 0,
'ctr' => $row['mc_metrics_ctr'] ?? 0,
'conversions' => (int) $row['mc_metrics_conversions'] ?? 0,
'price_compared_with_benchmark' => (int) $row['price_compared_with_benchmark'] ?? 0,
];
}
return [
'results' => $results,
'total' => $total,
];
}
/**
* Maps the orderby parameter to the corresponding database column name.
*
* @param string $order_by The orderby parameter from the request.
* @return string The corresponding database column name.
*/
private function map_orderby_to_db_value( string $order_by ): string {
// If the $order_by value is not in the COLUMN_MAP, use the default.
if ( ! in_array( $order_by, array_keys( self::COLUMN_MAP ), true ) ) {
$order_by = self::DEFAULT_ORDERBY;
}
return self::COLUMN_MAP[ $order_by ];
}
/**
* Converts a value in micros to a float representation.
*
* @param int $micros The value in micros (1,000,000 micros = 1 unit of currency).
* @return float The converted float value.
*/
private function micros_to_float( int $micros ): float {
// Convert micros to a float value.
return round( $micros / 1000000, 2 );
}
}