PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.8
Yatra – Travel Booking & Tour Operator Software v3.0.8
3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 2.0.10 All 83 releases
yatra / app / Controllers / ReportsController.php

ReportsController.php in Yatra – Travel Booking & Tour Operator Software 3.0.8, at app/Controllers/ReportsController.php

973 lines 42.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types=1);
4
5 namespace Yatra\Controllers;
6
7 use WP_REST_Request;
8 use WP_REST_Response;
9
10 class ReportsController extends BaseController
11 {
12 public function register_routes(): void
13 {
14 $namespace = 'yatra/v1';
15
16 register_rest_route($namespace, '/reports', [
17 [
18 'methods' => \WP_REST_Server::READABLE,
19 'callback' => [$this, 'get_reports'],
20 'permission_callback' => [$this, 'check_permission'],
21 ],
22 ]);
23 }
24
25 /**
26 * Reports gate — either of the registered view-reports caps grants
27 * access. Accountant role holds `yatra_view_financial_reports`;
28 * Manager / Marketing / Sales Agent / Auditor hold
29 * `yatra_view_operational_reports`. The endpoint returns the same
30 * combined payload either way today; future per-section gating
31 * (financial-only vs ops-only sections) can layer on this baseline.
32 * WP admins pass via the Team module's admin-fallback filter.
33 */
34 public function check_permission(?WP_REST_Request $request = null): bool
35 {
36 return current_user_can('yatra_view_financial_reports')
37 || current_user_can('yatra_view_operational_reports');
38 }
39
40 /**
41 * GET /reports
42 * Central reporting endpoint used by the admin Reports page.
43 */
44 public function get_reports(WP_REST_Request $request): WP_REST_Response
45 {
46 $dateFrom = $request->get_param('date_from');
47 $dateTo = $request->get_param('date_to');
48
49 // Basic defaults: last 30 days if not provided
50 if (!$dateFrom || !$dateTo) {
51 $today = new \DateTimeImmutable('today');
52 $start = $today->sub(new \DateInterval('P30D'));
53 $dateFrom = $start->format('Y-m-d');
54 $dateTo = $today->format('Y-m-d');
55 }
56
57 // Build the previous-period window of equal length, ending the day
58 // before $dateFrom. We aggregate this in parallel with the current
59 // window so KPIs can return both an absolute number AND a percent
60 // change vs. the prior period — the single thing operators ask for
61 // in dashboards that previously rendered "+0%" for every metric.
62 $fromDt = \DateTimeImmutable::createFromFormat('Y-m-d', $dateFrom);
63 $toDt = \DateTimeImmutable::createFromFormat('Y-m-d', $dateTo);
64 $prevFrom = $dateFrom;
65 $prevTo = $dateFrom;
66 if ($fromDt instanceof \DateTimeImmutable && $toDt instanceof \DateTimeImmutable) {
67 // +1 because the range is inclusive on both ends.
68 $rangeDays = (int) $fromDt->diff($toDt)->days + 1;
69 $prevToDt = $fromDt->sub(new \DateInterval('P1D'));
70 $prevFromDt = $prevToDt->sub(new \DateInterval('P' . max(0, $rangeDays - 1) . 'D'));
71 $prevFrom = $prevFromDt->format('Y-m-d');
72 $prevTo = $prevToDt->format('Y-m-d');
73 }
74
75 $params = [
76 'date_from' => $dateFrom,
77 'date_to' => $dateTo,
78 ];
79
80 $bookingsList = $this->request('GET', '/yatra/v1/bookings', $params);
81 $paymentsList = $this->request('GET', '/yatra/v1/payments', $params);
82 $departuresList = $this->request('GET', '/yatra/v1/departures', [
83 'date_from' => $dateFrom,
84 'date_to' => $dateTo,
85 'include_past' => 'false',
86 ]);
87
88 $bookings = isset($bookingsList['data']) && is_array($bookingsList['data'])
89 ? $bookingsList['data']
90 : (is_array($bookingsList) ? $bookingsList : []);
91 $payments = is_array($paymentsList) ? $paymentsList : [];
92 $departures = isset($departuresList['data']) && is_array($departuresList['data'])
93 ? $departuresList['data']
94 : (is_array($departuresList) ? $departuresList : []);
95
96 // ------------------------------------------------------------------
97 // Normalize and strictly filter bookings to the requested date range
98 // using created_at (or travel_date) so that "Today" and other
99 // filters only reflect bookings actually in that window.
100 // ------------------------------------------------------------------
101 $fromTs = strtotime($dateFrom . ' 00:00:00');
102 $toTs = strtotime($dateTo . ' 23:59:59');
103
104 if ($fromTs === false || $toTs === false) {
105 $fromTs = null;
106 $toTs = null;
107 }
108
109 $filteredBookings = [];
110 foreach ($bookings as $b) {
111 $createdAt = $b['created_at'] ?? ($b['travel_date'] ?? null);
112 if (!$createdAt) {
113 continue;
114 }
115 $ts = strtotime((string) $createdAt);
116 if ($ts === false) {
117 continue;
118 }
119 if ($fromTs !== null && ($ts < $fromTs || $ts > $toTs)) {
120 continue;
121 }
122 $filteredBookings[] = $b;
123 }
124
125 $bookings = $filteredBookings;
126
127 // ------------------------------------------------------------------
128 // Revenue stats (derived from filtered bookings only)
129 // ------------------------------------------------------------------
130 $totalRevenue = 0.0;
131 $totalBookings = count($bookings);
132
133 foreach ($bookings as $b) {
134 if (isset($b['total_amount'])) {
135 $totalRevenue += (float) $b['total_amount'];
136 }
137 }
138
139 $averageBooking = $totalBookings > 0 ? $totalRevenue / $totalBookings : 0.0;
140
141 // --- Previous-period aggregates (for period-over-period deltas) ---
142 // Same shape as current-period: pull bookings within the prior
143 // window, sum revenue + count. Cheap because /bookings already
144 // applies a paginated cap; we accept that as the trade-off vs.
145 // adding a dedicated repository method.
146 $prevBookingsList = $this->request('GET', '/yatra/v1/bookings', [
147 'date_from' => $prevFrom,
148 'date_to' => $prevTo,
149 ]);
150 $prevBookings = isset($prevBookingsList['data']) && is_array($prevBookingsList['data'])
151 ? $prevBookingsList['data']
152 : (is_array($prevBookingsList) ? $prevBookingsList : []);
153
154 $prevFromTs = strtotime($prevFrom . ' 00:00:00');
155 $prevToTs = strtotime($prevTo . ' 23:59:59');
156 $prevTotalRevenue = 0.0;
157 $prevTotalBookings = 0;
158 $prevCancelled = 0;
159 $prevConfirmed = 0;
160 foreach ($prevBookings as $b) {
161 $createdAt = $b['created_at'] ?? ($b['travel_date'] ?? null);
162 $ts = $createdAt ? strtotime((string) $createdAt) : false;
163 if ($ts === false || $prevFromTs === false || $prevToTs === false) continue;
164 if ($ts < $prevFromTs || $ts > $prevToTs) continue;
165 $prevTotalBookings++;
166 $prevTotalRevenue += isset($b['total_amount']) ? (float) $b['total_amount'] : 0.0;
167 $prevStatus = strtolower((string) ($b['status'] ?? ''));
168 if ($prevStatus === 'cancelled') $prevCancelled++;
169 if ($prevStatus === 'confirmed' || $prevStatus === 'completed') $prevConfirmed++;
170 }
171 $prevAverageBooking = $prevTotalBookings > 0 ? $prevTotalRevenue / $prevTotalBookings : 0.0;
172
173 $pctChange = static function (float $current, float $previous): float {
174 if ($previous == 0.0) {
175 // Going from 0 to anything positive isn't infinity — clamp
176 // to +100% (or 0 if both are zero) so the UI doesn't render
177 // "Infinity%" cards.
178 return $current > 0 ? 100.0 : 0.0;
179 }
180 return (($current - $previous) / $previous) * 100.0;
181 };
182
183 $revenueStats = [
184 'total' => $totalRevenue,
185 'bookings' => $totalBookings,
186 'average' => $averageBooking,
187 'previous' => $prevTotalRevenue,
188 'change' => $pctChange($totalRevenue, $prevTotalRevenue),
189 // Avg-booking-value delta is its own thing; surface it so the
190 // KPI card can show "+8% AOV" alongside the revenue delta.
191 'averagePrevious' => $prevAverageBooking,
192 'averageChange' => $pctChange($averageBooking, $prevAverageBooking),
193 ];
194
195 // ------------------------------------------------------------------
196 // Booking stats & trends
197 // ------------------------------------------------------------------
198 $statusCounts = [
199 'confirmed' => 0,
200 'pending' => 0,
201 'cancelled' => 0,
202 'completed' => 0,
203 ];
204
205 // Aggregate by DAY so the trend charts can show one point per day in
206 // the selected range. We also track per-day status counts so the
207 // Reports detail-breakdown table can render "confirmed / pending /
208 // cancelled" columns from REAL data instead of the synthetic 80/15/5
209 // split it previously fabricated client-side.
210 $byDayCount = [];
211 $byDayRevenue = [];
212 $byDayStatus = []; // [yyyy-mm-dd][status] => int
213
214 foreach ($bookings as $b) {
215 $status = strtolower((string) ($b['status'] ?? 'pending'));
216 if (isset($statusCounts[$status])) {
217 $statusCounts[$status]++;
218 }
219
220 $createdAt = $b['created_at'] ?? ($b['travel_date'] ?? null);
221 if (!$createdAt) {
222 continue;
223 }
224 $ts = strtotime((string) $createdAt);
225 if ($ts === false) {
226 continue;
227 }
228 $dayKey = gmdate('Y-m-d', $ts);
229 $amount = isset($b['total_amount']) ? (float) $b['total_amount'] : 0.0;
230
231 if (!isset($byDayCount[$dayKey])) {
232 $byDayCount[$dayKey] = 0;
233 $byDayRevenue[$dayKey] = 0.0;
234 $byDayStatus[$dayKey] = [
235 'confirmed' => 0, 'pending' => 0,
236 'cancelled' => 0, 'completed' => 0,
237 ];
238 }
239 $byDayCount[$dayKey]++;
240 $byDayRevenue[$dayKey] += $amount;
241 if (isset($byDayStatus[$dayKey][$status])) {
242 $byDayStatus[$dayKey][$status]++;
243 }
244 }
245
246 $totalCount = array_sum($statusCounts);
247 $cancelled = $statusCounts['cancelled'];
248 $cancellationRate = $totalCount > 0 ? ($cancelled / $totalCount) * 100.0 : 0.0;
249
250 // Build a continuous list of DAYS across the selected range so that
251 // the charts always reflect the full date window (including days
252 // with zero bookings), rather than only the days that have data.
253 //
254 // Each point ships both a human label ("1 Nov") AND the ISO date
255 // ("2025-11-01"). The label is fine for default chart axes; the
256 // date lets the detail-breakdown UI re-bucket day data into weeks
257 // / months without parsing localised strings.
258 $bookingTrend = [];
259 $revenueTrend = [];
260 $statusTrend = []; // [{date, label, confirmed, pending, cancelled, completed}]
261
262 if ($fromTs !== null && $toTs !== null && $fromTs <= $toTs) {
263 $day = $fromTs;
264
265 while ($day <= $toTs) {
266 $key = gmdate('Y-m-d', $day);
267 $count = $byDayCount[$key] ?? 0;
268 $revenue = $byDayRevenue[$key] ?? 0.0;
269 $statusRow = $byDayStatus[$key] ?? [
270 'confirmed' => 0, 'pending' => 0,
271 'cancelled' => 0, 'completed' => 0,
272 ];
273
274 $dt = \DateTimeImmutable::createFromFormat('Y-m-d', $key);
275 if ($dt) {
276 $label = $dt->format('j M');
277 $bookingTrend[] = [
278 'date' => $key,
279 'label' => $label,
280 'value' => $count,
281 ];
282 $revenueTrend[] = [
283 'date' => $key,
284 'label' => $label,
285 'value' => $revenue,
286 ];
287 $statusTrend[] = [
288 'date' => $key,
289 'label' => $label,
290 'confirmed' => $statusRow['confirmed'],
291 'pending' => $statusRow['pending'],
292 'cancelled' => $statusRow['cancelled'],
293 'completed' => $statusRow['completed'],
294 ];
295 }
296
297 // increment by one day
298 $day = strtotime('+1 day', $day);
299 }
300 }
301
302 // Conversion rate = bookings that landed in a "money-good" terminal
303 // state (confirmed OR completed) / total bookings in the window.
304 // This is the simplest defensible definition without an enquiries-
305 // to-bookings funnel — operators tracking that should add the
306 // enquiry-count denominator in a follow-up.
307 $convertedCount = $statusCounts['confirmed'] + $statusCounts['completed'];
308 $conversionRate = $totalCount > 0 ? ($convertedCount / $totalCount) * 100.0 : 0.0;
309
310 $prevConversionRate = $prevTotalBookings > 0
311 ? ($prevConfirmed / $prevTotalBookings) * 100.0
312 : 0.0;
313 $prevCancellationRate = $prevTotalBookings > 0
314 ? ($prevCancelled / $prevTotalBookings) * 100.0
315 : 0.0;
316
317 $bookingStats = [
318 'total' => $totalCount,
319 'confirmed' => $statusCounts['confirmed'],
320 'pending' => $statusCounts['pending'],
321 'cancelled' => $statusCounts['cancelled'],
322 'completed' => $statusCounts['completed'],
323 'cancellationRate' => $cancellationRate,
324 'conversionRate' => $conversionRate,
325 'averageBookingValue' => $averageBooking,
326 'trend' => $bookingTrend,
327 // Period-over-period deltas (computed once, reused everywhere
328 // the UI wants a small "↑ +12.4%" indicator next to the KPI).
329 'previousTotal' => $prevTotalBookings,
330 'totalChange' => $pctChange((float) $totalCount, (float) $prevTotalBookings),
331 'previousConversionRate' => $prevConversionRate,
332 'conversionRateChange' => $pctChange($conversionRate, $prevConversionRate),
333 'previousCancellationRate' => $prevCancellationRate,
334 'cancellationRateChange' => $pctChange($cancellationRate, $prevCancellationRate),
335 ];
336
337 // ------------------------------------------------------------------
338 // Trip performance (group by trip title).
339 //
340 // Bug fix: arsort() on an associative array whose values are
341 // themselves arrays sorts by array-comparison rules (length, then
342 // first differing element by key order). The result was
343 // effectively non-deterministic — "Top Trips" never reflected the
344 // actual top by count or revenue. We use uasort with an explicit
345 // revenue-desc comparator. Ties break on count desc.
346 // ------------------------------------------------------------------
347 $trips = [];
348 foreach ($bookings as $b) {
349 $title = $b['trip_title'] ?? ($b['trip']['title'] ?? __('(Untitled Trip)', 'yatra'));
350 $amount = isset($b['total_amount']) ? (float) $b['total_amount'] : 0.0;
351 if (!isset($trips[$title])) {
352 $trips[$title] = ['count' => 0, 'revenue' => 0.0];
353 }
354 $trips[$title]['count']++;
355 $trips[$title]['revenue'] += $amount;
356 }
357
358 uasort($trips, static function (array $a, array $b): int {
359 if ($b['revenue'] === $a['revenue']) {
360 return $b['count'] <=> $a['count'];
361 }
362 return $b['revenue'] <=> $a['revenue'];
363 });
364
365 // Build a trip-title → occupancy map from departures so the
366 // top-trips strip can show real seat utilization, not a 0
367 // placeholder. We compute booked / capacity per trip across all
368 // departures in the window. Trips with zero capacity emit 0.
369 $occupancyByTripTitle = [];
370 foreach ($departures as $d) {
371 $tripTitle = $d['trip']['title'] ?? ($d['trip_title'] ?? '');
372 if ($tripTitle === '') {
373 continue;
374 }
375 $cap = (int) ($d['max_capacity'] ?? $d['total_spots'] ?? 0);
376 $bkd = (int) ($d['booked_count'] ?? $d['travelers_count'] ?? 0);
377 if (!isset($occupancyByTripTitle[$tripTitle])) {
378 $occupancyByTripTitle[$tripTitle] = ['booked' => 0, 'capacity' => 0];
379 }
380 $occupancyByTripTitle[$tripTitle]['booked'] += $bkd;
381 $occupancyByTripTitle[$tripTitle]['capacity'] += $cap;
382 }
383
384 $palette = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4'];
385 $tripPerformance = [];
386 $i = 0;
387 foreach ($trips as $label => $stats) {
388 if ($i >= 6) break;
389 $occRow = $occupancyByTripTitle[$label] ?? null;
390 $occ = ($occRow && $occRow['capacity'] > 0)
391 ? round(($occRow['booked'] / $occRow['capacity']) * 100.0, 1)
392 : 0.0;
393 $tripPerformance[] = [
394 'label' => $label,
395 'value' => $stats['count'],
396 'revenue' => $stats['revenue'],
397 'occupancy' => $occ,
398 'color' => $palette[$i % count($palette)],
399 ];
400 $i++;
401 }
402
403 // ------------------------------------------------------------------
404 // Payment status breakdown
405 // ------------------------------------------------------------------
406 $byStatus = [];
407 foreach ($payments as $p) {
408 $status = strtolower((string) ($p['status'] ?? 'pending'));
409 $amount = isset($p['amount']) ? (float) $p['amount'] : (isset($p['total_amount']) ? (float) $p['total_amount'] : 0.0);
410 if (!isset($byStatus[$status])) {
411 $byStatus[$status] = ['count' => 0, 'amount' => 0.0];
412 }
413 $byStatus[$status]['count']++;
414 $byStatus[$status]['amount'] += $amount;
415 }
416
417 $statusOrder = [
418 'paid' => ['label' => __('Paid', 'yatra'), 'color' => '#10b981'],
419 'pending' => ['label' => __('Pending', 'yatra'), 'color' => '#f59e0b'],
420 'refunded'=> ['label' => __('Refunded', 'yatra'), 'color' => '#ef4444'],
421 'partial' => ['label' => __('Partial', 'yatra'), 'color' => '#8b5cf6'],
422 ];
423
424 $paymentStatus = [];
425 foreach ($statusOrder as $key => $meta) {
426 if (!isset($byStatus[$key])) continue;
427 $paymentStatus[] = [
428 'label' => $meta['label'],
429 'value' => $byStatus[$key]['count'],
430 'amount' => $byStatus[$key]['amount'],
431 'color' => $meta['color'],
432 ];
433 }
434
435 // ------------------------------------------------------------------
436 // Operational stats from departures
437 // ------------------------------------------------------------------
438 $upcomingDepartures = 0;
439 $totalCapacity = 0;
440 $bookedCapacity = 0;
441 $upcomingTrips = [];
442
443 $todayTs = strtotime('today');
444
445 foreach ($departures as $d) {
446 $dateStr = $d['start_date'] ?? ($d['date'] ?? null);
447 $depTs = $dateStr ? strtotime((string) $dateStr) : false;
448 if ($depTs !== false && $depTs >= $todayTs) {
449 $upcomingDepartures++;
450 $upcomingTrips[] = [
451 'trip' => $d['trip']['title'] ?? ($d['trip_title'] ?? __('Unknown Trip', 'yatra')),
452 'date' => $dateStr,
453 'booked' => (int) ($d['booked_count'] ?? $d['travelers_count'] ?? 0),
454 'capacity' => (int) ($d['max_capacity'] ?? $d['total_spots'] ?? 0),
455 ];
456 }
457
458 $capacity = (int) ($d['max_capacity'] ?? $d['total_spots'] ?? 0);
459 $booked = (int) ($d['booked_count'] ?? $d['travelers_count'] ?? 0);
460 $totalCapacity += $capacity;
461 $bookedCapacity += $booked;
462 }
463
464 $occupancyRate = $totalCapacity > 0 ? round(($bookedCapacity / $totalCapacity) * 100.0, 1) : 0.0;
465 $averageGroupSize = $upcomingDepartures > 0 ? round($bookedCapacity / $upcomingDepartures, 1) : 0.0;
466
467 $operationalStats = [
468 'upcomingDepartures' => $upcomingDepartures,
469 'totalCapacity' => $totalCapacity,
470 'bookedCapacity' => $bookedCapacity,
471 'occupancyRate' => $occupancyRate,
472 'averageGroupSize' => $averageGroupSize,
473 'upcomingTrips' => $upcomingTrips,
474 ];
475
476 // ------------------------------------------------------------------
477 // Customer analytics (group by email)
478 // ------------------------------------------------------------------
479 $customers = [];
480 foreach ($bookings as $b) {
481 $email = strtolower(trim((string) ($b['contact_email'] ?? $b['customer_email'] ?? '')));
482 if ($email === '') {
483 $email = __('Unknown', 'yatra');
484 }
485 $name = trim((string) (($b['contact_first_name'] ?? $b['customer_first_name'] ?? '') . ' ' . ($b['contact_last_name'] ?? $b['customer_last_name'] ?? '')));
486 if ($name === '') {
487 $name = $email;
488 }
489 $amount = isset($b['total_amount']) ? (float) $b['total_amount'] : 0.0;
490
491 if (!isset($customers[$email])) {
492 $customers[$email] = [
493 'name' => $name,
494 'email' => $email,
495 'bookings' => 0,
496 'revenue' => 0.0,
497 ];
498 }
499 $customers[$email]['bookings']++;
500 $customers[$email]['revenue'] += $amount;
501 }
502
503 $customerList = array_values($customers);
504 $totalCustomers = count($customerList);
505 $newCustomers = 0;
506 $returningCustomers = 0;
507 $firstTime = 0;
508 $returning23 = 0;
509 $loyal4 = 0;
510 $totalCustomerRevenue = 0.0;
511
512 foreach ($customerList as $c) {
513 $totalCustomerRevenue += $c['revenue'];
514 if ($c['bookings'] === 1) {
515 $newCustomers++;
516 $firstTime++;
517 } elseif ($c['bookings'] <= 3) {
518 $returningCustomers++;
519 $returning23++;
520 } else {
521 $returningCustomers++;
522 $loyal4++;
523 }
524 }
525
526 $repeatBookingRate = $totalCustomers > 0 ? ($returningCustomers / $totalCustomers) * 100.0 : 0.0;
527 $customerLifetimeValue = $totalCustomers > 0 ? $totalCustomerRevenue / $totalCustomers : 0.0;
528
529 usort($customerList, function ($a, $b) {
530 return $b['revenue'] <=> $a['revenue'];
531 });
532 $topCustomers = array_slice($customerList, 0, 5);
533
534 $customerSegments = [
535 ['label' => __('First-time', 'yatra'), 'value' => $firstTime, 'color' => '#3b82f6'],
536 ['label' => __('Returning (2-3)', 'yatra'), 'value' => $returning23, 'color' => '#10b981'],
537 ['label' => __('Loyal (4+)', 'yatra'), 'value' => $loyal4, 'color' => '#f59e0b'],
538 ];
539
540 $customerAnalytics = [
541 'newCustomers' => $newCustomers,
542 'returningCustomers' => $returningCustomers,
543 'totalCustomers' => $totalCustomers,
544 'customerLifetimeValue' => $customerLifetimeValue,
545 'repeatBookingRate' => $repeatBookingRate,
546 'customerRetentionRate' => $repeatBookingRate,
547 'topCustomers' => $topCustomers,
548 'customerSegments' => $customerSegments,
549 ];
550
551 // --------------------------------------------------------------
552 // Extended datasets for detailed reports UI
553 // --------------------------------------------------------------
554
555 // Revenue broken down by trip
556 $revenueByTrip = [];
557 foreach ($bookings as $b) {
558 $tripTitle = $b['trip_title'] ?? ($b['trip']['title'] ?? __('(Untitled Trip)', 'yatra'));
559 $amount = isset($b['total_amount']) ? (float) $b['total_amount'] : 0.0;
560 $status = strtolower((string) ($b['payment_status'] ?? $b['status'] ?? 'pending'));
561
562 if (!isset($revenueByTrip[$tripTitle])) {
563 $revenueByTrip[$tripTitle] = [
564 'trip' => $tripTitle,
565 'totalRevenue' => 0.0,
566 'bookings' => 0,
567 'paidTotal' => 0.0,
568 'pendingTotal' => 0.0,
569 'refundedTotal' => 0.0,
570 ];
571 }
572
573 $revenueByTrip[$tripTitle]['totalRevenue'] += $amount;
574 $revenueByTrip[$tripTitle]['bookings']++;
575
576 if ($status === 'paid' || $status === 'completed') {
577 $revenueByTrip[$tripTitle]['paidTotal'] += $amount;
578 } elseif ($status === 'pending') {
579 $revenueByTrip[$tripTitle]['pendingTotal'] += $amount;
580 } elseif ($status === 'refunded' || $status === 'cancelled') {
581 $revenueByTrip[$tripTitle]['refundedTotal'] += $amount;
582 }
583 }
584
585 foreach ($revenueByTrip as &$tripRow) {
586 $count = $tripRow['bookings'] > 0 ? $tripRow['bookings'] : 1;
587 $tripRow['avgRevenuePerBooking'] = $tripRow['totalRevenue'] / $count;
588 }
589 unset($tripRow);
590 $revenueByTripRows = array_values($revenueByTrip);
591
592 // Flat bookings table used by detailed booking and cancellation views
593 $bookingsTable = [];
594 foreach ($bookings as $b) {
595 $travelerCount = 0;
596 $travelerCount += isset($b['adult_count']) ? (int) $b['adult_count'] : 0;
597 $travelerCount += isset($b['child_count']) ? (int) $b['child_count'] : 0;
598 $travelerCount += isset($b['senior_count']) ? (int) $b['senior_count'] : 0;
599 $travelerCount += isset($b['student_count']) ? (int) $b['student_count'] : 0;
600
601 if ($travelerCount === 0 && isset($b['travelers_count'])) {
602 $travelerCount = (int) $b['travelers_count'];
603 }
604
605 $bookingsTable[] = [
606 'id' => $b['id'] ?? null,
607 'bookingNumber' => $b['booking_number'] ?? ($b['id'] ?? null),
608 'trip' => $b['trip_title'] ?? ($b['trip']['title'] ?? __('(Untitled Trip)', 'yatra')),
609 'departureDate' => $b['travel_date'] ?? null,
610 'travelerCount' => $travelerCount,
611 'price' => isset($b['total_amount']) ? (float) $b['total_amount'] : 0.0,
612 'paymentMethod' => $b['payment_method'] ?? ($b['gateway'] ?? null),
613 'status' => strtolower((string) ($b['status'] ?? 'pending')),
614 'cancellationReason' => $b['cancellation_reason'] ?? null,
615 'refundAmount' => isset($b['refund_amount']) ? (float) $b['refund_amount'] : 0.0,
616 ];
617 }
618
619 // Traveler segments (adult / child / senior / student) and trend
620 $travelerBuckets = [
621 'adult' => 0,
622 'child' => 0,
623 'senior' => 0,
624 'student' => 0,
625 ];
626 $byDayTravelers = [];
627
628 foreach ($bookings as $b) {
629 $adult = isset($b['adult_count']) ? (int) $b['adult_count'] : 0;
630 $child = isset($b['child_count']) ? (int) $b['child_count'] : 0;
631 $senior = isset($b['senior_count']) ? (int) $b['senior_count'] : 0;
632 $student = isset($b['student_count']) ? (int) $b['student_count'] : 0;
633
634 $travelerBuckets['adult'] += $adult;
635 $travelerBuckets['child'] += $child;
636 $travelerBuckets['senior'] += $senior;
637 $travelerBuckets['student'] += $student;
638
639 $createdAt = $b['created_at'] ?? ($b['travel_date'] ?? null);
640 if (!$createdAt) {
641 continue;
642 }
643 $ts = strtotime((string) $createdAt);
644 if ($ts === false) {
645 continue;
646 }
647 $dayKey = gmdate('Y-m-d', $ts);
648 $totalTravelers = $adult + $child + $senior + $student;
649 if (!isset($byDayTravelers[$dayKey])) {
650 $byDayTravelers[$dayKey] = 0;
651 }
652 $byDayTravelers[$dayKey] += $totalTravelers;
653 }
654
655 $travelersTrend = [];
656 if ($fromTs !== null && $toTs !== null && $fromTs <= $toTs) {
657 $day = $fromTs;
658 while ($day <= $toTs) {
659 $key = gmdate('Y-m-d', $day);
660 $count = $byDayTravelers[$key] ?? 0;
661 $dt = \DateTimeImmutable::createFromFormat('Y-m-d', $key);
662 if ($dt) {
663 $travelersTrend[] = [
664 'date' => $key,
665 'label' => $dt->format('j M'),
666 'value' => $count,
667 ];
668 }
669 $day = strtotime('+1 day', $day);
670 }
671 }
672
673 $totalTravelersAll = array_sum($travelerBuckets);
674 $avgTravelersPerBooking = $totalBookings > 0 ? $totalTravelersAll / $totalBookings : 0.0;
675 $topTravelerCategory = null;
676 if ($totalTravelersAll > 0) {
677 $maxVal = -1;
678 foreach ($travelerBuckets as $key => $val) {
679 if ($val > $maxVal) {
680 $maxVal = $val;
681 $topTravelerCategory = $key;
682 }
683 }
684 }
685
686 $travelerSegments = [
687 'segments' => [
688 ['label' => __('Adult', 'yatra'), 'key' => 'adult', 'value' => $travelerBuckets['adult']],
689 ['label' => __('Child', 'yatra'), 'key' => 'child', 'value' => $travelerBuckets['child']],
690 ['label' => __('Senior', 'yatra'), 'key' => 'senior', 'value' => $travelerBuckets['senior']],
691 ['label' => __('Student', 'yatra'), 'key' => 'student', 'value' => $travelerBuckets['student']],
692 ],
693 'totalTravelers' => $totalTravelersAll,
694 'avgTravelersPerBooking' => $avgTravelersPerBooking,
695 'topCategory' => $topTravelerCategory,
696 'trend' => $travelersTrend,
697 ];
698
699 // Departures table and occupancy datasets
700 $departuresTable = [];
701 $occupancyByDay = [];
702 $capacityByDay = [];
703 $seatUtilizationByTrip = [];
704
705 foreach ($departures as $d) {
706 $dateStr = $d['start_date'] ?? ($d['date'] ?? null);
707 $tripTitle = $d['trip']['title'] ?? ($d['trip_title'] ?? __('Unknown Trip', 'yatra'));
708 $capacity = (int) ($d['max_capacity'] ?? $d['total_spots'] ?? 0);
709 $booked = (int) ($d['booked_count'] ?? $d['travelers_count'] ?? 0);
710 $left = $capacity > 0 ? max(0, $capacity - $booked) : 0;
711 $status = strtolower((string) ($d['status'] ?? 'upcoming'));
712
713 $departuresTable[] = [
714 'date' => $dateStr,
715 'trip' => $tripTitle,
716 'maxSeats' => $capacity,
717 'bookedSeats' => $booked,
718 'leftSeats' => $left,
719 'status' => $status,
720 ];
721
722 if ($dateStr) {
723 $dayKey = substr((string) $dateStr, 0, 10);
724 if (!isset($occupancyByDay[$dayKey])) {
725 $occupancyByDay[$dayKey] = 0;
726 $capacityByDay[$dayKey] = 0;
727 }
728 $occupancyByDay[$dayKey] += $booked;
729 $capacityByDay[$dayKey] += $capacity;
730 }
731
732 if (!isset($seatUtilizationByTrip[$tripTitle])) {
733 $seatUtilizationByTrip[$tripTitle] = ['trip' => $tripTitle, 'booked' => 0, 'capacity' => 0];
734 }
735 $seatUtilizationByTrip[$tripTitle]['booked'] += $booked;
736 $seatUtilizationByTrip[$tripTitle]['capacity'] += $capacity;
737 }
738
739 $occupancyTrend = [];
740 foreach ($occupancyByDay as $dayKey => $bookedSum) {
741 $capSum = $capacityByDay[$dayKey] ?? 0;
742 if ($capSum <= 0) {
743 continue;
744 }
745 $dt = \DateTimeImmutable::createFromFormat('Y-m-d', $dayKey);
746 if ($dt) {
747 $occupancyTrend[] = [
748 'label' => $dt->format('j M'),
749 'value' => round(($bookedSum / $capSum) * 100.0, 1),
750 ];
751 }
752 }
753
754 $seatUtilization = [];
755 foreach ($seatUtilizationByTrip as $row) {
756 $cap = $row['capacity'] > 0 ? $row['capacity'] : 1;
757 $seatUtilization[] = [
758 'trip' => $row['trip'],
759 'utilization' => round(($row['booked'] / $cap) * 100.0, 1),
760 ];
761 }
762
763 // Cancellations summary
764 $totalCancellations = 0;
765 $revenueLost = 0.0;
766 foreach ($bookingsTable as $row) {
767 if ($row['status'] === 'cancelled') {
768 $totalCancellations++;
769 $revenueLost += $row['refundAmount'] > 0 ? $row['refundAmount'] : $row['price'];
770 }
771 }
772
773 $cancellationRatePercent = $totalCount > 0 ? ($totalCancellations / $totalCount) * 100.0 : 0.0;
774 $cancellationsSummary = [
775 'totalCancellations' => $totalCancellations,
776 'cancellationRate' => $cancellationRatePercent,
777 'revenueLost' => $revenueLost,
778 ];
779
780 // Profitability placeholders (phase 2)
781 $profitabilityPlaceholders = [
782 'profitPerTrip' => [],
783 'costVsRevenue' => [],
784 ];
785
786 // ------------------------------------------------------------------
787 // Payment method breakdown — operators routinely want to know
788 // which gateways are pulling weight (and which they could turn
789 // off). Grouped by both count and gross revenue.
790 // ------------------------------------------------------------------
791 $methodBuckets = [];
792 foreach ($bookings as $b) {
793 $method = (string) ($b['payment_method'] ?? $b['gateway'] ?? '');
794 if ($method === '') $method = __('Unknown', 'yatra');
795 $method = strtolower($method);
796 $amount = isset($b['total_amount']) ? (float) $b['total_amount'] : 0.0;
797 if (!isset($methodBuckets[$method])) {
798 $methodBuckets[$method] = ['method' => $method, 'count' => 0, 'revenue' => 0.0];
799 }
800 $methodBuckets[$method]['count']++;
801 $methodBuckets[$method]['revenue'] += $amount;
802 }
803 uasort($methodBuckets, static function (array $a, array $b): int {
804 return $b['revenue'] <=> $a['revenue'];
805 });
806 $paymentMethodBreakdown = array_values($methodBuckets);
807
808 // ------------------------------------------------------------------
809 // Lead time: average days between booking creation and travel
810 // date. Long lead times = better cash float; short = last-minute
811 // travellers (different marketing levers). Skip rows without
812 // both timestamps.
813 // ------------------------------------------------------------------
814 $leadTotal = 0;
815 $leadCount = 0;
816 $leadBuckets = [
817 'same_day' => 0, // 0 days
818 'within_week' => 0, // 1-7
819 'within_month' => 0, // 8-30
820 'within_quarter' => 0, // 31-90
821 'beyond_quarter' => 0, // 91+
822 ];
823 foreach ($bookings as $b) {
824 $createdTs = isset($b['created_at']) ? strtotime((string) $b['created_at']) : false;
825 $travelTs = isset($b['travel_date']) ? strtotime((string) $b['travel_date']) : false;
826 if ($createdTs === false || $travelTs === false || $travelTs < $createdTs) {
827 continue;
828 }
829 $days = (int) floor(($travelTs - $createdTs) / DAY_IN_SECONDS);
830 $leadTotal += $days;
831 $leadCount++;
832 if ($days === 0) {
833 $leadBuckets['same_day']++;
834 } elseif ($days <= 7) {
835 $leadBuckets['within_week']++;
836 } elseif ($days <= 30) {
837 $leadBuckets['within_month']++;
838 } elseif ($days <= 90) {
839 $leadBuckets['within_quarter']++;
840 } else {
841 $leadBuckets['beyond_quarter']++;
842 }
843 }
844 $leadTime = [
845 'averageDays' => $leadCount > 0 ? round($leadTotal / $leadCount, 1) : 0.0,
846 'sampleSize' => $leadCount,
847 'buckets' => [
848 ['label' => __('Same day', 'yatra'), 'value' => $leadBuckets['same_day'], 'color' => '#ef4444'],
849 ['label' => __('Within a week', 'yatra'), 'value' => $leadBuckets['within_week'], 'color' => '#f59e0b'],
850 ['label' => __('Within a month', 'yatra'), 'value' => $leadBuckets['within_month'], 'color' => '#3b82f6'],
851 ['label' => __('Within a quarter', 'yatra'), 'value' => $leadBuckets['within_quarter'], 'color' => '#10b981'],
852 ['label' => __('More than a quarter', 'yatra'), 'value' => $leadBuckets['beyond_quarter'], 'color' => '#8b5cf6'],
853 ],
854 ];
855
856 // ------------------------------------------------------------------
857 // Refunds summary — distinct from cancellations because a refund
858 // requires a payment to have happened first. We aggregate from
859 // bookings where refund_amount > 0 OR status = refunded.
860 // ------------------------------------------------------------------
861 $refundsCount = 0;
862 $refundsTotal = 0.0;
863 $refundsByMethod = [];
864 foreach ($bookings as $b) {
865 $refundAmt = isset($b['refund_amount']) ? (float) $b['refund_amount'] : 0.0;
866 $status = strtolower((string) ($b['status'] ?? ''));
867 $isRefund = $refundAmt > 0 || $status === 'refunded';
868 if (!$isRefund) continue;
869 $refundsCount++;
870 $refundsTotal += $refundAmt > 0 ? $refundAmt : (float) ($b['total_amount'] ?? 0);
871 $method = strtolower((string) ($b['payment_method'] ?? $b['gateway'] ?? __('Unknown', 'yatra')));
872 if (!isset($refundsByMethod[$method])) {
873 $refundsByMethod[$method] = ['method' => $method, 'count' => 0, 'amount' => 0.0];
874 }
875 $refundsByMethod[$method]['count']++;
876 $refundsByMethod[$method]['amount'] += $refundAmt > 0 ? $refundAmt : (float) ($b['total_amount'] ?? 0);
877 }
878 $refundsSummary = [
879 'count' => $refundsCount,
880 'total' => $refundsTotal,
881 'refundRate' => $totalBookings > 0 ? ($refundsCount / $totalBookings) * 100.0 : 0.0,
882 'avgRefund' => $refundsCount > 0 ? $refundsTotal / $refundsCount : 0.0,
883 'byMethod' => array_values($refundsByMethod),
884 ];
885
886 // ------------------------------------------------------------------
887 // Top destinations — group bookings by destination(s) so operators
888 // can see geographic concentration. A trip can have multiple
889 // destinations; we count each occurrence (a 2-destination booking
890 // contributes 1 to each). The first/primary destination is what
891 // most operators expect to see ranked.
892 // ------------------------------------------------------------------
893 $destinationBuckets = [];
894 foreach ($bookings as $b) {
895 $destinations = $b['trip']['destinations'] ?? ($b['destinations'] ?? []);
896 if (!is_array($destinations) || empty($destinations)) {
897 continue;
898 }
899 $primary = $destinations[0];
900 $name = is_array($primary) ? ($primary['name'] ?? '') : (string) $primary;
901 if ($name === '') continue;
902 $amount = isset($b['total_amount']) ? (float) $b['total_amount'] : 0.0;
903 if (!isset($destinationBuckets[$name])) {
904 $destinationBuckets[$name] = ['label' => $name, 'value' => 0, 'revenue' => 0.0];
905 }
906 $destinationBuckets[$name]['value']++;
907 $destinationBuckets[$name]['revenue'] += $amount;
908 }
909 uasort($destinationBuckets, static function (array $a, array $b): int {
910 return $b['value'] <=> $a['value'];
911 });
912 $topDestinations = array_slice(array_values($destinationBuckets), 0, 8);
913
914 return new WP_REST_Response([
915 'success' => true,
916 'data' => [
917 'date_range' => [
918 'from' => $dateFrom,
919 'to' => $dateTo,
920 'prev_from' => $prevFrom,
921 'prev_to' => $prevTo,
922 ],
923 'revenue_stats' => $revenueStats,
924 'revenue_trend' => $revenueTrend,
925 'booking_stats' => $bookingStats,
926 'booking_trend' => $bookingTrend,
927 'status_trend' => $statusTrend,
928 'trip_performance' => $tripPerformance,
929 'payment_status' => $paymentStatus,
930 'operational_stats' => $operationalStats,
931 'customer_analytics' => $customerAnalytics,
932 // Extended datasets
933 'revenue_by_trip' => $revenueByTripRows,
934 'bookings_table' => $bookingsTable,
935 'traveler_segments' => $travelerSegments,
936 'departures_table' => $departuresTable,
937 'occupancy_trend' => $occupancyTrend,
938 'seat_utilization' => $seatUtilization,
939 'cancellations' => $cancellationsSummary,
940 'profitability' => $profitabilityPlaceholders,
941 // New analytics blocks (3.0.5+)
942 'payment_methods' => $paymentMethodBreakdown,
943 'lead_time' => $leadTime,
944 'refunds' => $refundsSummary,
945 'top_destinations' => $topDestinations,
946 ],
947 ]);
948 }
949
950 /**
951 * Helper to call an internal REST endpoint and return decoded data
952 * in array form. This keeps all reporting logic in one place while
953 * reusing existing controllers.
954 *
955 * @param string $method
956 * @param string $route
957 * @param array<string,mixed> $params
958 * @return mixed
959 */
960 private function request(string $method, string $route, array $params = [])
961 {
962 $req = new \WP_REST_Request($method, $route);
963 foreach ($params as $key => $value) {
964 $req->set_param($key, $value);
965 }
966 $response = rest_do_request($req);
967 if ($response instanceof \WP_REST_Response) {
968 return $response->get_data();
969 }
970 return null;
971 }
972 }
973