👁 查看:Stepped_Job.php
路径:/home/forge/kingkrunch.com/wp/wp-content/plugins/woocommerce-square/includes/Sync/Stepped_Job.php
大小:15 KB · 修改:2026-09-30 14:36:42 · 权限:0644 · 可写
<?php
/**
 * WooCommerce Square
 *
 * This source file is subject to the GNU General Public License v3.0
 * that is bundled with this package in the file license.txt.
 * It is also available through the world-wide-web at this URL:
 * http://www.gnu.org/licenses/gpl-3.0.html GNU General Public License v3.0 or later
 * If you did not receive a copy of the license and are unable to
 * obtain it through the world-wide-web, please send an email
 * to license@woocommerce.com so we can send you a copy immediately.
 *
 * DISCLAIMER
 *
 * Do not edit or add to this file if you wish to upgrade WooCommerce Square to newer
 * versions in the future. If you wish to customize WooCommerce Square for your
 * needs please refer to https://docs.woocommerce.com/document/woocommerce-square/
 *
 * @author    WooCommerce
 * @copyright Copyright: (c) 2019, Automattic, Inc.
 * @license   http://www.gnu.org/licenses/gpl-3.0.html GNU General Public License v3.0 or later
 */

namespace WooCommerce\Square\Sync;

defined( 'ABSPATH' ) || exit;

/**
 * Stepped Job abstract.
 *
 * Adds multi-step management to the job class.
 *
 * @since 2.0.0
 */
abstract class Stepped_Job extends Job {


	/** @var int attempts a step makes to verify zero counts before it proceeds without them */
	const MAX_ZERO_VERIFICATION_ATTEMPTS = 3;


	/**
	 * Executes the next step of this job.
	 *
	 * @since 2.0.0
	 *
	 * @return \stdClass the job object
	 */
	public function run() {

		parent::run();

		if ( empty( $this->get_attr( 'next_steps' ) ) && empty( $this->get_attr( 'completed_steps' ) ) ) {
			$this->assign_next_steps();
		}

		$this->do_next_step();

		return $this->job;
	}


	/**
	 * Verifies which zero counts are real, applying this class's shared retry policy when Square
	 * cannot be asked.
	 *
	 * Wraps the two parts every step repeated: verify the zeros, and hold and retry a bounded number
	 * of times when verification is unavailable. Progress markers stay with the caller, because each
	 * step holds something different (a cursor, a watermark, a queue attribute) and flattening that in
	 * here would silently drop work.
	 *
	 * The return says which zeros may be written, and null says the caller must hold its own progress
	 * and return so the step runs again. An empty array is NOT a signal of failure: it also means the
	 * question was asked successfully and none of those zeros is real. A caller that needs to know the
	 * attempts ran out (to keep a watermark from advancing over a window it could not verify) must ask
	 * zero_verification_exhausted() rather than infer it from an empty array.
	 *
	 * @since 5.5.0
	 *
	 * @param string $step_name step name used for the attempt counters and messages
	 * @param string[] $zero_object_ids catalog object ids reporting a zero count
	 * @return string[]|null verified ids, or null when the caller should hold and retry
	 */
	protected function resolve_zero_count_verification( $step_name, array $zero_object_ids ) {

		$verified = Helper::get_catalog_objects_with_inventory_history( $zero_object_ids );

		if ( null !== $verified ) {
			$this->set_attr( 'zero_verification_exhausted_' . $step_name, false );
			$this->clear_unverified_zero_count_attempts( $step_name );
			return $verified;
		}

		if ( $this->should_retry_unverified_zero_counts( $step_name ) ) {
			return null;
		}

		// Attempts spent. Nothing is verified, so no zero may be written, and the caller is told so it
		// can leave any watermark alone: re-reading the same window later is what stops a genuine
		// sellout from being skipped permanently.
		// Persisted immediately rather than relying on a later attribute write to flush it: the
		// watermark guard reads this, and a step that returns before its next set_attr() would
		// otherwise leave the flag only in memory.
		$this->set_attr( 'zero_verification_exhausted_' . $step_name, true );

		return array();
	}


