Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
72.00% covered (warning)
72.00%
72 / 100
75.00% covered (warning)
75.00%
6 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
ReportService
72.00% covered (warning)
72.00%
72 / 100
75.00% covered (warning)
75.00%
6 / 8
23.34
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSpotlightData
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
3.00
 getTopUsers
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 getSummaryData
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
20
 getCoachLevels
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
2
 getUsageOverview
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 addDateRange
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 buildChartWithGaps
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3namespace App\Http\Services\Reports;
4
5use App\Helpers\CoachLevelHelper;
6use App\Http\Repositories\Reports\DailyUsageRepository;
7use App\Http\Repositories\Reports\UserInfoReportRepository;
8use Carbon\Carbon;
9use DateInterval;
10use DatePeriod;
11use MongoDB\BSON\UTCDateTime;
12
13/**
14 * Unified reporting service for both CMC and Admin reporting.
15 *
16 * Orchestrates DailyUsageRepository and UserInfoReportRepository to produce
17 * spotlight charts, top-user lists, coach-level categorizations, and usage overviews.
18 *
19 * Replaces: MgmtCenterReportingService, AccountCenterReportingService,
20 *           SharedReportingService, AccountCenterReporting (Action).
21 */
22class ReportService
23{
24    public function __construct(
25        private DailyUsageRepository $dailyUsageRepo,
26        private UserInfoReportRepository $userInfoRepo
27    ) {}
28
29    /**
30     * Get spotlight data: monthly chart + total + average.
31     *
32     * Used by: V2 spotlight/{type}, V1 reporting/*-spotlight endpoints.
33     *
34     * @param  array  $dailyMatch  MongoDB $match for FlyMsgUserDailyUsage (from toDailyUsageMatch)
35     * @param  string  $property  The daily usage field (e.g., 'cost_savings', 'time_saved')
36     * @param  Carbon  $startDate  Start of date range
37     * @param  Carbon  $endDate  End of date range
38     * @param  int  $round  Decimal places for rounding (default: 0)
39     * @return array{chart: array, total: string, average: string}
40     */
41    public function getSpotlightData(array $dailyMatch, string $property, Carbon $startDate, Carbon $endDate, int $round = 0, bool $useAvg = false): array
42    {
43        $match = $this->addDateRange($dailyMatch, $startDate, $endDate);
44
45        $monthlyData = $this->dailyUsageRepo->aggregateByMonth($match, $property, $round, $useAvg);
46        $summary = $this->dailyUsageRepo->getSummaryStats($match, $property, $useAvg);
47
48        $chart = $this->buildChartWithGaps($monthlyData, $startDate, $endDate);
49
50        $total = $summary['total'];
51        $uniqueUsers = $summary['unique_users'];
52        // Averaged charts already reduce to a mean â€” the "average" field
53        // in the response is the same number. Summed charts divide the
54        // total by the contributing user count.
55        $average = $useAvg
56            ? $total
57            : ($uniqueUsers > 0 ? $total / $uniqueUsers : 0);
58
59        return [
60            'chart' => $chart,
61            'total' => number_format(round($total, $round), $round, '.', ','),
62            'average' => number_format(round($average, max($round, 2)), max($round, 2), '.', ','),
63        ];
64    }
65
66    /**
67     * Get top N users with name/avatar enrichment (batch lookup, no N+1).
68     *
69     * Used by: V2 top-users/{type}, V1 reporting/*-top-users endpoints.
70     *
71     * @param  array  $dailyMatch  MongoDB $match for FlyMsgUserDailyUsage
72     * @param  string  $property  The daily usage field to rank by
73     * @param  Carbon  $startDate  Start of date range
74     * @param  Carbon  $endDate  End of date range
75     * @param  int  $round  Decimal places for formatting
76     * @param  int  $limit  Maximum users to return (default: 5)
77     * @return array Array of {name, count, image} for each top user
78     */
79    public function getTopUsers(array $dailyMatch, string $property, Carbon $startDate, Carbon $endDate, int $round = 0, int $limit = 5, bool $useAvg = false): array
80    {
81        $match = $this->addDateRange($dailyMatch, $startDate, $endDate);
82
83        $topUsers = $this->dailyUsageRepo->getTopUsersByMetric($match, $property, $limit, $useAvg);
84
85        if ($topUsers->isEmpty()) {
86            return [];
87        }
88
89        $userIds = $topUsers->pluck('user_id')->toArray();
90        $identities = $this->userInfoRepo->getUserIdentities($userIds);
91
92        return $topUsers->map(function ($top) use ($identities, $round) {
93            $identity = $identities->get($top['user_id']);
94
95            return [
96                'name' => $identity['full_name'] ?? 'Deleted User',
97                'count' => number_format(round($top['value'], $round), $round, '.', ','),
98                'image' => $identity['avatar'] ?? '',
99            ];
100        })->values()->toArray();
101    }
102
103    /**
104     * Get combined summary data: total + average + top users in one response.
105     *
106     * Used by: V2 data/{type} endpoint (returns everything in one call).
107     *
108     * @param  array  $baseMatch  MongoDB $match for UserInfo
109     * @param  array  $dailyMatch  MongoDB $match for FlyMsgUserDailyUsage
110     * @param  string  $property  The daily usage field
111     * @param  Carbon  $startDate  Start of date range
112     * @param  Carbon  $endDate  End of date range
113     * @param  int  $round  Decimal places for rounding
114     * @param  int  $topLimit  Maximum top users to return
115     * @return array{total: string, average: string, top: array}
116     */
117    public function getSummaryData(array $baseMatch, array $dailyMatch, string $property, Carbon $startDate, Carbon $endDate, int $round = 0, int $topLimit = 5): array
118    {
119        $match = $this->addDateRange($dailyMatch, $startDate, $endDate);
120
121        $summary = $this->dailyUsageRepo->getSummaryStats($match, $property);
122
123        $topUsers = $this->dailyUsageRepo->getTopUsersByMetric($match, $property, $topLimit);
124        $userIds = $topUsers->pluck('user_id')->toArray();
125
126        $usersInScope = $this->userInfoRepo->getUsersByMatch($baseMatch, [
127            '_id' => 0, 'user_id' => 1, 'full_name' => 1, 'avatar' => 1,
128        ]);
129
130        $dailyValues = [];
131        foreach ($topUsers as $row) {
132            $dailyValues[$row['user_id']] = $row['value'];
133        }
134
135        $identities = collect($usersInScope)->keyBy('user_id');
136
137        $top = $topUsers->map(function ($row) use ($identities, $round) {
138            $identity = $identities->get($row['user_id']);
139
140            return [
141                'name' => $identity['full_name'] ?? 'Deleted User',
142                'avatar' => $identity['avatar'] ?? null,
143                'value' => round($row['value'], $round),
144            ];
145        })->values()->toArray();
146
147        $total = $summary['total'];
148        $uniqueUsers = $summary['unique_users'];
149        $average = $uniqueUsers > 0 ? ($total / $uniqueUsers) : 0;
150
151        return [
152            'total' => number_format(round($total, $round), $round),
153            'average' => round($average, 2) ? number_format($average, 2) : $average,
154            'top' => $top,
155        ];
156    }
157
158    /**
159     * Get coach level categorization for users.
160     *
161     * Aggregates characters_typed directly on FlyMsgUserDailyUsage (no $lookup).
162     * Replaces the old approach that required ini_set('memory_limit', '3072M').
163     *
164     * @param  array  $baseMatch  MongoDB $match for UserInfo (for total user count)
165     * @param  array  $dailyMatch  MongoDB $match for FlyMsgUserDailyUsage
166     * @param  Carbon  $startDate  Start of date range
167     * @param  Carbon  $endDate  End of date range
168     * @return array{chart: array, expert_users: float, beginner_users: float}
169     */
170    public function getCoachLevels(array $baseMatch, array $dailyMatch, Carbon $startDate, Carbon $endDate): array
171    {
172        $match = $this->addDateRange($dailyMatch, $startDate, $endDate);
173
174        $characterUsages = $this->dailyUsageRepo->getCoachLevelAggregates($match);
175        $usersCount = $this->userInfoRepo->countUsers($baseMatch);
176
177        if ($usersCount === 0) {
178            return [
179                'chart' => [0, 0, 0, 0, 0],
180                'expert_users' => 0,
181                'beginner_users' => 0,
182            ];
183        }
184
185        $coachLevels = CoachLevelHelper::categorizeCharacterUsage($characterUsages, $usersCount);
186
187        return [
188            'chart' => [
189                $coachLevels->beginner,
190                $coachLevels->intermediate,
191                $coachLevels->proficient,
192                $coachLevels->advanced,
193                $coachLevels->expert,
194            ],
195            'expert_users' => round(($coachLevels->expert / $usersCount) * 100, 2),
196            'beginner_users' => round(($coachLevels->beginner / $usersCount) * 100, 2),
197        ];
198    }
199
200    /**
201     * Get usage overview: aggregated cost, time, and character totals.
202     *
203     * @param  array  $dailyMatch  MongoDB $match for FlyMsgUserDailyUsage
204     * @param  Carbon  $startDate  Start of date range
205     * @param  Carbon  $endDate  End of date range
206     * @return array{cost: float, time: float, chars: int, total_users: int}
207     */
208    public function getUsageOverview(array $dailyMatch, Carbon $startDate, Carbon $endDate): array
209    {
210        $match = $this->addDateRange($dailyMatch, $startDate, $endDate);
211
212        return $this->dailyUsageRepo->getUsageOverview($match);
213    }
214
215    /**
216     * Add created_at date range to a match array.
217     *
218     * @param  array  $match  Base $match conditions
219     * @param  Carbon  $startDate  Start of date range
220     * @param  Carbon  $endDate  End of date range
221     * @return array Match with created_at range appended
222     */
223    private function addDateRange(array $match, Carbon $startDate, Carbon $endDate): array
224    {
225        $match['created_at'] = [
226            '$gte' => new UTCDateTime($startDate),
227            '$lte' => new UTCDateTime($endDate),
228        ];
229
230        return $match;
231    }
232
233    /**
234     * Build a chart array from monthly aggregation data, filling in gaps with zeros.
235     *
236     * @param  iterable  $monthlyData  Collection of {month_year, total} from aggregateByMonth
237     * @param  Carbon  $startDate  Start of date range
238     * @param  Carbon  $endDate  End of date range
239     * @return array Array of {period: 'Mon YYYY', value: number} for each month
240     */
241    private function buildChartWithGaps(iterable $monthlyData, Carbon $startDate, Carbon $endDate): array
242    {
243        $dbData = [];
244        foreach ($monthlyData as $item) {
245            $dbData[$item['month_year']] = $item['total'];
246        }
247
248        $chart = [];
249        $period = new DatePeriod(
250            $startDate->copy()->startOfMonth(),
251            new DateInterval('P1M'),
252            $endDate->copy()->endOfMonth()
253        );
254
255        foreach ($period as $date) {
256            $monthKey = $date->format('Y-m');
257            $chart[] = [
258                'period' => $date->format('M Y'),
259                'value' => $dbData[$monthKey] ?? 0,
260            ];
261        }
262
263        return $chart;
264    }
265}