File "ProductHelper.php"

Full Path: /home/warrior1/public_html/wp-content/plugins/google-listings-and-ads/src/Product/ProductHelper.php
File size: 18.12 KB
MIME-type: text/x-php
Charset: utf-8

<?php
declare( strict_types=1 );

namespace Automattic\WooCommerce\GoogleListingsAndAds\Product;

use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\GoogleProductService;
use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service;
use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\TargetAudience;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\ChannelVisibility;
use Automattic\WooCommerce\GoogleListingsAndAds\Value\SyncStatus;
use Google\Service\ShoppingContent\Product as GoogleProduct;
use WC_Product;
use WC_Product_Variation;

defined( 'ABSPATH' ) || exit;

/**
 * Class ProductHelper
 *
 * @package Automattic\WooCommerce\GoogleListingsAndAds\Product
 */
class ProductHelper implements Service {

	use PluginHelper;

	/**
	 * @var ProductMetaHandler
	 */
	protected $meta_handler;

	/**
	 * @var WC
	 */
	protected $wc;

	/**
	 * @var TargetAudience
	 */
	protected $target_audience;

	/**
	 * ProductHelper constructor.
	 *
	 * @param ProductMetaHandler $meta_handler
	 * @param WC                 $wc
	 * @param TargetAudience     $target_audience
	 */
	public function __construct( ProductMetaHandler $meta_handler, WC $wc, TargetAudience $target_audience ) {
		$this->meta_handler    = $meta_handler;
		$this->wc              = $wc;
		$this->target_audience = $target_audience;
	}

	/**
	 * Mark a product as synced in the local database.
	 * This function also handles the following cleanup tasks:
	 * - Remove any failed delete attempts
	 * - Update the visibility (if it was previously empty)
	 * - Remove any previous product errors (if it was synced for all target countries)
	 *
	 * @param WC_Product    $product
	 * @param GoogleProduct $google_product
	 */
	public function mark_as_synced( WC_Product $product, GoogleProduct $google_product ) {
		$this->meta_handler->delete_failed_delete_attempts( $product );
		$this->meta_handler->update_synced_at( $product, time() );
		$this->meta_handler->update_sync_status( $product, SyncStatus::SYNCED );
		$this->update_empty_visibility( $product );

		// merge and update all google product ids
		$current_google_ids = $this->meta_handler->get_google_ids( $product );
		$current_google_ids = ! empty( $current_google_ids ) ? $current_google_ids : [];
		$google_ids         = array_unique( array_merge( $current_google_ids, [ $google_product->getTargetCountry() => $google_product->getId() ] ) );
		$this->meta_handler->update_google_ids( $product, $google_ids );

		// check if product is synced completely and remove any previous errors if it is
		$synced_countries = array_keys( $google_ids );
		$target_countries = $this->target_audience->get_target_countries();
		if ( count( $synced_countries ) === count( $target_countries ) && empty( array_diff( $synced_countries, $target_countries ) ) ) {
			$this->meta_handler->delete_errors( $product );
			$this->meta_handler->delete_failed_sync_attempts( $product );
			$this->meta_handler->delete_sync_failed_at( $product );
		}

		// mark the parent product as synced if it's a variation
		if ( $product instanceof WC_Product_Variation ) {
			try {
				$parent_product = $this->get_wc_product( $product->get_parent_id() );
			} catch ( InvalidValue $exception ) {
				return;
			}

			$this->mark_as_synced( $parent_product, $google_product );
		}
	}

	/**
	 * @param WC_Product $product
	 */
	public function mark_as_unsynced( WC_Product $product ) {
		$this->meta_handler->delete_synced_at( $product );
		$this->meta_handler->update_sync_status( $product, SyncStatus::NOT_SYNCED );
		$this->meta_handler->delete_google_ids( $product );
		$this->meta_handler->delete_errors( $product );
		$this->meta_handler->delete_failed_sync_attempts( $product );
		$this->meta_handler->delete_sync_failed_at( $product );

		// mark the parent product as un-synced if it's a variation
		if ( $product instanceof WC_Product_Variation ) {
			try {
				$parent_product = $this->get_wc_product( $product->get_parent_id() );
			} catch ( InvalidValue $exception ) {
				return;
			}

			$this->mark_as_unsynced( $parent_product );
		}
	}

