РАЗРАБОТКА НА БИТРИКС

Отправка СМС пользователю о неоплаченном заказе

Подписывайтесь на канал для bitrix-разработчиков в Telegram!

Введение

В данном кейсе рассмотрен функционал оповещения пользователя по СМС о неоплаченном заказе спустя 20 минут после его оформления.

Описание задачи

Отправить СМС пользователю через 20 минут после оформления заказа, если он не оплатил заказ.

Исходные данные

  • Редакция продукта: «Бизнес»
  • Версия Ядра: 22.600.300
  • Версия PHP: 8.1.18
  • Модуль vampirus.yandexkassa

План выполнения

1. Написать функционал (используя d7) получения всех заказов по условиям:
  1. Заказ не отменен.
  2. Заказ не оплачен.
  3. Статус заказа равен «Принят, ожидает оплаты».
  4. Выбранная система оплаты равна «Оплата онлайн».
  5. Со времени создания заказа прошло 20 минут.
  6. По данному заказу еще не отправляли СМС.
2. Отправить СМС-оповещение пользователю, в котором указаны данные:
  1. Короткая ссылка на оплату заказа.
  2. Номер заказа.
  3. Сумма заказа.
3. Запускать скрипт каждую минуту.

Решение задачи

  • Отметка, что СМС было отправлено
Служебное свойство типа чекбокс PAY_NOTIFICATION для заказа (физических и юридических лиц).
  • СМС-событие и шаблон.
– Тип события: SMS_USER_NOTIFY_PAY
Вид события: СМС-событие.
Название: «Уведомление о неоплаченном заказе».
Описание:
#USER_PHONE# — номер телефона пользователя.
#ORDER_NUMBER#— номер заказа.
#ORDER_SUM# — сумма заказа.
#PAYMENT_LINK# — ссылка на оплату.
– Шаблон СМС
Здравствуйте! Чтобы подтвердить заказ #ORDER_NUMBER# на #ORDER_SUM# р., оплатите его по ссылке в течение часа: #PAYMENT_LINK#
  • Интерфейс O2k\\SiteCore\\Services\\Order\\Notification\\Type
Содержит константы типов уведомлений СМС / EMAIL и методы sendSms() / sendEmail()

interface Type
{
    const SMS = 1;
    const EMAIL = 2;

    public function sendSms() :bool;
    public function sendEmail() :bool;
}
  • Абстрактный класс O2k\SiteCore\Services\Order\Notification\Base
Класс реализует интерфейсы: O2k\\SiteCore\\Services\\Order\\Notification\\Type и Bitrix\\Main\\Errorable
const SHOT_URI_LIFE_TIME
Константа времени жизни коротких ссылок.
getDependencies() :array
Абстрактный метод, который должен возвращать индексный массив идентификаторов зависимых модулей.
__construct($type) :void
Конструктор класса, в который передается аргумент $type — тип уведомления: (см. O2k\\SiteCore\\Services\\Order\\Notification\\Type)
checkDependencies() :void
Подключение зависимых модулей, идентификаторы которых указаны в методе getDependencies. В случае ошибки подключения выбрасывает исключение \\Bitrix\\Main\\LoaderException
deleteOldShortUri() :void
Удаляет устаревшие короткие ссылки из БД.
Полный код класса

abstract class Base implements Type, Errorable
{
    /**
     * @var \Bitrix\Main\ErrorCollection
     */
    protected $errorCollection;

    protected $type;

    /**
     * Short Uri lifetime in database
     * @var int
     */
    const SHOT_URI_LIFE_TIME = 86000;

    /**
     * Return array of module names.
     * @return array
     */
    abstract protected function getDependencies():array;

    public function __construct($type = self::SMS)
    {
        $this->type = $type;
        $this->errorCollection = new ErrorCollection();
    }

    /**
     * Checking errors.
     * @return bool
     */
    public function hasErrors()
    {
        return !empty($this->getErrors());
    }

    /**
     * Getting array of errors.
     * @return Error[]
     */
    public function getErrors()
    {
        return $this->errorCollection->toArray();
    }

    /**
     * Getting once error with the necessary code.
     * @param string $code Code of error.
     * @return Error
     */
    public function getErrorByCode($code)
    {
        return $this->errorCollection->getErrorByCode($code);
    }

    /**
     * Check moduled dependencies.
     * @return void
     * @throws \Bitrix\Main\LoaderException
     */
    protected function checkDependencies()
    {
        foreach ($this->getDependencies() as $moduleName)
        {
            if (!Loader::includeModule($moduleName))
                throw new \Bitrix\Main\LoaderException("Depended module {$moduleName} not included");
        }
    }

