👁 查看:Helper.php
路径:/home/forge/kingkrunch.com/wp/wp-content/plugins/woocommerce-square/includes/Sync/Helper.php
大小:18.3 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;

use Square\Models\BatchRetrieveCatalogObjectsResponse;
use Square\Models\BatchRetrieveInventoryCountsResponse;

defined( 'ABSPATH' ) || exit;

/**
 * Square Sync Helper Class
 *
 * The purpose of this class is to centralize common sync utility functions.
 *
 * @since 3.8.2
 */
class Helper {


	/**
	 * Maximum group narrowing passes before falling back to one request per unresolved id.
	 *
	 * Bounds the worst case: without it a page that resolves a single id each time would issue one
	 * group request per id on top of the per id fallback.
	 *
	 * @since 5.5.0
	 * @var int
	 */
	const MAX_HISTORY_NARROWING_PASSES = 5;

	/**
	 * Get the inventory tracking value for the given catalog object ids.
	 *
	 * @param array $catalog_object_ids The catalog object ids.
	 * @return array Array of inventory tracking for given catalog object ids.
	 */
	public static function get_catalog_objects_inventory_stats( $catalog_object_ids ) {
		if ( empty( $catalog_object_ids ) ) {
			return array();
		}

		$response = wc_square()->get_api()->batch_retrieve_inventory_counts(
			array(
				'catalog_object_ids' => $catalog_object_ids,
				'location_ids'       => array( wc_square()->get_settings_handler()->get_location_id() ),
				'states'             => array( 'IN_STOCK' ), // Get only in stock counts.
			)
		);

		if ( ! $response->get_data() instanceof BatchRetrieveInventoryCountsResponse ) {
			throw new \Exception( 'Response data missing or invalid' );
		}

		$inventory_hash = array();
		foreach ( $response->get_counts() as $inventory_count ) {
			$inventory_hash[ $inventory_count->getCatalogObjectId() ] = $inventory_count->getQuantity();
		}

		return $inventory_hash;
	}

	/**
	 * Get the inventory tracking value for the given catalog object ids.
	 *
	 * @param array $catalog_object_ids The catalog object ids.
	 * @return array Array of inventory tracking for given catalog object ids.
	 */
	public static function get_catalog_objects_tracking_stats( $catalog_object_ids ) {
		if ( empty( $catalog_object_ids ) ) {
			return array();
		}

		$catalog_response = wc_square()->get_api()->batch_retrieve_catalog_objects( $catalog_object_ids );
		if ( ! $catalog_response->get_data() instanceof BatchRetrieveCatalogObjectsResponse ) {
			throw new \Exception( 'Response data is missing' );
		}

		$objects = $catalog_response->get_data()->getObjects() ? $catalog_response->get_data()->getObjects() : array();

		return self::get_catalog_inventory_tracking( $objects );
	}

	/**
	 * Collects the catalog object ids whose count is zero.
	 *
	 * Callers hold counts in two shapes: a plain id to quantity map, and an id to stats map where the
	 * quantity sits under a key. Both are accepted so the zero collection is written once.
	 *
	 * @since 5.5.0
	 *
	 * @param array $counts id keyed counts, values either a quantity or an array of stats
	 * @param string|null $quantity_key key holding the quantity when values are arrays
	 * @return string[] ids whose count is exactly zero
	 */
	public static function zero_count_object_ids( array $counts, $quantity_key = null ) {

		$zero_object_ids = array();

		foreach ( $counts as $object_id => $value ) {

			if ( null !== $quantity_key ) {
				if ( ! is_array( $value ) || ! isset( $value[ $quantity_key ] ) ) {
					continue;
				}
				$value = $value[ $quantity_key ];
			}

			if ( 0.0 === (float) $value ) {
				$zero_object_ids[] = $object_id;
			}
		}

		return $zero_object_ids;
	}