	/**
	 * @param WC_Product $product
	 * @param string     $google_id
	 */
	public function remove_google_id( WC_Product $product, string $google_id ) {
		$google_ids = $this->meta_handler->get_google_ids( $product );
		if ( empty( $google_ids ) ) {
			return;
		}

		$idx = array_search( $google_id, $google_ids, true );
		if ( false === $idx ) {
			return;
		}

		unset( $google_ids[ $idx ] );

		if ( ! empty( $google_ids ) ) {
			$this->meta_handler->update_google_ids( $product, $google_ids );
		} else {
			// if there are no Google IDs left then this product is no longer considered "synced"
			$this->mark_as_unsynced( $product );
		}

	}

	/**
	 * Marks a WooCommerce product as invalid and stores the errors in a meta data key.
	 *
	 * Note: If a product variation is invalid then the parent product is also marked as invalid.
	 *
	 * @param WC_Product $product
	 * @param string[]   $errors
	 */
	public function mark_as_invalid( WC_Product $product, array $errors ) {
		// bail if no errors exist
		if ( empty( $errors ) ) {
			return;
		}

		$this->meta_handler->update_errors( $product, $errors );
		$this->meta_handler->update_sync_status( $product, SyncStatus::HAS_ERRORS );
		$this->update_empty_visibility( $product );

		if ( ! empty( $errors[ GoogleProductService::INTERNAL_ERROR_REASON ] ) ) {
			// update failed sync attempts count in case of internal errors
			$failed_attempts = ! empty( $this->meta_handler->get_failed_sync_attempts( $product ) ) ?
				$this->meta_handler->get_failed_sync_attempts( $product ) :
				0;
			$this->meta_handler->update_failed_sync_attempts( $product, $failed_attempts + 1 );
			$this->meta_handler->update_sync_failed_at( $product, time() );
		}

		// mark the parent product as invalid if it's a variation
		if ( $product instanceof WC_Product_Variation ) {
			try {
				$parent_product = $this->get_wc_product( $product->get_parent_id() );
			} catch ( InvalidValue $exception ) {
				return;
			}

			$parent_errors = ! empty( $this->meta_handler->get_errors( $parent_product ) ) ?
				$this->meta_handler->get_errors( $parent_product ) :
				[];

			$parent_errors[ $product->get_id() ] = $errors;

			$this->mark_as_invalid( $parent_product, $parent_errors );
		}
	}

	/**
	 * Marks a WooCommerce product as pending synchronization.
	 *
	 * Note: If a product variation is pending then the parent product is also marked as pending.
	 *
	 * @param WC_Product $product
	 */
	public function mark_as_pending( WC_Product $product ) {
		$this->meta_handler->update_sync_status( $product, SyncStatus::PENDING );

		// mark the parent product as pending if it's a variation
		if ( $product instanceof WC_Product_Variation ) {
			try {
				$parent_product = $this->get_wc_product( $product->get_parent_id() );
			} catch ( InvalidValue $exception ) {
				return;
			}

			$this->mark_as_pending( $parent_product );
		}
	}

	/**
	 * Update empty (NOT EXIST) visibility meta values to SYNC_AND_SHOW.
	 *
	 * @param WC_Product $product
	 */
	protected function update_empty_visibility( WC_Product $product ): void {
		try {
			$product = $this->maybe_swap_for_parent( $product );
		} catch ( InvalidValue $exception ) {
			return;
		}

		$visibility = $this->meta_handler->get_visibility( $product );

		if ( empty( $visibility ) ) {
			$this->meta_handler->update_visibility( $product, ChannelVisibility::SYNC_AND_SHOW );
		}
	}

