PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.10
Yatra – Travel Booking & Tour Operator Software v3.0.10
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 2.0.11 All 82 releases
yatra / app / Controllers / ReportsController.php

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

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