	/**
	 * Returns the subset of catalog object IDs that have real inventory history in Square.
	 *
	 * A tracked item that has never had a count recorded reports an IN_STOCK count of 0 that is
	 * indistinguishable from a genuine sellout by state alone. Square's inventory change history is
	 * the reliable discriminator: a never-counted item has an empty history, while any real count or
	 * sale leaves a PHYSICAL_COUNT / ADJUSTMENT record. Callers use this to decide whether a zero
	 * count may be written to WooCommerce (real) or must be ignored (phantom).
	 *
	 * On an API failure this returns null, which callers MUST treat as "verification unavailable":
	 * never write the zero, and never advance a watermark or processed marker past the item, or a
	 * genuine sellout would be permanently skipped once the API recovers. Null is distinct from an
	 * empty array, which is a POSITIVE verification that none of the ids have history.
	 *
	 * @since 5.5.0
	 *
	 * @param string[] $catalog_object_ids catalog object (variation) IDs to check
	 * @return string[]|null IDs with at least one real inventory change, or null when Square could not be asked
	 */
	public static function get_catalog_objects_with_inventory_history( $catalog_object_ids ) {

		$catalog_object_ids = array_values( array_filter( (array) $catalog_object_ids ) );

		if ( empty( $catalog_object_ids ) ) {
			return array();
		}

		$with_history = array();

		try {
			foreach ( array_chunk( $catalog_object_ids, 100 ) as $chunk ) {

				// First page for the whole chunk. When Square reports no further pages, every id
				// missing from it is positively verified as having no history. When more pages
				// exist, walking them all would page through the busiest item's entire history
				// just to prove a quiet item empty, so unresolved ids are re-queried individually
				// instead (a single id with no records answers in one page).
				$page = static::fetch_inventory_changes_page( $chunk );
				if ( null === $page ) {
					return null;
				}

				foreach ( $page['object_ids'] as $object_id ) {
					$with_history[ $object_id ] = true;
				}

				$unresolved = array_diff( $chunk, array_keys( $with_history ) );

				if ( empty( $unresolved ) || empty( $page['cursor'] ) ) {
					continue;
				}

				// Re-ask for the unresolved ids as a group before falling back to one request each.
				// A shared page holds the OLDEST changes across every id in it, so one busy id can
				// fill the page and hide a quiet id's record; asking again with the busy ids removed
				// usually settles the whole remainder in a single request, and settles it positively
				// when the response comes back empty with no further pages.
				$remaining = array_values( $unresolved );
				$passes    = 0;

				while ( ! empty( $remaining ) && $passes < self::MAX_HISTORY_NARROWING_PASSES ) {

					++$passes;

					$group = static::fetch_inventory_changes_page( $remaining );
					if ( null === $group ) {
						return null;
					}

					$before = count( $remaining );

					foreach ( $group['object_ids'] as $object_id ) {
						$with_history[ $object_id ] = true;
					}

					$remaining = array_values( array_diff( $remaining, array_keys( $with_history ) ) );

					// No further pages, so every id still remaining has no history at all. That is a
					// positive verification, not an unknown, and the loop is done.
					if ( empty( $group['cursor'] ) ) {
						$remaining = array();
						break;
					}

					// More pages exist but this one named none of the remaining ids, so narrowing has
					// stalled and another group request would return the same page.
					if ( count( $remaining ) === $before ) {
						break;
					}
				}

				// Anything still unresolved is settled one id at a time, where an empty first page is
				// conclusive: pagination is oldest first, so a single id with any record shows it there.
				foreach ( $remaining as $object_id ) {
					$single = static::fetch_inventory_changes_page( array( $object_id ) );
					if ( null === $single ) {
						return null;
					}
					// Membership, not emptiness: a response is only proof for the id it actually names.
					if ( in_array( $object_id, $single['object_ids'], true ) ) {
						$with_history[ $object_id ] = true;
					}
				}
			}
		} catch ( \Exception $exception ) {
			wc_square()->log( 'Could not verify inventory history for zero counts: ' . $exception->getMessage() );
			return null;
		}

		return array_keys( $with_history );
	}

