src/Command/AutomationsCommand.php line 24

Open in your IDE?
  1. <?php
  2. namespace App\Command;
  3. use App\DBAL\Types\LeadType;
  4. use App\DBAL\Types\MessageAutomationEventType;
  5. use App\Entity\Customer\Customer;
  6. use App\Entity\Lead\Lead;
  7. use App\Entity\Lead\Prospect;
  8. use App\Entity\MessageAutomation;
  9. use App\Entity\MessageAutomationLog;
  10. use App\Events;
  11. use App\Repository\CronScheduleRepository;
  12. use Doctrine\Persistence\ManagerRegistry;
  13. use EWZ\SymfonyAdminBundle\Command\AbstractCronCommand;
  14. use EWZ\SymfonyAdminBundle\Util\CommandRunner;
  15. use Symfony\Component\Console\Input\InputInterface;
  16. use Symfony\Component\Console\Output\OutputInterface;
  17. use Symfony\Component\HttpKernel\KernelInterface;
  18. /**
  19.  * Run message automations with offsetDays > 0 (follow-ups).
  20.  */
  21. class AutomationsCommand extends AbstractCronCommand
  22. {
  23.     /** @var KernelInterface */
  24.     private $kernel;
  25.     /** @var ManagerRegistry */
  26.     private $managerRegistry;
  27.     /**
  28.      * @param CronScheduleRepository $cronScheduleRepository
  29.      * @param KernelInterface        $kernel
  30.      * @param ManagerRegistry        $managerRegistry
  31.      */
  32.     public function __construct(
  33.         CronScheduleRepository $cronScheduleRepository,
  34.         KernelInterface $kernel,
  35.         ManagerRegistry $managerRegistry
  36.     ) {
  37.         parent::__construct($cronScheduleRepository);
  38.         $this->kernel $kernel;
  39.         $this->managerRegistry $managerRegistry;
  40.     }
  41.     /**
  42.      * {@inheritdoc}
  43.      */
  44.     public function getDefaultDefinition(): string
  45.     {
  46.         return '*/30 * * * *';
  47.     }
  48.     /**
  49.      * {@inheritdoc}
  50.      */
  51.     public function isActive(): bool
  52.     {
  53.         return true;
  54.     }
  55.     /**
  56.      * {@inheritdoc}
  57.      */
  58.     protected function configure()
  59.     {
  60.         $this
  61.             ->setName('admin:cron:automations:run')
  62.             ->setDescription('Run message automations follow-ups (offsetDays > 0)')
  63.         ;
  64.     }
  65.     /**
  66.      * {@inheritdoc}
  67.      */
  68.     protected function processCommand(InputInterface $inputOutputInterface $output): void
  69.     {
  70.         // always run retries (log-driven)
  71.         $this->runRetries();
  72.         /** @var MessageAutomation[] $automations */
  73.         $automations $this->managerRegistry
  74.             ->getRepository(MessageAutomation::class)
  75.             ->searchAll([
  76.                 'enabled' => true,
  77.             ]);
  78.         if (empty($automations)) {
  79.             return;
  80.         }
  81.         $now = new \DateTime();
  82.         // normalize seconds to avoid cron jitter skipping exact times
  83.         $now->setTime((int) $now->format('H'), (int) $now->format('i'), 0);
  84.         $today = (clone $now)->setTime(000); // anchor day windows at midnight (prevents sliding 24h bugs)
  85.         // define a range around the current time
  86.         $windowSlack 5// adjust the range: ±5 minutes
  87.         $from = (clone $now)->modify(sprintf('-%d minutes'$windowSlack)); // start of the time window
  88.         $to = (clone $now)->modify(sprintf('+%d minutes'$windowSlack)); // end of the time window
  89.         // group by event + offsetDays (only automations due in current sendTime window)
  90.         $groups = [];
  91.         foreach ($automations as $automation) {
  92.             $offsetDays = (int) ($automation->getOffsetDays() ?? 0);
  93.             if ($offsetDays 0) {
  94.                 continue;
  95.             }
  96.             $sendTime $automation->getSendTime();
  97.             // check that sendTime is set (it's required for time-window scheduling)
  98.             if (null === $sendTime) {
  99.                 continue;
  100.             }
  101.             // "Synchronize" sendTime to today, because sendTime doesn't include a date
  102.             $sendTime = (clone $today)->setTime((int) $sendTime->format('H'), (int) $sendTime->format('i'), 0);
  103.             // skip if sendTime is outside the current ±slack window
  104.             if ($sendTime $from || $sendTime $to) {
  105.                 continue;
  106.             }
  107.             $eventType = (string) $automation->getEvent();
  108.             if ('' === $eventType) {
  109.                 continue;
  110.             }
  111.             $key sprintf('%s|%d'$eventType$offsetDays);
  112.             if (!isset($groups[$key])) {
  113.                 $groups[$key] = [
  114.                     'eventType' => $eventType,
  115.                     'offsetDays' => $offsetDays,
  116.                     'automationIds' => [],
  117.                 ];
  118.             }
  119.             $groups[$key]['automationIds'][] = (int) $automation->getId();
  120.         }
  121.         if (empty($groups)) {
  122.             return;
  123.         }
  124.         // process highest offset first so the newest follow-up wins
  125.         $groups array_values($groups);
  126.         usort($groups, static function (array $a, array $b): int {
  127.             $o = ((int) $b['offsetDays']) <=> ((int) $a['offsetDays']); // DESC
  128.             return !== $o $o strcmp((string) $a['eventType'], (string) $b['eventType']);
  129.         });
  130.         // prevent the same entity from being processed multiple times in one run (highest offset wins)
  131.         $handledByEvent = [];
  132.         foreach ($groups as $group) {
  133.             $eventType = (string) $group['eventType'];
  134.             $offsetDays = (int) $group['offsetDays'];
  135.             $automationIds $group['automationIds'];
  136.             if (!isset($handledByEvent[$eventType])) {
  137.                 $handledByEvent[$eventType] = [];
  138.             }
  139.             $fromDate = (clone $today)->modify(sprintf('-%d day'$offsetDays));
  140.             $toDate = (clone $fromDate)->modify('+1 day');
  141.             if (MessageAutomationEventType::PROSPECT_CREATED === $eventType) {
  142.                 $this->runProspectCreated($fromDate$toDate$offsetDays$automationIds$handledByEvent[$eventType]);
  143.                 continue;
  144.             }
  145.             if (MessageAutomationEventType::LEAD_CREATED === $eventType) {
  146.                 $this->runLeadCreated($fromDate$toDate$offsetDays$automationIds$handledByEvent[$eventType]);
  147.                 continue;
  148.             }
  149.             if (MessageAutomationEventType::OPPORTUNITY_CREATED === $eventType) {
  150.                 $this->runOpportunityCreated($fromDate$toDate$offsetDays$automationIds$handledByEvent[$eventType]);
  151.                 continue;
  152.             }
  153.             if (MessageAutomationEventType::CUSTOMER_CREATED === $eventType) {
  154.                 $this->runCustomerCreated($fromDate$toDate$offsetDays$automationIds$handledByEvent[$eventType]);
  155.                 continue;
  156.             }
  157.         }
  158.     }
  159.     /**
  160.      * Prospect created follow-ups: enabled = true (use Prospect.createdAt).
  161.      *
  162.      * @param \DateTime $from
  163.      * @param \DateTime $to
  164.      * @param int       $offsetDays
  165.      * @param array     $automationIds
  166.      * @param array     $handledIds
  167.      */
  168.     private function runProspectCreated(
  169.         \DateTime $from,
  170.         \DateTime $to,
  171.         int $offsetDays,
  172.         array $automationIds,
  173.         array &$handledIds
  174.     ): void {
  175.         /** @var Prospect[] $items */
  176.         $items $this->managerRegistry
  177.             ->getRepository(Prospect::class)
  178.             ->searchAll([
  179.                 'enabled' => true,
  180.                 'createdAt' => [
  181.                     'from' => $from,
  182.                     'to' => $to,
  183.                 ],
  184.             ]);
  185.         foreach ($items as $prospect) {
  186.             $id = (string) $prospect->getId();
  187.             if (isset($handledIds[$id])) {
  188.                 continue;
  189.             }
  190.             $handledIds[$id] = true;
  191.             CommandRunner::runCommand(
  192.                 'admin:automation:run-one',
  193.                 array_merge(
  194.                     [
  195.                         str_replace('\\''\\\\'Prospect::class),
  196.                         $prospect->getId(),
  197.                         $offsetDays,
  198.                         implode(','$automationIds),
  199.                         Events::EMAIL_PROSPECT_CREATED,
  200.                     ],
  201.                     ['--env' => $this->kernel->getEnvironment()]
  202.                 )
  203.             );
  204.         }
  205.     }
  206.     /**
  207.      * Lead created follow-ups: enabled = true and customer = null (use Lead.createdAt).
  208.      *
  209.      * @param \DateTime $from
  210.      * @param \DateTime $to
  211.      * @param int       $offsetDays
  212.      * @param array     $automationIds
  213.      * @param array     $handledIds
  214.      */
  215.     private function runLeadCreated(
  216.         \DateTime $from,
  217.         \DateTime $to,
  218.         int $offsetDays,
  219.         array $automationIds,
  220.         array &$handledIds
  221.     ): void {
  222.         /** @var Lead[] $items */
  223.         $items $this->managerRegistry
  224.             ->getRepository(Lead::class)
  225.             ->searchAll([
  226.                 'enabled' => true,
  227.                 'customer' => null,
  228.                 'createdAt' => [
  229.                     'from' => $from,
  230.                     'to' => $to,
  231.                 ],
  232.             ]);
  233.         foreach ($items as $lead) {
  234.             // skip form leads (same rule as real-time dispatch)
  235.             if (LeadType::FORM === $lead->getType()) {
  236.                 continue;
  237.             }
  238.             $id = (string) $lead->getId();
  239.             if (isset($handledIds[$id])) {
  240.                 continue;
  241.             }
  242.             $handledIds[$id] = true;
  243.             CommandRunner::runCommand(
  244.                 'admin:automation:run-one',
  245.                 array_merge(
  246.                     [
  247.                         str_replace('\\''\\\\'Lead::class),
  248.                         $lead->getId(),
  249.                         $offsetDays,
  250.                         implode(','$automationIds),
  251.                         Events::EMAIL_LEAD_CREATED,
  252.                     ],
  253.                     ['--env' => $this->kernel->getEnvironment()]
  254.                 )
  255.             );
  256.         }
  257.     }
  258.     /**
  259.      * Opportunity created follow-ups: opportunity createdAt is within range and convertedAt is null.
  260.      *
  261.      * @param \DateTime $from
  262.      * @param \DateTime $to
  263.      * @param int       $offsetDays
  264.      * @param array     $automationIds
  265.      * @param array     $handledIds
  266.      */
  267.     private function runOpportunityCreated(
  268.         \DateTime $from,
  269.         \DateTime $to,
  270.         int $offsetDays,
  271.         array $automationIds,
  272.         array &$handledIds
  273.     ): void {
  274.         /** @var Customer[] $items */
  275.         $items $this->managerRegistry
  276.             ->getRepository(Customer::class)
  277.             ->searchAll([
  278.                 'convertedAt' => null,
  279.                 'createdAt' => [
  280.                     'from' => $from,
  281.                     'to' => $to,
  282.                 ],
  283.             ]);
  284.         foreach ($items as $customer) {
  285.             if ($customer->isCustomer()) {
  286.                 continue;
  287.             }
  288.             $id = (string) $customer->getId();
  289.             if (isset($handledIds[$id])) {
  290.                 continue;
  291.             }
  292.             $handledIds[$id] = true;
  293.             CommandRunner::runCommand(
  294.                 'admin:automation:run-one',
  295.                 array_merge(
  296.                     [
  297.                         str_replace('\\''\\\\'Customer::class),
  298.                         $customer->getId(),
  299.                         $offsetDays,
  300.                         implode(','$automationIds),
  301.                         Events::EMAIL_OPPORTUNITY_CREATED,
  302.                     ],
  303.                     ['--env' => $this->kernel->getEnvironment()]
  304.                 )
  305.             );
  306.         }
  307.     }
  308.     /**
  309.      * Customer created follow-ups: Customer.convertedAt is within range (only if the record is a real customer).
  310.      *
  311.      * @param \DateTime $from
  312.      * @param \DateTime $to
  313.      * @param int       $offsetDays
  314.      * @param array     $automationIds
  315.      * @param array     $handledIds
  316.      */
  317.     private function runCustomerCreated(
  318.         \DateTime $from,
  319.         \DateTime $to,
  320.         int $offsetDays,
  321.         array $automationIds,
  322.         array &$handledIds
  323.     ): void {
  324.         /** @var Customer[] $items */
  325.         $items $this->managerRegistry
  326.             ->getRepository(Customer::class)
  327.             ->searchAll([
  328.                 'convertedAt' => [
  329.                     'from' => $from,
  330.                     'to' => $to,
  331.                 ],
  332.             ]);
  333.         foreach ($items as $customer) {
  334.             if (!$customer->isCustomer()) {
  335.                 continue;
  336.             }
  337.             $id = (string) $customer->getId();
  338.             if (isset($handledIds[$id])) {
  339.                 continue;
  340.             }
  341.             $handledIds[$id] = true;
  342.             CommandRunner::runCommand(
  343.                 'admin:automation:run-one',
  344.                 array_merge(
  345.                     [
  346.                         str_replace('\\''\\\\'Customer::class),
  347.                         $customer->getId(),
  348.                         $offsetDays,
  349.                         implode(','$automationIds),
  350.                         Events::EMAIL_CUSTOMER_CREATED,
  351.                     ],
  352.                     ['--env' => $this->kernel->getEnvironment()]
  353.                 )
  354.             );
  355.         }
  356.     }
  357.     /**
  358.      * Spawn retry jobs for FAILED logs that are due (attempts < 3).
  359.      */
  360.     private function runRetries(): void
  361.     {
  362.         /** @var MessageAutomationLog[] $logs */
  363.         $logs $this->managerRegistry
  364.             ->getRepository(MessageAutomationLog::class)
  365.             ->findRetryable();
  366.         if (empty($logs)) {
  367.             return;
  368.         }
  369.         foreach ($logs as $log) {
  370.             if ($log->getAttempts() >= || !$log->getAutomation()) {
  371.                 continue;
  372.             }
  373.             $eventName $this->eventNameFromEventType((string) $log->getEvent());
  374.             if (!$eventName) {
  375.                 continue;
  376.             }
  377.             CommandRunner::runCommand(
  378.                 'admin:automation:run-one',
  379.                 array_merge(
  380.                     [
  381.                         str_replace('\\''\\\\', (string) $log->getEntityClass()),
  382.                         $log->getEntityId(),
  383.                         $log->getOffsetDays(),
  384.                         $log->getAutomation()->getId(),
  385.                         $eventName,
  386.                     ],
  387.                     ['--env' => $this->kernel->getEnvironment()]
  388.                 )
  389.             );
  390.         }
  391.     }
  392.     /**
  393.      * Map MessageAutomationEventType -> dispatched Events::* name.
  394.      *
  395.      * @param string $eventType
  396.      *
  397.      * @return string|null
  398.      */
  399.     private function eventNameFromEventType(string $eventType): ?string
  400.     {
  401.         switch ($eventType) {
  402.             case MessageAutomationEventType::PROSPECT_CREATED:
  403.                 return Events::EMAIL_PROSPECT_CREATED;
  404.             case MessageAutomationEventType::LEAD_CREATED:
  405.                 return Events::EMAIL_LEAD_CREATED;
  406.             case MessageAutomationEventType::OPPORTUNITY_CREATED:
  407.                 return Events::EMAIL_OPPORTUNITY_CREATED;
  408.             case MessageAutomationEventType::CUSTOMER_CREATED:
  409.                 return Events::EMAIL_CUSTOMER_CREATED;
  410.             default:
  411.                 return null;
  412.         }
  413.     }
  414. }