PluginProbe
VikBooking Hotel Booking Engine & PMS / trunk
VikBooking Hotel Booking Engine & PMS vtrunk
1.8.15 1.8.14 1.8.13 1.8.12 1.8.11 1.8.10 1.8.9 1.8.6 1.8.7 1.8.8 trunk 1.6.0 1.6.1 1.6.2 1.6.3 1.6.4 1.6.5 1.6.6 1.6.7 1.6.8 1.6.9 1.7.0 1.7.1 1.7.2 1.7.3 All 36 releases
vikbooking / admin / helpers / src / rms / pace.php

pace.php in VikBooking Hotel Booking Engine & PMS trunk, at admin/helpers/src/rms/pace.php

881 lines 36.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * @package VikBooking
4 * @subpackage core
5 * @author E4J s.r.l.
6 * @copyright Copyright (C) 2025 E4J s.r.l. All Rights Reserved.
7 * @license http://www.gnu.org/licenses/gpl-2.0.html GNU/GPL
8 * @link https://vikwp.com
9 */
10
11 // No direct access
12 defined('ABSPATH') or die('No script kiddies please!');
13
14 /**
15 * RMS Pace implementation
16 *
17 * @since 1.18.6 (J) - 1.8.6 (WP)
18 */
19 final class VBORmsPace
20 {
21 /**
22 * Proxy to construct the object.
23 *
24 * @return VBORmsPace
25 */
26 public static function getInstance()
27 {
28 return new static;
29 }
30
31 /**
32 * Class constructor.
33 */
34 public function __construct()
35 {}
36
37 /**
38 * Gets occupancy pace data for the RMS according to the options provided.
39 *
40 * @param ?array $options List of calculation options.
41 *
42 * @return array
43 *
44 * @throws Exception
45 */
46 public function getOccupancyData(?array $options = null)
47 {
48 // define default option values
49 $pickupDate = $options['pickup']['date'] ?? date('Y-m-d');
50 $targetDateFrom = $options['target']['from'] ?? date('Y-m-01');
51 $targetDateTo = $options['target']['to'] ?? date('Y-m-t');
52 $listingIds = (array) ($options['listings'] ?? []);
53 $periodInterval = in_array(strtoupper($options['interval'] ?? ''), ['DAY', 'MONTH']) ? $options['interval'] : 'DAY';
54
55 // obtain target timestamps for validation
56 $targetTsFrom = strtotime($targetDateFrom);
57 $targetTsTo = strtotime($targetDateTo);
58 if (!$targetTsFrom || !$targetTsTo || $targetTsTo < $targetTsFrom) {
59 throw new InvalidArgumentException('Invalid target (stay) dates.', 400);
60 }
61
62 // normalize period interval into a duration value
63 $intervalDuration = !strcasecmp($periodInterval, 'MONTH') ? 'P1M' : 'P1D';
64
65 // access the availability helper
66 $avHelper = VikBooking::getAvailabilityInstance(true);
67
68 // load the involved listings data (by also supporting category IDs)
69 $listingsData = $avHelper->loadRooms($listingIds);
70
71 // filter out the unpublished listings
72 $listingsData = array_filter($listingsData, function($listing) {
73 return !empty($listing['avail']);
74 });
75
76 if (!$listingsData) {
77 throw new Exception('No listings to analyse.', 400);
78 }
79
80 if ($options['sort_rooms'] ?? null) {
81 // custom rooms sorting other than default by "name"
82 if (is_callable($options['sort_rooms'])) {
83 // custom sorting function
84 uasort($listingsData, $options['sort_rooms']);
85 } else {
86 // determine sorting type
87 $listingsSortType = $options['sort_rooms'];
88 uasort($listingsData, function($a, $b) use ($listingsSortType) {
89 if ($listingsSortType === 'occupancy') {
90 // sort by occupancy ascending
91 return ($a['totpeople'] ?? 0) <=> ($b['totpeople'] ?? 0);
92 } elseif ($listingsSortType === 'units') {
93 // sort by units ascending
94 return ($a['units'] ?? 0) <=> ($b['units'] ?? 0);
95 }
96
97 // apply no sorting
98 return 0;
99 });
100 }
101 }
102
103 // update valid listing IDs
104 $listingIds = array_map('intval', array_column($listingsData, 'id'));
105
106 // count total rooms inventory
107 $totalInventoryCount = array_sum(array_column($listingsData, 'units'));
108
109 // shorten the listings data into an associative list
110 $listingsData = array_combine($listingIds, array_values(array_map(function($listing) {
111 return [
112 'name' => $listing['name'],
113 'units' => $listing['units'],
114 ];
115 }, $listingsData)));
116
117 // build target dates pool
118 $targetsPool = [
119 [
120 $targetTsFrom,
121 $targetTsTo,
122 ],
123 ];
124
125 // check for comparison instructions
126 foreach (($options['compare'] ?? []) as $compareData) {
127 if (!is_array($compareData) || empty($compareData['to'])) {
128 // unexpected comparison instruction
129 continue;
130 }
131
132 // check if week-days should match across the comparison dates
133 $alignWdays = (bool) ($compareData['align_wdays'] ?? 1);
134
135 // calculate target dates for comparison
136 $compareTsFrom = $this->getComparisonTimestamp($targetTsFrom, $compareData['to'], $alignWdays);
137 $compareTsTo = $this->getComparisonTimestamp($targetTsTo, $compareData['to'], $alignWdays);
138 if (is_null($compareTsFrom)) {
139 // unacceptable datetime comparison value
140 continue;
141 }
142
143 // push target dates to the pool for comparison
144 $targetsPool[] = [
145 $compareTsFrom,
146 $compareTsTo,
147 ];
148 }
149
150 // build the list of occupancy pace data metric objects for extracting data
151 $paceDataMetrics = $this->loadOccupancyPaceDataMetrics($options);
152
153 // build pace dataset
154 $dataset = [
155 'pace' => [],
156 'listings' => $listingsData,
157 'inventory_count' => $totalInventoryCount,
158 ];
159
160 // scan all target dates
161 foreach ($targetsPool as $index => $targetData) {
162 if (!isset($dataset['pace'][$index])) {
163 // start pace index container
164 $dataset['pace'][$index] = [];
165 }
166
167 // obtain the target details
168 list($tsFrom, $tsTo) = $targetData;
169
170 // ensure the end timestamp is full
171 $tsTo = strtotime('23:59:59', $tsTo);
172
173 // fetch all confirmed and cancelled bookings from pickup date, for targeted stay dates
174 $bookings = $this->getIntersectingBookings([
175 'pickup' => [
176 'date' => (!$index ? $pickupDate : null),
177 ],
178 'target' => [
179 'from_ts' => $tsFrom,
180 'to_ts' => $tsTo,
181 ],
182 'listings' => $listingIds,
183 'cancellation_dt' => 1,
184 'tariff_taxes' => 1,
185 ]);
186
187 // preload rate flow records and events only for the first set of target dates
188 $ratesRegistry = null;
189 $periodEvents = [];
190
191 if (!$index) {
192 // construct the RMS rates registry object from pickup date, for targeted stay dates
193 $ratesRegistry = (new VBORmsRatesRegistry([
194 'pickup' => [
195 'date' => $pickupDate,
196 ],
197 'target' => [
198 'from_ts' => $tsFrom,
199 'to_ts' => $tsTo,
200 ],
201 'listings' => $listingIds,
202 ]))->preloadFlowRecords();
203
204 // preload period events
205 $periodEvents = VBODateHotevents::loadPeriod($tsFrom, $tsTo, $listingIds);
206 }
207
208 // obtain the iterable date period
209 $datePeriod = $this->getDatePeriodInterval($tsFrom, $tsTo, $intervalDuration);
210
211 // iterate all target date intervals
212 foreach ($datePeriod as $period) {
213 // build period initial pace metrics
214 $periodPaceMetrics = [
215 // inject the period date object that we are parsing
216 'date' => $period,
217 ];
218
219 // construct the pace occupancy data-period registry
220 $paceDataPeriod = (new VBORmsPaceOccupancyDataperiod(
221 // get the confirmed bookings for the current period
222 $this->filterPeriodBookings($period, $bookings, $datePeriod->getDateInterval(), ['status' => 'confirmed']),
223 // the datetime period to evaluate
224 $period,
225 // the date evaluation interval
226 $datePeriod->getDateInterval()
227 ))
228 ->setListings($listingsData)
229 ->setCancellations($this->filterPeriodBookings($period, $bookings, $datePeriod->getDateInterval(), ['status' => 'cancelled']))
230 ->setRatesRegistry($ratesRegistry)
231 ->setHotEvents($periodEvents);
232
233 // iterate all pace data metric objects
234 foreach ($paceDataMetrics as $paceDataMetric) {
235 try {
236 // let the data metric object extract its own metrics
237 $periodPaceMetrics[$paceDataMetric->getID()] = $paceDataMetric->extract($paceDataPeriod, $periodPaceMetrics);
238 } catch (Exception $e) {
239 // catch and push the error
240 $periodPaceMetrics['_errors'] = $periodPaceMetrics['_errors'] ?? [];
241 $periodPaceMetrics['_errors'][$paceDataMetric->getID()] = $e;
242 }
243 }
244
245 // push period pace data to current index
246 $dataset['pace'][$index][] = $periodPaceMetrics;
247 }
248 }
249
250 return $dataset;
251 }
252
253 /**
254 * Gets booking pace data for the RMS according to the options provided.
255 *
256 * @param ?array $options List of calculation options.
257 *
258 * @return array
259 *
260 * @throws Exception
261 */
262 public function getBookingData(?array $options = null)
263 {
264 // define default option values
265 $pickupDateFrom = $options['pickup']['from'] ?? date('Y-m-01', strtotime('-1 month'));
266 $pickupDateTo = $options['pickup']['to'] ?? date('Y-m-d');
267 $targetDateFrom = $options['target']['from'] ?? date('Y-m-01', strtotime('+3 months'));
268 $targetDateTo = $options['target']['to'] ?? date('Y-m-t', strtotime('+3 months'));
269 $listingIds = (array) ($options['listings'] ?? []);
270 $periodInterval = in_array(strtoupper($options['interval'] ?? ''), ['DAY', 'MONTH']) ? $options['interval'] : 'DAY';
271
272 // obtain pickup timestamps for validation
273 $pickupTsFrom = strtotime($pickupDateFrom);
274 $pickupTsTo = strtotime($pickupDateTo);
275 if (!$pickupTsFrom || !$pickupTsTo || $pickupTsTo < $pickupTsFrom || $pickupTsTo > time()) {
276 throw new InvalidArgumentException('Invalid pickup dates.', 400);
277 }
278
279 // obtain target timestamps for validation
280 $targetTsFrom = strtotime($targetDateFrom);
281 $targetTsTo = strtotime($targetDateTo);
282 if (!$targetTsFrom || !$targetTsTo || $targetTsTo < $targetTsFrom) {
283 throw new InvalidArgumentException('Invalid target (stay) dates.', 400);
284 }
285
286 // normalize period interval into a duration value
287 $intervalDuration = !strcasecmp($periodInterval, 'MONTH') ? 'P1M' : 'P1D';
288
289 // access the availability helper
290 $avHelper = VikBooking::getAvailabilityInstance(true);
291
292 // load the involved listings data (by also supporting category IDs)
293 $listingsData = $avHelper->loadRooms($listingIds);
294
295 // filter out the unpublished listings
296 $listingsData = array_filter($listingsData, function($listing) {
297 return !empty($listing['avail']);
298 });
299
300 if (!$listingsData) {
301 throw new Exception('No listings to analyse.', 400);
302 }
303
304 if ($options['sort_rooms'] ?? null) {
305 // custom rooms sorting other than default by "name"
306 if (is_callable($options['sort_rooms'])) {
307 // custom sorting function
308 uasort($listingsData, $options['sort_rooms']);
309 } else {
310 // determine sorting type
311 $listingsSortType = $options['sort_rooms'];
312 uasort($listingsData, function($a, $b) use ($listingsSortType) {
313 if ($listingsSortType === 'occupancy') {
314 // sort by occupancy ascending
315 return ($a['totpeople'] ?? 0) <=> ($b['totpeople'] ?? 0);
316 } elseif ($listingsSortType === 'units') {
317 // sort by units ascending
318 return ($a['units'] ?? 0) <=> ($b['units'] ?? 0);
319 }
320
321 // apply no sorting
322 return 0;
323 });
324 }
325 }
326
327 // update valid listing IDs
328 $listingIds = array_map('intval', array_column($listingsData, 'id'));
329
330 // shorten the listings data into an associative list
331 $listingsData = array_combine($listingIds, array_values(array_map(function($listing) {
332 return [
333 'name' => $listing['name'],
334 'units' => $listing['units'],
335 ];
336 }, $listingsData)));
337
338 // build pickup and target data lists
339 $pickupData = [
340 $pickupTsFrom,
341 $pickupTsTo,
342 ];
343 $targetData = [
344 $targetTsFrom,
345 $targetTsTo,
346 ];
347
348 // build pace dataset
349 $dataset = [
350 'pace' => [],
351 'listings' => $listingsData,
352 ];
353
354 // obtain the target details
355 list($tsFrom, $tsTo) = $targetData;
356
357 // ensure the end timestamp is full
358 $tsTo = strtotime('23:59:59', $tsTo);
359
360 // fetch all confirmed and cancelled bookings intersecting the targeted stay dates
361 $bookings = $this->getIntersectingBookings([
362 'target' => [
363 'from_ts' => $tsFrom,
364 'to_ts' => $tsTo,
365 ],
366 'listings' => $listingIds,
367 'cancellation_dt' => 1,
368 'tariff_taxes' => 1,
369 ]);
370
371 // count the number of "on the books" bookings before pickup
372 $otbPickupCount = $this->calculatePickupStartingBookings($pickupData, $bookings);
373
374 // obtain the iterable date period
375 $datePeriod = $this->getDatePeriodInterval($pickupTsFrom, $pickupTsTo, $intervalDuration);
376
377 // build the list of booking pace data metric objects for extracting data
378 $paceDataMetrics = $this->loadBookingPaceDataMetrics($bookings, $options);
379
380 // iterate all target date intervals
381 foreach ($datePeriod as $period) {
382 // build period initial pace metrics
383 $periodPaceMetrics = [
384 // inject the period date object that we are parsing
385 'date' => $period,
386 ];
387
388 // construct the pace data-period registry
389 $paceDataPeriod = (new VBORmsPaceBookingDataperiod(
390 // pass the number of "on the books" bookings before pickup
391 $otbPickupCount,
392 // get the confirmed and cancelled bookings for the current period
393 $this->filterPeriodBookings($period, $bookings, $datePeriod->getDateInterval(), ['intersect' => 'creation']),
394 // the datetime period to evaluate
395 $period,
396 // the date evaluation interval
397 $datePeriod->getDateInterval()
398 ))
399 ->setListings($listingsData);
400
401 // iterate all pace data metric objects
402 foreach ($paceDataMetrics as $paceDataMetric) {
403 try {
404 // let the data metric object extract its own metrics
405 $periodPaceMetrics[$paceDataMetric->getID()] = $paceDataMetric->extract($paceDataPeriod, $periodPaceMetrics);
406 } catch (Exception $e) {
407 // catch and push the error
408 $periodPaceMetrics['_errors'] = $periodPaceMetrics['_errors'] ?? [];
409 $periodPaceMetrics['_errors'][$paceDataMetric->getID()] = $e;
410 }
411 }
412
413 // metrics must have set the number of "on the books" reservation at the current pickup period
414 // update value for the next period iteration to count new bookings and cancellations
415 $otbPickupCount = $paceDataPeriod->getPickupStartingBookings();
416
417 // push period booking pace data
418 $dataset['pace'][] = $periodPaceMetrics;
419 }
420
421 return $dataset;
422 }
423
424 /**
425 * Calculates the timestamp of the date to be compared against the initial date.
426 *
427 * @param int $ts Initial date timestamp.
428 * @param string $compare Initial date modifier for comparison (i.e. "-1 year").
429 * @param bool $align_wdays Whether to align the week-day of the comparison date.
430 *
431 * @return ?int
432 */
433 public function getComparisonTimestamp(int $ts, string $compare, bool $align_wdays = true)
434 {
435 // initialize source and target date objects with local timezone
436 $source = JFactory::getDate(date('Y-m-d H:i:s', $ts));
437 $target = clone $source;
438
439 try {
440 // modify target date according to compare modifier
441 $target->modify($compare);
442 } catch (Exception $error) {
443 // unacceptable datetime comparison string
444 return null;
445 }
446
447 // check if comparison requires additional operations
448 if (!$align_wdays || $source->format('Ym') == $target->format('Ym')) {
449 // return the target date in case no week-day alignment is needed
450 // or if source and target dates share the same month and year
451 return $target->getTimestamp();
452 }
453
454 // align target date to the same week day as source date, and return the timestamp for comparison
455 return VBODateComparator::alignWeekDay($source, (int) $target->format('Y'))->getTimestamp();
456 }
457
458 /**
459 * Builds and returns the iterable date period interval for the given dates interval.
460 *
461 * @param int $from_ts From date period timestamp.
462 * @param int $to_ts To date period timestamp.
463 * @param string $duration The interval specification used for DateInterval::__construct().
464 *
465 * @return DatePeriod
466 */
467 public function getDatePeriodInterval(int $from_ts, int $to_ts, string $duration = 'P1D')
468 {
469 // local timezone
470 $tz = new DateTimezone(date_default_timezone_get());
471
472 // get date bounds
473 $from_bound = new DateTime(date('Y-m-d 00:00:00', $from_ts), $tz);
474 $to_bound = new DateTime(date('Y-m-d 00:00:00', strtotime('+1 day', $to_ts)), $tz);
475
476 // return iterable dates interval (period)
477 return new DatePeriod(
478 // start date included by default in the result set
479 $from_bound,
480 // interval between recurrences within the period
481 new DateInterval($duration),
482 // end date excluded by default from the result set
483 $to_bound
484 );
485 }
486
487 /**
488 * Given two date timestamps, counts the nights in between.
489 *
490 * @param int $fromTs Start date timestamp.
491 * @param int $toTs End date timestamp.
492 * @param bool $inclusive True to increase the difference.
493 *
494 * @return int Difference in days.
495 */
496 public function countNightsDifferenceTs(int $fromTs, int $toTs, bool $inclusive = false)
497 {
498 // ensure the time is the same for both timestamps for an accurate difference
499 $fromTs = strtotime('10:00:00', $fromTs);
500 $toTs = strtotime('10:00:00', $toTs);
501
502 // constuct date objects from timestamps
503 $tz = new DateTimeZone(date_default_timezone_get());
504 $fromDate = new DateTime("@$fromTs", $tz);
505 $toDate = new DateTime("@$toTs", $tz);
506
507 return ((int) $fromDate->diff($toDate)->days) + (int) $inclusive;
508 }
509
510 /**
511 * Calculates the starting bookings counter before pickup start.
512 *
513 * @param array $pickupData List of pickup range timestamps (from and to).
514 * @param array $bookings List of exact bookings to parse.
515 *
516 * @return int
517 */
518 public function calculatePickupStartingBookings(array $pickupData, array $bookings)
519 {
520 // extract pickup stand and end timestamps
521 list($pickupFromTs, $pickupToTs) = $pickupData;
522
523 // start counter
524 $startingBookings = 0;
525
526 foreach ($bookings as $booking) {
527 if ($booking['status'] == 'confirmed' && $booking['ts'] <= $pickupFromTs) {
528 // increase starting reservations count for this booking created before pickup start
529 $startingBookings++;
530 } elseif ($booking['status'] == 'cancelled' && $booking['ts'] <= $pickupFromTs) {
531 // check the history cancellation date, and if cancelled after pickup
532 if (($booking['cancellation_ts'] ?? 0) > $pickupFromTs) {
533 // this booking was confirmed before pickup start, and so we should increase the counter
534 $startingBookings++;
535 }
536 }
537 }
538
539 return $startingBookings;
540 }
541
542 /**
543 * Returns a list of bookings intersecting the given bounds.
544 *
545 * @param array $bounds The bounds to fetch the bookings.
546 *
547 * @return array List of bookings involved.
548 */
549 public function getIntersectingBookings(array $bounds)
550 {
551 $dbo = JFactory::getDbo();
552
553 $bookings = [];
554 $bookingTariffs = [];
555
556 $q = $dbo->getQuery(true)
557 ->select([
558 $dbo->qn('b.id'),
559 $dbo->qn('b.ts'),
560 $dbo->qn('b.status'),
561 $dbo->qn('b.days'),
562 $dbo->qn('b.checkin'),
563 $dbo->qn('b.checkout'),
564 $dbo->qn('b.roomsnum'),
565 $dbo->qn('b.total'),
566 $dbo->qn('b.idorderota'),
567 $dbo->qn('b.channel'),
568 $dbo->qn('b.tot_taxes'),
569 $dbo->qn('b.tot_city_taxes'),
570 $dbo->qn('b.tot_fees'),
571 $dbo->qn('b.tot_damage_dep'),
572 $dbo->qn('b.cmms'),
573 $dbo->qn('b.closure'),
574 $dbo->qn('br.idroom', 'br_idroom'),
575 $dbo->qn('br.adults', 'br_adults'),
576 $dbo->qn('br.children', 'br_children'),
577 $dbo->qn('br.idtar', 'br_idtar'),
578 $dbo->qn('br.cust_cost', 'br_cust_cost'),
579 $dbo->qn('br.cust_idiva', 'br_cust_idiva'),
580 $dbo->qn('br.room_cost', 'br_room_cost'),
581 $dbo->qn('br.otarplan', 'br_otarplan'),
582 ])
583 ->from($dbo->qn('#__vikbooking_orders', 'b'))
584 ->leftJoin($dbo->qn('#__vikbooking_ordersrooms', 'br') . ' ON ' . $dbo->qn('b.id') . ' = ' . $dbo->qn('br.idorder'))
585 ->where($dbo->qn('b.status') . ' IN (' . implode(', ', array_map([$dbo, 'q'], ['confirmed', 'cancelled'])) . ')')
586 ->where($dbo->qn('b.checkin') . ' <= ' . $bounds['target']['to_ts'])
587 ->where($dbo->qn('b.checkout') . ' >= ' . $bounds['target']['from_ts'])
588 ->order($dbo->qn('b.id') . ' ASC')
589 ->order($dbo->qn('br.id') . ' ASC');
590
591 if (!($bounds['closures'] ?? 0)) {
592 // filter bookings by excluding closures
593 $q->where($dbo->qn('b.closure') . ' = 0');
594 } elseif ($bounds['only_closures'] ?? 0) {
595 // filter bookings by including only closures
596 $q->where($dbo->qn('b.closure') . ' = 1');
597 }
598
599 if ($bounds['listings'] ?? []) {
600 // filter bookings by listing IDs
601 $q->where($dbo->qn('br.idroom') . ' IN (' . implode(', ', $bounds['listings']) . ')');
602 }
603
604 if (($bounds['pickup']['date'] ?? '') && $bounds['pickup']['date'] != date('Y-m-d')) {
605 // filter bookings by creation timestamp
606 $q->where($dbo->qn('b.ts') . ' <= ' . strtotime('23:59:59', strtotime($bounds['pickup']['date'])));
607 }
608
609 $dbo->setQuery($q);
610 foreach ($dbo->loadAssocList() as $booking) {
611 if (!empty($booking['idtar'])) {
612 // handle booking tariff relation
613 $bookingTariffs[] = [
614 'id' => $booking['id'],
615 'idtar' => $booking['idtar'],
616 ];
617 }
618
619 // build booking record and booking room data levels
620 $bookingRecordData = [];
621 $bookingRoomData = [];
622 foreach ($booking as $prop => $val) {
623 if (substr($prop, 0, 3) === 'br_') {
624 // booking-room level data
625 $realProp = substr($prop, 3);
626 $bookingRoomData[$realProp] = $val;
627 } else {
628 // booking-record level data
629 $bookingRecordData[$prop] = $val;
630 }
631 }
632
633 if (!isset($bookings[$booking['id']])) {
634 // allocate first booking-room record
635 $bookings[$booking['id']] = $bookingRecordData;
636 $bookings[$booking['id']]['_rooms'] = [$bookingRoomData];
637 } else {
638 // push additional booking-room record
639 $bookings[$booking['id']]['_rooms'][] = $bookingRoomData;
640 }
641 }
642
643 if ($bounds['listings'] ?? []) {
644 // normalize multi-room booking properties in case of unwanted listings filtered
645 foreach ($bookings as $bid => $booking) {
646 // count booked rooms and eligible listings
647 $bookedRooms = $booking['roomsnum'];
648 $totListings = count($booking['_rooms']);
649 if ($bookedRooms > $totListings) {
650 // normalize properties
651 $bookings[$bid]['_roomsnum'] = $bookedRooms;
652 $bookings[$bid]['roomsnum'] = $totListings;
653 }
654 }
655 }
656
657 if (($bounds['tariff_taxes'] ?? 0) && $bookingTariffs) {
658 // we need to fetch the rate plan ID from the tariff ID of the rooms booked
659 // this is needed for calculation purposes of room rates before/after tax
660 $uniqueTariffIds = array_values(array_filter(array_unique(array_column($bookingTariffs, 'idtar'))));
661
662 if ($uniqueTariffIds) {
663 // list of tariff-booking processed
664 $tariffBidsProcessed = [];
665
666 // query the database to obtain the rate plan ID from the list of tariff IDs
667 $dbo->setQuery(
668 $dbo->getQuery(true)
669 ->select([
670 $dbo->qn('id'),
671 $dbo->qn('idprice'),
672 ])
673 ->from($dbo->qn('#__vikbooking_dispcost'))
674 ->where($dbo->qn('id') . ' IN (' . implode(', ', array_map('intval', $uniqueTariffIds)) . ')')
675 );
676
677 // scan all tariff records
678 foreach ($dbo->loadAssocList() as $tariffRecord) {
679 // scan all booking tariffs
680 foreach ($bookingTariffs as $bookingTariff) {
681 if ($bookingTariff['idtar'] == $tariffRecord['id']) {
682 // set and determine current booking room index
683 $tariffBidsProcessed[$bookingTariff['id']] = ($tariffBidsProcessed[$bookingTariff['id']] ?? -1) + 1;
684 $currentBookingRoomIndex = $tariffBidsProcessed[$bookingTariff['id']];
685
686 if (isset($bookings[$bookingTariff['id']]['_rooms'][$currentBookingRoomIndex])) {
687 // set booking room rate plan ID
688 $bookings[$bookingTariff['id']]['_rooms'][$currentBookingRoomIndex]['idprice'] = (int) $tariffRecord['idprice'];
689 }
690 }
691 }
692 }
693 }
694 }
695
696 if ($bounds['cancellation_dt'] ?? 0) {
697 // we need to fetch the exact cancellation date from history records
698 $cancBids = array_column(array_filter($bookings, function($booking) {
699 return $booking['status'] === 'cancelled';
700 }), 'id');
701
702 // build the list of history events related to booking cancellations
703 if ($cancBids && $cancHistoryEvents = VikBooking::getBookingHistoryInstance(0)->getBookingEventsType('cancelled')) {
704 // list of booking IDs with cancellation events processed
705 $cancBidsProcessed = [];
706
707 // query the database to fetch the needed history records
708 $dbo->setQuery(
709 $dbo->getQuery(true)
710 ->select([
711 $dbo->qn('idorder'),
712 $dbo->qn('dt'),
713 ])
714 ->from($dbo->qn('#__vikbooking_orderhistory'))
715 ->where($dbo->qn('idorder') . ' IN (' . implode(', ', array_map('intval', $cancBids)) . ')')
716 ->where($dbo->qn('type') . ' IN (' . implode(', ', array_map([$dbo, 'q'], $cancHistoryEvents)) . ')')
717 ->order($dbo->qn('idorder') . ' ASC')
718 ->order($dbo->qn('dt') . ' ASC')
719 );
720
721 // scan all booking cancellation records
722 foreach ($dbo->loadAssocList() as $cancRecord) {
723 if (!($cancBidsProcessed[$cancRecord['idorder']] ?? 0)) {
724 // turn flag on to process this booking only once and get the earliest (first) cancellation
725 $cancBidsProcessed[$cancRecord['idorder']] = 1;
726
727 // convert the cancellation date from UTC to local timezone and set booking cancellation timestamp
728 $bookings[$cancRecord['idorder']]['cancellation_ts'] = JHtml::fetch('date', $cancRecord['dt'], 'U');
729 }
730 }
731 }
732 }
733
734 // return a numeric list of booking records
735 return array_values($bookings);
736 }
737
738 /**
739 * Filters the booking records eligible for the given period and interval ("On The Books" reservations).
740 *
741 * @param DateTimeInterface $period The period to evaluate, either a single day or a full month.
742 * @param array $bookings List of bookings involved for filtering.
743 * @param ?DateInterval $interval Optional period interval for evaluation (day or month).
744 * @param ?array $options Optional list of filtering options.
745 *
746 * @return array List of bookings eligible with the given period, if any.
747 */
748 public function filterPeriodBookings(DateTimeInterface $period, array $bookings, ?DateInterval $interval = null, ?array $options = null)
749 {
750 $periodBookings = [];
751
752 // determine the range of timestamps for matching a booking, according to interval
753 $intervalType = ($interval->m ?? 0) ? 'MONTH' : 'DAY';
754 if (in_array(($options['intersect'] ?? null), ['creation', 'cancellation'])) {
755 // booking creation or cancellation timestamp
756 $matchTsFrom = strtotime('00:00:00', $period->format('U'));
757 $matchTsTo = strtotime('23:59:59', $period->format('U'));
758 } else {
759 // booking stay dates
760 $matchTsFrom = strtotime('23:59:59', $period->format('U'));
761 $matchTsTo = $matchTsFrom;
762 }
763 if ($intervalType === 'MONTH') {
764 $matchTsTo = strtotime('23:59:59', strtotime($period->format('Y-m-t')));
765 }
766
767 if (!empty($options['status'])) {
768 // filter bookings by status enums
769 $options['status'] = (array) $options['status'];
770 }
771
772 foreach ($bookings as $booking) {
773 if (!empty($booking['closure'])) {
774 // exclude closure reservations from revenue
775 continue;
776 }
777
778 if (!empty($options['status']) && !in_array($booking['status'], $options['status'])) {
779 // filter unwanted reservation status
780 continue;
781 }
782
783 // check if the booking fits the requested date interval
784 if (($options['intersect'] ?? null) === 'creation') {
785 // booking creation timestamp
786 if ($matchTsFrom <= ($booking['ts'] ?? 0) && ($booking['ts'] ?? 0) <= $matchTsTo) {
787 // match found, push booking record
788 $periodBookings[] = $booking;
789 }
790 } elseif (($options['intersect'] ?? null) === 'cancellation') {
791 // booking cancellation timestamp
792 if ($matchTsFrom <= ($booking['cancellation_ts'] ?? 0) && ($booking['cancellation_ts'] ?? 0) <= $matchTsTo) {
793 // match found, push booking record
794 $periodBookings[] = $booking;
795 }
796 } else {
797 // booking stay dates
798 if (($booking['checkin'] ?? 0) <= $matchTsTo && ($booking['checkout'] ?? 0) >= $matchTsFrom) {
799 // match found, push booking record
800 $periodBookings[] = $booking;
801 }
802 }
803 }
804
805 return $periodBookings;
806 }
807
808 /**
809 * Loads the various occupancy data metric objects to run.
810 *
811 * @param ?array $options Optional metric settings.
812 *
813 * @return array List of VBORmsPaceDataMetric objects.
814 */
815 private function loadOccupancyPaceDataMetrics(?array $options = null)
816 {
817 $defaultMetrics = [
818 // data metric for booking IDs
819 new VBORmsPaceDataMetricBookingids($options),
820 // data metric "ABRN"
821 new VBORmsPaceDataMetricAbrn($options),
822 // data metric for sellable units
823 new VBORmsPaceDataMetricSellableunits($options),
824 // data metric for occupancy percent
825 new VBORmsPaceDataMetricOccupancypcent($options),
826 // data metric for booked rooms
827 new VBORmsPaceDataMetricBookedrooms($options),
828 // data metric for multi-room bookings count
829 new VBORmsPaceDataMetricMultiroombookingscount($options),
830 // data metric "ADR"
831 new VBORmsPaceDataMetricAdr($options),
832 // data metric "Room Revenue"
833 new VBORmsPaceDataMetricRoomrevenue($options),
834 // data metric "RevPAR"
835 new VBORmsPaceDataMetricRevpar($options),
836 // data metric "Gross Revenue"
837 new VBORmsPaceDataMetricGrossrevenue($options),
838 // data metric "Rate Variation Date"
839 new VBORmsPaceDataMetricRatevariationdate($options),
840 // data metric "Rate Variation Plus"
841 new VBORmsPaceDataMetricRatevariationplus($options),
842 // data metric "Rate Variation Minus"
843 new VBORmsPaceDataMetricRatevariationminus($options),
844 // data metric "Room Rate Variation"
845 new VBORmsPaceDataMetricRoomratevariation($options),
846 // data metric "Nightly Rates"
847 new VBORmsPaceDataMetricNightlyrates($options),
848 // data metric "Hot Events"
849 new VBORmsPaceDataMetricHotevents($options),
850 ];
851
852 return array_merge($defaultMetrics, (array) ($options['metrics'] ?? []));
853 }
854
855 /**
856 * Loads the various booking data metric objects to run.
857 *
858 * @param array $bookings Raw list of bookings intersecting the target dates.
859 * @param ?array $options Optional metric settings.
860 *
861 * @return array List of VBORmsPaceDataMetric objects.
862 */
863 private function loadBookingPaceDataMetrics(array $bookings, ?array $options = null)
864 {
865 $defaultMetrics = [
866 // data metric for new bookings
867 new VBORmsPaceDataMetricNewbookings($options),
868 // data metric for cancelled bookings (overwrite constructor signature)
869 new VBORmsPaceDataMetricCancbookings($bookings, $options),
870 // data metric for "on the books"
871 new VBORmsPaceDataMetricOnthebooks($options),
872 // data metric "ADR"
873 new VBORmsPaceDataMetricAdr($options),
874 // data metric "Room Revenue"
875 new VBORmsPaceDataMetricRoomrevenue($options),
876 ];
877
878 return array_merge($defaultMetrics, (array) ($options['metrics'] ?? []));
879 }
880 }
881