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
← All changes | app/Controllers/ReportsController.php +387 -18 3.0.4trunk View file →
@@ -21,11 +21,21 @@
21 21 ],
22 22 ]);
23 23 }
24 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 + */
25 34 public function check_permission(?WP_REST_Request $request = null): bool
26 35 {
27 - return current_user_can('manage_options');
36 + return current_user_can('yatra_view_financial_reports')
37 + || current_user_can('yatra_view_operational_reports');
28 38 }
29 39
30 40 /**
31 41 * GET /reports
@@ -43,8 +53,26 @@
43 53 $dateFrom = $start->format('Y-m-d');
44 54 $dateTo = $today->format('Y-m-d');
45 55 }
46 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 +
47 75 $params = [
48 76 'date_from' => $dateFrom,
49 77 'date_to' => $dateTo,
50 78 ];
@@ -50,12 +78,25 @@
50 78 ];
51 79
52 80 $bookingsList = $this->request('GET', '/yatra/v1/bookings', $params);
53 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.
54 96 $departuresList = $this->request('GET', '/yatra/v1/departures', [
55 97 'date_from' => $dateFrom,
56 - 'date_to' => $dateTo,
57 - 'include_past' => 'false',
98 + 'include_past' => 'true',
58 99 ]);
59 100
60 101 $bookings = isset($bookingsList['data']) && is_array($bookingsList['data'])
61 102 ? $bookingsList['data']
@@ -109,14 +150,60 @@
109 150 }
110 151
111 152 $averageBooking = $totalBookings > 0 ? $totalRevenue / $totalBookings : 0.0;
112 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 +
113 196 $revenueStats = [
114 - 'total' => $totalRevenue,
115 - 'bookings' => $totalBookings,
116 - 'average' => $averageBooking,
117 - 'previous' => 0.0,
118 - 'change' => 0.0,
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),
119 206 ];
120 207
121 208 // ------------------------------------------------------------------
122 209 // Booking stats & trends
@@ -128,11 +215,15 @@
128 215 'completed' => 0,
129 216 ];
130 217
131 218 // Aggregate by DAY so the trend charts can show one point per day in
132 - // the selected range (e.g. 1..7 when filtering 7 days).
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.
133 223 $byDayCount = [];
134 224 $byDayRevenue = [];
225 + $byDayStatus = []; // [yyyy-mm-dd][status] => int
135 226
136 227 foreach ($bookings as $b) {
137 228 $status = strtolower((string) ($b['status'] ?? 'pending'));
138 229 if (isset($statusCounts[$status])) {
@@ -152,11 +243,18 @@
152 243
153 244 if (!isset($byDayCount[$dayKey])) {
154 245 $byDayCount[$dayKey] = 0;
155 246 $byDayRevenue[$dayKey] = 0.0;
247 + $byDayStatus[$dayKey] = [
248 + 'confirmed' => 0, 'pending' => 0,
249 + 'cancelled' => 0, 'completed' => 0,
250 + ];
156 251 }
157 252 $byDayCount[$dayKey]++;
158 253 $byDayRevenue[$dayKey] += $amount;
254 + if (isset($byDayStatus[$dayKey][$status])) {
255 + $byDayStatus[$dayKey][$status]++;
256 + }
159 257 }
160 258
161 259 $totalCount = array_sum($statusCounts);
162 260 $cancelled = $statusCounts['cancelled'];
@@ -164,10 +262,16 @@
164 262
165 263 // Build a continuous list of DAYS across the selected range so that
166 264 // the charts always reflect the full date window (including days
167 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.
168 271 $bookingTrend = [];
169 272 $revenueTrend = [];
273 + $statusTrend = []; // [{date, label, confirmed, pending, cancelled, completed}]
170 274
171 275 if ($fromTs !== null && $toTs !== null && $fromTs <= $toTs) {
172 276 $day = $fromTs;
173 277
@@ -174,21 +278,34 @@
174 278 while ($day <= $toTs) {
175 279 $key = gmdate('Y-m-d', $day);
176 280 $count = $byDayCount[$key] ?? 0;
177 281 $revenue = $byDayRevenue[$key] ?? 0.0;
282 + $statusRow = $byDayStatus[$key] ?? [
283 + 'confirmed' => 0, 'pending' => 0,
284 + 'cancelled' => 0, 'completed' => 0,
285 + ];
178 286
179 287 $dt = \DateTimeImmutable::createFromFormat('Y-m-d', $key);
180 288 if ($dt) {
181 - // Label as "1 Nov", "2 Nov" etc. You can tweak format if needed.
182 289 $label = $dt->format('j M');
183 290 $bookingTrend[] = [
291 + 'date' => $key,
184 292 'label' => $label,
185 293 'value' => $count,
186 294 ];
187 295 $revenueTrend[] = [
296 + 'date' => $key,
188 297 'label' => $label,
189 298 'value' => $revenue,
190 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 + ];
191 308 }
192 309
193 310 // increment by one day
194 311 $day = strtotime('+1 day', $day);
@@ -194,8 +311,23 @@
194 311 $day = strtotime('+1 day', $day);
195 312 }
196 313 }
197 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 +
198 330 $bookingStats = [
199 331 'total' => $totalCount,
200 332 'confirmed' => $statusCounts['confirmed'],
201 333 'pending' => $statusCounts['pending'],
@@ -201,15 +333,30 @@
201 333 'pending' => $statusCounts['pending'],
202 334 'cancelled' => $statusCounts['cancelled'],
203 335 'completed' => $statusCounts['completed'],
204 336 'cancellationRate' => $cancellationRate,
205 - 'conversionRate' => 0.0,
337 + 'conversionRate' => $conversionRate,
206 338 'averageBookingValue' => $averageBooking,
207 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),
208 348 ];
209 349
210 350 // ------------------------------------------------------------------
211 - // Trip performance (group by trip title)
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.
212 359 // ------------------------------------------------------------------
213 360 $trips = [];
214 361 foreach ($bookings as $b) {
215 362 $title = $b['trip_title'] ?? ($b['trip']['title'] ?? __('(Untitled Trip)', 'yatra'));
@@ -220,19 +367,48 @@
220 367 $trips[$title]['count']++;
221 368 $trips[$title]['revenue'] += $amount;
222 369 }
223 370
224 - arsort($trips);
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 +
225 397 $palette = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4'];
226 398 $tripPerformance = [];
227 399 $i = 0;
228 400 foreach ($trips as $label => $stats) {
229 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;
230 406 $tripPerformance[] = [
231 407 'label' => $label,
232 408 'value' => $stats['count'],
233 409 'revenue' => $stats['revenue'],
234 - 'occupancy' => 0,
410 + 'occupancy' => $occ,
235 411 'color' => $palette[$i % count($palette)],
236 412 ];
237 413 $i++;
238 414 }
@@ -274,8 +450,9 @@
274 450 // ------------------------------------------------------------------
275 451 $upcomingDepartures = 0;
276 452 $totalCapacity = 0;
277 453 $bookedCapacity = 0;
454 + $departuresWithBookings = 0;
278 455 $upcomingTrips = [];
279 456
280 457 $todayTs = strtotime('today');
281 458
@@ -287,20 +464,27 @@
287 464 $upcomingTrips[] = [
288 465 'trip' => $d['trip']['title'] ?? ($d['trip_title'] ?? __('Unknown Trip', 'yatra')),
289 466 'date' => $dateStr,
290 467 'booked' => (int) ($d['booked_count'] ?? $d['travelers_count'] ?? 0),
291 - 'capacity' => (int) ($d['max_capacity'] ?? $d['total_spots'] ?? 0),
468 + 'capacity' => $this->departureAvailabilityCapacity($d),
292 469 ];
293 470 }
294 471
295 - $capacity = (int) ($d['max_capacity'] ?? $d['total_spots'] ?? 0);
472 + $capacity = $this->departureAvailabilityCapacity($d);
296 473 $booked = (int) ($d['booked_count'] ?? $d['travelers_count'] ?? 0);
297 474 $totalCapacity += $capacity;
298 475 $bookedCapacity += $booked;
476 + if ($booked > 0) {
477 + $departuresWithBookings++;
478 + }
299 479 }
300 480
301 481 $occupancyRate = $totalCapacity > 0 ? round(($bookedCapacity / $totalCapacity) * 100.0, 1) : 0.0;
302 - $averageGroupSize = $upcomingDepartures > 0 ? round($bookedCapacity / $upcomingDepartures, 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;
303 487
304 488 $operationalStats = [
305 489 'upcomingDepartures' => $upcomingDepartures,
306 490 'totalCapacity' => $totalCapacity,
@@ -497,8 +681,9 @@
497 681 $count = $byDayTravelers[$key] ?? 0;
498 682 $dt = \DateTimeImmutable::createFromFormat('Y-m-d', $key);
499 683 if ($dt) {
500 684 $travelersTrend[] = [
685 + 'date' => $key,
501 686 'label' => $dt->format('j M'),
502 687 'value' => $count,
503 688 ];
504 689 }
@@ -540,9 +725,9 @@
540 725
541 726 foreach ($departures as $d) {
542 727 $dateStr = $d['start_date'] ?? ($d['date'] ?? null);
543 728 $tripTitle = $d['trip']['title'] ?? ($d['trip_title'] ?? __('Unknown Trip', 'yatra'));
544 - $capacity = (int) ($d['max_capacity'] ?? $d['total_spots'] ?? 0);
729 + $capacity = $this->departureAvailabilityCapacity($d);
545 730 $booked = (int) ($d['booked_count'] ?? $d['travelers_count'] ?? 0);
546 731 $left = $capacity > 0 ? max(0, $capacity - $booked) : 0;
547 732 $status = strtolower((string) ($d['status'] ?? 'upcoming'));
548 733
@@ -618,15 +803,150 @@
618 803 'profitPerTrip' => [],
619 804 'costVsRevenue' => [],
620 805 ];
621 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 +
622 935 return new WP_REST_Response([
623 936 'success' => true,
624 937 'data' => [
938 + 'date_range' => [
939 + 'from' => $dateFrom,
940 + 'to' => $dateTo,
941 + 'prev_from' => $prevFrom,
942 + 'prev_to' => $prevTo,
943 + ],
625 944 'revenue_stats' => $revenueStats,
626 945 'revenue_trend' => $revenueTrend,
627 946 'booking_stats' => $bookingStats,
628 947 'booking_trend' => $bookingTrend,
948 + 'status_trend' => $statusTrend,
629 949 'trip_performance' => $tripPerformance,
630 950 'payment_status' => $paymentStatus,
631 951 'operational_stats' => $operationalStats,
632 952 'customer_analytics' => $customerAnalytics,
@@ -638,10 +958,59 @@
638 958 'occupancy_trend' => $occupancyTrend,
639 959 'seat_utilization' => $seatUtilization,
640 960 'cancellations' => $cancellationsSummary,
641 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,
642 967 ],
643 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);
644 1013 }
645 1014
646 1015 /**
647 1016 * Helper to call an internal REST endpoint and return decoded data