Analytics (wp-admin/admin.php?page=wc-admin&path=/analytics/bpro). * * A clone of the Analytics > Products report that shows the BPro summary tabs and columns instead of * WooCommerce's own: λ0, βI, βO, βL, kI, kR, kL and the BPro amounts (XIP ... P). The Products report itself is left * exactly as WooCommerce ships it. * * The Analytics screens are a React app, so this is two halves: * * 1. PHP (this class): the `woocommerce_rest_prepare_report_products` filter adds the BPro values to every row of the * /wc-analytics/reports/products REST endpoint, and `rest_post_dispatch` adds them to the totals and the chart * points of /wc-analytics/reports/products/stats. The numbers come from Bpro_Data_Light_Data (calculated from the * WooCommerce data, cached in transients, refreshed whenever WooCommerce updates its Analytics data). * 2. JS (admin/js/bpro-data-light-analytics.js): registers the report, its tabs and columns, and the "Data status" box. * * Sorting by a BPro column: the values are computed here, not stored, so the Analytics SQL query has nothing to * ORDER BY. maybe_sort_by_lambda() answers such a request itself: it reads every row of the report through the * normal endpoint, sorts them and returns the requested page. * * The report appears only when BPro Light > Settings > "Show BPro Data at the WooCommerce Analytics" is ticked. * * @since 1.0.0 * @package Bpro_Data_Light * @subpackage Bpro_Data_Light/admin */ class Bpro_Data_Light_Analytics { const COLOR_RED = '#ff4560'; const COLOR_YELLOW = '#feb019'; const COLOR_GREEN = '#00e396'; /** REST namespace of the "Data status" box (status + "update now"). */ const REST_NAMESPACE = 'bpro-data-light/v1'; /** * A fraction as a percentage string, e.g. 0.00026 -> "+0.026%". * * @param float $fraction * @return string */ public static function format_percent( $fraction ) { $pct = $fraction * 100.0; $abs = abs( $pct ); $digits = 0.0 === $abs ? 0 : ( $abs < 0.1 ? 3 : ( $abs < 10 ? 2 : 1 ) ); $text = rtrim( rtrim( number_format( $pct, $digits, '.', '' ), '0' ), '.' ); return ( $pct > 0 ? '+' : '' ) . ( '' === $text || '-' === $text ? '0' : $text ) . '%'; } /** * A β/k ratio: 4 decimals, or 'n/a' when undefined (zero denominator). * * @param float|null $value * @return string */ public static function format_ratio( $value ) { return null === $value ? 'n/a' : number_format( (float) $value, 4, '.', ',' ); } /** * A plain 2-decimal number (a tiny negative residue never prints as "-0.00"). * * @param float $value * @return string */ public static function format_number( $value ) { return number_format( round( (float) $value, 2 ) + 0.0, 2, '.', ',' ); } /** * Routes of the "Data status" box: GET status, POST sync (an immediate update). * * Hooked on `rest_api_init`. */ public function register_rest_routes() { register_rest_route( self::REST_NAMESPACE, '/status', array( 'methods' => 'GET', 'callback' => static function () { return rest_ensure_response( Bpro_Data_Light_Data::get_status() ); }, 'permission_callback' => static function () { return current_user_can( 'manage_woocommerce' ); }, ) ); register_rest_route( self::REST_NAMESPACE, '/sync', array( 'methods' => 'POST', 'callback' => static function () { return rest_ensure_response( Bpro_Data_Light_Data::run_sync( true ) ); }, 'permission_callback' => static function () { return current_user_can( 'manage_woocommerce' ); }, ) ); } const SCRIPT_HANDLE = 'bpro-data-light-analytics'; const COLUMN_KEY = 'bpro_lambda0'; const STATS_ROUTE = '/wc-analytics/reports/products/stats'; const TABLE_ROUTE = '/wc-analytics/reports/products'; /** The "BPro" report under WooCommerce > Analytics: /analytics/bpro (menu item id + path). */ const REPORT_MENU_ID = 'bpro-data-light-analytics'; const REPORT_PATH = '/analytics/bpro'; /** * The six ratio columns placed after λ0: row key => [ gauge field, label ]. * Each row key holds the same { value, zone, text, date, threshold } object as λ0. */ public static function get_ratio_columns() { return array( 'bpro_beta_i' => array( 'beta_I', 'βI' ), 'bpro_beta_o' => array( 'beta_O', 'βO' ), 'bpro_beta_l' => array( 'beta_L', 'βL' ), 'bpro_k_i' => array( 'k_I', 'kI' ), 'bpro_k_r' => array( 'k_R', 'kR' ), 'bpro_k_l' => array( 'k_L', 'kL' ), ); } /** * Every sortable BPro column key (λ0 + the six ratios). */ protected static function get_sortable_keys() { return array_merge( array( self::COLUMN_KEY ), array_keys( self::get_ratio_columns() ), array_keys( self::get_amount_columns() ) ); } /** * The amount columns of the BPro report: row key => [ metric, kind ]. * - 'flow' : the metric's day values SUMMED over the report's date range * (the 12 base values and the 9 money-side / price-side derived ones), * - 'balance' : the running balance as of the last day of the range (the 12 saldo metrics). * Order: as on the BPro Structure table. * * @return array */ public static function get_amount_columns() { $flows = array( 'XIP', 'XIM', 'XLP', 'XLM', 'YIP', 'YIM', 'YLP', 'YLM', 'TXP', 'TXM', 'DP', 'DM', 'ZIM', 'XRM', 'XOM', 'YOM', 'NPM', 'PM', 'ZIP', 'XRP', 'YOP' ); $balances = array( 'ZI', 'XI', 'XR', 'XO', 'XL', 'YI', 'YL', 'YO', 'TX', 'NP', 'D', 'P' ); $columns = array(); foreach ( $flows as $metric ) { $columns[ 'bpro_' . strtolower( $metric ) ] = array( $metric, 'flow' ); } foreach ( $balances as $metric ) { $columns[ 'bpro_' . strtolower( $metric ) ] = array( $metric, 'balance' ); } return $columns; } /** * BPro Light > Settings > "Show BPro Data at the WooCommerce Analytics". * Off by default: until it is ticked, nothing is added to Analytics. */ const OPTION_ENABLED = 'bpro_data_light_show_in_analytics'; /** * Whether BPro Data (the λ0 column and tab) is switched on for the Analytics screens. * * @return bool */ public static function is_enabled() { return (bool) get_option( self::OPTION_ENABLED, '' ); } /** * Per-request memo: product id => series, so a product that shows up in * more than one response of the same request is only computed once. * * @var array */ protected static $series_cache = array(); /** * Loads the script on the WooCommerce Analytics app. * * The app is a single page: moving from "Overview" to "Products" happens * client-side without a reload, so the script is loaded on every * `page=wc-admin` screen and the JS filter itself only acts on the * "products" report table. * * @param string $hook Current admin page hook (unused - the page is identified by ?page=wc-admin, * which is stable whether Analytics sits under WooCommerce or at the top level). */ public function enqueue_assets( $hook ) { $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page-identity check. if ( 'wc-admin' !== $page || ! self::is_enabled() || ! current_user_can( 'manage_woocommerce' ) ) { return; } wp_enqueue_style( self::SCRIPT_HANDLE, BPRO_DATA_LIGHT_PLUGIN_URL . 'admin/css/bpro-data-light-analytics.css', array(), BPRO_DATA_LIGHT_VERSION ); wp_enqueue_script( self::SCRIPT_HANDLE, BPRO_DATA_LIGHT_PLUGIN_URL . 'admin/js/bpro-data-light-analytics.js', array( 'wp-hooks', 'wp-element', 'wp-i18n', 'wp-api-fetch' ), BPRO_DATA_LIGHT_VERSION, true ); // The summary tabs come from a filter WooCommerce evaluates once, when its app script // runs - so this script has to be executed BEFORE the app, whatever the enqueue order. add_action( 'admin_enqueue_scripts', array( __CLASS__, 'run_before_wc_admin_app' ), PHP_INT_MAX ); wp_localize_script( self::SCRIPT_HANDLE, 'bproDataLightAnalytics', array( 'columnKey' => self::COLUMN_KEY, 'ratios' => self::get_ratio_localized_config(), 'extraTabs' => self::get_extra_summary_tabs(), 'tabs' => self::get_ordered_summary_tabs(), 'tabColors' => self::get_summary_tab_colors(), 'pageColumns' => self::get_report_page_columns(), 'reportPath' => self::REPORT_PATH, 'reportId' => self::REPORT_MENU_ID, 'reportKey' => 'bpro', 'reportTitle' => __( 'BPro', 'bpro-data-light' ), 'status' => Bpro_Data_Light_Data::get_status(), 'restPath' => '/' . self::REST_NAMESPACE, 'i18n' => array( 'dataStatus' => __( 'Data status', 'bpro-data-light' ), 'lastUpdated' => __( 'Last updated', 'bpro-data-light' ), 'nextUpdate' => __( 'Next update', 'bpro-data-light' ), 'updating' => __( 'Updating…', 'bpro-data-light' ), 'updateNow' => __( 'Update now', 'bpro-data-light' ), ), 'label' => 'λ0', 'tabLabel' => 'λ0 (%)', 'tooltip' => __( 'λ0 — relative rate of change of XO, as of the last day of the selected date range (capped at today). Red: negative. Yellow: between 0 and the daily bank rate. Green: at or above it.', 'bpro-data-light' ), 'colors' => array( 'red' => self::COLOR_RED, 'yellow' => self::COLOR_YELLOW, 'green' => self::COLOR_GREEN, ), ) ); } /** * Stats requests (spl_object_id of the WP_REST_Request) that asked for a BPro field - the only * ones add_lambda_to_stats() touches. Filled by hide_lambda_from_stats_query(). * * @var array */ protected $bpro_stats_requests = array(); /** * A REST value as a plain array: WooCommerce hands `subtotals` over as an object (stdClass) in some * responses and as an array in others. Anything else -> empty array. * * @param mixed $value * @return array */ protected static function as_array( $value ) { if ( is_object( $value ) ) { return get_object_vars( $value ); } return is_array( $value ) ? $value : array(); } /** * Adds the "BPro" item to the WooCommerce > Analytics menu (after Stock, before Settings). * The page itself is a clone of the Products report - see the JS file. * * Hooked on `woocommerce_analytics_report_menu_items`. * * @param array $report_pages Analytics sub-menu items. * @return array */ public function add_report_menu_item( $report_pages ) { if ( ! self::is_enabled() || ! is_array( $report_pages ) ) { return $report_pages; } $report_pages[] = array( 'id' => self::REPORT_MENU_ID, 'title' => __( 'BPro', 'bpro-data-light' ), 'parent' => 'woocommerce-analytics', 'path' => self::REPORT_PATH, ); return $report_pages; } /** * Makes WooCommerce's Analytics app script depend on ours, so ours is always printed first. */ public static function run_before_wc_admin_app() { $scripts = wp_scripts(); foreach ( array( 'wc-admin-app', 'wc-admin-app-legacy' ) as $handle ) { if ( isset( $scripts->registered[ $handle ] ) && ! in_array( self::SCRIPT_HANDLE, $scripts->registered[ $handle ]->deps, true ) ) { $scripts->registered[ $handle ]->deps[] = self::SCRIPT_HANDLE; } } } /** * Adds `bpro_lambda0` to one row of the Analytics products report. * * Hooked on `woocommerce_rest_prepare_report_products`. * * @param WP_REST_Response $response The prepared report row. * @param mixed $report The raw report row. * @param WP_REST_Request $request The request (carries the report's after/before dates). * @return WP_REST_Response */ public function add_lambda_to_row( $response, $report, $request ) { if ( ! self::is_enabled() || ! $response instanceof WP_REST_Response ) { return $response; } $data = $response->get_data(); $product_id = 0; if ( is_array( $data ) && isset( $data['product_id'] ) ) { $product_id = (int) $data['product_id']; } elseif ( is_array( $report ) && isset( $report['product_id'] ) ) { $product_id = (int) $report['product_id']; } if ( $product_id <= 0 || ! is_array( $data ) ) { return $response; } $date = $this->get_as_of_date( $request ); $threshold = Bpro_Data_Light_Metrics::get_daily_bank_rate(); $value = self::calculate_lambda_o( $this->get_series( $product_id ), $date ); $data[ self::COLUMN_KEY ] = array( 'value' => $value, 'zone' => self::get_zone( $value, $threshold ), 'text' => null === $value ? '' : self::format_percent( $value ), 'date' => $date, 'threshold' => $threshold, ); // βI, βO, βL, kI, kR, kL - the same balances the BPro Structure gauges use, as of the same date. $series = $this->get_series( $product_id ); $metrics = self::get_metrics_as_of( $series, $date ); $analysis = Bpro_Data_Light_Business_Lever::analyze( $metrics ); $ratio_defs = self::get_ratio_columns(); foreach ( $ratio_defs as $key => $def ) { $field = $def[0]; $path = explode( '_', $field ); // 'beta_I' => [ 'beta', 'I' ]. $ratio = $analysis[ $path[0] ][ $path[1] ]; $limit = Bpro_Data_Light_Metrics::get_gauge_limit( $field ); $data[ $key ] = array( 'value' => $ratio, 'zone' => self::get_zone( $ratio, $limit ), 'text' => null === $ratio ? '' : self::format_ratio( $ratio ), 'date' => $date, 'threshold' => $limit, ); } // The amount columns of the BPro report: flows summed over the date range, balances as of its last day. $sums = self::sum_flows( $series, $this->get_after_date( $request ), $date ); foreach ( self::get_amount_columns() as $key => $def ) { list( $metric, $kind ) = $def; $value = null; if ( ! empty( $series ) ) { if ( 'flow' === $kind ) { $value = isset( $sums[ $metric ] ) ? $sums[ $metric ] : 0.0; } elseif ( isset( $metrics[ $metric ] ) ) { $value = (float) $metrics[ $metric ]; } } $data[ $key ] = array( 'value' => null === $value ? null : round( $value, 2 ), 'text' => null === $value ? '' : self::format_money( $value ), 'date' => $date, ); } $response->set_data( $data ); return $response; } /** * A money amount as plain text, the way the store's currency settings write it. * * @param float $value * @return string */ protected static function format_money( $value ) { if ( function_exists( 'wc_price' ) ) { return trim( html_entity_decode( wp_strip_all_tags( wc_price( $value ) ), ENT_QUOTES, 'UTF-8' ) ); } return self::format_number( $value ); } /** * Every flow metric (XIP, ZIM ...) summed over the days from $after to $before (inclusive). * * @param array[] $series * @param string|null $after 'Y-m-d' or null for "from the start". * @param string $before 'Y-m-d'. * @return array Metric (upper case) => sum. */ protected static function sum_flows( array $series, $after, $before ) { $sums = array(); foreach ( self::get_amount_columns() as $def ) { if ( 'flow' === $def[1] ) { $sums[ $def[0] ] = 0.0; } } foreach ( $series as $row ) { if ( $row['date'] > $before ) { break; } if ( null !== $after && $row['date'] < $after ) { continue; } foreach ( $sums as $metric => $total ) { if ( isset( $row['metrics'][ $metric ] ) ) { $sums[ $metric ] += (float) $row['metrics'][ $metric ]; } } } return $sums; } /** * First day of the report's date range ('after'), or null when the request has none. * * @param WP_REST_Request $request * @return string|null */ protected function get_after_date( $request ) { $after = $request instanceof WP_REST_Request ? $request->get_param( 'after' ) : null; return ( is_string( $after ) && preg_match( '/^(\d{4}-\d{2}-\d{2})/', $after, $m ) ) ? $m[1] : null; } /** * The columns of the "BPro" report, in display order, for the script: * [ key, label, tooltip, visible (shown by default) ]. * * @return array[] */ protected static function get_report_page_columns() { $shown = array( 'bpro_xip', 'bpro_xim', 'bpro_xlp', 'bpro_xlm', 'bpro_p', 'bpro_beta_o' ); $defs = Bpro_Data_Light_Metrics::get_metric_definitions(); $columns = array(); foreach ( self::get_amount_columns() as $key => $def ) { list( $metric, $kind ) = $def; $note = 'flow' === $kind ? __( 'Sum over the selected date range.', 'bpro-data-light' ) : __( 'Running balance as of the last day of the selected date range.', 'bpro-data-light' ); $columns[] = array( 'key' => $key, 'label' => $metric, 'tooltip' => ( isset( $defs[ $metric ] ) ? $defs[ $metric ]['label'] : $metric ) . ' — ' . $note, 'visible' => in_array( $key, $shown, true ), ); } $ratio_tips = array(); foreach ( self::get_ratio_localized_config() as $cfg ) { $ratio_tips[ $cfg['key'] ] = $cfg; } $ratio_order = array( 'bpro_beta_i', 'bpro_beta_o', 'bpro_beta_l', 'bpro_k_i', 'bpro_k_r', 'bpro_k_l' ); foreach ( $ratio_order as $key ) { $columns[] = array( 'key' => $key, 'label' => isset( $ratio_tips[ $key ] ) ? $ratio_tips[ $key ]['label'] : $key, 'tooltip' => isset( $ratio_tips[ $key ] ) ? $ratio_tips[ $key ]['tooltip'] : '', 'visible' => in_array( $key, $shown, true ), ); } $columns[] = array( 'key' => self::COLUMN_KEY, 'label' => 'λ0', 'tooltip' => __( 'λ0 — relative rate of change of XO, as of the last day of the selected date range.', 'bpro-data-light' ), 'visible' => false, ); return $columns; } /** * Localized per-ratio settings for the script: key => [ label, tooltip ]. * * @return array> */ protected static function get_ratio_localized_config() { $tips = array( 'bpro_beta_i' => __( 'βI = YI / XI — profitability, external consumer.', 'bpro-data-light' ), 'bpro_beta_o' => __( 'βO = YO / XO — profitability, internal source.', 'bpro-data-light' ), 'bpro_beta_l' => __( 'βL = YL / XL — profitability, external source.', 'bpro-data-light' ), 'bpro_k_i' => __( 'kI = XI / XO — main-value coefficient.', 'bpro-data-light' ), 'bpro_k_r' => __( 'kR = XR / XO — main-value coefficient.', 'bpro-data-light' ), 'bpro_k_l' => __( 'kL = XL / XO — main-value coefficient.', 'bpro-data-light' ), ); $out = array(); foreach ( self::get_ratio_columns() as $key => $def ) { $out[] = array( 'key' => $key, 'label' => $def[1], 'tooltip' => $tips[ $key ] . ' ' . __( 'As of the last day of the selected date range. Red: negative. Yellow: between 0 and the limit (the daily bank rate for βI, βO, βL; 1 for kI, kR, kL). Green: at or above it.', 'bpro-data-light' ), ); } return $out; } /** * The running-balance metrics (XI, XO, YI ...) on $date: the last series row on or before it * (balances carry forward over days without activity). Empty array -> analyze() gives null ratios. * * @param array[] $series * @param string $date 'Y-m-d'. * @return array */ protected static function get_metrics_as_of( array $series, $date ) { $metrics = array(); foreach ( $series as $row ) { if ( $row['date'] > $date ) { break; } $metrics = isset( $row['metrics'] ) ? $row['metrics'] : array(); } return $metrics; } /** * Sorting by the λ0 column - see the class docblock. * * Hooked on `rest_pre_dispatch`: returning a response here short-circuits * the endpoint (and with it its orderby validation, which would reject * "bpro_lambda0"). Any other request passes through untouched. * * @param mixed $result Null unless something earlier already answered. * @param WP_REST_Server $server * @param WP_REST_Request $request * @return mixed */ public function maybe_sort_by_lambda( $result, $server, $request ) { if ( null !== $result || ! self::is_enabled() || ! $request instanceof WP_REST_Request ) { return $result; } if ( self::TABLE_ROUTE !== $request->get_route() || ! in_array( $request->get_param( 'orderby' ), self::get_sortable_keys(), true ) ) { return $result; } $sort_key = (string) $request->get_param( 'orderby' ); if ( ! current_user_can( 'manage_woocommerce' ) ) { return $result; // Let the endpoint answer (it will refuse) the normal way. } $page = max( 1, (int) $request->get_param( 'page' ) ); $per_page = (int) $request->get_param( 'per_page' ); $per_page = $per_page > 0 ? $per_page : 25; $desc = 'asc' !== strtolower( (string) $request->get_param( 'order' ) ); // Every row of the report, through the normal endpoint (which also adds λ0 and the ratios to each row). $base_params = $request->get_query_params(); unset( $base_params['orderby'], $base_params['order'], $base_params['page'], $base_params['per_page'] ); $rows = array(); $fetch_page = 1; $total_pages = 1; do { $sub = new WP_REST_Request( 'GET', self::TABLE_ROUTE ); $sub->set_query_params( array_merge( $base_params, array( 'orderby' => 'items_sold', 'order' => 'desc', 'page' => $fetch_page, 'per_page' => 100, ) ) ); $sub_response = rest_do_request( $sub ); if ( $sub_response->is_error() || 200 !== $sub_response->get_status() ) { return $sub_response; // Same error the table would have got. } $sub_data = $sub_response->get_data(); if ( is_array( $sub_data ) ) { $rows = array_merge( $rows, array_values( $sub_data ) ); } $sub_headers = $sub_response->get_headers(); $total_pages = isset( $sub_headers['X-WP-TotalPages'] ) ? (int) $sub_headers['X-WP-TotalPages'] : 1; $fetch_page++; } while ( $fetch_page <= $total_pages && $fetch_page <= 50 ); // 50 x 100 rows: runaway guard. // Sort by λ0; rows without a value always last, whichever direction. $with = array(); $without = array(); foreach ( $rows as $row ) { if ( isset( $row[ $sort_key ]['value'] ) && null !== $row[ $sort_key ]['value'] ) { $with[] = $row; } else { $without[] = $row; } } usort( $with, static function ( $a, $b ) use ( $desc, $sort_key ) { $cmp = (float) $a[ $sort_key ]['value'] <=> (float) $b[ $sort_key ]['value']; return $desc ? -$cmp : $cmp; } ); $sorted = array_merge( $with, $without ); $total = count( $sorted ); $response = new WP_REST_Response( array_slice( $sorted, ( $page - 1 ) * $per_page, $per_page ), 200 ); $response->header( 'X-WP-Total', $total ); $response->header( 'X-WP-TotalPages', (int) ceil( $total / $per_page ) ); return $response; } /** * Keeps WooCommerce's own stats query from ever seeing the λ0 field. * * The λ0 chart/tab asks the stats endpoint for `fields=bpro_lambda0`. The * endpoint ignores a field it does not know - fine for the plain chart - but * in "Compare" mode (segmentby=product) its per-product segment query then has * nothing to select and returns NO `segments` list, and WooCommerce's compare * chart crashes on the missing list (blank page, "Cannot read properties of * undefined (reading 'forEach')"). So λ0 is taken out of `fields` before the * endpoint runs - replaced by a field it does know when nothing else is left - * and add_lambda_to_stats() adds λ0 to the response afterwards. * * Hooked on `rest_pre_dispatch`; never answers the request itself. * * @param mixed $result * @param WP_REST_Server $server * @param WP_REST_Request $request * @return mixed Unchanged $result. */ public function hide_lambda_from_stats_query( $result, $server, $request ) { if ( null !== $result || ! self::is_enabled() || ! $request instanceof WP_REST_Request || self::STATS_ROUTE !== $request->get_route() ) { return $result; } $fields = $request->get_param( 'fields' ); if ( null === $fields ) { return $result; } $fields = array_map( 'trim', (array) ( is_array( $fields ) ? $fields : explode( ',', (string) $fields ) ) ); if ( ! array_intersect( $fields, self::get_sortable_keys() ) ) { return $result; } // Only requests that asked for a BPro field (the Analytics > BPro page) get BPro values added to // the response; the Products page's own requests are left exactly as WooCommerce built them. $this->bpro_stats_requests[ spl_object_id( $request ) ] = true; $fields = array_values( array_diff( $fields, self::get_sortable_keys() ) ); if ( empty( $fields ) ) { $fields = array( 'items_sold' ); } $request->set_param( 'fields', $fields ); return $result; } /** * Adds `bpro_lambda0` to the products STATS response - `totals` (the summary * tab's number) and every `intervals[].subtotals` (the chart's points). * Values are percentages (0.026 = 0.026%), because the summary tab and the * chart show a plain number. * * Hooked on `rest_post_dispatch` and matched by route, so it does not depend * on the stats controller exposing its own prepare filter. * * @param WP_HTTP_Response|WP_Error $result * @param WP_REST_Server $server * @param WP_REST_Request $request * @return WP_HTTP_Response|WP_Error */ public function add_lambda_to_stats( $result, $server, $request ) { if ( ! self::is_enabled() || ! $request instanceof WP_REST_Request || self::STATS_ROUTE !== $request->get_route() ) { return $result; } $request_id = spl_object_id( $request ); if ( empty( $this->bpro_stats_requests[ $request_id ] ) ) { return $result; // Not asked for a BPro field (e.g. the Products page): leave the response alone. } unset( $this->bpro_stats_requests[ $request_id ] ); if ( ! $result instanceof WP_REST_Response || 200 !== $result->get_status() || ! current_user_can( 'manage_woocommerce' ) ) { return $result; } $data = $result->get_data(); if ( ! is_array( $data ) ) { return $result; } $series = $this->get_series_for_stats_request( $request ); $data['totals'] = self::as_array( isset( $data['totals'] ) ? $data['totals'] : null ); $as_of = $this->get_as_of_date( $request ); $data['totals'] = array_merge( $data['totals'], $this->get_stat_values( $series, $as_of, $this->get_after_date( $request ) ) ); // "Compare" (filter=compare-products) asks for the data split per product (segmentby=product): // every totals / interval "subtotals" then carries a `segments` list - one entry per compared // product - and the chart reads the selected tab's key from each. WooCommerce only fills that // list for fields it knows, so for λ0 the list is completed (or built) here, from the products // the request names (see build_segments()). $segmented = ! empty( $request->get_param( 'segmentby' ) ); $compared_ids = $segmented ? $this->get_compared_product_ids( $request ) : array(); if ( $segmented ) { $existing = self::as_array( isset( $data['totals']['segments'] ) ? $data['totals']['segments'] : null ); $data['totals']['segments'] = $this->build_segments( $existing, $compared_ids, $as_of, $this->get_after_date( $request ) ); } if ( isset( $data['intervals'] ) && is_array( $data['intervals'] ) ) { $today = current_time( 'Y-m-d' ); foreach ( $data['intervals'] as $index => $interval ) { if ( ! isset( $interval['date_end'] ) || ! preg_match( '/^(\d{4}-\d{2}-\d{2})/', (string) $interval['date_end'], $matches ) ) { continue; } $date = $matches[1] > $today ? $today : $matches[1]; // Flow tabs (XIP, XIM ...) are summed over the interval itself: date_start .. date_end. $interval_after = ( isset( $interval['date_start'] ) && preg_match( '/^(\d{4}-\d{2}-\d{2})/', (string) $interval['date_start'], $start_match ) ) ? $start_match[1] : $this->get_after_date( $request ); // Keep what WooCommerce already put in `subtotals` (items_sold ...), whatever its type. $subtotals = self::as_array( isset( $interval['subtotals'] ) ? $interval['subtotals'] : null ); $data['intervals'][ $index ]['subtotals'] = array_merge( $subtotals, $this->get_stat_values( $series, $date, $interval_after ) ); // Compare mode: the chart iterates `segments` unconditionally - never leave it missing or empty. if ( $segmented ) { $existing = self::as_array( isset( $data['intervals'][ $index ]['subtotals']['segments'] ) ? $data['intervals'][ $index ]['subtotals']['segments'] : null ); $data['intervals'][ $index ]['subtotals']['segments'] = $this->build_segments( $existing, $compared_ids, $date, $interval_after ); } } } $result->set_data( $data ); return $result; } /** * The chart / summary-tab values of every BPro field as of $date: * - bpro_lambda0 as a percentage (0.026 = 0.026%), * - the ratios (βI ... kL) as plain 4-decimal ratios, * - the amount metrics (XIP ... P): flows summed from $after to $date, balances as of $date. * Undefined (no data / zero denominator) -> 0, so the chart still has a point. * * @param array[] $series * @param string $date 'Y-m-d'. * @param string|null $after 'Y-m-d' first day of the summed range for flow metrics, null = from the start. * @return array */ protected function get_stat_values( array $series, $date, $after = null ) { $lambda = self::calculate_lambda_o( $series, $date ); $values = array( self::COLUMN_KEY => null === $lambda ? 0.0 : round( $lambda * 100.0, 4 ) ); $metrics = self::get_metrics_as_of( $series, $date ); $analysis = Bpro_Data_Light_Business_Lever::analyze( $metrics ); $ratio_defs = self::get_ratio_columns(); foreach ( $ratio_defs as $key => $def ) { $path = explode( '_', $def[0] ); $ratio = $analysis[ $path[0] ][ $path[1] ]; $values[ $key ] = null === $ratio ? 0.0 : round( (float) $ratio, 4 ); } $sums = self::sum_flows( $series, $after, $date ); foreach ( self::get_amount_columns() as $key => $def ) { list( $metric, $kind ) = $def; if ( 'flow' === $kind ) { $amount = isset( $sums[ $metric ] ) ? $sums[ $metric ] : 0.0; } else { $amount = isset( $metrics[ $metric ] ) ? (float) $metrics[ $metric ] : 0.0; } $values[ $key ] = round( $amount, 2 ); } return $values; } /** User meta: which version of the default column choice was last applied to this user. */ const META_COLUMNS_SEEDED = 'bpro_data_light_report_columns_seeded'; /** Bump to re-apply the default column choice to every user once. */ const COLUMNS_DEFAULTS_VERSION = '1'; /** * Makes the columns shown by default on Analytics > BPro (XIP, XIM, XLP, XLM, P, βO) the checked ones in the * table's "Choose which values to display" menu. * * WooCommerce ignores a column's `hiddenByDefault` as soon as it reads a column preference, and without a saved * one it shows EVERY column. The preference is a per-user list of HIDDEN column keys (user meta * `woocommerce_meta` > `products_report_columns`, a JSON string; the BPro report shares it with Products), so the * default is set by writing that list once per user: every BPro column that is not shown by default is hidden. * The user's other hidden columns (Products' own) are kept. After that, the user's own choices are respected. * * Hooked on `admin_init`, i.e. before WooCommerce loads the user's preferences for the page. */ public function seed_default_columns() { if ( ! self::is_enabled() || ! current_user_can( 'manage_woocommerce' ) ) { return; } $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page-identity check. if ( 'wc-admin' !== $page ) { return; } $user_id = get_current_user_id(); if ( ! $user_id || self::COLUMNS_DEFAULTS_VERSION === get_user_meta( $user_id, self::META_COLUMNS_SEEDED, true ) ) { return; } $bpro_keys = array(); $hide = array(); foreach ( self::get_report_page_columns() as $column ) { $bpro_keys[] = $column['key']; if ( empty( $column['visible'] ) ) { $hide[] = $column['key']; } } $meta = get_user_meta( $user_id, 'woocommerce_meta', true ); $meta = is_array( $meta ) ? $meta : array(); $hidden = array(); if ( isset( $meta['products_report_columns'] ) ) { $decoded = json_decode( (string) $meta['products_report_columns'], true ); $hidden = is_array( $decoded ) ? $decoded : array(); } // Keep what is hidden for other reasons (Products' own columns); BPro's own are set from scratch. $hidden = array_values( array_unique( array_merge( array_diff( $hidden, $bpro_keys ), $hide ) ) ); $meta['products_report_columns'] = wp_json_encode( $hidden ); update_user_meta( $user_id, 'woocommerce_meta', $meta ); update_user_meta( $user_id, self::META_COLUMNS_SEEDED, self::COLUMNS_DEFAULTS_VERSION ); } /** * The color of every summary tab that has a counterpart on the BPro Structure saldo grid: * tab key => '#rrggbb', taken from the same source the grid's cell tints use * (Bpro_Data_Light_Metrics::get_color_for_key()). λ0 is not on the grid, so it has none. * * @return array */ protected static function get_summary_tab_colors() { $colors = array(); foreach ( self::get_amount_columns() as $key => $def ) { $colors[ $key ] = Bpro_Data_Light_Metrics::get_color_for_key( $def[0] ); } $ratio_defs = self::get_ratio_columns(); foreach ( $ratio_defs as $key => $def ) { $colors[ $key ] = Bpro_Data_Light_Metrics::get_color_for_key( $def[0] ); } // λ0 sits on the grid as the pink \"growth\" field. $growth = Bpro_Data_Light_Metrics::get_growth_field_colors(); if ( isset( $growth['GROWTH'] ) ) { $colors[ self::COLUMN_KEY ] = $growth['GROWTH']; } return $colors; } /** * Every summary tab of the BPro report in the order of the saldo grid on the BPro Structure page, read row by * row (Settled M · Accrued P · Balance · Analysis): ZI, XI, XR, XO, XL, YI, YL, YO, TX, NP, D, P rows; λ0 comes last. Only tabs that exist are listed - XLP and YIP are on the * grid but not among the tabs; add them to get_extra_summary_tabs() and they appear here in their grid place. * * @return array[] [ key, label, type ]. */ protected static function get_ordered_summary_tabs() { $available = array( self::COLUMN_KEY => array( 'key' => self::COLUMN_KEY, 'label' => 'λ0 (%)', 'type' => 'number', ), ); foreach ( self::get_ratio_columns() as $key => $def ) { $available[ $key ] = array( 'key' => $key, 'label' => $def[1], 'type' => 'number', ); } foreach ( self::get_extra_summary_tabs() as $tab ) { $available[ $tab['key'] ] = $tab; } $order = array( 'bpro_zim', 'bpro_zip', 'bpro_zi', 'bpro_xim', 'bpro_xip', 'bpro_xi', 'bpro_k_i', 'bpro_xrm', 'bpro_xrp', 'bpro_xr', 'bpro_k_r', 'bpro_xom', 'bpro_xo', 'bpro_xlm', 'bpro_xlp', 'bpro_xl', 'bpro_k_l', 'bpro_yim', 'bpro_yip', 'bpro_yi', 'bpro_beta_i', 'bpro_ylm', 'bpro_ylp', 'bpro_yl', 'bpro_beta_l', 'bpro_yom', 'bpro_yop', 'bpro_yo', 'bpro_beta_o', 'bpro_txm', 'bpro_txp', 'bpro_tx', 'bpro_npm', 'bpro_np', 'bpro_dm', 'bpro_dp', 'bpro_d', 'bpro_pm', 'bpro_p', self::COLUMN_KEY, ); $tabs = array(); foreach ( $order as $key ) { if ( isset( $available[ $key ] ) ) { $tabs[] = $available[ $key ]; unset( $available[ $key ] ); } } // Anything not placed above (a tab added later) goes at the end rather than getting lost. return array_merge( $tabs, array_values( $available ) ); } /** * The summary tabs added after λ0 / βI ... kL, in the order they were asked for: * XIP, XIM, XLM, YIM ... P. Amounts are shown as currency, ratios as plain numbers. * * @return array[] [ key, label, type ]. */ protected static function get_extra_summary_tabs() { $metrics = array( 'XIP', 'XIM', 'XLM', 'YIM', 'YLP', 'YLM', 'TXP', 'TXM', 'DP', 'DM', 'ZIM', 'XRM', 'XOM', 'YOM', 'NPM', 'PM', 'ZIP', 'XRP', 'YOP', 'ZI', 'XI', 'XR', 'XO', 'XL', 'YI', 'YL', 'YO', 'TX', 'NP', 'D', 'P', ); $amounts = self::get_amount_columns(); $tabs = array(); foreach ( $metrics as $metric ) { $key = 'bpro_' . strtolower( $metric ); if ( isset( $amounts[ $key ] ) ) { $tabs[] = array( 'key' => $key, 'label' => $metric, 'type' => 'currency', ); } } return $tabs; } /** * The products a "compare" request is about (`products`, or `product_includes`). * * @param WP_REST_Request $request * @return int[] */ protected function get_compared_product_ids( $request ) { foreach ( array( 'products', 'product_includes' ) as $param ) { $ids = wp_parse_id_list( $request->get_param( $param ) ); if ( ! empty( $ids ) ) { return $ids; } } return array(); } /** * The `segments` list for one totals / interval entry of a compare response, with λ0 in each. * * If WooCommerce already returned segments they are kept (and get λ0 added). If it returned none - * which is what it does for a field it does not know - one segment per compared product is built: * [ segment_id => product id, segment_label => product name, subtotals => [ bpro_lambda0 => % ] ]. * * @param array[] $existing Segments as WooCommerce returned them (possibly empty). * @param int[] $product_ids Compared products, used only when $existing is empty. * @param string $date 'Y-m-d' λ0 is read at. * @param string|null $after 'Y-m-d' start of the summed range for flow tabs. * @return array[] */ protected function build_segments( array $existing, array $product_ids, $date, $after = null ) { if ( ! empty( $existing ) ) { return $this->add_lambda_to_segments( $existing, $date, $after ); } $segments = array(); foreach ( $product_ids as $product_id ) { $product = wc_get_product( $product_id ); $segments[] = array( 'segment_id' => (int) $product_id, 'segment_label' => $product ? $product->get_name() : (string) $product_id, 'subtotals' => $this->get_stat_values( $this->get_series( $product_id ), $date, $after ), ); } return $segments; } /** * λ0 for each segment (one product each) of a "compare" response, as of $date. * * @param array[] $segments Each: [ 'segment_id' => product id, 'segment_label' => ..., 'subtotals' => [...] ]. * @param string $date 'Y-m-d'. * @return array[] */ protected function add_lambda_to_segments( array $segments, $date, $after = null ) { foreach ( $segments as $index => $segment ) { $segment = self::as_array( $segment ); $product_id = isset( $segment['segment_id'] ) ? (int) $segment['segment_id'] : 0; $series = $product_id > 0 ? $this->get_series( $product_id ) : array(); $segments[ $index ]['subtotals'] = array_merge( self::as_array( isset( $segment['subtotals'] ) ? $segment['subtotals'] : null ), $this->get_stat_values( $series, $date, $after ) ); } return $segments; } /** * The computed series for whatever the report's "Show" filter selects: * chosen products, else chosen categories, else every product. Variations * are rolled into their parents. "Excluded" products/categories are not * applied. * * @param WP_REST_Request $request * @return array[] Bpro_Data_Light_Metrics::calculate_series() output, date ASC. */ protected function get_series_for_stats_request( $request ) { $ids_of = static function ( $request, array $params ) { foreach ( $params as $param ) { $ids = wp_parse_id_list( $request->get_param( $param ) ); if ( ! empty( $ids ) ) { return $ids; } } return array(); }; $product_ids = $ids_of( $request, array( 'product_includes', 'products' ) ); $category_ids = $ids_of( $request, array( 'category_includes', 'categories' ) ); if ( ! empty( $product_ids ) ) { $post_ids = array(); foreach ( $product_ids as $product_id ) { $post_ids = array_merge( $post_ids, Bpro_Data_Light_Data::get_post_ids( $product_id ) ); } } elseif ( ! empty( $category_ids ) ) { $post_ids = array(); foreach ( $category_ids as $term_id ) { $post_ids = array_merge( $post_ids, Bpro_Data_Light_Data::get_post_ids_for_category( $term_id ) ); } } else { $post_ids = Bpro_Data_Light_Data::get_post_ids_for_all_products(); } return Bpro_Data_Light_Data::get_computed_series( array_values( array_unique( array_map( 'intval', $post_ids ) ) ) ); } /** * The day λ0 is read at: the last day of the report's date range, * never later than today (the future has no facts, and a later day would * only ever show the "no change" 0%). * * Filter: `bpro_data_light_analytics_lambda_date` ( string $date 'Y-m-d', WP_REST_Request $request ). * * @param WP_REST_Request $request * @return string 'Y-m-d' */ protected function get_as_of_date( $request ) { $today = current_time( 'Y-m-d' ); $before = $request instanceof WP_REST_Request ? $request->get_param( 'before' ) : null; $date = $today; if ( is_string( $before ) && preg_match( '/^(\d{4}-\d{2}-\d{2})/', $before, $matches ) ) { $date = $matches[1]; } if ( $date > $today ) { $date = $today; } $filtered = apply_filters( 'bpro_data_light_analytics_lambda_date', $date, $request ); return ( is_string( $filtered ) && preg_match( '/^\d{4}-\d{2}-\d{2}$/', $filtered ) ) ? $filtered : $date; } /** * One product's computed series (variations rolled in), memoised per request. * * @param int $product_id * @return array[] */ protected function get_series( $product_id ) { if ( ! isset( self::$series_cache[ $product_id ] ) ) { self::$series_cache[ $product_id ] = Bpro_Data_Light_Data::get_computed_series_for_product( $product_id ); } return self::$series_cache[ $product_id ]; } /** * λ0 = XO(t) / XO(t-1) - 1 as of $date, from an already computed series. * * Same result the Saldo grid's "Growth" row gives for that date: * - $date before the first recorded day -> null (no data yet); * - $date on a recorded day -> that day's XO against the previous day's * (the series is gap-filled, so "previous" is the previous calendar day); * - $date after the last recorded day -> nothing happened, XO is carried * forward unchanged, so λ0 is 0 (as long as XO is not 0 itself); * - null whenever XO(t-1) is 0 (undefined). * * @param array[] $series Bpro_Data_Light_Data::get_computed_series_for_product() output, date ASC. * @param string $date 'Y-m-d'. * @return float|null */ public static function calculate_lambda_o( array $series, $date ) { if ( empty( $series ) || $series[0]['date'] > $date ) { return null; } $last = null; foreach ( $series as $index => $row ) { if ( $row['date'] > $date ) { break; } $last = $index; } if ( null === $last ) { return null; } $xo_of = static function ( $row ) { return isset( $row['metrics']['XO'] ) ? (float) $row['metrics']['XO'] : 0.0; }; $xo_last = $xo_of( $series[ $last ] ); if ( $series[ $last ]['date'] < $date ) { // No activity between the last recorded day and $date: balance carried forward. return Bpro_Data_Light_Metrics::calculate_xo_growth( $xo_last, $xo_last ); } if ( 0 === $last ) { return null; // First recorded day has no predecessor. } return Bpro_Data_Light_Metrics::calculate_xo_growth( $xo_last, $xo_of( $series[ $last - 1 ] ) ); } /** * The traffic-light zone for a λ0 value - same thresholds as the BPro * Structure gauge and the admin-bar light (λ0 of the whole store). * * @param float|null $value * @param float $threshold Daily bank rate. * @return string 'red'|'yellow'|'green'|'none' */ protected static function get_zone( $value, $threshold ) { if ( null === $value ) { return 'none'; } if ( $value < 0 ) { return 'red'; } if ( $value < $threshold ) { return 'yellow'; } return 'green'; } }