PluginProbe
Yatra – Travel Booking & Tour Operator Software / trunk
Yatra – Travel Booking & Tour Operator Software vtrunk
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 trunk, at app/Controllers/ReportsController.php

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