    /**
     * Deleting old short uri from database
     *
     * @return void
     * @throws \Bitrix\Main\ObjectException
     */
    public function deleteOldShortUri()
    {
        $time = time() - self::SHOT_URI_LIFE_TIME;
        $time = \Bitrix\Main\Type\DateTime::createFromTimestamp($time);
        $delIds = [];
        $db = \CBXShortUri::GetList([], []);
        while ($row = $db->Fetch())
        {
            if (new \Bitrix\Main\Type\DateTime($row['MODIFIED']) <= $time)
                $delIds[] = $row['ID'];
        }

        foreach ($delIds as $id)
            \CBXShortUri::Delete($id);
    }
}
  • Класс O2k\SiteCore\Services\Order\Notification\NotPaid
Класс наследуется от O2k\\SiteCore\\Services\\Order\\Notification\\Base и реализует отправку СМС пользователю о неоплаченном заказе.
const *PAY_LINK string*
Константа, в которой хранится шаблон полной ссылки для оплаты, где вместо #INVOICE_ID# подставляется идентификатор счета, полученный с помощью метода Bitrix\\Sale\\PaySystem\\Service::initiatePay->getPsData()['PS_INVOICE_ID'].
const *MINUTES_SEND_EVENT int*
Константа, в которой хранится количество минут, по истечении которых нужно отправить уведомление.
const *SMS_EVENT_TYPE string*
Константа, в которой хранится код события отправки уведомления.
const *ORDER_PROP_SENDNOTIFY_CODE string*
Константа, в которой хранится код свойства (типа чекбокс) отвечающий за отметку, что уведомление было отправлено пользователю.
const *ORDER_DATE_START string*
Константа, хранящая дату создания заказа со временем (в формат d.m.Y H:i:s), начиная с которого можно получать заказы. Создано для того, чтобы проигнорировать старые заказы.
sendSms() :bool
Основной метод, который реализует отправку уведомлений по СМС о неоплаченном заказе. Всегда возвращает true или бросает исключение в случае ошибки.

public function sendSms(): bool
{
    $this->checkDependencies();
    $this->deleteOldShortUri();
    $this->fillOrdersNotPaid();
    $this->processSendSms();

    return true;
}
getShotUri($link): string
Метод на вход получает полную ссылку и возвращает короткую с помощью метода \\CBXShortUri::*GetShortUri*($link)

protected function getShotUri($link)
{
    $domain = Core::SHOP_DOMAIN. '.site.ru';
    return $domain . \CBXShortUri::GetShortUri($link);
}
fillOrdersIdNotPaid(): void
Данный метод собирает ID заказов, которые удовлетворяют условиям. Все ошибки записывает в errorCollection.

protected function fillOrdersIdNotPaid()
{
		try {
				$date = new Main\Type\DateTime();
				$date->add("-".self::MINUTES_SEND_EVENT." minutes");

	      /**
	      * @var ORM\Query\Query $query
	      * @var ORM\Objectify\Collection $collection
	      */
        $query = Sale\Internals\OrderTable::query();
        $query->setSelect(['ID']);
        $query
            ->where(Query::filter()
            ->logic(ORM\Query\Filter\ConditionTree::LOGIC_AND)
            ->where('DATE_INSERT', '<=', $date)
            ->where('DATE_INSERT', '>', new Main\Type\DateTime(self::ORDER_DATE_START, 'd.m.Y H:i:s'))
                )
            ->where(Query::filter()
                ->logic(ORM\Query\Filter\ConditionTree::LOGIC_OR)
                ->whereNull(self::ORDER_PROP_SENDNOTIFY_CODE.'.VALUE')
                ->where(self::ORDER_PROP_SENDNOTIFY_CODE.'.VALUE', 'N')
            )
            ->where('DATE_INSERT', '<=', $date)
            ->where('CANCELED', 'N')
            ->where('STATUS_ID', Order\OrderService::ORDER_STATUS_NEW)
            ->where('PAYMENT.PAY_SYSTEM.CODE', SiteCore\Core::ONLINE_PAYMENT_CODE)
            ->where('PAYMENT.PAID', 'N');

        $query->registerRuntimeField(
            new Main\Entity\ReferenceField(
                self::ORDER_PROP_SENDNOTIFY_CODE,
                Sale\Internals\OrderPropsValueTable::class,
                [
                    '=this.ID' => 'ref.ORDER_ID',
                    'ref.CODE' => new Main\DB\SqlExpression('?', self::ORDER_PROP_SENDNOTIFY_CODE)
                ]
            )
        );
        $collection = $query->fetchCollection();
        foreach ($collection as $item) {
            $this->ordersId[] = $item->getId();
        }
    }
    catch (\Exception|\Error $e) {
        $this->errorCollection->setError(new Main\Error($e->getMessage()), $e->getCode());
    }
}
fillOrdersNotPaid(): void
Данный метод получает все ID заказов с помощью метода fillOrdersIdNotPaid(). По каждому, полученному, заказу формирует массив данных: USER_PHONE (телефон пользователя), ORDER_NUMBER (Номер заказа), ORDER_SUM (Сумма заказа), PAYMENT_LINK (Короткая ссылка для оплаты заказа).

