diff --git a/src/API/Google/MerchantReport.php b/src/API/Google/MerchantReport.php index cdd5811c34..f295a3390b 100644 --- a/src/API/Google/MerchantReport.php +++ b/src/API/Google/MerchantReport.php @@ -6,6 +6,7 @@ use Automattic\WooCommerce\Admin\API\Reports\TimeInterval; use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query\MerchantFreeListingReportQuery; use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query\MerchantProductReportQuery; +use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query\MerchantProductViewReportQuery; use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidValue; use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface; use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait; @@ -14,6 +15,8 @@ use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent; use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\ReportRow; use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\Segments; +use Automattic\WooCommerce\GoogleListingsAndAds\Value\MCStatus; +use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\ShoppingContentDateTrait; use DateTime; use Exception; @@ -26,6 +29,7 @@ class MerchantReport implements OptionsAwareInterface { use OptionsAwareTrait; use ReportTrait; + use ShoppingContentDateTrait; /** * The shopping service. @@ -52,6 +56,87 @@ public function __construct( ShoppingContent $service, ProductHelper $product_he $this->product_helper = $product_helper; } + /** + * Get ProductView Query response. + * + * @param string|null $next_page_token The next page token. + * @return array Associative array with product statuses and the next page token. + * + * @throws Exception If the product view report data can't be retrieved. + */ + public function get_product_view_report( $next_page_token = null ): array { + $batch_size = apply_filters( 'woocommerce_gla_product_view_report_page_size', 1000 ); + + try { + $product_view_data = [ + 'statuses' => [], + 'next_page' => null, + ]; + + $query = new MerchantProductViewReportQuery( + [ + 'next_page' => $next_page_token, + 'per_page' => $batch_size, + ] + ); + + $response = $query + ->set_client( $this->service, $this->options->get_merchant_id() ) + ->get_results(); + + foreach ( $response->getResults() as $row ) { + + /** @var ProductView $product_view */ + $product_view = $row->getProductView(); + + $wc_product_id = $this->product_helper->get_wc_product_id( $product_view->getId() ); + $mc_product_status = $this->convert_aggregated_status_to_mc_status( $product_view->getAggregatedDestinationStatus() ); + + // Skip if the product id does not exist + if ( ! $wc_product_id ) { + continue; + } + + $product_view_data['statuses'][] = [ + 'product_id' => $wc_product_id, + 'status' => $mc_product_status, + 'expiration_date' => $this->convert_shopping_content_date( $product_view->getExpirationDate() ), + ]; + + } + + $product_view_data['next_page'] = $response->getNextPageToken(); + + return $product_view_data; + } catch ( GoogleException $e ) { + do_action( 'woocommerce_gla_mc_client_exception', $e, __METHOD__ ); + throw new Exception( __( 'Unable to retrieve Product View Report.', 'google-listings-and-ads' ) . $e->getMessage(), $e->getCode() ); + } + } + + /** + * Convert the product view aggregated status to the MC status. + * + * @param string $status The aggregated status of the product. + * + * @return string The MC status. + */ + protected function convert_aggregated_status_to_mc_status( string $status ): string { + switch ( $status ) { + case 'ELIGIBLE': + return MCStatus::APPROVED; + case 'ELIGIBLE_LIMITED': + return MCStatus::PARTIALLY_APPROVED; + case 'NOT_ELIGIBLE_OR_DISAPPROVED': + return MCStatus::DISAPPROVED; + case 'PENDING': + return MCStatus::PENDING; + default: + return MCStatus::NOT_SYNCED; + } + } + + /** * Get report data for free listings. * diff --git a/src/API/Google/Query/MerchantProductViewReportQuery.php b/src/API/Google/Query/MerchantProductViewReportQuery.php new file mode 100644 index 0000000000..9519be3157 --- /dev/null +++ b/src/API/Google/Query/MerchantProductViewReportQuery.php @@ -0,0 +1,54 @@ +set_initial_columns(); + $this->handle_query_args( $args ); + } + + + /** + * Filter the query by a list of ID's. + * + * @param array $ids list of ID's to filter by. + * + * @return $this + */ + public function filter( array $ids ): QueryInterface { + // No filtering used for product view report. + return $this; + } + + /** + * Set the initial columns for this query. + */ + protected function set_initial_columns() { + $this->columns( + [ + 'id' => 'product_view.id', + 'offer_id' => 'product_view.offer_id', + 'expiration_date' => 'product_view.expiration_date', + 'status' => 'product_view.aggregated_destination_status', + ] + ); + } +} diff --git a/src/API/Google/ShoppingContentDateTrait.php b/src/API/Google/ShoppingContentDateTrait.php new file mode 100644 index 0000000000..86240ea3a8 --- /dev/null +++ b/src/API/Google/ShoppingContentDateTrait.php @@ -0,0 +1,28 @@ +getYear()}-{$date->getMonth()}-{$date->getDay()}" ); + } +} diff --git a/src/Internal/DependencyManagement/JobServiceProvider.php b/src/Internal/DependencyManagement/JobServiceProvider.php index 2ad3cbe7ec..f5681a17b1 100644 --- a/src/Internal/DependencyManagement/JobServiceProvider.php +++ b/src/Internal/DependencyManagement/JobServiceProvider.php @@ -9,6 +9,7 @@ use Automattic\WooCommerce\GoogleListingsAndAds\ActionScheduler\ActionSchedulerInterface; use Automattic\WooCommerce\GoogleListingsAndAds\ActionScheduler\AsyncActionRunner; use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Settings as GoogleSettings; +use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\MerchantReport; use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidClass; use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ValidateInterface; use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service; @@ -33,6 +34,7 @@ use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\Update\PluginUpdate; use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\UpdateShippingSettings; use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\UpdateSyncableProductsCount; +use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\UpdateMerchantProductStatuses; use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantCenterService; use Automattic\WooCommerce\GoogleListingsAndAds\Coupon\CouponHelper; use Automattic\WooCommerce\GoogleListingsAndAds\Coupon\CouponSyncer; @@ -42,6 +44,7 @@ use Automattic\WooCommerce\GoogleListingsAndAds\Product\ProductSyncer; use Automattic\WooCommerce\GoogleListingsAndAds\Event\StartProductSync; use Automattic\WooCommerce\GoogleListingsAndAds\Coupon; +use Automattic\WooCommerce\GoogleListingsAndAds\MerchantCenter\MerchantStatuses; use Automattic\WooCommerce\GoogleListingsAndAds\Product; use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC; use Automattic\WooCommerce\GoogleListingsAndAds\Shipping; @@ -144,6 +147,8 @@ public function register(): void { // Share update syncable products count job $this->share_action_scheduler_job( UpdateSyncableProductsCount::class, ProductRepository::class, ProductHelper::class ); + + $this->share_action_scheduler_job( UpdateMerchantProductStatuses::class, MerchantCenterService::class, MerchantReport::class, MerchantStatuses::class ); } /** diff --git a/src/Jobs/UpdateMerchantProductStatuses.php b/src/Jobs/UpdateMerchantProductStatuses.php new file mode 100644 index 0000000000..53934a6564 --- /dev/null +++ b/src/Jobs/UpdateMerchantProductStatuses.php @@ -0,0 +1,118 @@ +merchant_center = $merchant_center; + $this->merchant_report = $merchant_report; + $this->merchant_statuses = $merchant_statuses; + } + + /** + * Get the name of the job. + * + * @return string + */ + public function get_name(): string { + return 'update_merchant_product_statuses'; + } + + /** + * Can the job be scheduled. + * + * @param array|null $args + * + * @return bool Returns true if the job can be scheduled. + */ + public function can_schedule( $args = [] ): bool { + return parent::can_schedule( $args ) && $this->merchant_center->is_connected(); + } + + /** + * Process the job. + * + * @param int[] $items An array of job arguments. + * + * @throws JobException If the shipping settings cannot be synced. + */ + public function process_items( array $items ) { + $next_page_token = null; + + do { + $results = $this->merchant_report->get_product_view_report( $next_page_token ); + + $this->merchant_statuses->process_product_statuses( $results['statuses'] ); + + $next_page_token = $results['next_page']; + + } while ( $next_page_token ); + + $this->merchant_statuses->update_product_stats(); + } + + /** + * Schedule the job. + * + * @param array $args - arguments. + */ + public function schedule( array $args = [] ) { + if ( $this->can_schedule() ) { + $this->action_scheduler->schedule_immediate( $this->get_process_item_hook() ); + } + } + + /** + * The job is considered to be scheduled if the "process_item" action is currently pending or in-progress. + * + * @return bool + */ + public function is_scheduled(): bool { + return $this->is_running(); + } +} diff --git a/src/MerchantCenter/MerchantStatuses.php b/src/MerchantCenter/MerchantStatuses.php index eccfe21d31..a2db6e9d76 100644 --- a/src/MerchantCenter/MerchantStatuses.php +++ b/src/MerchantCenter/MerchantStatuses.php @@ -21,6 +21,7 @@ use Automattic\WooCommerce\GoogleListingsAndAds\Value\ChannelVisibility; use Automattic\WooCommerce\GoogleListingsAndAds\Value\MCStatus; use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\ProductStatus as GoogleProductStatus; +use Automattic\WooCommerce\GoogleListingsAndAds\Jobs\UpdateMerchantProductStatuses; use DateTime; use Exception; @@ -44,6 +45,7 @@ class MerchantStatuses implements Service, ContainerAwareInterface { use ContainerAwareTrait; use PluginHelper; + /** * The lifetime of the status-related data. */ @@ -106,7 +108,22 @@ public function __construct() { * @throws Exception If the Merchant Center can't be polled for the statuses. */ public function get_product_statistics( bool $force_refresh = false ): array { - $this->maybe_refresh_status_data( $force_refresh ); + $this->mc_statuses = $this->container->get( TransientsInterface::class )->get( Transients::MC_STATUSES ); + $job = $this->container->get( UpdateMerchantProductStatuses::class ); + + // If force_refresh is true or if not transient, return empty array and scheduled the job to update the statuses. + if ( ! $job->is_scheduled() && ( $force_refresh || ( ! $force_refresh && null === $this->mc_statuses ) ) ) { + // Schedule job to update the statuses. + $job->schedule(); + } + + if ( $job->is_scheduled() || null === $this->mc_statuses ) { + // TODO: Add a notice to the client to inform that the statuses are being updated, or maybe we can pass the is_scheduled to the client. + return [ + 'timestamp' => $this->cache_created_time->getTimestamp(), + 'statistics' => [], + ]; + } $counting_stats = $this->mc_statuses['statistics']; $counting_stats = array_merge( @@ -182,8 +199,6 @@ public function maybe_refresh_status_data( bool $force_refresh = false ): void { throw new Exception( __( 'Merchant Center account is not set up.', 'google-listings-and-ads' ) ); } - $this->mc_statuses = []; - // Update account-level issues. $this->refresh_account_issues(); @@ -191,14 +206,10 @@ public function maybe_refresh_status_data( bool $force_refresh = false ): void { $chunk_size = apply_filters( 'woocommerce_gla_merchant_status_google_ids_chunk', 1000 ); foreach ( array_chunk( $this->get_synced_google_ids(), $chunk_size ) as $google_ids ) { $mc_product_statuses = $this->filter_valid_statuses( $google_ids ); + // TODO: Get the product issues with the Product View Report. $this->refresh_product_issues( $mc_product_statuses ); - $this->sum_status_counts( $mc_product_statuses ); } - // Update each product's mc_status and then update the global statistics. - $this->update_product_mc_statuses(); - $this->update_mc_statuses(); - // Update pre-sync product validation issues. $this->refresh_presync_product_issues(); @@ -572,32 +583,86 @@ protected function refresh_presync_product_issues(): void { } /** - * Add the provided status counts to the overall totals. + * Process product status statistics. * - * @param GoogleProductStatus[] $validated_mc_statuses Product statuses of validated products. + * @param array[] $statuses statuses. + * @see MerchantReport::get_product_view_report */ - protected function sum_status_counts( array $validated_mc_statuses ): void { + public function process_product_statuses( $statuses ): void { /** @var ProductHelper $product_helper */ - $product_helper = $this->container->get( ProductHelper::class ); + $product_helper = $this->container->get( ProductHelper::class ); + $visibility_meta_key = $this->prefix_meta_key( ProductMetaHandler::KEY_VISIBILITY ); - foreach ( $validated_mc_statuses as $product ) { - $wc_product_id = $product_helper->get_wc_product_id( $product->getProductId() ); - $status = $this->get_product_shopping_status( $product ); - if ( is_null( $status ) ) { + foreach ( $statuses as $product_status ) { + + $wc_product_id = $product_status['product_id']; + $mc_product_status = $product_status['status']; + + // Skip if the product does not exist or if the product previously found/validated. + if ( ! $wc_product_id || ! empty( $this->product_data_lookup[ $wc_product_id ] ) ) { + continue; + } + + if ( $this->product_is_expiring( $product_status['expiration_date'] ) ) { + $mc_product_status = MCStatus::EXPIRING; + } + + $wc_product = $product_helper->get_wc_product_by_wp_post( $wc_product_id ); + if ( ! $wc_product || 'product' !== substr( $wc_product->post_type, 0, 7 ) ) { + // Should never reach here since the products IDS are retrieved from postmeta. + do_action( + 'woocommerce_gla_debug_message', + sprintf( 'Merchant Center product %s not found in this WooCommerce store.', $wc_product_id ), + __METHOD__, + ); continue; } + + $this->product_data_lookup[ $wc_product_id ] = [ + 'name' => get_the_title( $wc_product ), + 'visibility' => get_post_meta( $wc_product_id, $visibility_meta_key ), + 'parent_id' => $wc_product->post_parent, + ]; + // Products is used later for global product status statistics. - $this->product_statuses['products'][ $wc_product_id ][ $status ] = 1 + ( $this->product_statuses['products'][ $wc_product_id ][ $status ] ?? 0 ); + $this->product_statuses['products'][ $wc_product_id ][ $mc_product_status ] = 1 + ( $this->product_statuses['products'][ $wc_product_id ][ $mc_product_status ] ?? 0 ); // Aggregate parent statuses for mc_status postmeta. - $wc_parent_id = intval( $this->product_data_lookup[ $wc_product_id ]['parent_id'] ); + $wc_parent_id = $this->product_data_lookup[ $wc_product_id ]['parent_id']; if ( ! $wc_parent_id ) { continue; } - $this->product_statuses['parents'][ $wc_parent_id ][ $status ] = 1 + ( $this->product_statuses['parents'][ $wc_parent_id ][ $status ] ?? 0 ); + $this->product_statuses['parents'][ $wc_parent_id ][ $mc_product_status ] = 1 + ( $this->product_statuses['parents'][ $wc_parent_id ][ $mc_product_status ] ?? 0 ); + } } + /** + * Update the product status statistics. + */ + public function update_product_stats() { + $this->mc_statuses = []; + // Update each product's mc_status and then update the global statistics. + $this->update_products_meta_with_mc_status(); + $this->update_mc_status_statistics(); + } + + /** + * Whether a product is expiring. + * + * @param DateTime $expiration_date + * + * @return bool Whether the product is expiring. + */ + protected function product_is_expiring( DateTime $expiration_date ): bool { + if ( ! $expiration_date ) { + return false; + } + + // Products are considered expiring if they will expire within 3 days. + return time() + 3 * DAY_IN_SECONDS > $expiration_date->getTimestamp(); + } + /** * Calculate the synced product status statistics. It will group * the variations for the same parent. @@ -657,7 +722,7 @@ protected function calculate_synced_product_statistics(): array { /** * Calculate the product status statistics and update the transient. */ - protected function update_mc_statuses() { + protected function update_mc_status_statistics() { $product_statistics = $this->calculate_synced_product_statistics(); /** @var ProductRepository $product_repository */ @@ -680,7 +745,7 @@ protected function update_mc_statuses() { /** * Update the Merchant Center status for each product. */ - protected function update_product_mc_statuses() { + protected function update_products_meta_with_mc_status() { // Generate a product_id=>mc_status array. $new_product_statuses = []; foreach ( $this->product_statuses as $types ) { @@ -748,27 +813,6 @@ protected function update_product_mc_statuses() { } } - /** - * Return the product's shopping status in the Google Merchant Center. - * Active, Pending, Disapproved, Expiring. - * - * @param GoogleProductStatus $product_status - * - * @return string|null - */ - protected function get_product_shopping_status( GoogleProductStatus $product_status ): ?string { - $status = null; - foreach ( $product_status->getDestinationStatuses() as $d ) { - if ( 'SurfacesAcrossGoogle' === $d->getDestination() ) { - $status = $d->getStatus(); - } elseif ( 'Shopping' === $d->getDestination() ) { - $status = $d->getStatus(); - break; - } - } - return $status; - } - /** * Allows a hook to modify the lifetime of the statuses data. * diff --git a/src/Product/ProductRepository.php b/src/Product/ProductRepository.php index 2add3bb281..2f706cfb8f 100644 --- a/src/Product/ProductRepository.php +++ b/src/Product/ProductRepository.php @@ -6,7 +6,7 @@ use Automattic\WooCommerce\GoogleListingsAndAds\Infrastructure\Service; use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper; use Automattic\WooCommerce\GoogleListingsAndAds\Value\ChannelVisibility; -use Automattic\WooCommerce\GoogleListingsAndAds\Value\SyncStatus; +use Automattic\WooCommerce\GoogleListingsAndAds\Value\MCStatus; use WC_Product; defined( 'ABSPATH' ) || exit; @@ -267,14 +267,9 @@ public function find_mc_not_synced_product_ids( int $limit = -1, int $offset = 0 'type' => $types, 'meta_query' => [ [ - 'key' => ProductMetaHandler::KEY_SYNC_STATUS, - 'compare' => '!=', - 'value' => SyncStatus::SYNCED, - ], - [ - 'key' => ProductMetaHandler::KEY_VISIBILITY, + 'key' => ProductMetaHandler::KEY_MC_STATUS, 'compare' => '=', - 'value' => ChannelVisibility::SYNC_AND_SHOW, + 'value' => MCStatus::NOT_SYNCED, ], ], ]; diff --git a/src/Product/SyncerHooks.php b/src/Product/SyncerHooks.php index 0d7cc0c112..02627cb886 100644 --- a/src/Product/SyncerHooks.php +++ b/src/Product/SyncerHooks.php @@ -256,6 +256,7 @@ protected function get_duplicated_product_excluded_meta( array $exclude_meta ): $exclude_meta[] = $this->prefix_meta_key( ProductMetaHandler::KEY_ERRORS ); $exclude_meta[] = $this->prefix_meta_key( ProductMetaHandler::KEY_FAILED_SYNC_ATTEMPTS ); $exclude_meta[] = $this->prefix_meta_key( ProductMetaHandler::KEY_SYNC_FAILED_AT ); + // TODO: After completing the migration to the new sync mechanism, we can remove ProductMetaHandler::KEY_SYNC_STATUS since it's no longer in use. $exclude_meta[] = $this->prefix_meta_key( ProductMetaHandler::KEY_SYNC_STATUS ); $exclude_meta[] = $this->prefix_meta_key( ProductMetaHandler::KEY_MC_STATUS ); diff --git a/tests/Unit/Product/ProductRepositoryTest.php b/tests/Unit/Product/ProductRepositoryTest.php index 05759d25cd..ba7918f114 100644 --- a/tests/Unit/Product/ProductRepositoryTest.php +++ b/tests/Unit/Product/ProductRepositoryTest.php @@ -12,6 +12,7 @@ use Automattic\WooCommerce\GoogleListingsAndAds\Tests\Tools\HelperTrait\ProductTrait; use Automattic\WooCommerce\GoogleListingsAndAds\Value\ChannelVisibility; use Automattic\WooCommerce\GoogleListingsAndAds\Value\SyncStatus; +use Automattic\WooCommerce\GoogleListingsAndAds\Value\MCStatus; use WC_Helper_Product; use WC_Product; @@ -261,23 +262,28 @@ public function test_find_expiring_product_ids() { public function test_find_mc_not_synced_product_ids() { $product_1 = WC_Helper_Product::create_simple_product(); $this->product_helper->mark_as_synced( $product_1, $this->generate_google_product_mock() ); + $this->product_meta->update_mc_status( $product_1, MCStatus::APPROVED ); $product_2 = WC_Helper_Product::create_simple_product(); $this->product_meta->update_sync_status( $product_2, SyncStatus::HAS_ERRORS ); $this->product_meta->update_visibility( $product_2, ChannelVisibility::SYNC_AND_SHOW ); + $this->product_meta->update_mc_status( $product_2, MCStatus::NOT_SYNCED ); $product_3 = WC_Helper_Product::create_simple_product(); $this->product_meta->update_sync_status( $product_3, SyncStatus::HAS_ERRORS ); $this->product_meta->update_visibility( $product_3, ChannelVisibility::DONT_SYNC_AND_SHOW ); + $this->product_meta->update_mc_status( $product_3, MCStatus::NOT_SYNCED ); WC_Helper_Product::create_simple_product(); $variable_product = WC_Helper_Product::create_variation_product(); $this->product_meta->update_sync_status( $variable_product, SyncStatus::NOT_SYNCED ); $this->product_meta->update_visibility( $variable_product, ChannelVisibility::SYNC_AND_SHOW ); + $this->product_meta->update_mc_status( $variable_product, MCStatus::NOT_SYNCED ); foreach ( $variable_product->get_children() as $variation_id ) { $this->product_meta->update_sync_status( wc_get_product( $variation_id ), SyncStatus::NOT_SYNCED ); $this->product_meta->update_visibility( wc_get_product( $variation_id ), ChannelVisibility::SYNC_AND_SHOW ); + $this->product_meta->update_mc_status( wc_get_product( $variation_id ), MCStatus::NOT_SYNCED ); } $this->assertEqualSets(