PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.7
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.7
16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 All 506 releases
jetpack / jetpack_vendor / automattic / jetpack-sync / src / modules / class-woocommerce-analytics.php

class-woocommerce-analytics.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.7, at jetpack_vendor/automattic/jetpack-sync/src/modules/class-woocommerce-analytics.php

1,348 lines 42.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WooCommerce Analytics sync module.
4 *
5 * Syncs the data behind WooCommerce Analytics reports (wc_order_stats and the
6 * order product/coupon/tax lookup tables) to WordPress.com.
7 *
8 * Sync module name: `woocommerce_analytics`. The name, the action names, and the
9 * payload shapes are consumed by the WPCOM receiving side and by consumer packages
10 * (Premium Analytics, the standalone WooCommerce Analytics plugin); treat them as a public contract.
11 *
12 * This module is NOT registered by default. Consumers own its registration,
13 * WooCommerce runtime guard, full-sync policy, and any additional Sync data
14 * configuration. The module provides the minimum option and post meta requirements.
15 *
16 * WooCommerce is a runtime (not composer) dependency. The WC classes
17 * referenced here resolve via WooCommerce's autoloader at runtime; registration is
18 * guarded so this class is only instantiated when WooCommerce is active.
19 *
20 * @package automattic/jetpack-sync
21 */
22
23 namespace Automattic\Jetpack\Sync\Modules;
24
25 use Automattic\WooCommerce\Admin\API\Reports\Coupons\DataStore as CouponsDataStore;
26 use Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore as OrderStatsDataStore;
27 use Automattic\WooCommerce\Internal\Fulfillments\FulfillmentUtils;
28 use Automattic\WooCommerce\Utilities\FeaturesUtil;
29 use Automattic\WooCommerce\Utilities\OrderUtil;
30 use DateTimeZone;
31 use WC_Abstract_Order;
32 use WC_Coupon;
33 use WC_DateTime;
34 use WC_Order;
35 use WC_Order_Factory;
36 use WC_Tax;
37
38 if ( ! defined( 'ABSPATH' ) ) {
39 exit( 0 );
40 }
41
42 /**
43 * WooCommerce Analytics Module class.
44 */
45 class WooCommerce_Analytics extends Module {
46
47 /**
48 * Options required by WooCommerce Analytics Sync.
49 *
50 * @var string[]
51 */
52 private static $options_whitelist = array(
53 'woocommerce_excluded_report_order_statuses',
54 );
55
56 /**
57 * Post meta required by WooCommerce Analytics Sync.
58 *
59 * @var string[]
60 */
61 private static $post_meta_whitelist = array(
62 '_stock',
63 '_stock_quantity',
64 '_cogs_total_value',
65 '_global_unique_id',
66 );
67
68 /**
69 * WooCommerce Analytics' order class for each plain order class, which adds the report methods used here.
70 *
71 * @var string[]
72 */
73 private static $analytics_order_classes = array(
74 'WC_Order' => 'Automattic\WooCommerce\Admin\Overrides\Order',
75 'WC_Order_Refund' => 'Automattic\WooCommerce\Admin\Overrides\OrderRefund',
76 );
77
78 /**
79 * Constructor.
80 */
81 public function __construct() {
82 add_filter( 'jetpack_sync_options_whitelist', array( $this, 'add_woocommerce_analytics_options_whitelist' ), 10 );
83 add_filter( 'jetpack_sync_post_meta_whitelist', array( $this, 'add_woocommerce_analytics_post_meta_whitelist' ), 10 );
84 }
85
86 /**
87 * Add the options required by WooCommerce Analytics Sync.
88 *
89 * @param array $list Existing options whitelist.
90 * @return array Updated options whitelist.
91 */
92 public function add_woocommerce_analytics_options_whitelist( $list ) {
93 return array_values( array_unique( array_merge( $list, self::$options_whitelist ) ) );
94 }
95
96 /**
97 * Add the post meta required by WooCommerce Analytics Sync.
98 *
99 * @param array $list Existing post meta whitelist.
100 * @return array Updated post meta whitelist.
101 */
102 public function add_woocommerce_analytics_post_meta_whitelist( $list ) {
103 return array_values( array_unique( array_merge( $list, self::$post_meta_whitelist ) ) );
104 }
105
106 /**
107 * Get the module name.
108 *
109 * @return string
110 */
111 public function name() {
112 return 'woocommerce_analytics';
113 }
114
115 /**
116 * Get the ID field for the module.
117 *
118 * @return string
119 */
120 public function id_field() {
121 return 'order_id';
122 }
123
124 /**
125 * Get the table in the database.
126 *
127 * @return string
128 */
129 public function table() {
130 global $wpdb;
131 return $wpdb->prefix . 'wc_order_stats';
132 }
133
134 /**
135 * Init listeners.
136 *
137 * @param callable $handler Action handler callable.
138 *
139 * @return void
140 */
141 public function init_listeners( $handler ) {
142 // Actions to update order stats.
143 add_action( 'woocommerce_analytics_delete_order_stats', array( $this, 'sync_deleted_analytics_data' ) );
144
145 // In WooCommerce 10.3+ the new action is available.
146 if ( defined( 'WC_VERSION' ) && version_compare( WC_VERSION, '10.3', '>=' ) ) {
147 add_action( 'woocommerce_order_scheduler_after_import_order', array( $this, 'sync_analytics_reports_data' ) );
148 } else {
149 add_action( 'woocommerce_analytics_update_order_stats', array( $this, 'sync_analytics_reports_data' ) );
150 }
151
152 // Sync actions.
153 add_action( 'woocommerce_analytics_sync_reports_data', $handler );
154 add_action( 'woocommerce_analytics_delete_reports_data', $handler );
155
156 // Expand data.
157 add_filter( 'jetpack_sync_before_enqueue_woocommerce_analytics_sync_reports_data', array( $this, 'expand_data' ) );
158 add_filter( 'jetpack_sync_before_enqueue_woocommerce_analytics_delete_reports_data', array( $this, 'expand_data' ) );
159 }
160
161 /**
162 * Expand order stats data and attribution data.
163 *
164 * @param array|mixed $args List of arguments.
165 *
166 * @return array|false
167 */
168 public function expand_data( $args ) {
169 if ( ! is_array( $args ) || ! isset( $args[0] ) ) {
170 return false;
171 }
172
173 $data = $args[0];
174
175 return $data;
176 }
177
178 /**
179 * Init full sync listeners.
180 *
181 * @param callable $handler Action handler callable.
182 *
183 * @return void
184 */
185 public function init_full_sync_listeners( $handler ) {
186 add_action( 'jetpack_full_sync_woocommerce_analytics', $handler );
187 }
188
189 /**
190 * Get full sync actions.
191 *
192 * @return string[] The full sync actions.
193 */
194 public function get_full_sync_actions() {
195 return array( 'jetpack_full_sync_woocommerce_analytics' );
196 }
197
198 /**
199 * Get the supported object types.
200 *
201 * @return array The supported object types.
202 */
203 private function get_supported_object_types() {
204 return array( 'order', 'order_tax_lookup', 'order_product_lookup', 'order_coupon_lookup' );
205 }
206
207 /**
208 * Retrieves multiple orders data by their ID.
209 *
210 * @param string $object_type Type of object to retrieve. Should be `order`.
211 * @param array $ids List of order IDs.
212 *
213 * @return array
214 */
215 public function get_objects_by_id( $object_type, $ids ) {
216 if ( empty( $ids ) || ! is_array( $ids ) || empty( $object_type ) ) {
217 return array();
218 }
219
220 if ( ! in_array( $object_type, $this->get_supported_object_types(), true ) ) {
221 return array();
222 }
223
224 $orders = self::get_analytics_orders(
225 array(
226 'post__in' => $ids,
227 'post_status' => WooCommerce_HPOS_Orders::get_all_possible_order_status_keys(),
228 'limit' => -1,
229 'orderby' => 'id',
230 'order' => 'DESC',
231 )
232 );
233
234 // Get the order stats data for the orders.
235 $order_stats_items = $this->get_order_stats_items( $ids );
236 $order_stats_data = array();
237 if ( ! empty( $order_stats_items ) ) {
238 $order_stats_data = array_column( $order_stats_items, null, 'order_id' );
239 }
240
241 $orders_data = array();
242 $found_order_ids = array();
243 foreach ( $orders as $order ) {
244 $order_id = $order->get_id();
245 $found_order_ids[] = $order_id;
246 if ( 'order' === $object_type ) {
247 // Sync everything if the object type is order.
248 $orders_data[ $order_id ] = $this->build_woocommerce_analytics_reports_data( $order );
249 } else {
250 $orders_data[ $order_id ] = $this->build_woocommerce_analytics_reports_lookup_data( $order, $object_type );
251 }
252 if ( isset( $order_stats_data[ $order_id ] ) ) {
253 $this->do_order_status_discrepancy_check( $order, $order_stats_data[ $order_id ] );
254 }
255 }
256
257 // Check for missing order_ids in wc_order_stats table for orders that were not found.
258 $missing_order_ids = array_diff( $ids, $found_order_ids );
259
260 /**
261 * Trigger missing orders detected action.
262 *
263 * @param array $missing_order_ids The missing order IDs.
264 */
265 do_action( 'woocommerce_analytics_missing_orders_detected', $missing_order_ids );
266
267 foreach ( $missing_order_ids as $missing_order_id ) {
268 if ( 'order' === $object_type ) {
269 $orders_data[ $missing_order_id ] = $this->build_woocommerce_analytics_reports_data( $missing_order_id );
270 } else {
271 $orders_data[ $missing_order_id ] = $this->build_woocommerce_analytics_reports_lookup_data( $missing_order_id, $object_type );
272 }
273 }
274 // Let's sort the orders by ID in descending order. This is useful for the full sync to ensure that the latest orders are processed first.
275 krsort( $orders_data, SORT_NUMERIC );
276 return $orders_data;
277 }
278
279 /**
280 * Retrieve the analytics order data by its ID.
281 *
282 * @param string $object_type Type of the sync object.
283 * @param int $id ID of the sync object.
284 * @return mixed Object, or false if the object is invalid.
285 */
286 public function get_object_by_id( $object_type, $id ) {
287 if ( ! in_array( $object_type, $this->get_supported_object_types(), true ) ) {
288 return false;
289 }
290
291 $order = wc_get_order( $id );
292
293 if ( ! $order instanceof WC_Abstract_Order ) {
294 $order = $id; // If the order does not exists. We'll check if the order_id exists in wc_order_stats table.
295 }
296
297 if ( 'order' === $object_type ) {
298 return $this->build_woocommerce_analytics_reports_data( $order );
299 }
300
301 return $this->build_woocommerce_analytics_reports_lookup_data( $order, $object_type );
302 }
303
304 /**
305 * Enqueue full sync actions.
306 *
307 * @param array $config Full sync configuration.
308 * @param int $max_items_to_enqueue Maximum number of items to enqueue.
309 * @param boolean $state True if full sync has finished enqueueing this module.
310 * @return array Number of actions enqueued, and next module state.
311 */
312 public function enqueue_full_sync_actions( $config, $max_items_to_enqueue, $state ) {
313 return $this->enqueue_all_ids_as_action(
314 'jetpack_full_sync_woocommerce_analytics',
315 $this->table(),
316 $this->id_field(),
317 $this->get_where_sql( $config ),
318 $max_items_to_enqueue,
319 $state
320 );
321 }
322
323 /**
324 * Estimate full sync actions.
325 *
326 * @param array $config Full sync configuration.
327 * @return int Number of items yet to be enqueued.
328 */
329 public function estimate_full_sync_actions( $config ) {
330 global $wpdb;
331
332 $query = "SELECT COUNT(*) FROM {$this->table()}";
333
334 $where_sql = $this->get_where_sql( $config );
335 if ( $where_sql ) {
336 $query .= ' WHERE ' . $where_sql;
337 }
338
339 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
340 $count = (int) $wpdb->get_var( $query );
341
342 return (int) ceil( $count / self::ARRAY_CHUNK_SIZE );
343 }
344
345 /**
346 * Get where SQL clause for the module.
347 *
348 * @param array $config Full sync configuration.
349 * @return string
350 */
351 public function get_where_sql( $config ) {
352 global $wpdb;
353
354 $where = '1=1';
355
356 if ( ! empty( $config['start_date'] ) ) {
357 $where .= $wpdb->prepare( ' AND date_created >= %s', $config['start_date'] );
358 }
359 if ( ! empty( $config['end_date'] ) ) {
360 $where .= $wpdb->prepare( ' AND date_created <= %s', $config['end_date'] );
361 }
362
363 /**
364 * Filter the WHERE SQL for analytics full sync
365 *
366 * @param string $where The WHERE SQL clause
367 * @param array $config The sync configuration
368 */
369 return apply_filters( 'woocommerce_analytics_full_sync_where_sql', $where, $config );
370 }
371
372 /**
373 * Initialize module in the sender.
374 */
375 public function init_before_send() {
376 // Full sync.
377 add_filter(
378 'jetpack_sync_before_send_jetpack_full_sync_woocommerce_analytics',
379 array( $this, 'build_full_sync_action_array' )
380 );
381 }
382
383 /**
384 * Build the full sync action object.
385 *
386 * @param array $args An array with filtered objects and previous end.
387 *
388 * @return array An array with orders and previous end.
389 */
390 public function build_full_sync_action_array( $args ) {
391 list( $filtered_orders, $previous_end ) = $args;
392 return array(
393 'orders' => $filtered_orders['objects'],
394 'previous_end' => $previous_end,
395 );
396 }
397
398 /**
399 * Given the Module Configuration and Status return the next chunk of items to send.
400 * This function also expands the posts and metadata and filters them based on the maximum size constraints.
401 *
402 * @param array $config This module Full Sync configuration.
403 * @param array $status This module Full Sync status.
404 * @param int $chunk_size Chunk size.
405 *
406 * @return array
407 */
408 public function get_next_chunk( $config, $status, $chunk_size ) {
409
410 $order_ids = parent::get_next_chunk( $config, $status, $chunk_size );
411
412 if ( empty( $order_ids ) ) {
413 return array();
414 }
415
416 $orders = $this->get_objects_by_id( 'order', $order_ids );
417
418 // If no orders were fetched, make sure to return the expected structure so that status is updated correctly.
419 if ( empty( $orders ) ) {
420 return array(
421 'object_ids' => $order_ids,
422 'objects' => array(),
423 );
424 }
425
426 // Filter the orders based on the maximum size constraints.
427 list( $filtered_order_ids, $filtered_orders, ) = $this->filter_analytics_objects_by_size( $orders );
428
429 return array(
430 'object_ids' => $filtered_order_ids,
431 'objects' => $filtered_orders,
432 );
433 }
434
435 /**
436 * Filters objects and metadata based on maximum size constraints.
437 * It always allows the first object with its metadata, even if they exceed the limit.
438 *
439 * @param array $objects The array of objects to filter.
440 *
441 * @return array An array containing the filtered object IDsand filtered objects
442 */
443 public function filter_analytics_objects_by_size( $objects ) {
444 $filtered_objects = array();
445 $filtered_object_ids = array();
446 $current_size = 0;
447
448 foreach ( $objects as $key => $value ) {
449 $object_size = strlen( maybe_serialize( $value ) );
450
451 // Always allow the first object.
452 if ( empty( $filtered_object_ids ) || ( $current_size + $object_size ) <= self::MAX_SIZE_FULL_SYNC ) {
453 $filtered_object_ids[] = $key;
454 $filtered_objects[ $key ] = $value;
455 $current_size += $object_size;
456 } else {
457 break;
458 }
459 }
460
461 return array(
462 $filtered_object_ids,
463 $filtered_objects,
464 );
465 }
466
467 /**
468 * Handle Sync analytics reports data.
469 *
470 * @param int $order_id The order ID.
471 * @return void
472 */
473 public function sync_analytics_reports_data( $order_id ) {
474
475 $data = $this->get_object_by_id( 'order', $order_id );
476
477 if ( ! $data ) {
478 return;
479 }
480
481 /**
482 * Trigger the action to sync the reports data.
483 *
484 * @param array $data Analytics reports sync data.
485 */
486 do_action( 'woocommerce_analytics_sync_reports_data', $data );
487 }
488
489 /**
490 * Handle syncing of analytics deletion data.
491 *
492 * @param int $order_id The order ID.
493 * @return void
494 */
495 public function sync_deleted_analytics_data( $order_id ) {
496 if ( empty( $order_id ) ) {
497 return;
498 }
499
500 $data = array(
501 'id' => $order_id,
502 );
503
504 /**
505 * Filter the deletion data before syncing.
506 *
507 * @param array $data The deletion data.
508 */
509 $data = apply_filters( 'woocommerce_analytics_deletion_data', $data );
510
511 /**
512 * Trigger the action to sync the deletion.
513 *
514 * @param array $data The deletion sync data.
515 */
516 do_action( 'woocommerce_analytics_delete_reports_data', $data );
517 }
518
519 /**
520 * Build the WooCommerce analytics reports data.
521 *
522 * @param mixed $order The order ID or the WC_Order object.
523 * @return array The reports data.
524 */
525 protected function build_woocommerce_analytics_reports_data( $order ) {
526 // Reload once here, or each getter below would read the order again.
527 $report_order = $order;
528 if ( $order instanceof WC_Abstract_Order ) {
529 $analytics_order = self::get_analytics_order( $order );
530 if ( $analytics_order ) {
531 $report_order = $analytics_order;
532 }
533 }
534
535 $data_types = array(
536 'order_stats' => $this->get_order_stats_data( $report_order ),
537 'order_attribution_data' => $this->get_order_attribution_data( $report_order ),
538 'order_product_data' => $this->get_order_product_data( $report_order ),
539 'order_coupon_data' => $this->get_order_coupon_data( $report_order ),
540 'order_tax_data' => $this->get_order_tax_data( $report_order ),
541 );
542
543 $reports_data = array_filter( $data_types );
544
545 /**
546 * Filter the reports data before syncing.
547 *
548 * @param array $data The reports data.
549 * @param WC_Abstract_Order|int|string $order The order object or ID.
550 */
551 return apply_filters( 'woocommerce_analytics_reports_data', $reports_data, $order );
552 }
553
554 /**
555 * Build the WooCommerce analytics reports data for lookup tables.
556 *
557 * @param mixed $order The order ID or the WC_Order object.
558 * @param string $object_type The object type.
559 * @return array The reports data.
560 */
561 protected function build_woocommerce_analytics_reports_lookup_data( $order, $object_type ) {
562 $report_data = array();
563 switch ( $object_type ) {
564 case 'order_product_lookup':
565 $report_data['order_product_data'] = $this->get_order_product_data( $order );
566 break;
567 case 'order_coupon_lookup':
568 $report_data['order_coupon_data'] = $this->get_order_coupon_data( $order );
569 break;
570 case 'order_tax_lookup':
571 $report_data['order_tax_data'] = $this->get_order_tax_data( $order );
572 break;
573 }
574
575 /**
576 * Filter the reports lookup data before syncing.
577 *
578 * @param array $data The reports lookup data.
579 * @param WC_Abstract_Order|int|string $order The order object or ID.
580 * @param string $object_type The object type.
581 */
582 return apply_filters( 'woocommerce_analytics_reports_lookup_data', $report_data, $order, $object_type );
583 }
584
585 /**
586 * Get order attribution data.
587 *
588 * @param mixed $order The order ID or the WC_Order object.
589 * @return array|bool The order attribution data or false if the order is invalid.
590 */
591 protected function get_order_attribution_data( $order ) {
592 if ( is_numeric( $order ) ) {
593 $order = wc_get_order( $order );
594 }
595
596 if ( ! $order ) {
597 return false;
598 }
599
600 $order_id = $order->get_id();
601 $type = $order->get_type();
602 $attribution_prefix = $this->get_order_attribution_meta_prefix();
603 $allowed_keys = array(
604 'utm_campaign',
605 'utm_source',
606 'utm_medium',
607 'utm_content',
608 'utm_term',
609 'utm_source_platform',
610 'origin',
611 'device_type',
612 'source_type',
613 );
614
615 // Refunds inherit attribution from their parent order. Fall back to the refund
616 // itself when the parent can no longer be loaded.
617 $order_object_to_use = $order;
618 if ( 'shop_order_refund' === $type && ! empty( $order->get_parent_id() ) ) {
619 $parent_order = wc_get_order( $order->get_parent_id() );
620 if ( $parent_order ) {
621 $order_object_to_use = $parent_order;
622 }
623 }
624
625 $attribution_data = array(
626 'order_id' => $order_id,
627 );
628
629 foreach ( $allowed_keys as $key ) {
630 $meta_key = $attribution_prefix . $key;
631 $attribution_data[ $key ] = $order_object_to_use->get_meta( $meta_key, true );
632 }
633
634 return $attribution_data;
635 }
636
637 /**
638 * Get the filtered WooCommerce order attribution meta prefix.
639 *
640 * @return string The normalized meta prefix.
641 */
642 private function get_order_attribution_meta_prefix() {
643 /**
644 * Filters the prefix used for order attribution meta keys.
645 *
646 * @since 5.1.0
647 *
648 * @param string $prefix The order attribution meta key prefix.
649 */
650 $prefix = (string) apply_filters(
651 'wc_order_attribution_tracking_field_prefix',
652 'wc_order_attribution_'
653 );
654
655 return '_' . trim( $prefix, '_' ) . '_';
656 }
657
658 /**
659 * Handler order stats update.
660 *
661 * @param mixed $order The order ID or the WC_Order object.
662 * @return array|bool The order attribution data or false if the order stats item does not exist.
663 */
664 protected function get_order_stats_data( $order ) {
665 if ( is_numeric( $order ) ) {
666 $order_id = $order;
667 $order = wc_get_order( $order );
668 } elseif ( $order instanceof WC_Abstract_Order ) {
669 $order_id = $order->get_id();
670 } else {
671 return false;
672 }
673
674 // If the order does not exist or cannot have report methods, read its wc_order_stats row instead.
675 $order = self::get_analytics_order( $order );
676 if ( ! $order ) {
677 $order_stats_data_from_db = $this->get_order_stats_data_from_db( $order_id );
678 return $order_stats_data_from_db;
679 }
680
681 $order_fulfillment_status = null;
682 // @phan-suppress-next-line PhanUndeclaredStaticMethod -- Guarded by is_callable(); absent from the older WooCommerce stubs used by the "old Woo" Phan job.
683 if ( is_callable( array( OrderStatsDataStore::class, 'has_fulfillment_status_column' ) ) && OrderStatsDataStore::has_fulfillment_status_column() ) {
684 $order_stats_item = $this->get_order_stats_item( $order->get_id() );
685 $order_fulfillment_status = $order_stats_item['fulfillment_status'] ?? null;
686 } elseif ( is_callable( array( FulfillmentUtils::class, 'get_order_fulfillment_status' ) ) && $order instanceof WC_Order ) {
687 $fulfillment_status = FulfillmentUtils::get_order_fulfillment_status( $order );
688 $order_fulfillment_status = 'no_fulfillments' !== $fulfillment_status ? $fulfillment_status : null;
689 }
690
691 $order_stats_data = array(
692 'order_id' => $order->get_id(),
693 'parent_id' => $order->get_parent_id(),
694 'date_created' => self::datetime_to_object( $order->get_date_created() ),
695 'date_paid' => self::datetime_to_object( $order->get_date_paid() ),
696 'date_completed' => self::datetime_to_object( $order->get_date_completed() ),
697 'num_items_sold' => self::get_num_items_sold( $order ),
698 'total_sales' => $order->get_total(),
699 'tax_total' => $order->get_total_tax(),
700 'total_fees' => $order->get_total_fees(),
701 'total_fees_tax' => self::get_total_fees_tax( $order ),
702 'shipping_total' => $order->get_shipping_total(),
703 'shipping_tax' => $order->get_shipping_tax(),
704 'discount_total' => $order->get_discount_total(),
705 'discount_tax' => $order->get_discount_tax(),
706 'net_total' => self::get_net_total( $order ),
707 'returning_customer' => $order->is_returning_customer(),
708 'status' => self::normalize_order_status( $order->get_status() ),
709 'customer_id' => $order->get_report_customer_id(),
710 'fulfillment_status' => $order_fulfillment_status,
711 );
712
713 if ( 'shop_order_refund' === $order->get_type() ) {
714 $parent_order = wc_get_order( $order->get_parent_id() );
715 if ( $parent_order ) {
716 $order_stats_data['parent_id'] = $parent_order->get_id();
717
718 $refund_type = $order->get_meta( '_refund_type' );
719 if ( 'full' === $refund_type && self::uses_new_full_refund_data() ) {
720 $order_stats_data['tax_total'] = -1 * $parent_order->get_total_tax();
721 $order_stats_data['num_items_sold'] = -1 * self::get_num_items_sold( $parent_order );
722 $order_stats_data['net_total'] = -1 * self::get_net_total( $parent_order );
723 $order_stats_data['shipping_total'] = -1 * (float) $parent_order->get_shipping_total();
724 }
725 }
726 /**
727 * Set date_completed and date_paid the same as date_created to avoid problems
728 * when they are being used to sort the data, as refunds don't have them filled
729 */
730 $date_created_gmt = self::datetime_to_object( $order->get_date_created() );
731 $order_stats_data['date_completed'] = $date_created_gmt;
732 $order_stats_data['date_paid'] = $date_created_gmt;
733 }
734
735 return $order_stats_data;
736 }
737
738 /**
739 * Check whether WooCommerce stores full refunds using the new data format.
740 *
741 * @return bool Whether the new full-refund data format is in use.
742 */
743 private static function uses_new_full_refund_data() {
744 if ( ! is_callable( array( OrderUtil::class, 'uses_new_full_refund_data' ) ) ) {
745 return false;
746 }
747
748 // @phan-suppress-next-line PhanUndeclaredStaticMethod -- Guarded by is_callable(); absent from the older WooCommerce stubs used by the "old Woo" Phan job.
749 return OrderUtil::uses_new_full_refund_data();
750 }
751
752 /**
753 * Calculation methods.
754 */
755
756 /**
757 * Get number of items sold among all orders.
758 *
759 * @param WC_Order $order WC_Order object.
760 * @return int
761 */
762 protected static function get_num_items_sold( $order ) {
763 $num_items = 0;
764
765 $line_items = $order->get_items( 'line_item' );
766 foreach ( $line_items as $line_item ) {
767 $num_items += $line_item->get_quantity();
768 }
769
770 return $num_items;
771 }
772
773 /**
774 * Get the net amount from an order without shipping, tax, or refunds.
775 *
776 * @param WC_Order $order WC_Order object.
777 * @return float
778 */
779 protected static function get_net_total( $order ) {
780 $net_total = floatval( $order->get_total() ) - floatval( $order->get_total_tax() ) - floatval( $order->get_shipping_total() );
781 return $net_total;
782 }
783
784 /**
785 * Get the total fees tax from an order.
786 *
787 * @param WC_Order $order WC_Order object.
788 * @return float
789 */
790 protected static function get_total_fees_tax( $order ) {
791 $total_fees_tax = array_sum(
792 array_map(
793 function ( $item ) {
794 return $item->get_total_tax();
795 },
796 array_values( $order->get_items( 'fee' ) )
797 )
798 );
799
800 return $total_fees_tax;
801 }
802
803 /**
804 * Get the order stats row for a given order ID.
805 *
806 * @param int $order_id The order ID.
807 * @return array|null|void Database query result in format specified by $output or null on failure.
808 */
809 private function get_order_stats_item( $order_id ) {
810 global $wpdb;
811
812 $query = $wpdb->prepare(
813 "SELECT * FROM {$this->table()} WHERE order_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
814 $order_id
815 );
816
817 return $wpdb->get_row( $query, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
818 }
819
820 /**
821 * Get the order stats rows for a given order IDs.
822 *
823 * @param array $order_ids The order IDs.
824 * @return array|null Database query result in format specified by $output or null on failure.
825 */
826 private function get_order_stats_items( $order_ids ) {
827 global $wpdb;
828
829 $placeholders = implode( ',', array_fill( 0, count( $order_ids ), '%d' ) );
830 $query = $wpdb->prepare(
831 "SELECT * FROM {$this->table()} WHERE order_id IN ( $placeholders )", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
832 $order_ids
833 );
834
835 return $wpdb->get_results( $query, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
836 }
837
838 /**
839 * Get the order as WooCommerce Analytics' order class, which adds the report methods used here.
840 *
841 * WooCommerce swaps that class in only while Analytics is enabled, and an object cache can outlive the switch.
842 *
843 * @param WC_Abstract_Order|false $order The order object.
844 * @return WC_Abstract_Order|false The order with report methods, or false if it cannot have them.
845 */
846 private static function get_analytics_order( $order ) {
847 if ( ! $order || method_exists( $order, 'get_report_customer_id' ) ) {
848 return $order;
849 }
850
851 // Mirrors the exact-class check in WooCommerce's order_class_name filters, which leave subclasses alone.
852 $analytics_class = self::$analytics_order_classes[ get_class( $order ) ] ?? null;
853 if ( null === $analytics_class || ! class_exists( $analytics_class ) ) {
854 return false;
855 }
856
857 return new $analytics_class( $order->get_id() );
858 }
859
860 /**
861 * Query orders, loading them as WooCommerce Analytics' order classes even while Analytics is disabled.
862 *
863 * @param array $query_args Arguments for wc_get_orders().
864 * @return WC_Order[]|\stdClass What wc_get_orders() returns.
865 */
866 private static function get_analytics_orders( $query_args ) {
867 $added_callbacks = array();
868 foreach ( self::$analytics_order_classes as $analytics_class ) {
869 $callback = array( $analytics_class, 'order_class_name' );
870 // Only add, and later remove, what WooCommerce has not, or its own filter goes with it.
871 if ( class_exists( $analytics_class ) && false === has_filter( 'woocommerce_order_class', $callback ) ) {
872 add_filter( 'woocommerce_order_class', $callback, 10, 3 );
873 $added_callbacks[] = $callback;
874 }
875 }
876
877 try {
878 return wc_get_orders( $query_args );
879 } finally {
880 foreach ( $added_callbacks as $callback ) {
881 remove_filter( 'woocommerce_order_class', $callback, 10 );
882 }
883 }
884 }
885
886 /**
887 * Check if the COGS feature is enabled.
888 *
889 * @return bool True if the COGS feature is enabled, false otherwise.
890 */
891 private function is_cogs_enabled() {
892 return FeaturesUtil::feature_is_enabled( 'cost_of_goods_sold' );
893 }
894
895 /**
896 * Get order product lookup data.
897 *
898 * @param mixed $order The order ID or the WC_Order object.
899 * @return array|bool The order product data or false if no data exists.
900 */
901 protected function get_order_product_data( $order ) {
902 if ( is_numeric( $order ) ) {
903 $order_id = $order;
904 $order = wc_get_order( $order );
905 } elseif ( $order instanceof WC_Abstract_Order ) {
906 $order_id = $order->get_id();
907 } else {
908 return false;
909 }
910
911 // If the order does not exist or cannot have report methods, read its lookup rows instead.
912 $order = self::get_analytics_order( $order );
913 if ( ! $order ) {
914 return $this->get_order_product_data_from_db( $order_id );
915 }
916
917 // Get the order product data from the order object.
918 $order_products = $order->get_items( 'line_item' );
919
920 if ( empty( $order_products ) ) {
921 // Not a common use case, but there could be a case where this returns empty.
922 return $this->get_order_product_data_from_db( $order_id );
923 }
924
925 $is_refund_order = $this->is_refund_order( $order_id );
926 $round_tax = 'no' === get_option( 'woocommerce_tax_round_at_subtotal' );
927 $decimals = wc_get_price_decimals();
928
929 $results = array();
930 foreach ( $order_products as $order_product ) {
931 $shipping_amount = $order->get_item_shipping_amount( $order_product );
932 $shipping_tax_amount = $order->get_item_shipping_tax_amount( $order_product );
933 $coupon_amount = $order->get_item_coupon_amount( $order_product );
934 // Tax amount.
935 $tax_amount = 0;
936 $order_taxes = $order->get_taxes();
937 $tax_data = $order_product->get_taxes();
938 foreach ( $order_taxes as $tax_item ) {
939 $tax_item_id = $tax_item->get_rate_id();
940 $tax_amount += isset( $tax_data['total'][ $tax_item_id ] ) ? (float) $tax_data['total'][ $tax_item_id ] : 0;
941 }
942
943 $net_revenue = round( $order_product->get_total( 'edit' ), $decimals );
944 if ( $round_tax ) {
945 $tax_amount = round( $tax_amount, $decimals );
946 }
947
948 $product_id = $order_product->get_product_id();
949 $cogs_amount = $this->get_order_product_cogs_value( $order_product );
950
951 $product_data = array(
952 'order_id' => $order_id,
953 'order_item_id' => $order_product->get_id(),
954 'product_id' => $product_id,
955 'variation_id' => $order_product->get_variation_id(),
956 'product_qty' => $order_product->get_quantity(),
957 'product_net_revenue' => $net_revenue,
958 'product_gross_revenue' => $net_revenue + $tax_amount + $shipping_amount + $shipping_tax_amount,
959 'shipping_amount' => $shipping_amount,
960 'shipping_tax_amount' => $shipping_tax_amount,
961 'coupon_amount' => $coupon_amount,
962 'tax_amount' => $tax_amount,
963 'customer_id' => $order->get_report_customer_id(),
964 'date_created' => self::datetime_to_object( $order->get_date_created() ),
965 'cogs_amount' => $is_refund_order ? -abs( $cogs_amount ) : $cogs_amount,
966 );
967
968 $results[] = $product_data;
969 }
970
971 return $results;
972 }
973
974 /**
975 * Get COGS value for an order product.
976 *
977 * @param object|false $order_product The order product object, or false if it no longer exists.
978 * @return float|null The COGS amount or null if not available.
979 */
980 private function get_order_product_cogs_value( $order_product ) {
981 if ( ! is_object( $order_product ) || ! method_exists( $order_product, 'get_cogs_value' ) || ! $this->is_cogs_enabled() ) {
982 return null;
983 }
984
985 $cogs_amount = $order_product->get_cogs_value();
986
987 // Only fallback to product's COGS value if order product's COGS is null (not set).
988 if ( null === $cogs_amount ) {
989 $product_id = $order_product->get_product_id();
990 $product = wc_get_product( $product_id );
991
992 if ( $product && method_exists( $product, 'get_cogs_value' ) ) {
993 $product_cogs_value = $product->get_cogs_value();
994 if ( null !== $product_cogs_value ) {
995 $cogs_amount = $product_cogs_value;
996 }
997 }
998 }
999
1000 return $cogs_amount;
1001 }
1002
1003 /**
1004 * Get order product lookup data from database.
1005 *
1006 * @param int $order_id The order ID.
1007 * @return array|bool The order product data or false if no data exists.
1008 */
1009 protected function get_order_product_data_from_db( $order_id ) {
1010 $results = $this->get_order_lookup_data_from_db( 'wc_order_product_lookup', $order_id );
1011
1012 if ( empty( $results ) ) {
1013 return false;
1014 }
1015
1016 $is_refund_order = $this->is_refund_order( $order_id );
1017
1018 $parsed_results = array();
1019 foreach ( $results as $result ) {
1020 $order_item = WC_Order_Factory::get_order_item( absint( $result['order_item_id'] ) );
1021 $cogs_amount = $this->get_order_product_cogs_value( $order_item );
1022
1023 $product_data = array(
1024 'date_created' => self::datetime_to_object( $result['date_created'] ),
1025 'product_net_revenue' => floatval( $result['product_net_revenue'] ),
1026 'product_gross_revenue' => floatval( $result['product_gross_revenue'] ),
1027 'shipping_amount' => floatval( $result['shipping_amount'] ),
1028 'shipping_tax_amount' => floatval( $result['shipping_tax_amount'] ),
1029 'product_qty' => intval( $result['product_qty'] ),
1030 'variation_id' => intval( $result['variation_id'] ),
1031 'product_id' => intval( $result['product_id'] ),
1032 'customer_id' => intval( $result['customer_id'] ),
1033 'coupon_amount' => floatval( $result['coupon_amount'] ),
1034 'tax_amount' => floatval( $result['tax_amount'] ),
1035 'order_item_id' => intval( $result['order_item_id'] ),
1036 'order_id' => intval( $result['order_id'] ),
1037 'cogs_amount' => $is_refund_order ? -abs( $cogs_amount ) : $cogs_amount,
1038 );
1039
1040 $parsed_results[] = $product_data;
1041 }
1042
1043 return $parsed_results;
1044 }
1045
1046 /**
1047 * Check if the order is a refund order.
1048 *
1049 * @param int $order_id The order ID.
1050 * @return bool True if the order is a refund order, false otherwise.
1051 */
1052 private function is_refund_order( $order_id ) {
1053 $order_stats_data = $this->get_order_stats_item( $order_id );
1054
1055 if ( ! $order_stats_data || empty( $order_stats_data['parent_id'] ) ) {
1056 return false;
1057 }
1058
1059 $parent_id = $order_stats_data['parent_id'];
1060 $parent_order_stats_data = $this->get_order_stats_item( $parent_id );
1061
1062 if ( ! $parent_order_stats_data || empty( $parent_order_stats_data['status'] ) ) {
1063 return false;
1064 }
1065
1066 // OrderInternalStatus is unavailable before WooCommerce 9.5.
1067 return 'wc-refunded' === $parent_order_stats_data['status'];
1068 }
1069
1070 /**
1071 * Get order coupon lookup data.
1072 *
1073 * @param mixed $order The order ID or the WC_Order object.
1074 * @return array|bool The order coupon data or false if no data exists.
1075 */
1076 protected function get_order_coupon_data( $order ) {
1077 if ( is_numeric( $order ) ) {
1078 $order_id = $order;
1079 $order = wc_get_order( $order );
1080 } elseif ( $order instanceof WC_Abstract_Order ) {
1081 $order_id = $order->get_id();
1082 } else {
1083 return false;
1084 }
1085
1086 // If the order does not exist, check if coupon lookup data exists in the database.
1087 if ( ! $order ) {
1088 return $this->get_order_coupon_data_from_db( $order_id );
1089 }
1090
1091 // Get the order coupon data from the order object.
1092 $order_coupons = $order->get_coupons();
1093
1094 $results = array();
1095 foreach ( $order_coupons as $coupon ) {
1096 $results[] = array(
1097 'order_id' => $order_id,
1098 'coupon_id' => CouponsDataStore::get_coupon_id( $coupon ),
1099 'discount_amount' => $coupon->get_discount(),
1100 'date_created' => self::datetime_to_object( $order->get_date_created() ),
1101 'coupon_code' => $coupon->get_code(),
1102 );
1103 }
1104
1105 return $results;
1106 }
1107
1108 /**
1109 * Get order coupon lookup data from database.
1110 *
1111 * @param int $order_id The order ID.
1112 * @return array|bool The order coupon data or false if no data exists.
1113 */
1114 protected function get_order_coupon_data_from_db( $order_id ) {
1115 $results = $this->get_order_lookup_data_from_db( 'wc_order_coupon_lookup', $order_id );
1116
1117 if ( empty( $results ) ) {
1118 return false;
1119 }
1120
1121 $parsed_results = array();
1122 foreach ( $results as $result ) {
1123 $result_data = array(
1124 'date_created' => self::datetime_to_object( $result['date_created'] ),
1125 'discount_amount' => floatval( $result['discount_amount'] ),
1126 'order_id' => intval( $result['order_id'] ),
1127 'coupon_id' => intval( $result['coupon_id'] ),
1128 );
1129 $coupon = new WC_Coupon( absint( $result['coupon_id'] ) );
1130 $result_data['coupon_code'] = $coupon->get_code();
1131 $parsed_results[] = $result_data;
1132 }
1133
1134 return $parsed_results;
1135 }
1136
1137 /**
1138 * Get order tax lookup data.
1139 *
1140 * @param mixed $order The order ID or the WC_Order object.
1141 * @return array|bool The order tax data or false if no data exists.
1142 */
1143 protected function get_order_tax_data( $order ) {
1144 if ( is_numeric( $order ) ) {
1145 $order_id = $order;
1146 $order = wc_get_order( $order );
1147 } elseif ( $order instanceof WC_Abstract_Order ) {
1148 $order_id = $order->get_id();
1149 } else {
1150 return false;
1151 }
1152
1153 // If the order does not exist, check if tax lookup data exists in the database.
1154 if ( ! $order ) {
1155 return $this->get_order_tax_data_from_db( $order_id );
1156 }
1157
1158 // Get the order tax data from the order object.
1159 $order_taxes = $order->get_taxes();
1160
1161 $results = array();
1162 foreach ( $order_taxes as $tax ) {
1163 $order_tax = (float) $tax->get_tax_total();
1164 $shipping_tax = (float) $tax->get_shipping_tax_total();
1165 $results[] = array(
1166 'order_id' => $order_id,
1167 'tax_rate_id' => $tax->get_rate_id(),
1168 'order_tax' => $order_tax,
1169 'shipping_tax' => $shipping_tax,
1170 'total_tax' => $order_tax + $shipping_tax,
1171 'date_created' => self::datetime_to_object( $order->get_date_created() ),
1172 'tax_rate_code' => $tax->get_rate_code(),
1173 );
1174 }
1175
1176 return $results;
1177 }
1178
1179 /**
1180 * Get order tax lookup data from database.
1181 *
1182 * @param int $order_id The order ID.
1183 * @return array|bool The order tax data or false if no data exists.
1184 */
1185 protected function get_order_tax_data_from_db( $order_id ) {
1186 $results = $this->get_order_lookup_data_from_db( 'wc_order_tax_lookup', $order_id );
1187
1188 if ( empty( $results ) ) {
1189 return false;
1190 }
1191
1192 $parsed_results = array();
1193 foreach ( $results as $result ) {
1194 $result_data = array(
1195 'date_created' => self::datetime_to_object( $result['date_created'] ),
1196 'order_tax' => floatval( $result['order_tax'] ),
1197 'total_tax' => floatval( $result['total_tax'] ),
1198 'shipping_tax' => floatval( $result['shipping_tax'] ),
1199 'order_id' => intval( $result['order_id'] ),
1200 'tax_rate_id' => intval( $result['tax_rate_id'] ),
1201 'tax_rate_code' => WC_Tax::get_rate_code( $result['tax_rate_id'] ) ?? '',
1202 );
1203 $parsed_results[] = $result_data;
1204 }
1205
1206 return $parsed_results;
1207 }
1208
1209 /**
1210 * Get order lookup data from database.
1211 *
1212 * @param string $table_name The name of the table.
1213 * @param int $order_id The order ID.
1214 * @return array|bool The order lookup data or false if no data exists.
1215 */
1216 protected function get_order_lookup_data_from_db( $table_name, $order_id ) {
1217 global $wpdb;
1218
1219 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1220 $query = $wpdb->prepare(
1221 "SELECT * FROM {$wpdb->prefix}{$table_name} WHERE order_id = %d",
1222 $order_id
1223 );
1224 // phpcs:enable
1225
1226 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
1227 $results = $wpdb->get_results( $query, ARRAY_A );
1228
1229 if ( empty( $results ) ) {
1230 return false;
1231 }
1232
1233 return $results;
1234 }
1235
1236 /**
1237 * Get the order stats data from the database.
1238 *
1239 * @param int $order_id The order ID.
1240 * @return array|bool The order stats data or false if the order stats item does not exist.
1241 */
1242 private function get_order_stats_data_from_db( $order_id ) {
1243 $order_stats_data = $this->get_order_stats_item( $order_id );
1244
1245 if ( ! $order_stats_data ) {
1246 return false;
1247 }
1248
1249 // Convert date strings to datetime objects.
1250 $order_stats_data['date_created'] = self::datetime_to_object( $order_stats_data['date_created'] );
1251 $order_stats_data['date_completed'] = self::datetime_to_object( $order_stats_data['date_completed'] );
1252 $order_stats_data['date_paid'] = self::datetime_to_object( $order_stats_data['date_paid'] );
1253
1254 return $order_stats_data;
1255 }
1256
1257 /**
1258 * Perform an order status discrepancy check between the order object and the item in the wc_order_stats table.
1259 *
1260 * @param WC_Order $order WC_Order object.
1261 * @param array $order_stats_item The order stats item.
1262 *
1263 * @return void
1264 */
1265 private function do_order_status_discrepancy_check( $order, $order_stats_item = array() ) {
1266 if ( ! $order instanceof WC_Abstract_Order ) {
1267 return;
1268 }
1269
1270 $order_id = $order->get_id();
1271
1272 // If the order_stats_item is empty, then fetch it from the wc_order_stats table.
1273 if ( empty( $order_stats_item ) ) {
1274 $order_stats_item = $this->get_order_stats_data_from_db( $order_id );
1275 }
1276
1277 // Check for discrepancy in the order status. Happens in old orders that were not updated and hence the OrderStatsFixer did not run.
1278 $normalized_order_status = self::normalize_order_status( $order->get_status() );
1279 if ( $order_stats_item && $normalized_order_status !== $order_stats_item['status'] ) {
1280 /**
1281 * Trigger the action to fix the order stats. The OrderStatusFixer should be hooked to this action.
1282 *
1283 * @param int $order_id The order ID.
1284 */
1285 do_action( 'woocommerce_analytics_incorrect_order_status_detected', $order_id );
1286 }
1287 }
1288
1289 /**
1290 * Maps an order status to the value used in the database.
1291 *
1292 * @param string $status Order status.
1293 * @return string
1294 */
1295 protected static function normalize_order_status( $status ) {
1296 return WooCommerce_HPOS_Orders::get_wc_order_status_with_prefix( str_replace( 'wc-', '', $status ) );
1297 }
1298
1299 /**
1300 * Convert a WooCommerce datetime to an object for encoding.
1301 *
1302 * @param WC_DateTime|mixed $wc_datetime The datetime object.
1303 * @return object|null
1304 */
1305 protected static function datetime_to_object( $wc_datetime ) {
1306 if ( is_string( $wc_datetime ) ) {
1307 $wc_datetime = new WC_DateTime( $wc_datetime, self::get_site_datetimezone() );
1308 }
1309
1310 if ( is_a( $wc_datetime, 'WC_DateTime' ) ) {
1311 $wc_datetime->setTimezone( self::get_site_datetimezone() );
1312 $date_properties = (array) $wc_datetime;
1313
1314 // Remove protected properties, whose NUL-prefixed names cannot be processed by the receiver.
1315 foreach ( array_keys( $date_properties ) as $property_name ) {
1316 if ( false !== strpos( $property_name, "\0" ) ) {
1317 unset( $date_properties[ $property_name ] );
1318 }
1319 }
1320
1321 return (object) $date_properties;
1322 }
1323 }
1324
1325 /**
1326 * Convert seconds to an ISO 8601 timezone offset.
1327 *
1328 * @param int|float $offset_seconds The timezone offset in seconds.
1329 * @return string The ISO 8601 timezone offset.
1330 */
1331 protected static function format_utc_offset( $offset_seconds ) {
1332 $hours = intval( abs( $offset_seconds ) / HOUR_IN_SECONDS );
1333 $minutes = intval( ( abs( $offset_seconds ) % HOUR_IN_SECONDS ) / MINUTE_IN_SECONDS );
1334 $sign = $offset_seconds >= 0 ? '+' : '-';
1335
1336 return sprintf( '%s%02d:%02d', $sign, $hours, $minutes );
1337 }
1338
1339 /**
1340 * Get the site timezone as a fixed offset.
1341 *
1342 * @return DateTimeZone The site timezone.
1343 */
1344 protected static function get_site_datetimezone() {
1345 return new DateTimeZone( self::format_utc_offset( wc_timezone_offset() ) );
1346 }
1347 }
1348