	/**
	 * Whether the last verification attempt for a step ran out of retries without an answer.
	 *
	 * Steps that advance a watermark must not move it over a window whose zero counts they could not
	 * verify, or a genuine sellout in that window is skipped for good. An empty verified list cannot
	 * answer this, since it also means "asked, and none of them were real".
	 *
	 * @since 5.5.0
	 *
	 * @param string $step_name step name used for the attempt counters
	 * @return bool
	 */
	protected function zero_verification_exhausted( $step_name ) {

		return (bool) $this->get_attr( 'zero_verification_exhausted_' . $step_name, false );
	}


	/**
	 * Decides how a step should react when Square's inventory history could not be read.
	 *
	 * A zero count cannot be classified as a real sellout or as a never counted item without that
	 * history, and writing it blind is the behavior SQUARE-145 fixed. The step therefore holds its
	 * progress and is run again by the job loop. Those retries follow the job's own loopback timing,
	 * so they are close together rather than spread over cycles: they cover a brief blip, not a long
	 * outage. Holding forever would keep a job alive without ever finishing, so after a bounded
	 * number of attempts the step proceeds with no zero verified, which skips only the zero writes,
	 * and records an alert so the outcome is never silent. Steps that own a watermark should also
	 * leave it alone on that path so the same window is read again later.
	 *
	 * @since 5.5.0
	 *
	 * @param string $step_name step name for the log and record messages
	 * @return bool true when the caller should stop and retry later, false when it should proceed
	 */
	protected function should_retry_unverified_zero_counts( $step_name ) {

		$attr     = 'zero_verification_attempts_' . $step_name;
		$attempts = (int) $this->get_attr( $attr, 0 );

		if ( $attempts < self::MAX_ZERO_VERIFICATION_ATTEMPTS ) {

			$this->set_attr( $attr, $attempts + 1 );

			wc_square()->log( sprintf( 'Could not verify zero inventory counts during %1$s; holding this step and running it again (attempt %2$d of %3$d).', $step_name, $attempts + 1, self::MAX_ZERO_VERIFICATION_ATTEMPTS ) );

			return true;
		}

		$this->set_attr( $attr, 0 );

		// One alert per job AND at most one per window across jobs: interval polling creates a fresh
		// job every cycle, so a job attribute alone would still add a record on every poll during a
		// sustained outage and push other records out of the capped list.
		if ( ! $this->get_attr( 'zero_verification_alert_recorded', false ) && ! get_transient( 'wc_square_zero_verification_alerted' ) ) {

			$this->set_attr( 'zero_verification_alert_recorded', true );
			set_transient( 'wc_square_zero_verification_alerted', 1, 6 * HOUR_IN_SECONDS );

			Records::set_record(
				array(
					'type'    => 'alert',
					'message' => esc_html__( 'Square could not confirm which products are genuinely sold out, so their stock was left unchanged and the sync continued. Run the sync again once Square is responding normally.', 'woocommerce-square' ),
				)
			);
		}

		wc_square()->log( sprintf( 'Could not verify zero inventory counts during %s after %d attempts; continuing without writing any zero quantity.', $step_name, self::MAX_ZERO_VERIFICATION_ATTEMPTS ) );

		return false;
	}


	/**
	 * Clears the unverified zero count retry counter after a successful verification.
	 *
	 * @since 5.5.0
	 */
	protected function clear_unverified_zero_count_attempts( $step_name ) {

		$attr = 'zero_verification_attempts_' . $step_name;

		if ( $this->get_attr( $attr, 0 ) ) {
			$this->set_attr( $attr, 0 );
		}
	}


	/**
	 * Assigns the next steps needed for this sync job.
	 *
	 * Adds the next steps to the 'next_steps' attribute.
	 *
	 * @since 2.0.0
	 */
	abstract protected function assign_next_steps();


