merci-8.x-2.x-dev/src/ReservationConflicts.php
src/ReservationConflicts.php
<?php /** * @file * Contains \Drupal\merci\ReservationConflicts. * Abstraction of the selection logic of an entity reference field. * * Implementations that wish to provide an implementation of this should * register it using CTools' plugin system. */ namespace Drupal\merci; use \Drupal\merci\ReservationConflictsInterface; use \Drupal\Core\Link; /** * A null implementation of EntityReference_SelectionHandler. */ class ReservationConflicts implements ReservationConflictsInterface { protected $entity; protected $date_field; protected $item_field; protected $quantity_field; protected $validated; protected $parent_quantity_field; protected $conflicting_entities; protected $total_buckets_filled; protected $buckets; protected $errors; protected $date_column, $date_column2; public function setEntity(\Drupal\Core\Entity\FieldableEntityInterface $entity) { $this->entity = $entity; } public function getEntity() { return $entity; } public function setDateField($date_field) { $this->date_field = $date_field; $date_storage = $this->entity->get($this->date_field)->getFieldDefinition()->getFieldStorageDefinition(); $date_columns = $date_storage->getColumns(); $this->date_column = $this->date_field . '_' . key($date_columns); next($date_columns); $this->date_column2 = $this->date_field . '_' . key($date_columns); } public function getDateField() { return $date_field; } public function setItemField($item_field) { $this->item_field = $item_field; } public function getItemField() { return $item_field; } public function setQuantityField($quantity_field) { $this->quantity_field = $quantity_field; } public function getQuantityField() { return $quantity_field; } public function setParentQuantityField($parent_quantity_field) { $this->parent_quantity_field = $parent_quantity_field; } public function getParentQuantityField() { return $parent_quantity_field; } public function validate() { if (!$this->validated) { $this->buckets = $this->fillBuckets(); $this->validated = TRUE; $conflicts = array(); foreach ($this->buckets as $delta => $dates) { foreach ($dates as $date_value => $buckets){ if (!isset($this->total_buckets_filled[$delta])) { $this->total_buckets_filled[$delta] = array(); } $this->total_buckets_filled[$delta][$date_value] = count($buckets); if (!isset($conflicts[$delta])) { $conflicts[$delta] = array(); } $conflicts[$delta][$date_value] = array(); foreach ($buckets as $bucket) { $conflicts[$delta][$date_value] = array_merge($conflicts[$delta][$date_value], $bucket); } } } $this->conflicting_entities = $conflicts; } } public function getErrors($delta = NULL) { if ($this->errors === NULL) { $this->validate(); $entity = $this->entity; $entity_type = $this->entity->getEntityTypeId(); $errors = array(); // Determine if reserving too many of the same item. // How many of each item are we trying to reserve? if ($entity->hasField($this->parent_quantity_field)) { $quantity_reserved = $entity->get($this->parent_quantity_field)->value; } else { $quantity_reserved = 1; } // How many times was the item selected? foreach ($entity->get($this->item_field) as $delta => $resource) { $item_id = $resource->target_id; if (empty($item_id)) { continue; } if (empty($item_count[$item_id])) { $item_count[$item_id] = 0; } $item_count[$item_id] += $quantity_reserved; if ($resource->entity->hasField($this->quantity_field)) { $quantity_reservable = $resource->entity->get($this->quantity_field)->value; } else { $quantity_reservable = 1; } // Did we select too many? if ($item_count[$item_id] > $quantity_reservable) { // Selected to many. if (!array_key_exists($delta, $errors)) { $errors[$delta] = array(); } $parents_path = implode('][', array($this->item_field, 'und', $delta, 'target_id')); $errors[$delta][MERCI_ERROR_TOO_MANY] = t('@name: You have selected too many of the same item. We only have @quantity available but you reserved @reserved.', array( '@name' => $resource->entity->label(), '@quantity' => $quantity_reservable, '@reserved' => $item_count[$item_id], )); } } $total_buckets_filled = $this->getTotalBucketsFilled(); $total_buckets_filled = $total_buckets_filled ? $total_buckets_filled : array(); $reservation_counter = array(); foreach ($total_buckets_filled as $delta => $start_dates) { $conflict_errors = array(); $resource = $entity->get($this->item_field)[$delta]; if ($resource->entity->hasField($this->quantity_field)) { $quantity_reservable = $resource->entity->get($this->quantity_field)->value; } else { $quantity_reservable = 1; } $item_id = $resource->target_id; if (empty($reservation_counter[$item_id])) { $reservation_counter[$item_id] = 0; } $reservation_counter[$item_id] += $quantity_reserved; foreach ($this->entity->get($this->date_field) as $dates) { $used_buckets = $this->getTotalBucketsFilled($delta, $dates); // Determine if there are conflicts for this date and item. if ($quantity_reservable >= $used_buckets + $reservation_counter[$item_id]) { continue; } // Load each conflicting entity so we can show information about it to // the user. $ids = array(); foreach ($this->getConflicts($delta, $dates) as $conflict) { $ids[] = $conflict->parent_id; } // Load the entities which hold the conflicting item. $entities = \Drupal::entityManager()->getStorage($entity_type)->loadMultiple($ids); $line_items = array(); foreach ($entities as $id => $line_item) { $entity_uri = $line_item->toUrl();//entity_uri($entity_type, $line_item); $entity_label = $line_item->label();//entity_label($entity_type, $line_item); $line_items[] = Link::fromTextAndUrl($entity_label, $entity_uri)->toString(); } $date_start = $dates->get('value')->getValue(); // Don't show the date repeat rule in the error message. // @FIXME //$render_dates = field_view_value($entity_type, $entity->value(), $this->date_field, $dates); $conflict_errors[$date_start] = t('@name is already reserved by: :items for selected dates @dates', array( '@name' => $resource->entity->label(), ':items' => implode(', ', $line_items), '@dates' => render($render_dates), )); } if ($conflict_errors) { if (!array_key_exists($delta, $errors)) { $errors[$delta] = array(); } $errors[$delta][MERCI_ERROR_CONFLICT] = $conflict_errors; } } $this->errors = $errors; } return $this->errors; } public function getConflicts($delta = NULL, $dates = NULL) { $this->validate(); $conflicts = $this->conflicting_entities; if ($delta === NULL) { return $conflicts; } if (empty($dates)) { return array_key_exists($delta, $conflicts) ? $conflicts[$delta] : FALSE; } $date_value = $dates->get('value')->getValue(); return (array_key_exists($delta, $conflicts) and array_key_exists($date_value, $conflicts[$delta])) ? $conflicts[$delta][$date_value] : FALSE; } public function getTotalBucketsFilled($delta = NULL, $dates = NULL) { $this->validate(); $total_buckets_filled = $this->total_buckets_filled; if ($delta === NULL) { return $total_buckets_filled; } if (empty($dates)) { return array_key_exists($delta, $total_buckets_filled) ? $total_buckets_filled[$delta] : 0; } $date_value = $dates->get('value')->getValue(); return (array_key_exists($delta, $total_buckets_filled) and array_key_exists($date_value, $total_buckets_filled[$delta])) ? $total_buckets_filled[$delta][$date_value] : 0; } /* * Determine if merci_line_item $entity conflicts with any other existing line_items. * * Returns array of conflicting line items. */ public function conflicts($date) { $conflicts = array(); $date_value = $date->get('value')->getValue(); $query = $this->buildConflictQuery($date); $result = $query->execute(); $line_item_entities = \Drupal::entityTypeManager()->getStorage($this->entity->getEntityTypeId())->loadMultiple($result); foreach ($line_item_entities as $entity) { $dates = $entity->{$this->date_field}->getValue(); $dates = reset($dates); foreach ($entity->{$this->item_field} as $item) { $target_id = $item->{'target_id'}; $record = new \stdClass(); $record->item_id = $target_id; $record->parent_id = $entity->id(); if ($entity->hasField($this->parent_quantity_field)) { $record->quantity = (int)$entity->get($this->parent_quantity_field)->value; } else { $record->quantity = 1; } $record->{$this->date_column} = $dates['value']; $record->{$this->date_column2} = $dates['end_value']; if (!isset($conflicts[$target_id])) { $conflicts[$target_id] = array(); } if (!isset($conflicts[$target_id][$date_value])) { $conflicts[$target_id][$date_value] = array(); } $conflicts[$target_id][$date_value][] = $record; } } $return = array(); $items = $this->entity->get($this->item_field); foreach ($items as $delta => $item) { if (isset($conflicts[$item->target_id])) { $return[$delta] = $conflicts[$item->target_id]; } } return $return; } public function conflictingEntities($date, $item = NULL) { $date_value = $date->get('value')->getValue(); $query = $this->buildConflictQuery($date, $item); $query->addTag('debug'); $result = $query->execute(); $line_item_entities = \Drupal::entityTypeManager()->getStorage($this->entity->getEntityTypeId())->loadMultiple($result); return $line_item_entities; } public function buildConflictQuery($date, $item = NULL) { $exclude_id = $this->entity->id(); $entity_type = $this->entity->getEntityTypeId(); $items = array(); if ($item) { $items[] = $item->target_id; } else { foreach ($this->entity->get($this->item_field) as $delta => $item) { $items[] = $item->target_id; } } // Build the query. // Entity type is the entity holding the date and item fields. $query = \Drupal::entityQuery($entity_type); if (count($items) == 1) { $query->condition($this->item_field, reset($items)); } else { $query->condition($this->item_field, $items, 'IN'); } // Ignore myself. if ($exclude_id) { $entity_type_id_key = $this->entity->getEntityType()->getKey('id'); $query->condition($entity_type_id_key, $exclude_id, '!='); } $dates = array( 'value' => $date->get('value')->getValue(), 'end_value' => $date->get('end_value')->getValue() ); // start falls within another reservation. // |-------------this-------------| // |-------------conflict-------------------------| // OR // |-------------this-------------------------------| // |-------------conflict-------------------------| $and1 = $query->andConditionGroup() ->condition($this->date_field . '.value', $dates['value'], '<=') ->condition($this->date_field . '.end_value', $dates['value'], '>='); // end falls within another reservation. // |-------------this-------------------------------| // |-------------conflict-------------------------| $and2 = $query->andConditionGroup() ->condition($this->date_field . '.value', $dates['end_value'], '<=') ->condition($this->date_field . '.end_value', $dates['end_value'], '>='); // start before another reservation. // end after another reservation. // |-------------------------this-------------------------------| // |----------------conflict------------------| $and3 = $query->andConditionGroup() ->condition($this->date_field . '.value', $dates['value'], '>') ->condition($this->date_field . '.end_value', $dates['end_value'], '<'); $or = $query->orConditionGroup() ->condition($and1) ->condition($and2) ->condition($and3); $query->condition($or); $query->sort($this->date_field . '.value'); // Add a generic entity access tag to the query. $query->addTag('merci_resource'); $query->addMetaData('merci_reservable_handler', $this); return $query; } public function reservations($dates, $exclude_id) { $bestfit = $this->bestFit($dates); $reservations = array(); foreach ($bestfit as $enity_id => $reservation) { $reservations[] = $entity_id; } return $reservations; } public function fillBuckets() { $conflicts = array(); $dates = $this->entity->get($this->date_field); foreach ($dates as $date) { $date_value = $date->get('value')->getValue(); $result = $this->bestFit($date); // Result is array indexed by $delta of filled buckets. foreach ($result as $delta => $buckets) { if (!isset($conflicts[$delta])) { $conflicts[$delta] = array(); } $conflicts[$delta][$date_value] = $buckets; } } return $conflicts; } /* * Perform first-fit algorhtym on reservations into buckets. * * Return array indexed by item delta of array of filled buckets. */ public function bestFit($dates) { $entity = $this->entity; $best_fit = array(); $parent_conflicts = $this->conflicts($dates); $date_value = $dates->get('value')->getValue(); foreach ($entity->get($this->item_field) as $delta => $item) { // No need to sort into buckets if there is nothing to sort into buckets. if (!array_key_exists($delta, $parent_conflicts) or !array_key_exists($date_value, $parent_conflicts[$delta])) { continue; } if ($item->entity->hasField($this->quantity_field)) { $quantity = $item->entity->get($this->quantity_field)->value; } else { $quantity = 1; } // Split reservations based on quantity. $reservations = array(); foreach($parent_conflicts[$delta][$date_value] as $reservation) { for ($i = 0; $i < $reservation->quantity; $i++) { $reservations[] = $reservation; } } // Determine how many bucket items are needed for this time period. // Need to sort like this: // .... time .... // item1 x x a a a x x x x x f x e e e x x x x x // item2 x x x d d d d d d x x x x c c c x x x x // item3 x x b b b b b b b b b b b b b x x x x x // etc ...... // // // Order by lenght of reservation descending. // // Do first-fit algorythm. // Sort by length of reservation. uasort($reservations, array($this, "merci_bucket_cmp_length")); $buckets = array(); // First-fit algorythm. foreach ($reservations as $test_reservation) { // Go through each bucket item to look for a available slot for this reservation. // // Find a bucket to use for this reservation. for ($i = 0; $i < $quantity; $i++) { $fits = TRUE; // Bucket already has other reservations we need to check against for a fit. if (array_key_exists($i, $buckets)) { foreach ($buckets[$i] as $reservation) { if ($this->merci_bucket_intersects($reservation, $test_reservation)) { //Conflict so skip saving the reservation to this slot and try to use the next bucket item. $fits = FALSE; break; } } } // We've found a slot so test the next reservation. if ($fits) { if (array_key_exists($i, $buckets)) { $buckets[$i] = array(); } $buckets[$i][] = $test_reservation; break; } } } if (count($buckets)) { $best_fit[$delta] = $buckets; } } return $best_fit; } /* * |----------------------| range 1 * |---> range 2 overlap * |---> range 2 overlap * |---> range 2 overlap * |---> range 2 no overlap */ private function merci_bucket_intersects($r1, $r2) { $value = $this->date_column; $end_value = $this->date_column2; /* * Make sure r1 start date is before r2 start date. */ if (date_create($r1->{$value}) > date_create($r2->{$value})) { $temp = $r1; $r1 = $r2; $r2 = $temp; } if (date_create($r2->{$value}) <= date_create($r1->{$end_value})) { return true; } return false; } private function merci_bucket_cmp_length($a, $b) { $value = $this->date_column; $end_value = $this->date_column2; $len_a = date_format(date_create($a->{$end_value}),'U') - date_format(date_create($a->{$value}), 'U'); $len_b = date_format(date_create($b->{$end_value}),'U') - date_format(date_create($b->{$value}), 'U'); if ($len_a == $len_b) { return 0; } return ($len_a < $len_b) ? 1 : -1; } }