PluginProbe ʕ •ᴥ•ʔ
VikAppointments Services Booking Calendar / 1.2.21
VikAppointments Services Booking Calendar v1.2.21
1.2.21 1.2.20 trunk 1.2.17 1.2.18 1.2.19
vikappointments / site / helpers / libraries / availability / implementor.php
vikappointments / site / helpers / libraries / availability Last commit date
timeline 3 days ago implementor.php 3 days ago index.html 3 days ago manager.php 3 days ago search.php 3 days ago timeline.php 3 days ago
implementor.php
1385 lines
1 <?php
2 /**
3 * @package VikAppointments
4 * @subpackage core
5 * @author E4J s.r.l.
6 * @copyright Copyright (C) 2021 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 VAPLoader::import('libraries.availability.search');
15
16 /**
17 * Class manager of the availability search.
18 *
19 * @since 1.7
20 */
21 class VAPAvailabilityImplementor extends VAPAvailabilitySearch
22 {
23 /**
24 * Approximative check to determine whether the specified day
25 * is available or not by returning the related availability
26 * status (0: full, 1: fully available, 2: partially available).
27 *
28 * @todo Introduce some hooks here to allow the plugins to override the
29 * availability status of the days. There should be a "before" event
30 * to prevent useless queries and an "after" event to apply additional
31 * queries after the default ones. Maybe it could be helpful to run
32 * the same hooks also while checking the availability of a single
33 * employee through the `_isDayAvailable` method.
34 *
35 * @param string $date The UTC date in military format.
36 *
37 * @return integer The availability status.
38 */
39 public function isDayAvailable($date)
40 {
41 if ((int) $this->get('id_employee'))
42 {
43 // directly use helper method to fetch the
44 // availability of the selected employee
45 return $this->_isDayAvailable($date);
46 }
47
48 $model = JModelVAP::getInstance('service');
49
50 // We do not have a specific employee, so we need to retrieve all the employees
51 // assigned to this service. Always take any assigned employee.
52 $employees = $model->getEmployees((int) $this->get('id_service'), $strict = false);
53
54 if (!$employees)
55 {
56 // no employees found...
57 return 0;
58 }
59
60 $list = array();
61
62 // fetch the employee availability one by one
63 foreach ($employees as $employee)
64 {
65 // temporarily inject employee ID
66 $this->set('id_employee', (int) $employee->id);
67 // fetch availability and register status
68 $list[] = $this->_isDayAvailable($date);
69 }
70
71 // unset employee ID
72 $this->set('id_employee', 0);
73
74 // look for a fully available status first
75 if (in_array(1, $list))
76 {
77 return 1;
78 }
79
80 // then look for a partially available status
81 if (in_array(2, $list))
82 {
83 return 2;
84 }
85
86 // not available
87 return 0;
88 }
89
90 /**
91 * Helper used to approximatively determine whether the specified day
92 * is available or not by returning the related availability status.
93 *
94 * @param string $date The UTC date in military format.
95 *
96 * @return integer The availability status.
97 */
98 protected function _isDayAvailable($date)
99 {
100 // get working times
101 $worktime = $this->getWorkingTimes($date);
102
103 if (!$worktime)
104 {
105 // not available on this date
106 return 0;
107 }
108
109 $tmp = array();
110
111 foreach ($worktime as $wt)
112 {
113 // get a clone of the working times to allow their
114 // manipulation without compromising the cache
115 $tmp[] = clone $wt;
116 }
117
118 $worktime = $tmp;
119
120 // get reservations
121 $reservations = $this->getReservations($date);
122
123 if (!$reservations)
124 {
125 // fully available, no reservations on this date yet
126 return 1;
127 }
128
129 $id_service = (int) $this->get('id_service');
130 $id_employee = (int) $this->get('id_employee');
131
132 // get service-employee association model
133 $model = JModelVAP::getInstance('serempassoc');
134 // get service-employee overrides
135 $override = $model->getOverrides($id_service, $id_employee);
136
137 if (!$override)
138 {
139 // invalid relation
140 throw new Exception('Service-employee relation not found.', 404);
141 }
142
143 // fetch default system timezone
144 $tz = JFactory::getApplication()->get('offset', 'UTC');
145
146 $max_capacity = 1;
147
148 // use service maximum capacity in case it is able to host
149 // multiple appointments at the same time slot
150 if ($override->app_per_slot)
151 {
152 $max_capacity = $override->max_capacity;
153 }
154
155 foreach ($reservations as $r)
156 {
157 if (!$r->timezone)
158 {
159 // use default system timezone
160 $r->timezone = $tz;
161 }
162
163 // create check-in and adjust it to the employee timezone
164 $checkin = JFactory::getDate($r->checkin_ts);
165 $checkin->setTimezone(new DateTimeZone($r->timezone));
166
167 // get check-in time adjusted to local timezone
168 $from = $checkin->format('H:i', $local = true);
169 // convert time string in minutes
170 $from = JHtml::fetch('vikappointments.time2min', $from);
171
172 // calculate end time by adding total duration to start time
173 $to = $from + $r->duration + $r->sleep;
174
175 $idx = 0;
176
177 /**
178 * Go ahead in the loop also in case the checkin time of the appointments
179 * is equals to the end time of the working shift.
180 *
181 * For example, in case the checkin starts at 9:00 and we have a working
182 * shift ending at the same time, we should go ahead as it is not the
183 * record we need.
184 *
185 * This is a common issue that may occur in case of contiguous shifts,
186 * such as 14:00 - 15:00 and 15:00 - 16:00.
187 *
188 * @since 1.6.4
189 */
190 for ($idx; $idx < count($worktime) && ($worktime[$idx]->fromts > $from || $from >= $worktime[$idx]->endts); $idx++);
191
192 // make sure we found a matching working time
193 if ($idx < count($worktime))
194 {
195 if (!isset($worktime[$idx]->_counter))
196 {
197 // create counter
198 $worktime[$idx]->_counter = 0;
199 }
200
201 /**
202 * The number of people cannot exceed the maximum capacity of the service,
203 * so that appointments booked for other services with higher capacity
204 * won't fetch a wrong availability.
205 *
206 * @since 1.7
207 */
208 $people = min(array($r->people_count, $max_capacity));
209
210 /**
211 * In case the booked appointment doesn't match the ID of the current service,
212 * use the service capacity to automatically turn off the whole slot(s).
213 *
214 * @since 1.7
215 */
216 if ($id_service != $r->id_service)
217 {
218 $people = $max_capacity;
219 }
220
221 /**
222 * Multiply the occupied time slots by the selected number of people.
223 *
224 * @since 1.6.5
225 */
226 $worktime[$idx]->_counter += ($to - $from) * $people;
227 }
228 }
229
230 foreach ($worktime as $w)
231 {
232 /**
233 * Check whether the total duration of the working time still own some space.
234 * Multiply the whole shift by the maximum capacity of the service.
235 *
236 * @since 1.6.5
237 *
238 * In case the current working time ends at midnight and the working time for the
239 * next day starts at midnight, they are merged together for a correct availability check.
240 * In this case, the total length of the working shift might exceed the limit of 24 hours.
241 * For this reason, we should always take shifts that do not last more than 1440 minutes.
242 *
243 * @since 1.7.4
244 */
245 if (empty($w->_counter) || $w->_counter < (min(1440, $w->endts - $w->fromts)) * $max_capacity)
246 {
247 // seems to be (partially) available
248 return 2;
249 }
250 }
251
252 // all working times are full
253 return 0;
254 }
255
256 /**
257 * Returns the employee working times for the given day.
258 * In case of 24h working days, the system will extend the ending
259 * time of the last working day in order to support midnight appointments.
260 *
261 * @param string $date The UTC date in military format.
262 *
263 * @return array A list containing the matching working days.
264 */
265 public function getWorkingTimes($date)
266 {
267 // get working times for the given day
268 $worktimes = $this->_getWorkingTimes($date);
269
270 $date = JFactory::getDate($date);
271
272 // update specified day by one
273 $date->modify('+1 day 00:00:00');
274
275 // fallback to obtain the working times for the next day
276 $next = $this->_getWorkingTimes($date->format('Y-m-d'));
277
278 if ($worktimes && $next && $next[0]->fromts == 0)
279 {
280 // We have probably a 24H working time.
281 // Extend the last working time with the first
282 // one of the next day
283 $last = &$worktimes[count($worktimes) - 1];
284
285 $last->endts += $next[0]->endts;
286 }
287
288 return $worktimes;
289 }
290
291 /**
292 * Returns the employee working times for the given day.
293 *
294 * @param string $date The UTC date in military format.
295 *
296 * @return array A list containing the matching working days.
297 */
298 protected function _getWorkingTimes($date)
299 {
300 // cache working days grouped by service/employee relation
301 static $worktimes = array();
302
303 // create cache signature
304 $sign = serialize(array((int) $this->get('id_service'), (int) $this->get('id_employee')));
305
306 // load the all the working days available for the specified employee/service
307 // and cache them within an internal property to avoid several accesses to
308 // the database while loading the calendar availability
309 if (!isset($worktimes[$sign]))
310 {
311 $worktimes = array();
312
313 $dbo = JFactory::getDbo();
314
315 // obtain all the working days for the given employee/service
316 $q = $dbo->getQuery(true)
317 ->select('*')
318 ->from($dbo->qn('#__vikappointments_emp_worktime'))
319 ->where(array(
320 $dbo->qn('id_employee') . ' = ' . (int) $this->get('id_employee'),
321 $dbo->qn('id_service') . ' = ' . (int) $this->get('id_service'),
322 ))
323 ->order(array(
324 $dbo->qn('closed') . ' DESC',
325 $dbo->qn('ts') . ' DESC',
326 $dbo->qn('fromts') . ' ASC',
327 ));
328
329 // look for any specified locations
330 $locations = (array) $this->get('locations', array());
331
332 if (count($locations))
333 {
334 // filter by location
335 $q->andWhere(array(
336 $dbo->qn('id_location') . ' <= 0',
337 $dbo->qn('id_location') . ' IN (' . implode(',', array_map('intval', $locations)) . ')',
338 ), 'OR');
339 }
340
341 $dbo->setQuery($q);
342
343 foreach ($dbo->loadObjectList() as $wd)
344 {
345 if (VAPDateHelper::isNull($wd->tsdate))
346 {
347 // register by day of the week
348 $k = (int) $wd->day;
349 }
350 else
351 {
352 // register by date
353 $k = $wd->tsdate;
354 }
355
356 if (!isset($worktimes[$sign][$k]))
357 {
358 // init pool
359 $worktimes[$sign][$k] = array();
360 }
361
362 // add working time
363 $worktimes[$sign][$k][] = $wd;
364 }
365 }
366
367 // check whether there is a working time for the specified date
368 if (!empty($worktimes[$sign][$date]))
369 {
370 // make sure the date is not closed
371 if ($worktimes[$sign][$date][0]->closed)
372 {
373 // the employee is closed on this date
374 return array();
375 }
376
377 // return working times
378 return $worktimes[$sign][$date];
379 }
380
381 // get day of the week
382 $week = (int) JFactory::getDate($date)->format('w');
383
384 /**
385 * @todo implement start_publishing and end_publishing restrictions
386 */
387
388 // check whether there is a working time for the specified week day
389 if (!empty($worktimes[$sign][$week]))
390 {
391 // make sure the day is not closed
392 if ($worktimes[$sign][$week][0]->closed)
393 {
394 // the employee is closed on this day
395 return array();
396 }
397
398 // return working times
399 return $worktimes[$sign][$week];
400 }
401
402 // no specified working days
403 return array();
404 }
405
406 /**
407 * Returns a list of appointments that stays between 2 dates.
408 * This method returns only the appointments that might alter
409 * the availability of the registered service/employee.
410 *
411 * @param string $date The UTC start date in military format.
412 * @param mixed $end The UTC end date in military format. Leave empty to
413 * auto-set the end date at midnight of start date.
414 * @param integer $id The selected appointment ID, which will be excluded.
415 *
416 * @return array A list of appointments.
417 */
418 public function getReservations($date, $end = null, $id = 0)
419 {
420 // set initial date time at midnight
421 $start = JFactory::getDate($date);
422 $start->modify('00:00:00');
423 $start = $start->toSql();
424
425 if (!$end)
426 {
427 // use midnight of current date
428 $end = JFactory::getDate($date);
429 $end->modify('23:59:59');
430 $end = $end->toSql();
431 }
432
433 $id_ser = (int) $this->get('id_service', 0);
434 $id_emp = (int) $this->get('id_employee', 0);
435
436 /**
437 * Check if the service owns a private calendar, so
438 * that we can exclude all the reservations that belong
439 * to the same employee for different services.
440 *
441 * @since 1.6.5
442 */
443 $serModel = JModelVAP::getInstance('service');
444 $has_own_cal = $serModel->hasOwnCalendar($id_ser);
445
446 // get all status codes that locks the appointments
447 $statuses = JHtml::fetch('vaphtml.status.find', 'code', array('appointments' => 1, 'reserved' => 1));
448
449 $dbo = JFactory::getDbo();
450
451 $q = $dbo->getQuery(true);
452
453 $q->select($dbo->qn('r.checkin_ts'));
454 $q->select($dbo->qn('r.duration'));
455 $q->select($dbo->qn('r.sleep'));
456 $q->select($dbo->qn('r.id_employee'));
457 $q->select($dbo->qn('r.id_service'));
458 $q->select($dbo->qn('r.closure'));
459
460 $q->from($dbo->qn('#__vikappointments_reservation', 'r'));
461
462 // load check-in timezone
463 $q->select($dbo->qn('e.timezone'));
464 $q->leftjoin($dbo->qn('#__vikappointments_employee', 'e') . ' ON ' . $dbo->qn('e.id') . ' = ' . $dbo->qn('r.id_employee'));
465
466 // take only the appointments between the specified range
467 // $q->where($dbo->qn('r.checkin_ts') . ' >= ' . $dbo->q($start));
468 // $q->where($dbo->qn('r.checkin_ts') . ' < ' . $dbo->q($end));
469
470 // filter by check-in date
471 $q->where(sprintf(
472 'CONVERT_TZ(%s, \'+00:00\', IFNULL(%s, \'%s\')) BETWEEN %s AND %s',
473 // take checkin-date time
474 $dbo->qn('r.checkin_ts'),
475 // adjust it to the related timezone
476 $dbo->qn('r.tz_offset'),
477 // or use the current one if not specified
478 JHtml::fetch('date', 'now', 'P'),
479 // set initial delimiter
480 $dbo->q($start),
481 // set ending delimiter
482 $dbo->q($end)
483 ));
484
485 if ($statuses)
486 {
487 // take only the reserved appointments
488 $q->andWhere([
489 $dbo->qn('r.status') . ' IN (' . implode(',', array_map(array($dbo, 'q'), $statuses)) . ')',
490 $dbo->qn('r.closure') . ' = 1',
491 ], 'OR');
492 }
493
494 $q->order($dbo->qn('r.checkin_ts') . ' ASC');
495
496 if ($id_emp)
497 {
498 // count the total number of users per time slot
499 $q->select(sprintf('SUM(%s) AS %s', $dbo->qn('r.people'), $dbo->qn('people_count')));
500 // take only the appointments of the specified employee
501 $q->where($dbo->qn('r.id_employee') . ' = ' . $id_emp);
502 // group by check-in
503 $q->group($dbo->qn('r.checkin_ts'));
504 /**
505 * Group by closure too in order to keep pulling the closures even if the
506 * the latter are already intersecting some valid appointments.
507 *
508 * @since 1.7.6
509 */
510 $q->group($dbo->qn('r.closure'));
511 }
512 else
513 {
514 $q->select($dbo->qn('r.people', 'people_count'));
515 }
516
517 if ($has_own_cal)
518 {
519 // take only the reservations for this service
520 $q->where($dbo->qn('r.id_service') . ' = ' . $id_ser);
521 }
522 else if (!$id_emp)
523 {
524 // take any reservation that belong to any employee assigned to this service
525 $q->leftjoin($dbo->qn('#__vikappointments_ser_emp_assoc', 'a') . ' ON ' . $dbo->qn('a.id_employee') . ' = ' . $dbo->qn('r.id_employee'));
526 $q->where($dbo->qn('a.id_service') . ' = ' . $id_ser);
527 }
528
529 if ($id)
530 {
531 $q->where($dbo->qn('r.id') . ' <> ' . (int) $id);
532 }
533
534 $dbo->setQuery($q);
535 $app = $dbo->loadObjectList();
536
537 if (!$app)
538 {
539 // no appointments found on the given range
540 return array();
541 }
542
543 if (!$has_own_cal)
544 {
545 $lookup = array();
546
547 /**
548 * In case the specified service owns a shared calendar we should
549 * ignore any other appointments with private calendar.
550 *
551 * @since 1.6.5
552 */
553 $app = array_values(array_filter($app, function($r) use ($serModel)
554 {
555 // check whether the service owns a private calendar
556 $has_own_cal = $serModel->hasOwnCalendar($r->id_service);
557
558 // accept appointment if belongs to a service with shared calendar
559 return $has_own_cal == 0;
560 }));
561 }
562
563 return $app;
564 }
565
566 /**
567 * Checks whether there's at least an open working time on the given
568 * day for the specified service-employee relation.
569 *
570 * @param string $date The UTC date in military format.
571 *
572 * @return boolean True if available, false if missing or closed.
573 */
574 public function hasWorkingDay($date)
575 {
576 $id_service = (int) $this->get('id_service', 0);
577 $id_employee = (int) $this->get('id_employee', 0);
578
579 // create date helper
580 $date = JFactory::getDate($date);
581
582 $dbo = JFactory::getDbo();
583
584 $q = $dbo->getQuery(true);
585
586 $q->select($dbo->qn('closed'));
587 $q->from($dbo->qn('#__vikappointments_emp_worktime'));
588 $q->where($dbo->qn('id_service') . ' = ' . $id_service);
589
590 // take working times for the days of the year first
591 $q->order($dbo->qn('ts') . ' DESC');
592
593 if ($id_employee)
594 {
595 // filter by employee ID
596 $q->where($dbo->qn('id_employee') . ' = ' . $id_employee);
597 // take closing working times first
598 $q->order($dbo->qn('closed') . ' DESC');
599 }
600 else
601 {
602 $q->select($dbo->qn('id_employee'));
603 // take opening working times first
604 $q->order($dbo->qn('closed') . ' ASC');
605 }
606
607 /**
608 * @todo implement start_publishing and end_publishing restrictions
609 */
610
611 // filter by date/day
612 $q->andWhere(array(
613 $dbo->qn('day') . ' = ' . (int) $date->format('w') . ' AND ' . $dbo->qn('ts') . ' <= 0',
614 $dbo->qn('tsdate') . ' = ' . $dbo->q($date->toSql()),
615 ));
616
617 // look for any specified locations
618 $locations = (array) $this->get('locations', array());
619
620 if (count($locations))
621 {
622 // filter by location
623 $q->andWhere(array(
624 $dbo->qn('closed') . ' = 1',
625 $dbo->qn('id_location') . ' <= 0',
626 $dbo->qn('id_location') . ' IN (' . implode(',', array_map('intval', $locations)) . ')',
627 ), 'OR');
628 }
629
630 // take only one result in case of a single employee
631 $dbo->setQuery($q, 0, $id_employee ? 1 : null);
632 $rows = $dbo->loadObjectList();
633
634 if (!$rows)
635 {
636 // no working days found
637 return false;
638 }
639
640 if ($id_employee)
641 {
642 // make sure the returned value is 0 (= open)
643 return $rows[0]->closed == 0;
644 }
645
646 $lookup = array();
647
648 // split the working days found and group them under the related employee
649 foreach ($rows as $wd)
650 {
651 if (!isset($lookup[$wd->id_employee]))
652 {
653 // register only the closure status of the first working day
654 // found for each employee, which must be not closed
655 $lookup[$wd->id_employee] = $wd->closed;
656 }
657
658 // check whether the first working time of the employee
659 // is not a closure, meaning that it is open
660 if (!$lookup[$wd->id_employee])
661 {
662 return true;
663 }
664 }
665
666 // no open employees
667 return false;
668 }
669
670 /**
671 * Checks whether the specified employee is able to host an appointment at
672 * the specified date and for the given duration.
673 *
674 * This method should simply check the intersection between this search and
675 * the existing appointments. It is assumed that the seleceted check-in is
676 * already supported by the employee.
677 *
678 * @param string $date The UTC start date in military format.
679 * @param mixed $duration The appointment duration.
680 * @param integer $people The number of participants.
681 * @param integer $id The selected appointment ID, which will be excluded.
682 *
683 * @return boolean True if available, false otherwise.
684 */
685 public function isEmployeeAvailable($date, $duration = null, $people = 1, $id = 0)
686 {
687 // get service-employee assoc model
688 $assocModel = JModelVAP::getInstance('serempassoc');
689 // get service details
690 $service = $assocModel->getOverrides($this->get('id_service'), $this->get('id_employee'));
691
692 if (!$service)
693 {
694 // service not found...
695 return false;
696 }
697
698 if (!$duration)
699 {
700 // use service duration+sleep time
701 $duration = $service->duration + $service->sleep;
702 }
703
704 // get employee timezone
705 $tz = JModelVAP::getInstance('employee')->getTimezone($this->get('id_employee'));
706
707 // create check-in date
708 $checkin = JFactory::getDate($date);
709 $checkin->setTimezone(new DateTimeZone($tz));
710
711 // create check-out date by adding the duration
712 $checkout = clone $checkin;
713 $checkout->modify('+' . $duration . ' minutes');
714
715 /**
716 * We need to make sure that the selected check-in and check-out are not going
717 * to exceeds the bounds of the employee working times.
718 *
719 * Without this security check, it would be possible to exceed the bounds by adding
720 * an "extra-time" option to the last available time block.
721 *
722 * @since 1.7.4
723 */
724 $workingShifts = $this->getWorkingTimes($checkin->format('Y-m-d', true));
725
726 $checkinTime = JHtml::fetch('vikappointments.time2min', $checkin->format('H:i', true));
727 $checkoutTime = JHtml::fetch('vikappointments.time2min', $checkout->format('H:i', true));
728
729 /**
730 * In case the check-out is at midnight, extend the shift to the midnight of the next day.
731 *
732 * @since 1.7.8
733 */
734 if ($checkoutTime == 0)
735 {
736 $checkoutTime = 1440;
737 }
738
739 $shiftAvailable = false;
740
741 foreach ($workingShifts as $shift)
742 {
743 // make sure the check-in and check-out are between the shift bounds
744 if ($shift->fromts <= $checkinTime && $checkoutTime <= $shift->endts)
745 {
746 // time ok
747 $shiftAvailable = true;
748 }
749 }
750
751 if (!$shiftAvailable)
752 {
753 // invalid time, abort
754 return false;
755 }
756
757 // check if the service owns a private calendar, so
758 // that we can exclude all the reservations that belong
759 // to the same employee for different services
760 $serModel = JModelVAP::getInstance('service');
761 $has_own_cal = $serModel->hasOwnCalendar($this->get('id_service'));
762
763 $dbo = JFactory::getDbo();
764
765 $q = $dbo->getQuery(true);
766
767 $q->select('COUNT(' . $dbo->qn('r.people') . ') AS ' . $dbo->qn('count'));
768 $q->from($dbo->qn('#__vikappointments_reservation', 'r'));
769
770 if ($id)
771 {
772 // exclude selected appointment
773 $q->where($dbo->qn('r.id') . ' <> ' . (int) $id);
774 }
775
776 // filter reservations by employee
777 $q->where($dbo->qn('r.id_employee') . ' = ' . (int) $this->get('id_employee'));
778
779 // get all status codes that locks the appointments
780 $statuses = JHtml::fetch('vaphtml.status.find', 'code', array('appointments' => 1, 'reserved' => 1));
781
782 if ($statuses)
783 {
784 // take only the reserved appointments
785 $q->where($dbo->qn('r.status') . ' IN (' . implode(',', array_map(array($dbo, 'q'), $statuses)) . ')');
786 }
787
788 if ($has_own_cal)
789 {
790 // we have a service with private calendatr, so we need to take only the
791 // reservations assigned to this service
792 $q->where($dbo->qn('r.id_service') . ' = ' . (int) $this->get('id_service'));
793 }
794 else
795 {
796 // take any reservation for those services that don't own a private calendar
797 $q->leftjoin($dbo->qn('#__vikappointments_service', 's') . ' ON ' . $dbo->qn('s.id') . ' = ' . $dbo->qn('r.id_service'));
798 $q->where($dbo->qn('s.has_own_cal') . ' = 0');
799 }
800
801 // create expression to calculate the check-out date via SQL
802 $out = sprintf(
803 'DATE_ADD(%s, INTERVAL (%s + %s) MINUTE)',
804 $dbo->qn('r.checkin_ts'),
805 $dbo->qn('r.duration'),
806 $dbo->qn('r.sleep')
807 );
808
809 $collision = array();
810
811 // check whether the check-in is contained between the appointment range
812 $collision[] = sprintf(
813 '%1$s <= %2$s AND %2$s < %3$s',
814 $dbo->qn('r.checkin_ts'),
815 $dbo->q($checkin->toSql()),
816 $out
817 );
818
819 // check whether the check-out is contained between the appointment range
820 $collision[] = sprintf(
821 '%1$s < %2$s AND %2$s <= %3$s',
822 $dbo->qn('r.checkin_ts'),
823 $dbo->q($checkout->toSql()),
824 $out
825 );
826
827 // check whether our range entirely wraps the appointment
828 $collision[] = sprintf(
829 '%s < %s AND %s < %s',
830 $dbo->q($checkin->toSql()),
831 $dbo->qn('r.checkin_ts'),
832 $out,
833 $dbo->q($checkout->toSql())
834 );
835
836 // fetch intersections
837 $q->andWhere($collision);
838
839 $dbo->setQuery($q);
840 $count = (int) $dbo->loadResult();
841
842 if (!$count)
843 {
844 // no intersection between the appointments
845 return true;
846 }
847
848 if (!$service->app_per_slot && $count > 0)
849 {
850 // Even if the service supports a maximum capacity higher than 1,
851 // it doesn't allow simultaneous bookings. For this reason, since
852 // we found a colliding reservation, the employee is not available.
853 return false;
854 }
855
856 // make sure the current people count plus the specified number of
857 // participants doesn't exceed the maximum capacity of the service
858 // return ($count + $people) <= $service->max_capacity;
859
860 /**
861 * Ignore the people validation to bypass the limitation related to overlapping appointments
862 * with maximum capacity higher than one and time slots length different than the duration.
863 *
864 * Practical example (service max capacity = 2, duration = 60 min):
865 * 11:00 -> 1 seat available
866 * 11:30 -> 1 seat available
867 * 12:00 -> 1 seat available
868 *
869 * Considering that the service lasts 1 hour, checking the availability for the 11:30 - 12:30
870 * time slot results in a failure, as the total count is equal to 2 (11:00 + 12:00). Since the
871 * total count is equal to the maximum capacity, the appointment won't be accepted.
872 *
873 * However, the availability is already evaluated by looking at the timeline status. So, in case
874 * the timeline reports the time as available, we can bypass an extra validation check applied to
875 * the number of participants.
876 *
877 * @since 1.7.9
878 */
879 return true;
880 }
881
882 /**
883 * Checks whether the specified service is able to host an appointment at
884 * the specified date and for the given duration.
885 *
886 * This method should simply check the intersection between this search and
887 * the existing appointments. The system will iterate all the employees
888 * assigned to the selected service to find the first one available.
889 *
890 * @param string $date The UTC start date in military format.
891 * @param mixed $duration The appointment duration.
892 * @param integer $people The number of participants.
893 * @param integer $id The selected appointment ID, which will be excluded.
894 *
895 * @return mixed The ID of the available employee, false otherwise.
896 */
897 public function isServiceAvailable($date, $duration = null, $people = 1, $id = 0)
898 {
899 $dispatcher = VAPFactory::getEventDispatcher();
900
901 $employees = array();
902
903 /**
904 * Trigger hook to use a custom method to load the supported employees
905 * without having to use a direct query. The plugins will have to return
906 * an array of employee IDs.
907 *
908 * @param self $search The availability search instance.
909 * @param string $date The check-in date (UTC).
910 *
911 * @return array An array of employee IDs.
912 *
913 * @since 1.7
914 */
915 $results = $dispatcher->trigger('onFetchServiceAvailableEmployees', array($this, $date));
916
917 // iterate all results
918 foreach ($results as $result)
919 {
920 if (is_array($result))
921 {
922 // in case of an array, merge with the existing employees
923 $employees = array_merge($employees, array_map('intval', $result));
924 }
925 else
926 {
927 // append given employee
928 $employees[] = (int) $result;
929 }
930 }
931
932 // check whether the plugins already filled the array of employees
933 if (!$employees)
934 {
935 // nope, fallback to default query
936 $dbo = JFactory::getDbo();
937
938 $q = $dbo->getQuery(true)
939 ->select($dbo->qn('a.id_employee'))
940 ->select('COUNT(' . $dbo->qn('r.id') . ') AS ' . $dbo->qn('count'))
941 ->from($dbo->qn('#__vikappointments_ser_emp_assoc', 'a'))
942 ->leftjoin($dbo->qn('#__vikappointments_reservation', 'r') . ' ON ' . $dbo->qn('r.id_employee') . ' = ' . $dbo->qn('a.id_employee'))
943 ->where($dbo->qn('a.id_service') . ' = ' . (int) $this->get('id_service'))
944 ->group($dbo->qn('a.id_employee'))
945 ->order($dbo->qn('count') . ' ASC');
946
947 /**
948 * Trigger hook to allow the plugins to manipulate the default query used
949 * to load the employees assigned to the selected service. The query loads
950 * first the employees with the lowest count of overall reservations, so
951 * that we can have a correct balance.
952 *
953 * It is possible to use this hook to improve this algorithm, in example by
954 * counting only the reservations of the last quarter.
955 *
956 * @param mixed &$query Either a query string or a builder instance.
957 * @param self $search The availability search instance.
958 * @param string $date The check-in date (UTC).
959 *
960 * @return void
961 *
962 * @since 1.7
963 */
964 $results = $dispatcher->trigger('onQueryServiceAvailableEmployees', array(&$q, $this, $date));
965
966 $dbo->setQuery($q);
967
968 // get employees list
969 $employees = $dbo->loadColumn();
970
971 if (!$employees)
972 {
973 // no employees assigned to this service
974 return false;
975 }
976 }
977 else
978 {
979 // avoid duplicates
980 $employees = array_unique($employees);
981 }
982
983 // get list of excluded employees, if any
984 $excluded = (array) $this->get('exclude_employees', array());
985
986 // get rid of the employees that should be excluded
987 $employees = array_diff($employees, $excluded);
988
989 // load reservation model
990 $resModel = JModelVAP::getInstance('reservation');
991 // load employee model
992 $empModel = JModelVAP::getInstance('employee');
993
994 // iterate all the employees found
995 foreach ($employees as $id_employee)
996 {
997 // temporarily set employee ID
998 $this->set('id_employee', $id_employee);
999
1000 /**
1001 * Before to check whether an employees was available on a specific day,
1002 * we need to make sure that it actually works for the specified check-in
1003 * date and time, otherwise a reservation might be assigned to an employee
1004 * that doesn't work for that day.
1005 *
1006 * @since 1.7.1
1007 */
1008 $timeline = $resModel->getAvailableTimes([
1009 'checkin_ts' => $date,
1010 'people' => $people,
1011 'id' => $id,
1012 'id_service' => $this->get('id_service'),
1013 'id_employee' => $this->get('id_employee'),
1014 ]);
1015
1016 if (!$timeline)
1017 {
1018 // not available for the current day, go to the next employee
1019 continue;
1020 }
1021
1022 // convert times into an array
1023 $times = $timeline->toArray($flatten = true);
1024
1025 // get employee timezone
1026 $tz = $empModel->getTimezone($this->get('id_employee'));
1027
1028 // create check-in date and adjust it to the employee timezone
1029 $checkin = JFactory::getDate($date);
1030 $checkin->setTimezone(new DateTimeZone($tz));
1031
1032 // extract time
1033 $hm = $checkin->format('H:i', $local = true);
1034 // convert time to minutes
1035 $hm = JHtml::fetch('vikappointments.time2min', $hm);
1036
1037 // make sure the time is available
1038 if (!isset($times[$hm]) || $times[$hm] != 1)
1039 {
1040 // doesn't work or unavailable for the selected date and time,
1041 // go to the next employee
1042 continue;
1043 }
1044
1045 // validate availability for this employee
1046 if ($this->isEmployeeAvailable($date, $duration, $people, $id))
1047 {
1048 // Yes, first available one.
1049 // Clear employee field before returning the ID
1050 $this->set('id_employee', 0);
1051 return $id_employee;
1052 }
1053 }
1054
1055 // no available employee...
1056 $this->set('id_employee', 0);
1057 return false;
1058 }
1059
1060 /**
1061 * Checks whether there's a closing day/period on the given
1062 * day and for the specified service.
1063 *
1064 * @param string $date The UTC date in military format.
1065 *
1066 * @return boolean True if closed, false otherwise.
1067 */
1068 public function isClosingDay($date)
1069 {
1070 $id_service = (int) $this->get('id_service', 0);
1071
1072 // get supported global closing periods
1073 $closingPeriods = VikAppointments::getClosingPeriods($id_service);
1074
1075 foreach ($closingPeriods as $period)
1076 {
1077 // check whether the date stays between the closing period
1078 if ($period['start'] <= $date && $date <= $period['end'])
1079 {
1080 // closed
1081 return true;
1082 }
1083 }
1084
1085 // get supported global closing days
1086 $closingDays = VikAppointments::getClosingDays($id_service);
1087
1088 $dt = JFactory::getDate($date);
1089
1090 // get date chunks
1091 list($y, $m, $d) = explode('-', $dt->format('Y-m-d'));
1092 // get day of the week
1093 $w = (int) $dt->format('w');
1094
1095 foreach ($closingDays as $day)
1096 {
1097 if ($day['freq'] == 0)
1098 {
1099 // look for single day
1100 if ($date == $day['ts'])
1101 {
1102 // closed
1103 return true;
1104 }
1105 }
1106 else if ($day['freq'] == 1)
1107 {
1108 // look for weekly frequency
1109 if ($w == JFactory::getDate($day['ts'])->format('w'))
1110 {
1111 // closed
1112 return true;
1113 }
1114 }
1115 else if ($day['freq'] == 2)
1116 {
1117 // get date chunks
1118 $app = explode('-', $day['ts']);
1119
1120 // look for monthly frequency (same day)
1121 if ($d == $app[2])
1122 {
1123 return true;
1124 }
1125 }
1126 else if ($day['freq'] == 3)
1127 {
1128 // get date chunks
1129 $app = explode('-', $day['ts']);
1130
1131 // look for yearly frequency (same day and month)
1132 if ($d == $app[2] && $m == $app[1])
1133 {
1134 return true;
1135 }
1136 }
1137 }
1138
1139 // not a closing day
1140 return false;
1141 }
1142
1143 /**
1144 * Checks whether the service is published on the given date.
1145 *
1146 * @param string $date The UTC date in military format.
1147 *
1148 * @return boolean True if closed, false otherwise.
1149 */
1150 public function isServicePublished($date)
1151 {
1152 // get employee timezone
1153 $tz = JModelVAP::getInstance('employee')->getTimezone($this->get('id_employee'));
1154
1155 $date = JFactory::getDate($date)->format('Y-m-d H:i:s');
1156
1157 // get service-employee association model
1158 $model = JModelVAP::getInstance('serempassoc');
1159 // get service-employee overrides
1160 $override = $model->getOverrides((int) $this->get('id_service'), (int) $this->get('id_employee'));
1161
1162 if (!$override)
1163 {
1164 // missing relation
1165 return false;
1166 }
1167
1168 /**
1169 * The publishing status is now ignored if we are in the back-end.
1170 *
1171 * @since 1.7.8
1172 */
1173 if (!$this->isAdmin() && !$override->published)
1174 {
1175 // we have an unpublished service, we don't need to go ahead
1176 return false;
1177 }
1178
1179 // make sure the start publishing has been specified
1180 if (!VAPDateHelper::isNull($override->start_publishing))
1181 {
1182 // create start publishing date, which we should assume that it has been
1183 // specified by using the employee timezone
1184 $start = JFactory::getDate($override->start_publishing);
1185 $start->setTimezone(new DateTimeZone($tz));
1186
1187 if ($date < $start->format('Y-m-d H:i:s', $local = true))
1188 {
1189 // the selected check-in is prior than the publishing date
1190 return false;
1191 }
1192 }
1193
1194 // make sure the end publishing has been specified
1195 if (!VAPDateHelper::isNull($override->end_publishing))
1196 {
1197 // create end publishing date, which we should assume that it has been
1198 // specified by using the employee timezone
1199 $end = JFactory::getDate($override->end_publishing);
1200 $end->setTimezone(new DateTimeZone($tz));
1201
1202 if ($date >= $end->format('Y-m-d H:i:s', $local = true))
1203 {
1204 // the selected check-in is after the end publishing date
1205 return false;
1206 }
1207 }
1208
1209 // make sure the user is capable to access this service
1210 $levels = JFactory::getUser()->getAuthorisedViewLevels();
1211
1212 /**
1213 * The access level is now ignored if we are in the back-end.
1214 *
1215 * @since 1.7.6
1216 */
1217 if (!$this->isAdmin() && $levels && !in_array($override->level, $levels))
1218 {
1219 // not allowed to access this service
1220 return false;
1221 }
1222
1223 // validate days restrictions only in the front-end
1224 if (!$this->isAdmin())
1225 {
1226 $config = VAPFactory::getConfig();
1227
1228 /**
1229 * The booking is allowed only in case the selected check-in date is higher than the
1230 * current date plus the number of days set in configuration. In example, by specifying
1231 * "1 day", the first available date will be one day after the current one (tomorrow).
1232 *
1233 * @since 1.7
1234 */
1235 if ($override->mindate == -1)
1236 {
1237 // used global setting
1238 $override->mindate = $config->getUint('mindate');
1239 }
1240
1241 if ($override->mindate > 0)
1242 {
1243 /**
1244 * Create minimum date from now on.
1245 *
1246 * @since 1.7.8 Refer to the employee timezone to prevent date shifts.
1247 */
1248 $mindate = JFactory::getDate('+' . $override->mindate . ' days 00:00:00', $tz);
1249
1250 if ($date < $mindate->format('Y-m-d H:i:s', true))
1251 {
1252 // check-in date lower than the minimum allowed date
1253 return false;
1254 }
1255 }
1256
1257 /**
1258 * The booking is allowed only in case the selected check-in date is lower than the
1259 * current date plus the number of days set in configuration. In example, by specifying
1260 * "7 days", the first available date will be one week after the current one.
1261 *
1262 * @since 1.7
1263 */
1264 if ($override->maxdate == -1)
1265 {
1266 // used global setting
1267 $override->maxdate = $config->getUint('maxdate');
1268 }
1269
1270 if ($override->maxdate > 0)
1271 {
1272 /**
1273 * Create minimum date from now on.
1274 *
1275 * @since 1.7.8 Refer to the employee timezone to prevent date shifts.
1276 */
1277 $maxdate = JFactory::getDate('+' . $override->maxdate . ' days 23:59:59', $tz);
1278
1279 if ($date > $maxdate->format('Y-m-d H:i:s', true))
1280 {
1281 // check-in date higher than the maximum allowed date
1282 return false;
1283 }
1284 }
1285 }
1286
1287 /**
1288 * This event can be used to apply additional conditions while checking whether
1289 * the specified service is published or not. When this event is triggered, the
1290 * system already validated the standard conditions and the service is going
1291 * to be used by the website.
1292 *
1293 * @param object $service The service to check.
1294 * @param string $checkin The check-in date (UTC).
1295 *
1296 * @return boolean Return false to hide the service.
1297 *
1298 * @since 1.7
1299 */
1300 if (VAPFactory::getEventDispatcher()->false('onCheckServiceVisibility', array($override, $date)))
1301 {
1302 // a plugin decided to hide the service
1303 return false;
1304 }
1305
1306 return true;
1307 }
1308
1309 /**
1310 * Checks if the specified date is in the past or doesn't follow the
1311 * booking minutes restriction of the service.
1312 *
1313 * @param string $datetime The check-in date time (military format).
1314 *
1315 * @return boolean True if in the past, false otherwise.
1316 */
1317 public function isPastTime($datetime)
1318 {
1319 if ($this->isAdmin())
1320 {
1321 // never look for times in the past for admin
1322 return false;
1323 }
1324
1325 // get service-employee association model
1326 $model = JModelVAP::getInstance('serempassoc');
1327 // load service overrides or default details in case of missing employee
1328 $service = $model->getOverrides($this->get('id_service'), $this->get('id_employee'));
1329
1330 // get default booking minutes restrictions
1331 $advance = VAPFactory::getConfig()->getUint('minrestr');
1332
1333 /**
1334 * Check if we should use different restrictions
1335 * depending on the selected service.
1336 *
1337 * @since 1.6.5
1338 */
1339 if (isset($service->minrestr) && $service->minrestr != -1)
1340 {
1341 // use specified service restrictions
1342 $advance = (int) $service->minrestr;
1343 }
1344
1345 // make sure the time was set within the date time
1346 if (preg_match("/^[0-9]{4,4}-[0-9]{2,2}-[0-9]{2,2}$/", $datetime))
1347 {
1348 // time is missing, use the end of the day as threshold
1349 $datetime = JFactory::getDate($datetime . ' 23:59:59')->format('Y-m-d H:i:s');
1350 }
1351
1352 /**
1353 * Trigger event to let the plugins can calculate their own advance time,
1354 * also known as "Booking Minutes Restrictions".
1355 * Only the highest returned value will be used, also compared to the
1356 * default one.
1357 *
1358 * @param integer $advance The default "advance" amount.
1359 * @param string $datetime The check-in date (@since 1.7 changed from timestamp).
1360 * @param object $service The details of the booked service (@since 1.7 changed from array).
1361 *
1362 * @return integer The overwritten "advance" amount (in minutes).
1363 *
1364 * @since 1.6.6
1365 */
1366 $results = VAPFactory::getEventDispatcher()->trigger('onCalculateAdvanceTime', array($advance, $datetime, $service));
1367
1368 if ($results)
1369 {
1370 // keep only the highest amount
1371 $advance = max($results);
1372 }
1373
1374 // get the correct timezone
1375 $tz = JModelVAP::getInstance('employee')->getTimezone($this->get('id_employee'));
1376
1377 // create threshold by adding the advance minutes to the current time
1378 $threshold = JFactory::getDate('+' . $advance . ' minutes');
1379 $threshold->setTimezone(new DateTimeZone($tz));
1380
1381 // check whether the check-in is prior than the fetched threshold
1382 return $datetime < $threshold->format('Y-m-d H:i:s', $local = true);
1383 }
1384 }
1385