eligibility_service = $eligibility_service; $this->job_manager = $job_manager; $this->cycle_state_service = $cycle_state_service; $this->email_manager = $email_manager; } /** * Constructor. */ public function __construct() { $this->logger = \wc_get_logger(); add_action( JobManager::AS_JOB_SEND_STOCK_NOTIFICATIONS, array( $this, 'process_batch' ) ); } /** * Get the batch size for processing notifications. * * @return int */ private function get_batch_size(): int { /** * Filter: woocommerce_customer_stock_notifications_batch_size * * @since 10.2.0 * * Allow customization of batch size for processing notifications. * * @param int $batch_size Default batch size. * @return int */ return (int) apply_filters( 'woocommerce_customer_stock_notifications_batch_size', self::BATCH_SIZE ); } /** * Parse the product ID from the arguments. * * @param int $product_id The product ID. * @return int * @throws \Exception If the product is not found. */ private function parse_args( $product_id ): int { if ( empty( $product_id ) || ! is_numeric( $product_id ) ) { throw new \Exception( 'Invalid arguments.' ); } $product_id = (int) $product_id; if ( $product_id <= 0 ) { throw new \Exception( 'Product ID is required.' ); } return $product_id; } /** * Parse the product. * * @param int $product_id The product ID. * @return \WC_Product * @throws \Exception If the product is not found. * @throws ExpectedSkipException If the product or its stock status is no longer eligible, which is an expected outcome. */ private function parse_product( int $product_id ): WC_Product { $product = wc_get_product( $product_id ); if ( ! $product instanceof WC_Product ) { throw new \Exception( sprintf( 'Product %d not found.', absint( $product_id ) ) ); } if ( ! $this->eligibility_service->is_product_eligible( $product ) ) { throw new ExpectedSkipException( sprintf( 'Product %d is not eligible for notifications.', $product->get_id() ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped } if ( ! $this->eligibility_service->is_stock_status_eligible( $product->get_stock_status() ) ) { throw new ExpectedSkipException( sprintf( 'Product %d stock status is not eligible for notifications (i.e. not in stock).', $product->get_id() ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped } return $product; } /** * Process a batch of notifications. * * @param int $product_id The product ID from AS job args. * @return void */ public function process_batch( $product_id ) { // Sanity checks. try { $product_id = $this->parse_args( $product_id ); $cycle_state = $this->cycle_state_service->get_or_initialize_cycle_state( $product_id ); $product = $this->parse_product( $product_id ); } catch ( \Throwable $e ) { $product_id = (int) $product_id; // An ineligible product or stock status is a normal outcome (e.g. stock went back out before the job ran). $level = $e instanceof ExpectedSkipException ? \WC_Log_Levels::INFO : \WC_Log_Levels::ERROR; $this->logger->log( $level, sprintf( 'Background process for product %s terminated. Reason: %s', $product_id, $e->getMessage() ), array( 'source' => 'wc-customer-stock-notifications', 'product_id' => $product_id, 'exception' => get_class( $e ), ) ); // Clean up the cycle state. if ( isset( $cycle_state ) ) { $this->cycle_state_service->complete_cycle( $product_id, $cycle_state ); } return; } $cycle_state['product_ids'] = $this->eligibility_service->get_target_product_ids( $product ); // Get notifications. $notifications = NotificationQuery::get_notifications( array( 'status' => NotificationStatus::ACTIVE, 'product_id' => $cycle_state['product_ids'], 'last_attempt_limit' => (int) $cycle_state['cycle_start_time'], 'return' => 'ids', 'limit' => $this->get_batch_size(), 'orderby' => 'id', 'order' => 'ASC', ) ); if ( empty( $notifications ) ) { $this->cycle_state_service->complete_cycle( $product_id, $cycle_state ); return; } foreach ( $notifications as $notification_id ) { $notification = Factory::get_notification( $notification_id ); if ( ! $notification instanceof Notification ) { $this->logger->error( sprintf( 'Failed to get notification ID: %d', $notification_id ), array( 'source' => 'wc-customer-stock-notifications' ) ); continue; } $notification->set_date_last_attempt( time() ); ++$cycle_state['total_count']; if ( $this->eligibility_service->should_skip_notification( $notification, $product ) ) { ++$cycle_state['skipped_count']; $notification->save(); continue; } $is_sent = true; try { $this->email_manager->send_stock_notification_email( $notification ); } catch ( \Throwable $e ) { $is_sent = false; } if ( $is_sent ) { $notification->set_date_notified( time() ); $notification->set_status( NotificationStatus::SENT ); ++$cycle_state['sent_count']; } else { $notification->set_status( NotificationStatus::CANCELLED ); $notification->set_cancellation_source( NotificationCancellationSource::SYSTEM ); ++$cycle_state['failed_count']; } // Always save the notification to reflect last attempt time. $notification->save(); } if ( count( $notifications ) === $this->get_batch_size() ) { $this->cycle_state_service->save_cycle_state( $product_id, $cycle_state ); $this->job_manager->schedule_next_batch_for_product( $product_id ); return; } $this->cycle_state_service->complete_cycle( $product_id, $cycle_state ); } }