	/**
	 * Gets the next step in the sync process.
	 *
	 * @since 2.0.0
	 *
	 * @return string|null
	 */
	protected function get_next_step() {

		$next_steps = $this->get_next_steps();

		return isset( $next_steps[0] ) ? $next_steps[0] : null;
	}


	/**
	 * Gets the next steps for the sync process.
	 *
	 * @since 2.0.0
	 *
	 * @return string[]
	 */
	protected function get_next_steps() {

		return $this->get_attr( 'next_steps' );
	}


	/**
	 * Performs the next step in the sync process.
	 *
	 * @since 2.0.0
	 */
	protected function do_next_step() {
		$max_retry = 3;  // Maximum number of retries for rate limit errors.
		$retry     = $this->get_attr( 'retry', 0 ); // Number of retries for rate limit errors.

		$next_step = $this->get_next_step();

		if ( is_callable( array( $this, $next_step ) ) ) {

			$this->start_step_cycle( $next_step );

			try {

				$this->$next_step();
				$this->complete_step_cycle( $next_step );
				$this->set_attr( 'retry', 0 ); // Reset retry count to 0 after successful step cycle.
			} catch ( \Exception $exception ) {
				$error_message = $exception->getMessage();
				// If sync fail with rate limit error, retry the sync process after few seconds. (retry upto 3 times)
				if ( false !== strpos( $error_message, 'RATE_LIMITED' ) && $retry < $max_retry ) {
					wc_square()->log( 'Rate limit error detected, pausing sync process for few secs...' );
					$this->set_attr( 'retry', $retry + 1 );
					return;
				}

				$this->complete_step_cycle( $next_step, false, $exception->getMessage() );
				$this->fail( $exception->getMessage() );
				return;
			}
		}

		if ( ! $this->get_next_step() ) {

			$this->complete();
		}
	}


	/**
	 * Records the beginning of a new step cycle, meaning a new loop on the job for a given step.
	 *
	 * @since 2.0.0
	 *
	 * @param string $step_name the step name
	 */
	protected function start_step_cycle( $step_name ) {

		$current_step_cycle = array(
			'step_name'  => $step_name,
			'start_time' => microtime( true ),
		);

		wc_square()->log( "Starting step cycle: $step_name" );

		$this->set_attr( 'current_step_cycle', $current_step_cycle );
	}


	/**
	 * Records the completion of a step cycle.
	 *
	 * @since 2.0.0
	 *
	 * @param string $step_name the step name
	 * @param bool $is_successful (optional) whether the step completion is from a success or not
	 * @param string $error_message (optional) error message to include with failed step log
	 */
	protected function complete_step_cycle( $step_name, $is_successful = true, $error_message = '' ) {

		$current_step_cycle = $this->get_attr( 'current_step_cycle', array() );

		if ( ! empty( $current_step_cycle ) ) {

			$current_step_cycle['end_time'] = microtime( true );
			$current_step_cycle['runtime']  = number_format( $current_step_cycle['end_time'] - $current_step_cycle['start_time'], 2 ) . 's';
			$current_step_cycle['success']  = true === $is_successful;

			if ( true === $is_successful ) {

				wc_square()->log( "Completed step cycle: $step_name ({$current_step_cycle['runtime']})" );

			} else {

				wc_square()->log( "Failed step cycle: $step_name ({$current_step_cycle['runtime']}) - $error_message" );
			}

			$completed_cycles   = $this->get_attr( 'completed_step_cycles', array() );
			$completed_cycles[] = $current_step_cycle;
			$this->set_attr( 'completed_step_cycles', $completed_cycles );
		}
	}


	/**
	 * Completes the specified step (if it's the next step).
	 *
	 * @since 2.0.0
	 *
	 * @param string $step_name
	 */
	protected function complete_step( $step_name ) {

		$next_steps = $this->get_next_steps();

		if ( isset( $next_steps[0] ) && $step_name === $next_steps[0] ) {

			$this->add_completed_step( $step_name );
			array_shift( $next_steps );
			$this->set_attr( 'next_steps', $next_steps );
		}
	}