	/**
	 * @param WC_Product $product
	 *
	 * @return string[]|null An array of Google product IDs stored for each WooCommerce product
	 */
	public function get_synced_google_product_ids( WC_Product $product ): ?array {
		return $this->meta_handler->get_google_ids( $product );
	}

	/**
	 * See: WCProductAdapter::map_wc_product_id()
	 *
	 * @param string $mc_product_id
	 *
	 * @return int the ID for the WC product linked to the provided Google product ID (0 if not found)
	 */
	public function get_wc_product_id( string $mc_product_id ): int {
		$pattern = '/' . preg_quote( $this->get_slug(), '/' ) . '_(\d+)$/';
		if ( ! preg_match( $pattern, $mc_product_id, $matches ) ) {
			return 0;
		}

		return intval( $matches[1] );
	}

	/**
	 * Attempt to get the WooCommerce product title.
	 * The MC ID is converted to a WC ID before retrieving the product.
	 * If we can't retrieve the title we fallback to the original MC ID.
	 *
	 * @param string $mc_product_id Merchant Center product ID.
	 *
	 * @return string
	 */
	public function get_wc_product_title( string $mc_product_id ): string {
		try {
			$product = $this->get_wc_product( $this->get_wc_product_id( $mc_product_id ) );
		} catch ( InvalidValue $e ) {
			return $mc_product_id;
		}

		return $product->get_title();
	}

	/**
	 * Get WooCommerce product
	 *
	 * @param int $product_id
	 *
	 * @return WC_Product
	 *
	 * @throws InvalidValue If the given ID doesn't reference a valid product.
	 */
	public function get_wc_product( int $product_id ): WC_Product {
		return $this->wc->get_product( $product_id );
	}

	/**
	 * @param WC_Product $product
	 *
	 * @return bool
	 */
	public function is_product_synced( WC_Product $product ): bool {
		$synced_at  = $this->meta_handler->get_synced_at( $product );
		$google_ids = $this->meta_handler->get_google_ids( $product );

		return ! empty( $synced_at ) && ! empty( $google_ids );
	}

	/**
	 * @param WC_Product $product
	 *
	 * @return bool
	 */
	public function is_sync_ready( WC_Product $product ): bool {
		$product_visibility = $product->is_visible();
		$product_status     = $product->get_status();

		if ( $product instanceof WC_Product_Variation ) {
			// Check the post status of the parent product if it's a variation
			try {
				$parent = $this->get_wc_product( $product->get_parent_id() );
			} catch ( InvalidValue $exception ) {
				do_action(
					'woocommerce_gla_error',
					sprintf( 'Cannot sync an orphaned variation (ID: %s).', $product->get_id() ),
					__METHOD__
				);

				return false;
			}

			$product_status = $parent->get_status();

			/**
			 * Optionally hide invisible variations (disabled variations and variations with empty price).
			 *
			 * @see WC_Product_Variable::get_available_variations for filter documentation
			 */
			if ( apply_filters( 'woocommerce_hide_invisible_variations', true, $parent->get_id(), $product ) && ! $product->variation_is_visible() ) {
				$product_visibility = false;
			}
		}

		return ( ChannelVisibility::DONT_SYNC_AND_SHOW !== $this->get_channel_visibility( $product ) ) &&
			   ( in_array( $product->get_type(), ProductSyncer::get_supported_product_types(), true ) ) &&
			   ( 'publish' === $product_status ) &&
			   $product_visibility;
	}

