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 / booking / subunit / matrix.php

matrix.php in VikBooking Hotel Booking Engine & PMS 1.8.15, at admin/helpers/src/booking/subunit/matrix.php

620 lines 21.6 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) 2026 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 * Room-Booking sub-unit matrix implementation.
16 *
17 * @since 1.18.7 (J) - 1.8.7 (WP)
18 */
19 final class VBOBookingSubunitMatrix
20 {
21 /**
22 * @var array
23 */
24 private array $options = [];
25
26 /**
27 * @var ?VBOBookingRegistry
28 */
29 private ?VBOBookingRegistry $registry = null;
30
31 /**
32 * @var array
33 */
34 private array $daysUnitsMap = [];
35
36 /**
37 * @var array
38 */
39 private array $records = [];
40
41 /**
42 * @var ?VBOBookingSubunitRecord
43 */
44 private ?VBOBookingSubunitRecord $relocateRecord = null;
45
46 /**
47 * @var ?DatePeriod
48 */
49 private ?DatePeriod $recordsDatePeriod = null;
50
51 /**
52 * @var int
53 */
54 private int $fittingSolutionsCount = 0;
55
56 /**
57 * Generates all possible combinations to be assigned to a list of items, from the minimum index to the
58 * maximum index. Calculated values for each matrix container are applied over the provided items list.
59 * Returns an iterable (Generator) object that will provide a container with the items assigned values.
60 * For every item in the list, the Base-N is calculated to count the possible combinations.
61 * Base-N = maxIndex - minIndex + 1. Possible combinations = Base-N^itemsCount.
62 *
63 * @param array $items List of item objects/arrays for which combinations will be calculated.
64 * @param int $minIndex The minimum combination value to assign to each item.
65 * @param int $maxIndex The maximum combination value to assign to each item.
66 * @param ?callable $valueCallback Optional callback for setting the matrix iteration value on each item.
67 *
68 * @return Generator
69 *
70 * @throws InvalidArgumentException
71 */
72 public static function testYieldingMatrix(array $items, int $minIndex, int $maxIndex, ?callable $valueCallback = null)
73 {
74 if ($minIndex > $maxIndex) {
75 throw new InvalidArgumentException('Minimum index must be lower than maximum.', 400);
76 }
77
78 // count total items
79 $itemsCount = count($items);
80
81 // calculate the Base-N value
82 $baseN = $maxIndex - $minIndex + 1;
83
84 // count total combinations
85 $combinationsCount = pow($baseN, $itemsCount);
86
87 // loop over the total combinations count
88 for ($index = 0; $index < $combinationsCount; $index++) {
89 // start current matrix container
90 $container = [];
91
92 // get initial value
93 $value = $index;
94
95 // shift combination values for all items
96 for ($j = 0; $j < $itemsCount; $j++) {
97 // get digit
98 $digit = $value % $baseN;
99
100 // get value
101 $value = intdiv($value, $baseN);
102
103 // calculate matrix container item value
104 $itemValue = $digit + $minIndex;
105
106 // obtain matrix container element
107 if ($valueCallback) {
108 // call provided function
109 $element = call_user_func_array($valueCallback, [$items[$j], $itemValue]) ?: $items[$j];
110 } else {
111 // set item value
112 $element = $itemValue;
113 }
114
115 // push container item
116 $container[] = $element;
117 }
118
119 // yield current container with the current index as key
120 yield $index => $container;
121 }
122 }
123
124 /**
125 * Class constructor will bind the registry and days-units map.
126 *
127 * @param VBOBookingRegistry $registry The involved booking registry.
128 * @param array $daysUnitsMap Associative list of days and related data.
129 *
130 * @throws InvalidArgumentException
131 */
132 public function __construct(VBOBookingRegistry $registry, array $daysUnitsMap)
133 {
134 if (!$daysUnitsMap) {
135 // missing days-units mapping
136 throw new InvalidArgumentException('Missing days-units mapping', 400);
137 }
138
139 // bind booking registry
140 $this->registry = $registry;
141
142 // bind days-units map
143 $this->daysUnitsMap = $daysUnitsMap;
144 }
145
146 /**
147 * Generates a matrix with all possible moves to apply to the room booking records.
148 * For each moveset within the matrix, checks if room records can be re-assigned to
149 * fit the minimum check-in and maximum check-out of the involved reservations.
150 *
151 * @return VBOBookingSubunitMoveset First fitting moveset of room booking records.
152 *
153 * @throws OverflowException|Exception
154 */
155 public function relocateRoomRecords()
156 {
157 if (!$this->relocateRecord) {
158 throw new Exception('Missing room record to relocate.', 500);
159 }
160
161 // access default script execution time
162 $defaultMaxExecTime = @ini_get('max_execution_time');
163 $defaultMaxExecTime = is_numeric($defaultMaxExecTime) ? (int) $defaultMaxExecTime : 0;
164
165 // determine the script max execution time and cycle lifetime
166 $maxExecTime = intval(($this->options['max_exec_time'] ?? 0) ?: 180);
167 $maxExecTime = $maxExecTime < 10 ? 10 : $maxExecTime;
168 $maxExecTime = $defaultMaxExecTime > $maxExecTime ? $defaultMaxExecTime : $maxExecTime;
169 $cycleLifetime = $maxExecTime - 20;
170 $cycleLifetime = $cycleLifetime > 0 ? $cycleLifetime : ($maxExecTime - 1);
171
172 // try to give the script a higher execution time
173 @set_time_limit($maxExecTime);
174 @ini_set('max_execution_time', $maxExecTime);
175
176 // start timer
177 $timerStart = time();
178
179 // always reset fitting solutions counter
180 $this->fittingSolutionsCount = 0;
181
182 // count listing total inventory units
183 $totalUnits = $this->registry->getRoomDetails($this->registry->getCurrentRoomID())['units'] ?? 0;
184
185 if ($totalUnits < 2) {
186 throw new Exception('Listing total inventory units is less than 2.', 500);
187 }
188
189 // sort records by dates closer to relocation target
190 $this->sortRecords();
191
192 // build room record objects list
193 $objectsList = $this->getRecords();
194
195 // prepend room record to relocate to the list
196 array_unshift($objectsList, $this->getRelocateRecord());
197
198 // count total number of possible moves (iterations = Base-N^totalBookings)
199 $totalMoves = pow(($totalUnits + 1), count($objectsList));
200
201 // generate all possible room booking record moves matrix
202 $possbileMovesGenerator = $this->generateMovesetMatrix($objectsList, $totalUnits);
203
204 // the Generator object is iterable, but only once
205 foreach ($possbileMovesGenerator as $comboCount => $moveset) {
206 // parse the combination moveset
207
208 // check first if the cycle lifetime is over to prevent an un-handled script termination
209 // perform the check every 100k iterations, which should take approximately 2 seconds
210 if (($comboCount % 100000) === 0 && (time() - $timerStart) >= $cycleLifetime) {
211 // terminate the iterations to prevent the server from collapsing
212 throw new OverflowException(
213 'The operation was taking too long to complete. Matrix size is too large for the maximum script execution time (' . $maxExecTime . 's).',
214 508
215 );
216 }
217
218 // check if the calculated moveset fits
219 if ($this->relocationFits($moveset, $totalUnits) === true) {
220 // increase fitting solutions counter
221 $this->fittingSolutionsCount++;
222
223 // wrap the fitting moveset into a registry
224 $movesetRegistry = (new VBOBookingSubunitMoveset($moveset, $this->registry))
225 ->setIterationNumber($comboCount + 1)
226 ->setTotalMoves($totalMoves)
227 ->setSolutionsCount($this->fittingSolutionsCount)
228 ->setVerboseRelocation($this->relocationFits($moveset, $totalUnits, $verbose = true));
229
230 if ($this->options['count_all'] ?? null) {
231 // all eligible movesets should be identified and counted
232 continue;
233 }
234
235 if ($this->options['skip_moveset_signatures'] ?? null) {
236 // some moveset should be skipped
237 if (in_array($movesetRegistry->getSignature(), (array) $this->options['skip_moveset_signatures'])) {
238 // we don't want this moveset
239 continue;
240 }
241 }
242
243 if ($this->options['skip_booking_ids'] ?? null) {
244 // some bookings should not be moved
245 if (array_intersect($movesetRegistry->getBookingIDs(), (array) $this->options['skip_booking_ids'])) {
246 // the moveset includes some bookings that should not be moved
247 continue;
248 }
249 }
250
251 // abort and return the fitting relocation moveset registry
252 return $movesetRegistry;
253 }
254 }
255
256 if (($this->options['count_all'] ?? null) && $this->countFittingSolutions() && isset($movesetRegistry)) {
257 // return the last fitting relocation moveset registry found
258 return $movesetRegistry;
259 }
260
261 // no valid combinations found after exhausting the whole matrix
262 throw new Exception(
263 sprintf(
264 'Could not relocate room reservation after going through all possible moves (%d). Total fitting solutions: %d.',
265 ($comboCount + 1),
266 $this->countFittingSolutions()
267 ),
268 404
269 );
270 }
271
272 /**
273 * Returns the current room booking records.
274 *
275 * @return array
276 */
277 public function getRecords()
278 {
279 return $this->records;
280 }
281
282 /**
283 * Returns the current room booking record to relocate.
284 *
285 * @return ?VBOBookingSubunitRecord
286 */
287 public function getRelocateRecord()
288 {
289 return $this->relocateRecord;
290 }
291
292 /**
293 * Sets the room booking record to relocate.
294 *
295 * @param ?VBOBookingSubunitRecord $record The record to set.
296 *
297 * @return static
298 */
299 public function setRelocateRecord(?VBOBookingSubunitRecord $record)
300 {
301 $this->relocateRecord = $record;
302
303 return $this;
304 }
305
306 /**
307 * Resets the room booking records to relocate.
308 *
309 * @param bool $main True to also reset the main record to relocate.
310 *
311 * @return void
312 */
313 public function resetRecords(bool $main = false)
314 {
315 // empty sub-unit records
316 $this->records = [];
317
318 if ($main) {
319 // reset main record to relocate
320 $this->relocateRecord = null;
321 }
322 }
323
324 /**
325 * Injects the matrix options.
326 *
327 * @param array $options Options to bind.
328 *
329 * @return static
330 */
331 public function setOptions(array $options)
332 {
333 $this->options = $options;
334
335 return $this;
336 }
337
338 /**
339 * Returns the number of fitting solutions found so far.
340 *
341 * @return int
342 */
343 public function countFittingSolutions()
344 {
345 return $this->fittingSolutionsCount;
346 }
347
348 /**
349 * Adds a room-booking record wrapper to the pool, or sets it as main record.
350 *
351 * @param VBOBookingSubunitRecord $record The record to push.
352 *
353 * @return void
354 *
355 * @throws Exception
356 */
357 public function pushRecord(VBOBookingSubunitRecord $record)
358 {
359 if ($record->isRelocating()) {
360 // ensure the record to relocate is not a closure
361 if ($record->isClosure()) {
362 throw new Exception('Booking closures do not support sub-unit relocation.', 500);
363 }
364
365 // ensure we only get one record to relocate
366 if ($this->relocateRecord) {
367 throw new Exception('Matrix can only relocate one room booking record per time.', 500);
368 }
369
370 // set record as main record, without pushing it to the queue
371 $this->relocateRecord = $record;
372 } else {
373 // push room-booking record wrapper
374 $this->records[] = $record;
375 }
376 }
377
378 /**
379 * Builds and returns the iterable date period for all
380 * room booking records (min check-in to max check-out).
381 *
382 * @return DatePriod
383 *
384 * @throws Exception
385 */
386 public function getRecordsDatePeriod()
387 {
388 if ($this->recordsDatePeriod) {
389 // date period already available
390 return $this->recordsDatePeriod;
391 }
392
393 if (!$this->records && !$this->relocateRecord) {
394 throw new Exception('No room booking records for calculating the iterable date period.', 500);
395 }
396
397 // gather all check-in and check-out timestamps
398 $checkins = [];
399 $checkouts = [];
400
401 if ($this->relocateRecord) {
402 // push relocate record details
403 $checkins[] = $this->relocateRecord->getCheckin();
404 $checkouts[] = $this->relocateRecord->getCheckout();
405 }
406
407 foreach ($this->records as $roomRecord) {
408 // push room booking record details
409 $checkins[] = $roomRecord->getCheckin();
410 $checkouts[] = $roomRecord->getCheckout();
411 }
412
413 // local timezone
414 $tz = new DateTimezone(date_default_timezone_get());
415
416 // get date bounds
417 $from_bound = new DateTime(date('Y-m-d H:i:s', min($checkins)), $tz);
418 $to_bound = new DateTime(date('Y-m-d H:i:s', max($checkouts)), $tz);
419
420 // set iterable dates interval (period)
421 $this->recordsDatePeriod = new DatePeriod(
422 // start date included by default in the result set
423 $from_bound,
424 // interval between recurrences within the period
425 new DateInterval('P1D'),
426 // end date (check-out) excluded by default from the result set
427 $to_bound
428 );
429
430 // return the iterable date period
431 return $this->recordsDatePeriod;
432 }
433
434 /**
435 * Generates all possible moves for the room booking records. Returns an iterator, more precisely
436 * a Generator object containing a list of room booking record moveset for every combination.
437 * For every room booking record the Base-N is calculated to count the possible combinations.
438 * Base-N = maxIndex - minIndex + 1. Possible combinations = Base-N^countRoomBookingRecords.
439 * The minimum index is 0, meaning no moves, while the maximum index is the room inventory count.
440 * If we had 9 room booking records plus one room booking record to relocate, and if the room
441 * had 5 units in total, then the total count of possible moves would be: Base-6^10 = 60.466.176.
442 *
443 * @param array $records List of room booking record objects, inclusive of the one to relocate (0th).
444 * @param int $maxIndex Max combination value (maximum room index = total inventory count).
445 *
446 * @return Generator For every iteration, list of room booking record moveset.
447 */
448 private function generateMovesetMatrix(array $records, int $maxIndex)
449 {
450 // count total room booking records (inclusive of the one to relocate)
451 $objectsCount = count($records);
452
453 // default minimum index (0 = no moves)
454 $minIndex = 0;
455
456 // calculate the Base-N value
457 $baseN = $maxIndex - $minIndex + 1;
458
459 // count total combinations
460 $combinationsCount = pow($baseN, $objectsCount);
461
462 // loop over the total combinations count
463 for ($index = 0; $index < $combinationsCount; $index++) {
464 // start current moveset list
465 $moveset = [];
466
467 // get initial value
468 $value = $index;
469
470 // shift combination values for all elements
471 for ($j = 0; $j < $objectsCount; $j++) {
472 // get digit
473 $digit = $value % $baseN;
474
475 // get value
476 $value = intdiv($value, $baseN);
477
478 // calculate room index for this combination
479 $comboRoomIndex = $digit + $minIndex;
480
481 // clone current room record object
482 $roomObject = clone $records[$j];
483
484 // apply room index combination
485 $roomObject->setRoomUnitIndex($comboRoomIndex);
486
487 // set moveset object
488 $moveset[$j] = $roomObject;
489 }
490
491 // yield current moveset with the current index as key
492 yield $index => $moveset;
493 }
494 }
495
496 /**
497 * Tells if the given moveset fits without any overlapping room booking.
498 *
499 * @param VBOBookingSubunitRecord[] $moveset List of room booking record (cloned) objects.
500 * @param ?int $maxIndex Maximum room index (total inventory count).
501 * @param bool $verbose Whether to describe the relocation plan.
502 *
503 * @return bool|string String if successful and verbose, boolean otherwise.
504 *
505 * @throws InvalidArgumentException
506 */
507 private function relocationFits(array $moveset, ?int $maxIndex = null, bool $verbose = false)
508 {
509 if (!$moveset) {
510 throw new InvalidArgumentException('No room booking records in the moveset.', 500);
511 }
512
513 if (!$maxIndex) {
514 // count listing total inventory units
515 $maxIndex = $this->registry->getRoomDetails($this->registry->getCurrentRoomID())['units'] ?? 0;
516 }
517
518 // start verbose description
519 $verboseTexts = [];
520
521 // scan the date period for all room records to ensure we've got no duplicate indexes
522 foreach ($this->getRecordsDatePeriod() as $date) {
523 if ($verbose) {
524 $verboseTexts[] = 'Analysing date-time ' . $date->format('Y-m-d H:i:s') . "\n";
525 }
526
527 // access current calendar date timestamp
528 $currentTs = $date->format('U');
529
530 // build the list of occupied room indexes
531 $occupiedIndexes = [];
532
533 // iterate all room record objects in the moveset
534 foreach ($moveset as $roomRecord) {
535 // get record room index occupied
536 $occupiedIndex = $roomRecord->getRoomUnitIndex();
537
538 if (!$occupiedIndex) {
539 // this record is not impacting the moveset
540 if ($roomRecord->isRelocating()) {
541 // room record to relocate always requires a move
542 return false;
543 }
544
545 // process the next moveset
546 continue;
547 }
548
549 // check if the current room record intersects the current calendar date
550 if ($roomRecord->getCheckin() <= $currentTs && $roomRecord->getLastNight() >= $currentTs) {
551 // push occupied index
552 $occupiedIndexes[] = $occupiedIndex;
553
554 if ($verbose) {
555 $verboseTexts[] = 'Booking ID ' . $roomRecord->getBookingID() . ' is occupying the unit ' . (int) $occupiedIndex;
556 }
557 }
558 }
559
560 if ($verbose) {
561 $verboseTexts[] = 'Occupied indexes: ' . implode(', ', $occupiedIndexes) . "\n\n";
562 }
563
564 // count number of occupied indexes
565 $totOccupiedSlots = count($occupiedIndexes);
566
567 if (!$totOccupiedSlots) {
568 // no bookings on this day
569 continue;
570 }
571
572 if ($totOccupiedSlots > $maxIndex) {
573 // the relocation does not fit as this day is overbooked
574 return false;
575 }
576
577 if (count(array_unique($occupiedIndexes)) != $totOccupiedSlots) {
578 // multiple room bookings were occupying the same index
579 return false;
580 }
581 }
582
583 // the relocation moveset does fit!
584 return $verbose ? implode("\n", $verboseTexts) : true;
585 }
586
587 /**
588 * Sorts the room booking records by dates closer to target.
589 *
590 * @return void
591 */
592 private function sortRecords()
593 {
594 // access the target checkin and checkout date timestamps
595 $targetCheckinTs = $this->relocateRecord->getCheckin();
596 $targetCheckoutTs = $this->relocateRecord->getCheckout();
597
598 // check if some bookings should be skipped
599 $lowPriorityBids = (array) ($this->options['skip_booking_ids'] ?? null);
600
601 // sort records
602 usort($this->records, function($a, $b) use ($targetCheckinTs, $targetCheckoutTs, $lowPriorityBids) {
603 // calculate timestamp distance for both comparison elements
604 $aDistance = abs($targetCheckinTs - $a->getCheckin()) + abs($targetCheckoutTs - $a->getCheckout());
605 $bDistance = abs($targetCheckinTs - $b->getCheckin()) + abs($targetCheckoutTs - $b->getCheckout());
606
607 if ($lowPriorityBids) {
608 if (in_array($a->getBookingID(), $lowPriorityBids)) {
609 $aDistance = PHP_INT_MAX;
610 }
611 if (in_array($b->getBookingID(), $lowPriorityBids)) {
612 $bDistance = PHP_INT_MAX;
613 }
614 }
615
616 return $aDistance <=> $bDistance;
617 });
618 }
619 }
620