✏️ 编辑: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; } }
💾 保存
取消
保存为 UTF-8,换行统一为 LF