Initial commit: CloudOps infrastructure platform
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\CampaignBundle\Executioner\ContactFinder;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Mautic\CampaignBundle\Entity\Event;
|
||||
use Mautic\CampaignBundle\Entity\LeadRepository as CampaignLeadRepository;
|
||||
use Mautic\CampaignBundle\Executioner\ContactFinder\Limiter\ContactLimiter;
|
||||
use Mautic\CampaignBundle\Executioner\Exception\NoContactsFoundException;
|
||||
use Mautic\LeadBundle\Entity\Lead;
|
||||
use Mautic\LeadBundle\Entity\LeadRepository;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class InactiveContactFinder
|
||||
{
|
||||
/**
|
||||
* @var array<string, \DateTimeInterface>|null
|
||||
*/
|
||||
private ?array $campaignMemberDatesAdded = null;
|
||||
|
||||
public function __construct(
|
||||
private LeadRepository $leadRepository,
|
||||
private CampaignLeadRepository $campaignLeadRepository,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $campaignId
|
||||
*
|
||||
* @return ArrayCollection
|
||||
*
|
||||
* @throws NoContactsFoundException
|
||||
*/
|
||||
public function getContacts($campaignId, Event $decisionEvent, ContactLimiter $limiter)
|
||||
{
|
||||
if ($limiter->hasCampaignLimit() && 0 === $limiter->getCampaignLimitRemaining()) {
|
||||
// Limit was reached but do not trigger the NoContactsFoundException
|
||||
return new ArrayCollection();
|
||||
}
|
||||
|
||||
// Get list of all campaign leads
|
||||
$decisionParentEvent = $decisionEvent->getParent();
|
||||
$this->campaignMemberDatesAdded = $this->campaignLeadRepository->getInactiveContacts(
|
||||
$campaignId,
|
||||
$decisionEvent->getId(),
|
||||
($decisionParentEvent) ? $decisionParentEvent->getId() : null,
|
||||
$limiter
|
||||
);
|
||||
|
||||
if (empty($this->campaignMemberDatesAdded)) {
|
||||
// No new contacts found in the campaign
|
||||
throw new NoContactsFoundException();
|
||||
}
|
||||
|
||||
$campaignContacts = array_keys($this->campaignMemberDatesAdded);
|
||||
$this->logger->debug('CAMPAIGN: Processing the following contacts: '.implode(', ', $campaignContacts));
|
||||
|
||||
// Fetch entity objects for the found contacts
|
||||
$contacts = $this->leadRepository->getContactCollection($campaignContacts);
|
||||
|
||||
if (!count($contacts)) {
|
||||
// Just a precaution in case non-existent contacts are lingering in the campaign leads table
|
||||
$this->logger->debug('CAMPAIGN: No contact entities found.');
|
||||
|
||||
throw new NoContactsFoundException();
|
||||
}
|
||||
|
||||
return $contacts;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, \DateTimeInterface>|null
|
||||
*/
|
||||
public function getDatesAdded(): ?array
|
||||
{
|
||||
return $this->campaignMemberDatesAdded;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $campaignId
|
||||
*/
|
||||
public function getContactCount($campaignId, array $decisionEvents, ContactLimiter $limiter): int
|
||||
{
|
||||
return $this->campaignLeadRepository->getInactiveContactCount($campaignId, $decisionEvents, $limiter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear Lead entities from memory.
|
||||
*
|
||||
* @param Collection<int, Lead> $contacts
|
||||
*/
|
||||
public function clear(Collection $contacts): void
|
||||
{
|
||||
$this->leadRepository->detachEntities($contacts->toArray());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\CampaignBundle\Executioner\ContactFinder;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Mautic\CampaignBundle\Entity\CampaignRepository;
|
||||
use Mautic\CampaignBundle\Executioner\ContactFinder\Limiter\ContactLimiter;
|
||||
use Mautic\CampaignBundle\Executioner\Exception\NoContactsFoundException;
|
||||
use Mautic\LeadBundle\Entity\Lead;
|
||||
use Mautic\LeadBundle\Entity\LeadRepository;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class KickoffContactFinder
|
||||
{
|
||||
public function __construct(
|
||||
private LeadRepository $leadRepository,
|
||||
private CampaignRepository $campaignRepository,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $campaignId
|
||||
*
|
||||
* @return ArrayCollection
|
||||
*
|
||||
* @throws NoContactsFoundException
|
||||
*/
|
||||
public function getContacts($campaignId, ContactLimiter $limiter)
|
||||
{
|
||||
// Get list of all campaign leads; start is always zero in practice because of $pendingOnly
|
||||
$campaignContacts = $this->campaignRepository->getPendingContactIds($campaignId, $limiter);
|
||||
|
||||
if (empty($campaignContacts)) {
|
||||
// No new contacts found in the campaign
|
||||
|
||||
throw new NoContactsFoundException();
|
||||
}
|
||||
|
||||
$this->logger->debug('CAMPAIGN: Processing the following contacts: '.implode(', ', $campaignContacts));
|
||||
|
||||
// Fetch entity objects for the found contacts
|
||||
$contacts = $this->leadRepository->getContactCollection($campaignContacts);
|
||||
|
||||
if (!count($contacts)) {
|
||||
// Just a precaution in case non-existent contacts are lingering in the campaign leads table
|
||||
$this->logger->debug('CAMPAIGN: No contact entities found.');
|
||||
|
||||
throw new NoContactsFoundException();
|
||||
}
|
||||
|
||||
return $contacts;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $campaignId
|
||||
*/
|
||||
public function getContactCount($campaignId, array $eventIds, ContactLimiter $limiter): int
|
||||
{
|
||||
$countResult = $this->campaignRepository->getCountsForPendingContacts($campaignId, $eventIds, $limiter);
|
||||
|
||||
return $countResult->getCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear Lead entities from memory.
|
||||
*
|
||||
* @param Collection<int, Lead> $contacts
|
||||
*/
|
||||
public function clear(Collection $contacts): void
|
||||
{
|
||||
$this->leadRepository->detachEntities($contacts->toArray());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\CampaignBundle\Executioner\ContactFinder\Limiter;
|
||||
|
||||
use Mautic\CampaignBundle\Executioner\Exception\NoContactsFoundException;
|
||||
|
||||
class ContactLimiter
|
||||
{
|
||||
private int $batchLimit;
|
||||
|
||||
private ?int $contactId;
|
||||
|
||||
private ?int $minContactId;
|
||||
|
||||
private ?int $batchMinContactId = null;
|
||||
|
||||
private ?int $maxContactId;
|
||||
|
||||
private ?int $threadId = null;
|
||||
|
||||
private ?int $maxThreads = null;
|
||||
|
||||
/**
|
||||
* @var int|null
|
||||
*/
|
||||
private $campaignLimit;
|
||||
|
||||
private ?int $campaignLimitUsed = null;
|
||||
|
||||
/**
|
||||
* @param int $batchLimit
|
||||
* @param int|null $contactId
|
||||
* @param int|null $minContactId
|
||||
* @param int|null $maxContactId
|
||||
* @param int|null $threadId
|
||||
* @param int|null $maxThreads
|
||||
* @param int|null $campaignLimit
|
||||
*/
|
||||
public function __construct(
|
||||
$batchLimit,
|
||||
$contactId = null,
|
||||
$minContactId = null,
|
||||
$maxContactId = null,
|
||||
private array $contactIdList = [],
|
||||
$threadId = null,
|
||||
$maxThreads = null,
|
||||
$campaignLimit = null,
|
||||
) {
|
||||
$this->batchLimit = ($batchLimit) ? (int) $batchLimit : 100;
|
||||
$this->contactId = ($contactId) ? (int) $contactId : null;
|
||||
$this->minContactId = ($minContactId) ? (int) $minContactId : null;
|
||||
$this->maxContactId = ($maxContactId) ? (int) $maxContactId : null;
|
||||
|
||||
if ($threadId && $maxThreads) {
|
||||
$this->threadId = (int) $threadId;
|
||||
$this->maxThreads = (int) $maxThreads;
|
||||
|
||||
if ($threadId > $maxThreads) {
|
||||
throw new \InvalidArgumentException('$threadId cannot be larger than $maxThreads');
|
||||
}
|
||||
}
|
||||
|
||||
if ($campaignLimit) {
|
||||
$this->campaignLimit = $campaignLimit;
|
||||
$this->campaignLimitUsed = 0;
|
||||
}
|
||||
}
|
||||
|
||||
public function getBatchLimit(): int
|
||||
{
|
||||
return $this->batchLimit;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int|null
|
||||
*/
|
||||
public function getContactId()
|
||||
{
|
||||
return $this->contactId;
|
||||
}
|
||||
|
||||
public function getMinContactId(): ?int
|
||||
{
|
||||
return $this->batchMinContactId ?: $this->minContactId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int|null
|
||||
*/
|
||||
public function getMaxContactId()
|
||||
{
|
||||
return $this->maxContactId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
public function getContactIdList()
|
||||
{
|
||||
return $this->contactIdList;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $id
|
||||
*
|
||||
* @return $this
|
||||
*
|
||||
* @throws NoContactsFoundException
|
||||
*/
|
||||
public function setBatchMinContactId($id)
|
||||
{
|
||||
// Prevent a never ending loop if the contact ID never changes due to being the last batch of contacts
|
||||
if ($this->minContactId && $this->minContactId > (int) $id) {
|
||||
throw new NoContactsFoundException();
|
||||
}
|
||||
|
||||
// We've surpasssed the max so bai
|
||||
if ($this->maxContactId && $this->maxContactId < (int) $id) {
|
||||
throw new NoContactsFoundException();
|
||||
}
|
||||
|
||||
// The same batch of contacts were somehow processed so let's stop to prevent the loop
|
||||
if ($this->batchMinContactId && $this->batchMinContactId >= $id) {
|
||||
throw new NoContactsFoundException();
|
||||
}
|
||||
|
||||
$this->batchMinContactId = (int) $id;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function resetBatchMinContactId()
|
||||
{
|
||||
$this->batchMinContactId = null;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int|null
|
||||
*/
|
||||
public function getMaxThreads()
|
||||
{
|
||||
return $this->maxThreads;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int|null
|
||||
*/
|
||||
public function getThreadId()
|
||||
{
|
||||
return $this->threadId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int|null
|
||||
*/
|
||||
public function getCampaignLimit()
|
||||
{
|
||||
return $this->campaignLimit;
|
||||
}
|
||||
|
||||
public function hasCampaignLimit(): bool
|
||||
{
|
||||
return null !== $this->campaignLimit;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getCampaignLimitRemaining()
|
||||
{
|
||||
if (!$this->hasCampaignLimit()) {
|
||||
throw new \Exception('Campaign Limit was not set');
|
||||
}
|
||||
|
||||
return $this->campaignLimit - $this->campaignLimitUsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function reduceCampaignLimitRemaining($reduction)
|
||||
{
|
||||
if (!$this->hasCampaignLimit()) {
|
||||
throw new \Exception('Campaign Limit was not set');
|
||||
} elseif ($this->campaignLimit < ($this->campaignLimitUsed + $reduction)) {
|
||||
throw new \Exception('Campaign Limit exceeded');
|
||||
}
|
||||
$this->campaignLimitUsed += $reduction;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function resetCampaignLimitRemaining()
|
||||
{
|
||||
$this->campaignLimitUsed = 0;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\CampaignBundle\Executioner\ContactFinder;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Mautic\CampaignBundle\Entity\LeadEventLog;
|
||||
use Mautic\CampaignBundle\Executioner\Exception\NoContactsFoundException;
|
||||
use Mautic\LeadBundle\Entity\Lead;
|
||||
use Mautic\LeadBundle\Entity\LeadRepository;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class ScheduledContactFinder
|
||||
{
|
||||
public function __construct(
|
||||
private LeadRepository $leadRepository,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate contacts with custom field value, companies, etc.
|
||||
*
|
||||
* @return Collection<int, Lead>
|
||||
*/
|
||||
public function hydrateContacts(ArrayCollection $logs): Collection
|
||||
{
|
||||
$contactIds = [];
|
||||
/** @var LeadEventLog $log */
|
||||
foreach ($logs as $log) {
|
||||
$contactIds[] = $log->getLead()->getId();
|
||||
}
|
||||
|
||||
if (!count($contactIds)) {
|
||||
// Just a precaution in case non-existent contacts are lingering in the campaign leads table
|
||||
$this->logger->debug('CAMPAIGN: No contact entities found.');
|
||||
|
||||
throw new NoContactsFoundException();
|
||||
}
|
||||
|
||||
$contacts = $this->leadRepository->getContactCollection($contactIds);
|
||||
|
||||
foreach ($logs as $key => $log) {
|
||||
$contactId = $log->getLead()->getId();
|
||||
if (!$contact = $contacts->get($contactId)) {
|
||||
// the contact must have been deleted mid execution so remove this log from memory
|
||||
$logs->remove($key);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$log->setLead($contact);
|
||||
}
|
||||
|
||||
return $contacts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear Lead entities from memory.
|
||||
*
|
||||
* @param Collection<int, Lead> $contacts
|
||||
*/
|
||||
public function clear(Collection $contacts): void
|
||||
{
|
||||
$this->leadRepository->detachEntities($contacts->toArray());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\CampaignBundle\Executioner\Dispatcher;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Mautic\CampaignBundle\CampaignEvents;
|
||||
use Mautic\CampaignBundle\Entity\Event;
|
||||
use Mautic\CampaignBundle\Entity\LeadEventLog;
|
||||
use Mautic\CampaignBundle\Event\ExecutedBatchEvent;
|
||||
use Mautic\CampaignBundle\Event\ExecutedEvent;
|
||||
use Mautic\CampaignBundle\Event\FailedEvent;
|
||||
use Mautic\CampaignBundle\Event\PendingEvent;
|
||||
use Mautic\CampaignBundle\EventCollector\Accessor\Event\AbstractEventAccessor;
|
||||
use Mautic\CampaignBundle\EventCollector\Accessor\Event\ActionAccessor;
|
||||
use Mautic\CampaignBundle\Executioner\Dispatcher\Exception\LogNotProcessedException;
|
||||
use Mautic\CampaignBundle\Executioner\Dispatcher\Exception\LogPassedAndFailedException;
|
||||
use Mautic\CampaignBundle\Executioner\Scheduler\EventScheduler;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
class ActionDispatcher
|
||||
{
|
||||
public function __construct(
|
||||
private EventDispatcherInterface $dispatcher,
|
||||
private LoggerInterface $logger,
|
||||
private EventScheduler $scheduler,
|
||||
private LegacyEventDispatcher $legacyDispatcher,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws LogNotProcessedException
|
||||
* @throws LogPassedAndFailedException
|
||||
*/
|
||||
public function dispatchEvent(ActionAccessor $config, Event $event, ArrayCollection $logs, ?PendingEvent $pendingEvent = null): PendingEvent
|
||||
{
|
||||
if (!$pendingEvent) {
|
||||
$pendingEvent = new PendingEvent($config, $event, $logs);
|
||||
}
|
||||
|
||||
// this if statement can be removed when legacy dispatcher is removed
|
||||
if ($customEvent = $config->getBatchEventName()) {
|
||||
$this->dispatcher->dispatch($pendingEvent, $customEvent);
|
||||
|
||||
$success = $pendingEvent->getSuccessful();
|
||||
$failed = $pendingEvent->getFailures();
|
||||
|
||||
$this->validateProcessedLogs($logs, $success, $failed);
|
||||
|
||||
if ($success) {
|
||||
$this->dispatchExecutedEvent($config, $event, $success);
|
||||
}
|
||||
|
||||
if ($failed) {
|
||||
$this->dispatchFailedEvent($config, $failed);
|
||||
}
|
||||
|
||||
// Dispatch legacy ON_EVENT_EXECUTION event for BC
|
||||
$this->legacyDispatcher->dispatchExecutionEvents($config, $success, $failed);
|
||||
}
|
||||
|
||||
// Execute BC eventName or callback. Or support case where the listener has been converted to batchEventName but still wants to execute
|
||||
// eventName for BC support for plugins that could be listening to it's own custom event.
|
||||
$this->legacyDispatcher->dispatchCustomEvent($config, $logs, $customEvent, $pendingEvent);
|
||||
|
||||
return $pendingEvent;
|
||||
}
|
||||
|
||||
private function dispatchExecutedEvent(AbstractEventAccessor $config, Event $event, ArrayCollection $logs): void
|
||||
{
|
||||
if (!$logs->count()) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($logs as $log) {
|
||||
$this->dispatcher->dispatch(
|
||||
new ExecutedEvent($config, $log),
|
||||
CampaignEvents::ON_EVENT_EXECUTED
|
||||
);
|
||||
}
|
||||
|
||||
$this->dispatcher->dispatch(
|
||||
new ExecutedBatchEvent($config, $event, $logs),
|
||||
CampaignEvents::ON_EVENT_EXECUTED_BATCH
|
||||
);
|
||||
}
|
||||
|
||||
private function dispatchFailedEvent(AbstractEventAccessor $config, ArrayCollection $logs): void
|
||||
{
|
||||
if (!$logs->count()) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var LeadEventLog $log */
|
||||
foreach ($logs as $log) {
|
||||
$this->logger->debug(
|
||||
'CAMPAIGN: '.ucfirst($log->getEvent()->getEventType() ?? 'unknown event').' ID# '.$log->getEvent()->getId().' for contact ID# '.$log->getLead()->getId()
|
||||
);
|
||||
|
||||
$this->dispatcher->dispatch(
|
||||
new FailedEvent($config, $log),
|
||||
CampaignEvents::ON_EVENT_FAILED
|
||||
);
|
||||
}
|
||||
|
||||
$this->scheduler->rescheduleFailures($logs);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws LogNotProcessedException
|
||||
* @throws LogPassedAndFailedException
|
||||
*/
|
||||
private function validateProcessedLogs(ArrayCollection $pending, ArrayCollection $success, ArrayCollection $failed): void
|
||||
{
|
||||
foreach ($pending as $log) {
|
||||
if (!$success->contains($log) && !$failed->contains($log)) {
|
||||
throw new LogNotProcessedException($log);
|
||||
}
|
||||
|
||||
if ($success->contains($log) && $failed->contains($log)) {
|
||||
throw new LogPassedAndFailedException($log);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\CampaignBundle\Executioner\Dispatcher;
|
||||
|
||||
use Mautic\CampaignBundle\CampaignEvents;
|
||||
use Mautic\CampaignBundle\Entity\LeadEventLog;
|
||||
use Mautic\CampaignBundle\Event\ConditionEvent;
|
||||
use Mautic\CampaignBundle\EventCollector\Accessor\Event\ConditionAccessor;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
class ConditionDispatcher
|
||||
{
|
||||
public function __construct(
|
||||
private EventDispatcherInterface $dispatcher,
|
||||
) {
|
||||
}
|
||||
|
||||
public function dispatchEvent(ConditionAccessor $config, LeadEventLog $log): ConditionEvent
|
||||
{
|
||||
$event = new ConditionEvent($config, $log);
|
||||
$this->dispatcher->dispatch($event, $config->getEventName());
|
||||
$this->dispatcher->dispatch($event, CampaignEvents::ON_EVENT_CONDITION_EVALUATION);
|
||||
|
||||
return $event;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\CampaignBundle\Executioner\Dispatcher;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Mautic\CampaignBundle\CampaignEvents;
|
||||
use Mautic\CampaignBundle\Entity\LeadEventLog;
|
||||
use Mautic\CampaignBundle\Event\DecisionEvent;
|
||||
use Mautic\CampaignBundle\Event\DecisionResultsEvent;
|
||||
use Mautic\CampaignBundle\EventCollector\Accessor\Event\DecisionAccessor;
|
||||
use Mautic\CampaignBundle\Executioner\Result\EvaluatedContacts;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
class DecisionDispatcher
|
||||
{
|
||||
public function __construct(
|
||||
private EventDispatcherInterface $dispatcher,
|
||||
private LegacyEventDispatcher $legacyDispatcher,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $passthrough
|
||||
*/
|
||||
public function dispatchRealTimeEvent(DecisionAccessor $config, LeadEventLog $log, $passthrough): DecisionEvent
|
||||
{
|
||||
$event = new DecisionEvent($config, $log, $passthrough);
|
||||
$this->dispatcher->dispatch($event, $config->getEventName());
|
||||
|
||||
return $event;
|
||||
}
|
||||
|
||||
public function dispatchEvaluationEvent(DecisionAccessor $config, LeadEventLog $log): DecisionEvent
|
||||
{
|
||||
$event = new DecisionEvent($config, $log);
|
||||
|
||||
$this->dispatcher->dispatch($event, CampaignEvents::ON_EVENT_DECISION_EVALUATION);
|
||||
$this->legacyDispatcher->dispatchDecisionEvent($event);
|
||||
|
||||
return $event;
|
||||
}
|
||||
|
||||
public function dispatchDecisionResultsEvent(DecisionAccessor $config, ArrayCollection $logs, EvaluatedContacts $evaluatedContacts): void
|
||||
{
|
||||
if (!$logs->count()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->dispatcher->dispatch(
|
||||
new DecisionResultsEvent($config, $logs, $evaluatedContacts),
|
||||
CampaignEvents::ON_EVENT_DECISION_EVALUATION_RESULTS
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\CampaignBundle\Executioner\Dispatcher\Exception;
|
||||
|
||||
use Mautic\CampaignBundle\Entity\LeadEventLog;
|
||||
|
||||
class LogNotProcessedException extends \Exception
|
||||
{
|
||||
public function __construct(LeadEventLog $log)
|
||||
{
|
||||
parent::__construct("LeadEventLog ID # {$log->getId()} must be passed to either pass() or fail()", 0, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\CampaignBundle\Executioner\Dispatcher\Exception;
|
||||
|
||||
use Mautic\CampaignBundle\Entity\LeadEventLog;
|
||||
|
||||
class LogPassedAndFailedException extends \Exception
|
||||
{
|
||||
public function __construct(LeadEventLog $log)
|
||||
{
|
||||
parent::__construct("LeadEventLog ID # {$log->getId()} was passed to both pass() or fail(). Pass or fail the log, not both.", 0, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\CampaignBundle\Executioner\Dispatcher;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Mautic\CampaignBundle\CampaignEvents;
|
||||
use Mautic\CampaignBundle\Entity\LeadEventLog;
|
||||
use Mautic\CampaignBundle\Event\CampaignDecisionEvent;
|
||||
use Mautic\CampaignBundle\Event\CampaignExecutionEvent;
|
||||
use Mautic\CampaignBundle\Event\DecisionEvent;
|
||||
use Mautic\CampaignBundle\Event\EventArrayTrait;
|
||||
use Mautic\CampaignBundle\Event\ExecutedBatchEvent;
|
||||
use Mautic\CampaignBundle\Event\ExecutedEvent;
|
||||
use Mautic\CampaignBundle\Event\FailedEvent;
|
||||
use Mautic\CampaignBundle\Event\PendingEvent;
|
||||
use Mautic\CampaignBundle\EventCollector\Accessor\Event\AbstractEventAccessor;
|
||||
use Mautic\CampaignBundle\Executioner\Scheduler\EventScheduler;
|
||||
use Mautic\LeadBundle\Tracker\ContactTracker;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
/**
|
||||
* @deprecated 2.13.0 to be removed in 3.0; BC support for old listeners
|
||||
*/
|
||||
class LegacyEventDispatcher
|
||||
{
|
||||
use EventArrayTrait;
|
||||
|
||||
public function __construct(
|
||||
private EventDispatcherInterface $dispatcher,
|
||||
private EventScheduler $scheduler,
|
||||
private LoggerInterface $logger,
|
||||
private ContactTracker $contactTracker,
|
||||
) {
|
||||
}
|
||||
|
||||
public function dispatchCustomEvent(
|
||||
AbstractEventAccessor $config,
|
||||
ArrayCollection $logs,
|
||||
$wasBatchProcessed,
|
||||
PendingEvent $pendingEvent,
|
||||
): void {
|
||||
$settings = $config->getConfig();
|
||||
|
||||
if (!isset($settings['eventName']) && !isset($settings['callback'])) {
|
||||
// Bad plugin but only fail if the new event didn't already process the log
|
||||
if (!$wasBatchProcessed) {
|
||||
$pendingEvent->failAll('Invalid event configuration');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$rescheduleFailures = new ArrayCollection();
|
||||
|
||||
/** @var LeadEventLog $log */
|
||||
foreach ($logs as $log) {
|
||||
$this->contactTracker->setSystemContact($log->getLead());
|
||||
|
||||
if (isset($settings['eventName'])) {
|
||||
$event = $this->dispatchEventName($settings['eventName'], $settings, $log);
|
||||
$result = $event->getResult();
|
||||
} else {
|
||||
if (!is_callable($settings['callback'])) {
|
||||
// No use to keep trying for the other logs as it won't ever work
|
||||
break;
|
||||
}
|
||||
|
||||
$result = $this->dispatchCallback($settings, $log);
|
||||
}
|
||||
|
||||
// If the new batch event was handled, the $log was already processed so only process legacy logs if false
|
||||
if (!$wasBatchProcessed) {
|
||||
$this->dispatchExecutionEvent($config, $log, $result);
|
||||
|
||||
if (!is_bool($result)) {
|
||||
$log->appendToMetadata($result);
|
||||
}
|
||||
|
||||
// Dispatch new events for legacy processed logs
|
||||
if ($this->isFailed($result)) {
|
||||
$this->processFailedLog($log, $pendingEvent);
|
||||
|
||||
$rescheduleFailures->set($log->getId(), $log);
|
||||
|
||||
$this->dispatchFailedEvent($config, $log);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_array($result) && !empty($result['failed']) && isset($result['reason'])) {
|
||||
$pendingEvent->passWithError($log, (string) $result['reason']);
|
||||
} else {
|
||||
$pendingEvent->pass($log);
|
||||
}
|
||||
|
||||
$this->dispatchExecutedEvent($config, $log);
|
||||
}
|
||||
}
|
||||
|
||||
if ($rescheduleFailures->count()) {
|
||||
$this->scheduler->rescheduleFailures($rescheduleFailures);
|
||||
}
|
||||
|
||||
$this->contactTracker->setSystemContact(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the new ON_EVENT_FAILED and ON_EVENT_EXECUTED events for logs processed by BC code.
|
||||
*/
|
||||
public function dispatchExecutionEvents(AbstractEventAccessor $config, ArrayCollection $success, ArrayCollection $failures): void
|
||||
{
|
||||
foreach ($success as $log) {
|
||||
$this->dispatchExecutionEvent($config, $log, true);
|
||||
}
|
||||
|
||||
foreach ($failures as $log) {
|
||||
$this->dispatchExecutionEvent($config, $log, false);
|
||||
}
|
||||
}
|
||||
|
||||
public function dispatchDecisionEvent(DecisionEvent $decisionEvent): void
|
||||
{
|
||||
if ($this->dispatcher->hasListeners(CampaignEvents::ON_EVENT_DECISION_TRIGGER)) {
|
||||
$log = $decisionEvent->getLog();
|
||||
$event = $log->getEvent();
|
||||
|
||||
$legacyDecisionEvent = $this->dispatcher->dispatch(
|
||||
new CampaignDecisionEvent(
|
||||
$log->getLead(),
|
||||
$event->getType(),
|
||||
$decisionEvent->getEventConfig()->getConfig(),
|
||||
$this->getLegacyEventsArray($log),
|
||||
$this->getLegacyEventsConfigArray($event, $decisionEvent->getEventConfig()),
|
||||
0 === $event->getOrder(),
|
||||
[$log]
|
||||
),
|
||||
CampaignEvents::ON_EVENT_DECISION_TRIGGER
|
||||
);
|
||||
|
||||
if ($legacyDecisionEvent->wasDecisionTriggered()) {
|
||||
$decisionEvent->setAsApplicable();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function dispatchEventName($eventName, array $settings, LeadEventLog $log): CampaignExecutionEvent
|
||||
{
|
||||
@trigger_error('eventName is deprecated. Convert to using batchEventName.', E_USER_DEPRECATED);
|
||||
|
||||
$campaignEvent = new CampaignExecutionEvent(
|
||||
[
|
||||
'eventSettings' => $settings,
|
||||
'eventDetails' => null,
|
||||
'event' => $log->getEvent(),
|
||||
'lead' => $log->getLead(),
|
||||
'systemTriggered' => $log->getSystemTriggered(),
|
||||
],
|
||||
null,
|
||||
$log
|
||||
);
|
||||
|
||||
$this->dispatcher->dispatch($campaignEvent, $eventName);
|
||||
|
||||
if ($channel = $campaignEvent->getChannel()) {
|
||||
$log->setChannel($channel);
|
||||
$log->setChannelId($campaignEvent->getChannelId());
|
||||
}
|
||||
|
||||
return $campaignEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return mixed
|
||||
*/
|
||||
private function dispatchCallback(array $settings, LeadEventLog $log)
|
||||
{
|
||||
@trigger_error('callback is deprecated. Convert to using batchEventName.', E_USER_DEPRECATED);
|
||||
|
||||
$eventArray = $this->getEventArray($log->getEvent());
|
||||
$args = [
|
||||
'eventSettings' => $settings,
|
||||
'eventDetails' => null, // @todo fix when procesing decisions,
|
||||
'event' => $eventArray,
|
||||
'lead' => $log->getLead(),
|
||||
'systemTriggered' => $log->getSystemTriggered(),
|
||||
'config' => $eventArray['properties'],
|
||||
];
|
||||
|
||||
try {
|
||||
if (is_array($settings['callback'])) {
|
||||
$reflection = new \ReflectionMethod($settings['callback'][0], $settings['callback'][1]);
|
||||
} elseif (str_contains($settings['callback'], '::')) {
|
||||
$parts = explode('::', $settings['callback']);
|
||||
$reflection = new \ReflectionMethod($parts[0], $parts[1]);
|
||||
} else {
|
||||
$reflection = new \ReflectionMethod(null, $settings['callback']);
|
||||
}
|
||||
|
||||
$pass = [];
|
||||
foreach ($reflection->getParameters() as $param) {
|
||||
if (isset($args[$param->getName()])) {
|
||||
$pass[] = $args[$param->getName()];
|
||||
} else {
|
||||
$pass[] = null;
|
||||
}
|
||||
}
|
||||
|
||||
return $reflection->invokeArgs($this, $pass);
|
||||
} catch (\ReflectionException) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function dispatchExecutionEvent(AbstractEventAccessor $config, LeadEventLog $log, $result): void
|
||||
{
|
||||
$eventArray = $this->getEventArray($log->getEvent());
|
||||
|
||||
$this->dispatcher->dispatch(
|
||||
new CampaignExecutionEvent(
|
||||
[
|
||||
'eventSettings' => $config->getConfig(),
|
||||
'eventDetails' => null, // @todo fix when procesing decisions,
|
||||
'event' => $eventArray,
|
||||
'lead' => $log->getLead(),
|
||||
'systemTriggered' => $log->getSystemTriggered(),
|
||||
'config' => $eventArray['properties'],
|
||||
],
|
||||
$result,
|
||||
$log
|
||||
),
|
||||
CampaignEvents::ON_EVENT_EXECUTION
|
||||
);
|
||||
}
|
||||
|
||||
private function dispatchExecutedEvent(AbstractEventAccessor $config, LeadEventLog $log): void
|
||||
{
|
||||
$this->dispatcher->dispatch(
|
||||
new ExecutedEvent($config, $log),
|
||||
CampaignEvents::ON_EVENT_EXECUTED
|
||||
);
|
||||
|
||||
$collection = new ArrayCollection();
|
||||
$collection->set($log->getId(), $log);
|
||||
$this->dispatcher->dispatch(
|
||||
new ExecutedBatchEvent($config, $log->getEvent(), $collection),
|
||||
CampaignEvents::ON_EVENT_EXECUTED_BATCH
|
||||
);
|
||||
}
|
||||
|
||||
private function dispatchFailedEvent(AbstractEventAccessor $config, LeadEventLog $log): void
|
||||
{
|
||||
$this->dispatcher->dispatch(
|
||||
new FailedEvent($config, $log),
|
||||
CampaignEvents::ON_EVENT_FAILED
|
||||
);
|
||||
}
|
||||
|
||||
private function isFailed($result): bool
|
||||
{
|
||||
return
|
||||
false === $result
|
||||
|| (is_array($result) && isset($result['result']) && false === $result['result']);
|
||||
}
|
||||
|
||||
private function processFailedLog(LeadEventLog $log, PendingEvent $pendingEvent): void
|
||||
{
|
||||
$this->logger->debug(
|
||||
'CAMPAIGN: '.ucfirst($log->getEvent()->getEventType() ?? 'unknown event').' ID# '.$log->getEvent()->getId().' for contact ID# '.$log->getLead()->getId()
|
||||
);
|
||||
|
||||
$metadata = $log->getMetadata();
|
||||
|
||||
$reason = null;
|
||||
if (isset($metadata['errors'])) {
|
||||
$reason = (is_array($metadata['errors'])) ? implode('<br />', $metadata['errors']) : $metadata['errors'];
|
||||
} elseif (isset($metadata['reason'])) {
|
||||
$reason = $metadata['reason'];
|
||||
}
|
||||
|
||||
$pendingEvent->fail($log, $reason);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\CampaignBundle\Executioner\Event;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Mautic\CampaignBundle\Entity\Event;
|
||||
use Mautic\CampaignBundle\Entity\LeadEventLog;
|
||||
use Mautic\CampaignBundle\EventCollector\Accessor\Event\AbstractEventAccessor;
|
||||
use Mautic\CampaignBundle\EventCollector\Accessor\Event\ActionAccessor;
|
||||
use Mautic\CampaignBundle\Executioner\Dispatcher\ActionDispatcher;
|
||||
use Mautic\CampaignBundle\Executioner\Exception\CannotProcessEventException;
|
||||
use Mautic\CampaignBundle\Executioner\Logger\EventLogger;
|
||||
use Mautic\CampaignBundle\Executioner\Result\EvaluatedContacts;
|
||||
use Mautic\CoreBundle\Service\OptimisticLockServiceInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
class ActionExecutioner implements EventInterface
|
||||
{
|
||||
public const TYPE = 'action';
|
||||
|
||||
public function __construct(
|
||||
private ActionDispatcher $dispatcher,
|
||||
private EventLogger $eventLogger,
|
||||
private OptimisticLockServiceInterface $optimisticLockService,
|
||||
#[Autowire(service: 'monolog.logger.mautic')]
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CannotProcessEventException
|
||||
* @throws \Mautic\CampaignBundle\Executioner\Dispatcher\Exception\LogNotProcessedException
|
||||
* @throws \Mautic\CampaignBundle\Executioner\Dispatcher\Exception\LogPassedAndFailedException
|
||||
*/
|
||||
public function execute(AbstractEventAccessor $config, ArrayCollection $logs): EvaluatedContacts
|
||||
{
|
||||
\assert($config instanceof ActionAccessor);
|
||||
|
||||
/** @var LeadEventLog $firstLog */
|
||||
if (!$firstLog = $logs->first()) {
|
||||
return new EvaluatedContacts();
|
||||
}
|
||||
|
||||
$event = $firstLog->getEvent();
|
||||
|
||||
if (Event::TYPE_ACTION !== $event->getEventType()) {
|
||||
throw new CannotProcessEventException('Cannot process event ID '.$event->getId().' as an action.');
|
||||
}
|
||||
|
||||
$this->lockLogs($logs);
|
||||
|
||||
// Execute to process the batch of contacts
|
||||
$pendingEvent = $this->dispatcher->dispatchEvent($config, $event, $logs);
|
||||
|
||||
$passed = $this->eventLogger->extractContactsFromLogs($pendingEvent->getSuccessful());
|
||||
$failed = $this->eventLogger->extractContactsFromLogs($pendingEvent->getFailures());
|
||||
|
||||
return new EvaluatedContacts($passed, $failed);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<LeadEventLog> $logs
|
||||
*/
|
||||
private function lockLogs(Collection $logs): void
|
||||
{
|
||||
foreach ($logs as $key => $log) {
|
||||
if (!$this->optimisticLockService->acquireLock($log)) {
|
||||
$logs->remove($key);
|
||||
$this->logger->error(message: sprintf(
|
||||
'Campaign event log ID "%s" was skipped as it had been executed already.',
|
||||
$log->getId(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\CampaignBundle\Executioner\Event;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Mautic\CampaignBundle\Entity\Event;
|
||||
use Mautic\CampaignBundle\Entity\LeadEventLog;
|
||||
use Mautic\CampaignBundle\EventCollector\Accessor\Event\AbstractEventAccessor;
|
||||
use Mautic\CampaignBundle\EventCollector\Accessor\Event\ConditionAccessor;
|
||||
use Mautic\CampaignBundle\Executioner\Dispatcher\ConditionDispatcher;
|
||||
use Mautic\CampaignBundle\Executioner\Exception\CannotProcessEventException;
|
||||
use Mautic\CampaignBundle\Executioner\Exception\ConditionFailedException;
|
||||
use Mautic\CampaignBundle\Executioner\Result\EvaluatedContacts;
|
||||
|
||||
class ConditionExecutioner implements EventInterface
|
||||
{
|
||||
public const TYPE = 'condition';
|
||||
|
||||
public function __construct(
|
||||
private ConditionDispatcher $dispatcher,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CannotProcessEventException
|
||||
*/
|
||||
public function execute(AbstractEventAccessor $config, ArrayCollection $logs): EvaluatedContacts
|
||||
{
|
||||
\assert($config instanceof ConditionAccessor);
|
||||
$evaluatedContacts = new EvaluatedContacts();
|
||||
|
||||
/** @var LeadEventLog $log */
|
||||
foreach ($logs as $log) {
|
||||
try {
|
||||
/* @var ConditionAccessor $config */
|
||||
$this->dispatchEvent($config, $log);
|
||||
$evaluatedContacts->pass($log->getLead());
|
||||
} catch (ConditionFailedException) {
|
||||
$evaluatedContacts->fail($log->getLead());
|
||||
$log->setNonActionPathTaken(true);
|
||||
}
|
||||
|
||||
// Unschedule the condition and update date triggered timestamp
|
||||
$log->setDateTriggered(new \DateTime());
|
||||
}
|
||||
|
||||
return $evaluatedContacts;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CannotProcessEventException
|
||||
* @throws ConditionFailedException
|
||||
*/
|
||||
private function dispatchEvent(ConditionAccessor $config, LeadEventLog $log): void
|
||||
{
|
||||
if (Event::TYPE_CONDITION !== $log->getEvent()->getEventType()) {
|
||||
throw new CannotProcessEventException('Cannot process event ID '.$log->getEvent()->getId().' as a condition.');
|
||||
}
|
||||
|
||||
$conditionEvent = $this->dispatcher->dispatchEvent($config, $log);
|
||||
|
||||
if (!$conditionEvent->wasConditionSatisfied()) {
|
||||
throw new ConditionFailedException('evaluation failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\CampaignBundle\Executioner\Event;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Mautic\CampaignBundle\Entity\Event;
|
||||
use Mautic\CampaignBundle\Entity\LeadEventLog;
|
||||
use Mautic\CampaignBundle\EventCollector\Accessor\Event\AbstractEventAccessor;
|
||||
use Mautic\CampaignBundle\EventCollector\Accessor\Event\DecisionAccessor;
|
||||
use Mautic\CampaignBundle\Executioner\Dispatcher\DecisionDispatcher;
|
||||
use Mautic\CampaignBundle\Executioner\Exception\CannotProcessEventException;
|
||||
use Mautic\CampaignBundle\Executioner\Exception\DecisionNotApplicableException;
|
||||
use Mautic\CampaignBundle\Executioner\Logger\EventLogger;
|
||||
use Mautic\CampaignBundle\Executioner\Result\EvaluatedContacts;
|
||||
use Mautic\LeadBundle\Entity\Lead;
|
||||
|
||||
class DecisionExecutioner implements EventInterface
|
||||
{
|
||||
public const TYPE = 'decision';
|
||||
|
||||
public function __construct(
|
||||
private EventLogger $eventLogger,
|
||||
private DecisionDispatcher $dispatcher,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $passthrough
|
||||
* @param string|null $channel
|
||||
* @param int|null $channelId
|
||||
*
|
||||
* @throws CannotProcessEventException
|
||||
* @throws DecisionNotApplicableException
|
||||
*/
|
||||
public function evaluateForContact(DecisionAccessor $config, Event $event, Lead $contact, $passthrough = null, $channel = null, $channelId = null): void
|
||||
{
|
||||
if (Event::TYPE_DECISION !== $event->getEventType()) {
|
||||
throw new CannotProcessEventException('Cannot process event ID '.$event->getId().' as a decision.');
|
||||
}
|
||||
|
||||
$log = $this->eventLogger->buildLogEntry($event, $contact);
|
||||
$log->setChannel($channel);
|
||||
$log->setChannelId($channelId);
|
||||
|
||||
$decisionEvent = $this->dispatcher->dispatchRealTimeEvent($config, $log, $passthrough);
|
||||
|
||||
if (!$decisionEvent->wasDecisionApplicable()) {
|
||||
throw new DecisionNotApplicableException('evaluation failed');
|
||||
}
|
||||
|
||||
$this->eventLogger->persistLog($log);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CannotProcessEventException
|
||||
*/
|
||||
public function execute(AbstractEventAccessor $config, ArrayCollection $logs): EvaluatedContacts
|
||||
{
|
||||
\assert($config instanceof DecisionAccessor);
|
||||
$evaluatedContacts = new EvaluatedContacts();
|
||||
$failedLogs = [];
|
||||
|
||||
/** @var LeadEventLog $log */
|
||||
foreach ($logs as $log) {
|
||||
if (Event::TYPE_DECISION !== $log->getEvent()->getEventType()) {
|
||||
throw new CannotProcessEventException('Event ID '.$log->getEvent()->getId().' is not a decision');
|
||||
}
|
||||
|
||||
try {
|
||||
/* @var DecisionAccessor $config */
|
||||
$this->dispatchEvent($config, $log);
|
||||
$evaluatedContacts->pass($log->getLead());
|
||||
|
||||
// Update the date triggered timestamp
|
||||
$log->setDateTriggered(new \DateTime());
|
||||
} catch (DecisionNotApplicableException) {
|
||||
// Fail the contact but remove the log from being processed upstream
|
||||
// active/positive/green path while letting the InactiveExecutioner handle the inactive/negative/red paths
|
||||
$failedLogs[] = $log;
|
||||
$evaluatedContacts->fail($log->getLead());
|
||||
}
|
||||
}
|
||||
|
||||
$this->dispatcher->dispatchDecisionResultsEvent($config, $logs, $evaluatedContacts);
|
||||
|
||||
// Remove the logs
|
||||
foreach ($failedLogs as $log) {
|
||||
$logs->removeElement($log);
|
||||
}
|
||||
|
||||
return $evaluatedContacts;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws DecisionNotApplicableException
|
||||
*/
|
||||
private function dispatchEvent(DecisionAccessor $config, LeadEventLog $log): void
|
||||
{
|
||||
$decisionEvent = $this->dispatcher->dispatchEvaluationEvent($config, $log);
|
||||
|
||||
if (!$decisionEvent->wasDecisionApplicable()) {
|
||||
throw new DecisionNotApplicableException('evaluation failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\CampaignBundle\Executioner\Event;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Mautic\CampaignBundle\EventCollector\Accessor\Event\AbstractEventAccessor;
|
||||
use Mautic\CampaignBundle\Executioner\Result\EvaluatedContacts;
|
||||
|
||||
interface EventInterface
|
||||
{
|
||||
/**
|
||||
* @return EvaluatedContacts
|
||||
*/
|
||||
public function execute(AbstractEventAccessor $config, ArrayCollection $logs);
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\CampaignBundle\Executioner;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Mautic\CampaignBundle\Entity\Event;
|
||||
use Mautic\CampaignBundle\Entity\FailedLeadEventLog;
|
||||
use Mautic\CampaignBundle\Entity\LeadEventLog;
|
||||
use Mautic\CampaignBundle\EventCollector\Accessor\Exception\TypeNotFoundException;
|
||||
use Mautic\CampaignBundle\EventCollector\EventCollector;
|
||||
use Mautic\CampaignBundle\EventListener\CampaignActionJumpToEventSubscriber;
|
||||
use Mautic\CampaignBundle\Executioner\Event\ActionExecutioner;
|
||||
use Mautic\CampaignBundle\Executioner\Event\ConditionExecutioner;
|
||||
use Mautic\CampaignBundle\Executioner\Event\DecisionExecutioner;
|
||||
use Mautic\CampaignBundle\Executioner\Logger\EventLogger;
|
||||
use Mautic\CampaignBundle\Executioner\Result\Counter;
|
||||
use Mautic\CampaignBundle\Executioner\Result\EvaluatedContacts;
|
||||
use Mautic\CampaignBundle\Executioner\Result\Responses;
|
||||
use Mautic\CampaignBundle\Executioner\Scheduler\EventScheduler;
|
||||
use Mautic\CampaignBundle\Helper\RemovedContactTracker;
|
||||
use Mautic\LeadBundle\Entity\Lead;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class EventExecutioner
|
||||
{
|
||||
private ?Responses $responses = null;
|
||||
|
||||
private \DateTimeInterface $executionDate;
|
||||
|
||||
public function __construct(
|
||||
private EventCollector $collector,
|
||||
private EventLogger $eventLogger,
|
||||
private ActionExecutioner $actionExecutioner,
|
||||
private ConditionExecutioner $conditionExecutioner,
|
||||
private DecisionExecutioner $decisionExecutioner,
|
||||
private LoggerInterface $logger,
|
||||
private EventScheduler $scheduler,
|
||||
private RemovedContactTracker $removedContactTracker,
|
||||
) {
|
||||
// Be sure that all events are compared using the exact same \DateTime
|
||||
$this->executionDate = new \DateTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Dispatcher\Exception\LogNotProcessedException
|
||||
* @throws Dispatcher\Exception\LogPassedAndFailedException
|
||||
* @throws Exception\CannotProcessEventException
|
||||
* @throws Scheduler\Exception\NotSchedulableException
|
||||
*/
|
||||
public function executeForContact(Event $event, Lead $contact, ?Responses $responses = null, ?Counter $counter = null): void
|
||||
{
|
||||
if ($responses) {
|
||||
$this->responses = $responses;
|
||||
}
|
||||
|
||||
$contacts = new ArrayCollection([$contact->getId() => $contact]);
|
||||
|
||||
$this->executeForContacts($event, $contacts, $counter);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Dispatcher\Exception\LogNotProcessedException
|
||||
* @throws Dispatcher\Exception\LogPassedAndFailedException
|
||||
* @throws Exception\CannotProcessEventException
|
||||
* @throws Scheduler\Exception\NotSchedulableException
|
||||
*/
|
||||
public function executeEventsForContact(ArrayCollection $events, Lead $contact, ?Responses $responses = null, ?Counter $counter = null): void
|
||||
{
|
||||
if ($responses) {
|
||||
$this->responses = $responses;
|
||||
}
|
||||
|
||||
$contacts = new ArrayCollection([$contact->getId() => $contact]);
|
||||
|
||||
$this->executeEventsForContacts($events, $contacts, $counter);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ArrayCollection<int,Lead> $contacts
|
||||
* @param bool $isInactiveEvent
|
||||
*
|
||||
* @throws Dispatcher\Exception\LogNotProcessedException
|
||||
* @throws Dispatcher\Exception\LogPassedAndFailedException
|
||||
* @throws Exception\CannotProcessEventException
|
||||
* @throws Scheduler\Exception\NotSchedulableException
|
||||
*/
|
||||
public function executeForContacts(Event $event, ArrayCollection $contacts, ?Counter $counter = null, $isInactiveEvent = false): void
|
||||
{
|
||||
if (!$contacts->count()) {
|
||||
$this->logger->debug('CAMPAIGN: No contacts to process for event ID '.$event->getId());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$config = $this->collector->getEventConfig($event);
|
||||
$logs = $this->eventLogger->fetchRotationAndGenerateLogsFromContacts($event, $config, $contacts, $isInactiveEvent);
|
||||
|
||||
$this->executeLogs($event, $logs, $counter);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Dispatcher\Exception\LogNotProcessedException
|
||||
* @throws Dispatcher\Exception\LogPassedAndFailedException
|
||||
* @throws Exception\CannotProcessEventException
|
||||
* @throws Scheduler\Exception\NotSchedulableException
|
||||
*/
|
||||
public function executeLogs(Event $event, ArrayCollection $logs, ?Counter $counter = null): void
|
||||
{
|
||||
$this->logger->debug('CAMPAIGN: Executing '.$event->getType().' ID '.$event->getId());
|
||||
|
||||
if (!$logs->count()) {
|
||||
$this->logger->debug('CAMPAIGN: No logs to process for event ID '.$event->getId());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$config = $this->collector->getEventConfig($event);
|
||||
|
||||
if ($counter) {
|
||||
// Must pass $counter around rather than setting it as a class property as this class is used
|
||||
// circularly to process children of parent events thus counter must be kept track separately
|
||||
$counter->advanceExecuted($logs->count());
|
||||
}
|
||||
|
||||
switch ($event->getEventType()) {
|
||||
case Event::TYPE_ACTION:
|
||||
$evaluatedContacts = $this->actionExecutioner->execute($config, $logs);
|
||||
$this->persistLogs($logs);
|
||||
$this->executeConditionEventsForContacts($event, $evaluatedContacts->getPassed(), $counter);
|
||||
$this->executeActionEventsForContacts($event, $evaluatedContacts->getPassed(), $counter);
|
||||
break;
|
||||
case Event::TYPE_CONDITION:
|
||||
$evaluatedContacts = $this->conditionExecutioner->execute($config, $logs);
|
||||
$this->persistLogs($logs);
|
||||
$this->executeBranchedEventsForContacts($event, $evaluatedContacts, $counter);
|
||||
break;
|
||||
case Event::TYPE_DECISION:
|
||||
$evaluatedContacts = $this->decisionExecutioner->execute($config, $logs);
|
||||
$this->persistLogs($logs);
|
||||
$this->executePositivePathEventsForContacts($event, $evaluatedContacts->getPassed(), $counter);
|
||||
break;
|
||||
default:
|
||||
throw new TypeNotFoundException("{$event->getEventType()} is not a valid event type");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param bool $isInactive
|
||||
*
|
||||
* @throws Dispatcher\Exception\LogNotProcessedException
|
||||
* @throws Dispatcher\Exception\LogPassedAndFailedException
|
||||
* @throws Exception\CannotProcessEventException
|
||||
* @throws Scheduler\Exception\NotSchedulableException
|
||||
*/
|
||||
public function executeEventsForContacts(ArrayCollection $events, ArrayCollection $contacts, ?Counter $childrenCounter = null, $isInactive = false): void
|
||||
{
|
||||
if (!$contacts->count()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Schedule then return those that need to be immediately executed
|
||||
$executeThese = $this->scheduleEvents($events, $contacts, $childrenCounter, $isInactive);
|
||||
|
||||
// Execute non jump-to events normally
|
||||
$otherEvents = $executeThese->filter(fn (Event $event): bool => CampaignActionJumpToEventSubscriber::EVENT_NAME !== $event->getType());
|
||||
|
||||
if ($otherEvents->count()) {
|
||||
foreach ($otherEvents as $event) {
|
||||
$this->executeForContacts($event, $contacts, $childrenCounter, $isInactive);
|
||||
}
|
||||
}
|
||||
|
||||
// Now execute jump to events
|
||||
$jumpEvents = $executeThese->filter(fn (Event $event): bool => CampaignActionJumpToEventSubscriber::EVENT_NAME === $event->getType());
|
||||
if ($jumpEvents->count()) {
|
||||
$jumpLogs = [];
|
||||
|
||||
// Create logs for the jump to events before the rotation is incremented
|
||||
foreach ($jumpEvents as $key => $event) {
|
||||
$config = $this->collector->getEventConfig($event);
|
||||
$jumpLogs[$key] = $this->eventLogger->fetchRotationAndGenerateLogsFromContacts($event, $config, $contacts, $isInactive);
|
||||
}
|
||||
|
||||
// Process the jump to events
|
||||
foreach ($jumpLogs as $key => $logs) {
|
||||
$this->executeLogs($jumpEvents->get($key), $logs, $childrenCounter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param bool $isInactiveEvent
|
||||
*/
|
||||
public function recordLogsAsExecutedForEvent(Event $event, ArrayCollection $contacts, $isInactiveEvent = false): void
|
||||
{
|
||||
$config = $this->collector->getEventConfig($event);
|
||||
$logs = $this->eventLogger->generateLogsFromContacts($event, $config, $contacts, $isInactiveEvent);
|
||||
|
||||
// Save updated log entries and clear from memory
|
||||
if (!$logs->isEmpty()) {
|
||||
$this->eventLogger->persistCollection($logs)
|
||||
->clearCollection($logs);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param bool $isInactiveEvent
|
||||
*/
|
||||
public function recordLogsAsFailedForEvent(Event $event, ArrayCollection $contacts, $reason, $isInactiveEvent = false): void
|
||||
{
|
||||
$config = $this->collector->getEventConfig($event);
|
||||
$logs = $this->eventLogger->generateLogsFromContacts($event, $config, $contacts, $isInactiveEvent);
|
||||
if (!$logs->isEmpty()) {
|
||||
foreach ($logs as $log) {
|
||||
$failedLog = new FailedLeadEventLog();
|
||||
$failedLog->setLog($log)
|
||||
->setReason($reason);
|
||||
}
|
||||
|
||||
// Save updated log entries and clear from memory
|
||||
$this->eventLogger->persistCollection($logs)
|
||||
->clearCollection($logs);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \DateTimeInterface
|
||||
*/
|
||||
public function getExecutionDate()
|
||||
{
|
||||
return $this->executionDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param bool $isInactive
|
||||
*
|
||||
* @return ArrayCollection
|
||||
*
|
||||
* @throws Scheduler\Exception\NotSchedulableException
|
||||
*/
|
||||
private function scheduleEvents(ArrayCollection $events, ArrayCollection $contacts, ?Counter $childrenCounter = null, $isInactive = false)
|
||||
{
|
||||
$events = clone $events;
|
||||
|
||||
foreach ($events as $key => $event) {
|
||||
// Ignore decisions
|
||||
if (Event::TYPE_DECISION == $event->getEventType()) {
|
||||
$this->logger->debug('CAMPAIGN: Ignoring child event ID '.$event->getId().' as a decision');
|
||||
continue;
|
||||
}
|
||||
|
||||
$executionDate = $this->scheduler->getExecutionDateTime($event, $this->executionDate);
|
||||
|
||||
$this->logger->debug(
|
||||
'CAMPAIGN: Event ID# '.$event->getId().
|
||||
' to be executed on '.$executionDate->format('Y-m-d H:i:s e')
|
||||
);
|
||||
|
||||
// Check if we need to schedule this if it is not an inactivity check
|
||||
if (!$isInactive && $this->scheduler->shouldScheduleEvent($event, $executionDate, $this->executionDate)) {
|
||||
if ($childrenCounter) {
|
||||
$childrenCounter->advanceTotalScheduled($contacts->count());
|
||||
}
|
||||
|
||||
$this->scheduler->schedule($event, $executionDate, $contacts, $isInactive);
|
||||
|
||||
$events->remove($key);
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return $events;
|
||||
}
|
||||
|
||||
private function persistLogs(ArrayCollection $logs): void
|
||||
{
|
||||
if ($this->responses) {
|
||||
// Extract responses
|
||||
$this->responses->setFromLogs($logs);
|
||||
}
|
||||
|
||||
$this->checkForRemovedContacts($logs);
|
||||
|
||||
// Save updated log entries and clear from memory
|
||||
$this->eventLogger->persistCollection($logs)
|
||||
->clearCollection($logs);
|
||||
}
|
||||
|
||||
private function checkForRemovedContacts(ArrayCollection $logs): void
|
||||
{
|
||||
/**
|
||||
* @var int $key
|
||||
* @var LeadEventLog $log
|
||||
*/
|
||||
foreach ($logs as $key => $log) {
|
||||
// Use the deleted ID if the contact was removed by the delete contact action
|
||||
$contact = $log->getLead();
|
||||
$contactId = (!empty($contact->deletedId)) ? $contact->deletedId : $contact->getId();
|
||||
$campaignId = $log->getCampaign()->getId();
|
||||
|
||||
if ($this->removedContactTracker->wasContactRemoved($campaignId, $contactId)) {
|
||||
$this->logger->debug("CAMPAIGN: Contact ID# $contactId has been removed from campaign ID $campaignId");
|
||||
$logs->remove($key);
|
||||
|
||||
// Clear out removed contacts to prevent a memory leak
|
||||
$this->removedContactTracker->clearRemovedContact($campaignId, $contactId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Dispatcher\Exception\LogNotProcessedException
|
||||
* @throws Dispatcher\Exception\LogPassedAndFailedException
|
||||
* @throws Exception\CannotProcessEventException
|
||||
* @throws Scheduler\Exception\NotSchedulableException
|
||||
*/
|
||||
private function executeActionEventsForContacts(Event $event, ArrayCollection $contacts, ?Counter $counter = null): void
|
||||
{
|
||||
$childrenCounter = new Counter();
|
||||
$actions = $event->getChildrenByEventType(Event::TYPE_ACTION);
|
||||
$childrenCounter->advanceEvaluated($actions->count());
|
||||
|
||||
$this->logger->debug('CAMPAIGN: Executing '.$actions->count().' actions under action ID '.$event->getId());
|
||||
|
||||
$this->executeEventsForContacts($actions, $contacts, $childrenCounter);
|
||||
|
||||
if ($counter) {
|
||||
$counter->advanceTotalEvaluated($childrenCounter->getTotalEvaluated());
|
||||
$counter->advanceTotalExecuted($childrenCounter->getTotalExecuted());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Dispatcher\Exception\LogNotProcessedException
|
||||
* @throws Dispatcher\Exception\LogPassedAndFailedException
|
||||
* @throws Exception\CannotProcessEventException
|
||||
* @throws Scheduler\Exception\NotSchedulableException
|
||||
*/
|
||||
private function executeConditionEventsForContacts(Event $event, ArrayCollection $contacts, ?Counter $counter = null): void
|
||||
{
|
||||
$childrenCounter = new Counter();
|
||||
$conditions = $event->getChildrenByEventType(Event::TYPE_CONDITION);
|
||||
$childrenCounter->advanceEvaluated($conditions->count());
|
||||
|
||||
$this->logger->debug('CAMPAIGN: Evaluating '.$conditions->count().' conditions for action ID '.$event->getId());
|
||||
|
||||
$this->executeEventsForContacts($conditions, $contacts, $childrenCounter);
|
||||
|
||||
if ($counter) {
|
||||
$counter->advanceTotalEvaluated($childrenCounter->getTotalEvaluated());
|
||||
$counter->advanceTotalExecuted($childrenCounter->getTotalExecuted());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Dispatcher\Exception\LogNotProcessedException
|
||||
* @throws Dispatcher\Exception\LogPassedAndFailedException
|
||||
* @throws Exception\CannotProcessEventException
|
||||
* @throws Scheduler\Exception\NotSchedulableException
|
||||
*/
|
||||
private function executeBranchedEventsForContacts(Event $event, EvaluatedContacts $contacts, ?Counter $counter = null): void
|
||||
{
|
||||
$childrenCounter = new Counter();
|
||||
$this->executePositivePathEventsForContacts($event, $contacts->getPassed(), $childrenCounter);
|
||||
$this->executeNegativePathEventsForContacts($event, $contacts->getFailed(), $childrenCounter);
|
||||
|
||||
if ($counter) {
|
||||
$counter->advanceTotalEvaluated($childrenCounter->getTotalEvaluated());
|
||||
$counter->advanceTotalExecuted($childrenCounter->getTotalExecuted());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Counter|null $counter
|
||||
*
|
||||
* @throws Dispatcher\Exception\LogNotProcessedException
|
||||
* @throws Dispatcher\Exception\LogPassedAndFailedException
|
||||
* @throws Exception\CannotProcessEventException
|
||||
* @throws Scheduler\Exception\NotSchedulableException
|
||||
*/
|
||||
private function executePositivePathEventsForContacts(Event $event, ArrayCollection $contacts, Counter $counter): void
|
||||
{
|
||||
if (!$contacts->count()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->logger->debug('CAMPAIGN: Contact IDs '.implode(',', $contacts->getKeys()).' passed evaluation for event ID '.$event->getId());
|
||||
|
||||
$children = $event->getPositiveChildren();
|
||||
$counter->advanceEvaluated($children->count());
|
||||
|
||||
$this->executeEventsForContacts($children, $contacts, $counter);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Counter|null $counter
|
||||
*
|
||||
* @throws Dispatcher\Exception\LogNotProcessedException
|
||||
* @throws Dispatcher\Exception\LogPassedAndFailedException
|
||||
* @throws Exception\CannotProcessEventException
|
||||
* @throws Scheduler\Exception\NotSchedulableException
|
||||
*/
|
||||
private function executeNegativePathEventsForContacts(Event $event, ArrayCollection $contacts, Counter $counter): void
|
||||
{
|
||||
if (!$contacts->count()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->logger->debug('CAMPAIGN: Contact IDs '.implode(',', $contacts->getKeys()).' failed evaluation for event ID '.$event->getId());
|
||||
|
||||
$children = $event->getNegativeChildren();
|
||||
$counter->advanceEvaluated($children->count());
|
||||
|
||||
$this->executeEventsForContacts($children, $contacts, $counter);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Doctrine\DBAL\Exception
|
||||
* @throws \Doctrine\ORM\OptimisticLockException
|
||||
*/
|
||||
public function persistSummaries(): void
|
||||
{
|
||||
$this->eventLogger->getSummaryModel()->persistSummaries();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\CampaignBundle\Executioner\Exception;
|
||||
|
||||
class CampaignNotExecutableException extends \Exception
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\CampaignBundle\Executioner\Exception;
|
||||
|
||||
class CannotProcessEventException extends \Exception
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\CampaignBundle\Executioner\Exception;
|
||||
|
||||
class ConditionFailedException extends \Exception
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\CampaignBundle\Executioner\Exception;
|
||||
|
||||
class DecisionNotApplicableException extends \Exception
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mautic\CampaignBundle\Executioner\Exception;
|
||||
|
||||
class IntervalNotConfiguredException extends \Exception
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\CampaignBundle\Executioner\Exception;
|
||||
|
||||
class NoContactsFoundException extends \Exception
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\CampaignBundle\Executioner\Exception;
|
||||
|
||||
class NoEventsFoundException extends \Exception
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\CampaignBundle\Executioner;
|
||||
|
||||
use Mautic\CampaignBundle\Entity\Campaign;
|
||||
use Mautic\CampaignBundle\Executioner\ContactFinder\Limiter\ContactLimiter;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
interface ExecutionerInterface
|
||||
{
|
||||
/**
|
||||
* @return mixed
|
||||
*/
|
||||
public function execute(Campaign $campaign, ContactLimiter $limiter, ?OutputInterface $output = null);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mautic\CampaignBundle\Executioner\Helper;
|
||||
|
||||
use Mautic\CampaignBundle\Entity\Event;
|
||||
use Mautic\CampaignBundle\Entity\LeadRepository;
|
||||
use Mautic\CampaignBundle\Executioner\Exception\DecisionNotApplicableException;
|
||||
use Mautic\LeadBundle\Entity\Lead;
|
||||
|
||||
class DecisionHelper
|
||||
{
|
||||
public function __construct(
|
||||
private LeadRepository $leadRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws DecisionNotApplicableException
|
||||
*/
|
||||
public function checkIsDecisionApplicableForContact(Event $event, Lead $contact, ?string $channel = null, ?int $channelId = null): void
|
||||
{
|
||||
if (Event::TYPE_DECISION !== $event->getEventType()) {
|
||||
@trigger_error(
|
||||
"{$event->getType()} is not assigned to a decision and no longer supported. ".
|
||||
'Check that you are executing RealTimeExecutioner::execute for an event registered as a decision.',
|
||||
E_USER_DEPRECATED
|
||||
);
|
||||
|
||||
throw new DecisionNotApplicableException("Event {$event->getId()} is not a decision.");
|
||||
}
|
||||
|
||||
// If channels do not match up at all (not even fuzzy logic i.e. page vs page.redirect), there's no need to go further
|
||||
if ($channel && $event->getChannel() && !str_contains($channel, $event->getChannel())) {
|
||||
throw new DecisionNotApplicableException("Channels, $channel and {$event->getChannel()}, do not match.");
|
||||
}
|
||||
|
||||
if ($channel && $channelId && $event->getChannelId() && (string) $channelId !== (string) $event->getChannelId()) {
|
||||
throw new DecisionNotApplicableException("Channel IDs, $channelId and {$event->getChannelId()}, do not match for $channel.");
|
||||
}
|
||||
|
||||
// Check if parent taken path is the path of this event, otherwise exit
|
||||
$parentEvent = $event->getParent();
|
||||
|
||||
if (null !== $parentEvent && !$parentEvent->isDeleted() && null !== $event->getDecisionPath()) {
|
||||
$rotation = $this->leadRepository->getContactRotations([$contact->getId()], $event->getCampaign()->getId());
|
||||
$rotationValue = isset($rotation[$contact->getId()]) ? $rotation[$contact->getId()]['rotation'] : null;
|
||||
$log = $parentEvent->getLogByContactAndRotation($contact, $rotationValue);
|
||||
|
||||
if (null === $log) {
|
||||
throw new DecisionNotApplicableException("Parent {$parentEvent->getId()} has not been fired, event {$event->getId()} should not be fired.");
|
||||
}
|
||||
|
||||
$pathTaken = (int) $log->getNonActionPathTaken();
|
||||
|
||||
if (1 === $pathTaken && !$parentEvent->getNegativeChildren()->contains($event)) {
|
||||
throw new DecisionNotApplicableException("Parent {$parentEvent->getId()} take negative path, event {$event->getId()} is on positive path.");
|
||||
} elseif (0 === $pathTaken && !$parentEvent->getPositiveChildren()->contains($event)) {
|
||||
throw new DecisionNotApplicableException("Parent {$parentEvent->getId()} take positive path, event {$event->getId()} is on negative path.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\CampaignBundle\Executioner\Helper;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Mautic\CampaignBundle\Entity\Event;
|
||||
use Mautic\CampaignBundle\Entity\EventRepository;
|
||||
use Mautic\CampaignBundle\Entity\LeadEventLogRepository;
|
||||
use Mautic\CampaignBundle\Executioner\ContactFinder\InactiveContactFinder;
|
||||
use Mautic\CampaignBundle\Executioner\Exception\DecisionNotApplicableException;
|
||||
use Mautic\CampaignBundle\Executioner\Scheduler\EventScheduler;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class InactiveHelper
|
||||
{
|
||||
private ?\DateTimeInterface $earliestInactiveDate = null;
|
||||
|
||||
public function __construct(
|
||||
private EventScheduler $scheduler,
|
||||
private InactiveContactFinder $inactiveContactFinder,
|
||||
private LeadEventLogRepository $eventLogRepository,
|
||||
private EventRepository $eventRepository,
|
||||
private LoggerInterface $logger,
|
||||
private DecisionHelper $decisionHelper,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ArrayCollection<int, Event> $decisions
|
||||
*/
|
||||
public function removeDecisionsWithoutNegativeChildren(ArrayCollection $decisions): void
|
||||
{
|
||||
/**
|
||||
* @var int $key
|
||||
* @var Event $decision
|
||||
*/
|
||||
foreach ($decisions as $key => $decision) {
|
||||
$negativeChildren = $decision->getNegativeChildren();
|
||||
if (!$negativeChildren->count()) {
|
||||
$decisions->remove($key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $lastActiveEventId
|
||||
*
|
||||
* @throws \Mautic\CampaignBundle\Executioner\Scheduler\Exception\NotSchedulableException
|
||||
*/
|
||||
public function removeContactsThatAreNotApplicable(
|
||||
\DateTime $now,
|
||||
ArrayCollection $contacts,
|
||||
$lastActiveEventId,
|
||||
ArrayCollection $negativeChildren,
|
||||
Event $event,
|
||||
): void {
|
||||
$contactIds = $contacts->getKeys();
|
||||
$lastActiveDates = $this->getLastActiveDates($lastActiveEventId, $contactIds);
|
||||
$this->earliestInactiveDate = $now;
|
||||
|
||||
foreach ($contactIds as $contactId) {
|
||||
try {
|
||||
$this->decisionHelper->checkIsDecisionApplicableForContact($event, $contacts->get($contactId));
|
||||
} catch (DecisionNotApplicableException $e) {
|
||||
$this->logger->debug($e->getMessage());
|
||||
$contacts->remove($contactId);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($lastActiveDates[$contactId])) {
|
||||
// This contact does not have a last active date so likely the event is scheduled
|
||||
$contacts->remove($contactId);
|
||||
|
||||
$this->logger->debug('CAMPAIGN: Contact ID# '.$contactId.' does not have a last active date ('.$lastActiveEventId.')');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$earliestContactInactiveDate = $this->getEarliestInactiveDate($negativeChildren, $lastActiveDates[$contactId]);
|
||||
$this->logger->debug(
|
||||
'CAMPAIGN: Earliest date for inactivity for contact ID# '.$contactId.' is '.
|
||||
$earliestContactInactiveDate->format('Y-m-d H:i:s T').' based on last active date of '.
|
||||
$lastActiveDates[$contactId]->format('Y-m-d H:i:s T')
|
||||
);
|
||||
|
||||
if ($this->earliestInactiveDate < $earliestContactInactiveDate) {
|
||||
$this->earliestInactiveDate = $earliestContactInactiveDate;
|
||||
}
|
||||
|
||||
// If any are found to be inactive, we process or schedule all the events associated with the inactive path of a decision
|
||||
if ($earliestContactInactiveDate > $now) {
|
||||
$contacts->remove($contactId);
|
||||
$this->logger->debug('CAMPAIGN: Contact ID# '.$contactId.' has been active and thus not applicable');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->logger->debug('CAMPAIGN: Contact ID# '.$contactId.' has not been active');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \DateTimeInterface
|
||||
*/
|
||||
public function getEarliestInactiveDateTime()
|
||||
{
|
||||
return $this->earliestInactiveDate;
|
||||
}
|
||||
|
||||
public function getCollectionByDecisionId($decisionId): ArrayCollection
|
||||
{
|
||||
$collection = new ArrayCollection();
|
||||
|
||||
/** @var Event|null $decision */
|
||||
$decision = $this->eventRepository->find($decisionId);
|
||||
if ($decision && !$decision->isDeleted()) {
|
||||
$collection->set($decision->getId(), $decision);
|
||||
}
|
||||
|
||||
return $collection;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Mautic\CampaignBundle\Executioner\Scheduler\Exception\NotSchedulableException
|
||||
*/
|
||||
public function getEarliestInactiveDate(ArrayCollection $negativeChildren, \DateTimeInterface $lastActiveDate): ?\DateTimeInterface
|
||||
{
|
||||
$earliestDate = null;
|
||||
foreach ($negativeChildren as $event) {
|
||||
$executionDate = $this->scheduler->getExecutionDateTime($event, $lastActiveDate);
|
||||
if (!$earliestDate || $executionDate < $earliestDate) {
|
||||
$earliestDate = $executionDate;
|
||||
}
|
||||
}
|
||||
|
||||
return $earliestDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, \DateTimeInterface>|null
|
||||
*/
|
||||
private function getLastActiveDates($lastActiveEventId, array $contactIds): ?array
|
||||
{
|
||||
// If there is a parent ID, get last active dates based on when that event was executed for the given contact
|
||||
// Otherwise, use when the contact was added to the campaign for comparison
|
||||
if ($lastActiveEventId) {
|
||||
return $this->eventLogRepository->getDatesExecuted($lastActiveEventId, $contactIds);
|
||||
}
|
||||
|
||||
return $this->inactiveContactFinder->getDatesAdded();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\CampaignBundle\Executioner\Helper;
|
||||
|
||||
use Mautic\CampaignBundle\Entity\Event;
|
||||
use Mautic\CoreBundle\Helper\CoreParametersHelper;
|
||||
use Mautic\CoreBundle\Model\NotificationModel;
|
||||
use Mautic\LeadBundle\Entity\Lead;
|
||||
use Mautic\UserBundle\Entity\User;
|
||||
use Mautic\UserBundle\Model\UserModel;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Component\Routing\Router;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
class NotificationHelper
|
||||
{
|
||||
public function __construct(
|
||||
private UserModel $userModel,
|
||||
private NotificationModel $notificationModel,
|
||||
private TranslatorInterface $translator,
|
||||
private Router $router,
|
||||
private CoreParametersHelper $coreParametersHelper,
|
||||
) {
|
||||
}
|
||||
|
||||
public function notifyOfFailure(Lead $contact, Event $event): void
|
||||
{
|
||||
$user = $this->getUser($contact, $event);
|
||||
if (!$user || !$user->getId()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->notificationModel->addNotification(
|
||||
$event->getCampaign()->getName().' / '.$event->getName(),
|
||||
'error',
|
||||
false,
|
||||
$this->translator->trans(
|
||||
'mautic.campaign.event.failed',
|
||||
[
|
||||
'%contact%' => '<a href="'.$this->router->generate(
|
||||
'mautic_contact_action',
|
||||
['objectAction' => 'view', 'objectId' => $contact->getId()]
|
||||
).'" data-toggle="ajax">'.$contact->getPrimaryIdentifier().'</a>',
|
||||
]
|
||||
),
|
||||
null,
|
||||
null,
|
||||
$user
|
||||
);
|
||||
}
|
||||
|
||||
public function notifyOfUnpublish(Event $event): void
|
||||
{
|
||||
/**
|
||||
* Pass a fake lead so we can just get the campaign creator.
|
||||
*/
|
||||
$user = $this->getUser(new Lead(), $event);
|
||||
|
||||
if (!$user || !$user->getId()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$campaign = $event->getCampaign();
|
||||
|
||||
$this->notificationModel->addNotification(
|
||||
$campaign->getName().' / '.$event->getName(),
|
||||
'error',
|
||||
false,
|
||||
$this->translator->trans(
|
||||
'mautic.campaign.event.failed.campaign.unpublished',
|
||||
[
|
||||
'%campaign%' => '<a href="'.$this->router->generate(
|
||||
'mautic_campaign_action',
|
||||
[
|
||||
'objectAction' => 'view',
|
||||
'objectId' => $campaign->getId(),
|
||||
],
|
||||
UrlGeneratorInterface::ABSOLUTE_URL
|
||||
).'" data-toggle="ajax">'.$campaign->getName().'</a>',
|
||||
'%event%' => $event->getName(),
|
||||
]
|
||||
),
|
||||
null,
|
||||
null,
|
||||
$user
|
||||
);
|
||||
|
||||
$subject = $this->translator->trans(
|
||||
'mautic.campaign.event.campaign_unpublished',
|
||||
[
|
||||
'%title%' => $campaign->getName(),
|
||||
]
|
||||
);
|
||||
|
||||
$content = $this->translator->trans(
|
||||
'mautic.campaign.event.failed.campaign.unpublished',
|
||||
[
|
||||
'%campaign%' => '<a href="'.$this->router->generate(
|
||||
'mautic_campaign_action',
|
||||
[
|
||||
'objectAction' => 'view',
|
||||
'objectId' => $campaign->getId(),
|
||||
],
|
||||
UrlGeneratorInterface::ABSOLUTE_URL
|
||||
).'" data-toggle="ajax">'.$campaign->getName().'</a>',
|
||||
'%event%' => $event->getName(),
|
||||
]
|
||||
);
|
||||
|
||||
$sendToAuthor = $this->coreParametersHelper->get('campaign_send_notification_to_author', 1);
|
||||
if ($sendToAuthor) {
|
||||
$this->userModel->emailUser($user, $subject, $content);
|
||||
} else {
|
||||
$emailAddresses = array_map('trim', explode(',', $this->coreParametersHelper->get('campaign_notification_email_addresses')));
|
||||
$this->userModel->sendMailToEmailAddresses($emailAddresses, $subject, $content);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return User|null
|
||||
*/
|
||||
private function getUser(Lead $contact, Event $event)
|
||||
{
|
||||
// Default is to notify the contact owner
|
||||
if ($owner = $contact->getOwner()) {
|
||||
return $owner;
|
||||
}
|
||||
|
||||
// If the contact doesn't have an owner, notify the one that created the campaign
|
||||
if ($campaignCreator = $event->getCampaign()->getCreatedBy()) {
|
||||
if ($owner = $this->userModel->getEntity($campaignCreator)) {
|
||||
return $owner;
|
||||
}
|
||||
}
|
||||
|
||||
// If all else fails, notifiy a system admins
|
||||
return $this->userModel->getSystemAdministrator();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\CampaignBundle\Executioner;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Mautic\CampaignBundle\Entity\Campaign;
|
||||
use Mautic\CampaignBundle\Entity\Event;
|
||||
use Mautic\CampaignBundle\Executioner\ContactFinder\InactiveContactFinder;
|
||||
use Mautic\CampaignBundle\Executioner\ContactFinder\Limiter\ContactLimiter;
|
||||
use Mautic\CampaignBundle\Executioner\Exception\NoContactsFoundException;
|
||||
use Mautic\CampaignBundle\Executioner\Exception\NoEventsFoundException;
|
||||
use Mautic\CampaignBundle\Executioner\Helper\InactiveHelper;
|
||||
use Mautic\CampaignBundle\Executioner\Result\Counter;
|
||||
use Mautic\CampaignBundle\Executioner\Scheduler\EventScheduler;
|
||||
use Mautic\CoreBundle\Helper\ProgressBarHelper;
|
||||
use Mautic\CoreBundle\ProcessSignal\ProcessSignalService;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Console\Output\NullOutput;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
class InactiveExecutioner implements ExecutionerInterface
|
||||
{
|
||||
/**
|
||||
* @var Campaign
|
||||
*/
|
||||
private $campaign;
|
||||
|
||||
private ?ContactLimiter $limiter = null;
|
||||
|
||||
private ?OutputInterface $output = null;
|
||||
|
||||
private ?\Symfony\Component\Console\Helper\ProgressBar $progressBar = null;
|
||||
|
||||
private ?Counter $counter = null;
|
||||
|
||||
private ?ArrayCollection $decisions = null;
|
||||
|
||||
protected ?\DateTime $now = null;
|
||||
|
||||
public function __construct(
|
||||
private InactiveContactFinder $inactiveContactFinder,
|
||||
private LoggerInterface $logger,
|
||||
private TranslatorInterface $translator,
|
||||
private EventScheduler $scheduler,
|
||||
private InactiveHelper $helper,
|
||||
private EventExecutioner $executioner,
|
||||
private ProcessSignalService $processSignalService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Counter
|
||||
*
|
||||
* @throws Dispatcher\Exception\LogNotProcessedException
|
||||
* @throws Dispatcher\Exception\LogPassedAndFailedException
|
||||
* @throws Exception\CannotProcessEventException
|
||||
* @throws Scheduler\Exception\NotSchedulableException
|
||||
*/
|
||||
public function execute(Campaign $campaign, ContactLimiter $limiter, ?OutputInterface $output = null)
|
||||
{
|
||||
$this->campaign = $campaign;
|
||||
$this->limiter = $limiter;
|
||||
$this->output = $output ?: new NullOutput();
|
||||
$this->counter = new Counter();
|
||||
|
||||
try {
|
||||
$this->decisions = $this->campaign->getEventsByType(Event::TYPE_DECISION);
|
||||
|
||||
$this->prepareForExecution();
|
||||
$this->executeEvents();
|
||||
} catch (NoContactsFoundException) {
|
||||
$this->logger->debug('CAMPAIGN: No more contacts to process');
|
||||
} catch (NoEventsFoundException) {
|
||||
$this->logger->debug('CAMPAIGN: No events to process');
|
||||
} finally {
|
||||
if ($this->progressBar) {
|
||||
$this->progressBar->finish();
|
||||
}
|
||||
}
|
||||
|
||||
return $this->counter;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $decisionId
|
||||
*
|
||||
* @return Counter
|
||||
*
|
||||
* @throws Dispatcher\Exception\LogNotProcessedException
|
||||
* @throws Dispatcher\Exception\LogPassedAndFailedException
|
||||
* @throws Exception\CannotProcessEventException
|
||||
* @throws Scheduler\Exception\NotSchedulableException
|
||||
*/
|
||||
public function validate($decisionId, ContactLimiter $limiter, ?OutputInterface $output = null)
|
||||
{
|
||||
$this->limiter = $limiter;
|
||||
$this->output = $output ?: new NullOutput();
|
||||
$this->counter = new Counter();
|
||||
|
||||
try {
|
||||
$this->decisions = $this->helper->getCollectionByDecisionId($decisionId);
|
||||
|
||||
$this->checkCampaignIsPublished();
|
||||
$this->prepareForExecution();
|
||||
$this->executeEvents();
|
||||
} catch (NoContactsFoundException) {
|
||||
$this->logger->debug('CAMPAIGN: No more contacts to process');
|
||||
} catch (NoEventsFoundException) {
|
||||
$this->logger->debug('CAMPAIGN: No events to process');
|
||||
} finally {
|
||||
if ($this->progressBar) {
|
||||
$this->progressBar->finish();
|
||||
}
|
||||
}
|
||||
|
||||
return $this->counter;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws NoEventsFoundException
|
||||
*/
|
||||
private function checkCampaignIsPublished(): void
|
||||
{
|
||||
if (!$this->decisions->count()) {
|
||||
throw new NoEventsFoundException();
|
||||
}
|
||||
|
||||
$this->campaign = $this->decisions->first()->getCampaign();
|
||||
if (!$this->campaign->isPublished()) {
|
||||
throw new NoEventsFoundException();
|
||||
}
|
||||
|
||||
if ($this->campaign->isDeleted()) {
|
||||
throw new NoEventsFoundException();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws NoContactsFoundException
|
||||
* @throws NoEventsFoundException
|
||||
*/
|
||||
private function prepareForExecution(): void
|
||||
{
|
||||
$this->logger->debug('CAMPAIGN: Triggering inaction events');
|
||||
|
||||
$this->helper->removeDecisionsWithoutNegativeChildren($this->decisions);
|
||||
|
||||
$totalDecisions = $this->decisions->count();
|
||||
if (!$totalDecisions) {
|
||||
throw new NoEventsFoundException();
|
||||
}
|
||||
$totalContacts = 0;
|
||||
if (!($this->output instanceof NullOutput)) {
|
||||
$totalContacts = $this->inactiveContactFinder->getContactCount($this->campaign->getId(), $this->decisions->getKeys(), $this->limiter);
|
||||
|
||||
$this->output->writeln(
|
||||
$this->translator->trans(
|
||||
'mautic.campaign.trigger.decision_count_analyzed',
|
||||
[
|
||||
'%decisions%' => $totalDecisions,
|
||||
'%leads%' => $totalContacts,
|
||||
'%batch%' => $this->limiter->getBatchLimit(),
|
||||
]
|
||||
)
|
||||
);
|
||||
|
||||
if (!$totalContacts) {
|
||||
throw new NoContactsFoundException();
|
||||
}
|
||||
}
|
||||
|
||||
// Approximate total count because the query to fetch contacts will filter out those that have not arrived to this point in the campaign yet
|
||||
$this->progressBar = ProgressBarHelper::init($this->output, $totalContacts * $totalDecisions);
|
||||
$this->progressBar->start();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Dispatcher\Exception\LogNotProcessedException
|
||||
* @throws Dispatcher\Exception\LogPassedAndFailedException
|
||||
* @throws Exception\CannotProcessEventException
|
||||
* @throws Scheduler\Exception\NotSchedulableException
|
||||
*/
|
||||
private function executeEvents(): void
|
||||
{
|
||||
// Use the same timestamp across all contacts processed
|
||||
$now = $this->now ?? new \DateTime();
|
||||
|
||||
/** @var Event $decisionEvent */
|
||||
foreach ($this->decisions as $decisionEvent) {
|
||||
try {
|
||||
// We need the parent ID of the decision in order to fetch the time the contact executed this event
|
||||
$parentEvent = $decisionEvent->getParent();
|
||||
$parentEventId = $parentEvent && !$parentEvent->isDeleted() ? $parentEvent->getId() : null;
|
||||
|
||||
// Ge the first batch of contacts
|
||||
$contacts = $this->inactiveContactFinder->getContacts($this->campaign->getId(), $decisionEvent, $this->limiter);
|
||||
|
||||
// Loop over all contacts till we've processed all those applicable for this decision
|
||||
while ($contacts->count()) {
|
||||
// Get the max contact ID before any are removed
|
||||
$batchMinContactId = max($contacts->getKeys()) + 1;
|
||||
|
||||
$this->progressBar->advance($contacts->count());
|
||||
$this->counter->advanceEvaluated($contacts->count());
|
||||
|
||||
$inactiveEvents = $decisionEvent->getNegativeChildren();
|
||||
$this->helper->removeContactsThatAreNotApplicable($now, $contacts, $parentEventId, $inactiveEvents, $decisionEvent);
|
||||
$earliestLastActiveDateTime = $this->helper->getEarliestInactiveDateTime();
|
||||
|
||||
$this->logger->debug(
|
||||
'CAMPAIGN: ('.$decisionEvent->getId().') Earliest date for inactivity for this batch of contacts is '.
|
||||
$earliestLastActiveDateTime->format('Y-m-d H:i:s T')
|
||||
);
|
||||
|
||||
if ($contacts->count()) {
|
||||
// Record decision for these contacts
|
||||
$this->executioner->recordLogsAsExecutedForEvent($decisionEvent, $contacts, true);
|
||||
|
||||
// Execute or schedule the events attached to the inactive side of the decision
|
||||
$this->executeLogsForInactiveEvents($inactiveEvents, $contacts, $this->counter, $earliestLastActiveDateTime);
|
||||
}
|
||||
|
||||
// Clear contacts from memory
|
||||
$this->inactiveContactFinder->clear($contacts);
|
||||
|
||||
if ($this->limiter->getContactId()) {
|
||||
// No use making another call
|
||||
break;
|
||||
}
|
||||
|
||||
$this->processSignalService->throwExceptionIfSignalIsCaught();
|
||||
|
||||
$this->logger->debug('CAMPAIGN: Fetching the next batch of inactive contacts starting with contact ID '.$batchMinContactId);
|
||||
$this->limiter->setBatchMinContactId($batchMinContactId);
|
||||
|
||||
// Get the next batch, starting with the max contact ID
|
||||
$contacts = $this->inactiveContactFinder->getContacts($this->campaign->getId(), $decisionEvent, $this->limiter);
|
||||
}
|
||||
} catch (NoContactsFoundException) {
|
||||
// On to the next decision
|
||||
$this->logger->debug('CAMPAIGN: No more contacts to process for decision ID #'.$decisionEvent->getId());
|
||||
}
|
||||
|
||||
// Ensure the batch min is reset from the last decision event
|
||||
$this->limiter->resetBatchMinContactId();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Dispatcher\Exception\LogNotProcessedException
|
||||
* @throws Dispatcher\Exception\LogPassedAndFailedException
|
||||
* @throws Exception\CannotProcessEventException
|
||||
* @throws Scheduler\Exception\NotSchedulableException
|
||||
*/
|
||||
private function executeLogsForInactiveEvents(ArrayCollection $events, ArrayCollection $contacts, Counter $childrenCounter, \DateTimeInterface $earliestLastActiveDateTime): void
|
||||
{
|
||||
$events = clone $events;
|
||||
$eventExecutionDates = $this->scheduler->getSortedExecutionDates($events, $earliestLastActiveDateTime);
|
||||
|
||||
/** @var \DateTime $earliestExecutionDate */
|
||||
$earliestExecutionDate = reset($eventExecutionDates);
|
||||
|
||||
$executionDate = $this->executioner->getExecutionDate();
|
||||
|
||||
foreach ($events as $key => $event) {
|
||||
// Ignore decisions
|
||||
if (Event::TYPE_DECISION == $event->getEventType()) {
|
||||
$this->logger->debug('CAMPAIGN: Ignoring child event ID '.$event->getId().' as a decision');
|
||||
|
||||
$events->remove($key);
|
||||
continue;
|
||||
}
|
||||
|
||||
$eventExecutionDate = $this->scheduler->getExecutionDateForInactivity(
|
||||
$eventExecutionDates[$event->getId()],
|
||||
$earliestExecutionDate,
|
||||
$executionDate
|
||||
);
|
||||
|
||||
$this->logger->debug(
|
||||
'CAMPAIGN: Event ID# '.$event->getId().
|
||||
' to be executed on '.$eventExecutionDate->format('Y-m-d H:i:s e')
|
||||
);
|
||||
|
||||
if ($this->scheduler->shouldScheduleEvent($event, $eventExecutionDate, $executionDate)) {
|
||||
$childrenCounter->advanceTotalScheduled($contacts->count());
|
||||
$this->scheduler->schedule($event, $eventExecutionDate, $contacts, true);
|
||||
|
||||
$events->remove($key);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if ($events->count()) {
|
||||
$this->executioner->executeEventsForContacts($events, $contacts, $childrenCounter, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\CampaignBundle\Executioner;
|
||||
|
||||
use Mautic\CampaignBundle\Entity\Campaign;
|
||||
use Mautic\CampaignBundle\Entity\Event;
|
||||
use Mautic\CampaignBundle\Executioner\ContactFinder\KickoffContactFinder;
|
||||
use Mautic\CampaignBundle\Executioner\ContactFinder\Limiter\ContactLimiter;
|
||||
use Mautic\CampaignBundle\Executioner\Exception\NoContactsFoundException;
|
||||
use Mautic\CampaignBundle\Executioner\Exception\NoEventsFoundException;
|
||||
use Mautic\CampaignBundle\Executioner\Result\Counter;
|
||||
use Mautic\CampaignBundle\Executioner\Scheduler\EventScheduler;
|
||||
use Mautic\CampaignBundle\Executioner\Scheduler\Exception\NotSchedulableException;
|
||||
use Mautic\CoreBundle\Event\JobExtendTimeEvent;
|
||||
use Mautic\CoreBundle\Helper\CoreParametersHelper;
|
||||
use Mautic\CoreBundle\Helper\ProgressBarHelper;
|
||||
use Mautic\CoreBundle\ProcessSignal\ProcessSignalService;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Console\Output\NullOutput;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
class KickoffExecutioner implements ExecutionerInterface
|
||||
{
|
||||
private ?ContactLimiter $limiter = null;
|
||||
|
||||
private ?Campaign $campaign = null;
|
||||
|
||||
private ?OutputInterface $output = null;
|
||||
|
||||
private ?\Symfony\Component\Console\Helper\ProgressBar $progressBar = null;
|
||||
|
||||
private ?\Doctrine\Common\Collections\ArrayCollection $rootEvents = null;
|
||||
|
||||
private ?Counter $counter = null;
|
||||
|
||||
public function __construct(
|
||||
private LoggerInterface $logger,
|
||||
private KickoffContactFinder $kickoffContactFinder,
|
||||
private TranslatorInterface $translator,
|
||||
private EventExecutioner $executioner,
|
||||
private EventScheduler $scheduler,
|
||||
private ProcessSignalService $processSignalService,
|
||||
private CoreParametersHelper $coreParametersHelper,
|
||||
private EventDispatcherInterface $eventDispatcher,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Counter
|
||||
*
|
||||
* @throws Dispatcher\Exception\LogNotProcessedException
|
||||
* @throws Dispatcher\Exception\LogPassedAndFailedException
|
||||
* @throws Exception\CannotProcessEventException
|
||||
* @throws NotSchedulableException
|
||||
*/
|
||||
public function execute(Campaign $campaign, ContactLimiter $limiter, ?OutputInterface $output = null)
|
||||
{
|
||||
$this->campaign = $campaign;
|
||||
$this->limiter = $limiter;
|
||||
$this->output = $output ?: new NullOutput();
|
||||
$this->counter = new Counter();
|
||||
|
||||
try {
|
||||
$this->prepareForExecution();
|
||||
$this->executeOrScheduleEvent();
|
||||
} catch (NoContactsFoundException) {
|
||||
$this->logger->debug('CAMPAIGN: No more contacts to process');
|
||||
} catch (NoEventsFoundException) {
|
||||
$this->logger->debug('CAMPAIGN: No events to process');
|
||||
} finally {
|
||||
if ($this->progressBar) {
|
||||
$this->progressBar->finish();
|
||||
}
|
||||
if ($this->coreParametersHelper->get('campaign_use_summary')) {
|
||||
$this->executioner->persistSummaries();
|
||||
}
|
||||
}
|
||||
|
||||
return $this->counter;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws NoEventsFoundException
|
||||
*/
|
||||
private function prepareForExecution(): void
|
||||
{
|
||||
$this->logger->debug('CAMPAIGN: Triggering kickoff events');
|
||||
|
||||
$this->rootEvents = $this->campaign->getRootEvents();
|
||||
$totalRootEvents = $this->rootEvents->count();
|
||||
if (!$totalRootEvents) {
|
||||
throw new NoEventsFoundException();
|
||||
}
|
||||
$this->logger->debug('CAMPAIGN: Processing the following events: '.implode(', ', $this->rootEvents->getKeys()));
|
||||
$totalKickoffEvents = 0;
|
||||
if (!($this->output instanceof NullOutput)) {
|
||||
$totalContacts = $this->kickoffContactFinder->getContactCount($this->campaign->getId(), $this->rootEvents->getKeys(), $this->limiter);
|
||||
$totalKickoffEvents = $totalRootEvents * $totalContacts;
|
||||
|
||||
$this->output->writeln(
|
||||
$this->translator->trans(
|
||||
'mautic.campaign.trigger.event_count',
|
||||
[
|
||||
'%events%' => $totalKickoffEvents,
|
||||
'%batch%' => $this->limiter->getBatchLimit(),
|
||||
]
|
||||
)
|
||||
);
|
||||
|
||||
if (!$totalKickoffEvents) {
|
||||
throw new NoEventsFoundException();
|
||||
}
|
||||
}
|
||||
|
||||
$this->progressBar = ProgressBarHelper::init($this->output, $totalKickoffEvents);
|
||||
$this->progressBar->start();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Dispatcher\Exception\LogNotProcessedException
|
||||
* @throws Dispatcher\Exception\LogPassedAndFailedException
|
||||
* @throws Exception\CannotProcessEventException
|
||||
* @throws NoContactsFoundException
|
||||
* @throws NotSchedulableException
|
||||
*/
|
||||
private function executeOrScheduleEvent(): void
|
||||
{
|
||||
// Use the same timestamp across all contacts processed
|
||||
$now = new \DateTime();
|
||||
$this->counter->advanceEventCount($this->rootEvents->count());
|
||||
|
||||
// Loop over contacts until the entire campaign is executed
|
||||
$contacts = $this->kickoffContactFinder->getContacts($this->campaign->getId(), $this->limiter);
|
||||
while ($contacts && $contacts->count()) {
|
||||
$batchMinContactId = max($contacts->getKeys()) + 1;
|
||||
$rootEvents = clone $this->rootEvents;
|
||||
|
||||
/** @var Event $event */
|
||||
foreach ($rootEvents as $key => $event) {
|
||||
$this->progressBar->advance($contacts->count());
|
||||
$this->counter->advanceEvaluated($contacts->count());
|
||||
|
||||
try {
|
||||
// Get the date the event would be executed on as if it was based on days only
|
||||
$executionDate = $this->scheduler->getExecutionDateTime($event, $now);
|
||||
$this->logger->debug(
|
||||
'CAMPAIGN: Event ID# '.$event->getId().
|
||||
' to be executed on '.$executionDate->format('Y-m-d H:i:s e').
|
||||
' compared to '.$now->format('Y-m-d H:i:s e')
|
||||
);
|
||||
|
||||
// Adjust the hour based on contact timezone if applicable
|
||||
$this->scheduler->validateAndScheduleEventForContacts($event, $executionDate, $contacts, $now);
|
||||
|
||||
$this->counter->advanceTotalScheduled($contacts->count());
|
||||
$rootEvents->remove($key);
|
||||
|
||||
continue;
|
||||
} catch (NotSchedulableException) {
|
||||
// Execute the event
|
||||
}
|
||||
}
|
||||
|
||||
if ($rootEvents->count()) {
|
||||
// Execute the events for the batch of contacts
|
||||
$this->executioner->executeEventsForContacts($rootEvents, $contacts, $this->counter);
|
||||
}
|
||||
|
||||
$this->kickoffContactFinder->clear($contacts);
|
||||
|
||||
if ($this->limiter->getContactId()) {
|
||||
// No use making another call
|
||||
break;
|
||||
}
|
||||
|
||||
$this->processSignalService->throwExceptionIfSignalIsCaught();
|
||||
|
||||
$this->logger->debug('CAMPAIGN: Fetching the next batch of kickoff contacts starting with contact ID '.$batchMinContactId);
|
||||
$this->limiter->setBatchMinContactId($batchMinContactId);
|
||||
// Get the next batch
|
||||
$contacts = $this->kickoffContactFinder->getContacts($this->campaign->getId(), $this->limiter);
|
||||
$this->eventDispatcher->dispatch(new JobExtendTimeEvent());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\CampaignBundle\Executioner\Logger;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Mautic\CampaignBundle\Entity\Event;
|
||||
use Mautic\CampaignBundle\Entity\LeadEventLog;
|
||||
use Mautic\CampaignBundle\Entity\LeadEventLogRepository;
|
||||
use Mautic\CampaignBundle\Entity\LeadRepository;
|
||||
use Mautic\CampaignBundle\EventCollector\Accessor\Event\AbstractEventAccessor;
|
||||
use Mautic\CampaignBundle\Helper\ChannelExtractor;
|
||||
use Mautic\CampaignBundle\Model\SummaryModel;
|
||||
use Mautic\CoreBundle\Helper\CoreParametersHelper;
|
||||
use Mautic\CoreBundle\Helper\IpLookupHelper;
|
||||
use Mautic\LeadBundle\Entity\Lead;
|
||||
use Mautic\LeadBundle\Tracker\ContactTracker;
|
||||
|
||||
class EventLogger
|
||||
{
|
||||
private ArrayCollection $persistQueue;
|
||||
|
||||
private ArrayCollection $logs;
|
||||
|
||||
private array $contactRotations = [];
|
||||
|
||||
private int $lastUsedCampaignIdToFetchRotation;
|
||||
|
||||
public function __construct(
|
||||
private IpLookupHelper $ipLookupHelper,
|
||||
private ContactTracker $contactTracker,
|
||||
private LeadEventLogRepository $leadEventLogRepository,
|
||||
private LeadRepository $leadRepository,
|
||||
private SummaryModel $summaryModel,
|
||||
private CoreParametersHelper $coreParametersHelper,
|
||||
) {
|
||||
$this->persistQueue = new ArrayCollection();
|
||||
$this->logs = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function queueToPersist(LeadEventLog $log): void
|
||||
{
|
||||
$this->persistQueue->add($log);
|
||||
|
||||
if ($this->persistQueue->count() >= 20) {
|
||||
$this->persistPendingAndInsertIntoLogStack();
|
||||
}
|
||||
}
|
||||
|
||||
public function persistLog(LeadEventLog $log): void
|
||||
{
|
||||
$this->leadEventLogRepository->saveEntity($log);
|
||||
if ($this->coreParametersHelper->get('campaign_use_summary')) {
|
||||
$this->summaryModel->updateSummary([$log]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param bool $isInactiveEvent
|
||||
*/
|
||||
public function buildLogEntry(Event $event, ?Lead $contact = null, $isInactiveEvent = false): LeadEventLog
|
||||
{
|
||||
$log = new LeadEventLog();
|
||||
|
||||
if (!defined('MAUTIC_CAMPAIGN_SYSTEM_TRIGGERED')) {
|
||||
$log->setIpAddress($this->ipLookupHelper->getIpAddress());
|
||||
}
|
||||
|
||||
$log->setEvent($event);
|
||||
$log->setCampaign($campaign = $event->getCampaign());
|
||||
|
||||
if (null === $contact) {
|
||||
$contact = $this->contactTracker->getContact();
|
||||
}
|
||||
$log->setLead($contact);
|
||||
|
||||
if ($isInactiveEvent) {
|
||||
$log->setNonActionPathTaken(true);
|
||||
}
|
||||
|
||||
$log->setDateTriggered(new \DateTime());
|
||||
$log->setSystemTriggered(defined('MAUTIC_CAMPAIGN_SYSTEM_TRIGGERED'));
|
||||
|
||||
if (isset($this->contactRotations[$campaign->getId()][$contact->getId()]) && ($this->lastUsedCampaignIdToFetchRotation === $event->getCampaign()->getId())) {
|
||||
$log->setRotation($this->contactRotations[$campaign->getId()][$contact->getId()]['rotation']);
|
||||
} else {
|
||||
// Likely a single contact handle such as decision processing
|
||||
$rotations = $this->leadRepository->getContactRotations([$contact->getId()], $event->getCampaign()->getId());
|
||||
$rotationVal = isset($rotations[$contact->getId()]) ? $rotations[$contact->getId()]['rotation'] : 1;
|
||||
$log->setRotation($rotationVal);
|
||||
}
|
||||
|
||||
return $log;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the queue, clear the entities from memory, and reset the queue.
|
||||
*
|
||||
* @return ArrayCollection
|
||||
*/
|
||||
public function persistQueuedLogs()
|
||||
{
|
||||
$this->persistPendingAndInsertIntoLogStack();
|
||||
|
||||
$logs = clone $this->logs;
|
||||
$this->logs->clear();
|
||||
|
||||
return $logs;
|
||||
}
|
||||
|
||||
public function persistCollection(ArrayCollection $collection): self
|
||||
{
|
||||
if (!$collection->count()) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
$this->leadEventLogRepository->saveEntities($collection->getValues());
|
||||
if ($this->coreParametersHelper->get('campaign_use_summary')) {
|
||||
$this->summaryModel->updateSummary($collection->getValues());
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function clearCollection(ArrayCollection $collection): self
|
||||
{
|
||||
$this->leadEventLogRepository->detachEntities($collection->getValues());
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function extractContactsFromLogs(ArrayCollection $logs): ArrayCollection
|
||||
{
|
||||
$contacts = new ArrayCollection();
|
||||
|
||||
/** @var LeadEventLog $log */
|
||||
foreach ($logs as $log) {
|
||||
$contact = $log->getLead();
|
||||
$contacts->set($contact->getId(), $contact);
|
||||
}
|
||||
|
||||
return $contacts;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param bool $isInactiveEntry
|
||||
*
|
||||
* @return ArrayCollection
|
||||
*/
|
||||
public function fetchRotationAndGenerateLogsFromContacts(Event $event, AbstractEventAccessor $config, ArrayCollection $contacts, $isInactiveEntry = false)
|
||||
{
|
||||
$this->hydrateContactRotationsForNewLogs($contacts->getKeys(), $event->getCampaign()->getId());
|
||||
|
||||
return $this->generateLogsFromContacts($event, $config, $contacts, $isInactiveEntry);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param bool $isInactiveEntry
|
||||
*
|
||||
* @return ArrayCollection
|
||||
*/
|
||||
public function generateLogsFromContacts(Event $event, AbstractEventAccessor $config, ArrayCollection $contacts, $isInactiveEntry)
|
||||
{
|
||||
$isDecision = Event::TYPE_DECISION === $event->getEventType();
|
||||
$campaign = $event->getCampaign();
|
||||
|
||||
// Ensure each contact has a log entry to prevent them from being picked up again prematurely
|
||||
foreach ($contacts as $contact) {
|
||||
if (isset($this->contactRotations[$campaign->getId()][$contact->getId()]) && $this->contactRotations[$campaign->getId()][$contact->getId()]['manually_removed']) {
|
||||
continue;
|
||||
}
|
||||
$log = $this->buildLogEntry($event, $contact, $isInactiveEntry);
|
||||
$log->setIsScheduled(false);
|
||||
$log->setDateTriggered(new \DateTime());
|
||||
ChannelExtractor::setChannel($log, $event, $config);
|
||||
|
||||
if ($isDecision) {
|
||||
// Do not pre-persist decision logs as they must be evaluated first
|
||||
$this->logs->add($log);
|
||||
} else {
|
||||
$this->queueToPersist($log);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->persistQueuedLogs();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $campaignId
|
||||
*/
|
||||
public function hydrateContactRotationsForNewLogs(array $contactIds, $campaignId): void
|
||||
{
|
||||
$this->contactRotations[$campaignId] = $this->leadRepository->getContactRotations($contactIds, $campaignId);
|
||||
$this->lastUsedCampaignIdToFetchRotation = $campaignId;
|
||||
}
|
||||
|
||||
private function persistPendingAndInsertIntoLogStack(): void
|
||||
{
|
||||
if (!$this->persistQueue->count()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->leadEventLogRepository->saveEntities($this->persistQueue->getValues());
|
||||
|
||||
// Push them into the logs ArrayCollection to be used later.
|
||||
/** @var LeadEventLog $log */
|
||||
foreach ($this->persistQueue as $log) {
|
||||
$this->logs->set($log->getId(), $log);
|
||||
}
|
||||
|
||||
$this->persistQueue->clear();
|
||||
}
|
||||
|
||||
public function getSummaryModel(): SummaryModel
|
||||
{
|
||||
return $this->summaryModel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\CampaignBundle\Executioner;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Mautic\CampaignBundle\Entity\Event;
|
||||
use Mautic\CampaignBundle\Entity\EventRepository;
|
||||
use Mautic\CampaignBundle\EventCollector\Accessor\Event\DecisionAccessor;
|
||||
use Mautic\CampaignBundle\EventCollector\EventCollector;
|
||||
use Mautic\CampaignBundle\Executioner\Event\DecisionExecutioner as Executioner;
|
||||
use Mautic\CampaignBundle\Executioner\Exception\CampaignNotExecutableException;
|
||||
use Mautic\CampaignBundle\Executioner\Exception\DecisionNotApplicableException;
|
||||
use Mautic\CampaignBundle\Executioner\Helper\DecisionHelper;
|
||||
use Mautic\CampaignBundle\Executioner\Result\Responses;
|
||||
use Mautic\CampaignBundle\Executioner\Scheduler\EventScheduler;
|
||||
use Mautic\CampaignBundle\Helper\ChannelExtractor;
|
||||
use Mautic\LeadBundle\Entity\Lead;
|
||||
use Mautic\LeadBundle\Model\LeadModel;
|
||||
use Mautic\LeadBundle\Tracker\ContactTracker;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class RealTimeExecutioner
|
||||
{
|
||||
/**
|
||||
* @var Lead
|
||||
*/
|
||||
private $contact;
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
private $events;
|
||||
|
||||
private ?Responses $responses = null;
|
||||
|
||||
public function __construct(
|
||||
private LoggerInterface $logger,
|
||||
private LeadModel $leadModel,
|
||||
private EventRepository $eventRepository,
|
||||
private EventExecutioner $executioner,
|
||||
private Executioner $decisionExecutioner,
|
||||
private EventCollector $collector,
|
||||
private EventScheduler $scheduler,
|
||||
private ContactTracker $contactTracker,
|
||||
private DecisionHelper $decisionHelper,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $type
|
||||
* @param mixed $passthrough
|
||||
* @param string|null $channel
|
||||
* @param int|null $channelId
|
||||
*
|
||||
* @return Responses
|
||||
*
|
||||
* @throws Dispatcher\Exception\LogNotProcessedException
|
||||
* @throws Dispatcher\Exception\LogPassedAndFailedException
|
||||
* @throws Exception\CannotProcessEventException
|
||||
* @throws Scheduler\Exception\NotSchedulableException
|
||||
*/
|
||||
public function execute($type, $passthrough = null, $channel = null, $channelId = null)
|
||||
{
|
||||
$this->responses = new Responses();
|
||||
$now = new \DateTime();
|
||||
|
||||
$this->logger->debug('CAMPAIGN: Campaign triggered for event type '.$type.'('.$channel.' / '.$channelId.')');
|
||||
|
||||
// Kept for BC support although not sure we need this
|
||||
defined('MAUTIC_CAMPAIGN_NOT_SYSTEM_TRIGGERED') or define('MAUTIC_CAMPAIGN_NOT_SYSTEM_TRIGGERED', 1);
|
||||
|
||||
try {
|
||||
$this->fetchCurrentContact();
|
||||
} catch (CampaignNotExecutableException $exception) {
|
||||
$this->logger->debug('CAMPAIGN: '.$exception->getMessage());
|
||||
|
||||
return $this->responses;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->fetchCampaignData($type);
|
||||
} catch (CampaignNotExecutableException $exception) {
|
||||
$this->logger->debug('CAMPAIGN: '.$exception->getMessage());
|
||||
|
||||
return $this->responses;
|
||||
}
|
||||
|
||||
/** @var Event $event */
|
||||
foreach ($this->events as $event) {
|
||||
try {
|
||||
$this->evaluateDecisionForContact($event, $passthrough, $channel, $channelId);
|
||||
} catch (DecisionNotApplicableException $exception) {
|
||||
$this->logger->debug('CAMPAIGN: Event ID '.$event->getId().' is not applicable ('.$exception->getMessage().')');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$children = $event->getPositiveChildren();
|
||||
if (!$children->count()) {
|
||||
$this->logger->debug('CAMPAIGN: Event ID '.$event->getId().' has no positive children');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->executeAssociatedEvents($children, $now);
|
||||
}
|
||||
|
||||
// Save any changes to the contact done by the listeners
|
||||
if ($this->contact->getChanges()) {
|
||||
$this->leadModel->saveEntity($this->contact, false);
|
||||
}
|
||||
|
||||
return $this->responses;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Dispatcher\Exception\LogNotProcessedException
|
||||
* @throws Dispatcher\Exception\LogPassedAndFailedException
|
||||
* @throws Exception\CannotProcessEventException
|
||||
* @throws Scheduler\Exception\NotSchedulableException
|
||||
*/
|
||||
private function executeAssociatedEvents(ArrayCollection $children, \DateTime $now): void
|
||||
{
|
||||
$children = clone $children;
|
||||
|
||||
/** @var Event $child */
|
||||
foreach ($children as $key => $child) {
|
||||
$executionDate = $this->scheduler->getExecutionDateTime($child, $now);
|
||||
$this->logger->debug(
|
||||
'CAMPAIGN: Event ID# '.$child->getId().
|
||||
' to be executed on '.$executionDate->format('Y-m-d H:i:s e')
|
||||
);
|
||||
|
||||
if ($this->scheduler->shouldSchedule($executionDate, $now)) {
|
||||
$this->scheduler->scheduleForContact($child, $executionDate, $this->contact);
|
||||
|
||||
$children->remove($key);
|
||||
}
|
||||
}
|
||||
|
||||
if ($children->count()) {
|
||||
$this->executioner->executeEventsForContact($children, $this->contact, $this->responses);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $passthrough
|
||||
* @param string|null $channel
|
||||
* @param int|null $channelId
|
||||
*
|
||||
* @throws DecisionNotApplicableException
|
||||
* @throws Exception\CannotProcessEventException
|
||||
*/
|
||||
private function evaluateDecisionForContact(Event $event, $passthrough = null, $channel = null, $channelId = null): void
|
||||
{
|
||||
$this->logger->debug('CAMPAIGN: Executing '.$event->getType().' ID '.$event->getId().' for contact ID '.$this->contact->getId());
|
||||
|
||||
$this->decisionHelper->checkIsDecisionApplicableForContact($event, $this->contact, $channel, $channelId);
|
||||
|
||||
/** @var DecisionAccessor $config */
|
||||
$config = $this->collector->getEventConfig($event);
|
||||
$this->decisionExecutioner->evaluateForContact($config, $event, $this->contact, $passthrough, $channel, $channelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CampaignNotExecutableException
|
||||
*/
|
||||
private function fetchCurrentContact(): void
|
||||
{
|
||||
$this->contact = $this->contactTracker->getContact();
|
||||
if (!$this->contact instanceof Lead || !$this->contact->getId()) {
|
||||
throw new CampaignNotExecutableException('Unidentifiable contact');
|
||||
}
|
||||
|
||||
$this->logger->debug('CAMPAIGN: Current contact ID# '.$this->contact->getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CampaignNotExecutableException
|
||||
*/
|
||||
private function fetchCampaignData($type): void
|
||||
{
|
||||
if (!$this->events = $this->eventRepository->getContactPendingEvents($this->contact->getId(), $type)) {
|
||||
throw new CampaignNotExecutableException('Contact does not have any applicable '.$type.' associations.');
|
||||
}
|
||||
|
||||
// 2.14 BC break workaround - pre 2.14 had a bug that recorded channelId for decisions as 1 regardless of actually ID
|
||||
// if channelIdField was an array and only one item was selected. That caused the channel ID check in evaluateDecisionForContact
|
||||
// to fail resulting in the decision never being evaluated. Therefore we are going to self heal these decisions.
|
||||
/** @var Event $event */
|
||||
foreach ($this->events as $event) {
|
||||
if ('1' === $event->getChannelId()) {
|
||||
ChannelExtractor::setChannel($event, $event, $this->collector->getEventConfig($event));
|
||||
|
||||
$this->eventRepository->saveEntity($event);
|
||||
}
|
||||
}
|
||||
|
||||
$this->logger->debug('CAMPAIGN: Found '.count($this->events).' events to analyze for contact ID '.$this->contact->getId());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\CampaignBundle\Executioner\Result;
|
||||
|
||||
class Counter
|
||||
{
|
||||
/**
|
||||
* @param int $eventCount
|
||||
* @param int $evaluated
|
||||
* @param int $executed
|
||||
* @param int $totalEvaluated
|
||||
* @param int $totalExecuted
|
||||
* @param int $totalScheduled
|
||||
*/
|
||||
public function __construct(
|
||||
private $eventCount = 0,
|
||||
private $evaluated = 0,
|
||||
private $executed = 0,
|
||||
private $totalEvaluated = 0,
|
||||
private $totalExecuted = 0,
|
||||
private $totalScheduled = 0,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int
|
||||
*/
|
||||
public function getEventCount()
|
||||
{
|
||||
return $this->eventCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $step
|
||||
*/
|
||||
public function advanceEventCount($step = 1): void
|
||||
{
|
||||
$this->eventCount += $step;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int
|
||||
*/
|
||||
public function getEvaluated()
|
||||
{
|
||||
return $this->evaluated;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $step
|
||||
*/
|
||||
public function advanceEvaluated($step = 1): void
|
||||
{
|
||||
$this->evaluated += $step;
|
||||
$this->totalEvaluated += $step;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int
|
||||
*/
|
||||
public function getExecuted()
|
||||
{
|
||||
return $this->executed;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $step
|
||||
*/
|
||||
public function advanceExecuted($step = 1): void
|
||||
{
|
||||
$this->executed += $step;
|
||||
$this->totalExecuted += $step;
|
||||
}
|
||||
|
||||
/**
|
||||
* Includes all child events (conditions, etc) evaluated.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function getTotalEvaluated()
|
||||
{
|
||||
return $this->totalEvaluated;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $step
|
||||
*/
|
||||
public function advanceTotalEvaluated($step = 1): void
|
||||
{
|
||||
$this->totalEvaluated += $step;
|
||||
}
|
||||
|
||||
/**
|
||||
* Includes all child events (conditions, etc) executed.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function getTotalExecuted()
|
||||
{
|
||||
return $this->totalExecuted;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $step
|
||||
*/
|
||||
public function advanceTotalExecuted($step = 1): void
|
||||
{
|
||||
$this->totalExecuted += $step;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int
|
||||
*/
|
||||
public function getTotalScheduled()
|
||||
{
|
||||
return $this->totalScheduled;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $step
|
||||
*/
|
||||
public function advanceTotalScheduled($step = 1): void
|
||||
{
|
||||
$this->totalScheduled += $step;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\CampaignBundle\Executioner\Result;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Mautic\LeadBundle\Entity\Lead;
|
||||
|
||||
class EvaluatedContacts
|
||||
{
|
||||
private ArrayCollection $passed;
|
||||
|
||||
private ArrayCollection $failed;
|
||||
|
||||
public function __construct(?ArrayCollection $passed = null, ?ArrayCollection $failed = null)
|
||||
{
|
||||
$this->passed = $passed ?? new ArrayCollection();
|
||||
$this->failed = $failed ?? new ArrayCollection();
|
||||
}
|
||||
|
||||
public function pass(Lead $contact): void
|
||||
{
|
||||
$this->passed->set($contact->getId(), $contact);
|
||||
}
|
||||
|
||||
public function fail(Lead $contact): void
|
||||
{
|
||||
$this->failed->set($contact->getId(), $contact);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ArrayCollection|Lead[]
|
||||
*/
|
||||
public function getPassed()
|
||||
{
|
||||
return $this->passed;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ArrayCollection|Lead[]
|
||||
*/
|
||||
public function getFailed()
|
||||
{
|
||||
return $this->failed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\CampaignBundle\Executioner\Result;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Mautic\CampaignBundle\Entity\Event;
|
||||
use Mautic\CampaignBundle\Entity\LeadEventLog;
|
||||
|
||||
class Responses
|
||||
{
|
||||
private array $actionResponses = [];
|
||||
|
||||
private array $conditionResponses = [];
|
||||
|
||||
public function setFromLogs(ArrayCollection $logs): void
|
||||
{
|
||||
/** @var LeadEventLog $log */
|
||||
foreach ($logs as $log) {
|
||||
$metadata = $log->getMetadata();
|
||||
$response = $metadata;
|
||||
|
||||
if (isset($metadata['timeline']) && 1 === count($metadata)) {
|
||||
// Legacy listeners set a string in CampaignExecutionEvent::setResult that Lead::appendToMetadata put into
|
||||
// under a timeline key for BC support. To keep BC for decisions, we have to extract that back out for the bubble
|
||||
// up responses
|
||||
|
||||
$response = $metadata['timeline'];
|
||||
}
|
||||
|
||||
$this->setResponse($log->getEvent(), $response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $response
|
||||
*/
|
||||
public function setResponse(Event $event, $response): void
|
||||
{
|
||||
switch ($event->getEventType()) {
|
||||
case Event::TYPE_ACTION:
|
||||
if (!isset($this->actionResponses[$event->getType()])) {
|
||||
$this->actionResponses[$event->getType()] = [];
|
||||
}
|
||||
$this->actionResponses[$event->getType()][$event->getId()] = $response;
|
||||
break;
|
||||
case Event::TYPE_CONDITION:
|
||||
if (!isset($this->conditionResponses[$event->getType()])) {
|
||||
$this->conditionResponses[$event->getType()] = [];
|
||||
}
|
||||
$this->conditionResponses[$event->getType()][$event->getId()] = $response;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|null $type
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getActionResponses($type = null)
|
||||
{
|
||||
if ($type) {
|
||||
return $this->actionResponses[$type] ?? [];
|
||||
}
|
||||
|
||||
return $this->actionResponses;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|null $type
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getConditionResponses($type = null)
|
||||
{
|
||||
if ($type) {
|
||||
return $this->conditionResponses[$type] ?? [];
|
||||
}
|
||||
|
||||
return $this->conditionResponses;
|
||||
}
|
||||
|
||||
public function containsResponses(): int
|
||||
{
|
||||
return count($this->actionResponses) + count($this->conditionResponses);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\CampaignBundle\Executioner;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Mautic\CampaignBundle\Entity\Campaign;
|
||||
use Mautic\CampaignBundle\Entity\Event;
|
||||
use Mautic\CampaignBundle\Entity\LeadEventLog;
|
||||
use Mautic\CampaignBundle\Entity\LeadEventLogRepository;
|
||||
use Mautic\CampaignBundle\EventListener\CampaignActionJumpToEventSubscriber;
|
||||
use Mautic\CampaignBundle\Executioner\ContactFinder\Limiter\ContactLimiter;
|
||||
use Mautic\CampaignBundle\Executioner\ContactFinder\ScheduledContactFinder;
|
||||
use Mautic\CampaignBundle\Executioner\Exception\NoContactsFoundException;
|
||||
use Mautic\CampaignBundle\Executioner\Exception\NoEventsFoundException;
|
||||
use Mautic\CampaignBundle\Executioner\Result\Counter;
|
||||
use Mautic\CampaignBundle\Executioner\Scheduler\EventScheduler;
|
||||
use Mautic\CoreBundle\Helper\ProgressBarHelper;
|
||||
use Mautic\CoreBundle\ProcessSignal\ProcessSignalService;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Console\Output\NullOutput;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Contracts\Service\ResetInterface;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
class ScheduledExecutioner implements ExecutionerInterface, ResetInterface
|
||||
{
|
||||
private ?Campaign $campaign = null;
|
||||
|
||||
private ?ContactLimiter $limiter = null;
|
||||
|
||||
private ?OutputInterface $output = null;
|
||||
|
||||
private ?\Symfony\Component\Console\Helper\ProgressBar $progressBar = null;
|
||||
|
||||
private ?array $scheduledEvents = null;
|
||||
|
||||
private ?Counter $counter = null;
|
||||
|
||||
protected ?\DateTime $now = null;
|
||||
|
||||
public function __construct(
|
||||
private LeadEventLogRepository $repo,
|
||||
private LoggerInterface $logger,
|
||||
private TranslatorInterface $translator,
|
||||
private EventExecutioner $executioner,
|
||||
private EventScheduler $scheduler,
|
||||
private ScheduledContactFinder $scheduledContactFinder,
|
||||
private ProcessSignalService $processSignalService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Counter|mixed
|
||||
*
|
||||
* @throws Dispatcher\Exception\LogNotProcessedException
|
||||
* @throws Dispatcher\Exception\LogPassedAndFailedException
|
||||
* @throws Exception\CannotProcessEventException
|
||||
* @throws Scheduler\Exception\NotSchedulableException
|
||||
* @throws \Doctrine\ORM\Query\QueryException
|
||||
*/
|
||||
public function execute(Campaign $campaign, ContactLimiter $limiter, ?OutputInterface $output = null)
|
||||
{
|
||||
$this->campaign = $campaign;
|
||||
$this->limiter = $limiter;
|
||||
$this->output = $output ?: new NullOutput();
|
||||
$this->counter = new Counter();
|
||||
|
||||
$this->logger->debug('CAMPAIGN: Triggering scheduled events');
|
||||
|
||||
try {
|
||||
$this->prepareForExecution();
|
||||
$this->executeOrRescheduleEvent();
|
||||
} catch (NoEventsFoundException) {
|
||||
$this->logger->debug('CAMPAIGN: No events to process');
|
||||
} finally {
|
||||
if ($this->progressBar) {
|
||||
$this->progressBar->finish();
|
||||
}
|
||||
}
|
||||
|
||||
return $this->counter;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Counter
|
||||
*
|
||||
* @throws Dispatcher\Exception\LogNotProcessedException
|
||||
* @throws Dispatcher\Exception\LogPassedAndFailedException
|
||||
* @throws Exception\CannotProcessEventException
|
||||
* @throws Scheduler\Exception\NotSchedulableException
|
||||
* @throws \Doctrine\ORM\Query\QueryException
|
||||
*/
|
||||
public function executeByIds(array $logIds, ?OutputInterface $output = null, ?\DateTime $now = null)
|
||||
{
|
||||
$now = $now ?? $this->now ?? new \DateTime();
|
||||
$this->output = $output ?: new NullOutput();
|
||||
$this->counter = new Counter();
|
||||
|
||||
if (!$logIds) {
|
||||
return $this->counter;
|
||||
}
|
||||
|
||||
$logs = $this->repo->getScheduledByIds($logIds);
|
||||
$totalLogsFound = $logs->count();
|
||||
$this->counter->advanceEvaluated($totalLogsFound);
|
||||
|
||||
$this->logger->debug('CAMPAIGN: '.$logs->count().' events scheduled to execute.');
|
||||
$this->output->writeln(
|
||||
$this->translator->trans(
|
||||
'mautic.campaign.trigger.event_count',
|
||||
[
|
||||
'%events%' => $totalLogsFound,
|
||||
'%batch%' => 'n/a',
|
||||
]
|
||||
)
|
||||
);
|
||||
|
||||
if (!$logs->count()) {
|
||||
return $this->counter;
|
||||
}
|
||||
|
||||
$this->progressBar = ProgressBarHelper::init($this->output, $totalLogsFound);
|
||||
$this->progressBar->start();
|
||||
|
||||
$scheduledLogCount = $totalLogsFound - $logs->count();
|
||||
$this->progressBar->advance($scheduledLogCount);
|
||||
|
||||
// Organize the logs by event ID
|
||||
$organized = $this->organizeByEvent($logs);
|
||||
foreach ($organized as $organizedLogs) {
|
||||
/** @var Event $event */
|
||||
$event = $organizedLogs->first()->getEvent();
|
||||
|
||||
// Validate that the schedule is still appropriate
|
||||
$this->validateSchedule($organizedLogs, $now, true);
|
||||
|
||||
// Check that the campaign is published with up/down dates
|
||||
if ($event->getCampaign()->isPublished()) {
|
||||
try {
|
||||
// Hydrate contacts with custom field data
|
||||
$this->scheduledContactFinder->hydrateContacts($organizedLogs);
|
||||
|
||||
$this->executioner->executeLogs($event, $organizedLogs, $this->counter);
|
||||
} catch (NoContactsFoundException) {
|
||||
// All of the events were rescheduled
|
||||
}
|
||||
}
|
||||
|
||||
$this->progressBar->advance($organizedLogs->count());
|
||||
}
|
||||
|
||||
$this->progressBar->finish();
|
||||
|
||||
return $this->counter;
|
||||
}
|
||||
|
||||
public function reset(): void
|
||||
{
|
||||
$this->now = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws NoEventsFoundException
|
||||
*/
|
||||
private function prepareForExecution(): void
|
||||
{
|
||||
$this->now ??= new \DateTime();
|
||||
|
||||
// Get counts by event
|
||||
$scheduledEvents = $this->repo->getScheduledCounts($this->campaign->getId(), $this->now, $this->limiter);
|
||||
$totalScheduledCount = $scheduledEvents ? array_sum($scheduledEvents) : 0;
|
||||
$this->scheduledEvents = array_keys($scheduledEvents);
|
||||
$this->logger->debug('CAMPAIGN: '.$totalScheduledCount.' events scheduled to execute.');
|
||||
|
||||
$this->output->writeln(
|
||||
$this->translator->trans(
|
||||
'mautic.campaign.trigger.event_count',
|
||||
[
|
||||
'%events%' => $totalScheduledCount,
|
||||
'%batch%' => $this->limiter->getBatchLimit(),
|
||||
]
|
||||
)
|
||||
);
|
||||
|
||||
if (!$totalScheduledCount) {
|
||||
throw new NoEventsFoundException();
|
||||
}
|
||||
|
||||
$this->progressBar = ProgressBarHelper::init($this->output, $totalScheduledCount);
|
||||
$this->progressBar->start();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Dispatcher\Exception\LogNotProcessedException
|
||||
* @throws Dispatcher\Exception\LogPassedAndFailedException
|
||||
* @throws Exception\CannotProcessEventException
|
||||
* @throws Scheduler\Exception\NotSchedulableException
|
||||
* @throws \Doctrine\ORM\Query\QueryException
|
||||
*/
|
||||
private function executeOrRescheduleEvent(): void
|
||||
{
|
||||
// Use the same timestamp across all contacts processed
|
||||
$now = $this->now ?? new \DateTime();
|
||||
|
||||
foreach ($this->scheduledEvents as $eventId) {
|
||||
$this->counter->advanceEventCount();
|
||||
|
||||
// Loop over contacts until the entire campaign is executed
|
||||
$this->executeScheduled($eventId, $now);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Dispatcher\Exception\LogNotProcessedException
|
||||
* @throws Dispatcher\Exception\LogPassedAndFailedException
|
||||
* @throws Exception\CannotProcessEventException
|
||||
* @throws Scheduler\Exception\NotSchedulableException
|
||||
* @throws \Doctrine\ORM\Query\QueryException
|
||||
*/
|
||||
private function executeScheduled($eventId, \DateTime $now): void
|
||||
{
|
||||
$logs = $this->repo->getScheduled($eventId, $this->now, $this->limiter);
|
||||
while ($logs->count()) {
|
||||
try {
|
||||
$fetchedContacts = $this->scheduledContactFinder->hydrateContacts($logs);
|
||||
} catch (NoContactsFoundException) {
|
||||
break;
|
||||
}
|
||||
|
||||
$event = $logs->first()->getEvent();
|
||||
$this->progressBar->advance($logs->count());
|
||||
$this->counter->advanceEvaluated($logs->count());
|
||||
|
||||
// Validate that the schedule is still appropriate
|
||||
$this->validateSchedule($logs, $now);
|
||||
|
||||
// Execute if there are any that did not get rescheduled
|
||||
$this->executioner->executeLogs($event, $logs, $this->counter);
|
||||
|
||||
$this->processSignalService->throwExceptionIfSignalIsCaught();
|
||||
|
||||
// Get next batch
|
||||
$this->scheduledContactFinder->clear($fetchedContacts);
|
||||
$logs = $this->repo->getScheduled($eventId, $this->now, $this->limiter);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param bool $scheduleTogether
|
||||
*
|
||||
* @throws Scheduler\Exception\NotSchedulableException
|
||||
*/
|
||||
private function validateSchedule(ArrayCollection $logs, \DateTime $now, $scheduleTogether = false): void
|
||||
{
|
||||
$toBeRescheduled = new ArrayCollection();
|
||||
$latestExecutionDate = $now;
|
||||
|
||||
// Check if the event should be scheduled (let the schedulers do the debug logging)
|
||||
/** @var LeadEventLog $log */
|
||||
foreach ($logs as $key => $log) {
|
||||
$executionDate = $this->scheduler->validateExecutionDateTime($log, $now);
|
||||
$this->logger->debug(
|
||||
'CAMPAIGN: Log ID #'.$log->getID().
|
||||
' to be executed on '.$executionDate->format('Y-m-d H:i:s e').
|
||||
' compared to '.$now->format('Y-m-d H:i:s e')
|
||||
);
|
||||
|
||||
if ($this->scheduler->shouldSchedule($executionDate, $now)) {
|
||||
// The schedule has changed for this event since first scheduled
|
||||
$this->counter->advanceTotalScheduled();
|
||||
if ($scheduleTogether) {
|
||||
$toBeRescheduled->set($key, $log);
|
||||
|
||||
if ($executionDate > $latestExecutionDate) {
|
||||
$latestExecutionDate = $executionDate;
|
||||
}
|
||||
} else {
|
||||
$this->scheduler->reschedule($log, $executionDate);
|
||||
}
|
||||
|
||||
$logs->remove($key);
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if ($toBeRescheduled->count()) {
|
||||
$this->scheduler->rescheduleLogs($toBeRescheduled, $latestExecutionDate);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ArrayCollection[]
|
||||
*/
|
||||
private function organizeByEvent(ArrayCollection $logs): array
|
||||
{
|
||||
$jumpTo = [];
|
||||
$other = [];
|
||||
|
||||
/** @var LeadEventLog $log */
|
||||
foreach ($logs as $log) {
|
||||
$event = $log->getEvent();
|
||||
$eventType = $event->getType();
|
||||
|
||||
if (CampaignActionJumpToEventSubscriber::EVENT_NAME === $eventType) {
|
||||
if (!isset($jumpTo[$event->getId()])) {
|
||||
$jumpTo[$event->getId()] = new ArrayCollection();
|
||||
}
|
||||
|
||||
$jumpTo[$event->getId()]->set($log->getId(), $log);
|
||||
} else {
|
||||
if (!isset($other[$event->getId()])) {
|
||||
$other[$event->getId()] = new ArrayCollection();
|
||||
}
|
||||
|
||||
$other[$event->getId()]->set($log->getId(), $log);
|
||||
}
|
||||
}
|
||||
|
||||
return array_merge($other, $jumpTo);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\CampaignBundle\Executioner\Scheduler;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Mautic\CampaignBundle\CampaignEvents;
|
||||
use Mautic\CampaignBundle\Entity\Event;
|
||||
use Mautic\CampaignBundle\Entity\LeadEventLog;
|
||||
use Mautic\CampaignBundle\Event\ScheduledBatchEvent;
|
||||
use Mautic\CampaignBundle\Event\ScheduledEvent;
|
||||
use Mautic\CampaignBundle\EventCollector\Accessor\Event\AbstractEventAccessor;
|
||||
use Mautic\CampaignBundle\EventCollector\EventCollector;
|
||||
use Mautic\CampaignBundle\Executioner\Exception\IntervalNotConfiguredException;
|
||||
use Mautic\CampaignBundle\Executioner\Logger\EventLogger;
|
||||
use Mautic\CampaignBundle\Executioner\Scheduler\Exception\NotSchedulableException;
|
||||
use Mautic\CampaignBundle\Executioner\Scheduler\Mode\DateTime as DateTimeScheduler;
|
||||
use Mautic\CampaignBundle\Executioner\Scheduler\Mode\Interval as IntervalScheduler;
|
||||
use Mautic\CampaignBundle\Executioner\Scheduler\Mode\Optimized as OptimizedScheduler;
|
||||
use Mautic\CoreBundle\Helper\CoreParametersHelper;
|
||||
use Mautic\CoreBundle\Service\OptimisticLockServiceInterface;
|
||||
use Mautic\LeadBundle\Entity\Lead;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
class EventScheduler
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'monolog.logger.mautic')]
|
||||
private LoggerInterface $logger,
|
||||
private EventLogger $eventLogger,
|
||||
private IntervalScheduler $intervalScheduler,
|
||||
private DateTimeScheduler $dateTimeScheduler,
|
||||
private OptimizedScheduler $optimizedScheduler,
|
||||
private EventCollector $collector,
|
||||
private EventDispatcherInterface $dispatcher,
|
||||
private CoreParametersHelper $coreParametersHelper,
|
||||
private OptimisticLockServiceInterface $optimisticLockService,
|
||||
) {
|
||||
}
|
||||
|
||||
public function scheduleForContact(Event $event, \DateTimeInterface $executionDate, Lead $contact): void
|
||||
{
|
||||
$contacts = new ArrayCollection([$contact]);
|
||||
|
||||
$this->schedule($event, $executionDate, $contacts);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param bool $isInactiveEvent
|
||||
*/
|
||||
public function schedule(Event $event, \DateTimeInterface $executionDate, ArrayCollection $contacts, $isInactiveEvent = false): void
|
||||
{
|
||||
$config = $this->collector->getEventConfig($event);
|
||||
|
||||
// Load the rotations for creating new log entries
|
||||
$this->eventLogger->hydrateContactRotationsForNewLogs($contacts->getKeys(), $event->getCampaign()->getId());
|
||||
|
||||
// If this is relative to a specific hour, process the contacts in batches by contacts' timezone
|
||||
if ($this->intervalScheduler->isContactSpecificExecutionDateRequired($event)) {
|
||||
$groupedExecutionDates = $this->intervalScheduler->groupContactsByDate($event, $contacts, $executionDate);
|
||||
|
||||
foreach ($groupedExecutionDates as $groupExecutionDateDAO) {
|
||||
$this->scheduleEventForContacts(
|
||||
$event,
|
||||
$config,
|
||||
$groupExecutionDateDAO->getExecutionDate(),
|
||||
$groupExecutionDateDAO->getContacts(),
|
||||
$isInactiveEvent
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise just schedule as the default
|
||||
$this->scheduleEventForContacts($event, $config, $executionDate, $contacts, $isInactiveEvent);
|
||||
}
|
||||
|
||||
public function reschedule(LeadEventLog $log, \DateTimeInterface $toBeExecutedOn): void
|
||||
{
|
||||
$log->setTriggerDate($toBeExecutedOn);
|
||||
$this->eventLogger->persistLog($log);
|
||||
|
||||
$event = $log->getEvent();
|
||||
$config = $this->collector->getEventConfig($event);
|
||||
|
||||
$this->dispatchScheduledEvent($config, $log, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ArrayCollection|LeadEventLog[] $logs
|
||||
*/
|
||||
public function rescheduleLogs(ArrayCollection $logs, \DateTimeInterface $toBeExecutedOn): void
|
||||
{
|
||||
foreach ($logs as $log) {
|
||||
$log->setTriggerDate($toBeExecutedOn);
|
||||
}
|
||||
|
||||
$this->eventLogger->persistCollection($logs);
|
||||
|
||||
$event = $logs->first()->getEvent();
|
||||
$config = $this->collector->getEventConfig($event);
|
||||
|
||||
$this->dispatchBatchScheduledEvent($config, $event, $logs, true);
|
||||
}
|
||||
|
||||
public function rescheduleFailures(ArrayCollection $logs): void
|
||||
{
|
||||
if (!$logs->count()) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($logs as $log) {
|
||||
try {
|
||||
$this->reschedule($log, $this->getRescheduleDate($log));
|
||||
$this->optimisticLockService->resetVersion($log);
|
||||
} catch (IntervalNotConfiguredException) {
|
||||
// Do not reschedule if an interval was not configured.
|
||||
}
|
||||
}
|
||||
|
||||
// Send out a batch event
|
||||
$event = $logs->first()->getEvent();
|
||||
$config = $this->collector->getEventConfig($event);
|
||||
|
||||
$this->dispatchBatchScheduledEvent($config, $event, $logs, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws NotSchedulableException
|
||||
*/
|
||||
public function getExecutionDateTime(Event $event, ?\DateTimeInterface $compareFromDateTime = null, ?\DateTime $comparedToDateTime = null): \DateTimeInterface
|
||||
{
|
||||
if (null === $compareFromDateTime) {
|
||||
$compareFromDateTime = new \DateTime();
|
||||
} else {
|
||||
// Prevent comparisons from modifying original object
|
||||
$compareFromDateTime = clone $compareFromDateTime;
|
||||
}
|
||||
|
||||
if (null === $comparedToDateTime) {
|
||||
$comparedToDateTime = clone $compareFromDateTime;
|
||||
} else {
|
||||
// Prevent comparisons from modifying original object
|
||||
$comparedToDateTime = clone $comparedToDateTime;
|
||||
}
|
||||
|
||||
switch ($event->getTriggerMode()) {
|
||||
case Event::TRIGGER_MODE_IMMEDIATE:
|
||||
case Event::TRIGGER_MODE_OPTIMIZED:
|
||||
case null: // decision
|
||||
$this->logger->debug('CAMPAIGN: ('.$event->getId().') Executing immediately');
|
||||
|
||||
return $compareFromDateTime;
|
||||
case Event::TRIGGER_MODE_INTERVAL:
|
||||
return $this->intervalScheduler->getExecutionDateTime($event, $compareFromDateTime, $comparedToDateTime);
|
||||
case Event::TRIGGER_MODE_DATE:
|
||||
return $this->dateTimeScheduler->getExecutionDateTime($event, $compareFromDateTime, $comparedToDateTime);
|
||||
}
|
||||
|
||||
throw new NotSchedulableException();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \DateTimeInterface
|
||||
*
|
||||
* @throws NotSchedulableException
|
||||
*/
|
||||
public function validateExecutionDateTime(LeadEventLog $log, \DateTime $currentDateTime)
|
||||
{
|
||||
if (!$scheduledDateTime = $log->getTriggerDate()) {
|
||||
throw new NotSchedulableException();
|
||||
}
|
||||
|
||||
$event = $log->getEvent();
|
||||
|
||||
switch ($event->getTriggerMode()) {
|
||||
case Event::TRIGGER_MODE_IMMEDIATE:
|
||||
case Event::TRIGGER_MODE_OPTIMIZED:
|
||||
case null: // decision
|
||||
$this->logger->debug('CAMPAIGN: ('.$event->getId().') Executing immediately');
|
||||
|
||||
return $currentDateTime;
|
||||
case Event::TRIGGER_MODE_INTERVAL:
|
||||
return $this->intervalScheduler->validateExecutionDateTime($log, $currentDateTime);
|
||||
case Event::TRIGGER_MODE_DATE:
|
||||
return $this->dateTimeScheduler->getExecutionDateTime($event, $currentDateTime, $scheduledDateTime);
|
||||
}
|
||||
|
||||
throw new NotSchedulableException();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ArrayCollection|Event[] $events
|
||||
*
|
||||
* @throws NotSchedulableException
|
||||
*/
|
||||
public function getSortedExecutionDates(ArrayCollection $events, \DateTimeInterface $lastActiveDate): array
|
||||
{
|
||||
$eventExecutionDates = [];
|
||||
|
||||
/** @var Event $child */
|
||||
foreach ($events as $child) {
|
||||
$eventExecutionDates[$child->getId()] = $this->getExecutionDateTime($child, $lastActiveDate);
|
||||
}
|
||||
|
||||
uasort(
|
||||
$eventExecutionDates,
|
||||
fn (\DateTimeInterface $a, \DateTimeInterface $b): int => $a <=> $b
|
||||
);
|
||||
|
||||
return $eventExecutionDates;
|
||||
}
|
||||
|
||||
public function getExecutionDateForInactivity(\DateTimeInterface $eventExecutionDate, \DateTimeInterface $earliestExecutionDate, \DateTimeInterface $now): \DateTimeInterface
|
||||
{
|
||||
if ($eventExecutionDate->getTimestamp() === $earliestExecutionDate->getTimestamp()) {
|
||||
// Inactivity is based on the "wait" period so execute now
|
||||
return clone $now;
|
||||
}
|
||||
|
||||
return $eventExecutionDate;
|
||||
}
|
||||
|
||||
public function shouldSchedule(\DateTimeInterface $executionDate, \DateTimeInterface $now): bool
|
||||
{
|
||||
// Mainly for functional tests so we don't have to wait minutes but technically can be used in an environment as well if this behavior
|
||||
// is desired by system admin
|
||||
if (false === (bool) getenv('CAMPAIGN_EXECUTIONER_SCHEDULER_ACKNOWLEDGE_SECONDS')) {
|
||||
// Purposively ignore seconds to prevent rescheduling based on a variance of a few seconds
|
||||
$executionDate = new \DateTime($executionDate->format('Y-m-d H:i'), $executionDate->getTimezone());
|
||||
$now = new \DateTime($now->format('Y-m-d H:i'), $now->getTimezone());
|
||||
}
|
||||
|
||||
return $executionDate > $now;
|
||||
}
|
||||
|
||||
public function shouldScheduleEvent(Event $event, \DateTimeInterface $executionDate, \DateTimeInterface $now): bool
|
||||
{
|
||||
if ($this->intervalScheduler->isContactSpecificExecutionDateRequired($event)) {
|
||||
// Event has days in week specified. Needs to be recalculated to the next day configured
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->shouldSchedule($executionDate, $now);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws NotSchedulableException
|
||||
*/
|
||||
public function validateAndScheduleEventForContacts(Event $event, \DateTimeInterface $executionDateTime, ArrayCollection $contacts, \DateTimeInterface $comparedFromDateTime): void
|
||||
{
|
||||
if ($this->intervalScheduler->isContactSpecificExecutionDateRequired($event)) {
|
||||
$this->logger->debug(
|
||||
'CAMPAIGN: Event ID# '.$event->getId().
|
||||
' has to be scheduled based on contact specific parameters '.
|
||||
' compared to '.$executionDateTime->format('Y-m-d H:i:s')
|
||||
);
|
||||
|
||||
$groupedExecutionDates = $this->intervalScheduler->groupContactsByDate($event, $contacts, $executionDateTime);
|
||||
$config = $this->collector->getEventConfig($event);
|
||||
|
||||
foreach ($groupedExecutionDates as $groupExecutionDateDAO) {
|
||||
$this->scheduleEventForContacts(
|
||||
$event,
|
||||
$config,
|
||||
$groupExecutionDateDAO->getExecutionDate(),
|
||||
$groupExecutionDateDAO->getContacts()
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->shouldSchedule($executionDateTime, $comparedFromDateTime)) {
|
||||
$this->schedule($event, $executionDateTime, $contacts);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
throw new NotSchedulableException();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param bool $isReschedule
|
||||
*/
|
||||
private function dispatchScheduledEvent(AbstractEventAccessor $config, LeadEventLog $log, $isReschedule = false): void
|
||||
{
|
||||
$this->dispatcher->dispatch(
|
||||
new ScheduledEvent($config, $log, $isReschedule),
|
||||
CampaignEvents::ON_EVENT_SCHEDULED
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param bool $isReschedule
|
||||
*/
|
||||
private function dispatchBatchScheduledEvent(AbstractEventAccessor $config, Event $event, ArrayCollection $logs, $isReschedule = false): void
|
||||
{
|
||||
if (!$logs->count()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->dispatcher->dispatch(
|
||||
new ScheduledBatchEvent($config, $event, $logs, $isReschedule),
|
||||
CampaignEvents::ON_EVENT_SCHEDULED_BATCH
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param bool $isInactiveEvent
|
||||
*/
|
||||
private function scheduleEventForContacts(Event $event, AbstractEventAccessor $config, \DateTimeInterface $executionDate, ArrayCollection $contacts, $isInactiveEvent = false): void
|
||||
{
|
||||
foreach ($contacts as $contact) {
|
||||
// Create the entry
|
||||
$log = $this->eventLogger->buildLogEntry($event, $contact, $isInactiveEvent);
|
||||
|
||||
// Determine the execution date based on the trigger mode
|
||||
if (Event::TRIGGER_MODE_OPTIMIZED === $event->getTriggerMode()) {
|
||||
$optimizedExecutionDate = $this->optimizedScheduler->getExecutionDateTimeForContact($event, $contact);
|
||||
$log->setTriggerDate($optimizedExecutionDate);
|
||||
} else {
|
||||
// For other trigger modes, use the provided execution date
|
||||
$log->setTriggerDate($executionDate);
|
||||
}
|
||||
|
||||
// Add it to the queue to persist to the DB
|
||||
$this->eventLogger->queueToPersist($log);
|
||||
|
||||
// lead actively triggered this event, a decision wasn't involved, or it was system triggered and a "no" path so schedule the event to be fired at the defined time
|
||||
$this->logger->debug(
|
||||
'CAMPAIGN: '.ucfirst($event->getEventType()).' ID# '.$event->getId().' for contact ID# '.$contact->getId()
|
||||
.' has timing that is not appropriate and thus scheduled for '.$executionDate->format('Y-m-d H:i:s T')
|
||||
);
|
||||
|
||||
$this->dispatchScheduledEvent($config, $log);
|
||||
}
|
||||
|
||||
// Persist any pending in the queue
|
||||
$logs = $this->eventLogger->persistQueuedLogs();
|
||||
|
||||
// Send out a batch event
|
||||
$this->dispatchBatchScheduledEvent($config, $event, $logs);
|
||||
|
||||
// Update log entries and clear from memory
|
||||
$this->eventLogger->persistCollection($logs)
|
||||
->clearCollection($logs);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws IntervalNotConfiguredException
|
||||
*/
|
||||
private function getRescheduleDate(LeadEventLog $leadEventLog): \DateTimeInterface
|
||||
{
|
||||
$rescheduleDate = new \DateTime();
|
||||
$logInterval = $leadEventLog->getRescheduleInterval();
|
||||
|
||||
if ($logInterval) {
|
||||
return $rescheduleDate->add($logInterval);
|
||||
}
|
||||
|
||||
$defaultIntervalString = $this->coreParametersHelper->get('campaign_time_wait_on_event_false');
|
||||
|
||||
if (!$defaultIntervalString) {
|
||||
throw new IntervalNotConfiguredException('No Interval has been set on the lead event log nor as campaign_time_wait_on_event_false config value.');
|
||||
}
|
||||
|
||||
try {
|
||||
return $rescheduleDate->add(new \DateInterval($defaultIntervalString));
|
||||
} catch (\Exception) {
|
||||
// Bad interval
|
||||
throw new IntervalNotConfiguredException("'{$defaultIntervalString}' is not valid interval string for campaign_time_wait_on_event_false config key.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\CampaignBundle\Executioner\Scheduler\Exception;
|
||||
|
||||
class NotSchedulableException extends \Exception
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\CampaignBundle\Executioner\Scheduler\Mode\DAO;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Mautic\LeadBundle\Entity\Lead;
|
||||
|
||||
class GroupExecutionDateDAO
|
||||
{
|
||||
private ArrayCollection $contacts;
|
||||
|
||||
public function __construct(
|
||||
private \DateTimeInterface $executionDate,
|
||||
) {
|
||||
$this->contacts = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function addContact(Lead $contact): void
|
||||
{
|
||||
$this->contacts->set($contact->getId(), $contact);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \DateTimeInterface
|
||||
*/
|
||||
public function getExecutionDate()
|
||||
{
|
||||
return $this->executionDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ArrayCollection
|
||||
*/
|
||||
public function getContacts()
|
||||
{
|
||||
return $this->contacts;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\CampaignBundle\Executioner\Scheduler\Mode;
|
||||
|
||||
use Mautic\CampaignBundle\Entity\Event;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class DateTime implements ScheduleModeInterface
|
||||
{
|
||||
public function __construct(
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getExecutionDateTime(Event $event, \DateTimeInterface $compareFromDateTime, \DateTimeInterface $comparedToDateTime): \DateTimeInterface
|
||||
{
|
||||
$triggerDate = $event->getTriggerDate();
|
||||
|
||||
if (null === $triggerDate) {
|
||||
$this->logger->debug('CAMPAIGN: Trigger date is null');
|
||||
|
||||
return $compareFromDateTime;
|
||||
}
|
||||
|
||||
if ($compareFromDateTime >= $triggerDate) {
|
||||
$this->logger->debug(
|
||||
'CAMPAIGN: ('.$event->getId().') Date to execute ('.$triggerDate->format('Y-m-d H:i:s T').') compared to now ('
|
||||
.$compareFromDateTime->format('Y-m-d H:i:s T').') and is thus overdue'
|
||||
);
|
||||
|
||||
return $compareFromDateTime;
|
||||
}
|
||||
|
||||
return $triggerDate;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\CampaignBundle\Executioner\Scheduler\Mode;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Mautic\CampaignBundle\Entity\Event;
|
||||
use Mautic\CampaignBundle\Entity\LeadEventLog;
|
||||
use Mautic\CampaignBundle\Executioner\Scheduler\Exception\NotSchedulableException;
|
||||
use Mautic\CampaignBundle\Executioner\Scheduler\Mode\DAO\GroupExecutionDateDAO;
|
||||
use Mautic\CoreBundle\Helper\CoreParametersHelper;
|
||||
use Mautic\CoreBundle\Helper\DateTimeHelper;
|
||||
use Mautic\LeadBundle\Entity\Lead;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class Interval implements ScheduleModeInterface
|
||||
{
|
||||
public const LOG_DATE_FORMAT = 'Y-m-d H:i:s T';
|
||||
|
||||
private ?\DateTimeZone $defaultTimezone = null;
|
||||
|
||||
public function __construct(
|
||||
private LoggerInterface $logger,
|
||||
private CoreParametersHelper $coreParametersHelper,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws NotSchedulableException
|
||||
*/
|
||||
public function getExecutionDateTime(Event $event, \DateTimeInterface $compareFromDateTime, \DateTimeInterface $comparedToDateTime): \DateTimeInterface
|
||||
{
|
||||
$interval = $event->getTriggerInterval();
|
||||
$unit = $event->getTriggerIntervalUnit();
|
||||
|
||||
try {
|
||||
$this->logger->debug(
|
||||
'CAMPAIGN: ('.$event->getId().') Adding interval of '.$interval.$unit.' to '.$comparedToDateTime->format(self::LOG_DATE_FORMAT)
|
||||
);
|
||||
/** @var \DateTime $comparedToDateTime */
|
||||
$comparedToDateTime->add((new DateTimeHelper())->buildInterval($interval, $unit));
|
||||
} catch (\Exception $exception) {
|
||||
$this->logger->error('CAMPAIGN: Determining interval scheduled failed with "'.$exception->getMessage().'"');
|
||||
|
||||
throw new NotSchedulableException($exception->getMessage());
|
||||
}
|
||||
|
||||
if ($comparedToDateTime > $compareFromDateTime) {
|
||||
$this->logger->debug(
|
||||
'CAMPAIGN: ('.$event->getId().') '.$comparedToDateTime->format(self::LOG_DATE_FORMAT).' is later than '
|
||||
.$compareFromDateTime->format(self::LOG_DATE_FORMAT).' and thus returning '.$comparedToDateTime->format(self::LOG_DATE_FORMAT)
|
||||
);
|
||||
|
||||
// the event is to be scheduled based on the time interval
|
||||
return $comparedToDateTime;
|
||||
}
|
||||
|
||||
$this->logger->debug(
|
||||
'CAMPAIGN: ('.$event->getId().') '.$comparedToDateTime->format(self::LOG_DATE_FORMAT).' is earlier than '
|
||||
.$compareFromDateTime->format(self::LOG_DATE_FORMAT).' and thus returning '.$compareFromDateTime->format(self::LOG_DATE_FORMAT)
|
||||
);
|
||||
|
||||
return $compareFromDateTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \DateTimeInterface
|
||||
*
|
||||
* @throws NotSchedulableException
|
||||
*/
|
||||
public function validateExecutionDateTime(LeadEventLog $log, \DateTimeInterface $compareFromDateTime)
|
||||
{
|
||||
$event = $log->getEvent();
|
||||
$dateTriggered = clone $log->getDateTriggered();
|
||||
|
||||
if (!$this->isContactSpecificExecutionDateRequired($event)) {
|
||||
return $this->getExecutionDateTime($event, $compareFromDateTime, $dateTriggered);
|
||||
}
|
||||
|
||||
$interval = $event->getTriggerInterval();
|
||||
$unit = $event->getTriggerIntervalUnit();
|
||||
|
||||
if ($interval && $unit) {
|
||||
/** @var \DateTime $dateTriggered */
|
||||
$dateTriggered->add((new DateTimeHelper())->buildInterval($interval, $unit));
|
||||
}
|
||||
|
||||
if ($dateTriggered < $compareFromDateTime) {
|
||||
$this->logger->debug(
|
||||
sprintf('CAMPAIGN: (%s) %s is earlier than %s and thus setting %s', $event->getId(), $dateTriggered->format(self::LOG_DATE_FORMAT), $compareFromDateTime->format(self::LOG_DATE_FORMAT), $compareFromDateTime->format(self::LOG_DATE_FORMAT))
|
||||
);
|
||||
$dateTriggered = clone $compareFromDateTime;
|
||||
}
|
||||
|
||||
$hour = $event->getTriggerHour();
|
||||
$startTime = $event->getTriggerRestrictedStartHour();
|
||||
$endTime = $event->getTriggerRestrictedStopHour();
|
||||
$dow = $event->getTriggerRestrictedDaysOfWeek();
|
||||
|
||||
return $this->getGroupExecutionDateTime($event->getId(), $log->getLead(), $dateTriggered, $hour, $startTime, $endTime, $dow);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return GroupExecutionDateDAO[]
|
||||
*/
|
||||
public function groupContactsByDate(Event $event, ArrayCollection $contacts, \DateTimeInterface $executionDate, ?\DateTimeInterface $compareFromDateTime = null): array
|
||||
{
|
||||
$groupedExecutionDates = [];
|
||||
$hour = $event->getTriggerHour();
|
||||
$startTime = $event->getTriggerRestrictedStartHour();
|
||||
$endTime = $event->getTriggerRestrictedStopHour();
|
||||
$daysOfWeek = $event->getTriggerRestrictedDaysOfWeek();
|
||||
|
||||
/** @var Lead $contact */
|
||||
foreach ($contacts as $contact) {
|
||||
$groupExecutionDate = $this->getGroupExecutionDateTime(
|
||||
$event->getId(),
|
||||
$contact,
|
||||
$executionDate,
|
||||
$hour,
|
||||
$startTime,
|
||||
$endTime,
|
||||
$daysOfWeek
|
||||
);
|
||||
$key = $groupExecutionDate->format(DateTimeHelper::FORMAT_DB);
|
||||
if (!isset($groupedExecutionDates[$key])) {
|
||||
$groupedExecutionDates[$key] = new GroupExecutionDateDAO($groupExecutionDate);
|
||||
}
|
||||
|
||||
$groupedExecutionDates[$key]->addContact($contact);
|
||||
}
|
||||
|
||||
return $groupedExecutionDates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an event has a relative time configured.
|
||||
*/
|
||||
public function isContactSpecificExecutionDateRequired(Event $event): bool
|
||||
{
|
||||
if ($this->isTriggerModeOptimized($event)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!$this->isTriggerModeInterval($event) || $this->isRestrictedToDailyScheduling($event) || $this->hasTimeRelatedRestrictions($event) || $this->isNegativePath($event)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function isTriggerModeInterval(Event $event): bool
|
||||
{
|
||||
return Event::TRIGGER_MODE_INTERVAL === $event->getTriggerMode();
|
||||
}
|
||||
|
||||
private function isTriggerModeOptimized(Event $event): bool
|
||||
{
|
||||
return Event::TRIGGER_MODE_OPTIMIZED === $event->getTriggerMode();
|
||||
}
|
||||
|
||||
private function isRestrictedToDailyScheduling(Event $event): bool
|
||||
{
|
||||
return !in_array($event->getTriggerIntervalUnit(), ['i', 'h', 'd', 'm', 'y'])
|
||||
&& empty($event->getTriggerRestrictedDaysOfWeek());
|
||||
}
|
||||
|
||||
private function hasTimeRelatedRestrictions(Event $event): bool
|
||||
{
|
||||
return null === $event->getTriggerHour()
|
||||
&& (null === $event->getTriggerRestrictedStartHour() || null === $event->getTriggerRestrictedStopHour())
|
||||
&& empty($event->getTriggerRestrictedDaysOfWeek());
|
||||
}
|
||||
|
||||
private function isNegativePath(Event $event): bool
|
||||
{
|
||||
if ($event->getParent()) {
|
||||
return Event::TYPE_DECISION === $event->getParent()->getEventType() && Event::TYPE_ACTION === $event->getEventType() && Event::PATH_INACTION === $event->getDecisionPath();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \DateTimeInterface
|
||||
*/
|
||||
private function getGroupExecutionDateTime(
|
||||
$eventId,
|
||||
Lead $contact,
|
||||
\DateTimeInterface $compareFromDateTime,
|
||||
?\DateTimeInterface $hour = null,
|
||||
?\DateTimeInterface $startTime = null,
|
||||
?\DateTimeInterface $endTime = null,
|
||||
array $daysOfWeek = [],
|
||||
) {
|
||||
$this->logger->debug(
|
||||
sprintf('CAMPAIGN: Comparing calculated executed time for event ID %s and contact ID %s with %s', $eventId, $contact->getId(), $compareFromDateTime->format('Y-m-d H:i:s e'))
|
||||
);
|
||||
|
||||
if ($hour) {
|
||||
$this->logger->debug(
|
||||
sprintf('CAMPAIGN: Scheduling event ID %s for contact ID %s based on hour of %s', $eventId, $contact->getId(), $hour->format('H:i e'))
|
||||
);
|
||||
$groupDateTime = $this->getExecutionDateTimeFromHour($contact, $hour, $eventId, $compareFromDateTime);
|
||||
} elseif ($startTime && $endTime) {
|
||||
$this->logger->debug(
|
||||
sprintf(
|
||||
'CAMPAIGN: Scheduling event ID %s for contact ID %s based on hour range of %s to %s',
|
||||
$eventId,
|
||||
$contact->getId(),
|
||||
$startTime->format('H:i e'),
|
||||
$endTime->format('H:i e')
|
||||
)
|
||||
);
|
||||
|
||||
$groupDateTime = $this->getExecutionDateTimeBetweenHours($contact, $startTime, $endTime, $eventId, $compareFromDateTime);
|
||||
} else {
|
||||
$this->logger->debug(
|
||||
sprintf('CAMPAIGN: Scheduling event ID %s for contact ID %s without hour restrictions.', $eventId, $contact->getId())
|
||||
);
|
||||
|
||||
$groupDateTime = clone $compareFromDateTime;
|
||||
}
|
||||
|
||||
if ([] !== $daysOfWeek) {
|
||||
$this->logger->debug(
|
||||
sprintf(
|
||||
'CAMPAIGN: Scheduling event ID %s for contact ID %s based on DOW restrictions of %s',
|
||||
$eventId,
|
||||
$contact->getId(),
|
||||
implode(',', $daysOfWeek)
|
||||
)
|
||||
);
|
||||
|
||||
if (in_array(7, $daysOfWeek, true) || in_array('7', $daysOfWeek, true)) {
|
||||
throw new \LogicException('The Mautic accepts only 0-6 as day of week (0 is Sunday).');
|
||||
}
|
||||
|
||||
// Schedule for the next day of the week if applicable
|
||||
while (!in_array((int) $groupDateTime->format('w'), $daysOfWeek)) {
|
||||
/** @var \DateTime $groupDateTime */
|
||||
$groupDateTime->modify('+1 day');
|
||||
}
|
||||
}
|
||||
|
||||
return $groupDateTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \DateTimeInterface
|
||||
*/
|
||||
private function getExecutionDateTimeFromHour(Lead $contact, \DateTimeInterface $hour, $eventId, \DateTimeInterface $compareFromDateTime)
|
||||
{
|
||||
/** @var \DateTime $groupHour */
|
||||
$groupHour = clone $hour;
|
||||
|
||||
/** @var \DateTime $groupExecutionDate */
|
||||
$groupExecutionDate = $this->getGroupExecutionDateWithTimeZone($contact, $eventId, $compareFromDateTime);
|
||||
$groupExecutionDate->setTime((int) $groupExecutionDate->format('H'), (int) $groupExecutionDate->format('i'));
|
||||
|
||||
$testGroupHour = clone $groupExecutionDate;
|
||||
$testGroupHour->setTime($groupHour->format('H'), $groupHour->format('i'));
|
||||
|
||||
if ($groupExecutionDate <= $testGroupHour) {
|
||||
// Schedule for the configured hour today if it's not passed yet.
|
||||
return $testGroupHour;
|
||||
}
|
||||
|
||||
// Execute rigt away if the hour has passed.
|
||||
return $groupExecutionDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \DateTimeInterface
|
||||
*/
|
||||
private function getExecutionDateTimeBetweenHours(
|
||||
Lead $contact,
|
||||
\DateTimeInterface $startTime,
|
||||
\DateTimeInterface $endTime,
|
||||
$eventId,
|
||||
\DateTimeInterface $compareFromDateTime,
|
||||
) {
|
||||
/* @var \DateTime $startTime */
|
||||
$startTime = clone $startTime;
|
||||
/* @var \DateTime $endTime */
|
||||
$endTime = clone $endTime;
|
||||
|
||||
if ($endTime < $startTime) {
|
||||
// End time is after start time so switch them
|
||||
$tempStartTime = clone $startTime;
|
||||
$startTime = clone $endTime;
|
||||
$endTime = clone $tempStartTime;
|
||||
unset($tempStartTime);
|
||||
}
|
||||
|
||||
/** @var \DateTime $groupExecutionDate */
|
||||
$groupExecutionDate = $this->getGroupExecutionDateWithTimeZone($contact, $eventId, $compareFromDateTime);
|
||||
|
||||
// Is the time between the start and end hours?
|
||||
$testStartDateTime = clone $groupExecutionDate;
|
||||
$testStartDateTime->setTime($startTime->format('H'), $startTime->format('i'));
|
||||
|
||||
$testStopDateTime = clone $groupExecutionDate;
|
||||
$testStopDateTime->setTime($endTime->format('H'), $endTime->format('i'));
|
||||
|
||||
if ($groupExecutionDate < $testStartDateTime) {
|
||||
// Too early so set it to the start date
|
||||
return $testStartDateTime;
|
||||
}
|
||||
|
||||
if ($groupExecutionDate >= $testStopDateTime) {
|
||||
// Too late so try again tomorrow
|
||||
$groupExecutionDate->modify('+1 day')->setTime((int) $startTime->format('H'), (int) $startTime->format('i'));
|
||||
}
|
||||
|
||||
return $groupExecutionDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \DateTimeZone
|
||||
*/
|
||||
private function getDefaultTimezone()
|
||||
{
|
||||
if ($this->defaultTimezone) {
|
||||
return $this->defaultTimezone;
|
||||
}
|
||||
|
||||
$this->defaultTimezone = new \DateTimeZone(
|
||||
$this->coreParametersHelper->getDefaultTimezone()
|
||||
);
|
||||
|
||||
return $this->defaultTimezone;
|
||||
}
|
||||
|
||||
private function getGroupExecutionDateWithTimeZone(Lead $contact, int $eventId, \DateTimeInterface $compareFromDateTime): \DateTimeInterface
|
||||
{
|
||||
/** @var \DateTime $groupExecutionDate */
|
||||
$groupExecutionDate = clone $compareFromDateTime;
|
||||
$contactTimezone = $this->getDefaultTimezone();
|
||||
// Set execution to UTC
|
||||
if ($timezone = $contact->getTimezone()) {
|
||||
try {
|
||||
// Set the group's timezone to the contact's
|
||||
$contactTimezone = new \DateTimeZone($timezone);
|
||||
|
||||
$this->logger->debug(
|
||||
'CAMPAIGN: ('.$eventId.') Setting '.$timezone.' for contact '.$contact->getId()
|
||||
);
|
||||
} catch (\Exception) {
|
||||
// Timezone is not recognized so use the default
|
||||
$this->logger->debug(
|
||||
'CAMPAIGN: ('.$eventId.') '.$timezone.' for contact '.$contact->getId().' is not recognized'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$groupExecutionDate->setTimezone($contactTimezone);
|
||||
|
||||
return $groupExecutionDate;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mautic\CampaignBundle\Executioner\Scheduler\Mode;
|
||||
|
||||
use Mautic\CampaignBundle\Entity\Event;
|
||||
use Mautic\LeadBundle\Entity\Lead;
|
||||
use Mautic\LeadBundle\Services\PeakInteractionTimer;
|
||||
|
||||
class Optimized implements ScheduleModeInterface
|
||||
{
|
||||
public const OPTIMIZED_TIME = 0;
|
||||
public const OPTIMIZED_DAY_AND_TIME = 1;
|
||||
|
||||
/** @var string[] */
|
||||
public const AVAILABLE_FOR_EVENTS = ['email.send', 'message.send', 'plugin.leadpush', 'campaign.sendwebhook'];
|
||||
|
||||
public function __construct(
|
||||
private PeakInteractionTimer $peakInteractionTimer,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getExecutionDateTime(Event $event, \DateTimeInterface $now, \DateTimeInterface $comparedToDateTime): \DateTimeInterface
|
||||
{
|
||||
return $now;
|
||||
}
|
||||
|
||||
public function getExecutionDateTimeForContact(Event $event, Lead $contact): \DateTimeInterface
|
||||
{
|
||||
if (self::OPTIMIZED_DAY_AND_TIME === $event->getTriggerWindow()) {
|
||||
return $this->peakInteractionTimer->getOptimalTimeAndDay($contact);
|
||||
} else {
|
||||
return $this->peakInteractionTimer->getOptimalTime($contact);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace Mautic\CampaignBundle\Executioner\Scheduler\Mode;
|
||||
|
||||
use Mautic\CampaignBundle\Entity\Event;
|
||||
|
||||
interface ScheduleModeInterface
|
||||
{
|
||||
public function getExecutionDateTime(Event $event, \DateTimeInterface $now, \DateTimeInterface $comparedToDateTime): \DateTimeInterface;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mautic\CampaignBundle\Executioner;
|
||||
|
||||
use Symfony\Contracts\Service\ResetInterface;
|
||||
|
||||
/**
|
||||
* @internal Used in tests
|
||||
*/
|
||||
class TestInactiveExecutioner extends InactiveExecutioner implements ResetInterface
|
||||
{
|
||||
/**
|
||||
* @internal Used in tests
|
||||
*/
|
||||
public function setNowTime(\DateTime $dateTime): void
|
||||
{
|
||||
$this->now = $dateTime;
|
||||
}
|
||||
|
||||
public function reset(): void
|
||||
{
|
||||
$this->now = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mautic\CampaignBundle\Executioner;
|
||||
|
||||
/**
|
||||
* @internal Used in tests
|
||||
*/
|
||||
class TestScheduledExecutioner extends ScheduledExecutioner
|
||||
{
|
||||
/**
|
||||
* @internal Used in tests
|
||||
*/
|
||||
public function setNowTime(\DateTime $dateTime): void
|
||||
{
|
||||
$this->now = $dateTime;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user