File: D:/HostingSpaces/SBogers10/farmfun.komma.pro/app/Komma/Availability/AvailabilityService.php
<?php
namespace App\Komma\Availability;
use App\Komma\Availability\Queries\AbstractAvailabilityProductFilterQuery;
use App\Komma\Availability\Queries\ProductCategoryQuery;
use App\Komma\Availability\Queries\SearchAmountQuery;
use App\Komma\Availability\Types\Availability;
use App\Komma\Availability\Types\TimeSlot;
use App\Komma\Base\Service;
use App\Komma\CalendarBlockOuts\CalendarBlockOutService;
use App\Komma\LocationProducts\Models\LocationProduct;
use App\Komma\Locations\LocationService;
use App\Komma\Locations\Models\Location;
use App\Komma\Locations\Types\DayInfo;
use App\Komma\ProductCategories\Models\ProductCategory;
use App\Komma\Products\Models\Product;
use App\Komma\Reservations\Models\ReservationItem;
use App\Komma\SearchAmountOfPersons\SearchAmountOfPersonsService;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\MessageBag;
final class AvailabilityService extends Service
{
public static $reservedItems;
private static $locationId;
const SEARCH_LOCATION_KEY = 'searchLocation';
const SEARCH_DATE_KEY = 'searchDate';
const SEARCH_AMOUNT_KEY = 'searchAttendeesCount';
const SEARCH_VARIABLES = [self::SEARCH_LOCATION_KEY, self::SEARCH_DATE_KEY, self::SEARCH_AMOUNT_KEY];
/** @var array */
private $additionalProductFilters = [];
/**
* Check and return possible redirect response if search for wrong amount of persons
*
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector|void
*/
public function checkIfShouldRedirect()
{
if (! $searchAmountKey = request()->input(__('site/availability.keys.' . self::SEARCH_AMOUNT_KEY))) {
return;
}
/** @var SearchAmountOfPersonsService $searchAmountOfPersonsService */
$searchAmountOfPersonsService = app(SearchAmountOfPersonsService::class);
if (! $searchAmountOption = $searchAmountOfPersonsService->getOptionByLabel($searchAmountKey)) {
throw new \UnexpectedValueException(self::class . ': Option ' . $searchAmountKey . ' not found in searchAmountOptions collection ');
}
if (empty($searchAmountOption->redirect_to_page)) {
return;
}
$searchAmountOption->load('redirectPage');
// Throw error if relation can't be resolved or is inactive
if (! isset($searchAmountOption->redirectPage) || ! $searchAmountOption->redirectPage->active) {
throw new \LogicException(self::class . ': Redirect page (id: ' . $searchAmountOption->redirect_to_page . ') has not been found or is inactive.');
}
// Load the needed relation for redirecting
$searchAmountOption->redirectPage->load('translation', 'translation.route');
// Throw error if relation can't be resolved or is inactive
if (empty($searchAmountOption->redirectPage->translation) || empty($searchAmountOption->redirectPage->translation->route)) {
throw new \LogicException(self::class . ': Redirect page "' . $searchAmountOption->redirectPage->code_name . '" has not a translation and/or route');
}
if (! empty($searchAmountOption->redirect_message)) {
return redirect($searchAmountOption->redirectPage->translation->route->alias, 303)
->with([
'flashMessages' => new MessageBag([
[
'type' => 'warning',
'message' => $searchAmountOption->redirect_message,
],
]),
]);
}
// Redirect
return redirect($searchAmountOption->redirectPage->translation->route->alias, 303);
}
/**
* Make an availability out of the request information
*
* @return Availability
*/
public function makeAvailabilityFromRequest(array $availabilityRequest)
{
// Make availability out of request.
$availability = $this->makeAvailabilityByProductAndLocationId($availabilityRequest['product_id'], $availabilityRequest['location_id']);
$availability->setDate($availabilityRequest['date']);
$availability->setAmountOfPersons($availabilityRequest['amount_of_persons']);
// Append the possible additional settings to the availability
// if(request()->get('amount_of_persons', '') != '') $availability->setAmountOfPersons(request()->get('amount_of_persons'));
// if(!empty($availabilityRequest['date'])) $availability->setDate($availabilityRequest['date']);
// if(request()->get('notifications', '') != '') $availability->setNotification(request()->get('notifications'));
return $availability;
}
/**
* Make an availability out of the request information
*
* @return Availability
*/
public function makeAvailabilityFromGetRequest($product, int $location, Carbon $date)
{
// Make availability out of request.
$availability = $this->makeAvailabilityByProductAndLocationId($product->id, $location);
// Append the possible additional settings to the availability
$availability->setDate($date->format('d-m-Y'));
if (isset($product->timeSlot)) {
$availability->timeSlot = $product->timeSlot;
}
if ($product->persons != '') {
$availability->setAmountOfPersons($product->persons);
}
return $availability;
}
/**
* Make an availability out of the product and location
*
* @param int $productId
* @param int $locationId
* @return Availability
*/
public function makeAvailabilityByProductAndLocationId(int $productId, int $locationId) : Availability
{
$product = Product::where('id', $productId)
->where('active', 1)
->first();
$location = Location::where('id', $locationId)
->where('active', 1)
->first();
return new Availability($product, $location);
}
/**
* Check if availability class is possible within the system.
*
* @param Availability $availability
* @return bool
* @throws \Illuminate\Contracts\Container\BindingResolutionException
*/
public function isAvailabilityValid(Availability &$availability, bool $appendTimeSlots = false) : bool
{
/** @var DayInfo $dayInfo */
$dayInfo = $availability->getLocation()->availability->infoForDay($availability->getDate());
// Special exception for given product
if ($availability->getProduct()->id == 50) {
$nthReservations = ReservationItem::where('location_id', '=', $availability->getLocation()->id)
->where('product_id', '=', 50)
->where('date', '=', $availability->getDate()->format('Y-m-d'))
->count();
if ($availability->getDate()->format('Y-m-d') !== '2022-09-18') {
$availability->available = false;
$availability->available_reason = 'unavailable';
return false;
}
if ($nthReservations < 12) {
$availability->available = true;
} else {
$availability->available = false;
$availability->available_reason = 'full';
return false;
}
if ($appendTimeSlots) {
$timeSlotDate = $availability->getDate();
$startTime = clone $timeSlotDate;
$startTime->setHour(13);
$endTime = clone $timeSlotDate;
$endTime->setHour(16);
$availability->timeSlots = collect([new TimeSlot($startTime, $endTime, $endTime)]);
}
return true;
}
/** Check if day is available at location */
if (! $dayInfo->open) {
$availability->available_reason = 'closed';
return $availability->available = false;
}
/**
* Check if availability has been blocked for that day.
* This could be because of the whole day has been blocked for the location or a activity specific
*
* @var CalendarBlockOutService $calendarBlockOutService
*/
$calendarBlockOutService = app()->make(CalendarBlockOutService::class);
if ($calendarBlockOutService->isAvailabilityBlockedForToday($availability)) {
$availability->available_reason = 'unavailable';
return $availability->available = false;
}
// Make time slots for availability
$timeSlots = $this->makeTimeSlots($dayInfo, $availability->getProduct()->system_duration, $availability->getProduct()->duration, $availability);
/** Check if there are some time slots not locked. */
if ($timeSlots->where('locked', false)->count() <= 0) {
$availability->available_reason = 'full';
return $availability->available = false;
}
// if($availability->getLocation()->id === 6 && app()->environment('local')) {
// $availability->available_reason = 'full';
// return $availability->available = false;
// }
// Everything is valid
if ($appendTimeSlots) {
$availability->timeSlots = $timeSlots;
}
return $availability->available = true;
}
/**
* Append the desired additional function we want to call.
* Set the productCategory
*
* @param ProductCategory $productCategory
*/
public function setProductCategoryFilter(ProductCategory $productCategory)
{
$this->additionalProductFilters[] = new ProductCategoryQuery($productCategory);
}
/**
* Get the availabilityResultCollection based upon the defined search inputs
*
* @return AvailabilityResultCollection
*/
public function getAvailabilityResultCollection(): AvailabilityResultCollection
{
/** @var LocationService $locationService */
$locationService = app(LocationService::class);
/** @var AvailabilityResultCollection $availabilityResultCollection */
$availabilityResultCollection = new AvailabilityResultCollection();
// If location key is 0 (is statically defined), we load all the available location
if (session()->get(self::SEARCH_LOCATION_KEY, 0) == 0) {
$locations = $locationService->getLocations();
} else {
// Else we get the filled location by looking up it's key
$location = $locationService->getLocationById(session()->get(self::SEARCH_LOCATION_KEY));
$locations = collect([$location]);
}
foreach ($locations as $location) {
$locationProducts = $this->getAvailableProductsForLocation($location);
$availabilityResults = $this->convertProductsIntoAvailabilityResults($locationProducts, $location);
$availabilityResultCollection->addResults($availabilityResults);
}
return $availabilityResultCollection;
}
/**
* Get the bound products of a location filtered by the search parameters
*
* @param Location $location
* @return Collection
*/
private function getAvailableProductsForLocation(Location $location)
{
// First of all, get the bound products by location
$productFilterQuery = $location->boundProducts()
->where('hide_on_site', '!=', 1);
// Filter the products that are within the amount of persons range
$searchAmountQuery = new SearchAmountQuery();
if ($searchAmountQuery->shouldRunFilter()) {
$productFilterQuery = $searchAmountQuery->filter($productFilterQuery);
}
// Call additional filter query we have defined in additionalProductFilters
// This will be classes that are in the Availability/Queries folder
/** @var AbstractAvailabilityProductFilterQuery $additionalProductFilter */
foreach ($this->additionalProductFilters as $additionalProductFilter) {
$productFilterQuery = $additionalProductFilter->filter($productFilterQuery);
}
return $productFilterQuery->with('translation', 'overviewImage')->get();
}
/**
* Convert the Products into AvailabilityResults
*
* @param Collection $products
* @param Location $location
* @return \Illuminate\Support\Collection
*/
private function convertProductsIntoAvailabilityResults(Collection $products, Location $location)
{
$results = collect();
foreach ($products as $product) {
$result = new Availability($product, $location);
$result->appendCapacity($product->pivot->max_persons_each_block, $product->pivot->available_each_block, $product->pivot->capacity_type);
$date = session()->get(self::SEARCH_DATE_KEY);
if (! isset($date)) {
$date = Carbon::today()->addDays(14)->format('d-m-Y');
}
$result->setDate($date);
$this->isAvailabilityValid($result);
$results->push($result);
}
return $results;
}
/**
* Gets a random availability for each location
*
* @return AvailabilityResultCollection
*/
public function getRandomAvailabilityForEachLocation(): AvailabilityResultCollection
{
/** @var LocationService $locationService */
$locationService = app(LocationService::class);
$locations = $locationService->getLocations();
$locations->load('availability');
/** @var AvailabilityResultCollection $availabilityResultCollection */
$availabilityResultCollection = new AvailabilityResultCollection();
$excludedIds = [];
foreach ($locations as $location) {
// First of all, get the bound products by location
$locationProduct = $location->boundProducts()
->where('hide_on_site', '!=', 1)
->where('product_type', '=', 0) // Do - Activities
->whereNotIn('products.id', $excludedIds)
->where('products.price_each_unit', '!=', 0)
->inRandomOrder()
->take(1)
->get();
// Prevent duplicate products
if ($locationProduct->count() != 0) {
$excludedIds[] = $locationProduct->first()->id;
}
$availabilityResults = $this->convertProductsIntoAvailabilityResults($locationProduct, $location);
$availabilityResultCollection->addResults($availabilityResults);
}
return $availabilityResultCollection;
}
/**
* Get an amount of random availbilities for a given location and e
*
* @param Location $location
* @param int $amount
* @param array $excludedProductIds
* @return AvailabilityResultCollection
*/
public function getAmountOfRandomAvailabilitiesForLocationWhereNot(Location $location, int $amount, array $excludedProductIds = []): AvailabilityResultCollection
{
/** @var AvailabilityResultCollection $availabilityResultCollection */
$availabilityResultCollection = new AvailabilityResultCollection();
$otherAvailabilitiesQuery = $location->boundProducts()
->where('hide_on_site', '!=', 1);
// If we have any excluded ids we run the where not in
if (count($excludedProductIds) != 0) {
$otherAvailabilitiesQuery = $otherAvailabilitiesQuery->whereNotIn('products.id', $excludedProductIds);
}
$otherAvailabilities = $otherAvailabilitiesQuery->inRandomOrder()
->take($amount)
->get();
$availabilityResults = $this->convertProductsIntoAvailabilityResults($otherAvailabilities, $location);
$availabilityResultCollection->addResults($availabilityResults);
return $availabilityResultCollection;
}
/**
* Get an amount of food activities for a given location
*
* @param int $amount
* @param array $excludedProductIds
* @return \Illuminate\Support\Collection
*/
public function getAmountOfFoodAvailabilities(int $amount, array $excludedProductIds, Location $location): \Illuminate\Support\Collection
{
$productQuery = Product::where('active', 1)
->where('hide_on_site', '!=', 1)
->where('product_type', '=', 1) // Food is product Type 1
->with('overviewImage', 'translation', 'locations');
// If we have any excluded ids we run the where not in
if (count($excludedProductIds) != 0) {
$productQuery = $productQuery->whereNotIn('id', $excludedProductIds);
}
return $productQuery->orderBy('price_each_unit', 'asc')
->take($amount)
->get()
->map(function ($product) use ($location) {
$locationProduct = LocationProduct::where('product_id', $product->id)
->where('location_id', $location->id)
->first();
return (object) [
'id' => $product->id,
'name' => $product->translation->name,
'slug' => $product->translation->slug,
'overview_image' => $product->overviewImage->small_image_url ?? null,
'price_label' => $this->getCartLabel($locationProduct, $product),
'locations' => $product->locations->pluck('id')->toArray() ?? [],
'minimum_amount_of_persons' => $product->minimum_amount_of_persons,
];
});
}
/**
* Make time slots by the given day info and the duration.
*
* @param DayInfo $dayInfo
* @param $duration
* @param $visualDuration
* @param Availability $availability
* @return \Illuminate\Support\Collection
*/
public function makeTimeSlots(DayInfo $dayInfo, $duration, $visualDuration, Availability $availability = null): \Illuminate\Support\Collection
{
/** @var \Illuminate\Support\Collection $timeSlots */
$timeSlots = collect();
// First time slot
$loopableTimeSlot = $dayInfo->from;
// Day end
$dayInfoEnd = clone $dayInfo;
$dayInfoEnd->latest_start->addHours($visualDuration);
// Loop till we have reached the cap (or latest_start or end time exceed till)
while ($loopableTimeSlot <= $dayInfoEnd->latest_start) {
// clone the time slot (because it remembers it origin and will also increment with the loop)
$slotStartTime = clone $loopableTimeSlot;
$slotEndTime = clone $loopableTimeSlot;
$slotEndTime->addMinutes($duration * 60);
$slotVisualEndTime = clone $loopableTimeSlot;
$slotVisualEndTime->addMinutes($visualDuration * 60);
// If the end time is larger the end of the openings hours break
if ($slotEndTime > $dayInfoEnd->latest_start || $slotStartTime > $dayInfo->till) {
break;
}
$timeSlot = new TimeSlot($slotStartTime, $slotEndTime, $slotVisualEndTime);
// If availability is given, we also validate if the slot is available
if (isset($availability)) {
$timeSlot->locked = ! $this->isTimeSlotAvailable($timeSlot, $availability);
}
// Append a new TimeSlot
$timeSlots[] = $timeSlot;
// Increment the time slot
$loopableTimeSlot->addMinutes(config('site.time_gap'));
// $breakCounter++;
}
return $timeSlots;
}
/**
* Validate a given time slot
*
* @param TimeSlot $timeSlot
* @param Availability $availability
* @return bool
* @throws \Illuminate\Contracts\Container\BindingResolutionException
*/
public function isTimeSlotAvailable(TimeSlot $timeSlot, Availability $availability): bool
{
if (! self::$reservedItems || $availability->getLocation()->id !== self::$locationId) {
self::$locationId = $availability->getLocation()->id;
self::$reservedItems = ReservationItem::where('location_id', '=', $availability->getLocation()->id)
->where('date', '=', $availability->getDate()->format('Y-m-d'))
->orderBy('start_time')
->get();
}
/**
* Check that the timeslot is not locked because upon a block out
* @var CalendarBlockOutService $calendarBlockOutService
*/
$calendarBlockOutService = app()->make(CalendarBlockOutService::class);
if ($calendarBlockOutService->isTimeSlotForAvailabilityBlocked($timeSlot, $availability)) {
return false;
}
// First prep some values for the next checks
// $reservations = self::$reservedItems->groupBy('reservation_id'); // Items group by reservation
$reservationStartItems = self::$reservedItems->unique('reservation_id'); // Start items of the reservations
$reservationItemsWithOverlap = self::$reservedItems->where('end_time', '>', $timeSlot->start->format('H:i:s'))
->where('start_time', '<', $timeSlot->end->format('H:i:s'));
/**
* Check if the amount of starting reservation at the same time is below the max amount of starting
* This is defined in the config
*/
$amountOfReservationWithSameStartTime = $reservationStartItems->where('start_time', '=', $timeSlot->start->format('H:i:s'))->count();
if ($amountOfReservationWithSameStartTime >= config('site.max_start_item_each_time_gap')) {
return false;
}
/**
* Checks that only needs to be done when there are overlapping items
*/
if ($reservationItemsWithOverlap->count() >= 1) {
// Get the overlapping activities with the same products
$sameActivitiesWithOverlap = $reservationItemsWithOverlap->where('product_id', $availability->getProduct()->id);
/**
* Checks only for same activities within the overlapping
*/
if ($sameActivitiesWithOverlap->count() >= 1) {
if (! $this->validateCapacity($timeSlot, $availability, $sameActivitiesWithOverlap)) {
return false;
}
}
}
// No check have return false so therefor available
return true;
}
private function validateCapacity(TimeSlot $timeSlot, Availability $availability, $overlappingActivities)
{
// Get capacity values from availability
[$maxPersonEachBlock, $availableEachBlock, $capacityType] = $availability->getCapacity();
// Quick exit functions for the capacity types
// This way we don't have to loop through the possible overlapping time slots :)
switch ($capacityType) {
// Unlimited therefor always true
case LocationProduct::CAPACITY_TYPE_UNLIMITED:
return true;
case LocationProduct::CAPACITY_TYPE_MAX_PERSONS:
if ($maxPersonEachBlock == 0) {
Log::channel('daily')->warning(self::class . ': ValidateCapacity function has been called but in fact the LocationAvailability (for product id "' . $availability->getProduct()->id . '" and location id "' . $availability->getLocation()->id . '") should be inactive.. ');
return false;
}
break;
case LocationProduct::CAPACITY_TYPE_AVAILABLE:
if ($availableEachBlock == 0) {
Log::channel('daily')->warning(self::class . ': ValidateCapacity function has been called but in fact the LocationAvailability (for product id "' . $availability->getProduct()->id . '" and location id "' . $availability->getLocation()->id . '") should be inactive.. ');
return false;
}
break;
case LocationProduct::CAPACITY_TYPE_MAX_PERSONS_AND_AVAILABLE:
if ($availableEachBlock == 0 || $maxPersonEachBlock == 0) {
Log::channel('daily')->warning(self::class . ': ValidateCapacity function has been called but in fact the LocationAvailability (for product id "' . $availability->getProduct()->id . '" and location id "' . $availability->getLocation()->id . '") should be inactive.. ');
return false;
}
break;
default:
throw new \RuntimeException(self::class . ': LocationProduct for constant value "' . $capacityType . '" has not been defined.');
}
$loopTimeBlock = $timeSlot->start;
// Loop till we have reached the cap (or latest_start or end time exceed till)
while ($loopTimeBlock < $timeSlot->end) {
// clone the time slot (because it remembers it origin and will also increment with the loop)
$slotStartTime = clone $loopTimeBlock;
$slotEndTime = clone $loopTimeBlock;
$slotEndTime->addMinutes(config('site.time_gap'));
// Get the activities during this time block
$activitiesInTimeSlots = $overlappingActivities->where('end_time', '>', $slotStartTime->format('H:i:s'))
->where('start_time', '<', $slotEndTime->format('H:i:s'));
/**
* If zero activities continue
*/
if ($activitiesInTimeSlots->count() == 0) {
$loopTimeBlock = $slotEndTime;
continue;
}
switch ($capacityType) {
case LocationProduct::CAPACITY_TYPE_MAX_PERSONS:
if ($this->doesAmountOfPersonsExceedsCapacity($availability, $activitiesInTimeSlots, $maxPersonEachBlock)) {
return false;
}
break;
case LocationProduct::CAPACITY_TYPE_AVAILABLE:
if ($activitiesInTimeSlots->count() >= $availableEachBlock) {
return false;
}
break;
case LocationProduct::CAPACITY_TYPE_MAX_PERSONS_AND_AVAILABLE:
debug($activitiesInTimeSlots->count() >= $availableEachBlock);
if ($activitiesInTimeSlots->count() >= $availableEachBlock) {
return false;
}
if ($this->doesAmountOfPersonsExceedsCapacity($availability, $activitiesInTimeSlots, $maxPersonEachBlock)) {
return false;
}
break;
default:
throw new \RuntimeException(self::class . ': LocationProduct handling for constant value "' . $capacityType . '" has not been defined.');
}
$loopTimeBlock = $slotEndTime;
}
return true;
}
/**
* @param Availability $availability
* @param Collection $activitiesInTimeSlots
* @param int $maxPersonEachBlock
* @return bool
*/
private function doesAmountOfPersonsExceedsCapacity(Availability $availability, Collection $activitiesInTimeSlots, int $maxPersonEachBlock): bool
{
// Start value is amount of persons of when undefined the minimum amount of persons
$capacityFilled = $availability->getAmountOfPersons();
if (! isset($capacityFilled)) {
$capacityFilled = $availability->getProduct()->minimum_amount_of_persons;
}
// Increment the capacity with the already stored items
foreach ($activitiesInTimeSlots as $activityInTimeSlots) {
$capacityFilled += $activityInTimeSlots->quantity;
}
// If capacity exceed the max persons each block fail validation
if ($capacityFilled > $maxPersonEachBlock) {
return true;
}
return false;
}
private function getCartLabel($locationProduct, $product): string
{
if (! $locationProduct || $locationProduct->use_location_product_price === 0) {
return $product->getCartLabel(true, false) . ' (incl. btw)';
}
if ($locationProduct->price_each_unit !== 0) {
$cartLine = config('site.shop_currency') . ' ' . euro_pricing_format($locationProduct->price_each_unit) . ' ' . __('site/availability.price_each_person');
if (! $locationProduct->has_fixed_price && $locationProduct->price_start_up !== 0) {
$cartLine .= ' <br/>+ ' . config('site.shop_currency') . ' ' . euro_pricing_format($locationProduct->price_start_up) . ' ' . __('site/availability.start_up_small');
}
$cartLine .= ' (incl. btw)';
return $cartLine;
} else {
return config('site.shop_currency') . ' ' . $locationProduct->getPriceLabel(true, false) . ' (incl. btw)';
}
}
}