[ [ 'd' => 'Y-m-d', 'p' => paid-side?, 'v' => [ 'xip' => ..., ... ] ], ... ] ]. * - `bpro_data_light_index` transient: which products each order touched (so an order that changed can be * taken out of the data before it is calculated again). * - `bpro_data_light_state` option (small, not autoloaded): the queue, the progress of a full rebuild and the * "last updated" time. * - `bpro_data_light_restocks` option (small, not autoloaded): stock-in events (xlp / xlm). WooCommerce keeps only * the CURRENT stock quantity, never a history of stock-ins, so these cannot be recalculated from WooCommerce data - * they are recorded as they happen (and once, as "starting stock", when the plugin is activated). * * Everything in the two transients can be rebuilt from WooCommerce at any time; if they disappear * (expired, flushed, evicted) the plugin notices and rebuilds them. * * The calculation of the rows from an order is the same as the full plugin's importer, so both give the * same numbers for the same orders - see build_order_rows(). * * @since 1.0.0 * @package Bpro_Data_Light * @subpackage Bpro_Data_Light/includes */ class Bpro_Data_Light_Data { const SYNC_HOOK = 'bpro_data_light_sync'; const TRANSIENT_P = 'bpro_data_light_p_'; const TRANSIENT_IX = 'bpro_data_light_index'; const LOCK = 'bpro_data_light_lock'; const OPTION_STATE = 'bpro_data_light_state'; const OPTION_RESTOCKS = 'bpro_data_light_restocks'; const OPTION_SEEDED = 'bpro_data_light_starting_stock_imported'; const OPTION_ACTIVATED = 'bpro_data_light_activated_at'; /** The transients are renewed on every write; this is only how long unused data is kept. */ const TTL = 30 * DAY_IN_SECONDS; /** Orders read per batch of a full rebuild. */ const BATCH_SIZE = 50; /** More queued orders than this -> do one full rebuild instead. */ const QUEUE_LIMIT = 2000; /** The 12 base values, lower case. */ public static function base_keys() { return Bpro_Data_Light_Metrics::BASE_KEYS; } /* ================================================================= * Hooks * ============================================================= */ /** * Wires the sync: Analytics order updates, stock-ins and the cron event. */ public static function init() { // WooCommerce Analytics data update -> BPro data update. add_action( 'woocommerce_analytics_update_order_stats', array( __CLASS__, 'queue_order' ), 20 ); add_action( 'woocommerce_order_scheduler_after_import_order', array( __CLASS__, 'queue_order' ), 20 ); add_action( 'woocommerce_analytics_delete_order_stats', array( __CLASS__, 'queue_order' ), 20 ); add_action( 'woocommerce_delete_order', array( __CLASS__, 'queue_order' ), 20 ); add_action( 'woocommerce_trash_order', array( __CLASS__, 'queue_order' ), 20 ); // Stock-ins (xlp / xlm): recorded the moment the stock goes up. add_action( 'woocommerce_product_before_set_stock', array( __CLASS__, 'capture_live_stock_change' ) ); add_action( 'woocommerce_variation_before_set_stock', array( __CLASS__, 'capture_live_stock_change' ) ); add_action( self::SYNC_HOOK, array( __CLASS__, 'run_sync' ) ); add_action( 'admin_init', array( __CLASS__, 'ensure_scheduled' ) ); } /* ================================================================= * State * ============================================================= */ /** * @return array{queue:int[],full:bool,page:int,last_updated:int,running:bool} */ public static function get_state() { $state = get_option( self::OPTION_STATE, array() ); $state = is_array( $state ) ? $state : array(); return array_merge( array( 'queue' => array(), 'full' => false, 'page' => 0, 'last_updated' => 0, 'running' => false, ), $state ); } protected static function save_state( array $state ) { update_option( self::OPTION_STATE, $state, false ); } /** * What the "Data status" box shows. * * @return array{last_updated:int,last_text:string,next_update:int,next_text:string,running:bool,pending:int} */ public static function get_status() { $state = self::get_state(); $next = (int) wp_next_scheduled( self::SYNC_HOOK ); $last = (int) $state['last_updated']; $fmt = static function ( $ts ) { return $ts > 0 ? wp_date( 'M j \a\t H:i', $ts ) : '—'; }; return array( 'last_updated' => $last, 'last_text' => $fmt( $last ), 'next_update' => $next, 'next_text' => $fmt( $next ), 'running' => (bool) get_transient( self::LOCK ), 'pending' => count( (array) $state['queue'] ) + ( $state['full'] ? 1 : 0 ), ); } /* ================================================================= * Queue + schedule * ============================================================= */ /** @var int[] Orders queued during this request, written to the option at shutdown. */ protected static $pending_queue = array(); /** @var bool */ protected static $shutdown_registered = false; /** * An Analytics order update (or an order deletion) happened: remember the order for the next BPro update. * * @param int|object $order_id */ public static function queue_order( $order_id ) { if ( is_object( $order_id ) ) { $order_id = method_exists( $order_id, 'get_id' ) ? $order_id->get_id() : ( $order_id->ID ?? 0 ); } $order_id = (int) $order_id; if ( $order_id <= 0 ) { return; } self::$pending_queue[ $order_id ] = $order_id; if ( ! self::$shutdown_registered ) { self::$shutdown_registered = true; add_action( 'shutdown', array( __CLASS__, 'flush_queue' ) ); } } /** * Writes the orders queued in this request and makes sure an update is scheduled. */ public static function flush_queue() { if ( empty( self::$pending_queue ) ) { return; } $state = self::get_state(); $queue = array_values( array_unique( array_merge( array_map( 'intval', (array) $state['queue'] ), array_values( self::$pending_queue ) ) ) ); self::$pending_queue = array(); if ( count( $queue ) > self::QUEUE_LIMIT ) { $state['full'] = true; $state['page'] = 0; $queue = array(); } $state['queue'] = $queue; self::save_state( $state ); // Debounce: a burst of Analytics updates ends up as one BPro update a minute later. self::schedule_sync( (int) apply_filters( 'bpro_data_light_sync_delay', MINUTE_IN_SECONDS ) ); } /** * Schedules the (single) update event, unless one is already due sooner. * * @param int $delay Seconds from now. */ public static function schedule_sync( $delay ) { $when = time() + max( 1, (int) $delay ); $next = wp_next_scheduled( self::SYNC_HOOK ); if ( $next && $next <= $when ) { return; } if ( $next ) { wp_unschedule_event( $next, self::SYNC_HOOK ); } wp_schedule_single_event( $when, self::SYNC_HOOK ); } /** * There is always a next update: if none is scheduled (cron events lost, first run), schedule one. */ public static function ensure_scheduled() { if ( ! wp_next_scheduled( self::SYNC_HOOK ) ) { self::schedule_sync( 30 ); } } /** * The interval between regular updates, in seconds (they catch whatever no Analytics hook announced). * * @return int */ public static function get_sync_interval() { return max( 60, (int) apply_filters( 'bpro_data_light_sync_interval', 15 * MINUTE_IN_SECONDS ) ); } /** * Asks for a full rebuild from WooCommerce on the next update. * * @param int $delay Seconds until the update. */ public static function request_full_rebuild( $delay = 5 ) { $state = self::get_state(); if ( ! $state['full'] ) { $state['full'] = true; $state['page'] = 0; self::save_state( $state ); } self::schedule_sync( $delay ); } /* ================================================================= * The update itself * ============================================================= */ /** @var array|null Product post_id => order rows, not yet written to its transient. */ protected static $write_buffer = array(); /** * WP-Cron callback / "Update now": works the queue off (or continues a full rebuild), within a time budget. * If there is more to do it schedules itself again a few seconds later. * * @param bool $force Run even if another update holds the lock (never true for cron). * @return array Status, as get_status(). */ public static function run_sync( $force = false ) { if ( ! function_exists( 'wc_get_orders' ) ) { return self::get_status(); } if ( ! $force && get_transient( self::LOCK ) ) { self::schedule_sync( 30 ); return self::get_status(); } set_transient( self::LOCK, 1, 2 * MINUTE_IN_SECONDS ); if ( function_exists( 'wc_set_time_limit' ) ) { wc_set_time_limit( 0 ); } $state = self::get_state(); $queue_at_start = array_map( 'intval', (array) $state['queue'] ); $state['running'] = true; self::save_state( $state ); $deadline = time() + max( 5, (int) apply_filters( 'bpro_data_light_sync_time_budget', 20 ) ); // A missing index means the transients are gone: rebuild from WooCommerce. if ( false === get_transient( self::TRANSIENT_IX ) && ! $state['full'] ) { $state['full'] = true; $state['page'] = 0; } self::maybe_seed_starting_stock(); // 1. Full rebuild (first run, Reset All, evicted transients). if ( $state['full'] ) { if ( 0 === (int) $state['page'] ) { self::delete_computed_data(); $state['page'] = 1; } do { $orders = wc_get_orders( array( 'limit' => self::BATCH_SIZE, 'page' => (int) $state['page'], 'orderby' => array( 'date' => 'ASC', 'ID' => 'ASC', ), 'status' => 'any', 'type' => 'shop_order', ) ); foreach ( $orders as $order ) { self::apply_order( $order ); } self::flush_writes(); if ( count( $orders ) < self::BATCH_SIZE ) { $state['full'] = false; $state['page'] = 0; break; } $state['page']++; self::save_state( $state ); } while ( time() < $deadline ); } // 2. The orders Analytics announced. if ( ! $state['full'] ) { $queue = array_map( 'intval', (array) $state['queue'] ); while ( ! empty( $queue ) && time() < $deadline ) { self::apply_order( array_shift( $queue ) ); } self::flush_writes(); $state['queue'] = $queue; } // Orders queued while this update ran (by another request) are kept. $fresh = self::get_state(); $arrived = array_diff( array_map( 'intval', (array) $fresh['queue'] ), $queue_at_start ); $state['queue'] = array_values( array_unique( array_merge( array_map( 'intval', (array) $state['queue'] ), $arrived ) ) ); $state['running'] = false; $more = $state['full'] || ! empty( $state['queue'] ); if ( ! $more ) { $state['last_updated'] = time(); } self::save_state( $state ); delete_transient( self::LOCK ); self::$series_cache = array(); if ( $more ) { self::schedule_sync( 10 ); } else { self::schedule_sync( self::get_sync_interval() ); } return self::get_status(); } /* ================================================================= * Orders -> rows * ============================================================= */ /** @var array|null Index of the current update: [ 'orders' => [ order_id => post_id[] ], 'products' => [ post_id => 1 ] ]. */ protected static $index = null; /** * @return array{orders:array,products:array} */ protected static function get_index() { if ( null === self::$index ) { $index = get_transient( self::TRANSIENT_IX ); if ( ! is_array( $index ) ) { $index = array(); } self::$index = array_merge( array( 'orders' => array(), 'products' => array(), ), $index ); } return self::$index; } /** * One product's order rows: [ order_id => rows[] ]. * * @param int $post_id * @return array */ protected static function get_product_orders( $post_id ) { $post_id = (int) $post_id; if ( isset( self::$write_buffer[ $post_id ] ) ) { return self::$write_buffer[ $post_id ]; } $data = get_transient( self::TRANSIENT_P . $post_id ); if ( is_array( $data ) ) { return $data; } // The index knows the product but its transient is gone (evicted / flushed): rebuild. $index = self::get_index(); if ( isset( $index['products'][ $post_id ] ) && ! self::$rebuild_requested ) { self::$rebuild_requested = true; self::request_full_rebuild(); } return array(); } /** @var bool */ protected static $rebuild_requested = false; /** * Takes one order out of the data, then (unless it is gone / cancelled / refunded) calculates it again from the * order as it is now. Same rules as the full plugin's importer: * * xip = list price * qty @ date the order was placed * xim = invoiced price * qty @ date the order was paid (paid orders only) * yip = (list price - cost) * qty @ placed yim = (invoiced price - cost) * qty @ paid * txp = tax on the list price @ placed (taxable products) txm = tax actually paid @ paid * ylp / ylm = the order's fees, prorated to the line items @ placed / @ paid * dp = the order's discount, prorated to the line items @ placed * * cancelled / refunded / trashed / deleted orders are no economic event and are removed; for pending / on-hold / * failed orders the paid side is left exactly as it was. * * @param WC_Order|int $order Order or order ID. */ public static function apply_order( $order ) { $order_id = is_object( $order ) ? $order->get_id() : (int) $order; if ( ! is_object( $order ) ) { $order = wc_get_order( $order_id ); } if ( ! $order ) { self::remove_order( $order_id, false ); // Permanently deleted. return; } if ( ! $order instanceof WC_Order ) { return; // Refund objects: the parent order is what counts. } $status = $order->get_status(); if ( in_array( $status, array( 'trash', 'cancelled', 'refunded' ), true ) ) { self::remove_order( $order_id, false ); return; } if ( in_array( $status, array( 'auto-draft', 'checkout-draft' ), true ) ) { return; // Never imported. } $no_paid_side = in_array( $status, array( 'pending', 'on-hold', 'failed' ), true ); self::remove_order( $order_id, $no_paid_side ); foreach ( self::build_order_rows( $order, $no_paid_side ) as $post_id => $rows ) { $data = self::get_product_orders( $post_id ); // Pending / on-hold / failed: the rows kept by remove_order() are the paid side; add the "placed" side next to them. $data[ $order_id ] = array_merge( isset( $data[ $order_id ] ) ? $data[ $order_id ] : array(), $rows ); self::$write_buffer[ $post_id ] = $data; } self::refresh_index_for_order( $order_id ); } /** * Removes an order's rows from every product it touched. * * @param int $order_id * @param bool $keep_paid Keep the paid-side rows (the order is pending / on hold / failed). */ protected static function remove_order( $order_id, $keep_paid ) { $index = self::get_index(); if ( empty( $index['orders'][ $order_id ] ) ) { return; } foreach ( $index['orders'][ $order_id ] as $post_id ) { $data = self::get_product_orders( $post_id ); if ( ! isset( $data[ $order_id ] ) ) { continue; } if ( $keep_paid ) { $data[ $order_id ] = array_values( array_filter( $data[ $order_id ], static function ( $row ) { return ! empty( $row['p'] ); } ) ); if ( empty( $data[ $order_id ] ) ) { unset( $data[ $order_id ] ); } } else { unset( $data[ $order_id ] ); } self::$write_buffer[ $post_id ] = $data; } self::refresh_index_for_order( $order_id ); } /** * Re-derives the index entries of one order from the buffered / stored product data. * * @param int $order_id */ protected static function refresh_index_for_order( $order_id ) { $index = self::get_index(); $pids = isset( $index['orders'][ $order_id ] ) ? $index['orders'][ $order_id ] : array(); $pids = array_unique( array_merge( $pids, array_keys( self::$write_buffer ) ) ); $now = array(); foreach ( $pids as $post_id ) { if ( isset( self::$write_buffer[ $post_id ] ) ) { $data = self::$write_buffer[ $post_id ]; } else { $data = get_transient( self::TRANSIENT_P . $post_id ); $data = is_array( $data ) ? $data : array(); } if ( isset( $data[ $order_id ] ) ) { $now[] = (int) $post_id; } } if ( empty( $now ) ) { unset( self::$index['orders'][ $order_id ] ); } else { self::$index['orders'][ $order_id ] = $now; } } /** * The rows of one order, by product: [ post_id => [ row, ... ] ] with row = [ 'd' => 'Y-m-d', 'p' => bool, 'v' => [...] ]. * * @param WC_Order $order * @param bool $no_paid_side Pending / on-hold / failed: no paid-side rows are built. * @return array */ protected static function build_order_rows( $order, $no_paid_side ) { $items = $order->get_items( 'line_item' ); if ( empty( $items ) ) { return array(); } $order_subtotal = (float) $order->get_subtotal(); $order_discount = (float) $order->get_discount_total(); $allocate_order_level = apply_filters( 'bpro_data_light_allocate_order_level_amounts', true, $order ); $order_fees_total = 0.0; foreach ( $order->get_items( 'fee' ) as $fee_item ) { $order_fees_total += (float) $fee_item->get_total(); } $allocate_order_fees = apply_filters( 'bpro_data_light_allocate_order_fees', $allocate_order_level, $order ); // Date "product added to order / shipped": the order creation date unless a filter says otherwise. $added = apply_filters( 'bpro_data_light_order_added_date', $order->get_date_created(), $order ); $added = $added ? $added->date( 'Y-m-d' ) : current_time( 'Y-m-d' ); $date_paid = $order->get_date_paid(); $paid = $date_paid ? $date_paid->date( 'Y-m-d' ) : null; $out = array(); /** @var WC_Order_Item_Product $item */ foreach ( $items as $item ) { $product_id = $item->get_product_id(); if ( ! $product_id ) { continue; // Line item for a deleted / unknown product. } $post_id = $item->get_variation_id() ? $item->get_variation_id() : $product_id; $qty = (float) $item->get_quantity(); if ( $qty <= 0 ) { continue; } $line_subtotal = (float) $item->get_subtotal(); // Before discounts. $line_total = (float) $item->get_total(); // After discounts, what was invoiced. $cs_list = $line_subtotal / $qty; $cs_paid = $line_total / $qty; $share = $order_subtotal > 0 ? $line_subtotal / $order_subtotal : 0; $cb = self::get_order_item_cost( $item, $post_id ); $product = $item->get_product(); $is_taxable = ( $product instanceof WC_Product ) && $product->is_taxable(); if ( ! isset( $out[ $post_id ] ) ) { $out[ $post_id ] = array( 'added' => array( 'd' => $added, 'p' => false, 'v' => array(), ), 'paid' => array( 'd' => $paid, 'p' => true, 'v' => array(), ), ); } $add = static function ( &$values, $key, $amount ) { $values[ $key ] = ( isset( $values[ $key ] ) ? $values[ $key ] : 0.0 ) + $amount; }; $add( $out[ $post_id ]['added']['v'], 'xip', $cs_list * $qty ); $add( $out[ $post_id ]['added']['v'], 'yip', ( $cs_list - $cb ) * $qty ); if ( $allocate_order_level ) { $add( $out[ $post_id ]['added']['v'], 'dp', $order_discount * $share ); } if ( $is_taxable ) { $add( $out[ $post_id ]['added']['v'], 'txp', (float) $item->get_subtotal_tax() ); } if ( $allocate_order_fees && 0.0 !== $order_fees_total ) { $add( $out[ $post_id ]['added']['v'], 'ylp', $order_fees_total * $share ); } if ( $paid && ! $no_paid_side ) { $add( $out[ $post_id ]['paid']['v'], 'xim', $cs_paid * $qty ); $add( $out[ $post_id ]['paid']['v'], 'yim', ( $cs_paid - $cb ) * $qty ); if ( $is_taxable ) { $add( $out[ $post_id ]['paid']['v'], 'txm', (float) $item->get_total_tax() ); } if ( $allocate_order_fees && 0.0 !== $order_fees_total ) { $add( $out[ $post_id ]['paid']['v'], 'ylm', $order_fees_total * $share ); } } } $rows = array(); foreach ( $out as $post_id => $sides ) { foreach ( $sides as $row ) { if ( empty( $row['v'] ) || null === $row['d'] ) { continue; } $row['v'] = array_map( static function ( $n ) { return round( (float) $n, 8 ); }, $row['v'] ); $rows[ $post_id ][] = $row; } } return $rows; } /** * Writes the buffered product data and the index to their transients. */ protected static function flush_writes() { $index = self::get_index(); foreach ( self::$write_buffer as $post_id => $data ) { if ( empty( $data ) ) { delete_transient( self::TRANSIENT_P . $post_id ); unset( self::$index['products'][ $post_id ] ); } else { set_transient( self::TRANSIENT_P . $post_id, $data, self::TTL ); self::$index['products'][ $post_id ] = 1; } } self::$write_buffer = array(); set_transient( self::TRANSIENT_IX, self::$index, self::TTL ); self::$order_rows_cache = array(); self::$series_cache = array(); } /** * Deletes every calculated transient (the stock-in record is NOT touched: it cannot be recalculated). */ public static function delete_computed_data() { $index = get_transient( self::TRANSIENT_IX ); if ( is_array( $index ) && ! empty( $index['products'] ) ) { foreach ( array_keys( $index['products'] ) as $post_id ) { delete_transient( self::TRANSIENT_P . $post_id ); } } delete_transient( self::TRANSIENT_IX ); // Anything left behind (index lost, product transients still there). global $wpdb; $like = $wpdb->esc_like( '_transient_' . self::TRANSIENT_P ) . '%'; $wpdb->query( $wpdb->prepare( "DELETE FROM `{$wpdb->options}` WHERE option_name LIKE %s OR option_name LIKE %s", $like, $wpdb->esc_like( '_transient_timeout_' . self::TRANSIENT_P ) . '%' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery self::$index = null; self::$write_buffer = array(); self::$order_rows_cache = array(); self::$series_cache = array(); } /* ================================================================= * Product cost (cb) * ============================================================= */ /** * The unit cost of an order line: WooCommerce core Cost of Goods Sold's `_cogs_value`, locked in when the item was * added to the order. No fallback: an item with no record costs 0 (same as the full plugin). * * @param WC_Order_Item_Product $item * @param int $post_id * @return float */ protected static function get_order_item_cost( $item, $post_id ) { $cost = 0.0; if ( $item instanceof WC_Order_Item_Product && method_exists( $item, 'get_cogs_value' ) ) { $qty = (float) $item->get_quantity(); if ( $qty > 0 ) { $cost = (float) $item->get_cogs_value() / $qty; } } return (float) apply_filters( 'bpro_data_light_resolved_order_item_cost', $cost, $item, $post_id ); } /** * The product's own current defined cost (for stock valuation). * * @param int $product_id * @return float */ public static function get_product_cost( $product_id ) { $product_id = (int) $product_id; $cost = self::read_cost_meta( $product_id ); if ( null === $cost ) { $parent_id = wp_get_post_parent_id( $product_id ); if ( $parent_id ) { $cost = self::read_cost_meta( $parent_id ); } } return (float) apply_filters( 'bpro_data_light_resolved_product_cost', null === $cost ? 0.0 : $cost, $product_id ); } protected static function read_cost_meta( $post_id ) { $keys = apply_filters( 'bpro_data_light_cost_meta_keys', array( '_cogs_total_value', '_wc_cog_cost', '_cost_price', '_cogs_cost', '_product_cost', '_alg_wc_cog_cost' ) ); foreach ( $keys as $meta_key ) { $value = get_post_meta( $post_id, $meta_key, true ); if ( '' !== $value && null !== $value && is_numeric( $value ) ) { return (float) $value; } } return null; } /* ================================================================= * Stock-ins (xlp / xlm) * ============================================================= */ /** @var array|null */ protected static $restocks = null; /** * @return array post_id => rows. */ protected static function get_restocks() { if ( null === self::$restocks ) { $stored = get_option( self::OPTION_RESTOCKS, array() ); self::$restocks = is_array( $stored ) ? $stored : array(); } return self::$restocks; } /** * `woocommerce_product_before_set_stock` / `woocommerce_variation_before_set_stock`: the product carries the NEW stock * in memory while the database still holds the old one, so the delta is read synchronously and exactly. Only increases * (stock-ins) are recorded. * * @param WC_Product $product */ public static function capture_live_stock_change( $product ) { if ( ! $product instanceof WC_Product ) { return; } $product_id = $product->get_id(); $stock_after = $product->get_stock_quantity(); if ( ! $product_id || null === $stock_after ) { return; } global $wpdb; // The database still holds the OLD stock here (the product object carries the new one); read it uncached on purpose. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $stock_before = $wpdb->get_var( $wpdb->prepare( "SELECT meta_value FROM `{$wpdb->postmeta}` WHERE post_id = %d AND meta_key = '_stock' LIMIT 1", $product_id ) ); if ( null === $stock_before || '' === $stock_before ) { return; } $delta = (float) $stock_after - (float) $stock_before; if ( $delta <= 0 ) { return; } self::add_restock( $product_id, $delta, current_time( 'Y-m-d' ), 'restock' ); } /** * Records a stock-in: cb * qb as xlp (product added to stock) and xlm (store owner paid for it). * * @param int $product_id * @param float $qty * @param string $date 'Y-m-d'. * @param string $label * @param bool $persist Write the record to the option now (false while seeding many products; the caller saves). */ public static function add_restock( $product_id, $qty, $date, $label = 'restock', $persist = true ) { $product_id = (int) $product_id; $qty = (float) $qty; if ( $qty <= 0 || ! $product_id ) { return; } $value = round( self::get_product_cost( $product_id ) * $qty, 8 ); $offset_days = (int) apply_filters( 'bpro_data_light_supplier_payment_offset_days', 0 ); $rows = array(); if ( 0 === $offset_days ) { $rows[] = array( 'd' => $date, 'p' => false, 'v' => array( 'xlp' => $value, 'xlm' => $value, ), 'q' => $qty, 'l' => $label, ); } else { $rows[] = array( 'd' => $date, 'p' => false, 'v' => array( 'xlp' => $value ), 'q' => $qty, 'l' => $label . ' (received)', ); $rows[] = array( 'd' => gmdate( 'Y-m-d', strtotime( $date . ' 00:00:00 UTC' ) + $offset_days * DAY_IN_SECONDS ), 'p' => false, 'v' => array( 'xlm' => $value ), 'q' => $qty, 'l' => $label . ' (supplier paid)', ); } $all = self::get_restocks(); foreach ( $rows as $row ) { $all[ $product_id ][] = $row; } self::$restocks = $all; if ( $persist ) { update_option( self::OPTION_RESTOCKS, $all, false ); } self::$series_cache = array(); self::$order_rows_cache = array(); } /** * One-time seed: every product's CURRENT stock is the starting stock-in, dated the day the plugin was activated * (or $date). WooCommerce keeps no stock history, so without it the stock that was already on the shelf would be * invisible. * * @param string|null $date 'Y-m-d'. * @return int Products / variations seeded. */ public static function maybe_seed_starting_stock( $date = null ) { if ( get_option( self::OPTION_SEEDED ) && null === $date ) { return 0; } if ( ! function_exists( 'wc_get_products' ) ) { return 0; } if ( null === $date ) { $activated = (string) get_option( self::OPTION_ACTIVATED, '' ); $date = '' !== $activated ? substr( $activated, 0, 10 ) : current_time( 'Y-m-d' ); } $written = 0; $page = 1; do { $products = wc_get_products( array( 'status' => 'publish', 'limit' => 100, 'page' => $page, 'orderby' => 'ID', 'order' => 'ASC', ) ); foreach ( $products as $product ) { $written += self::seed_product( $product, $date ); if ( $product->is_type( 'variable' ) ) { foreach ( $product->get_children() as $variation_id ) { $variation = wc_get_product( $variation_id ); if ( $variation ) { $written += self::seed_product( $variation, $date ); } } } } $page++; } while ( count( $products ) === 100 ); update_option( self::OPTION_RESTOCKS, self::get_restocks(), false ); update_option( self::OPTION_SEEDED, current_time( 'mysql' ), false ); self::$series_cache = array(); self::$order_rows_cache = array(); return $written; } protected static function seed_product( $product, $date ) { if ( ! $product instanceof WC_Product || ! $product->managing_stock() ) { return 0; } $stock = $product->get_stock_quantity(); if ( null === $stock || $stock <= 0 ) { return 0; } self::add_restock( $product->get_id(), (float) $stock, $date, 'starting stock', false ); return 1; } /** * "Reset All": forgets everything BPro Light has calculated and recorded (the calculated transients, the queue, * the stock-in record), re-seeds the starting stock from each product's current stock and asks for a full * rebuild of the order data from WooCommerce. Orders, products and stock quantities are never touched. * * @return int Products / variations seeded with starting stock. */ public static function reset_all() { self::delete_computed_data(); delete_option( self::OPTION_RESTOCKS ); delete_option( self::OPTION_SEEDED ); self::$restocks = null; $written = self::maybe_seed_starting_stock( current_time( 'Y-m-d' ) ); self::save_state( array( 'queue' => array(), 'full' => true, 'page' => 0, 'last_updated' => 0, 'running' => false, ) ); delete_transient( self::LOCK ); self::schedule_sync( 5 ); return $written; } /* ================================================================= * Reading: rows and series * ============================================================= */ /** @var array Per-request memo: post_id => dated rows. */ protected static $order_rows_cache = array(); /** @var array Per-request memo: product id => computed series. */ protected static $series_cache = array(); /** * The product's own post_id plus, for variable products, every variation's post_id (sales are recorded against the * variation when a line item is one). * * @param int $product_id * @return int[] */ public static function get_post_ids( $product_id ) { $post_ids = array( (int) $product_id ); $product = wc_get_product( $product_id ); if ( $product && $product->is_type( 'variable' ) ) { foreach ( $product->get_children() as $variation_id ) { $post_ids[] = (int) $variation_id; } } return $post_ids; } /** * The dated rows of one post_id (order rows + stock-ins): [ [ 'd', 'v' ], ... ]. * * @param int $post_id * @return array[] */ protected static function get_rows_for_post( $post_id ) { $post_id = (int) $post_id; if ( isset( self::$order_rows_cache[ $post_id ] ) ) { return self::$order_rows_cache[ $post_id ]; } $rows = array(); foreach ( self::get_product_orders( $post_id ) as $order_rows ) { foreach ( $order_rows as $row ) { $rows[] = $row; } } $restocks = self::get_restocks(); if ( isset( $restocks[ $post_id ] ) ) { foreach ( $restocks[ $post_id ] as $row ) { $rows[] = $row; } } self::$order_rows_cache[ $post_id ] = $rows; return $rows; } /** * Sums the base values of the given post_ids by day. * * @param int[] $post_ids * @return array[] [ 'date' => 'Y-m-d', 'xip' => float, ... ], ordered by date ASC. */ public static function get_daily_aggregated_rows( array $post_ids ) { $by_date = array(); $zero = array_fill_keys( self::base_keys(), 0.0 ); foreach ( array_unique( array_map( 'intval', $post_ids ) ) as $post_id ) { foreach ( self::get_rows_for_post( $post_id ) as $row ) { $date = $row['d']; if ( ! isset( $by_date[ $date ] ) ) { $by_date[ $date ] = $zero; } foreach ( $row['v'] as $key => $value ) { if ( isset( $by_date[ $date ][ $key ] ) ) { $by_date[ $date ][ $key ] += (float) $value; } } } } ksort( $by_date ); $out = array(); foreach ( $by_date as $date => $values ) { $out[] = array_merge( array( 'date' => $date ), $values ); } return $out; } /** * Daily rows -> the computed metric series (the 33 values, running balances threaded forward). * * @param int[] $post_ids * @return array[] [ 'date' => 'Y-m-d', 'metrics' => [...] ], ordered by date ASC. */ public static function get_computed_series( array $post_ids ) { return Bpro_Data_Light_Metrics::calculate_series( self::get_daily_aggregated_rows( $post_ids ) ); } /** * One product's computed series (variations rolled in). * * @param int $product_id * @return array[] */ public static function get_computed_series_for_product( $product_id ) { return self::get_computed_series( self::get_post_ids( $product_id ) ); } /** * Every post_id (product + variations) of every product directly assigned to a product_cat term. * * @param int $term_id * @return int[] */ public static function get_post_ids_for_category( $term_id ) { $product_ids = get_posts( array( 'post_type' => 'product', 'post_status' => 'publish', 'posts_per_page' => -1, 'fields' => 'ids', 'tax_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query array( 'taxonomy' => 'product_cat', 'field' => 'term_id', 'terms' => (int) $term_id, ), ), ) ); $post_ids = array(); foreach ( $product_ids as $product_id ) { $post_ids = array_merge( $post_ids, self::get_post_ids( $product_id ) ); } return array_values( array_unique( array_map( 'intval', $post_ids ) ) ); } /** * Every post_id (product + variations) of every published product. * * @return int[] */ public static function get_post_ids_for_all_products() { $product_ids = get_posts( array( 'post_type' => 'product', 'post_status' => 'publish', 'posts_per_page' => -1, 'fields' => 'ids', ) ); $post_ids = array(); foreach ( $product_ids as $product_id ) { $post_ids = array_merge( $post_ids, self::get_post_ids( $product_id ) ); } return array_values( array_unique( array_map( 'intval', $post_ids ) ) ); } }