	/**
	 * Fetches one page of inventory changes for the given catalog object ids.
	 *
	 * @since 5.5.0
	 *
	 * @param string[] $catalog_object_ids ids to query
	 * @return array|null { object_ids: string[] with a change on this page, cursor: string|null } or null on failure
	 */
	protected static function fetch_inventory_changes_page( array $catalog_object_ids ) {

		$response = wc_square()->get_api()->batch_retrieve_inventory_changes(
			array(
				'catalog_object_ids' => $catalog_object_ids,
				'location_ids'       => array( wc_square()->get_settings_handler()->get_location_id() ),
			)
		);

		$data = $response->get_data();

		if ( ! $data instanceof \Square\Models\BatchRetrieveInventoryChangesResponse ) {
			wc_square()->log( 'Could not verify inventory history for zero counts: unexpected API response.' );
			return null;
		}

		$object_ids = array();

		foreach ( is_array( $data->getChanges() ) ? $data->getChanges() : array() as $change ) {
			$object_id = null;

			if ( $change->getPhysicalCount() ) {
				$object_id = $change->getPhysicalCount()->getCatalogObjectId();
			} elseif ( $change->getAdjustment() ) {
				$object_id = $change->getAdjustment()->getCatalogObjectId();
			} elseif ( $change->getTransfer() ) {
				$object_id = $change->getTransfer()->getCatalogObjectId();
			}

			if ( $object_id ) {
				$object_ids[ $object_id ] = true;
			}
		}

		$returned = array_keys( $object_ids );

		// Square honors catalog_object_ids only while at least one of the supplied ids exists. When
		// none of them exists it silently drops the filter and answers with the whole location's
		// change history, so a response naming ids we did not ask about means exactly one thing:
		// none of the ids in this request exist in Square any more (a stale local mapping).
		//
		// That is a conclusive answer, not an unknown. A catalog object that does not exist cannot
		// have sold out, so report no history and no further pages: the caller then treats these ids
		// as unverified zeros and leaves the products alone. Without this check the per id branch
		// below would read the unrelated history as proof and write the zero, which is the very bug
		// SQUARE-145 fixes, for exactly the products whose mappings are stale.
		$unexpected = array_diff( $returned, $catalog_object_ids );

		if ( ! empty( $unexpected ) ) {

			wc_square()->log(
				sprintf(
					'Square ignored the catalog object filter for %1$d id(s), which means none of them exist there any more; treating them as having no inventory history. First id: %2$s',
					count( $catalog_object_ids ),
					reset( $catalog_object_ids )
				)
			);

			return array(
				'object_ids' => array(),
				'cursor'     => null,
			);
		}

		return array(
			'object_ids' => $returned,
			'cursor'     => $data->getCursor(),
		);
	}


