PluginProbe
WooCommerce Square / 4.3.2
WooCommerce Square v4.3.2
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 All 132 releases
woocommerce-square / includes / Sync / Manual_Synchronization.php
Manual_Synchronization.php
1,751 lines 54.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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\BatchRetrieveInventoryCountsResponse;
27 use Square\Models\BatchUpsertCatalogObjectsResponse;
28 use Square\Models\BatchRetrieveCatalogObjectsResponse;
29 use Square\Models\CatalogObject;
30 use Square\Models\SearchCatalogObjectsResponse;
31 use Square\Models\CatalogInfoResponse;
32 use \Square\ApiHelper;
33 use WooCommerce\Square\Handlers\Category;
34 use WooCommerce\Square\Handlers\Product;
35
36 defined( 'ABSPATH' ) || exit;
37
38 /**
39 * Class to represent a single synchronization job triggered manually.
40 *
41 * @since 2.0.0
42 */
43 class Manual_Synchronization extends Stepped_Job {
44
45
46 /** @var int the limit for how many objects can be upserted in a batch upsert request */
47 const BATCH_UPSERT_OBJECT_LIMIT = 600;
48
49 /** @var int the limit for how many inventory changes can be made in a single request */
50 const BATCH_CHANGE_INVENTORY_LIMIT = 100;
51
52 /** @var int the limit for how many inventory counts can be requested per batch
53 * Square paginates responses in page size of 100.
54 * Consider some items can have more than one object returned with different states. */
55 const BATCH_INVENTORY_COUNTS_LIMIT = 125;
56
57 /**
58 * Validates the products attached to this job.
59 *
60 * @since 2.0.0
61 */
62 protected function validate_products() {
63 $product_ids = $this->get_attr( 'product_ids' );
64 $unsupported_product_ids = array();
65
66 if ( is_array( $product_ids ) ) {
67 $matched_product_ids = wc_get_products(
68 array(
69 'include' => $product_ids,
70 'return' => 'ids',
71 'type' => wc_square()->get_sync_handler()->supported_product_types(),
72 'limit' => -1,
73 )
74 );
75
76 $matched_product_ids = is_array( $matched_product_ids ) ? $matched_product_ids : array();
77 $unsupported_product_ids = array_diff( $product_ids, $matched_product_ids );
78
79 foreach ( $unsupported_product_ids as $matched_product_id ) {
80 $product = wc_get_product( $matched_product_id );
81 $type = $product->get_type();
82
83 Records::set_record(
84 array(
85 'type' => 'alert',
86 'message' => sprintf(
87 /* translators: %1$s - product edit page URL, %2$s - Product ID, %3$s - Product type. */
88 __( 'Product <a href="%1$s">#%2$s</a> is excluded from sync as the product type "%3$s" is unsupported.', 'woocommerce-square' ),
89 get_edit_post_link( $matched_product_id ),
90 $matched_product_id,
91 $type
92 ),
93 )
94 );
95 }
96 }
97
98 $products_query = array(
99 'include' => $product_ids,
100 'limit' => -1,
101 'status' => array( 'private', 'publish' ),
102 'return' => 'ids',
103 );
104
105 if ( 'delete' === $this->get_attr( 'action' ) ) {
106
107 $products_query['status'] = array( 'trash', 'draft', 'pending', 'private', 'publish' );
108 }
109
110 $validated_products = wc_get_products( $products_query );
111
112 $this->set_attr( 'validated_product_ids', $validated_products );
113
114 $this->complete_step( 'validate_products' );
115 }
116
117
118 /**
119 * Updates the catalog API limits.
120 *
121 * @since 2.0.0
122 */
123 protected function update_limits() {
124
125 try {
126
127 $catalog_info = wc_square()->get_api()->catalog_info();
128
129 if ( $catalog_info->get_data() instanceof CatalogInfoResponse && $catalog_info->get_data()->getLimits() ) {
130
131 $limits = $catalog_info->get_data()->getLimits();
132
133 $this->set_attr( 'max_objects_to_retrieve', $limits->getBatchRetrieveMaxObjectIds() );
134 $this->set_attr( 'max_objects_per_batch', $limits->getBatchUpsertMaxObjectsPerBatch() );
135 $this->set_attr( 'max_objects_total', $limits->getBatchUpsertMaxTotalObjects() );
136 }
137 } catch ( \Exception $exception ) { // no need to handle errors here
138 }
139
140 $this->complete_step( 'update_limits' );
141 }
142
143
144 /**
145 * Extracts the category IDs from the list of product IDs in this job, and saves them.
146 *
147 * @since 2.0.0
148 */
149 protected function extract_category_ids() {
150
151 $category_ids = $this->get_shared_category_ids( $this->get_attr( 'validated_product_ids' ) );
152
153 $this->set_attr( 'category_ids', $category_ids );
154
155 $this->complete_step( 'extract_category_ids' );
156 }
157
158
159 /**
160 * Refreshes mappings for categories with known Square IDs.
161 *
162 * @since 2.0.0
163 *
164 * @throws \Exception
165 */
166 protected function refresh_category_mappings() {
167
168 $map = Category::get_map();
169 $category_ids = $this->get_attr( 'refresh_mappings_category_ids', $this->get_attr( 'category_ids' ) );
170 $mapped_categories = array();
171 $unmapped_categories = $this->get_attr( 'unmapped_categories', array() );
172 $unmapped_category_ids = array();
173
174 if ( empty( $category_ids ) ) {
175 $this->complete_step( 'refresh_category_mappings' );
176 return;
177 }
178
179 if ( count( $category_ids ) > $this->get_max_objects_to_retrieve() ) {
180
181 $category_ids_batch = array_slice( $category_ids, 0, $this->get_max_objects_to_retrieve() );
182
183 $this->set_attr( 'refresh_mappings_category_ids', array_diff( $category_ids, $category_ids_batch ) );
184
185 $category_ids = $category_ids_batch;
186
187 } else {
188
189 $this->set_attr( 'refresh_mappings_category_ids', array() );
190 }
191
192 foreach ( $category_ids as $category_id ) {
193
194 if ( isset( $map[ $category_id ] ) ) {
195
196 $mapped_categories[ $category_id ] = $map[ $category_id ];
197
198 } else {
199
200 $unmapped_category_ids[] = $category_id;
201 }
202 }
203
204 if ( ! empty( $mapped_categories ) ) {
205
206 $square_ids = array_values(
207 array_filter(
208 array_map(
209 function ( $mapped_category ) {
210 return isset( $mapped_category['square_id'] ) ? $mapped_category['square_id'] : null;
211 },
212 $mapped_categories
213 )
214 )
215 );
216
217 if ( ! empty( $square_ids ) ) {
218
219 $response = wc_square()->get_api()->batch_retrieve_catalog_objects( $square_ids );
220
221 // swap the square ID into the array key for quick lookup
222 $mapped_category_audit = array();
223
224 foreach ( $mapped_categories as $mapped_category_id => $mapped_category ) {
225 $mapped_category_audit[ $mapped_category['square_id'] ] = $mapped_category_id;
226 }
227
228 if ( ! $response->get_data() instanceof BatchRetrieveCatalogObjectsResponse ) {
229 throw new \Exception( 'Could not fetch category data from Square. Response data is missing' );
230 }
231
232 // handle response
233 if ( is_array( $response->get_data()->getObjects() ) ) {
234 foreach ( $response->get_data()->getObjects() as $category ) {
235
236 // don't check for the name, it will get overwritten by the Woo value anyway
237 if ( isset( $mapped_category_audit[ $category->getId() ] ) ) {
238
239 $category_id = $mapped_category_audit[ $category->getId() ];
240
241 $map[ $category_id ]['square_version'] = $category->getVersion();
242 unset( $mapped_category_audit[ $category->getId() ] );
243 }
244 }
245 }
246
247 // any remaining categories were not found in Square and should have their local mapping data removed
248 if ( ! empty( $mapped_category_audit ) ) {
249
250 $outdated_category_ids = array_values( $mapped_category_audit );
251
252 foreach ( $outdated_category_ids as $outdated_category_id ) {
253
254 unset( $map[ $outdated_category_id ], $mapped_categories[ $outdated_category_id ] );
255
256 $unmapped_category_ids[] = $outdated_category_id;
257 }
258
259 $unmapped_category_ids = array_unique( $unmapped_category_ids );
260 }
261 }
262 // update unmapped list
263 }
264
265 if ( ! empty( $unmapped_category_ids ) ) {
266
267 $unmapped_category_terms = get_terms(
268 array(
269 'taxonomy' => 'product_cat',
270 'include' => $unmapped_category_ids,
271 )
272 );
273
274 // make the 'name' attribute the array key, for more efficient searching later.
275 foreach ( $unmapped_category_terms as $unmapped_category_term ) {
276 $unmapped_categories[ strtolower( wp_specialchars_decode( $unmapped_category_term->name ) ) ] = $unmapped_category_term;
277 }
278 }
279
280 // save category lists
281 $this->set_attr( 'mapped_categories', $mapped_categories );
282 $this->set_attr( 'unmapped_categories', $unmapped_categories );
283
284 Category::update_map( $map );
285 }
286
287
288 /**
289 * Checks the Square API for any unmapped categories we may have.
290 *
291 * @since 2.0.0
292 *
293 * @throws \Exception
294 */
295 protected function query_unmapped_categories() {
296
297 $unmapped_categories = $this->get_attr( 'unmapped_categories', array() );
298 $mapped_categories = $this->get_attr( 'mapped_categories', array() );
299
300 if ( empty( $unmapped_categories ) ) {
301
302 $this->complete_step( 'query_unmapped_categories' );
303
304 } else {
305
306 $response = wc_square()->get_api()->search_catalog_objects(
307 array(
308 'object_types' => array( 'CATEGORY' ),
309 'cursor' => $this->get_attr( 'unmapped_categories_cursor' ),
310 )
311 );
312
313 $category_map = Category::get_map();
314 $categories = $response->get_data() instanceof SearchCatalogObjectsResponse ? $response->get_data()->getObjects() : null;
315
316 if ( is_array( $categories ) ) {
317
318 foreach ( $categories as $category_object ) {
319
320 $unmapped_category_key = strtolower( $category_object->getCategoryData()->getName() );
321
322 if ( isset( $unmapped_categories[ $unmapped_category_key ] ) ) {
323
324 $category_id = $unmapped_categories[ $unmapped_category_key ]['term_id'];
325
326 $category_map[ $category_id ] = array(
327 'square_id' => $category_object->getId(),
328 'square_version' => $category_object->getVersion(),
329 );
330
331 $mapped_categories[] = $category_id;
332 unset( $unmapped_categories[ $unmapped_category_key ] );
333 }
334 }
335 }
336
337 Category::update_map( $category_map );
338 $this->set_attr( 'mapped_categories', $mapped_categories );
339 $this->set_attr( 'unmapped_categories', $unmapped_categories );
340
341 $cursor = $response->get_data() instanceof SearchCatalogObjectsResponse ? $response->get_data()->getCursor() : null;
342 $this->set_attr( 'unmapped_categories_cursor', $cursor );
343
344 if ( empty( $cursor ) ) {
345
346 $this->complete_step( 'query_unmapped_categories' );
347 }
348 }
349 }
350
351
352 /**
353 * Upserts the categories for the selected products to Square.
354 *
355 * @since 2.0.0
356 *
357 * @throws \Exception
358 */
359 protected function upsert_categories() {
360
361 $category_ids = $this->get_attr( 'category_ids' );
362 $categories = get_terms(
363 array(
364 'taxonomy' => 'product_cat',
365 'include' => $category_ids,
366 )
367 );
368
369 $batches = array();
370 $reverse_map = array();
371
372 // For now, keep it to one category per batch. Since we can still send 1000 batches per request, it's efficient,
373 // and insulates errors per category rather than a single category error breaking the entire batch it is in.
374 // TODO: Performance - Consider sending larger-sized batches to reduce total requests for shops with thousands of categories.
375 // This will require the ability to handle a failed batch, pulling out the error-causing category, and retrying the batch.
376 foreach ( $categories as $category ) {
377
378 $category_id = $category->term_id;
379 $square_id = Category::get_square_id( $category_id );
380 $square_version = Category::get_square_version( $category_id );
381
382 $reverse_map[ $square_id ] = $category_id;
383
384 $catalog_category = new \Square\Models\CatalogCategory();
385 $catalog_category->setName( wp_specialchars_decode( $category->name ) );
386
387 $catalog_object = new \Square\Models\CatalogObject( 'CATEGORY', $square_id );
388 $catalog_object->setCategoryData( $catalog_category );
389
390 if ( 0 < $square_version ) {
391 $catalog_object->setVersion( $square_version );
392 }
393
394 $batches[] = new \Square\Models\CatalogObjectBatch( array( $catalog_object ) );
395 }
396
397 foreach ( array_chunk( $batches, $this->get_max_objects_per_upsert() ) as $batch ) {
398 $idempotency_key = wc_square()->get_idempotency_key( md5( serialize( $batch ) . $this->get_attr( 'id' ) ) . '_upsert_categories' );
399 $result = wc_square()->get_api()->batch_upsert_catalog_objects( $idempotency_key, $batch );
400
401 if ( ! $result->get_data() instanceof BatchUpsertCatalogObjectsResponse ) {
402 throw new \Exception( 'Response data is invalid' );
403 }
404
405 $id_mappings = $result->get_data()->getIdMappings(); // new entries to Square will return in the ID Mapping.
406
407 if ( ! empty( $id_mappings ) ) {
408 foreach ( $id_mappings as $id_mapping ) {
409 $client_object_id = $id_mapping->getClientObjectId();
410 $remote_object_id = $id_mapping->getObjectId();
411
412 if ( isset( $reverse_map[ $client_object_id ] ) ) {
413 $reverse_map[ $remote_object_id ] = $reverse_map[ $client_object_id ];
414 unset( $reverse_map[ $client_object_id ] );
415 }
416 }
417 }
418
419 foreach ( $result->get_data()->getObjects() as $upserted_category ) {
420 $id = $upserted_category->getId();
421 $version = $upserted_category->getVersion();
422
423 if ( isset( $reverse_map[ $id ] ) ) {
424 Category::update_mapping( $reverse_map[ $id ], $id, $version );
425 unset( $reverse_map[ $id ] );
426 }
427 }
428 }
429
430 $this->complete_step( 'upsert_categories' );
431 }
432
433 /**
434 * Updates a set of products that already have a Square ID set and are found in the catalog.
435 *
436 * @since 2.0.0
437 *
438 * @throws \Exception
439 */
440 protected function update_matched_products() {
441
442 $product_ids = $this->get_attr( 'matched_product_ids', $this->get_attr( 'validated_product_ids', array() ) );
443 $processed_product_ids = $this->get_attr( 'processed_product_ids', array() );
444
445 // remove IDs that have already been processed
446 $product_ids = array_diff( $product_ids, $processed_product_ids );
447
448 if ( empty( $product_ids ) ) {
449
450 $this->complete_step( 'update_matched_products' );
451 return;
452 }
453
454 if ( count( $product_ids ) > $this->get_max_objects_to_retrieve() ) {
455
456 $product_ids_batch = array_slice( $product_ids, 0, $this->get_max_objects_to_retrieve() );
457
458 $this->set_attr( 'matched_product_ids', array_diff( $product_ids, $product_ids_batch ) );
459
460 $product_ids = $product_ids_batch;
461
462 } else {
463
464 $this->set_attr( 'matched_product_ids', array() );
465 }
466
467 $products_map = Product::get_square_meta( $product_ids, 'square_item_id' );
468 $square_ids = array_keys( $products_map );
469
470 if ( empty( $square_ids ) ) {
471 return;
472 }
473
474 $response = wc_square()->get_api()->batch_retrieve_catalog_objects( $square_ids );
475
476 if ( ! $response->get_data() instanceof BatchRetrieveCatalogObjectsResponse ) {
477 throw new \Exception( 'Response data is missing' );
478 }
479
480 $catalog_objects = array();
481
482 if ( $response->get_data()->getObjects() ) {
483
484 foreach ( $response->get_data()->getObjects() as $catalog_object ) {
485
486 if ( ! empty( $products_map[ $catalog_object->getId() ]['product_id'] ) ) {
487
488 $product_id = $products_map[ $catalog_object->getId() ]['product_id'];
489
490 $catalog_objects[ $product_id ] = $catalog_object;
491 }
492 }
493 }
494
495 if ( ! empty( $catalog_objects ) ) {
496
497 $result = $this->upsert_catalog_objects( $catalog_objects );
498
499 $this->set_attr( 'processed_product_ids', array_merge( $result['processed'], $processed_product_ids ) );
500
501 // any products that were staged but not processed, push to the matched array to try next time
502 $matched_product_ids = $this->get_attr( 'matched_product_ids', array() );
503 $this->set_attr( 'matched_product_ids', array_merge( $result['unprocessed'], $matched_product_ids ) );
504 }
505 }
506
507
508 /**
509 * Searches the full Square catalog to find matches and updates them.
510 *
511 * @since 2.0.0
512 *
513 * @throws \Exception
514 */
515 protected function search_matched_products() {
516
517 $product_ids = $this->get_attr( 'search_product_ids', $this->get_attr( 'validated_product_ids', array() ) );
518 $processed_product_ids = $this->get_attr( 'processed_product_ids', array() );
519 $in_progress = $this->get_attr(
520 'in_progress_search_matched_products',
521 array(
522 'unprocessed_search_response' => null,
523 'processed_remote_object_ids' => array(),
524 'catalog_objects_to_update' => array(),
525 'upserting' => false,
526 )
527 );
528
529 // remove IDs that have already been processed
530 $product_ids = array_diff( $product_ids, $processed_product_ids );
531
532 if ( empty( $product_ids ) ) {
533
534 $this->complete_step( 'search_matched_products' );
535 return;
536 }
537
538 $products_map = Product::get_square_meta( $product_ids, 'square_item_id' );
539
540 if ( ! empty( $in_progress['unprocessed_search_response'] ) ) {
541 $search_response = ApiHelper::deserialize( $in_progress['unprocessed_search_response'], new SearchCatalogObjectsResponse() );
542 } else {
543
544 $response = wc_square()->get_api()->search_catalog_objects(
545 array(
546 'cursor' => $this->get_attr( 'search_products_cursor' ),
547 'object_types' => array( 'ITEM' ),
548 'limit' => $this->get_max_objects_to_retrieve(),
549 )
550 );
551
552 $search_response = $response->get_data();
553
554 $in_progress['unprocessed_search_response'] = wp_json_encode( $search_response, JSON_PRETTY_PRINT );
555 }
556
557 if ( ! $search_response instanceof SearchCatalogObjectsResponse ) {
558 throw new \Exception( 'Response data is missing' );
559 }
560
561 $catalog_objects = $search_response->getObjects() ? $search_response->getObjects() : array();
562 $cursor = $search_response->getCursor();
563 $catalog_objects_to_update = $in_progress['catalog_objects_to_update'];
564
565 if ( true !== $in_progress['upserting'] ) {
566
567 wc_square()->log( 'Searching through ' . count( $catalog_objects ) . ' catalog objects' );
568
569 foreach ( $catalog_objects as $catalog_object ) {
570
571 $remote_object_id = $catalog_object->getId();
572
573 if ( in_array( $remote_object_id, $in_progress['processed_remote_object_ids'], true ) ) {
574 continue;
575 }
576
577 if ( isset( $products_map[ $remote_object_id ]['product_id'] ) ) {
578
579 $product_id = $products_map[ $remote_object_id ]['product_id'];
580
581 $product = wc_get_product( $product_id );
582
583 // update the product's meta
584 if ( $product ) {
585 Product\Woo_SOR::update_product( $product, $catalog_object );
586 }
587
588 foreach ( $catalog_object->getItemData()->getVariations() as $catalog_variation ) {
589
590 $variation_product_id = Product::get_product_id_by_square_variation_id( $catalog_variation->getId() );
591
592 if ( $variation_product_id ) {
593
594 $variation = wc_get_product( $variation_product_id );
595
596 if ( $variation ) {
597 Product\Woo_SOR::update_variation( $variation, $catalog_variation );
598 }
599 }
600 }
601
602 $catalog_objects_to_update[ $product_id ] = $catalog_object;
603
604 } else {
605
606 // no variations? no sku
607 if ( ! is_array( $catalog_object->getItemData()->getVariations() ) ) {
608 continue;
609 }
610
611 $product_id = 0;
612 $matched_object = null;
613
614 foreach ( $catalog_object->getItemData()->getVariations() as $catalog_variation ) {
615
616 $product_id = wc_get_product_id_by_sku( $catalog_variation->getItemVariationData()->getSku() );
617
618 $product = wc_get_product( $product_id );
619
620 if ( ! $product ) {
621 continue;
622 }
623
624 $parent_product = wc_get_product( $product->get_parent_id() );
625
626 if ( $product->get_parent_id() && $parent_product ) {
627 $product = $parent_product;
628 }
629
630 if ( ! in_array( $product->get_id(), $product_ids, false ) ) { // phpcs:ignore WordPress.PHP.StrictInArray.FoundNonStrictFalse
631 continue;
632 }
633
634 $product_id = $product->get_id();
635 $matched_object = $catalog_object;
636
637 break;
638 }
639
640 if ( $product_id && $matched_object ) {
641 $catalog_objects_to_update[ $product_id ] = $matched_object;
642 }
643 }
644
645 $in_progress['processed_remote_object_ids'][] = $remote_object_id;
646 $in_progress['catalog_objects_to_update'] = $catalog_objects_to_update;
647 }
648 }
649
650 $in_progress['upserting'] = true;
651
652 $catalog_processed = ! $cursor;
653
654 $remaining_product_ids = array_diff( $product_ids, array_keys( $catalog_objects_to_update ) );
655
656 if ( ! empty( $catalog_objects_to_update ) ) {
657
658 $result = $this->upsert_catalog_objects( $catalog_objects_to_update );
659
660 $processed_product_ids = array_merge( $result['processed'], $processed_product_ids );
661 $this->set_attr( 'processed_product_ids', $processed_product_ids );
662
663 if ( ! empty( $result['unprocessed'] ) ) {
664
665 $catalog_processed = false;
666 $remaining_product_ids = array_merge( $result['unprocessed'], $remaining_product_ids );
667 $in_progress['catalog_objects_to_update'] = array_diff_key( $catalog_objects_to_update, array_flip( $processed_product_ids ) );
668
669 } else {
670
671 $in_progress = null;
672 }
673
674 $this->set_attr( 'in_progress_search_matched_products', $in_progress );
675 }
676
677 if ( ! $catalog_processed && ! empty( $remaining_product_ids ) ) {
678
679 $this->set_attr( 'search_products_cursor', $cursor );
680 $this->set_attr( 'search_product_ids', $remaining_product_ids );
681
682 } else {
683
684 Product::clear_square_meta( $remaining_product_ids );
685 $this->complete_step( 'search_matched_products' );
686 }
687 }
688
689
690 /**
691 * @throws \Exception
692 */
693 protected function upsert_new_products() {
694
695 $product_ids = $this->get_attr( 'upsert_new_product_ids', $this->get_attr( 'validated_product_ids', array() ) );
696 $processed_product_ids = $this->get_attr( 'processed_product_ids', array() );
697
698 // remove IDs that have already been processed
699 $product_ids = array_diff( $product_ids, $processed_product_ids );
700
701 if ( empty( $product_ids ) ) {
702
703 $this->complete_step( 'upsert_new_products' );
704 return;
705 }
706
707 $catalog_objects = array();
708
709 foreach ( $product_ids as $product_id ) {
710
711 $catalog_item = new \Square\Models\CatalogItem();
712 $catalog_object = new CatalogObject( 'ITEM', Product::get_square_item_id( $product_id ) );
713 $catalog_object->setItemData( $catalog_item );
714 $catalog_objects[ $product_id ] = $catalog_object;
715 }
716
717 $result = $this->upsert_catalog_objects( $catalog_objects );
718
719 // newly upserted IDs should get their inventory pushed
720 $this->set_attr( 'inventory_push_product_ids', $result['processed'] );
721
722 $processed_product_ids = array_merge( $result['processed'], $processed_product_ids );
723
724 $this->set_attr( 'processed_product_ids', $processed_product_ids );
725
726 if ( ! empty( $result['unprocessed'] ) ) {
727
728 $this->set_attr( 'upsert_new_product_ids', $result['unprocessed'] );
729
730 } else {
731
732 // at this point, log a failure for any products that weren't processed
733 foreach ( array_diff( $product_ids, $processed_product_ids ) as $product_id ) {
734 Records::set_record(
735 array(
736 'type' => 'info',
737 'product_id' => $product_id,
738 'message' => sprintf(
739 /* translators: Placeholder: %s - product ID */
740 esc_html__( 'Product #%s could not be updated.', 'woocommerce-square' ),
741 '<a href="' . esc_url( get_edit_post_link( $product_id ) ) . '">' . $product_id . '</a>'
742 ),
743 )
744 );
745 }
746
747 $this->complete_step( 'upsert_new_products' );
748 }
749 }
750
751
752 /**
753 * Upserts a list of catalog objects and updates their cooresponding products.
754 *
755 * @since 2.0.0
756 *
757 * @param array $objects list of catalog objects to update, as $product_id => CatalogItem
758 * @return array
759 * @throws \Exception
760 */
761 protected function upsert_catalog_objects( array $objects ) {
762
763 wc_square()->log( 'Upserting ' . count( $objects ) . ' catalog objects' );
764
765 $is_delete_action = 'delete' === $this->get_attr( 'action' );
766 $product_ids = array_keys( $objects );
767 $original_square_image_ids = array();
768 $staged_product_ids = array();
769 $successful_product_ids = array();
770 $total_object_count = 0;
771 $batches = array();
772 $result = array(
773 'processed' => array(),
774 'unprocessed' => $product_ids,
775 );
776
777 $in_progress = $this->get_attr(
778 'in_progress_upsert_catalog_objects',
779 array(
780 'batches' => array(),
781 'staged_product_ids' => array(),
782 'total_object_count' => null,
783 'unprocessed_upsert_response' => null,
784 'mapped_client_item_ids' => array(),
785 'processed_remote_catalog_item_ids' => array(),
786 )
787 );
788
789 if ( null === $in_progress['unprocessed_upsert_response'] ) {
790
791 // need all three items to restore from in-progress
792 if ( ! empty( $in_progress['batches'] ) && ! empty( $in_progress['staged_product_ids'] ) && ! empty( $in_progress['total_object_count'] ) ) {
793
794 $staged_product_ids = $in_progress['staged_product_ids'];
795 $total_object_count = $in_progress['total_object_count'];
796 $batches = array_map(
797 static function ( $batch_data ) {
798 return ApiHelper::deserialize( $batch_data );
799 },
800 $in_progress['batches']
801 );
802 }
803
804 foreach ( $objects as $product_id => $object ) {
805
806 if ( in_array( $product_id, $staged_product_ids, true ) ) {
807 continue;
808 }
809
810 if ( ! $object instanceof CatalogObject ) {
811 $object = $this->convert_to_catalog_object( $object );
812 }
813
814 $product = wc_get_product( $product_id );
815 $original_square_image_ids[ $product_id ] = $product->get_meta( '_square_item_image_id' );
816
817 $catalog_item = new Catalog_Item( $product, $is_delete_action );
818 $batch = $catalog_item->get_batch( $object );
819 $object_count = $catalog_item->get_batch_object_count();
820
821 if ( $this->get_max_objects_total() >= $object_count + $total_object_count ) {
822 $batches[] = $batch;
823 $total_object_count += $object_count;
824 $staged_product_ids[] = $product_id;
825 } else {
826 break;
827 }
828 }
829 }
830
831 $upsert_response = null;
832
833 if ( null !== $in_progress['unprocessed_upsert_response'] ) {
834 $upsert_response = ApiHelper::deserialize( $in_progress['unprocessed_upsert_response'], new BatchUpsertCatalogObjectsResponse() );
835 }
836
837 if ( ! $upsert_response instanceof BatchUpsertCatalogObjectsResponse ) {
838
839 $start = microtime( true );
840
841 $idempotency_key = wc_square()->get_idempotency_key( md5( serialize( $batches ) ) . time() . '_upsert_products' );
842 $response = wc_square()->get_api()->batch_upsert_catalog_objects( $idempotency_key, $batches );
843 $upsert_response = $response->get_data();
844
845 if ( ! $upsert_response instanceof BatchUpsertCatalogObjectsResponse ) {
846 throw new \Exception( 'API response data is missing' );
847 }
848
849 $duration = number_format( microtime( true ) - $start, 2 );
850
851 wc_square()->log( 'Upserted ' . count( $upsert_response->getObjects() ) . ' objects in ' . $duration . 's' );
852
853 $in_progress['unprocessed_upsert_response'] = wp_json_encode( $response, JSON_PRETTY_PRINT );
854 }
855
856 // update local square meta for newly upserted objects
857 if ( ! $is_delete_action && $upsert_response instanceof BatchUpsertCatalogObjectsResponse && is_array( $upsert_response->getIdMappings() ) ) {
858
859 wc_square()->log( 'Mapping new Square item IDs to WooCommerce product IDs' );
860
861 $start = microtime( true );
862
863 foreach ( $upsert_response->getIdMappings() as $id_mapping ) {
864
865 $client_item_id = $id_mapping->getClientObjectId();
866 $remote_item_id = $id_mapping->getObjectId();
867
868 if ( in_array( $client_item_id, $in_progress['mapped_client_item_ids'], true ) ) {
869 continue;
870 }
871
872 if ( 0 === strpos( $client_item_id, '#item_variation_' ) ) {
873
874 $product_id = substr( $client_item_id, strlen( '#item_variation_' ) );
875 Product::set_square_item_variation_id( $product_id, $remote_item_id );
876
877 } elseif ( 0 === strpos( $client_item_id, '#item_' ) ) {
878
879 $product_id = substr( $client_item_id, strlen( '#item_' ) );
880 Product::set_square_item_id( $product_id, $remote_item_id );
881 }
882
883 $in_progress['mapped_client_item_ids'][] = $client_item_id;
884 }
885
886 $duration = number_format( microtime( true ) - $start, 2 );
887
888 wc_square()->log( 'Mapped ' . count( $in_progress['mapped_client_item_ids'] ) . ' Square IDs in ' . $duration . 's' );
889 }
890
891 $pull_inventory_variation_ids = $this->get_attr( 'pull_inventory_variation_ids', array() );
892
893 wc_square()->log( 'Storing Square item data to WooCommerce products' );
894
895 $start = microtime( true );
896
897 // loop through all returned objects and store their IDs to Woo products
898 foreach ( $upsert_response->getObjects() as $remote_catalog_item ) {
899
900 $remote_item_id = $remote_catalog_item->getId();
901
902 if ( in_array( $remote_item_id, $in_progress['processed_remote_catalog_item_ids'], true ) ) {
903 continue;
904 }
905
906 $product = Product::get_product_by_square_id( $remote_item_id );
907
908 if ( ! $product ) {
909 $in_progress['processed_remote_catalog_item_ids'][] = $remote_item_id;
910 continue;
911 }
912
913 Product::update_square_meta(
914 $product,
915 array(
916 'item_id' => $remote_item_id,
917 'item_version' => $remote_catalog_item->getVersion(),
918 'item_image_id' => Product::get_catalog_item_thumbnail_id( $remote_catalog_item ),
919 )
920 );
921
922 $successful_product_ids[] = $product->get_id();
923
924 if ( is_array( $remote_catalog_item->getItemData()->getVariations() ) ) {
925
926 foreach ( $remote_catalog_item->getItemData()->getVariations() as $catalog_item_variation ) {
927
928 $product_variation = Product::get_product_by_square_variation_id( $catalog_item_variation->getId() );
929
930 if ( $product_variation ) {
931
932 $pull_inventory_variation_ids[] = $catalog_item_variation->getId();
933
934 Product::update_square_meta(
935 $product_variation,
936 array(
937 'item_variation_id' => $catalog_item_variation->getId(),
938 'item_variation_version' => $catalog_item_variation->getVersion(),
939 )
940 );
941 }
942 }
943 }
944
945 $local_image_id = $product->get_image_id();
946 $product_id = $product->get_id();
947
948 // If there is a local image which is different from the last uploaded image
949 // Or if the remote square image id has changed
950 if ( ( $local_image_id && $local_image_id !== $product->get_meta( '_square_uploaded_image_id' ) ) ||
951 ( ! ( $original_square_image_ids[ $product_id ] && $original_square_image_ids[ $product_id ] === $product->get_meta( '_square_item_image_id' ) ) ) ) {
952 // there is no batch image endpoint
953 $this->push_product_image( $product );
954
955 }
956
957 $in_progress['processed_remote_catalog_item_ids'][] = $remote_item_id;
958
959 $result['processed'][] = $product->get_id();
960 $result['unprocessed'] = array_diff( $product_ids, $result['processed'] );
961 }
962
963 $this->set_attr( 'pull_inventory_variation_ids', $pull_inventory_variation_ids );
964
965 $duration = number_format( microtime( true ) - $start, 2 );
966
967 wc_square()->log( 'Stored Square data to ' . count( $result['processed'] ) . ' products in ' . $duration . 's' );
968
969 // log any failed products
970 foreach ( array_diff( $staged_product_ids, $successful_product_ids ) as $product_id ) {
971
972 Records::set_record(
973 array(
974 'type' => 'alert',
975 'product_id' => $product_id,
976 'message' => sprintf(
977 /* translators: Placeholder: %s - product ID */
978 esc_html__( 'Product %s could not be updated in Square.', 'woocommerce-square' ),
979 '<a href="' . esc_url( get_edit_post_link( $product_id ) ) . '">' . $product_id . '</a>'
980 ),
981 )
982 );
983 }
984
985 $this->set_attr( 'in_progress_upsert_catalog_objects', null );
986
987 $result['processed'] = $staged_product_ids;
988 $result['unprocessed'] = array_diff( $product_ids, $staged_product_ids );
989
990 return $result;
991 }
992
993
994 /**
995 * Converts object data to an instance of CatalogObject.
996 *
997 * @since 2.0.0
998 *
999 * @param array|string $object_data json string or array of object data
1000 * @return CatalogObject
1001 */
1002 protected function convert_to_catalog_object( $object_data ) {
1003 $object_data_string = is_string( $object_data ) ? $object_data : wp_json_encode( $object_data );
1004 $object_data_obj = is_string( $object_data ) ? json_decode( $object_data ) : $object_data;
1005
1006 $catalog_object = new CatalogObject( $object_data_obj->type, $object_data_obj->id );
1007 $object = ApiHelper::deserialize( $object_data_string, $catalog_object );
1008
1009 return $object instanceof CatalogObject ? $object : null;
1010 }
1011
1012
1013 /**
1014 * Pushes a product's image to Square.
1015 *
1016 * @since 2.0.0
1017 *
1018 * @param \WC_Product|int $product product object or ID
1019 */
1020 protected function push_product_image( $product ) {
1021
1022 $product = wc_get_product( $product );
1023
1024 if ( ! $product instanceof \WC_Product || ! $product->get_image_id() ) {
1025 return;
1026 }
1027
1028 $local_image_id = $product->get_image_id();
1029 $image_path = get_attached_file( $local_image_id );
1030
1031 if ( $image_path ) {
1032
1033 try {
1034
1035 $image_id = wc_square()->get_api()->create_image( $image_path, Product::get_square_item_id( $product ), $product->get_name() );
1036
1037 Product::set_square_image_id( $product, $image_id );
1038
1039 // record the WC image ID that was uploaded
1040 $product->update_meta_data( '_square_uploaded_image_id', $local_image_id );
1041 $product->save_meta_data();
1042
1043 } catch ( \Exception $exception ) {
1044
1045 if ( wc_square()->get_settings_handler()->is_debug_enabled() ) {
1046 wc_square()->log( 'Could not upload image for product #' . $product->get_id() . ': ' . $exception->getMessage() );
1047 }
1048 }
1049 }
1050 }
1051
1052
1053 /**
1054 * Pushes WooCommerce inventory to Square for synced items.
1055 *
1056 * @since 2.0.0
1057 *
1058 * @throws \Exception
1059 */
1060 protected function push_inventory() {
1061
1062 $product_ids = $this->get_attr( 'inventory_push_product_ids', array() );
1063 $count = $this->get_attr( 'push_inventory_count', 0 );
1064 $inventory_changes = array();
1065 $inventory_change_count = 0;
1066
1067 foreach ( $product_ids as $key => $product_id ) {
1068
1069 $product = wc_get_product( $product_id );
1070 $square_variation_id = Product::get_square_item_variation_id( $product_id, false );
1071
1072 if ( $product instanceof \WC_Product ) {
1073
1074 $product_inventory_changes = array();
1075
1076 if ( $product->is_type( 'variable' ) && $product->has_child() ) {
1077
1078 foreach ( $product->get_children() as $child_id ) {
1079
1080 $child = wc_get_product( $child_id );
1081 $inventory_change = Product::get_inventory_change_physical_count_type( $child );
1082
1083 if ( $child instanceof \WC_Product && $child->get_manage_stock() && $inventory_change ) {
1084
1085 $product_inventory_changes[] = $inventory_change;
1086 }
1087 }
1088 } elseif ( $square_variation_id ) {
1089
1090 $inventory_change = Product::get_inventory_change_physical_count_type( $product );
1091
1092 if ( $inventory_change && $product->get_manage_stock() ) {
1093
1094 $product_inventory_changes[] = $inventory_change;
1095 }
1096 }
1097
1098 if ( self::BATCH_CHANGE_INVENTORY_LIMIT >= $inventory_change_count + count( $product_inventory_changes ) ) {
1099 if ( ! empty( $product_inventory_changes ) ) {
1100 $inventory_changes[] = $product_inventory_changes;
1101 $inventory_change_count += count( $product_inventory_changes );
1102 }
1103 unset( $product_ids[ $key ] );
1104
1105 } else {
1106
1107 break;
1108 }
1109 } else {
1110
1111 unset( $product_ids[ $key ] );
1112 }
1113 }
1114
1115 if ( ! empty( $inventory_changes ) ) {
1116
1117 $inventory_changes = array_merge( ...$inventory_changes );
1118 $idempotency_key = wc_square()->get_idempotency_key( md5( serialize( $inventory_changes ) ) . '_change_inventory' );
1119 $result = wc_square()->get_api()->batch_change_inventory( $idempotency_key, $inventory_changes );
1120 }
1121
1122 $this->set_attr( 'inventory_push_product_ids', $product_ids );
1123 $this->set_attr( 'push_inventory_count', $count + count( $inventory_changes ) );
1124
1125 if ( empty( $product_ids ) ) {
1126
1127 $this->complete_step( 'push_inventory' );
1128 }
1129 }
1130
1131
1132 /**
1133 * Performs a sync when Square is the Sync setting.
1134 *
1135 * @since 2.0.0
1136 */
1137 protected function square_sor_sync() {
1138
1139 $synced_product_ids = $this->get_attr( 'validated_product_ids', array() );
1140 $processed_product_ids = $this->get_attr( 'processed_product_ids', array() );
1141 $deleted_square_variations = $this->get_attr( 'deleted_square_variations', array() );
1142 $unprocessed_product_ids = array_diff( array_merge( $synced_product_ids, $deleted_square_variations ), $processed_product_ids );
1143 $catalog_processed = $this->get_attr( 'catalog_processed', false );
1144
1145 if ( $catalog_processed ) {
1146
1147 wc_square()->log( 'Square catalog fully processed' );
1148
1149 if ( ! empty( $unprocessed_product_ids ) ) {
1150 $this->mark_failed_products( $unprocessed_product_ids );
1151 }
1152
1153 $this->complete_step( 'square_sor_sync' );
1154 return;
1155 }
1156
1157 try {
1158
1159 $response_data = $this->get_attr( 'catalog_objects_search_response_data', null );
1160
1161 if ( ! $response_data ) {
1162
1163 wc_square()->log( 'Generating a new catalog search request' );
1164
1165 $cursor = $this->get_attr( 'square_sor_cursor' );
1166
1167 $response = wc_square()->get_api()->search_catalog_objects(
1168 array(
1169 'cursor' => $cursor,
1170 'object_types' => array( 'ITEM' ),
1171 'include_related_objects' => true,
1172 'limit' => $this->get_max_objects_to_retrieve(),
1173 )
1174 );
1175
1176 $response_data = $response->get_data();
1177
1178 $this->set_attr( 'catalog_objects_search_response_data', wp_json_encode( $response_data ) );
1179
1180 } else {
1181
1182 $response_data = ApiHelper::deserialize( $response_data, new SearchCatalogObjectsResponse() );
1183 }
1184
1185 if ( ! $response_data instanceof SearchCatalogObjectsResponse ) {
1186 throw new \Exception( 'API response data is missing' );
1187 }
1188
1189 $cursor = $response_data->getCursor();
1190 $this->set_attr( 'square_sor_cursor', $cursor );
1191
1192 $catalog_processed = ! $cursor;
1193 $this->set_attr( 'catalog_processed', $catalog_processed );
1194
1195 } catch ( \Exception $exception ) { // bail early and fail for any API and plugin errors
1196
1197 $this->fail( 'Product sync failed. ' . $exception->getMessage() );
1198 return;
1199 }
1200
1201 $related_objects = $response_data->getRelatedObjects();
1202
1203 if ( $related_objects && is_array( $related_objects ) ) {
1204 // first import any related categories
1205 foreach ( $related_objects as $related_object ) {
1206 if ( 'CATEGORY' === $related_object->getType() ) {
1207 Category::import_or_update( $related_object );
1208 }
1209 }
1210 }
1211
1212 $pull_inventory_variation_ids = $this->get_attr( 'pull_inventory_variation_ids', array() );
1213
1214 /** @var \Square\Models\CatalogObject[] */
1215 $catalog_objects = $products_to_update = array();
1216
1217 $catalog_objects = $response_data->getObjects() ? $response_data->getObjects() : array();
1218
1219 wc_square()->log( 'Searching for products in ' . count( $catalog_objects ) . ' Square objects' );
1220
1221 foreach ( $catalog_objects as $object ) {
1222
1223 $found_product = null;
1224
1225 if ( ! $object instanceof CatalogObject ) {
1226 continue;
1227 }
1228
1229 // filter out objects that aren't at our configured location
1230 if ( ! $object->getPresentAtAllLocations() && ( ! is_array( $object->getPresentAtLocationIds() ) || ! in_array( wc_square()->get_settings_handler()->get_location_id(), $object->getPresentAtLocationIds(), true ) ) ) {
1231 continue;
1232 }
1233
1234 // even simple items have a single variation
1235 if ( ! is_array( $object->getItemData()->getVariations() ) ) {
1236 continue;
1237 }
1238
1239 $maybe_parent_product = Product::get_product_by_square_id( $object->getId() );
1240
1241 if ( $maybe_parent_product instanceof \WC_Product && $maybe_parent_product->is_type( 'variable' ) ) {
1242 $missing_variations = array();
1243 $woo_product_variations = $maybe_parent_product->get_children();
1244 $square_product_variations = $object->getItemData()->getVariations();
1245 $square_variation_ids = array_map(
1246 function( $square_product_variation ) {
1247 return wc_get_product_id_by_sku( $square_product_variation->getItemVariationData()->getSku() );
1248 },
1249 $square_product_variations
1250 );
1251
1252 foreach ( $woo_product_variations as $woo_product_variation_id ) {
1253 if ( ! in_array( (int) $woo_product_variation_id, $square_variation_ids, true ) ) {
1254 $woo_product_variation = wc_get_product( $woo_product_variation_id );
1255 $woo_product_variation->set_status( 'private' );
1256 $woo_product_variation->save();
1257 $missing_variations[] = $woo_product_variation_id;
1258 }
1259 }
1260
1261 $missing_variations = array_diff( $woo_product_variations, $square_variation_ids );
1262 $this->set_attr( 'deleted_square_variations', $missing_variations );
1263 }
1264
1265 foreach ( $object->getItemData()->getVariations() as $variation ) {
1266
1267 $found_product_id = wc_get_product_id_by_sku( $variation->getItemVariationData()->getSku() );
1268
1269 // bail if this product has already been processed
1270 if ( in_array( $found_product_id, $processed_product_ids, false ) ) { // phpcs:ignore WordPress.PHP.StrictInArray.FoundNonStrictFalse
1271 break;
1272 }
1273
1274 $found_product = wc_get_product( $found_product_id );
1275
1276 if ( ! $found_product ) {
1277 continue;
1278 }
1279
1280 if ( $found_product instanceof \WC_Product_Variation ) {
1281
1282 $found_variation = $found_product;
1283 $found_parent_id = $found_product->get_parent_id() ? $found_product->get_parent_id() : 0;
1284 $found_product = null;
1285
1286 // bail if this parent product has already been processed
1287 if ( in_array( $found_parent_id, $processed_product_ids, false ) ) { // phpcs:ignore WordPress.PHP.StrictInArray.FoundNonStrictFalse
1288 break;
1289 }
1290
1291 $found_parent = wc_get_product( $found_parent_id );
1292
1293 if ( $found_parent ) {
1294
1295 Product::set_square_item_variation_id( $found_variation, $variation->getId() );
1296
1297 $found_product = $found_parent;
1298 }
1299
1300 break;
1301
1302 } else {
1303
1304 Product::set_square_item_variation_id( $found_product, $variation->getId() );
1305 }
1306 }
1307
1308 if ( $found_product && in_array( $found_product->get_id(), $synced_product_ids, false ) ) { // phpcs:disable WordPress.PHP.StrictInArray.FoundNonStrictFalse
1309
1310 Product::set_square_item_id( $found_product, $object->getId() );
1311
1312 $products_to_update[] = $found_product;
1313
1314 $catalog_objects[ $found_product->get_id() ] = $object;
1315 }
1316 }
1317
1318 wc_square()->log( 'Found ' . count( $products_to_update ) . ' products with matching SKUs' );
1319
1320 // Square SOR always gets the latest inventory
1321 // set this before processing so nothing is missed during processing
1322 wc_square()->get_sync_handler()->set_inventory_last_synced_at();
1323
1324 foreach ( $products_to_update as $product ) {
1325
1326 try {
1327
1328 $square_object = ! empty( $catalog_objects[ $product->get_id() ] ) ? $catalog_objects[ $product->get_id() ] : null;
1329
1330 // if no Square object was found
1331 if ( ! $square_object ) {
1332 $record = array(
1333 'type' => 'alert',
1334 'product_id' => $product->get_id(),
1335 /* translators: Placeholder %s Product ID */
1336 'message' => sprintf( esc_html__( '%s does not exist in the Square catalog.', 'woocommerce-square' ), '<a href="' . esc_url( get_edit_post_link( $product->get_id() ) ) . '">' . $product->get_formatted_name() . '</a>' ),
1337 );
1338
1339 // if enabled, hide the product from the catalog
1340 if ( wc_square()->get_settings_handler()->is_system_of_record_square() && wc_square()->get_settings_handler()->hide_missing_square_products() ) {
1341 try {
1342 $product->set_catalog_visibility( 'hidden' );
1343 $product->save();
1344
1345 $record['product_hidden'] = true;
1346 } catch ( \Exception $e ) {
1347 $record['message'] .= esc_html__( 'This product failed to be hidden.', 'woocommerce-square' );
1348 }
1349 }
1350
1351 Records::set_record( $record );
1352 continue;
1353 }
1354
1355 foreach ( $square_object->getItemData()->getVariations() as $variation ) {
1356 $pull_inventory_variation_ids[] = $variation->getId();
1357 }
1358
1359 Product::update_from_square( $product, $square_object->getItemData(), false );
1360
1361 $image_id = Product::get_catalog_item_thumbnail_id( $square_object );
1362 Product::update_image_from_square( $product, $image_id );
1363
1364 } catch ( \Exception $exception ) {
1365
1366 Records::set_record(
1367 array(
1368 'type' => 'alert',
1369 'product_id' => $product->get_id(),
1370 /* translators: Placeholder %1$s Product Name, %2$s Exception message */
1371 'message' => sprintf( esc_html__( 'Could not sync %1$s data from Square. %2$s.', 'woocommerce-square' ), '<a href="' . esc_url( get_edit_post_link( $product->get_id() ) ) . '">' . $product->get_formatted_name() . '</a>', $exception->getMessage() ),
1372 )
1373 );
1374
1375 }
1376
1377 $processed_product_ids[] = $product->get_id();
1378 }
1379
1380 $this->set_attr( 'catalog_objects_search_response_data', null );
1381
1382 $this->set_attr( 'pull_inventory_variation_ids', $pull_inventory_variation_ids );
1383
1384 $this->set_attr( 'processed_product_ids', $processed_product_ids );
1385 }
1386
1387
1388 /**
1389 * Pulls the latest inventory counts for the variation IDs in `pull_inventory_variation_ids`.
1390 *
1391 * @since 2.0.2
1392 *
1393 * @throws \Exception
1394 */
1395 protected function pull_inventory() {
1396
1397 $processed_ids = $this->get_attr( 'processed_square_variation_ids', array() );
1398
1399 $in_progress = wp_parse_args(
1400 $this->get_attr(
1401 'in_progress_pull_inventory',
1402 array()
1403 ),
1404 array(
1405 'response_data' => null,
1406 'processed_variation_ids' => array(),
1407 )
1408 );
1409
1410 $response_data = null;
1411
1412 // if a response was never cleared, we likely had a timeout
1413 if ( null !== $in_progress['response_data'] ) {
1414 $response_data = ApiHelper::deserialize( $in_progress['response_data'], new BatchRetrieveInventoryCountsResponse() );
1415 }
1416
1417 // if the saved response was somehow corrupted, start over
1418 if ( ! $response_data instanceof BatchRetrieveInventoryCountsResponse ) {
1419
1420 $square_variation_ids = $this->get_attr( 'pull_inventory_variation_ids', array() );
1421
1422 // remove IDs that have already been processed
1423 $square_variation_ids = array_diff( $square_variation_ids, $processed_ids );
1424
1425 if ( empty( $square_variation_ids ) ) {
1426
1427 $this->complete_step( 'pull_inventory' );
1428 return;
1429 }
1430
1431 if ( count( $square_variation_ids ) > self::BATCH_INVENTORY_COUNTS_LIMIT ) {
1432
1433 $variation_ids_batch = array_slice( $square_variation_ids, 0, self::BATCH_INVENTORY_COUNTS_LIMIT );
1434
1435 $this->set_attr( 'pull_inventory_variation_ids', array_diff( $square_variation_ids, $variation_ids_batch ) );
1436
1437 $square_variation_ids = $variation_ids_batch;
1438 }
1439
1440 $cursor = '';
1441 $response_counts = array();
1442 $location_ids = array( wc_square()->get_settings_handler()->get_location_id() );
1443 $catalog_object_ids = array_values( $square_variation_ids );
1444
1445 // Repeat fetching objects using the cursor when the results are paginated.
1446 do {
1447 $response = wc_square()->get_api()->batch_retrieve_inventory_counts(
1448 array(
1449 'catalog_object_ids' => $catalog_object_ids,
1450 'location_ids' => $location_ids,
1451 'cursor' => $cursor,
1452 )
1453 );
1454
1455 if ( ! $response->get_data() instanceof BatchRetrieveInventoryCountsResponse ) {
1456 throw new \Exception( 'Response data missing or invalid' );
1457 }
1458
1459 $response_data = $response->get_data();
1460
1461 // if no counts were returned, there's nothing to process
1462 if ( ! is_array( $response_data->getCounts() ) ) {
1463
1464 $this->set_attr( 'processed_square_variation_ids', array_merge( $processed_ids, $square_variation_ids ) );
1465 return;
1466 }
1467
1468 $in_progress['response_data'] = wp_json_encode( $response_data, JSON_PRETTY_PRINT );
1469
1470 // Store the response counts to be processed later.
1471 $response_counts = array_merge( $response_counts, $response_data->getCounts() );
1472 $cursor = $response->get_data()->getCursor();
1473
1474 } while ( ! empty( $cursor ) );
1475 }
1476
1477 $catalog_objects_inventory_stats = array();
1478
1479 foreach ( $response_counts as $count ) {
1480 // If catalog stats array already contains the catalog object marked as IN_STOCK, then continue.
1481 if ( isset( $catalog_objects_inventory_stats[ $count->getCatalogObjectId() ] ) && $catalog_objects_inventory_stats[ $count->getCatalogObjectId() ]['IN_STOCK'] ) {
1482 continue;
1483 // Else if the catalog object is IN_STOCK, then mark IN_STOCK as true and set the quantity for later use.
1484 } elseif ( 'IN_STOCK' === $count->getState() ) {
1485 $catalog_objects_inventory_stats[ $count->getCatalogObjectId() ] = array(
1486 'IN_STOCK' => true,
1487 'quantity' => $count->getQuantity(),
1488 );
1489 // Else if the catalog object doesn't have an IN_STOCK status, then mark IN_STOCK as false and set the quantity as 0 for later use.
1490 } else {
1491 $catalog_objects_inventory_stats[ $count->getCatalogObjectId() ] = array(
1492 'IN_STOCK' => false,
1493 'quantity' => 0,
1494 );
1495 }
1496 }
1497
1498 $catalog_objects_tracking_stats = Helper::get_catalog_objects_tracking_stats( $catalog_object_ids );
1499
1500 foreach ( $catalog_objects_tracking_stats as $catalog_object_id => $is_tracking_inventory ) {
1501
1502 if ( in_array( $catalog_object_id, $in_progress['processed_variation_ids'], false ) ) { // phpcs:disable WordPress.PHP.StrictInArray.FoundNonStrictFalse
1503 continue;
1504 }
1505
1506 $product = Product::get_product_by_square_variation_id( $catalog_object_id );
1507
1508 if ( $product instanceof \WC_Product ) {
1509
1510 /* If catalog object is tracked and has a quantity > 0 set in Square. */
1511 if ( $is_tracking_inventory && isset( $catalog_objects_inventory_stats[ $catalog_object_id ] ) ) {
1512 $product->set_stock_quantity( (float) $catalog_objects_inventory_stats[ $catalog_object_id ]['quantity'] );
1513 $product->set_manage_stock( true );
1514
1515 /* If the catalog object is tracked but the quantity in Square is set to 0. */
1516 } elseif ( $is_tracking_inventory ) {
1517 $product->set_stock_quantity( 0 );
1518 $product->set_manage_stock( true );
1519
1520 /* If the catalog object is not tracked in Square at all. */
1521 } else {
1522 $product->set_stock_status( 'instock' );
1523 $product->set_manage_stock( false );
1524 }
1525
1526 $product->save();
1527
1528 $in_progress['processed_variation_ids'][] = $catalog_object_id;
1529 }
1530
1531 $this->set_attr( 'in_progress_pull_inventory', $in_progress );
1532 }
1533
1534 $this->set_attr( 'processed_square_variation_ids', array_merge( $processed_ids, $in_progress['processed_variation_ids'] ) );
1535
1536 // clear any in-progress data
1537 $this->set_attr( 'in_progress_pull_inventory', array() );
1538 }
1539
1540 /**
1541 * Marks a set of products as failed to sync.
1542 *
1543 * @since 2.0.0
1544 *
1545 * @param \WC_Product[]|int[] $products products to mark as failed
1546 */
1547 protected function mark_failed_products( $products = array() ) {
1548
1549 foreach ( $products as $product ) {
1550
1551 $product = wc_get_product( $product );
1552
1553 if ( ! $product instanceof \WC_Product ) {
1554 continue;
1555 }
1556
1557 $record_data = array(
1558 'type' => 'alert',
1559 'product_id' => $product->get_id(),
1560 );
1561
1562 // optionally hide unmatched products from catalog
1563 if ( wc_square()->get_settings_handler()->is_system_of_record_square() && wc_square()->get_settings_handler()->hide_missing_square_products() ) {
1564
1565 try {
1566
1567 $product->set_catalog_visibility( 'hidden' );
1568 $product->save();
1569
1570 $record_data['product_hidden'] = true;
1571
1572 } catch ( \Exception $e ) {
1573 /* translators: Placeholder %1$s Product Name, %2$s Exception message */
1574 $record['message'] = sprintf( esc_html__( '%1$s was deleted in Square but could not be hidden in WooCommerce. %2$s.', 'woocommerce-square' ), '<a href="' . esc_url( get_edit_post_link( $product->get_id() ) ) . '">' . $product->get_formatted_name() . '</a>', $e->getMessage() );
1575 }
1576 }
1577
1578 Records::set_record( $record_data );
1579 }
1580 }
1581
1582
1583 /**
1584 * Gets a list of unique category IDs used by a group of product IDs.
1585 *
1586 * @since 2.0.0
1587 *
1588 * @param int[] $product_ids array of product IDs.
1589 * @return int[]
1590 */
1591 protected function get_shared_category_ids( $product_ids ) {
1592
1593 if ( ! empty( $product_ids ) ) {
1594 $category_ids = get_terms(
1595 array(
1596 'taxonomy' => 'product_cat',
1597 'fields' => 'ids',
1598 'object_ids' => $product_ids,
1599 )
1600 );
1601 }
1602
1603 return ! empty( $category_ids ) && ! is_wp_error( $category_ids ) ? $category_ids : array();
1604 }
1605
1606
1607 /**
1608 * Assigns the next steps needed for this sync job.
1609 *
1610 * @since 2.0.0
1611 */
1612 protected function assign_next_steps() {
1613
1614 $next_steps = array();
1615
1616 if ( $this->is_system_of_record_woocommerce() ) {
1617
1618 if ( 'delete' === $this->get_attr( 'action' ) ) {
1619
1620 $next_steps = array(
1621 'validate_products',
1622 'update_matched_products',
1623 'search_matched_products',
1624 );
1625
1626 } else {
1627
1628 $next_steps = array(
1629 'validate_products',
1630 'extract_category_ids',
1631 'refresh_category_mappings',
1632 'query_unmapped_categories',
1633 'upsert_categories',
1634 'update_matched_products',
1635 'search_matched_products',
1636 'upsert_new_products',
1637 );
1638
1639 // only handle product inventory if enabled
1640 if ( wc_square()->get_settings_handler()->is_inventory_sync_enabled() ) {
1641 $next_steps[] = 'push_inventory';
1642 $next_steps[] = 'pull_inventory';
1643 }
1644 }
1645 } elseif ( $this->is_system_of_record_square() ) {
1646
1647 $next_steps = array(
1648 'validate_products',
1649 'square_sor_sync',
1650 );
1651
1652 // only pull product inventory if enabled
1653 if ( wc_square()->get_settings_handler()->is_inventory_sync_enabled() ) {
1654 $next_steps[] = 'pull_inventory';
1655 }
1656 }
1657
1658 $this->set_attr( 'next_steps', $next_steps );
1659 }
1660
1661
1662 /**
1663 * Gets the maximum number of objects to retrieve in a single sync job.
1664 *
1665 * @since 2.0.0
1666 *
1667 * @return int
1668 */
1669 protected function get_max_objects_to_retrieve() {
1670 $max = $this->get_attr( 'max_objects_to_retrieve', 100 );
1671
1672 /**
1673 * Filters the maximum number of objects to retrieve in a single sync job.
1674 *
1675 * @since 2.0.0
1676 *
1677 * $param int $max
1678 */
1679 return max( 1, (int) apply_filters( 'wc_square_sync_max_objects_to_retrieve', $max ) );
1680 }
1681
1682
1683 /**
1684 * Gets the maximum number of objects per batch in a single sync job.
1685 *
1686 * @deprecated 3.2
1687 * @since 2.0.0
1688 *
1689 * @return int
1690 */
1691 protected function get_max_objects_per_batch() {
1692
1693 wc_deprecated_function( __METHOD__, '3.2' );
1694
1695 $max = $this->get_attr( 'max_objects_per_batch', 1000 );
1696
1697 /**
1698 * Filters the maximum number of objects per batch in a single sync job.
1699 *
1700 * @since 2.0.0
1701 *
1702 * $param int $max
1703 */
1704 return max( 10, (int) apply_filters( 'wc_square_sync_max_objects_per_batch', $max ) );
1705 }
1706
1707
1708 /**
1709 * Gets the maximum number of objects per batch upsert in a single request.
1710 *
1711 * @since 2.0.0
1712 *
1713 * @return int
1714 */
1715 protected function get_max_objects_per_upsert() {
1716
1717 $max = $this->get_attr( 'max_objects_per_upsert', 500 );
1718
1719 /**
1720 * Filters the maximum number of objects per upsert in a single request.
1721 *
1722 * @since 2.0.0
1723 *
1724 * $param int $max
1725 */
1726 return max( 1, (int) apply_filters( 'wc_square_sync_max_objects_per_upsert', $max ) );
1727 }
1728
1729
1730 /**
1731 * Gets the maximum number of objects allowed in a single sync job.
1732 *
1733 * @since 2.0.0
1734 *
1735 * @return int
1736 */
1737 protected function get_max_objects_total() {
1738
1739 $max = $this->get_attr( 'max_objects_total', self::BATCH_UPSERT_OBJECT_LIMIT );
1740
1741 /**
1742 * Filters the maximum number of objects allowed in a single sync job.
1743 *
1744 * @since 2.0.0
1745 *
1746 * $param int $max
1747 */
1748 return max( 1, (int) apply_filters( 'wc_square_sync_max_objects_total', $max ) );
1749 }
1750 }
1751