forecasts = $forecasts; $this->settings = $settings; $this->supplier_products = $supplier_products; $this->records = $records; $this->snapshotter = $snapshotter; $this->pos = $pos; $this->velocity = $velocity; $this->resolver = $resolver; $this->logger = $logger; } /** * Recompute a batch of forecasts. * * @param int $limit Batch size. * @param int $offset Offset. * @return int Number of units processed in this batch. */ public function recompute_batch( int $limit = 200, int $offset = 0 ): int { $rows = $this->settings->enabled( $limit, $offset ); if ( empty( $rows ) ) { return 0; } $config = $this->config(); $statuses = $this->snapshotter->counted_statuses(); $now = time(); $to = gmdate( 'Y-m-d H:i:s', $now ); // Bulk sales for the global window + all open-PO quantities (one query each). $global_from = gmdate( 'Y-m-d H:i:s', strtotime( '-' . (int) $config['forecast_window'] . ' days', $now ) ); $global_sold = $this->records->units_sold_by_product( $global_from, $to, $statuses ); $open_map = $this->pos->open_incoming_by_product(); foreach ( $rows as $row ) { $this->compute_one( $row, $config, $statuses, $now, $to, $global_sold, $open_map ); } return count( $rows ); } /** * Recompute a single product's forecast on demand. * * @param int $product_id Purchasable-unit ID. * @return array|null The forecast breakdown, or null. */ public function recompute_one( int $product_id ): ?array { $settings = $this->settings->get( $product_id ); if ( ! $settings ) { return null; } $config = $this->config(); $statuses = $this->snapshotter->counted_statuses(); $now = time(); $to = gmdate( 'Y-m-d H:i:s', $now ); $open_map = $this->pos->open_incoming_by_product(); // No bulk sales map here: compute_one() queries this product's sales directly. $this->compute_one( $settings, $config, $statuses, $now, $to, null, $open_map ); return $this->breakdown( $product_id ); } /** * A transparent breakdown of a unit's forecast (inputs + outputs). * * @param int $product_id Purchasable-unit ID. * @return array|null */ public function breakdown( int $product_id ): ?array { $f = $this->forecasts->get( $product_id ); if ( ! $f ) { return null; } $shared = $this->resolver->shared_stock_parent( $product_id ); return array( 'product_id' => (int) $f['product_id'], 'label' => $this->resolver->unit_label( $product_id ), 'sku' => $this->resolver->sku( $product_id ), 'avg_daily_sales' => Money::to_float( $f['avg_daily_sales'] ), 'avg_weekly_sales' => Money::to_float( $f['avg_weekly_sales'] ), 'current_stock' => Money::to_float( $f['current_stock'] ), 'stock_tracked' => null !== $this->resolver->stock_quantity( $product_id ) || null !== $shared, // Shared (product-level) stock: the whole pool; current_stock is this variation's share. 'shared_stock' => $shared ? (float) ( $shared->get_stock_quantity() ?? 0 ) : null, 'days_remaining' => Money::to_float( $f['days_remaining'] ), 'reorder_point' => (int) $f['reorder_point'], 'lead_time_days' => (int) $f['lead_time_days'], 'safety_stock' => (int) $f['safety_stock'], 'suggested_qty' => (int) $f['suggested_qty'], 'qty_on_open_pos' => Money::to_float( $f['qty_on_open_pos'] ), 'severity' => (string) $f['severity'], 'confidence' => (string) $f['confidence'], 'computed_at' => (string) $f['computed_at'], ); } /** * Compute and persist a single forecast row. * * @param array $settings Product settings row. * @param array $config Global config. * @param string[] $statuses Counted statuses. * @param int $now Current timestamp. * @param string $to Range upper bound (UTC). * @param array|null $global_sold Bulk sales map (global window), or null to query this product directly. * @param array $open_map Open-PO quantities. */ private function compute_one( array $settings, array $config, array $statuses, int $now, string $to, ?array $global_sold, array $open_map ): void { $pid = (int) $settings['product_id']; $window = ! empty( $settings['forecast_window_days'] ) ? (int) $settings['forecast_window_days'] : (int) $config['forecast_window']; // Sales in the window: the bulk map when one covers this window (absent = no sales), else a targeted query. $sold = function ( int $id ) use ( $global_sold, $window, $config, $now, $to, $statuses ): float { if ( null !== $global_sold && $window === (int) $config['forecast_window'] ) { return (float) ( $global_sold[ $id ] ?? 0.0 ); } $from = gmdate( 'Y-m-d H:i:s', strtotime( '-' . $window . ' days', $now ) ); return $this->records->units_sold_for_product( $id, $from, $to, $statuses ); }; $units_sold = $sold( $pid ); $avg_daily = $this->velocity->avg_daily( $units_sold, $window ); $stock = $this->resolver->stock_quantity( $pid ); // Shared (product-level) stock: give this variation the part of the pool // that matches its share of the pool's sales (an equal split when nothing // sold). Every variation then shows the pool's real days of stock left, and // their suggested orders add up to what the whole product needs. $shared_parent = null === $stock ? $this->resolver->shared_stock_parent( $pid ) : null; if ( $shared_parent ) { $siblings = $this->resolver->shared_stock_variations( $shared_parent ); $pool_sold = 0.0; foreach ( $siblings as $sibling ) { $pool_sold += $sibling === $pid ? $units_sold : $sold( $sibling ); } $share = $pool_sold > 0 ? $units_sold / $pool_sold : 1 / max( 1, count( $siblings ) ); $stock = (float) ( $shared_parent->get_stock_quantity() ?? 0 ) * $share; } $current_stock = $stock ?? 0.0; // Supplier terms (primary supplier), with global fallbacks. $primary = $this->supplier_products->primary_for_product( $pid ); $lead_time = $primary && (int) $primary['lead_time_days'] > 0 ? (int) $primary['lead_time_days'] : (int) $config['default_lead_time']; $moq = $primary ? (int) $primary['moq'] : 0; $safety_stock = $this->velocity->safety_stock( (int) $settings['safety_stock'], $avg_daily, (int) $config['safety_days'] ); $manual_rop = ( null !== $settings['reorder_point'] && '' !== $settings['reorder_point'] ) ? (int) $settings['reorder_point'] : null; $reorder_point = $this->velocity->reorder_point( $manual_rop, $avg_daily, $lead_time, $safety_stock ); $on_open = $open_map[ $pid ] ?? 0.0; $manual_qty = ( null !== $settings['manual_reorder_qty'] && '' !== $settings['manual_reorder_qty'] ) ? (int) $settings['manual_reorder_qty'] : null; $suggested = null !== $manual_qty ? $manual_qty : $this->velocity->suggested_qty( $avg_daily, $lead_time, (int) $config['coverage_days'], $safety_stock, $current_stock, $on_open, $moq ); $days_remaining = $this->velocity->days_remaining( $current_stock, $avg_daily ); $days_stored = min( $days_remaining, self::DAYS_CAP ); $severity = $this->velocity->severity( $avg_daily, $current_stock, $days_remaining, $lead_time, (int) $config['coverage_days'], $reorder_point ); $confidence = $this->resolver->created_days( $pid ) < $window ? 'low' : 'normal'; // Untracked stock is unknown, not zero: never flag it or suggest an order. if ( null === $stock ) { $suggested = 0; $severity = 'ok'; $days_stored = self::DAYS_CAP; } $previous = $this->forecasts->get( $pid ); $old_severity = $previous ? (string) $previous['severity'] : null; $this->forecasts->upsert( $pid, array( 'avg_daily_sales' => Money::store( $avg_daily ), 'avg_weekly_sales' => Money::store( $avg_daily * 7 ), 'current_stock' => Money::store( $current_stock ), 'days_remaining' => Money::store( $days_stored ), 'reorder_point' => (int) round( $reorder_point ), 'lead_time_days' => $lead_time, 'safety_stock' => (int) round( $safety_stock ), 'suggested_qty' => (int) $suggested, 'qty_on_open_pos' => Money::store( $on_open ), 'severity' => $severity, 'confidence' => $confidence, ) ); if ( $old_severity !== $severity ) { /** * Fires when a product's reorder urgency changes (e.g. warning → critical). * * @param int $product_id Purchasable-unit ID. * @param string|null $old_severity Previous severity, null on the first forecast. * @param string $severity New severity. * @param array $forecast Key figures. */ do_action( 'profit_engine_forecast_severity_changed', $pid, $old_severity, $severity, array( 'current_stock' => $current_stock, 'avg_daily_sales' => $avg_daily, 'days_remaining' => $days_stored, 'suggested_qty' => (int) $suggested, ) ); } } /** * Global forecasting config from settings. * * @return array */ private function config(): array { $s = get_option( 'profit_engine_settings', array() ); $s = is_array( $s ) ? $s : array(); return array( 'forecast_window' => (int) ( $s['forecast_window'] ?? 30 ), 'coverage_days' => (int) ( $s['coverage_days'] ?? 30 ), 'safety_days' => (int) ( $s['safety_days'] ?? 7 ), 'default_lead_time' => (int) ( $s['default_lead_time'] ?? 7 ), ); } }