	/**
	 * Whether the sync has failed repeatedly for the product within the given timeframe.
	 *
	 * @param WC_Product $product
	 *
	 * @return bool
	 *
	 * @see ProductSyncer::FAILURE_THRESHOLD        The number of failed attempts allowed per timeframe
	 * @see ProductSyncer::FAILURE_THRESHOLD_WINDOW The specified timeframe
	 */
	public function is_sync_failed_recently( WC_Product $product ): bool {
		$failed_attempts = $this->meta_handler->get_failed_sync_attempts( $product );
		$failed_at       = $this->meta_handler->get_sync_failed_at( $product );

		// if it has failed more times than the specified threshold AND if syncing it has failed within the specified window
		return $failed_attempts > ProductSyncer::FAILURE_THRESHOLD &&
			   $failed_at > strtotime( sprintf( '-%s', ProductSyncer::FAILURE_THRESHOLD_WINDOW ) );
	}

	/**
	 * Increment failed delete attempts.
	 *
	 * @since 1.12.0
	 *
	 * @param WC_Product $product
	 */
	public function increment_failed_delete_attempt( WC_Product $product ) {
		$failed_attempts = $this->meta_handler->get_failed_delete_attempts( $product ) ?? 0;
		$this->meta_handler->update_failed_delete_attempts( $product, $failed_attempts + 1 );
	}

	/**
	 * Whether deleting has failed more times than the specified threshold.
	 *
	 * @since 1.12.0
	 *
	 * @param WC_Product $product
	 *
	 * @return boolean
	 */
	public function is_delete_failed_threshold_reached( WC_Product $product ): bool {
		$failed_attempts = $this->meta_handler->get_failed_delete_attempts( $product ) ?? 0;
		return $failed_attempts >= ProductSyncer::FAILURE_THRESHOLD;
	}

	/**
	 * Increment failed delete attempts.
	 *
	 * @since 1.12.2
	 *
	 * @param WC_Product $product
	 */
	public function increment_failed_update_attempt( WC_Product $product ) {
		$failed_attempts = $this->meta_handler->get_failed_sync_attempts( $product ) ?? 0;
		$this->meta_handler->update_failed_sync_attempts( $product, $failed_attempts + 1 );
	}

	/**
	 * Whether deleting has failed more times than the specified threshold.
	 *
	 * @since 1.12.2
	 *
	 * @param WC_Product $product
	 *
	 * @return boolean
	 */
	public function is_update_failed_threshold_reached( WC_Product $product ): bool {
		$failed_attempts = $this->meta_handler->get_failed_sync_attempts( $product ) ?? 0;
		return $failed_attempts >= ProductSyncer::FAILURE_THRESHOLD;
	}

	/**
	 * @param WC_Product $wc_product
	 *
	 * @return string|null
	 */
	public function get_channel_visibility( WC_Product $wc_product ): ?string {
		try {
			// todo: we might need to define visibility per variation later.
			return $this->meta_handler->get_visibility( $this->maybe_swap_for_parent( $wc_product ) );
		} catch ( InvalidValue $exception ) {
			do_action(
				'woocommerce_gla_debug_message',
				sprintf( 'Channel visibility forced to "%s" for invalid product (ID: %s).', ChannelVisibility::DONT_SYNC_AND_SHOW, $wc_product->get_id() ),
				__METHOD__
			);

			return ChannelVisibility::DONT_SYNC_AND_SHOW;
		}
	}

	/**
	 * Return a string indicating sync status based on several factors.
	 *
	 * @param WC_Product $wc_product
	 *
	 * @return string|null
	 */
	public function get_sync_status( WC_Product $wc_product ): ?string {
		return $this->meta_handler->get_sync_status( $wc_product );
	}

	/**
	 * Return the string indicating the product status as reported by the Merchant Center.
	 *
	 * @param WC_Product $wc_product
	 *
	 * @return string|null
	 */
	public function get_mc_status( WC_Product $wc_product ): ?string {
		try {
			return $this->meta_handler->get_mc_status( $this->maybe_swap_for_parent( $wc_product ) );
		} catch ( InvalidValue $exception ) {
			do_action(
				'woocommerce_gla_debug_message',
				sprintf( 'Product status returned null for invalid product (ID: %s).', $wc_product->get_id() ),
				__METHOD__
			);

			return null;
		}
	}