	/**
	 * Applies a Square IN_STOCK count to a WooCommerce product using the sync write policy.
	 *
	 * Policy (SQUARE-145 / SQUARE-359):
	 * - A positive count is trusted (a phantom is always zero): write the quantity and keep the
	 *   existing behavior of enabling stock management to mirror Square tracking.
	 * - A zero count never changes manage_stock. For a stock-managed product it is written only
	 *   when Square's change history proves a real count was ever recorded ($zero_verified);
	 *   a phantom zero from a never-counted item is skipped. For a product that does not manage
	 *   stock, counts are ignored entirely and only the stock status is reflected.
	 *
	 * @since 5.5.0
	 *
	 * @param \WC_Product $product the WooCommerce product or variation
	 * @param float $quantity the IN_STOCK quantity reported by Square
	 * @param bool $sold_out whether Square reports the item as sold out at the configured location
	 * @param bool $zero_verified whether a zero count is backed by real inventory history
	 * @return bool whether the product was modified (caller is responsible for saving)
	 */
	public static function apply_square_inventory_count( \WC_Product $product, $quantity, $sold_out, $zero_verified ) {

		$quantity = (float) $quantity;

		// A variation inheriting stock management reports the string 'parent': its quantity is
		// governed by the parent's pooled stock, so a quantity written to it is invisible until the
		// variation manages its own stock, and a stock status write is overridden by the pool.
		//
		// Who owns that decision depends on the system of record. Under WooCommerce SOR the pool is
		// merchant intent, so a per-variation Square count is not applicable data and is skipped.
		// Under Square SOR the authority is reversed: a positive count is applied and the variation
		// takes over its own stock, which is what the plugin did before this changeset.
		//
		// A zero or negative count is skipped in both modes. Those are the counts that wiped stock
		// (SQUARE-145), and writing one into a pool would move stock shared with sibling variations
		// on the strength of a single variation's reading.
		if ( 'parent' === $product->get_manage_stock() ) {

			$square_is_system_of_record = wc_square()->get_settings_handler()->is_system_of_record_square();

			if ( ! $square_is_system_of_record || $quantity <= 0 ) {
				wc_square()->log(
					sprintf(
						'Skipped writing a stock quantity to variation #%1$d: its stock is managed by the parent product pool%2$s.',
						$product->get_id(),
						$square_is_system_of_record ? ' and the count was not positive' : ''
					)
				);

				return false;
			}

			wc_square()->log(
				sprintf(
					'Variation #%1$d inherits parent stock, but Square is the system of record and reports %2$s in stock, so the variation now manages its own stock.',
					$product->get_id(),
					$quantity
				)
			);
		}

		if ( $quantity > 0 ) {
			$product->set_stock_quantity( $quantity );
			$product->set_manage_stock( true );

			return true;
		}

		// A negative count can only come from real inventory movement, because an item that was
		// never counted reads exactly zero, so it needs no history check. Square itself cannot hold
		// a negative quantity (which is why the push side clamps at zero) but WooCommerce can, and a
		// store that allows backorders uses it to record how deep it is oversold, so the value is
		// written through rather than flattened. manage_stock is still left alone: only a positive
		// count mirrors Square tracking onto that setting.
		if ( $quantity < 0 ) {

			if ( ! $product->get_manage_stock() ) {
				$product->set_stock_status( 'outofstock' );

				return true;
			}

			$product->set_stock_quantity( $quantity );

			return true;
		}

		// Zero count: never change the product's manage_stock setting in either direction.

		if ( ! $product->get_manage_stock() ) {

			// Not stock-managed in WooCommerce, so a quantity is never written. A zero still has to
			// stop the product selling, but only when it is a proven sellout: an unproven zero (a
			// tracked item that was never counted) must not mark a product the merchant keeps
			// permanently sellable as out of stock. A zero is also never a reason to force a
			// product back in stock, so this branch only ever writes out of stock.
			if ( $zero_verified ) {
				$product->set_stock_status( 'outofstock' );

				return true;
			}

			wc_square()->log(
				sprintf(
					'Skipped marking product #%d out of stock: Square has no inventory history for the item, so its zero count is not a proven sellout.',
					$product->get_id()
				)
			);

			return false;
		}

		if ( $zero_verified ) {
			$product->set_stock_quantity( 0 );

			return true;
		}

		wc_square()->log(
			sprintf(
				'Skipped writing a zero stock quantity to product #%d: Square has no inventory history for the item (phantom zero from an uncounted catalog object).',
				$product->get_id()
			)
		);

		return false;
	}


	/**
	 * Get the inventory tracking value for the given catalog objects.
	 *
	 * @param \Square\Models\CatalogObject[] $catalog_objects The catalog objects.
	 * @return array Array of inventory tracking for given catalog objects.
	 */
	public static function get_catalog_inventory_tracking( $catalog_objects ) {
		$catalog_objects_tracking = array();

		/** @var \Square\Models\CatalogObject $catalog_object */
		foreach ( $catalog_objects as $catalog_object ) {
			$variation_data      = $catalog_object->getItemVariationData();
			$location_overrides  = $variation_data->getLocationOverrides();
			$configured_location = wc_square()->get_settings_handler()->get_location_id();

			$default_data = array(
				'track_inventory' => $variation_data->getTrackInventory(),
				'sold_out'        => false,
			);

			if ( ! empty( $location_overrides ) ) {
				$location_ids = array_map(
					function ( $location_override ) {
						return $location_override->getLocationId();
					},
					$location_overrides
				);

				if ( ! in_array( $configured_location, $location_ids, true ) ) {
					$catalog_objects_tracking[ $catalog_object->getId() ] = $default_data;
					continue;
				}

				foreach ( $location_overrides as $location_override ) {
					$location_id = $location_override->getLocationId();

					if ( $configured_location === $location_id ) {
						$sold_out = $location_override->getSoldOut() ?? false;
						if ( ! is_null( $location_override->getTrackInventory() ) ) {
							$catalog_objects_tracking[ $catalog_object->getId() ] = array(
								'track_inventory' => $location_override->getTrackInventory(),
								'sold_out'        => $sold_out,
							);
						} else {
							$catalog_objects_tracking[ $catalog_object->getId() ] = array(
								'track_inventory' => $variation_data->getTrackInventory(),
								'sold_out'        => $sold_out,
							);
						}
					}
				}
			} else {
				$catalog_objects_tracking[ $catalog_object->getId() ] = $default_data;
			}
		}

		return $catalog_objects_tracking;
	}
}