PluginProbe ʕ •ᴥ•ʔ
WooCommerce Square / 5.5.0
WooCommerce Square v5.5.0
5.5.0 5.4.3 5.4.2 5.4.1 5.4.0 trunk 1.0.25 1.0.26 1.0.27 1.0.28 1.0.29 1.0.30 1.0.31 1.0.32 1.0.33 1.0.34 1.0.35 1.0.36 1.0.37 1.0.38 2.0.0 2.0.1 2.0.2 2.0.3 2.0.4 2.0.5 2.0.6 2.0.7 2.0.8 2.1.0 2.1.1 2.1.2 2.1.3 2.1.4 2.1.5 2.1.6 2.2.0 2.2.1 2.2.2 2.2.3 2.2.4 2.2.5 2.3.0 2.3.1 2.3.2 2.3.3 2.3.4 2.4.0 2.4.1 2.5.0 2.5.1 2.5.2 2.5.3 2.6.0 2.7.0 2.8.0 2.9.0 2.9.1 3.0.0 3.0.1 3.0.2 3.0.3 3.1.0 3.2.0 3.3.0 3.4.0 3.4.1 3.4.2 3.5.0 3.6.0 3.6.1 3.7.0 3.7.1 3.8.0 3.8.1 3.8.2 3.8.3 3.9.0 4.0.0 4.1.0 4.2.0 4.2.1 4.2.2 4.2.3 4.3.0 4.3.1 4.3.2 4.4.0 4.4.1 4.4.2 4.5.0 4.5.1 4.5.2 4.6.0 4.6.1 4.6.2 4.6.3 4.6.4 4.7.0 4.7.1 4.7.2 4.7.3 4.7.4 4.8.0 4.8.1 4.8.2 4.8.3 4.8.4 4.8.5 4.8.6 4.8.7 4.8.8 4.9.0 4.9.1 4.9.2 4.9.3 4.9.4 4.9.5 4.9.6 4.9.7 4.9.8 4.9.9 5.0.0 5.0.1 5.1.0 5.1.1 5.1.2 5.2.0 5.3.0 5.3.1 5.3.2 5.3.3
woocommerce-square / includes / Sync / Helper.php
woocommerce-square / includes / Sync Last commit date
Records 3 months ago Catalog_Item.php 9 months ago Helper.php 2 days ago Interval_Polling.php 2 days ago Job.php 2 years ago Manual_Synchronization.php 2 days ago Order_Importer.php 11 months ago Order_Mapper.php 11 months ago Order_Polling.php 11 months ago Product_Import.php 2 days ago Records.php 2 years ago Stepped_Job.php 2 days ago
Helper.php
516 lines
1 <?php
2 /**
3 * WooCommerce Square
4 *
5 * This source file is subject to the GNU General Public License v3.0
6 * that is bundled with this package in the file license.txt.
7 * It is also available through the world-wide-web at this URL:
8 * http://www.gnu.org/licenses/gpl-3.0.html GNU General Public License v3.0 or later
9 * If you did not receive a copy of the license and are unable to
10 * obtain it through the world-wide-web, please send an email
11 * to license@woocommerce.com so we can send you a copy immediately.
12 *
13 * DISCLAIMER
14 *
15 * Do not edit or add to this file if you wish to upgrade WooCommerce Square to newer
16 * versions in the future. If you wish to customize WooCommerce Square for your
17 * needs please refer to https://docs.woocommerce.com/document/woocommerce-square/
18 *
19 * @author WooCommerce
20 * @copyright Copyright: (c) 2019, Automattic, Inc.
21 * @license http://www.gnu.org/licenses/gpl-3.0.html GNU General Public License v3.0 or later
22 */
23
24 namespace WooCommerce\Square\Sync;
25
26 use Square\Models\BatchRetrieveCatalogObjectsResponse;
27 use Square\Models\BatchRetrieveInventoryCountsResponse;
28
29 defined( 'ABSPATH' ) || exit;
30
31 /**
32 * Square Sync Helper Class
33 *
34 * The purpose of this class is to centralize common sync utility functions.
35 *
36 * @since 3.8.2
37 */
38 class Helper {
39
40
41 /**
42 * Maximum group narrowing passes before falling back to one request per unresolved id.
43 *
44 * Bounds the worst case: without it a page that resolves a single id each time would issue one
45 * group request per id on top of the per id fallback.
46 *
47 * @since 5.5.0
48 * @var int
49 */
50 const MAX_HISTORY_NARROWING_PASSES = 5;
51
52 /**
53 * Get the inventory tracking value for the given catalog object ids.
54 *
55 * @param array $catalog_object_ids The catalog object ids.
56 * @return array Array of inventory tracking for given catalog object ids.
57 */
58 public static function get_catalog_objects_inventory_stats( $catalog_object_ids ) {
59 if ( empty( $catalog_object_ids ) ) {
60 return array();
61 }
62
63 $response = wc_square()->get_api()->batch_retrieve_inventory_counts(
64 array(
65 'catalog_object_ids' => $catalog_object_ids,
66 'location_ids' => array( wc_square()->get_settings_handler()->get_location_id() ),
67 'states' => array( 'IN_STOCK' ), // Get only in stock counts.
68 )
69 );
70
71 if ( ! $response->get_data() instanceof BatchRetrieveInventoryCountsResponse ) {
72 throw new \Exception( 'Response data missing or invalid' );
73 }
74
75 $inventory_hash = array();
76 foreach ( $response->get_counts() as $inventory_count ) {
77 $inventory_hash[ $inventory_count->getCatalogObjectId() ] = $inventory_count->getQuantity();
78 }
79
80 return $inventory_hash;
81 }
82
83 /**
84 * Get the inventory tracking value for the given catalog object ids.
85 *
86 * @param array $catalog_object_ids The catalog object ids.
87 * @return array Array of inventory tracking for given catalog object ids.
88 */
89 public static function get_catalog_objects_tracking_stats( $catalog_object_ids ) {
90 if ( empty( $catalog_object_ids ) ) {
91 return array();
92 }
93
94 $catalog_response = wc_square()->get_api()->batch_retrieve_catalog_objects( $catalog_object_ids );
95 if ( ! $catalog_response->get_data() instanceof BatchRetrieveCatalogObjectsResponse ) {
96 throw new \Exception( 'Response data is missing' );
97 }
98
99 $objects = $catalog_response->get_data()->getObjects() ? $catalog_response->get_data()->getObjects() : array();
100
101 return self::get_catalog_inventory_tracking( $objects );
102 }
103
104 /**
105 * Collects the catalog object ids whose count is zero.
106 *
107 * Callers hold counts in two shapes: a plain id to quantity map, and an id to stats map where the
108 * quantity sits under a key. Both are accepted so the zero collection is written once.
109 *
110 * @since 5.5.0
111 *
112 * @param array $counts id keyed counts, values either a quantity or an array of stats
113 * @param string|null $quantity_key key holding the quantity when values are arrays
114 * @return string[] ids whose count is exactly zero
115 */
116 public static function zero_count_object_ids( array $counts, $quantity_key = null ) {
117
118 $zero_object_ids = array();
119
120 foreach ( $counts as $object_id => $value ) {
121
122 if ( null !== $quantity_key ) {
123 if ( ! is_array( $value ) || ! isset( $value[ $quantity_key ] ) ) {
124 continue;
125 }
126 $value = $value[ $quantity_key ];
127 }
128
129 if ( 0.0 === (float) $value ) {
130 $zero_object_ids[] = $object_id;
131 }
132 }
133
134 return $zero_object_ids;
135 }
136
137
138 /**
139 * Returns the subset of catalog object IDs that have real inventory history in Square.
140 *
141 * A tracked item that has never had a count recorded reports an IN_STOCK count of 0 that is
142 * indistinguishable from a genuine sellout by state alone. Square's inventory change history is
143 * the reliable discriminator: a never-counted item has an empty history, while any real count or
144 * sale leaves a PHYSICAL_COUNT / ADJUSTMENT record. Callers use this to decide whether a zero
145 * count may be written to WooCommerce (real) or must be ignored (phantom).
146 *
147 * On an API failure this returns null, which callers MUST treat as "verification unavailable":
148 * never write the zero, and never advance a watermark or processed marker past the item, or a
149 * genuine sellout would be permanently skipped once the API recovers. Null is distinct from an
150 * empty array, which is a POSITIVE verification that none of the ids have history.
151 *
152 * @since 5.5.0
153 *
154 * @param string[] $catalog_object_ids catalog object (variation) IDs to check
155 * @return string[]|null IDs with at least one real inventory change, or null when Square could not be asked
156 */
157 public static function get_catalog_objects_with_inventory_history( $catalog_object_ids ) {
158
159 $catalog_object_ids = array_values( array_filter( (array) $catalog_object_ids ) );
160
161 if ( empty( $catalog_object_ids ) ) {
162 return array();
163 }
164
165 $with_history = array();
166
167 try {
168 foreach ( array_chunk( $catalog_object_ids, 100 ) as $chunk ) {
169
170 // First page for the whole chunk. When Square reports no further pages, every id
171 // missing from it is positively verified as having no history. When more pages
172 // exist, walking them all would page through the busiest item's entire history
173 // just to prove a quiet item empty, so unresolved ids are re-queried individually
174 // instead (a single id with no records answers in one page).
175 $page = static::fetch_inventory_changes_page( $chunk );
176 if ( null === $page ) {
177 return null;
178 }
179
180 foreach ( $page['object_ids'] as $object_id ) {
181 $with_history[ $object_id ] = true;
182 }
183
184 $unresolved = array_diff( $chunk, array_keys( $with_history ) );
185
186 if ( empty( $unresolved ) || empty( $page['cursor'] ) ) {
187 continue;
188 }
189
190 // Re-ask for the unresolved ids as a group before falling back to one request each.
191 // A shared page holds the OLDEST changes across every id in it, so one busy id can
192 // fill the page and hide a quiet id's record; asking again with the busy ids removed
193 // usually settles the whole remainder in a single request, and settles it positively
194 // when the response comes back empty with no further pages.
195 $remaining = array_values( $unresolved );
196 $passes = 0;
197
198 while ( ! empty( $remaining ) && $passes < self::MAX_HISTORY_NARROWING_PASSES ) {
199
200 ++$passes;
201
202 $group = static::fetch_inventory_changes_page( $remaining );
203 if ( null === $group ) {
204 return null;
205 }
206
207 $before = count( $remaining );
208
209 foreach ( $group['object_ids'] as $object_id ) {
210 $with_history[ $object_id ] = true;
211 }
212
213 $remaining = array_values( array_diff( $remaining, array_keys( $with_history ) ) );
214
215 // No further pages, so every id still remaining has no history at all. That is a
216 // positive verification, not an unknown, and the loop is done.
217 if ( empty( $group['cursor'] ) ) {
218 $remaining = array();
219 break;
220 }
221
222 // More pages exist but this one named none of the remaining ids, so narrowing has
223 // stalled and another group request would return the same page.
224 if ( count( $remaining ) === $before ) {
225 break;
226 }
227 }
228
229 // Anything still unresolved is settled one id at a time, where an empty first page is
230 // conclusive: pagination is oldest first, so a single id with any record shows it there.
231 foreach ( $remaining as $object_id ) {
232 $single = static::fetch_inventory_changes_page( array( $object_id ) );
233 if ( null === $single ) {
234 return null;
235 }
236 // Membership, not emptiness: a response is only proof for the id it actually names.
237 if ( in_array( $object_id, $single['object_ids'], true ) ) {
238 $with_history[ $object_id ] = true;
239 }
240 }
241 }
242 } catch ( \Exception $exception ) {
243 wc_square()->log( 'Could not verify inventory history for zero counts: ' . $exception->getMessage() );
244 return null;
245 }
246
247 return array_keys( $with_history );
248 }
249
250 /**
251 * Fetches one page of inventory changes for the given catalog object ids.
252 *
253 * @since 5.5.0
254 *
255 * @param string[] $catalog_object_ids ids to query
256 * @return array|null { object_ids: string[] with a change on this page, cursor: string|null } or null on failure
257 */
258 protected static function fetch_inventory_changes_page( array $catalog_object_ids ) {
259
260 $response = wc_square()->get_api()->batch_retrieve_inventory_changes(
261 array(
262 'catalog_object_ids' => $catalog_object_ids,
263 'location_ids' => array( wc_square()->get_settings_handler()->get_location_id() ),
264 )
265 );
266
267 $data = $response->get_data();
268
269 if ( ! $data instanceof \Square\Models\BatchRetrieveInventoryChangesResponse ) {
270 wc_square()->log( 'Could not verify inventory history for zero counts: unexpected API response.' );
271 return null;
272 }
273
274 $object_ids = array();
275
276 foreach ( is_array( $data->getChanges() ) ? $data->getChanges() : array() as $change ) {
277 $object_id = null;
278
279 if ( $change->getPhysicalCount() ) {
280 $object_id = $change->getPhysicalCount()->getCatalogObjectId();
281 } elseif ( $change->getAdjustment() ) {
282 $object_id = $change->getAdjustment()->getCatalogObjectId();
283 } elseif ( $change->getTransfer() ) {
284 $object_id = $change->getTransfer()->getCatalogObjectId();
285 }
286
287 if ( $object_id ) {
288 $object_ids[ $object_id ] = true;
289 }
290 }
291
292 $returned = array_keys( $object_ids );
293
294 // Square honors catalog_object_ids only while at least one of the supplied ids exists. When
295 // none of them exists it silently drops the filter and answers with the whole location's
296 // change history, so a response naming ids we did not ask about means exactly one thing:
297 // none of the ids in this request exist in Square any more (a stale local mapping).
298 //
299 // That is a conclusive answer, not an unknown. A catalog object that does not exist cannot
300 // have sold out, so report no history and no further pages: the caller then treats these ids
301 // as unverified zeros and leaves the products alone. Without this check the per id branch
302 // below would read the unrelated history as proof and write the zero, which is the very bug
303 // SQUARE-145 fixes, for exactly the products whose mappings are stale.
304 $unexpected = array_diff( $returned, $catalog_object_ids );
305
306 if ( ! empty( $unexpected ) ) {
307
308 wc_square()->log(
309 sprintf(
310 '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',
311 count( $catalog_object_ids ),
312 reset( $catalog_object_ids )
313 )
314 );
315
316 return array(
317 'object_ids' => array(),
318 'cursor' => null,
319 );
320 }
321
322 return array(
323 'object_ids' => $returned,
324 'cursor' => $data->getCursor(),
325 );
326 }
327
328
329 /**
330 * Applies a Square IN_STOCK count to a WooCommerce product using the sync write policy.
331 *
332 * Policy (SQUARE-145 / SQUARE-359):
333 * - A positive count is trusted (a phantom is always zero): write the quantity and keep the
334 * existing behavior of enabling stock management to mirror Square tracking.
335 * - A zero count never changes manage_stock. For a stock-managed product it is written only
336 * when Square's change history proves a real count was ever recorded ($zero_verified);
337 * a phantom zero from a never-counted item is skipped. For a product that does not manage
338 * stock, counts are ignored entirely and only the stock status is reflected.
339 *
340 * @since 5.5.0
341 *
342 * @param \WC_Product $product the WooCommerce product or variation
343 * @param float $quantity the IN_STOCK quantity reported by Square
344 * @param bool $sold_out whether Square reports the item as sold out at the configured location
345 * @param bool $zero_verified whether a zero count is backed by real inventory history
346 * @return bool whether the product was modified (caller is responsible for saving)
347 */
348 public static function apply_square_inventory_count( \WC_Product $product, $quantity, $sold_out, $zero_verified ) {
349
350 $quantity = (float) $quantity;
351
352 // A variation inheriting stock management reports the string 'parent': its quantity is
353 // governed by the parent's pooled stock, so a quantity written to it is invisible until the
354 // variation manages its own stock, and a stock status write is overridden by the pool.
355 //
356 // Who owns that decision depends on the system of record. Under WooCommerce SOR the pool is
357 // merchant intent, so a per-variation Square count is not applicable data and is skipped.
358 // Under Square SOR the authority is reversed: a positive count is applied and the variation
359 // takes over its own stock, which is what the plugin did before this changeset.
360 //
361 // A zero or negative count is skipped in both modes. Those are the counts that wiped stock
362 // (SQUARE-145), and writing one into a pool would move stock shared with sibling variations
363 // on the strength of a single variation's reading.
364 if ( 'parent' === $product->get_manage_stock() ) {
365
366 $square_is_system_of_record = wc_square()->get_settings_handler()->is_system_of_record_square();
367
368 if ( ! $square_is_system_of_record || $quantity <= 0 ) {
369 wc_square()->log(
370 sprintf(
371 'Skipped writing a stock quantity to variation #%1$d: its stock is managed by the parent product pool%2$s.',
372 $product->get_id(),
373 $square_is_system_of_record ? ' and the count was not positive' : ''
374 )
375 );
376
377 return false;
378 }
379
380 wc_square()->log(
381 sprintf(
382 '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.',
383 $product->get_id(),
384 $quantity
385 )
386 );
387 }
388
389 if ( $quantity > 0 ) {
390 $product->set_stock_quantity( $quantity );
391 $product->set_manage_stock( true );
392
393 return true;
394 }
395
396 // A negative count can only come from real inventory movement, because an item that was
397 // never counted reads exactly zero, so it needs no history check. Square itself cannot hold
398 // a negative quantity (which is why the push side clamps at zero) but WooCommerce can, and a
399 // store that allows backorders uses it to record how deep it is oversold, so the value is
400 // written through rather than flattened. manage_stock is still left alone: only a positive
401 // count mirrors Square tracking onto that setting.
402 if ( $quantity < 0 ) {
403
404 if ( ! $product->get_manage_stock() ) {
405 $product->set_stock_status( 'outofstock' );
406
407 return true;
408 }
409
410 $product->set_stock_quantity( $quantity );
411
412 return true;
413 }
414
415 // Zero count: never change the product's manage_stock setting in either direction.
416
417 if ( ! $product->get_manage_stock() ) {
418
419 // Not stock-managed in WooCommerce, so a quantity is never written. A zero still has to
420 // stop the product selling, but only when it is a proven sellout: an unproven zero (a
421 // tracked item that was never counted) must not mark a product the merchant keeps
422 // permanently sellable as out of stock. A zero is also never a reason to force a
423 // product back in stock, so this branch only ever writes out of stock.
424 if ( $zero_verified ) {
425 $product->set_stock_status( 'outofstock' );
426
427 return true;
428 }
429
430 wc_square()->log(
431 sprintf(
432 'Skipped marking product #%d out of stock: Square has no inventory history for the item, so its zero count is not a proven sellout.',
433 $product->get_id()
434 )
435 );
436
437 return false;
438 }
439
440 if ( $zero_verified ) {
441 $product->set_stock_quantity( 0 );
442
443 return true;
444 }
445
446 wc_square()->log(
447 sprintf(
448 'Skipped writing a zero stock quantity to product #%d: Square has no inventory history for the item (phantom zero from an uncounted catalog object).',
449 $product->get_id()
450 )
451 );
452
453 return false;
454 }
455
456
457 /**
458 * Get the inventory tracking value for the given catalog objects.
459 *
460 * @param \Square\Models\CatalogObject[] $catalog_objects The catalog objects.
461 * @return array Array of inventory tracking for given catalog objects.
462 */
463 public static function get_catalog_inventory_tracking( $catalog_objects ) {
464 $catalog_objects_tracking = array();
465
466 /** @var \Square\Models\CatalogObject $catalog_object */
467 foreach ( $catalog_objects as $catalog_object ) {
468 $variation_data = $catalog_object->getItemVariationData();
469 $location_overrides = $variation_data->getLocationOverrides();
470 $configured_location = wc_square()->get_settings_handler()->get_location_id();
471
472 $default_data = array(
473 'track_inventory' => $variation_data->getTrackInventory(),
474 'sold_out' => false,
475 );
476
477 if ( ! empty( $location_overrides ) ) {
478 $location_ids = array_map(
479 function ( $location_override ) {
480 return $location_override->getLocationId();
481 },
482 $location_overrides
483 );
484
485 if ( ! in_array( $configured_location, $location_ids, true ) ) {
486 $catalog_objects_tracking[ $catalog_object->getId() ] = $default_data;
487 continue;
488 }
489
490 foreach ( $location_overrides as $location_override ) {
491 $location_id = $location_override->getLocationId();
492
493 if ( $configured_location === $location_id ) {
494 $sold_out = $location_override->getSoldOut() ?? false;
495 if ( ! is_null( $location_override->getTrackInventory() ) ) {
496 $catalog_objects_tracking[ $catalog_object->getId() ] = array(
497 'track_inventory' => $location_override->getTrackInventory(),
498 'sold_out' => $sold_out,
499 );
500 } else {
501 $catalog_objects_tracking[ $catalog_object->getId() ] = array(
502 'track_inventory' => $variation_data->getTrackInventory(),
503 'sold_out' => $sold_out,
504 );
505 }
506 }
507 }
508 } else {
509 $catalog_objects_tracking[ $catalog_object->getId() ] = $default_data;
510 }
511 }
512
513 return $catalog_objects_tracking;
514 }
515 }
516