	/**
	 * If an item from the provided list of products has a parent, replace it with the parent ID.
	 *
	 * @param int[] $product_ids                        A list of WooCommerce product ID.
	 * @param bool  $check_product_status    (Optional) Check if the product status is publish.
	 * @param bool  $ignore_product_on_error (Optional) Ignore the product when invalid value error occurs.
	 *
	 * @return int[] A list of parent ID or product ID if it doesn't have a parent.
	 *
	 * @throws InvalidValue If the given param ignore_product_on_error is false and any of a given ID doesn't reference a valid product.
	 *                      Or if a variation product does not have a valid parent ID (i.e. it's an orphan).
	 *
	 * @since 2.2.0
	 */
	public function maybe_swap_for_parent_ids( array $product_ids, bool $check_product_status = true, bool $ignore_product_on_error = true ) {
		$new_product_ids = [];

		foreach ( $product_ids as $index => $product_id ) {
			try {
				$product     = $this->get_wc_product( $product_id );
				$new_product = $this->maybe_swap_for_parent( $product );
				if ( ! $check_product_status || 'publish' === $new_product->get_status() ) {
					$new_product_ids[ $index ] = $new_product->get_id();
				}
			} catch ( InvalidValue $exception ) {
				if ( ! $ignore_product_on_error ) {
					throw $exception;
				}
			}
		}

		return array_unique( $new_product_ids );
	}

	/**
	 * If the provided product has a parent, return its ID. Otherwise, return the given (valid product) ID.
	 *
	 * @param int $product_id WooCommerce product ID.
	 *
	 * @return int The parent ID or product ID if it doesn't have a parent.
	 *
	 * @throws InvalidValue If a given ID doesn't reference a valid product. Or if a variation product does not have a
	 *                      valid parent ID (i.e. it's an orphan).
	 */
	public function maybe_swap_for_parent_id( int $product_id ): int {
		$product = $this->get_wc_product( $product_id );

		return $this->maybe_swap_for_parent( $product )->get_id();
	}

	/**
	 * If the provided product has a parent, return its parent object. Otherwise, return the given product.
	 *
	 * @param WC_Product $product WooCommerce product object.
	 *
	 * @return WC_Product The parent product object or the given product object if it doesn't have a parent.
	 *
	 * @throws InvalidValue If a variation product does not have a valid parent ID (i.e. it's an orphan).
	 *
	 * @since 1.3.0
	 */
	public function maybe_swap_for_parent( WC_Product $product ): WC_Product {
		if ( $product instanceof WC_Product_Variation ) {
			try {
				return $this->get_wc_product( $product->get_parent_id() );
			} catch ( InvalidValue $exception ) {
				do_action(
					'woocommerce_gla_error',
					sprintf( 'An orphaned variation found (ID: %s). Please delete it via "WooCommerce > Status > Tools > Delete orphaned variations".', $product->get_id() ),
					__METHOD__
				);

				throw $exception;
			}
		}

		return $product;
	}

	/**
	 * Get validation errors for a specific product.
	 * Combines errors for variable products, which have a variation-indexed array of errors.
	 *
	 * @param WC_Product $product
	 *
	 * @return array
	 */
	public function get_validation_errors( WC_Product $product ): array {
		$errors = $this->meta_handler->get_errors( $product ) ?: [];

		$first_key = array_key_first( $errors );
		if ( ! empty( $errors ) && is_numeric( $first_key ) && 0 !== $first_key ) {
			$errors = array_unique( array_merge( ...$errors ) );
		}

		return $errors;
	}

	/**
	 * Get categories list for a specific product.
	 *
	 * @param WC_Product $product
	 *
	 * @return array
	 */
	public function get_categories( WC_Product $product ): array {
		$terms = get_the_terms( $product->get_id(), 'product_cat' );
		return ( empty( $terms ) || is_wp_error( $terms ) ) ? [] : wp_list_pluck( $terms, 'name' );
	}
}