PluginProbe
VikBooking Hotel Booking Engine & PMS / 1.8.15
VikBooking Hotel Booking Engine & PMS v1.8.15
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 / rates / registry.php

registry.php in VikBooking Hotel Booking Engine & PMS 1.8.15, at admin/helpers/src/rms/rates/registry.php

485 lines 18.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 Rates Registry implementation.
16 *
17 * @since 1.18.6 (J) - 1.8.6 (WP)
18 */
19 final class VBORmsRatesRegistry
20 {
21 /**
22 * @var array
23 */
24 private array $options = [];
25
26 /**
27 * @var array
28 */
29 private array $flowRecords = [];
30
31 /**
32 * @var array
33 */
34 private array $ratePlansList = [];
35
36 /**
37 * @var int
38 */
39 private $mainRatePlanId = 0;
40
41 /**
42 * Construct the registry by binding the options.
43 *
44 * @param array $options Registry options to bind.
45 */
46 public function __construct(array $options)
47 {
48 // bind options
49 $this->options = $options;
50
51 // identify the main rate plan ID across all listings
52 $this->ratePlansList = VikBooking::getAvailabilityInstance(true)->loadRatePlans(true);
53 foreach ($this->ratePlansList as $rplan) {
54 $this->mainRatePlanId = $rplan['id'] ?? 0;
55 break;
56 }
57 }
58
59 /**
60 * Preloads the rates flow records according to the options.
61 *
62 * @return self
63 */
64 public function preloadFlowRecords(): VBORmsRatesRegistry
65 {
66 $dbo = JFactory::getDbo();
67
68 $q = $dbo->getQuery(true)
69 ->select([
70 $dbo->qn('day_from'),
71 $dbo->qn('day_to'),
72 $dbo->qn('vbo_room_id'),
73 $dbo->qn('nightly_fee'),
74 $dbo->qn('created_on'),
75 ])
76 ->from($dbo->qn('#__vikchannelmanager_rates_flow'))
77 // filter by channel/platform (website)
78 ->where($dbo->qn('channel_id') . ' = -1')
79 // filter by rate plan ID to ensure accurate values
80 ->where($dbo->qn('vbo_price_id') . ' = ' . $this->mainRatePlanId)
81 // filter by pickup date
82 ->where($dbo->qn('created_on') . ' <= ' . $dbo->q(date('Y-m-d 23:59:59', strtotime($this->options['pickup']['date'] ?? date('Y-m-d')))))
83 // filter by target (stay) dates
84 ->where($dbo->qn('day_from') . ' <= ' . $dbo->q(date('Y-m-d', $this->options['target']['to_ts'] ?? 0)))
85 ->where($dbo->qn('day_to') . ' >= ' . $dbo->q(date('Y-m-d', $this->options['target']['from_ts'] ?? 0)))
86 // sort records by creation date and range start date
87 ->order($dbo->qn('created_on') . ' DESC')
88 ->order($dbo->qn('day_from') . ' ASC');
89
90 if (!($this->options['no_nightly_fee'] ?? 0)) {
91 // make sure the records fetched will have a nightly fee value set (exclude restrictions update-only)
92 $q->where($dbo->qn('nightly_fee') . ' IS NOT NULL');
93 }
94
95 if ($this->options['listings'] ?? []) {
96 // filter by specific listing IDs
97 $q->where($dbo->qn('vbo_room_id') . ' IN (' . implode(', ', array_map('intval', $this->options['listings'])) . ')');
98 }
99
100 try {
101 // attempt to load records from database
102 $dbo->setQuery($q);
103 $this->flowRecords = $dbo->loadAssocList();
104 } catch (Exception $e) {
105 // do nothing
106 }
107
108 return $this;
109 }
110
111 /**
112 * Loads the OTA rates flow records and data according to the options.
113 *
114 * @return array
115 *
116 * @throws Exception
117 */
118 public function loadOtaFlowRecords(): array
119 {
120 if (empty($this->options['from_date'])) {
121 $this->options['from_date'] = date('Y-m-d');
122 }
123
124 if (empty($this->options['to_date'])) {
125 // calculate the end date by number of days
126 $this->options['to_date'] = date('Y-m-d', strtotime(sprintf('+%d days', (int) ($this->options['days'] ?? 2) - 1), strtotime($this->options['from_date'])));
127 }
128
129 $dbo = JFactory::getDbo();
130
131 $q = $dbo->getQuery(true)
132 ->select([
133 $dbo->qn('day_from'),
134 $dbo->qn('day_to'),
135 $dbo->qn('channel_id'),
136 $dbo->qn('vbo_room_id'),
137 $dbo->qn('vbo_price_id'),
138 $dbo->qn('nightly_fee'),
139 $dbo->qn('created_on'),
140 ])
141 ->from($dbo->qn('#__vikchannelmanager_rates_flow'))
142 // filter by channel/platform (no website)
143 ->where($dbo->qn('channel_id') . ' > 0')
144 // filter by stay dates
145 ->where($dbo->qn('day_from') . ' <= ' . $dbo->q($this->options['to_date']))
146 ->where($dbo->qn('day_to') . ' >= ' . $dbo->q($this->options['from_date']))
147 // sort records by creation date and range start date
148 ->order($dbo->qn('created_on') . ' DESC')
149 ->order($dbo->qn('day_from') . ' ASC');
150
151 if ($this->options['id_price'] ?? 0) {
152 // filter by exact rate plan ID
153 $q->where($dbo->qn('vbo_price_id') . ' = ' . (int) $this->options['id_price']);
154 } elseif ($this->options['use_main_rate'] ?? 0) {
155 // filter by main rate plan ID
156 $q->where($dbo->qn('vbo_price_id') . ' = ' . $this->mainRatePlanId);
157 }
158
159 if (!($this->options['no_nightly_fee'] ?? 0)) {
160 // make sure the records fetched will have a nightly fee value set (exclude restrictions update-only)
161 $q->where($dbo->qn('nightly_fee') . ' IS NOT NULL');
162 }
163
164 if ($this->options['id_rooms'] ?? []) {
165 // filter by specific listing IDs
166 $q->where($dbo->qn('vbo_room_id') . ' IN (' . implode(', ', array_map('intval', (array) $this->options['id_rooms'])) . ')');
167 }
168
169 try {
170 // attempt to load records from database
171 $dbo->setQuery($q);
172 $flowRecords = $dbo->loadAssocList();
173 } catch (Exception $e) {
174 // propagate the error
175 throw $e;
176 }
177
178 if (!$flowRecords) {
179 // no records found
180 return [];
181 }
182
183 // get the list of listings involved
184 $involvedListingIds = array_map('intval', array_values(array_unique(array_column($flowRecords, 'vbo_room_id'))));
185
186 // get the list of rate plan IDs involved
187 $involvedRateIds = array_map('intval', array_values(array_unique(array_column($flowRecords, 'vbo_price_id'))));
188
189 // get the list of channel IDs involved
190 $involvedChannelIds = array_values(array_unique(array_column($flowRecords, 'channel_id')));
191
192 // sort channels by importance and alphabetically
193 $otasImportance = [
194 VikChannelManagerConfig::AIRBNBAPI,
195 VikChannelManagerConfig::BOOKING,
196 VikChannelManagerConfig::EXPEDIA,
197 VikChannelManagerConfig::VRBOAPI,
198 ];
199 usort($involvedChannelIds, function($a, $b) use ($otasImportance) {
200 $aRank = in_array($a, $otasImportance) ? (int) array_search($a, $otasImportance) : 100;
201 $bRank = in_array($b, $otasImportance) ? (int) array_search($b, $otasImportance) : 100;
202 return $aRank <=> $bRank;
203 });
204
205 // list of channels requiring net rates (pricing before tax)
206 $netRateOtas = [
207 VikChannelManagerConfig::AIRBNBAPI,
208 VikChannelManagerConfig::VRBOAPI,
209 ];
210
211 // pricing tax policy (included or excluded)
212 $pricingTaxInclusive = VikBooking::ivaInclusa();
213 $handleVat = $this->options['handle_vat'] ?? 1;
214
215 // obtain the iterable dates period
216 $datePeriod = VBORmsPace::getInstance()->getDatePeriodInterval(strtotime($this->options['from_date']), strtotime($this->options['to_date']), 'P1D');
217
218 // build the list of OTA rates flow records
219 $otaflowRecords = [];
220
221 // scan the involved listing IDs
222 foreach ($involvedListingIds as $listingId) {
223 // scan the involved rate plan IDs
224 foreach ($involvedRateIds as $ratePlanId) {
225 // tell whether the rate plan is tax eligible
226 $taxEligible = !empty($this->ratePlansList[$ratePlanId]['idiva']);
227 // scan the involved channel IDs
228 foreach ($involvedChannelIds as $channelId) {
229 // build listing flow container
230 $listingRecords = [
231 'id_room' => $listingId,
232 'id_price' => $ratePlanId,
233 'id_channel' => $channelId,
234 'rates' => [],
235 ];
236
237 // count matches for the current listing, rate plan and channel
238 $totMatches = 0;
239
240 // iterate all stay date intervals
241 foreach ($datePeriod as $period) {
242 // match the last flow record for the current date, listing, rate plan and channel
243 $matchRecord = $this->matchPeriodLastFlowRecord($flowRecords, $period, 'DAY', $listingId, function($record) use ($ratePlanId, $channelId) {
244 if ($record['vbo_price_id'] != $ratePlanId) {
245 // rate plan mismatch
246 return false;
247 }
248 if ($record['channel_id'] != $channelId) {
249 // channel mismatch
250 return false;
251 }
252 // record is valid
253 return true;
254 });
255
256 if ($matchRecord) {
257 // increase counter
258 $totMatches++;
259 }
260
261 // calculate OTA nightly rate, if any
262 $otaNightlyRate = $matchRecord ? (floatval($matchRecord['nightly_fee'] ?? 0)) : null;
263 if ($otaNightlyRate && $taxEligible && $handleVat) {
264 // check if rate needs to be adjusted by VAT/GST
265 if ($pricingTaxInclusive && in_array($channelId, $netRateOtas)) {
266 // add VAT/GST to nightly rate
267 $otaNightlyRate = VikBooking::sayPackagePlusIva($otaNightlyRate, ($this->ratePlansList[$ratePlanId]['idiva'] ?? 0), true);
268 } elseif (!$pricingTaxInclusive && !in_array($channelId, $netRateOtas)) {
269 // deduct VAT/GST from nightly rate
270 $otaNightlyRate = VikBooking::sayPackageMinusIva($otaNightlyRate, ($this->ratePlansList[$ratePlanId]['idiva'] ?? 0), true);
271 }
272 }
273
274 // set OTA nightly rate (null if no matching records found)
275 $listingRecords['rates'][$period->format('Y-m-d')] = $otaNightlyRate;
276 }
277
278 if ($totMatches) {
279 // it is safe to push the current listing flow container
280 $otaflowRecords[] = $listingRecords;
281 }
282 }
283 }
284 }
285
286 if (!$otaflowRecords) {
287 // no records found
288 return [];
289 }
290
291 // obtain the OTAs data
292 $otasData = [];
293 $vcm_logos = VikBooking::getVcmChannelsLogo('', true);
294 foreach ($involvedChannelIds as $channelId) {
295 // build channel default data
296 $channelData = [
297 'id' => $channelId,
298 'name' => $channelId,
299 'logo' => null,
300 ];
301
302 // get channel details
303 $channelInfo = VikChannelManager::getChannel($channelId);
304
305 if ($channelInfo) {
306 // update proper name
307 $channelData['name'] = (string) ($channelInfo['name'] ?? '') ?: $channelData['name'];
308 $channelData['name'] = preg_replace('/api$/', '', $channelData['name']);
309 $channelData['name'] = preg_replace('/^(google)(hotel|vr)$/', '$1 $2', $channelData['name']);
310 $channelData['name'] = ucwords($channelData['name']);
311
312 // attempt to find the channel logo URL
313 $ch_logo_url = $vcm_logos ? $vcm_logos->setProvenience($channelInfo['name'], $channelInfo['name'])->getTinyLogoURL() : '';
314 $channelData['logo'] = $ch_logo_url ?: null;
315 }
316
317 // set channel data
318 $otasData[$channelId] = $channelData;
319 }
320
321 // return the list of OTA flow records and data, if any
322 return [
323 'data' => $otasData,
324 'records' => $otaflowRecords,
325 ];
326 }
327
328 /**
329 * Returns the current flow records.
330 *
331 * @param bool $ascending True for ascending ordering.
332 *
333 * @return array
334 */
335 public function getFlowRecords(bool $ascending = false): array
336 {
337 if ($ascending) {
338 return array_reverse($this->flowRecords);
339 }
340
341 return $this->flowRecords;
342 }
343
344 /**
345 * Returns the main rate plan ID under evaluation.
346 *
347 * @return int
348 */
349 public function getMainRatePlanId(): int
350 {
351 return $this->mainRatePlanId;
352 }
353
354 /**
355 * Updates the current options through merging.
356 *
357 * @param array $options Associative list to merge.
358 *
359 * @return self
360 */
361 public function setOptions(array $options): VBORmsRatesRegistry
362 {
363 $this->options = array_merge($this->options, $options);
364
365 return $this;
366 }
367
368 /**
369 * Given a period date and interval type, matches the last flow
370 * record and returns its creation date and time object.
371 *
372 * @param DateTimeInterface $period The data period under evaluation.
373 * @param string $intervalType The data evaluation interval type.
374 * @param ?array $listingIds Optional list of listing IDs to filter.
375 *
376 * @return ?DateTimeInterface
377 */
378 public function matchPeriodLastFlowDate(DateTimeInterface $period, string $intervalType, ?array $listingIds = null)
379 {
380 // get the timestamps at midnight for the evaluation date
381 $dt = clone $period;
382 $dt->modify('00:00:00');
383 $tsFrom = $dt->format('U');
384 $tsTo = $tsFrom;
385
386 if ($intervalType === 'MONTH') {
387 $tsTo = strtotime(date('Y-m-t', $tsFrom));
388 }
389
390 foreach ($this->getFlowRecords() as $flowRecord) {
391 if ($listingIds && !in_array($flowRecord['vbo_room_id'], $listingIds)) {
392 // ignore flow record
393 continue;
394 }
395
396 if (strtotime($flowRecord['day_from']) <= $tsTo && strtotime($flowRecord['day_to']) >= $tsFrom) {
397 // intersection found, return the date object for the record creation
398 return new DateTime($flowRecord['created_on'], new DateTimeZone(date_default_timezone_get()));
399 }
400 }
401
402 return null;
403 }
404
405 /**
406 * Given a period date, interval type and listing, matches the last nightly rate applied.
407 *
408 * @param DateTimeInterface $period The data period under evaluation.
409 * @param string $intervalType The data evaluation interval type.
410 * @param int $listingId Listing IDs to match.
411 *
412 * @return ?float
413 */
414 public function matchPeriodLastNightlyRate(DateTimeInterface $period, string $intervalType, int $listingId)
415 {
416 // get the timestamps at midnight for the evaluation date
417 $dt = clone $period;
418 $dt->modify('00:00:00');
419 $tsFrom = $dt->format('U');
420 $tsTo = $tsFrom;
421
422 if ($intervalType === 'MONTH') {
423 $tsTo = strtotime(date('Y-m-t', $tsFrom));
424 }
425
426 foreach ($this->getFlowRecords() as $flowRecord) {
427 if ($flowRecord['vbo_room_id'] != $listingId) {
428 // ignore flow record
429 continue;
430 }
431
432 if (strtotime($flowRecord['day_from']) <= $tsTo && strtotime($flowRecord['day_to']) >= $tsFrom) {
433 // intersection found, return the record nightly rate
434 return (float) ($flowRecord['nightly_fee'] ?? 0);
435 }
436 }
437
438 return null;
439 }
440
441 /**
442 * Given a list of records, period date, interval type and listing, matches the last flow record applied.
443 *
444 * @param array $records The flow records list to evaluate.
445 * @param DateTimeInterface $period The data period under evaluation.
446 * @param string $intervalType The data evaluation interval type.
447 * @param int $listingId Listing IDs to match.
448 * @param ?callable $callFilter Optional callback to filter the record.
449 *
450 * @return ?array
451 */
452 public function matchPeriodLastFlowRecord(array $records, DateTimeInterface $period, string $intervalType, int $listingId, ?callable $callFilter = null)
453 {
454 // get the timestamps at midnight for the evaluation date
455 $dt = clone $period;
456 $dt->modify('00:00:00');
457 $tsFrom = $dt->format('U');
458 $tsTo = $tsFrom;
459
460 if ($intervalType === 'MONTH') {
461 $tsTo = strtotime(date('Y-m-t', $tsFrom));
462 }
463
464 foreach ($records as $flowRecord) {
465 if ($flowRecord['vbo_room_id'] != $listingId) {
466 // ignore flow record
467 continue;
468 }
469
470 if (strtotime($flowRecord['day_from']) <= $tsTo && strtotime($flowRecord['day_to']) >= $tsFrom) {
471 // intersection found, check for custom filter validation (i.e. specific channel ID)
472 if ($callFilter && !call_user_func_array($callFilter, [$flowRecord])) {
473 // ignore flow record due to negative filter validation
474 continue;
475 }
476
477 // return the record found
478 return $flowRecord;
479 }
480 }
481
482 return null;
483 }
484 }
485