PluginProbe
VikBooking Hotel Booking Engine & PMS / 1.8.16
VikBooking Hotel Booking Engine & PMS v1.8.16
1.8.16 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 All 37 releases
← All changes | admin/helpers/src/taxonomy/finance.php +817 -515 1.6.7 → 1.8.16 View file →
@@ -13,591 +13,893 @@
13 13
14 14 /**
15 15 * Taxonomy finance helper class.
16 16 *
17 - * @since 1.16.0 (J) - 1.6.0 (WP)
17 + * @since 1.16.0 (J) - 1.6.0 (WP)
18 18 */
19 19 class VBOTaxonomyFinance
20 20 {
21 - /**
22 - * The singleton instance of the class.
23 - *
24 - * @var VBOTaxonomyFinance
25 - */
26 - protected static $instance = null;
21 + /**
22 + * The singleton instance of the class.
23 + *
24 + * @var VBOTaxonomyFinance
25 + */
26 + protected static $instance = null;
27 27
28 - /**
29 - * Class constructor is protected.
30 - *
31 - * @see getInstance()
32 - */
33 - protected function __construct()
34 - {
35 - // do nothing
36 - }
28 + /**
29 + * Finance calculation options.
30 + *
31 + * @since 1.18.13 (J) - 1.8.13 (WP)
32 + */
33 + protected array $options = [];
37 34
38 - /**
39 - * Returns the global object, either
40 - * a new instance or the existing instance
41 - * if the class was already instantiated.
42 - *
43 - * @return self A new instance of the class.
44 - */
45 - public static function getInstance()
46 - {
47 - if (is_null(static::$instance)) {
48 - static::$instance = new static();
49 - }
35 + /**
36 + * Class constructor is protected.
37 + *
38 + * @param array $options Options to bind.
39 + *
40 + * @see getInstance()
41 + */
42 + protected function __construct(array $options)
43 + {
44 + // bind options
45 + $this->options = $options;
46 + }
50 47
51 - return static::$instance;
52 - }
48 + /**
49 + * Returns the global object, either a new instance,
50 + * or the previously instantiated instance.
51 + *
52 + * @param ?array $options Optional options to bind.
53 + *
54 + * @return VBOTaxonomyFinance
55 + */
56 + public static function getInstance(?array $options = null)
57 + {
58 + if (is_null(static::$instance)) {
59 + static::$instance = new static((array) $options);
60 + }
53 61
54 - /**
55 - * Returns the number of total units for all rooms, or for a specific room.
56 - * By default, the rooms unpublished are skipped, and all rooms are used.
57 - *
58 - * @param mixed $idroom int or array.
59 - * @param bool $published true or false.
60 - *
61 - * @return int
62 - */
63 - public function countRooms($idroom = 0, $published = true)
64 - {
65 - $dbo = JFactory::getDbo();
62 + return static::$instance;
63 + }
66 64
67 - $totrooms = 0;
68 - $clauses = [];
65 + /**
66 + * Returns the current calculation options.
67 + *
68 + * @return array
69 + *
70 + * @since 1.18.13 (J) - 1.8.13 (WP)
71 + */
72 + public function getOptions()
73 + {
74 + return $this->options;
75 + }
69 76
70 - if (is_int($idroom) && $idroom > 0) {
71 - $clauses[] = "`id`=" . (int)$idroom;
72 - } elseif (is_array($idroom) && count($idroom)) {
73 - $idroom = array_map('intval', $idroom);
74 - $clauses[] = "`id` IN (" . implode(', ', $idroom) . ")";
75 - }
77 + /**
78 + * Binds calculation options.
79 + *
80 + * @param array $options Options to bind.
81 + *
82 + * @return VBOTaxonomyFinance
83 + *
84 + * @since 1.18.13 (J) - 1.8.13 (WP)
85 + */
86 + public function setOptions(array $options)
87 + {
88 + // bind options
89 + $this->options = $options;
76 90
77 - if ($published) {
78 - $clauses[] = "`avail`=1";
79 - }
91 + return $this;
92 + }
80 93
81 - $q = "SELECT SUM(`units`) FROM `#__vikbooking_rooms`" . (count($clauses) ? " WHERE " . implode(' AND ', $clauses) : "");
82 - $dbo->setQuery($q);
83 - $totrooms = (int)$dbo->loadResult();
94 + /**
95 + * Returns the number of total units for all rooms, or for a specific room.
96 + * By default, the rooms unpublished are skipped, and all rooms are used.
97 + *
98 + * @param mixed $idroom int or array.
99 + * @param bool $published true or false.
100 + *
101 + * @return int
102 + */
103 + public function countRooms($idroom = 0, $published = true)
104 + {
105 + $dbo = JFactory::getDbo();
84 106
85 - return $totrooms;
86 - }
107 + $totrooms = 0;
108 + $clauses = [];
87 109
88 - /**
89 - * Counts the number of nights of difference between two timestamps.
90 - *
91 - * @param int $to_ts the target end date timestamp.
92 - * @param int $from_ts the starting date timestamp.
93 - *
94 - * @return int the nights of difference between from and to timestamps.
95 - */
96 - public function countNightsTo($to_ts, $from_ts = 0)
97 - {
98 - if (empty($from_ts)) {
99 - $from_ts = time();
100 - }
110 + if (is_int($idroom) && $idroom > 0) {
111 + $clauses[] = "`id`=" . (int)$idroom;
112 + } elseif (is_array($idroom) && count($idroom)) {
113 + $idroom = array_map('intval', $idroom);
114 + $clauses[] = "`id` IN (" . implode(', ', $idroom) . ")";
115 + }
101 116
102 - $from_ymd = date('Y-m-d', $from_ts);
103 - $to_ymd = date('Y-m-d', $to_ts);
117 + if ($published) {
118 + $clauses[] = "`avail`=1";
119 + }
104 120
105 - if ($from_ymd == $to_ymd) {
106 - return 1;
107 - }
121 + $q = "SELECT SUM(`units`) FROM `#__vikbooking_rooms`" . (count($clauses) ? " WHERE " . implode(' AND ', $clauses) : "");
122 + $dbo->setQuery($q);
123 + $totrooms = (int)$dbo->loadResult();
108 124
109 - $from_date = new DateTime($from_ymd);
110 - $to_date = new DateTime($to_ymd);
111 - $daysdiff = (int)$from_date->diff($to_date)->format('%a');
125 + return $totrooms;
126 + }
112 127
113 - if ($to_ts < $from_ts) {
114 - // we need a negative integer number in this case
115 - $daysdiff = $daysdiff - ($daysdiff * 2);
116 - }
128 + /**
129 + * Counts the number of nights of difference between two timestamps.
130 + *
131 + * @param int $to_ts the target end date timestamp.
132 + * @param int $from_ts the starting date timestamp.
133 + *
134 + * @return int the nights of difference between from and to timestamps.
135 + */
136 + public function countNightsTo($to_ts, $from_ts = 0)
137 + {
138 + if (empty($from_ts)) {
139 + $from_ts = time();
140 + }
117 141
118 - if ($from_ymd != $to_ymd && $daysdiff > 0) {
119 - // the to date is actually another night of stay
120 - $daysdiff += 1;
121 - }
142 + $from_ymd = date('Y-m-d', $from_ts);
143 + $to_ymd = date('Y-m-d', $to_ts);
122 144
123 - return $daysdiff;
124 - }
145 + if ($from_ymd == $to_ymd) {
146 + return 1;
147 + }
125 148
126 - /**
127 - * Helper method to format long currency values into short numbers.
128 - * I.e. 2.600.000 = 2.6M (empty decimals are always removed to keep the string short).
129 - *
130 - * @param int|float $num the amount to format.
131 - * @param int $decimals the precision to use.
132 - *
133 - * @return string the formatted amount string.
134 - */
135 - public function numberFormatShort($num, $decimals = 1)
136 - {
137 - // get global formatting values
138 - $formatvals = VikBooking::getNumberFormatData();
139 - $formatparts = explode(':', $formatvals);
149 + $from_date = new DateTime($from_ymd);
150 + $to_date = new DateTime($to_ymd);
151 + $daysdiff = (int)$from_date->diff($to_date)->format('%a');
140 152
141 - if ($num < 951) {
142 - // 0 - 950
143 - $short_amount = number_format($num, $decimals, $formatparts[1], $formatparts[2]);
144 - $type_amount = '';
145 - } elseif ($num < 900000) {
146 - // 1k - 950k
147 - $short_amount = number_format($num / 1000, $decimals, $formatparts[1], $formatparts[2]);
148 - $type_amount = 'k';
149 - } elseif ($num < 900000000) {
150 - // 0.9m - 950m
151 - $short_amount = number_format($num / 1000000, $decimals, $formatparts[1], $formatparts[2]);
152 - $type_amount = 'm';
153 - } elseif ($num < 900000000000) {
154 - // 0.9b - 950b
155 - $short_amount = number_format($num / 1000000000, $decimals, $formatparts[1], $formatparts[2]);
156 - $type_amount = 'b';
157 - } else {
158 - // >= 0.9t
159 - $short_amount = number_format($num / 1000000000000, $decimals, $formatparts[1], $formatparts[2]);
160 - $type_amount = 't';
161 - }
153 + if ($to_ts < $from_ts) {
154 + // we need a negative integer number in this case
155 + $daysdiff = $daysdiff - ($daysdiff * 2);
156 + }
162 157
163 - // unpad zeroes from the right
164 - if ($decimals > 0) {
165 - while (substr($short_amount, -1, 1) == '0') {
166 - $short_amount = substr($short_amount, 0, strlen($short_amount) - 1);
167 - }
158 + if ($from_ymd != $to_ymd && $daysdiff > 0) {
159 + // the to date is actually another night of stay
160 + $daysdiff += 1;
161 + }
168 162
169 - if (substr($short_amount, -1, 1) == $formatparts[1]) {
170 - // remove also the decimals separator if no more decimals
171 - $short_amount = substr($short_amount, 0, strlen($short_amount) - 1);
172 - }
173 - }
174 -
175 - // return the formatted amount string
176 - return $short_amount . $type_amount;
177 - }
163 + return $daysdiff;
164 + }
178 165
179 - /**
180 - * Calculate the absolute percent amount between the current and previous financial stat.
181 - * This method will use the following calculation: ((A1 - A2) / A2) * 100.
182 - *
183 - * @param float $stat the current (previously calculated) financial amount.
184 - * @param float $compare the previous period financial amount.
185 - * @param int $precision the precision for apply rounding.
186 - *
187 - * @return int|float the absolute percent amount calculated.
188 - */
189 - public function calcAbsPercent($current, $compare, $precision = 1)
190 - {
191 - if ($current == $compare) {
192 - return 0;
193 - }
166 + /**
167 + * Counts the number of days of difference between two timestamps.
168 + *
169 + * @param int $from_ts the starting date timestamp.
170 + * @param int $to_ts the target end date timestamp.
171 + *
172 + * @return int the days of difference from the given dates.
173 + *
174 + * @since 1.16.10 (J) - 1.6.10 (WP)
175 + */
176 + public function countDaysDiff($from_ts, $to_ts)
177 + {
178 + if (empty($from_ts) || empty($to_ts)) {
179 + return 0;
180 + }
194 181
195 - if ($current > $compare && $compare < 1) {
196 - return 100;
197 - }
182 + $from_ymd = date('Y-m-d', $from_ts);
183 + $to_ymd = date('Y-m-d', $to_ts);
198 184
199 - if ($current < $compare && $current < 1) {
200 - return 100;
201 - }
185 + if ($from_ymd == $to_ymd) {
186 + return 0;
187 + }
202 188
203 - return round(abs(($current - $compare) / $compare * 100), $precision);
204 - }
189 + $from_date = new DateTime($from_ymd);
190 + $to_date = new DateTime($to_ymd);
191 + $daysdiff = (int)$from_date->diff($to_date)->format('%a');
205 192
206 - /**
207 - * Obtain booking statistics from a range of dates and an
208 - * optional list of room types. The data obtained will be
209 - * based on the effective nights of stay within the range,
210 - * unless type "booking_dates" to obtain different data.
211 - *
212 - * @param string $from the Y-m-d (or website) date from.
213 - * @param string $to the Y-m-d (or website) date to.
214 - * @param array $rooms list of room IDs to filter.
215 - * @param string $type either "stay_dates" or "booking_dates".
216 - *
217 - * @return array associative list of information.
218 - *
219 - * @throws Exception
220 - */
221 - public function getStats($from, $to, $rooms = [], $type = 'stay_dates')
222 - {
223 - $dbo = JFactory::getDbo();
193 + if ($to_ts < $from_ts) {
194 + // we need a negative integer number in this case
195 + $daysdiff = $daysdiff - ($daysdiff * 2);
196 + }
224 197
225 - // access the availability helper
226 - $av_helper = VikBooking::getAvailabilityInstance();
198 + return $daysdiff;
199 + }
227 200
228 - $from_ts = VikBooking::getDateTimestamp($from, 0, 0);
229 - $to_ts = VikBooking::getDateTimestamp($to, 23, 59, 59);
230 - if (empty($from) || empty($from_ts) || empty($to_ts) || $to_ts < $from_ts) {
231 - throw new Exception('Invalid dates provided', 500);
232 - }
233 201
234 - // total number of days (nights) in the range
235 - $total_range_nights = $this->countNightsTo($to_ts, $from_ts);
236 - if ($total_range_nights < 1) {
237 - throw new Exception('Invalid number of days provided', 500);
238 - }
202 + /**
203 + * Helper method to format long currency values into short numbers.
204 + * I.e. 2.600.000 = 2.6M (empty decimals are always removed to keep the string short).
205 + *
206 + * @param int|float $num the amount to format.
207 + * @param int $decimals the precision to use.
208 + *
209 + * @return string the formatted amount string.
210 + */
211 + public function numberFormatShort($num, $decimals = 1)
212 + {
213 + // get global formatting values
214 + $formatvals = VikBooking::getNumberFormatData();
215 + $formatparts = explode(':', $formatvals);
239 216
240 - // total number of room units
241 - $total_room_units = $this->countRooms($rooms);
217 + if ($num < 951) {
218 + // 0 - 950
219 + $short_amount = number_format($num, $decimals, $formatparts[1], $formatparts[2]);
220 + $type_amount = '';
221 + } elseif ($num < 900000) {
222 + // 1k - 950k
223 + $short_amount = number_format($num / 1000, $decimals, $formatparts[1], $formatparts[2]);
224 + $type_amount = 'k';
225 + } elseif ($num < 900000000) {
226 + // 0.9m - 950m
227 + $short_amount = number_format($num / 1000000, $decimals, $formatparts[1], $formatparts[2]);
228 + $type_amount = 'm';
229 + } elseif ($num < 900000000000) {
230 + // 0.9b - 950b
231 + $short_amount = number_format($num / 1000000000, $decimals, $formatparts[1], $formatparts[2]);
232 + $type_amount = 'b';
233 + } else {
234 + // >= 0.9t
235 + $short_amount = number_format($num / 1000000000000, $decimals, $formatparts[1], $formatparts[2]);
236 + $type_amount = 't';
237 + }
242 238
243 - if ($total_room_units < 1) {
244 - throw new Exception('Having no rooms published may lead to divisions by zero, hence errors.', 500);
245 - }
239 + // unpad zeroes from the right
240 + if ($decimals > 0) {
241 + while (substr($short_amount, -1, 1) == '0') {
242 + $short_amount = substr($short_amount, 0, strlen($short_amount) - 1);
243 + }
246 244
247 - // the associative array of statistics to collect and return
248 - $stats = [
249 - // booking ids involved
250 - 'bids' => [],
251 - // total number of room units counted for stats
252 - 'room_units' => $total_room_units,
253 - // total number of room units times the number of nights in the range
254 - 'tot_inventory' => ($total_room_units * $total_range_nights),
255 - // number of rooms booked
256 - 'rooms_booked' => 0,
257 - // number of bookings found
258 - 'tot_bookings' => 0,
259 - // total number of nights booked (proportionally adjusted according to affected dates)
260 - 'nights_booked' => 0,
261 - // percent value
262 - 'occupancy' => 0,
263 - // average length of stay
264 - 'avg_los' => 0,
265 - // points of sale revenue (revenue divided by each individual ota and ibe)
266 - 'pos_revenue' => [],
267 - // list of countries with ranking
268 - 'country_ranks' => [],
269 - // ibe revenue net
270 - 'ibe_revenue' => 0,
271 - // otas revenue net (no matter if commissions were applied)
272 - 'ota_revenue' => 0,
273 - // total refunded amounts (never deducted)
274 - 'tot_refunds' => 0,
275 - // average daily rate
276 - 'adr' => 0,
277 - // revenue per available room
278 - 'revpar' => 0,
279 - // amount of taxes
280 - 'taxes' => 0,
281 - // commissions (amount)
282 - 'cmms' => 0,
283 - // net revenue before tax (otas + ibe)
284 - 'revenue' => 0,
285 - // gross revenue after tax
286 - 'gross_revenue' => 0,
287 - // otas total before tax (same as ota_revenue, but only if ota commissions were applied)
288 - 'ota_tot_net' => 0,
289 - // otas commissions
290 - 'ota_cmms' => 0,
291 - // percent value for average ota commissions amount
292 - 'ota_avg_cmms' => 0,
293 - // commission savings amount
294 - 'cmm_savings' => 0,
295 - ];
245 + if (substr($short_amount, -1, 1) == $formatparts[1]) {
246 + // remove also the decimals separator if no more decimals
247 + $short_amount = substr($short_amount, 0, strlen($short_amount) - 1);
248 + }
249 + }
250 +
251 + // return the formatted amount string
252 + return $short_amount . $type_amount;
253 + }
296 254
297 - // get all (real/completed) bookings in the given range of dates
298 - $q = $dbo->getQuery(true);
299 - $q->select($dbo->qn([
300 - 'o.id',
301 - 'o.ts',
302 - 'o.days',
303 - 'o.checkin',
304 - 'o.checkout',
305 - 'o.totpaid',
306 - 'o.roomsnum',
307 - 'o.total',
308 - 'o.idorderota',
309 - 'o.channel',
310 - 'o.country',
311 - 'o.tot_taxes',
312 - 'o.tot_city_taxes',
313 - 'o.tot_fees',
314 - 'o.cmms',
315 - 'o.refund',
316 - 'or.idorder',
317 - 'or.idroom',
318 - 'or.optionals',
319 - 'or.cust_cost',
320 - 'or.cust_idiva',
321 - 'or.extracosts',
322 - 'or.room_cost',
323 - 'ob.idbusy',
324 - 'c.country_name',
325 - ]));
326 - $q->select($dbo->qn('b.checkin', 'room_checkin'));
327 - $q->select($dbo->qn('b.checkout', 'room_checkout'));
328 - $q->from($dbo->qn('#__vikbooking_orders', 'o'));
329 - $q->leftjoin($dbo->qn('#__vikbooking_ordersrooms', 'or') . ' ON ' . $dbo->qn('or.idorder') . ' = ' . $dbo->qn('o.id'));
330 - $q->leftjoin($dbo->qn('#__vikbooking_ordersbusy', 'ob') . ' ON ' . $dbo->qn('ob.idorder') . ' = ' . $dbo->qn('o.id'));
331 - $q->leftjoin($dbo->qn('#__vikbooking_busy', 'b') . ' ON ' . $dbo->qn('b.id') . ' = ' . $dbo->qn('ob.idbusy'));
332 - $q->leftjoin($dbo->qn('#__vikbooking_countries', 'c') . ' ON ' . $dbo->qn('c.country_3_code') . ' = ' . $dbo->qn('o.country'));
333 - $q->where($dbo->qn('o.status') . ' = ' . $dbo->q('confirmed'));
334 - $q->where($dbo->qn('o.total') . ' > 0');
335 - $q->where($dbo->qn('o.closure') . ' = 0');
336 - if ($type == 'stay_dates') {
337 - // regular calculation based on stay dates
338 - $q->where($dbo->qn('o.checkout') . ' >= ' . $from_ts);
339 - $q->where($dbo->qn('o.checkin') . ' <= ' . $to_ts);
340 - } else {
341 - // calculation based on booked dates
342 - $q->where($dbo->qn('o.ts') . ' >= ' . $from_ts);
343 - $q->where($dbo->qn('o.ts') . ' <= ' . $to_ts);
344 - }
255 + /**
256 + * Calculate the absolute percent amount between the current and previous financial stat.
257 + * This method will use the following calculation: ((A1 - A2) / A2) * 100.
258 + *
259 + * @param float $stat the current (previously calculated) financial amount.
260 + * @param float $compare the previous period financial amount.
261 + * @param int $precision the precision for apply rounding.
262 + *
263 + * @return int|float the absolute percent amount calculated.
264 + */
265 + public function calcAbsPercent($current, $compare, $precision = 1)
266 + {
267 + if ($current == $compare) {
268 + return 0;
269 + }
345 270
346 - if (is_array($rooms) && $rooms) {
347 - $rooms = array_map('intval', $rooms);
348 - $q->where($dbo->qn('or.idroom') . ' IN (' . implode(', ', $rooms) . ')');
349 - }
271 + if ($current > $compare && $compare < 1) {
272 + return 100;
273 + }
350 274
351 - $q->order($dbo->qn('o.checkin') . ' ASC');
352 - $q->order($dbo->qn('o.id') . ' ASC');
275 + if ($current < $compare && $current < 1) {
276 + return 100;
277 + }
353 278
354 - $dbo->setQuery($q);
355 - $records = $dbo->loadAssocList();
279 + return round(abs(($current - $compare) / $compare * 100), $precision);
280 + }
356 281
357 - if (!$records) {
358 - // no bookings found, do not proceed
359 - return $stats;
360 - }
282 + /**
283 + * Obtain booking statistics from a range of dates and an
284 + * optional list of room types. The data obtained will be
285 + * based on the effective nights of stay within the range,
286 + * unless type "booking_dates" to obtain different data.
287 + *
288 + * @param string $from the Y-m-d (or website) date from.
289 + * @param string $to the Y-m-d (or website) date to.
290 + * @param array $rooms list of room IDs to filter.
291 + * @param string $type one among "stay_dates", "booking_dates" or "checkin".
292 + *
293 + * @return array associative list of information.
294 + *
295 + * @throws Exception
296 + *
297 + * @since 1.18.13 (J) - 1.8.13 (WP) added support to $type = "checkin".
298 + */
299 + public function getStats($from, $to, array $rooms = [], $type = 'stay_dates')
300 + {
301 + $dbo = JFactory::getDbo();
361 302
362 - // nest records with multiple rooms booked inside sub-arrays
363 - $bookings = [];
364 - foreach ($records as $b) {
365 - if (!isset($bookings[$b['id']])) {
366 - $bookings[$b['id']] = [];
367 - }
368 - // calculate the effective from and to stay timestamps for this room (by supporting split stays or early departures/late arrivals)
369 - $room_checkin = !empty($b['room_checkin']) && $b['room_checkin'] != $b['checkin'] ? $b['room_checkin'] : $b['checkin'];
370 - $room_checkout = !empty($b['room_checkout']) && $b['room_checkout'] != $b['checkout'] ? $b['room_checkout'] : $b['checkout'];
371 - $in_info = getdate($room_checkin);
372 - $out_info = getdate($room_checkout);
373 - $b['stay_from_ts'] = mktime(0, 0, 0, $in_info['mon'], $in_info['mday'], $in_info['year']);
374 - $b['stay_to_ts'] = mktime(23, 59, 59, $out_info['mon'], ($out_info['mday'] - 1), $out_info['year']);
375 - $b['stay_nights'] = $room_checkin != $b['checkin'] || $room_checkout != $b['checkout'] ? $av_helper->countNightsOfStay($room_checkin, $room_checkout) : $b['days'];
376 - $b['stay_nights'] = $b['stay_nights'] < 1 ? 1 : $b['stay_nights'];
377 - // push room-booking
378 - $bookings[$b['id']][] = $b;
379 - }
303 + // access the availability helper
304 + $av_helper = VikBooking::getAvailabilityInstance();
380 305
381 - // free memory up
382 - unset($records);
306 + $from_ts = VikBooking::getDateTimestamp($from, 0, 0);
307 + $to_ts = VikBooking::getDateTimestamp($to, 23, 59, 59);
308 + if (empty($from) || empty($from_ts) || empty($to_ts) || $to_ts < $from_ts) {
309 + throw new Exception('Invalid dates provided', 500);
310 + }
383 311
384 - // counters and pools
385 - $los_counter = 0;
386 - $pos_pool = [];
387 - $pos_counter = [];
388 - $countries = [];
389 - $country_map = [];
312 + // total number of days (nights) in the range
313 + $total_range_nights = $this->countNightsTo($to_ts, $from_ts);
314 + if ($total_range_nights < 1) {
315 + throw new Exception('Invalid number of days provided', 500);
316 + }
390 317
391 - // parse all bookings
392 - foreach ($bookings as $bid => $booking) {
393 - // push booking ID
394 - $stats['bids'][] = $bid;
318 + // total number of room units
319 + $total_room_units = $this->countRooms($rooms);
395 320
396 - // increase tot bookings and los counter
397 - $stats['tot_bookings']++;
398 - $los_counter += $booking[0]['days'];
321 + if ($total_room_units < 1) {
322 + throw new Exception('Having no rooms published may lead to divisions by zero, hence errors.', 500);
323 + }
399 324
400 - // use default total values
401 - $booking_rooms = count($booking);
402 - $room_total = $booking[0]['total'];
403 - $room_cmms = $booking[0]['cmms'];
404 - $room_refund = $booking[0]['refund'];
405 - $room_tot_taxes = $booking[0]['tot_taxes'];
406 - $room_tot_city_taxes = $booking[0]['tot_city_taxes'];
407 - $room_tot_fees = $booking[0]['tot_fees'];
325 + // the associative array of statistics to collect and return
326 + $stats = [
327 + // booking ids involved
328 + 'bids' => [],
329 + // total number of room units counted for stats
330 + 'room_units' => $total_room_units,
331 + // total number of room units times the number of nights in the range
332 + 'tot_inventory' => ($total_room_units * $total_range_nights),
333 + // number of rooms booked
334 + 'rooms_booked' => 0,
335 + // number of bookings found
336 + 'tot_bookings' => 0,
337 + // total number of nights booked (proportionally adjusted according to affected dates)
338 + 'nights_booked' => 0,
339 + // percent value
340 + 'occupancy' => 0,
341 + // average length of stay
342 + 'avg_los' => 0,
343 + // points of sale revenue (revenue divided by each individual ota and ibe)
344 + 'pos_revenue' => [],
345 + // list of countries with ranking
346 + 'country_ranks' => [],
347 + // ibe revenue net
348 + 'ibe_revenue' => 0,
349 + // otas revenue net (no matter if commissions were applied)
350 + 'ota_revenue' => 0,
351 + // total refunded amounts (never deducted)
352 + 'tot_refunds' => 0,
353 + // average daily rate
354 + 'adr' => 0,
355 + // revenue per available room
356 + 'revpar' => 0,
357 + // average booking window
358 + 'abw' => 0,
359 + // amount of all taxes
360 + 'taxes' => 0,
361 + // amount of VAT
362 + 'tot_vat' => 0,
363 + // amount of city taxes
364 + 'city_taxes' => 0,
365 + // amount of damage deposits
366 + 'damage_deposits' => 0,
367 + // commissions (amount)
368 + 'cmms' => 0,
369 + // sum of room-level costs or custom costs
370 + 'room_level_revenue' => 0,
371 + // net revenue before tax (otas + ibe)
372 + 'revenue' => 0,
373 + // gross revenue after tax
374 + 'gross_revenue' => 0,
375 + // otas total before tax (same as ota_revenue, but only if ota commissions were applied)
376 + 'ota_tot_net' => 0,
377 + // otas commissions
378 + 'ota_cmms' => 0,
379 + // percent value for average ota commissions amount
380 + 'ota_avg_cmms' => 0,
381 + // commission savings amount
382 + 'cmm_savings' => 0,
383 + // total cancelled reservations
384 + 'tot_cancellations' => 0,
385 + // cancelled reservation IDs
386 + 'cancellation_ids' => [],
387 + // total amount of cancelled bookings
388 + 'cancellations_amt' => 0,
389 + ];
408 390
409 - // point of sale name
410 - $pos_name = null;
391 + if ($this->options['booking_level'] ?? null) {
392 + // include booking-level stats
393 + $stats['_bid_stats'] = [];
394 + }
411 395
412 - // parse all rooms booked
413 - foreach ($booking as $room_booking) {
414 - // increase rooms booked
415 - $stats['rooms_booked']++;
396 + // get all (real/completed) bookings in the given range of dates
397 + $q = $dbo->getQuery(true);
398 + $q->select($dbo->qn([
399 + 'o.id',
400 + 'o.ts',
401 + 'o.status',
402 + 'o.days',
403 + 'o.checkin',
404 + 'o.checkout',
405 + 'o.totpaid',
406 + 'o.coupon',
407 + 'o.roomsnum',
408 + 'o.total',
409 + 'o.idorderota',
410 + 'o.channel',
411 + 'o.country',
412 + 'o.tot_taxes',
413 + 'o.tot_city_taxes',
414 + 'o.tot_fees',
415 + 'o.tot_damage_dep',
416 + 'o.cmms',
417 + 'o.refund',
418 + 'or.idorder',
419 + 'or.idroom',
420 + 'or.optionals',
421 + 'or.cust_cost',
422 + 'or.cust_idiva',
423 + 'or.extracosts',
424 + 'or.room_cost',
425 + 'c.country_name',
426 + ]));
427 + $q->from($dbo->qn('#__vikbooking_orders', 'o'));
428 + $q->leftjoin($dbo->qn('#__vikbooking_ordersrooms', 'or') . ' ON ' . $dbo->qn('or.idorder') . ' = ' . $dbo->qn('o.id'));
429 + $q->leftjoin($dbo->qn('#__vikbooking_countries', 'c') . ' ON ' . $dbo->qn('c.country_3_code') . ' = ' . $dbo->qn('o.country'));
430 + $q->where($dbo->qn('o.total') . ' > 0');
431 + $q->where($dbo->qn('o.closure') . ' = 0');
432 + // use the "andWhere" method after having set some "where" clauses
433 + $q->andWhere([
434 + $dbo->qn('o.status') . ' = ' . $dbo->q('confirmed'),
435 + $dbo->qn('o.status') . ' = ' . $dbo->q('cancelled'),
436 + ], 'OR');
437 + if ($type == 'stay_dates') {
438 + // regular calculation based on stay dates
439 + $q->where($dbo->qn('o.checkout') . ' >= ' . $from_ts);
440 + $q->where($dbo->qn('o.checkin') . ' <= ' . $to_ts);
441 + } elseif ($type == 'checkin') {
442 + // calculation based on check-in date
443 + $q->where($dbo->qn('o.checkin') . ' >= ' . $from_ts);
444 + $q->where($dbo->qn('o.checkin') . ' <= ' . $to_ts);
445 + } else {
446 + // calculation based on booked dates
447 + $q->where($dbo->qn('o.ts') . ' >= ' . $from_ts);
448 + $q->where($dbo->qn('o.ts') . ' <= ' . $to_ts);
449 + }
416 450
417 - // number of nights affected
418 - $los_affected = $room_booking['stay_nights'];
451 + /**
452 + * Do not filter the room booking records by room ID, but always join them, to improve accuracy with calculations.
453 + *
454 + * @since 1.18.0 (J) - 1.8.0 (WP)
455 + */
456 + if ($rooms) {
457 + // $q->where($dbo->qn('or.idroom') . ' IN (' . implode(', ', array_map('intval', $rooms)) . ')');
458 + }
419 459
420 - // check if amounts must be calculated proportionally for the range of dates requested
421 - if ($type == 'stay_dates' && ($room_booking['stay_from_ts'] < $from_ts || $room_booking['stay_to_ts'] > $to_ts)) {
422 - // calculate number of nights of stay affected
423 - $los_affected = $this->countNightsAffected($from_ts, $to_ts, $room_booking['stay_from_ts'], $room_booking['stay_to_ts']);
460 + $q->order($dbo->qn('o.checkin') . ' ASC');
461 + $q->order($dbo->qn('o.id') . ' ASC');
462 + $q->order($dbo->qn('or.id') . ' ASC');
424 463
425 - // adjust the amounts proportionally
426 - $room_total = $room_total * $los_affected / $room_booking['stay_nights'];
427 - $room_cmms = $room_cmms * $los_affected / $room_booking['stay_nights'];
428 - $room_refund = $room_refund * $los_affected / $room_booking['stay_nights'];
429 - $room_tot_taxes = $room_tot_taxes * $los_affected / $room_booking['stay_nights'];
430 - $room_tot_city_taxes = $room_tot_city_taxes * $los_affected / $room_booking['stay_nights'];
431 - $room_tot_fees = $room_tot_fees * $los_affected / $room_booking['stay_nights'];
432 - }
464 + $dbo->setQuery($q);
465 + $records = $dbo->loadAssocList();
433 466
434 - // apply average values per room booked
435 - $room_total /= $booking_rooms;
436 - $room_cmms /= $booking_rooms;
437 - $room_refund /= $booking_rooms;
438 - $room_tot_taxes /= $booking_rooms;
439 - $room_tot_city_taxes /= $booking_rooms;
440 - $room_tot_fees /= $booking_rooms;
467 + if (!$records) {
468 + // no bookings found, do not proceed
469 + return $stats;
470 + }
441 471
442 - // calculate and sum average values per room booked
443 - $tot_net = $room_total - (float)$room_tot_taxes - (float)$room_tot_city_taxes - (float)$room_tot_fees - (float)$room_cmms;
444 - $stats['revenue'] += $tot_net;
445 - $stats['gross_revenue'] += $room_total;
446 - $stats['nights_booked'] += $los_affected;
472 + /**
473 + * Join the busy records afterwards to support accurate calculations for split stays or early departures/late arrivals.
474 + * This is also needed to support multi-room reservations without joining the busy records together with the room records.
475 + *
476 + * @since 1.18.0 (J) - 1.8.0 (WP)
477 + */
478 + $busy_joined = [];
479 + foreach ($records as &$booking) {
480 + if (strcasecmp($booking['status'], 'confirmed')) {
481 + // we only want to target confirmed bookings
482 + continue;
483 + }
447 484
448 - // increase country stats
449 - $country_code = !empty($room_booking['country']) ? $room_booking['country'] : 'unknown';
450 - if (!isset($countries[$country_code])) {
451 - $countries[$country_code] = 0;
452 - if (!empty($room_booking['country_name'])) {
453 - $country_map[$country_code] = $room_booking['country_name'];
454 - }
455 - }
456 - $countries[$country_code] += $tot_net;
485 + // build cache signature for busy records joined
486 + $booking_room_signature = $booking['id'] . '-' . $booking['idroom'];
457 487
458 - if (!empty($room_booking['idorderota']) && !empty($room_booking['channel'])) {
459 - $stats['ota_revenue'] += $tot_net;
460 - if ($room_cmms > 0) {
461 - $stats['ota_tot_net'] += $tot_net;
462 - $stats['ota_cmms'] += $room_cmms;
463 - }
464 - // set pos name
465 - $channel_parts = explode('_', $room_booking['channel']);
466 - $pos_name = trim($channel_parts[0]);
467 - } else {
468 - $stats['ibe_revenue'] += $tot_net;
469 - // set pos name
470 - $pos_name = 'website';
471 - }
488 + // build the db query
489 + $dbo->setQuery(
490 + $dbo->getQuery(true)
491 + ->select($dbo->qn('ob.idbusy'))
492 + ->select($dbo->qn('b.checkin', 'room_checkin'))
493 + ->select($dbo->qn('b.checkout', 'room_checkout'))
494 + ->from($dbo->qn('#__vikbooking_ordersbusy', 'ob'))
495 + ->leftjoin($dbo->qn('#__vikbooking_busy', 'b') . ' ON ' . $dbo->qn('b.id') . ' = ' . $dbo->qn('ob.idbusy'))
496 + ->where($dbo->qn('ob.idorder') . ' = ' . (int) $booking['id'])
497 + ->where($dbo->qn('b.idroom') . ' = ' . (int) $booking['idroom'])
498 + ->order($dbo->qn('b.id') . ' ASC')
499 + );
472 500
473 - // set pos net revenue
474 - if (!isset($pos_pool[$pos_name])) {
475 - $pos_pool[$pos_name] = 0;
476 - }
477 - $pos_pool[$pos_name] += $tot_net;
501 + // either fetch the records or use the previously cached ones to support multi-room bookings with equal room IDs
502 + $busy_joined[$booking_room_signature] = $busy_joined[$booking_room_signature] ?? $dbo->loadAssocList();
478 503
479 - $stats['taxes'] += (float)$room_tot_taxes + (float)$room_tot_city_taxes + (float)$room_tot_fees;
480 - $stats['cmms'] += (float)$room_cmms;
481 - $stats['tot_refunds'] += $room_refund;
482 - }
504 + // get the busy record to process for the current booking-room by shifting the list
505 + $process_busy = array_shift($busy_joined[$booking_room_signature]);
483 506
484 - if ($pos_name) {
485 - // increase number of bookings for this pos
486 - if (!isset($pos_counter[$pos_name])) {
487 - $pos_counter[$pos_name] = 0;
488 - }
489 - $pos_counter[$pos_name]++;
490 - }
491 - }
507 + if (!$process_busy) {
508 + continue;
509 + }
492 510
493 - // count the average length of stay (no proportional data for the dates requested)
494 - $stats['avg_los'] = $stats['tot_bookings'] > 0 ? round($los_counter / $stats['tot_bookings'], 1) : 0;
511 + // inject busy details
512 + $booking['idbusy'] = $process_busy['idbusy'];
513 + $booking['room_checkin'] = $process_busy['room_checkin'];
514 + $booking['room_checkout'] = $process_busy['room_checkout'];
515 + }
495 516
496 - // calculate occupancy percent value
497 - $stats['occupancy'] = round(($stats['nights_booked'] * 100 / ($total_room_units * $total_range_nights)), 2);
517 + // unset last reference and cached values
518 + unset($booking, $busy_joined);
498 519
499 - // count the average daily rate (ADR)
500 - $stats['adr'] = $stats['rooms_booked'] > 0 ? $stats['revenue'] / $stats['rooms_booked'] / $total_range_nights : 0;
520 + /**
521 + * Immediately count cancellations and unset the records found.
522 + *
523 + * @since 1.16.10 (J) - 1.6.10 (WP)
524 + */
525 + foreach ($records as $k => $b) {
526 + if (!strcasecmp($b['status'], 'cancelled')) {
527 + if (!in_array($b['id'], $stats['cancellation_ids'])) {
528 + // add statistics for the cancelled booking only once
529 + $stats['tot_cancellations']++;
530 + $stats['cancellations_amt'] += (float) $b['total'];
531 + $stats['cancellation_ids'][] = $b['id'];
532 + }
501 533
502 - // count the revenue per available room (RevPAR)
503 - $stats['revpar'] = $stats['revenue'] / $total_room_units;
534 + // get rid of this record to not alter the regular statistics
535 + unset($records[$k]);
536 + }
537 + }
504 538
505 - // count OTAs average commission amount
506 - if ($stats['ota_tot_net'] > 0 && $stats['ota_cmms'] > 0) {
507 - // find the average percent value of OTA commissions (tot_net : tot_cmms = 100 : x)
508 - $stats['ota_avg_cmms'] = round(($stats['ota_cmms'] * 100 / $stats['ota_tot_net']), 2);
539 + if ($stats['cancellation_ids']) {
540 + // reset key values
541 + $records = array_values($records);
542 + }
509 543
510 - if ($stats['ibe_revenue'] > 0) {
511 - // calculate the commission savings amount
512 - $stats['cmm_savings'] = $stats['ibe_revenue'] * $stats['ota_avg_cmms'] / 100;
513 - }
514 - }
544 + // nest records with multiple rooms booked inside sub-arrays
545 + $bookings = [];
546 + foreach ($records as $b) {
547 + if (!isset($bookings[$b['id']])) {
548 + $bookings[$b['id']] = [];
549 + }
515 550
516 - // get channel logos helper
517 - $vcm_logos = VikBooking::getVcmChannelsLogo('', true);
551 + // calculate the effective from and to stay timestamps for this room (by supporting split stays or early departures/late arrivals)
552 + $room_checkin = !empty($b['room_checkin']) && $b['room_checkin'] != $b['checkin'] ? $b['room_checkin'] : $b['checkin'];
553 + $room_checkout = !empty($b['room_checkout']) && $b['room_checkout'] != $b['checkout'] ? $b['room_checkout'] : $b['checkout'];
554 + $in_info = getdate($room_checkin);
555 + $out_info = getdate($room_checkout);
556 + $b['stay_from_ts'] = mktime(0, 0, 0, $in_info['mon'], $in_info['mday'], $in_info['year']);
557 + $b['stay_to_ts'] = mktime(23, 59, 59, $out_info['mon'], ($out_info['mday'] - 1), $out_info['year']);
558 + $b['stay_nights'] = $room_checkin != $b['checkin'] || $room_checkout != $b['checkout'] ? $av_helper->countNightsOfStay($room_checkin, $room_checkout) : $b['days'];
559 + $b['stay_nights'] = $b['stay_nights'] < 1 ? 1 : $b['stay_nights'];
518 560
519 - // sort and build pos revenues
520 - if (count($pos_pool) && $stats['revenue'] > 0) {
521 - // apply sorting descending
522 - arsort($pos_pool);
523 - // build readable pos values
524 - foreach ($pos_pool as $pos_name => $pos_revenue) {
525 - $pos_data = [
526 - 'name' => $pos_name,
527 - 'revenue' => $pos_revenue,
528 - 'pcent' => round(($pos_revenue * 100 / $stats['revenue']), 2),
529 - 'logo' => null,
530 - 'bookings' => isset($pos_counter[$pos_name]) ? $pos_counter[$pos_name] : 0,
531 - 'ibe' => false,
532 - 'ota' => false,
533 - ];
534 - if (!strcasecmp($pos_name, 'website')) {
535 - // ibe revenue
536 - $pos_data['name'] = JText::translate('VBORDFROMSITE');
537 - $pos_data['ibe'] = true;
538 - } else {
539 - // ota revenue
540 - $pos_data['ota'] = true;
541 - if (is_object($vcm_logos)) {
542 - $ota_logo_img = $vcm_logos->setProvenience($pos_name)->getSmallLogoURL();
543 - if ($ota_logo_img !== false) {
544 - $pos_data['logo'] = $ota_logo_img;
545 - }
546 - }
547 - }
548 - // push pos data
549 - $stats['pos_revenue'][] = $pos_data;
550 - }
551 - }
561 + // push room-booking
562 + $bookings[$b['id']][] = $b;
563 + }
552 564
553 - // sort countries revenue
554 - if (count($countries) && $stats['revenue'] > 0) {
555 - // apply sorting descending
556 - arsort($countries);
557 - // build readable values
558 - foreach ($countries as $country_code => $country_revenue) {
559 - // push country data
560 - $stats['country_ranks'][] = [
561 - 'code' => $country_code,
562 - 'name' => (isset($country_map[$country_code]) ? $country_map[$country_code] : $country_code),
563 - 'revenue' => $country_revenue,
564 - 'pcent' => round(($country_revenue * 100 / $stats['revenue']), 2),
565 - ];
566 - }
567 - }
565 + // free memory up
566 + unset($records);
568 567
569 - // return the statistics information
570 - return $stats;
571 - }
568 + // counters and pools
569 + $los_counter = 0;
570 + $pos_pool = [];
571 + $pos_counter = [];
572 + $countries = [];
573 + $country_map = [];
572 574
573 - /**
574 - * Counts the number of nights involved in a range of dates. This is
575 - * useful to proportionally calculate the amounts to be used.
576 - *
577 - * @param int $from_ts the 00:00:00 timestamp of the range start date.
578 - * @param int $to_ts the 23:59:59 timestamp of the range end date.
579 - * @param int $in_ts the 00:00:00 timestamp of the check-in date.
580 - * @param int $out_ts the 23:59:59 timestamp of the last night of stay (check-out -1).
581 - *
582 - * @return int the number of stay nights involved in the range.
583 - */
584 - protected function countNightsAffected($from_ts, $to_ts, $in_ts, $out_ts)
585 - {
586 - $nights_affected = 0;
575 + // sum of booking window
576 + $sum_booking_window = 0;
587 577
588 - if ($from_ts > $to_ts) {
589 - return $nights_affected;
590 - }
578 + // parse all bookings
579 + foreach ($bookings as $bid => $booking) {
580 + // increase tot bookings and los counter
581 + if (!$rooms || ($rooms && array_intersect(array_column($booking, 'idroom'), $rooms))) {
582 + // make sure to increase counters only if filtered listing(s) involved or if no filters set
583 + $stats['tot_bookings']++;
584 + $los_counter += $booking[0]['days'];
585 + // push booking ID involved
586 + $stats['bids'][] = $bid;
587 + }
591 588
592 - $range_from_info = getdate($from_ts);
593 - while ($range_from_info[0] < $to_ts) {
594 - if ($range_from_info[0] >= $in_ts && $range_from_info[0] <= $out_ts) {
595 - $nights_affected++;
596 - }
597 - // next day iteration
598 - $range_from_info = getdate(mktime(0, 0, 0, $range_from_info['mon'], ($range_from_info['mday'] + 1), $range_from_info['year']));
599 - }
589 + // count rooms booked within the reservation
590 + $booking_rooms = count($booking);
600 591
601 - return $nights_affected;
602 - }
592 + // define the total booking amount for multi-room reservations
593 + $multi_room_total = 0;
594 +
595 + // define room-level revenue
596 + $room_level_revenue = array_sum(
597 + array_filter(
598 + array_map(
599 + 'floatval',
600 + array_merge(
601 + array_column($booking, 'cust_cost'),
602 + array_column($booking, 'room_cost')
603 + )
604 + )
605 + )
606 + );
607 +
608 + if ($rooms && $booking[0]['roomsnum'] > 1) {
609 + // when filters applied to multi-room bookings, count the effective number of rooms involved
610 + $booking_rooms = count(array_intersect(array_column($booking, 'idroom'), $rooms));
611 +
612 + // overwrite room total amount when filtering by listing(s)
613 + foreach ($booking as $room_booking) {
614 + if (!in_array($room_booking['idroom'], $rooms)) {
615 + continue;
616 + }
617 + if ($room_booking['cust_cost'] > 0) {
618 + $multi_room_total += $room_booking['cust_cost'];
619 + } elseif ($room_booking['room_cost'] > 0) {
620 + $multi_room_total += $room_booking['room_cost'];
621 + }
622 + }
623 + }
624 +
625 + // point of sale name
626 + $pos_name = null;
627 +
628 + // parse all rooms booked
629 + foreach ($booking as $room_booking) {
630 + if ($rooms && !in_array($room_booking['idroom'], $rooms)) {
631 + // room booked is excluded from filters
632 + continue;
633 + }
634 +
635 + // increase rooms booked
636 + $stats['rooms_booked']++;
637 +
638 + // number of nights affected
639 + $los_affected = $room_booking['stay_nights'];
640 +
641 + // use default total values
642 + $room_total = $multi_room_total ?: $room_booking['total'];
643 + $room_level_total = $room_level_revenue;
644 + $room_cmms = $room_booking['cmms'];
645 + $room_refund = $room_booking['refund'];
646 + $room_tot_taxes = $room_booking['tot_taxes'];
647 + $room_tot_city_taxes = $room_booking['tot_city_taxes'];
648 + $room_tot_damage_dep = $room_booking['tot_damage_dep'];
649 + $room_tot_fees = $room_booking['tot_fees'];
650 +
651 + // check if amounts must be calculated proportionally for the range of dates requested
652 + if ($type == 'stay_dates' && ($room_booking['stay_from_ts'] < $from_ts || $room_booking['stay_to_ts'] > $to_ts)) {
653 + // calculate number of nights of stay affected
654 + $los_affected = $this->countNightsAffected($from_ts, $to_ts, $room_booking['stay_from_ts'], $room_booking['stay_to_ts']);
655 +
656 + // adjust the amounts proportionally
657 + $room_total = $room_total * $los_affected / $room_booking['stay_nights'];
658 + $room_level_total = $room_level_total * $los_affected / $room_booking['stay_nights'];
659 + $room_cmms = $room_cmms * $los_affected / $room_booking['stay_nights'];
660 + $room_refund = $room_refund * $los_affected / $room_booking['stay_nights'];
661 + $room_tot_taxes = $room_tot_taxes * $los_affected / $room_booking['stay_nights'];
662 + $room_tot_city_taxes = $room_tot_city_taxes * $los_affected / $room_booking['stay_nights'];
663 + $room_tot_damage_dep = $room_tot_damage_dep * $los_affected / $room_booking['stay_nights'];
664 + $room_tot_fees = $room_tot_fees * $los_affected / $room_booking['stay_nights'];
665 + }
666 +
667 + // apply average values per room booked (with filters or booked in total)
668 + $room_total /= $booking_rooms;
669 + $room_level_total /= $booking_rooms;
670 + $room_cmms /= $booking_rooms;
671 + $room_refund /= $booking_rooms;
672 + $room_tot_taxes /= $booking_rooms;
673 + $room_tot_city_taxes /= $booking_rooms;
674 + $room_tot_damage_dep /= $room_booking['roomsnum'];
675 + $room_tot_fees /= $booking_rooms;
676 +
677 + // calculate and sum average values per room booked
678 + $tot_net = $multi_room_total ?: ($room_total - (float) $room_tot_taxes - (float) $room_tot_city_taxes - (float) $room_tot_fees - (float) $room_tot_damage_dep - (float) $room_cmms);
679 + $tot_revenue = $multi_room_total ? ($tot_net / $booking_rooms) : $tot_net;
680 + $stats['room_level_revenue'] += $room_level_total;
681 + $stats['revenue'] += $tot_revenue;
682 + $stats['gross_revenue'] += $room_total;
683 + $stats['nights_booked'] += $los_affected;
684 +
685 + // increase booking window for this room-booking
686 + $booking_window = $this->countDaysDiff($room_booking['ts'], $room_booking['stay_from_ts']);
687 + $sum_booking_window += $booking_window >= 0 ? $booking_window : 0;
688 +
689 + // increase country stats
690 + $country_code = !empty($room_booking['country']) ? $room_booking['country'] : 'unknown';
691 + if (!isset($countries[$country_code])) {
692 + $countries[$country_code] = 0;
693 + if (!empty($room_booking['country_name'])) {
694 + $country_map[$country_code] = $room_booking['country_name'];
695 + }
696 + }
697 + $countries[$country_code] += $tot_revenue;
698 +
699 + if (!empty($room_booking['idorderota']) && !empty($room_booking['channel'])) {
700 + $stats['ota_revenue'] += $tot_revenue;
701 + if ($room_cmms > 0) {
702 + $stats['ota_tot_net'] += $tot_revenue;
703 + $stats['ota_cmms'] += $room_cmms;
704 + }
705 + // set pos name
706 + $channel_parts = explode('_', $room_booking['channel']);
707 + $pos_name = trim($channel_parts[0]);
708 + } else {
709 + $stats['ibe_revenue'] += $tot_revenue;
710 + // set pos name
711 + $pos_name = 'website';
712 + }
713 +
714 + // set pos net revenue
715 + if (!isset($pos_pool[$pos_name])) {
716 + $pos_pool[$pos_name] = 0;
717 + }
718 + $pos_pool[$pos_name] += $tot_revenue;
719 +
720 + $stats['taxes'] += (float) $room_tot_taxes + (float) $room_tot_city_taxes + (float) $room_tot_fees;
721 + $stats['tot_vat'] += (float) $room_tot_taxes;
722 + $stats['city_taxes'] += (float) $room_tot_city_taxes;
723 + $stats['damage_deposits'] += (float) $room_tot_damage_dep;
724 + $stats['cmms'] += (float) $room_cmms;
725 + $stats['tot_refunds'] += $room_refund;
726 +
727 + /**
728 + * Set booking-level stats.
729 + *
730 + * @since 1.18.13 (J) - 1.8.13 (WP)
731 + */
732 + if ($this->options['booking_level'] ?? null) {
733 + $stats['_bid_stats'][$bid] = $stats['_bid_stats'][$bid] ?? [
734 + 'room_level_revenue' => 0,
735 + 'revenue' => 0,
736 + 'gross_revenue' => 0,
737 + 'taxes' => 0,
738 + 'tot_vat' => 0,
739 + 'city_taxes' => 0,
740 + 'damage_deposits' => 0,
741 + 'cmms' => 0,
742 + 'tot_refunds' => 0,
743 + ];
744 + $stats['_bid_stats'][$bid]['room_level_revenue'] += $room_level_total;
745 + $stats['_bid_stats'][$bid]['revenue'] += $tot_revenue;
746 + $stats['_bid_stats'][$bid]['gross_revenue'] += $room_total;
747 + $stats['_bid_stats'][$bid]['taxes'] += (float) $room_tot_taxes + (float) $room_tot_city_taxes + (float) $room_tot_fees;
748 + $stats['_bid_stats'][$bid]['tot_vat'] += (float) $room_tot_taxes;
749 + $stats['_bid_stats'][$bid]['city_taxes'] += (float) $room_tot_city_taxes;
750 + $stats['_bid_stats'][$bid]['damage_deposits'] += (float) $room_tot_damage_dep;
751 + $stats['_bid_stats'][$bid]['cmms'] += (float) $room_cmms;
752 + $stats['_bid_stats'][$bid]['tot_refunds'] += $room_refund;
753 + }
754 + }
755 +
756 + if ($pos_name) {
757 + // increase number of bookings for this pos
758 + if (!isset($pos_counter[$pos_name])) {
759 + $pos_counter[$pos_name] = 0;
760 + }
761 + $pos_counter[$pos_name]++;
762 + }
763 + }
764 +
765 + // count the average length of stay (no proportional data for the dates requested)
766 + $stats['avg_los'] = $stats['tot_bookings'] > 0 ? round($los_counter / $stats['tot_bookings'], 1) : 0;
767 +
768 + // calculate occupancy percent value
769 + $stats['occupancy'] = round(($stats['nights_booked'] * 100 / ($total_room_units * $total_range_nights)), 2);
770 +
771 + // count the average daily rate (ADR)
772 + $stats['adr'] = $stats['rooms_booked'] > 0 ? $stats['revenue'] / $stats['rooms_booked'] / $total_range_nights : 0;
773 +
774 + // count the revenue per available room (RevPAR)
775 + $stats['revpar'] = $stats['revenue'] / $total_room_units;
776 +
777 + // count the average booking window (ABW - number of days between the reservation date and the check-in date)
778 + $stats['abw'] = $stats['rooms_booked'] > 0 ? $sum_booking_window / $stats['rooms_booked'] : 0;
779 +
780 + // count OTAs average commission amount
781 + if ($stats['ota_tot_net'] > 0 && $stats['ota_cmms'] > 0) {
782 + // find the average percent value of OTA commissions (tot_net : tot_cmms = 100 : x)
783 + $stats['ota_avg_cmms'] = round(($stats['ota_cmms'] * 100 / $stats['ota_tot_net']), 2);
784 +
785 + if ($stats['ibe_revenue'] > 0) {
786 + // calculate the commission savings amount
787 + $stats['cmm_savings'] = $stats['ibe_revenue'] * $stats['ota_avg_cmms'] / 100;
788 + }
789 + }
790 +
791 + // get channel logos helper
792 + $vcm_logos = VikBooking::getVcmChannelsLogo('', true);
793 +
794 + // sort and build pos revenues
795 + if ($pos_pool && $stats['revenue'] > 0) {
796 + // apply sorting descending
797 + arsort($pos_pool);
798 + // build readable pos values
799 + foreach ($pos_pool as $pos_name => $pos_revenue) {
800 + $pos_data = [
801 + 'name' => $pos_name,
802 + 'revenue' => $pos_revenue,
803 + 'pcent' => round(($pos_revenue * 100 / $stats['revenue']), 2),
804 + 'logo' => null,
805 + 'bookings' => isset($pos_counter[$pos_name]) ? $pos_counter[$pos_name] : 0,
806 + 'ibe' => false,
807 + 'ota' => false,
808 + ];
809 + if (!strcasecmp($pos_name, 'website')) {
810 + // ibe revenue
811 + $pos_data['name'] = JText::translate('VBORDFROMSITE');
812 + $pos_data['ibe'] = true;
813 + } else {
814 + // ota revenue
815 + $pos_data['ota'] = true;
816 + if (is_object($vcm_logos)) {
817 + $ota_logo_img = $vcm_logos->setProvenience($pos_name)->getSmallLogoURL();
818 + if ($ota_logo_img !== false) {
819 + $pos_data['logo'] = $ota_logo_img;
820 + }
821 + }
822 + }
823 + // push pos data
824 + $stats['pos_revenue'][] = $pos_data;
825 + }
826 + }
827 +
828 + // sort countries revenue
829 + if ($countries && $stats['revenue'] > 0) {
830 + // apply sorting descending
831 + arsort($countries);
832 + // build readable values
833 + foreach ($countries as $country_code => $country_revenue) {
834 + // push country data
835 + $stats['country_ranks'][] = [
836 + 'code' => $country_code,
837 + 'name' => (isset($country_map[$country_code]) ? $country_map[$country_code] : $country_code),
838 + 'revenue' => $country_revenue,
839 + 'pcent' => round(($country_revenue * 100 / $stats['revenue']), 2),
840 + ];
841 + }
842 + }
843 +
844 + // return the statistics information
845 + return $stats;
846 + }
847 +
848 + /**
849 + * Returns the information of the given rate plan ID.
850 + *
851 + * @param int $rplan_id The rate plan ID.
852 + *
853 + * @return ?array
854 + *
855 + * @since 1.18.2 (J) - 1.8.2 (WP)
856 + */
857 + public function getRatePlanData(int $rplan_id)
858 + {
859 + if (empty($rplan_id)) {
860 + return null;
861 + }
862 +
863 + $dbo = JFactory::getDbo();
864 +
865 + $dbo->setQuery(
866 + $dbo->getQuery(true)
867 + ->select('*')
868 + ->from($dbo->qn('#__vikbooking_prices'))
869 + ->where($dbo->qn('id') . ' = ' . $rplan_id)
870 + );
871 +
872 + return $dbo->loadAssoc();
873 + }
874 +
875 + /**
876 + * Counts the number of nights involved in a range of dates. This is
877 + * useful to proportionally calculate the amounts to be used.
878 + *
879 + * @param int $from_ts the 00:00:00 timestamp of the range start date.
880 + * @param int $to_ts the 23:59:59 timestamp of the range end date.
881 + * @param int $in_ts the 00:00:00 timestamp of the check-in date.
882 + * @param int $out_ts the 23:59:59 timestamp of the last night of stay (check-out -1).
883 + *
884 + * @return int the number of stay nights involved in the range.
885 + */
886 + protected function countNightsAffected($from_ts, $to_ts, $in_ts, $out_ts)
887 + {
888 + $nights_affected = 0;
889 +
890 + if ($from_ts > $to_ts) {
891 + return $nights_affected;
892 + }
893 +
894 + $range_from_info = getdate($from_ts);
895 + while ($range_from_info[0] < $to_ts) {
896 + if ($range_from_info[0] >= $in_ts && $range_from_info[0] <= $out_ts) {
897 + $nights_affected++;
898 + }
899 + // next day iteration
900 + $range_from_info = getdate(mktime(0, 0, 0, $range_from_info['mon'], ($range_from_info['mday'] + 1), $range_from_info['year']));
901 + }
902 +
903 + return $nights_affected;
904 + }
603 905 }