| @@ -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 |