array (
    'USER_PHONE' => $phone,
    'ORDER_NUMBER' => $order->getField('ACCOUNT_NUMBER'),
    'ORDER_SUM' => $order->getPrice(),
    'PAYMENT_LINK' => $this->getShotUri($paymentLink)
)
Данные по каждому заказу записывает в свойство arNotifications с ключом ID заказа. Также записывает Объект заказа в свойство orders с ключом ID заказа.

protected function fillOrdersNotPaid()
{
    $this->fillOrdersIdNotPaid();

    foreach ($this->ordersId as $orderId) {
        try {
            $order = \Bitrix\Sale\Order::load($orderId);
            $propertyCollection = $order->getPropertyCollection();
            $paymentCollection = $order->getPaymentCollection();

            if ($phone = $propertyCollection->getPhone()) {
                $phone = SiteCore\Tools\DataAlteration::clearPhone($phone->getValue());
            } else {
                continue;
            }

            $payment = $paymentCollection[0];
            $service = Sale\PaySystem\Manager::getObjectById($payment->getPaymentSystemId());
            $initResult = $service->initiatePay($payment, null, Sale\PaySystem\BaseServiceHandler::STRING);
            $invoiceId = $initResult->getPsData()['PS_INVOICE_ID'];
            $paymentLink = str_replace('#INVOICE_ID#', $invoiceId, self::PAY_LINK);

            if ($phone && $paymentLink)
            {
                $this->orders[$order->getId()] = $order;
                $this->arNotifications[$order->getId()] = [
                    'USER_PHONE' => $phone,
                    'ORDER_NUMBER' => $order->getField('ACCOUNT_NUMBER'),
                    'ORDER_SUM' => $order->getPrice(),
                    'PAYMENT_LINK' => $this->getShotUri($paymentLink)
                ];
            }
        }
        catch (\Exception|\Error $e) {
            $this->errorCollection->setError(new Main\Error($e->getMessage()), $e->getCode());
        }
    }
}
processSendSms(): void
Данный метод осуществляет отправку СМС пользователю и если операция прошла успешно — ставит значение чекбоксу ORDER_PROP_SENDNOTIFY_CODE = Y

protected function processSendSms()
{
    if ($this->arNotifications)
    {
        foreach ($this->arNotifications as $orderId => $notifyData)
        {
            $propertyCollection = $this->orders[$orderId]->getPropertyCollection();
            $propItem = $propertyCollection->getItemByOrderPropertyCode(self::ORDER_PROP_SENDNOTIFY_CODE);
            if ($propItem === null)
            {
                $error = new Main\Error('Order property '.self::ORDER_PROP_SENDNOTIFY_CODE. ' not found');
                $this->errorCollection->setError($error);
                continue;
            }

            $event = new \Bitrix\Main\Sms\Event(self::SMS_EVENT_TYPE, $notifyData);
            $res = $event->send(true);
            if (!$res->isSuccess()) {
                foreach ($res->getErrorCollection() as $error)
                    $this->errorCollection->setError($error);

                $errorMessages = $res->getErrorMessages();
                $strErrorMessages = implode("<br>", $errorMessages);

                \CEventLog::Log(
                    "SECURITY",
                    "SMS_SEND_ERROR",
                    "main",
                    '',
                    implode('<br>', [$notifyData['USER_PHONE'], 'notpaidSms', $strErrorMessages])
                );
            }
            else
            {
                $propItem->setValue('Y');
                $res = $this->orders[$orderId]->save();
                if (!$res->isSuccess())
                {
                    foreach ($res->getErrorCollection() as $error)
                        $this->errorCollection->setError($error);
                }
            }
        }
    }
}
sendEmail(): bool
Всегда возвращает true, так как нет надобности реализации данного метода в пределах решения этой задачи.
  • Файл send_sms_notification_pay_order.php
Был создан файл, который будет запускаться каждую минуту по Cron.

<?php
set_time_limit(0);
define("NO_KEEP_STATISTIC", true);
define("NOT_CHECK_PERMISSIONS", true);

$_SERVER["DOCUMENT_ROOT"] = str_replace('/local/cron', '',__DIR__);

if (php_sapi_name() !== 'cli')
{
    header('HTTP/1.1 403 Forbidden');
    die();
}

require_once($_SERVER['DOCUMENT_ROOT'] . '/bitrix/modules/main/include/prolog_before.php');

use O2k\SiteCore\Services\Order\Notification;

/**
 * Module o2k.sitecore included in init.php
 */

$notify = new Notification\NotPaid();
$notify->sendSms();
if ($notify->hasErrors())
{
    foreach ($notify->getErrors() as $error)
        echo $error->getMessage() .PHP_EOL;
}
Подписывайтесь на канал для bitrix-разработчиков в Telegram!

Рекомендованные статьи