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 / performance / cleaner.php

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

621 lines 22.5 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) 2024 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 * Performance Cleaner implementation.
16 *
17 * @since 1.17.2 (J) - 1.7.2 (WP)
18 */
19 final class VBOPerformanceCleaner
20 {
21 /**
22 * @var array
23 */
24 private static $options = [];
25
26 /**
27 * Sets a list of given options to filter certain cleaning operations.
28 *
29 * @param array $options List of options to set.
30 *
31 * @return void
32 */
33 public static function setOptions(array $options)
34 {
35 static::$options = $options;
36 }
37
38 /**
39 * Performs a global check on what needs to be done to clean up performances.
40 *
41 * @return int The number of database records that were cleaned up.
42 */
43 public static function runCheck()
44 {
45 // list of operations that should be skipped
46 $skip_checks = VBOFactory::getConfig()->getArray('performance_cleaner_skip_list', []);
47
48 // number of records affected
49 $affected_records = 0;
50
51 if (!in_array('seasons', $skip_checks)) {
52 // clean up expired seasonal records (expired at least 7 days ago)
53 $affected_records += self::pricingAlterations(7);
54
55 // clean up ghost records
56 $affected_records += self::ghostRecords();
57 }
58
59 return $affected_records;
60 }
61
62 /**
63 * Performs a general database optimization.
64 *
65 * @param ?array $options Optional optimization options.
66 *
67 * @return array Optimization results.
68 *
69 * @since 1.18.5 (J) - 1.8.5 (WP)
70 */
71 public static function optimizeDatabase(?array $options = null)
72 {
73 // operation results pool
74 $results = [];
75
76 // check whether database optimization should run
77 $min_ts = mktime(date('G'), 0, 0, date('n'), date('j'), date('Y'));
78 $max_ts = mktime(date('G'), 59, 59, date('n'), date('j'), date('Y'));
79 $scheduled_ts = 0;
80 if ($db_opt_time = VBOFactory::getConfig()->get('dboptimizetime')) {
81 $time_parts = explode(':', $db_opt_time);
82 $scheduled_ts = mktime((int) $time_parts[0], (int) ($time_parts[1] ?? 0), 0, date('n'), date('j'), date('Y'));
83 }
84
85 if (!($options['forced'] ?? 0) && (!$scheduled_ts || !($scheduled_ts >= $min_ts && $scheduled_ts <= $max_ts))) {
86 // execution not allowed at all or at this time
87 return $results;
88 }
89
90 // check, at this point, if some custom optimization options were set
91 if ($custom_options = VBOFactory::getConfig()->getArray('db_optimize_options', [])) {
92 // merge default options with custom ones
93 $options = array_merge(($options ?: []), $custom_options);
94 }
95
96 // define script max execution time
97 ignore_user_abort(true);
98 ini_set('max_execution_time', (int) ($options['max_execution_time'] ?? 600));
99 set_time_limit((int) ($options['max_execution_time'] ?? 600));
100
101 // operations time start
102 $microStart = microtime(true);
103
104 // list of database optimization operations
105 $db_operations = [
106 'listings_global_snapshot' => function($options) {
107 // get rid of redundant pricing alteration records by performing a global listing snapshot
108 return static::listingsGlobalSnapshot($options);
109 },
110 'delete_old_guest_messages' => function($options) {
111 // get rid of the "old" guest messages
112 return static::deleteOldGuestMessages($options);
113 },
114 ];
115
116 foreach ($db_operations as $operation_name => $operation_callback) {
117 if (isset($options['ignore_' . $operation_name])) {
118 // skip this operation
119 continue;
120 }
121
122 try {
123 // execute the operation callback
124 $results[$operation_name] = $operation_callback($options);
125 } catch (Exception $e) {
126 // push the error
127 $results[$operation_name] = $e;
128 }
129 }
130
131 // operations time end
132 $microEnd = microtime(true);
133
134 // set the total execution time in seconds
135 $results['_durationSeconds'] = round(($microEnd - $microStart) / 1000, 2);
136
137 if (!($options['ignore_notification'] ?? 0)) {
138 // store an entry within the notifications center
139 try {
140 VBOFactory::getNotificationCenter()
141 ->store([
142 [
143 'sender' => 'website',
144 'type' => 'info',
145 'title' => JText::translate('VBO_MAINTENANCE'),
146 'summary' => JText::sprintf('VBO_DB_OPTIM_DURATION', $results['_durationSeconds']),
147 ],
148 ]);
149 } catch (Exception $e) {
150 // do nothing
151 }
152 }
153
154 // return the pool of results
155 return $results;
156 }
157
158 /**
159 * Performs a global rates snapshot over all listings.
160 *
161 * @param ?array $options Optional execution options.
162 *
163 * @return array Snapshot results.
164 *
165 * @since 1.18.5 (J) - 1.8.5 (WP)
166 */
167 public static function listingsGlobalSnapshot(?array $options = null)
168 {
169 // get all listings
170 $listingsData = VikBooking::getAvailabilityInstance()->loadRooms($options['listing_ids'] ?? [], $max = 0, $anew = true);
171
172 // filter out the unpublished ones, if any
173 $listingsData = array_filter($listingsData, function($listing) {
174 return !empty($listing['avail']);
175 });
176
177 // shuffle the elements
178 shuffle($listingsData);
179
180 // build snapshot data
181 $snapshotData = [
182 'listing_id' => null,
183 'id_price' => $options['id_price'] ?? null,
184 'from_date' => $options['from_date'] ?? date('Y-m-d'),
185 'to_date' => $options['to_date'] ?? date('Y-m-d', strtotime(sprintf('+%d months', (int) ($options['months'] ?? 3)))),
186 'skip_derived' => boolval($options['skip_derived'] ?? true),
187 'use_cache' => true,
188 'forced' => true,
189 ];
190
191 // processing results
192 $processingResults = [];
193
194 // process listings
195 foreach ($listingsData as $listing) {
196 try {
197 // inject options
198 static::setOptions(
199 array_merge(
200 $snapshotData,
201 [
202 'listing_id' => $listing['id'],
203 ]
204 )
205 );
206
207 // perform listing rates snapshot
208 $snapshotResult = static::listingSeasonSnapshot();
209
210 // process the result
211 if ($options['full_response'] ?? null) {
212 // set the full snapshot response
213 $processingResults[$listing['id']] = $snapshotResult;
214 } else {
215 // include only errors, if any
216 $processingResults[$listing['id']] = array_column($snapshotResult, 'errors');
217 }
218 } catch (Exception $e) {
219 $processingResults[$listing['id']] = $e->getMessage();
220 }
221 }
222
223 return $processingResults;
224 }
225
226 /**
227 * Deletes the guest messages that belong to past reservations.
228 *
229 * @param ?array $options Optional execution options.
230 *
231 * @return array Deletion results.
232 *
233 * @since 1.18.5 (J) - 1.8.5 (WP)
234 */
235 public static function deleteOldGuestMessages(?array $options = null)
236 {
237 $dbo = JFactory::getDbo();
238
239 $results = [
240 '_threadsDeleted' => 0,
241 '_messagesDeleted' => 0,
242 ];
243
244 try {
245 // get all the "expired" threads
246 $dbo->setQuery(
247 $dbo->getQuery(true)
248 ->select([
249 $dbo->qn('t.id'),
250 $dbo->qn('t.idorder'),
251 $dbo->qn('o.checkout'),
252 ])
253 ->from($dbo->qn('#__vikchannelmanager_threads', 't'))
254 ->leftJoin($dbo->qn('#__vikbooking_orders', 'o') . ' ON ' . $dbo->qn('t.idorder') . ' = ' . $dbo->qn('o.id'))
255 ->where($dbo->qn('o.checkout') . ' < ' . strtotime(sprintf('-%d months', (int) ($options['past_months'] ?? 9))))
256 );
257
258 $threads = $dbo->loadAssocList();
259
260 if (!$threads) {
261 // nothing to clean
262 return $results;
263 }
264
265 // delete guest messages
266 $dbo->setQuery(
267 $dbo->getQuery(true)
268 ->delete($dbo->qn('#__vikchannelmanager_threads_messages'))
269 ->where($dbo->qn('idthread') . ' IN (' . implode(', ', array_map('intval', array_column($threads, 'id'))) . ')')
270 );
271 $dbo->execute();
272
273 $results['_messagesDeleted'] += (int) $dbo->getAffectedRows();
274
275 // delete threads
276 $dbo->setQuery(
277 $dbo->getQuery(true)
278 ->delete($dbo->qn('#__vikchannelmanager_threads'))
279 ->where($dbo->qn('id') . ' IN (' . implode(', ', array_map('intval', array_column($threads, 'id'))) . ')')
280 );
281 $dbo->execute();
282
283 $results['_threadsDeleted'] += (int) $dbo->getAffectedRows();
284 } catch (Exception $e) {
285 // do nothing
286 }
287
288 return $results;
289 }
290
291 /**
292 * Cleans up expired season pricing records.
293 *
294 * @param ?int $pastDays Optional days in the past to use as target date.
295 *
296 * @return int The number of rows affected.
297 *
298 * @since 1.18.4 (J) - 1.8.4 (WP) added argument $pastDays.
299 */
300 public static function pricingAlterations(?int $pastDays = null)
301 {
302 $dbo = JFactory::getDbo();
303
304 $affected = 0;
305
306 $nowinfo = is_int($pastDays) ? getdate(strtotime(sprintf('-%d days', abs($pastDays)))) : getdate();
307
308 $year_base = mktime(0, 0, 0, 1, 1, $nowinfo['year']);
309 $midnight_base = ($nowinfo['hours'] * 3600) + ($nowinfo['minutes'] * 60) + $nowinfo['seconds'];
310
311 $season_secs = $nowinfo[0] - $year_base - $midnight_base;
312
313 $isleap = $nowinfo['year'] % 4 == 0 && ($nowinfo['year'] % 100 != 0 || $nowinfo['year'] % 400 == 0);
314
315 if ($isleap) {
316 $leapts = mktime(0, 0, 0, 2, 29, $nowinfo['year']);
317 if ($nowinfo[0] >= $leapts) {
318 $season_secs -= 86400;
319 }
320 }
321
322 // delete the records of the past year
323 $dbo->setQuery(
324 $dbo->getQuery(true)
325 ->delete($dbo->qn('#__vikbooking_seasons'))
326 ->where($dbo->qn('year') . ' = ' . $dbo->q(($nowinfo['year'] - 1)))
327 );
328
329 $dbo->execute();
330
331 $affected += (int) $dbo->getAffectedRows();
332
333 // delete the expired records for the current year
334 $dbo->setQuery(
335 $dbo->getQuery(true)
336 ->delete($dbo->qn('#__vikbooking_seasons'))
337 ->where($dbo->qn('from') . ' < ' . $season_secs)
338 ->where($dbo->qn('to') . ' < ' . $season_secs)
339 ->where($dbo->qn('from') . ' < ' . $dbo->qn('to'))
340 ->where($dbo->qn('from') . ' > 0')
341 ->where($dbo->qn('to') . ' > 0')
342 ->where($dbo->qn('year') . ' = ' . $dbo->q($nowinfo['year']))
343 );
344
345 $dbo->execute();
346
347 $affected += (int) $dbo->getAffectedRows();
348
349 return $affected;
350 }
351
352 /**
353 * Identifies and cleans up ghost records occupying listings with non existing bookings.
354 * If the E4jConnect Channel Manager is available, an auto bulk-action is triggered.
355 *
356 * @return int The number of rows affected.
357 *
358 * @since 1.17.7 (J) - 1.7.7 (WP)
359 */
360 public static function ghostRecords()
361 {
362 $dbo = JFactory::getDbo();
363
364 $affected = 0;
365
366 // identify the room-busy relations whose booking IDs are empty
367 $dbo->setQuery(
368 $dbo->getQuery(true)
369 ->select($dbo->qn('idbusy'))
370 ->from($dbo->qn('#__vikbooking_ordersbusy'))
371 ->where(1)
372 ->andWhere([
373 $dbo->qn('idorder') . ' = 0',
374 $dbo->qn('idorder') . ' IS NULL',
375 ], 'OR')
376 );
377
378 // set involved busy record IDs, if any (column is `idbusy`)
379 $hanging_busy_ids = array_column($dbo->loadAssocList(), 'idbusy');
380
381 // identify the occupied records whose busy relations have empty booking IDs
382 $dbo->setQuery(
383 $dbo->getQuery(true)
384 ->select($dbo->qn('b') . '.*')
385 ->select($dbo->qn('ob.idorder'))
386 ->select($dbo->qn('o.id', 'res_id'))
387 ->from($dbo->qn('#__vikbooking_busy', 'b'))
388 ->leftJoin($dbo->qn('#__vikbooking_ordersbusy', 'ob') . ' ON ' . $dbo->qn('b.id') . ' = ' . $dbo->qn('ob.idbusy'))
389 ->leftJoin($dbo->qn('#__vikbooking_orders', 'o') . ' ON ' . $dbo->qn('ob.idorder') . ' = ' . $dbo->qn('o.id'))
390 ->where($dbo->qn('b.checkout') . ' >= ' . time())
391 ->andWhere([
392 $dbo->qn('ob.idorder') . ' = 0',
393 $dbo->qn('ob.idorder') . ' IS NULL',
394 $dbo->qn('o.id') . ' IS NULL',
395 ], 'OR')
396 );
397
398 $ghost_records = $dbo->loadAssocList();
399
400 // merge involved busy record IDs, if any (column is `id`)
401 $hanging_busy_ids = array_merge($hanging_busy_ids, array_column($ghost_records, 'id'));
402
403 // map to integer and filter involved busy record IDs for removal
404 $hanging_busy_ids = array_values(array_unique(array_filter(array_map('intval', $hanging_busy_ids))));
405
406 if ($hanging_busy_ids) {
407 // delete records involved
408 $dbo->setQuery(
409 $dbo->getQuery(true)
410 ->delete($dbo->qn('#__vikbooking_busy'))
411 ->where($dbo->qn('id') . ' IN (' . implode(', ', $hanging_busy_ids) . ')')
412 );
413
414 $dbo->execute();
415
416 $affected += (int) $dbo->getAffectedRows();
417 }
418
419 if ($ghost_records && class_exists('VikChannelManager')) {
420 // ghost records were removed, but OTAs may require a sync of availability
421 $listing_times_pool = [];
422 foreach ($ghost_records as $ghost_record) {
423 if (empty($ghost_record['idroom']) || empty($ghost_record['checkin']) || empty($ghost_record['checkout'])) {
424 continue;
425 }
426 if (!isset($listing_times_pool[$ghost_record['idroom']])) {
427 $listing_times_pool[$ghost_record['idroom']] = [];
428 }
429 // push listing check-in and check-out date times involved
430 $listing_times_pool[$ghost_record['idroom']][] = $ghost_record['checkin'];
431 $listing_times_pool[$ghost_record['idroom']][] = $ghost_record['checkout'];
432 }
433
434 $min_ts = 0;
435 $max_ts = 0;
436 foreach ($listing_times_pool as $listing_id => $listing_times) {
437 $min_ts = $min_ts ? min(min($listing_times), $min_ts) : min($listing_times);
438 $max_ts = $max_ts ? max(max($listing_times), $max_ts) : max($listing_times);
439 }
440
441 if ($min_ts && $max_ts) {
442 // no past dates allowed
443 $min_ts = $min_ts < time() ? time() : $min_ts;
444
445 // prepare the options for an auto bulk-action of type "availability"
446 VikChannelManager::autoBulkActions([
447 'from_date' => date('Y-m-d', $min_ts),
448 'to_date' => date('Y-m-d', $max_ts),
449 'forced_rooms' => array_keys($listing_times_pool),
450 'update' => 'availability',
451 ]);
452 }
453 }
454
455 // return the number of affected records
456 return $affected;
457 }
458
459 /**
460 * Performs a pricing snapshot of a listing for a range of dates, by cleaning up
461 * all previous seasonal rates and by re-creating only the needed records. Useful
462 * for those listings who had hundreds of pricing alteration updates for the same
463 * calendar day. Strongly recommended only for those who use a SINGLE rate plan.
464 *
465 * @return array Seasons snapshot operation results.
466 *
467 * @throws Exception
468 *
469 * @since 1.18.3 (J) - 1.8.3 (WP) performance skip list preferences applied.
470 */
471 public static function listingSeasonSnapshot()
472 {
473 $dbo = JFactory::getDbo();
474
475 $listing_id = (int) (static::$options['listing_id'] ?? null);
476 $id_price = (int) (static::$options['id_price'] ?? null);
477 $from_date = static::$options['from_date'] ?? date('Y-m-d');
478 $to_date = static::$options['to_date'] ?? date('Y-m-d', strtotime('+3 months'));
479 $skip_derived = (bool) (static::$options['skip_derived'] ?? true);
480 $use_cache = (bool) (static::$options['use_cache'] ?? false);
481
482 if (!$listing_id) {
483 throw new Exception('Missing required listing ID.', 400);
484 }
485
486 if (!$from_date || !$to_date || strtotime($from_date) > strtotime($to_date)) {
487 throw new Exception('Invalid dates provided.', 400);
488 }
489
490 // list of operations that should be skipped
491 $skip_checks = VBOFactory::getConfig()->getArray('performance_cleaner_skip_list', []);
492
493 // ensure season records cleaning is not disabled, unless forced
494 if (in_array('seasons', $skip_checks) && !(static::$options['forced'] ?? false)) {
495 // abort the process
496 return [];
497 }
498
499 // obtain a pricing snapshot for the given listing and dates
500 // given rate plan ID or main one will be used (single rate plan only)
501 $snapshot = VBOModelPricing::getInstance()->getRoomRates([
502 'from_date' => $from_date,
503 'to_date' => $to_date,
504 'id_room' => $listing_id,
505 'id_price' => $id_price,
506 'all_rplans' => false,
507 'restrictions' => false,
508 'use_cache' => $use_cache,
509 ]);
510
511 // confirm the rate plan ID
512 foreach ($snapshot as $dayrate) {
513 $id_price = $dayrate['idprice'];
514 break;
515 }
516
517 // gather the list of season records to remove
518 $involved_seasons = [];
519 foreach ($snapshot as $dayrate) {
520 foreach ($dayrate['spids'] ?? [] as $sp_id) {
521 if (!in_array($sp_id, $involved_seasons)) {
522 $involved_seasons[] = $sp_id;
523 }
524 }
525 }
526
527 if (!$involved_seasons) {
528 throw new Exception('No seasonal records to clean for the given listing and dates.', 406);
529 }
530
531 // clean up database records
532 $dbo->setQuery(
533 $dbo->getQuery(true)
534 ->delete($dbo->qn('#__vikbooking_seasons'))
535 ->where($dbo->qn('id') . ' IN (' . implode(',', array_map('intval', $involved_seasons)) . ')')
536 );
537 $dbo->execute();
538
539 $records_removed = (int) $dbo->getAffectedRows();
540
541 // build new season intervals from snapshot
542 $all_days = array_keys($snapshot);
543 $season_intervals = [];
544 $firstind = 0;
545 $firstdaycost = $snapshot[$all_days[0]]['cost'];
546 $nextdaycost = false;
547 for ($i = 1; $i < count($all_days); $i++) {
548 $ind = $all_days[$i];
549 $nextdaycost = $snapshot[$ind]['cost'];
550 if ($firstdaycost != $nextdaycost) {
551 $interval = [
552 'from' => $all_days[$firstind],
553 'to' => $all_days[($i - 1)],
554 'cost' => $firstdaycost
555 ];
556 $season_intervals[] = $interval;
557 $firstdaycost = $nextdaycost;
558 $firstind = $i;
559 }
560 }
561 if ($nextdaycost === false) {
562 $interval = [
563 'from' => $all_days[$firstind],
564 'to' => $all_days[$firstind],
565 'cost' => $firstdaycost
566 ];
567 $season_intervals[] = $interval;
568 } elseif ($firstdaycost == $nextdaycost) {
569 $interval = [
570 'from' => $all_days[$firstind],
571 'to' => $all_days[($i - 1)],
572 'cost' => $firstdaycost
573 ];
574 $season_intervals[] = $interval;
575 }
576
577 // list of errors occurred
578 $errors = [];
579
580 /**
581 * Attempt to preload and cache seasons for other dates, so that applying new rates
582 * will be faster. We cannot preload these dates, because seasons were just removed.
583 */
584 VikBooking::preloadSeasonRecords([$listing_id], strtotime($to_date), strtotime('+1 month', strtotime($to_date)));
585
586 // scan all season intervals
587 foreach ($season_intervals as $season_snap) {
588 try {
589 // set the new rate for this calculated interval
590 VBOModelPricing::getInstance([
591 'from_date' => $season_snap['from'],
592 'to_date' => $season_snap['to'],
593 'id_room' => $listing_id,
594 'id_price' => $id_price,
595 'rate' => $season_snap['cost'],
596 'min_los' => 0,
597 'max_los' => 0,
598 'update_otas' => false,
599 'skip_derived' => $skip_derived,
600 'use_cache' => $use_cache,
601 ])->modifyRateRestrictions();
602 } catch (Exception $e) {
603 // silently push the error
604 $errors[] = sprintf('%s - %s: %s', $season_snap['from'], $season_snap['to'], $e->getMessage());
605 }
606 }
607
608 // unset preloaded and cached seasons
609 VikBooking::preloadSeasonRecords([$listing_id], false);
610
611 // return values
612 return [
613 'listing_id' => $listing_id,
614 'new_intervals' => count($season_intervals),
615 'records_removed' => $records_removed,
616 'intervals' => $season_intervals,
617 'errors' => $errors,
618 ];
619 }
620 }
621