profit = $profit; $this->records = $records; $this->snapshotter = $snapshotter; $this->pos = $pos; $this->forecasts = $forecasts; $this->reorder = $reorder; $this->settings = $settings; $this->resolver = $resolver; $this->suppliers = $suppliers; $this->inventory = $inventory; } /** * Onboarding progress for the setup checklist / empty states. * * @return array */ private function onboarding(): array { $suppliers = $this->suppliers->count_active(); $configured = $this->settings->count_enabled(); $forecasts = array_sum( $this->forecasts->severity_counts() ); $spending = $this->pos->recent( 1 ); return array( 'suppliers' => $suppliers, 'configured' => $configured, 'forecasts' => (int) $forecasts, 'has_pos' => count( $spending ) > 0, 'complete' => $suppliers > 0 && $configured > 0 && $forecasts > 0 && count( $spending ) > 0, ); } /** * Build the dashboard payload. * * @param array $args Range args. * @return array */ public function build( array $args ): array { /** * Dashboard cache lifetime in seconds. Return 0 to disable caching. * * The dashboard runs several aggregate queries, so a short cache keeps * large stores responsive; the default 120s trades minimal staleness for * a big reduction in repeated query load. * * @param int $ttl Seconds. */ $ttl = (int) apply_filters( 'profit_engine_dashboard_cache_ttl', 120 ); $statuses = $this->snapshotter->counted_statuses(); // Fold the counted statuses into the key so changing which order statuses // count as revenue yields a fresh payload immediately (cache miss) rather // than serving up to $ttl seconds of stale numbers. $cache_key = 'profit_engine_dash_' . md5( (string) wp_json_encode( $args ) . '|' . implode( ',', $statuses ) ); if ( $ttl > 0 ) { $cached = get_transient( $cache_key ); if ( is_array( $cached ) ) { return $cached; } } [ $from, $to ] = DateRange::resolve( $args ); $sales = $this->profit->summary( $args ); $spending = $this->pos->spending( $from, $to ); $bucket = DateRange::bucket_for( $from, $to ); // The dashboard chart reads best as ~12 monthly bars for long ranges, // rather than 50+ cramped weekly ones. if ( 'week' === $bucket && ( strtotime( $to ) - strtotime( $from ) ) > 120 * DAY_IN_SECONDS ) { $bucket = 'month'; } if ( 'today' === ( $args['range'] ?? '' ) ) { $bucket = 'hour'; } $series = $this->fill_series( $this->records->series( $from, $to, $statuses, $bucket ), $from, $to, $bucket ); $reorder = $this->reorder_rollup(); $payload = array( 'onboarding' => $this->onboarding(), 'range' => array( 'from' => $from, 'to' => $to, ), 'sales' => array( 'revenue' => $sales['revenue'], 'cost' => $sales['total_cost'], 'profit' => $sales['profit'], 'margin' => $sales['margin'], 'units' => $sales['units'], 'orders' => $sales['orders'], ), 'purchase_spending' => $spending, 'inventory_value' => $this->inventory->totals(), 'reorder' => $reorder['summary'], 'suppliers_to_order' => $reorder['suppliers'], 'recent_pos' => $this->recent_pos(), 'series' => $series, 'bucket' => $bucket, ); if ( $ttl > 0 ) { set_transient( $cache_key, $payload, $ttl ); } return $payload; } /** * Zero-fill a time series so every day/week/month in the range is present. * * The query only returns periods that had sales; a chart needs a continuous * axis. Buckets use the same keys as the SQL (day = date, week = Monday, * month = first of month). For "all time" the axis starts at the first sale * rather than the 1970 lower bound. * * @param array> $rows Rows from the series query. * @param string $from UTC lower bound. * @param string $to UTC upper bound. * @param string $bucket hour|day|week|month. * @return array> */ private function fill_series( array $rows, string $from, string $to, string $bucket ): array { $by_period = array(); foreach ( $rows as $row ) { $by_period[ $row['period'] ] = $row; } $start = strtotime( $from . ' UTC' ); if ( gmdate( 'Y', $start ) < 2000 ) { if ( empty( $rows ) ) { return array(); } $start = strtotime( $rows[0]['period'] . ' UTC' ); } $end = strtotime( $to . ' UTC' ); if ( 'hour' === $bucket ) { $end = $start + DAY_IN_SECONDS - 1; // Whole day, so the axis doesn't grow hour by hour. } // Snap the start to its bucket boundary. if ( 'hour' === $bucket ) { $cursor = strtotime( gmdate( 'Y-m-d H:00:00', $start ) . ' UTC' ); } elseif ( 'month' === $bucket ) { $cursor = strtotime( gmdate( 'Y-m-01', $start ) . ' UTC' ); } elseif ( 'week' === $bucket ) { $day = strtotime( gmdate( 'Y-m-d', $start ) . ' UTC' ); $cursor = $day - ( (int) gmdate( 'N', $day ) - 1 ) * DAY_IN_SECONDS; } else { $cursor = strtotime( gmdate( 'Y-m-d', $start ) . ' UTC' ); } $out = array(); $guard = 0; while ( $cursor <= $end && $guard++ < 500 ) { if ( 'hour' === $bucket ) { $key = gmdate( 'Y-m-d H:00:00', $cursor ); } else { $key = 'month' === $bucket ? gmdate( 'Y-m-01', $cursor ) : gmdate( 'Y-m-d', $cursor ); } $row = $by_period[ $key ] ?? array( 'period' => $key, 'revenue' => 0.0, 'cost' => 0.0, 'profit' => 0.0, 'orders' => 0, ); if ( 'hour' === $bucket ) { // Hours are stored in UTC; label them in the store's timezone. $row['period'] = get_date_from_gmt( $key, 'Y-m-d H:i' ); } $out[] = $row; if ( 'hour' === $bucket ) { $cursor += HOUR_IN_SECONDS; } elseif ( 'month' === $bucket ) { $cursor = strtotime( '+1 month', $cursor ); } elseif ( 'week' === $bucket ) { $cursor += 7 * DAY_IN_SECONDS; } else { $cursor += DAY_IN_SECONDS; } } return $out; } /** * Roll up reorder items into headline figures + supplier grouping. * * @return array{summary:array,suppliers:array>} */ private function reorder_rollup(): array { $data = $this->reorder->list( array() ); $items = $data['items']; $counts = $data['counts']; $recommended_spend = 0.0; $expected_revenue = 0.0; $suppliers = array(); foreach ( $items as $item ) { $qty = (float) $item['suggested_qty']; if ( $qty <= 0 ) { continue; } $spend = (float) $item['estimated_cost']; $price = $this->resolver->price( (int) $item['product_id'] ); $recommended_spend += $spend; $expected_revenue += $qty * $price; $sid = (int) $item['supplier_id']; if ( $sid > 0 ) { if ( ! isset( $suppliers[ $sid ] ) ) { $suppliers[ $sid ] = array( 'supplier_id' => $sid, 'supplier_name' => $item['supplier_name'], 'item_count' => 0, 'spend' => 0.0, ); } ++$suppliers[ $sid ]['item_count']; $suppliers[ $sid ]['spend'] += $spend; } } usort( $suppliers, static fn( $a, $b ): int => $b['spend'] <=> $a['spend'] ); return array( 'summary' => array( 'counts' => array( 'critical' => (int) ( $counts['critical'] ?? 0 ), 'warning' => (int) ( $counts['warning'] ?? 0 ), 'recommended' => (int) ( $counts['recommended'] ?? 0 ), ), 'items_total' => count( $items ), 'recommended_spend' => $recommended_spend, 'expected_revenue' => $expected_revenue, 'expected_profit' => $expected_revenue - $recommended_spend, ), 'suppliers' => array_values( $suppliers ), ); } /** * Recent purchase orders for the dashboard feed. * * @return array> */ private function recent_pos(): array { return array_map( static fn( array $po ): array => array( 'id' => (int) $po['id'], 'po_number' => (string) $po['po_number'], 'supplier_name' => (string) ( $po['supplier_name'] ?? '' ), 'status' => (string) $po['status'], 'grand_total' => Money::to_float( $po['grand_total'] ), 'currency' => (string) $po['currency'], 'created_at' => (string) $po['created_at'], ), $this->pos->recent( 5 ) ); } }