contacts_events-8.x-1.x-dev/src/PriceCalculator.php
src/PriceCalculator.php
<?php
namespace Drupal\contacts_events;
use Drupal\commerce_advancedqueue\CommerceOrderJob;
use Drupal\commerce_order\Entity\OrderItemInterface;
use Drupal\commerce_price\Price;
use Drupal\contacts_events\Entity\EventClass;
use Drupal\contacts_events\Entity\EventInterface;
use Drupal\contacts_events\Entity\SingleUsePurchasableEntityInterface;
use Drupal\contacts_events\Event\PriceCalculationEvent;
use Drupal\Core\Datetime\DrupalDateTime;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Logger\LoggerChannelInterface;
use Drupal\Core\Utility\Error;
use Drupal\devel\DevelDumperManagerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
/**
* Price calculation service for booking order items.
*/
class PriceCalculator {
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The logger channel.
*
* @var \Drupal\Core\Logger\LoggerChannelInterface
*/
protected $logger;
/**
* A devel dumper for debug output.
*
* @var \Drupal\devel\DevelDumperManagerInterface
*/
protected $dumper;
/**
* Event dispatcher.
*
* @var \Symfony\Component\EventDispatcher\EventDispatcherInterface
*/
protected $eventDispatcher;
/**
* Construct the price calculator.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
* @param \Drupal\Core\Logger\LoggerChannelInterface $logger
* The logger channel.
* @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $event_dispatcher
* Event dispatcher.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager, LoggerChannelInterface $logger, EventDispatcherInterface $event_dispatcher) {
$this->entityTypeManager = $entity_type_manager;
$this->logger = $logger;
$this->eventDispatcher = $event_dispatcher;
}
/**
* Calculate the price for an order item.
*
* @param \Drupal\commerce_order\Entity\OrderItemInterface $order_item
* The order item.
*/
public function calculatePrice(OrderItemInterface $order_item) {
// Get hold of the required data.
if ($price_map_items = $this->findPriceMap($order_item)) {
if (!($booking_windows = $price_map_items->getBookingWindows())) {
$this->logger->error('Unable to find booking windows for @event::@price_map.', [
'@event' => $price_map_items->getEntity()->label(),
'@price_map' => $price_map_items->getFieldDefinition()->getLabel(),
]);
}
if (!($classes = $price_map_items->getClasses())) {
$this->logger->error('Unable to find classes for @event::@price_map.', [
'@event' => $price_map_items->getEntity()->label(),
'@price_map' => $price_map_items->getFieldDefinition()->getLabel(),
]);
}
}
// Stop if we don't have the required information.
if (!$price_map_items || !$booking_windows || $booking_windows->count() == 0 || empty($classes)) {
$this->setResult($order_item);
return;
}
// If we can, get the mapped price from the purchased entity.
if ($purchased_entity = $order_item->get('purchased_entity')->entity) {
if ($purchased_entity instanceof SingleUsePurchasableEntityInterface) {
$mapping = $purchased_entity->getMappedPrice();
}
}
// Otherwise see if we can get it from the order item.
if (!isset($mapping) && $order_item->hasField('mapped_price')) {
$mapped_price_items = $order_item->get('mapped_price');
if ($mapped_price_items->count()) {
$mapping = $mapped_price_items->first()->getValue();
}
}
// Otherwise just initialise an array.
if (!isset($mapping)) {
$mapping = [];
}
// Ensure all the required keys are set.
$mapping += [
'booking_window' => NULL,
'booking_window_overridden' => FALSE,
'class' => NULL,
'class_overridden' => FALSE,
];
// Check whether the item has already been confirmed. If the item is not
// stateful, we treat it as always unconfirmed so it uses the earlier of the
// possible window dates.
$confirmed_date = $order_item->hasField('confirmed') && !$order_item->get('confirmed')->isEmpty()
? DrupalDateTime::createFromTimestamp($order_item->get('confirmed')->value)
: NULL;
// If the booking window is not overridden, calculate it.
if (!$mapping['booking_window_overridden']) {
$booking_window = $booking_windows->findWindow($confirmed_date);
if (!$booking_window) {
$this->setResult($order_item);
return;
}
$mapping['booking_window'] = $booking_window->id;
}
// If the class is not overridden, calculate it.
if (!$mapping['class_overridden']) {
$matched_classes = $this->findClasses($order_item, $classes);
// If there are no matched classes, return early.
if (empty($matched_classes)) {
$this->setResult($order_item);
return;
}
// If we have multiple, attempt to use the existing selection.
if (count($matched_classes) > 1) {
foreach ($matched_classes as $matched_class) {
if ($matched_class->id() == $mapping['class']) {
$class = $matched_class;
break;
}
}
}
// If we don't have a class, use the first.
if (!isset($class)) {
$class = reset($matched_classes);
}
// Set the class in our map, using the selected or first matched class.
$mapping['class'] = $class->id();
}
// Look up our price map to get the appropriate value.
/** @var \Drupal\contacts_events\Plugin\Field\FieldType\PriceMapItem[][] $price_map */
$price_map = $price_map_items->getPriceMap();
if (!isset($price_map[$mapping['booking_window']][$mapping['class']])) {
// We have no suitable price, so clear and return.
$this->setResult($order_item);
return;
}
// We have a result, so set our price and mapping.
$calculated_price = $price_map[$mapping['booking_window']][$mapping['class']]->toPrice();
$this->setResult($order_item, $calculated_price, $mapping, $price_map);
}
/**
* Set the result of a price calculation.
*
* @param \Drupal\commerce_order\Entity\OrderItemInterface $order_item
* The order item.
* @param \Drupal\commerce_price\Price|null $price
* The price or NULL to set it 0.
* @param array|null $mapping
* The mapping or NULL to leave it unchanged.
* @param array|null $price_map
* The price map or NULL to leave it unchanged.
*/
protected function setResult(OrderItemInterface $order_item, Price $price = NULL, array $mapping = NULL, array $price_map = NULL) {
/** @var \Drupal\commerce\PurchasableEntityInterface $purchased_entity*/
$purchased_entity = $order_item->get('purchased_entity')->entity;
// Raise an event to give other modules the opportunity to change the price.
$event = new PriceCalculationEvent($order_item, $purchased_entity, $price_map, $mapping, $price);
$this->eventDispatcher->dispatch(PriceCalculationEvent::NAME, $event);
$price = $event->getPrice();
// Default price to zero.
if (!$price) {
$stores = $purchased_entity->getStores();
$store = reset($stores);
$price = new Price(0, $store->getDefaultCurrencyCode());
}
// If the purchasable entity is a single use item, set the values back.
if ($purchased_entity instanceof SingleUsePurchasableEntityInterface) {
$purchased_entity->setCalculatedPrice($price);
if ($mapping) {
$purchased_entity->setMappedPrice($mapping);
}
// Check for overrides and pull that from the purchased entity.
if ($has_override = ($purchased_entity->getPriceOverride() !== NULL)) {
$price = $purchased_entity->getPrice();
}
}
// If an override isn't set by the purchased entity but the order item is
// overridden, we don't want to reset it.
if (isset($has_override) || !$order_item->isUnitPriceOverridden()) {
$order_item->setUnitPrice($price, $has_override ?? FALSE);
}
// Store the mapping if the order item has the right field.
if ($mapping && $order_item->hasField('mapped_price')) {
$order_item->set('mapped_price', $mapping);
}
}
/**
* Find the price map field for an order item.
*
* @param \Drupal\commerce_order\Entity\OrderItemInterface $order_item
* The order item entity.
*
* @return \Drupal\contacts_events\Plugin\Field\FieldType\PriceMapItemList|null
* The price map or NULL if we can't find one.
*/
public function findPriceMap(OrderItemInterface $order_item) {
/** @var \Drupal\contacts_events\Entity\EventInterface $event */
$event = $order_item->getOrder()->get('event')->entity;
// Loop over definitions to find the appropriate price map.
foreach ($event->getFieldDefinitions() as $definition) {
if ($definition->getType() == 'price_map' && $definition->getSetting('order_item_type') == $order_item->bundle()) {
return $event->get($definition->getName());
}
}
return NULL;
}
/**
* Find the class for an order item.
*
* @param \Drupal\commerce_order\Entity\OrderItemInterface $order_item
* The order item.
* @param \Drupal\contacts_events\Entity\EventClassInterface[]|null $classes
* The set of classes to check against. If NULL, we retrieve it.
*
* @return \Drupal\contacts_events\Entity\EventClassInterface[]
* The matched event classes.
*/
public function findClasses(OrderItemInterface $order_item, array $classes = NULL) {
// Get our classes if we weren't given them.
if (!isset($classes)) {
if ($price_map = $this->findPriceMap($order_item)) {
$classes = $price_map->getClasses();
}
else {
return [];
}
}
// If there are no classes, there's nothing we can do.
if (empty($classes)) {
$this->logger->error('Unable to find classes for @event::@price_map.', [
'@event' => $price_map->getEntity()->label(),
'@price_map' => $price_map->getFieldDefinition()->getLabel(),
]);
return [];
}
// Ensure our items are sorted.
uasort($classes, [EventClass::class, 'sort']);
// Loop over and find the suitable matches.
$matches = [];
$this->debug('Looking for suitable classes.');
foreach ($classes as $class) {
$this->debug($class->label(), $class->id());
// If we already have a match and this is not selectable, we can skip it.
if (!empty($matches) && !$class->get('selectable')) {
$this->debug('Skipping non-selectable class as we already have options.');
continue;
}
// Evaluate the class.
try {
$this->debug('Attempting to evaluate.');
if ($class->evaluate($order_item)) {
$this->debug('Class is suitable.');
// Add this class to our matches.
$matches[] = $class;
// If this is not a selectable class, this is our final, so stop.
if (!$class->get('selectable')) {
$this->debug('Found non-selectable class, stopping.');
break;
}
}
else {
$this->debug('Class not suitable.');
}
}
catch (\Throwable $throwable) {
$error = Error::decodeException($throwable);
$this->debug($error, 'Evaluation failed with an error');
$error['%event_class'] = $class->label();
$this->logger->error('%event_class - %type: @message in %function (line %line of %file) @backtrace_string.', $error);
}
}
return $matches;
}
/**
* Checks for changes that affect pricing and queues recalculations as needed.
*
* @param \Drupal\contacts_events\Entity\EventInterface $entity
* The updated event entity.
* @param \Drupal\contacts_events\Entity\EventInterface $original
* The original event entity.
*
* @return int|false
* FALSE if there is no calculation required. Otherwise the number of orders
* queued for recalculation.
*
* @todo See if this can be not specific to event entitites.
* @todo See if we can make it target specific order items.
*/
public function onEntityUpdate(EventInterface $entity, EventInterface $original) {
// Check to see if any price mappings or their dependencies have changed.
$fields_to_check = [];
foreach ($entity->getFieldDefinitions() as $field_name => $definition) {
// Skip anything that's not a price map.
if ($definition->getType() == 'price_map') {
$bundle = $definition->getSetting('order_item_type');
$fields_to_check[$field_name][$bundle] = TRUE;
if ($field_name = $definition->getSetting('booking_window_field')) {
$fields_to_check[$field_name][$bundle] = TRUE;
}
if ($field_name = $definition->getSetting('class_field')) {
$fields_to_check[$field_name][$bundle] = TRUE;
}
}
}
// If there are any changes, recalculate all orders.
$bundles_to_recalculate = [];
foreach ($fields_to_check as $field_name => $bundles) {
if ($entity->get($field_name)->getValue() != $original->get($field_name)->getValue()) {
$bundles_to_recalculate += $bundles;
break;
}
}
// If we're not recalculating, return.
if (empty($bundles_to_recalculate)) {
return FALSE;
}
// Enqueue the jobs.
return $this->enqueueJobs([$entity->id()], array_keys($bundles_to_recalculate));
}
/**
* Enqueue recalculation jobs.
*
* @param int[] $event_ids
* An array of event IDs.
* @param string[] $order_item_types
* An array of order item types to recalculate.
*
* @return int
* The number of jobs queued.
*/
public function enqueueJobs(array $event_ids, array $order_item_types) {
// Find all order IDs.
$query = $this->entityTypeManager
->getStorage('commerce_order')
->getQuery();
$query->accessCheck(FALSE);
$query->condition('type', 'contacts_booking');
$query->condition('event', $event_ids, 'IN');
$order_ids = $query->execute();
if (empty($order_ids)) {
return 0;
}
// Build our jobs.
$jobs = [];
foreach ($order_ids as $order_id) {
$jobs[] = CommerceOrderJob::create('contacts_events_recalculate_order_items', ['bundles' => $order_item_types], $order_id);
}
/** @var \Drupal\advancedqueue\Entity\QueueInterface $queue */
$queue = $this->entityTypeManager
->getStorage('advancedqueue_queue')
->load('commerce_order');
$queue->enqueueJobs($jobs);
return count($jobs);
}
/**
* Show debug output, if we have a dumper.
*
* @param mixed $input
* An arbitrary value to output.
* @param string|null $name
* Optional name for identifying the output.
*/
protected function debug($input, $name = NULL) {
if ($this->dumper) {
$this->dumper->message($input, $name);
}
}
/**
* Set a dumper to retrieve debugging information.
*
* @param \Drupal\devel\DevelDumperManagerInterface $dumper
* The devel dumper.
*
* @return $this
*/
public function setDumper(DevelDumperManagerInterface $dumper = NULL) {
$this->dumper = $dumper;
return $this;
}
}
