description (what the AI reads when choosing). * * @return array */ public static function catalogue() { return array( 'overview' => 'Headline numbers: customers, repeat purchase rate, active customers, new customers in 30 days, average customer value, revenue, forecast for the next 12 months, repeat customers likely to stop buying and their yearly revenue.', 'groups' => 'Every customer group with customer count, share of revenue, average spend, average orders, average churn risk, days since last order and forecast value.', 'group_detail' => 'One group in depth: its numbers, where its customers moved from and to in the last 30 days, and the products its customers buy most. Needs a group key.', 'cohorts' => 'For each of the last 12 first-purchase months: how many new customers, and what share bought again 1 to 6 months later.', 'trends' => 'The last 26 weeks of revenue, orders, new customers and returning orders, plus any unusual week.', 'patterns' => 'Products bought together in one order, and what customers come back for next after buying a product (with typical days in between).', 'changes' => 'Moves between groups in the last 30 days, counted by from-group and to-group.', 'model' => 'How churn risk is calculated: the trained model or the simple rule, and how accurate it was on past customers.', ); } /** * Runs a report. * * @param string $id Report ID. * @param array $args Arguments (group for group_detail). * @return array */ public static function run( $id, array $args = array() ) { $snap = Insights::get(); switch ( $id ) { case 'overview': return array( 'currency' => $snap['currency'] ?? get_woocommerce_currency(), 'kpis' => $snap['kpis'] ?? Insights::kpis( time() ), ); case 'groups': $out = array(); foreach ( Scoring::groups() as $key => $g ) { $out[ $key ] = array_merge( array( 'label' => $g['label'], 'about' => $g['about'], ), $snap['groups'][ $key ] ?? array() ); } return $out; case 'group_detail': return self::group_detail( (string) ( $args['group'] ?? '' ) ); case 'cohorts': return $snap['cohorts'] ?? array(); case 'trends': return array( 'weeks' => $snap['weeks'] ?? array(), 'anomalies' => $snap['anomalies'] ?? array(), ); case 'patterns': return $snap['patterns'] ?? array(); case 'changes': return Changes::flows( 30 ); case 'model': return $snap['model'] ?? Insights::model_summary(); } return array( 'error' => 'unknown_report' ); } /** * One group in depth. * * @param string $group Group key. * @return array */ public static function group_detail( $group ) { $groups = Scoring::groups(); if ( ! isset( $groups[ $group ] ) ) { return array( 'error' => 'unknown_group', 'groups' => array_keys( $groups ), ); } $snap = Insights::get(); $flows = array( 'in' => array(), 'out' => array(), ); foreach ( Changes::flows( 30 ) as $f ) { if ( $f['to_segment'] === $group ) { $flows['in'][ $f['from_segment'] ] = (int) $f['n']; } elseif ( $f['from_segment'] === $group ) { $flows['out'][ $f['to_segment'] ] = (int) $f['n']; } } $out = array( 'group' => $group, 'label' => $groups[ $group ]['label'], 'about' => $groups[ $group ]['about'], 'stats' => $snap['groups'][ $group ] ?? array(), 'moves_30d' => $flows, 'top_products' => self::top_products( $group ), ); if ( Settings::get( 'ai_share_names' ) ) { $out['top_customers'] = self::top_customers( $group, 5 ); } return $out; } /** * Products the group's customers bought most in the last year. * * @param string $group Group key. * @param int $limit Rows. * @return array[] */ public static function top_products( $group, $limit = 5 ) { global $wpdb; $items = $wpdb->prefix . 'woocommerce_order_items'; $meta = $wpdb->prefix . 'woocommerce_order_itemmeta'; $t = Store::table(); $sql = 'SELECT CAST(im.meta_value AS UNSIGNED) AS product_id, COUNT(DISTINCT o.order_id) AS orders FROM ( ' . Orders::order_rows_sql() . " ) o JOIN $t c ON c.customer_key = MD5(o.ckey) AND c.segment = %s JOIN $items oi ON oi.order_id = o.order_id AND oi.order_item_type = 'line_item' JOIN $meta im ON im.order_item_id = oi.order_item_id AND im.meta_key = '_product_id' WHERE o.net > 0 AND o.created >= %s GROUP BY product_id ORDER BY orders DESC LIMIT %d"; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- inner SQL built from constants; table names only. $rows = (array) $wpdb->get_results( $wpdb->prepare( $sql, $group, gmdate( 'Y-m-d H:i:s', time() - YEAR_IN_SECONDS ), $limit ), ARRAY_A ); foreach ( $rows as $i => $r ) { $p = wc_get_product( (int) $r['product_id'] ); $rows[ $i ]['name'] = $p ? $p->get_name() : '#' . $r['product_id']; $rows[ $i ]['orders'] = (int) $r['orders']; } return $rows; } /** * Highest-value customers in a group (only shared with AI when the owner opts in). * * @param string $group Group key. * @param int $limit Rows. * @return array[] */ public static function top_customers( $group, $limit = 5 ) { global $wpdb; $t = Store::table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name only. return (array) $wpdb->get_results( $wpdb->prepare( "SELECT name, orders, ROUND(spent, 2) AS spent, churn_risk, DATEDIFF(UTC_TIMESTAMP(), last_order) AS days_since_last_order FROM $t WHERE segment = %s ORDER BY spent DESC LIMIT %d", $group, $limit ), ARRAY_A ); } /** * Registers the reports as WordPress Abilities (WordPress 6.9+). */ public static function register_abilities() { if ( ! function_exists( 'wp_register_ability' ) ) { return; } foreach ( self::catalogue() as $id => $description ) { $input = 'group_detail' === $id ? array( 'type' => 'object', 'properties' => array( 'group' => array( 'type' => 'string', 'enum' => array_keys( Scoring::groups() ), ), ), 'required' => array( 'group' ), ) : array( 'type' => 'object', 'properties' => new \stdClass(), ); wp_register_ability( 'statbird/' . str_replace( '_', '-', $id ), array( /* translators: %s: report ID. */ 'label' => sprintf( __( 'Statbird report: %s', 'statbird' ), str_replace( '_', ' ', $id ) ), 'description' => $description, 'category' => 'statbird', 'input_schema' => $input, 'output_schema' => array( 'type' => array( 'object', 'array' ) ), 'execute_callback' => static function ( $args = array() ) use ( $id ) { return Reports::run( $id, is_array( $args ) ? $args : array() ); }, 'permission_callback' => static function () { return current_user_can( 'manage_woocommerce' ); // phpcs:ignore WordPress.WP.Capabilities.Unknown -- WooCommerce's store manager capability. }, 'meta' => array( 'annotations' => array( 'readonly' => true, 'destructive' => false, 'idempotent' => true, ), 'show_in_rest' => true, ), ) ); } } /** * Registers the Statbird ability category. */ public static function register_category() { if ( function_exists( 'wp_register_ability_category' ) ) { wp_register_ability_category( 'statbird', array( 'label' => __( 'Statbird customer analytics', 'statbird' ), 'description' => __( 'Read-only customer analytics for this WooCommerce store.', 'statbird' ), ) ); } } }