	/**
	 * Adds a step to the completed steps array.
	 *
	 * @since 2.0.0
	 *
	 * @param string $step_name
	 */
	protected function add_completed_step( $step_name ) {

		if ( empty( $step_name ) ) {
			return;
		}

		$completed_steps = $this->get_attr( 'completed_steps', array() );

		$completed_steps[] = array(
			'name'            => $step_name,
			'completion_time' => current_time( 'mysql' ),
		);

		$this->set_attr( 'completed_steps', $completed_steps );

		$update_data = $this->get_step_update_data( $step_name );

		wc_square()->log( 'Completed job step: ' . $step_name . $update_data );
	}

	/**
	 * Get step update data like count of synced products or categories.
	 *
	 * @param string $step_name Step name.
	 * @return string
	 */
	protected function get_step_update_data( $step_name ) {
		$update_data = '';
		$count       = $this->get_attr( $step_name . '_count', 0 );
		switch ( $step_name ) {
			// Product Import.
			case 'import_products':
				$imported    = count( $this->get_attr( 'processed_product_ids', array() ) );
				$updated     = count( $this->get_attr( 'updated_product_ids', array() ) );
				$skipped     = count( $this->get_attr( 'skipped_products', array() ) );
				$update_data = sprintf( ' (Imported products: %d, Updated products: %d, Skipped products: %d)', $imported, $updated, $skipped );
				break;

			case 'import_inventory':
				$update_data = sprintf( ' (Synced products: %d)', $count );
				break;

			// Manual Sync.
			case 'validate_products':
				$count       = count( $this->get_attr( 'validated_product_ids', array() ) );
				$update_data = sprintf( ' (Validated products: %d)', $count );
				break;

			case 'extract_category_ids':
				$count       = count( $this->get_attr( 'category_ids', array() ) );
				$update_data = sprintf( ' (Extracted categories: %d)', $count );
				break;

			case 'refresh_category_mappings':
				$mapped_cat   = count( $this->get_attr( 'mapped_categories', array() ) );
				$unmapped_cat = count( $this->get_attr( 'unmapped_categories', array() ) );
				$update_data  = sprintf( ' (Mapped categories: %d, Unmapped categories: %d)', $mapped_cat, $unmapped_cat );
				break;

			case 'query_unmapped_categories':
				$mapped_cat  = count( $this->get_attr( 'mapped_categories', array() ) );
				$update_data = sprintf( ' (Total mapped categories: %d)', $mapped_cat );
				break;

			case 'upsert_categories':
				$count       = count( $this->get_attr( 'category_ids', array() ) );
				$update_data = sprintf( ' (Upserted categories: %d)', $count );
				break;

			case 'update_matched_products':
				$count       = count( $this->get_attr( 'processed_product_ids', array() ) );
				$update_data = sprintf( ' (Synced matched products: %d)', $count );
				break;

			case 'search_matched_products':
			case 'square_sor_sync':
				$count       = count( $this->get_attr( 'processed_product_ids', array() ) );
				$update_data = sprintf( ' (Synced products: %d)', $count );
				break;

			case 'upsert_new_products':
				$count       = count( $this->get_attr( 'inventory_push_product_ids', array() ) );
				$update_data = sprintf( ' (Newly upserted products: %d)', $count );
				break;

			case 'push_inventory':
				$update_data = sprintf( ' (Synced products: %d)', $count );
				break;

			case 'pull_inventory':
				$count       = count( $this->get_attr( 'processed_square_variation_ids', array() ) );
				$update_data = sprintf( ' (Synced products: %d)', $count );
				break;

			// Interval Polling.
			case 'update_category_data':
				$update_data = sprintf( ' (Updated categories: %d)', $count );
				break;

			case 'update_product_data':
				$update_data = sprintf( ' (Updated products: %d)', $count );
				break;

			case 'update_inventory_counts':
				$update_data = sprintf( ' (Synced products: %d)', $count );
				break;

			default:
				break;
		}

		return $update_data;
	}
}