На этот раз речь пойдет о бизнес-логике модуля. Это, пожалуй, самая важная часть. Это то, без чего смысла в модуле вообще никакого нет. Бизнес-логика должна решать реальную задачу, что в свою очередь нацелено на извлечение прибыли. Ей и займемся, но сначала ….
Инсталлятор
Инсталлятор, черт побери! Я уже писал ранее, что к нему скорее всего придется возвращаться время от времени. И вот время снова пришло 🙂 Я тут в процессе изысканий обнаружил очередное неочевидное поведение в ядре (почему меня это не удивляет?). При описании ORM сущности мы должны создать метод getMap, который должен вернуть массив с описанием всех полей таблицы сущности. В моем модуле используется две сущности - DiscountCoupon и Process. Последняя сущность содержит поля с датами, одно из которых, в соответствии с описанием, должно по дефолту иметь значение null. Когда я задаю сущности дефолтное значение null, я ожидаю, что ORM сгенерирует мне таблицу с колонкой, в которую можно записать NULL’able значения. И каково было моё удивление, когда я попытался создать с помощью ORM запись в этой таблице со значением NULL для этого поля, но ORM вывалила мне Exception.
А дело вот в чем. Для создания таблицы в d7 используется метод \Bitrix\Main\DB\Connection::createTable. Этот метод на основании массива полей сущности создает таблицу. Если заглянуть в исходники, то можно увидеть, что там захардкодили запрет на создание NULL’able значений для всех полей. Возможно в будущем это изменится, поэтому вставлю скрином:

Если кто-то знает причины, которыми руководствовались разработчики при написании этого кода - подскажите. Причем тут этот комментарий в коде - я тоже не понял. На звание гуру в теории реляционных СУБД я ну никак не претендую, да и с mysql изнутри тоже знаком не особо. Чем тут может помешать наличие NULL значений? Не понятно ..
Если посмотреть тот же метод в реализации для других СУБД, то для MSSQL наблюдается аналогичная картина, а вот для Oracle выбор между NULL и NOT NULL осуществляется в зависимости от того, является ли поле первичным ключом. В общем - мне не понятно. Если кто-то может истолковать - будьте добры, в комментах.
В связи с этой ерундистикой пришлось изящный createTable заменить на прямой запрос CREATE TABLE … Ну да ладно …
Коллекции
Для того, чтобы генерировать какие-то последовательности символов, нам нужно определить наборы символов, которые будут использоваться в будущем для генерации. На этапе создания ТЗ я описал несколько разных классов последовательностей. Был определен базовый интерфейс SymbolsCollectionInterface, реализовать который должны все коллекции. Есть еще дополнительный интерфейс LettersCollectionInterface, который описывает коллекцию букв, необходимость которого заключается в том, чтобы отличать буквы от других последовательностей, а также иметь возможность работать с регистром букв. В итоге были реализованы коллекции для чисел, букв русского и английского алфавита, спец-символов, а также специальная коллекция, в которую можно собрать несколько других коллекций. Вся иерархия реализованных коллекций может быть представлена на диаграмме в следующем виде:

Реализация этих классов довольно тривиальна.
Шаблон последовательности
Помимо коллекций символов нам понадобится шаблон. Можно было бы пойти по более простому пути - указать в настройках количество купонов, количество символов в каждом купоне и формировать все купоны на основании этой настройки, но эта штука не является достаточно гибкой. Поэтому я решил сразу ввести понятие шаблона. В будущем эту концепцию можно будет развить (и у меня уже есть планы), но на старте этого будет более чем достаточно.
Шаблон купона будет представлять из себя набор символов, где часть символов является статическими, а часть - генерируемыми. Если в шаблоне будет присутствовать какой-то заранее предопределенный символ, являющийся генерируемым, то он будет заменен на рандомный символ из ассоциированной этому символу коллекции. В остальном же это обычная строка.
Класс для составления шаблона принимает в конструкторе строку, а также предоставляет интерфейс, который позволяет добавить к шаблону соответствие между символом и коллекцией. Это позволит составлять шаблоны программно на лету.
Структура классов и реализацию можно посмотреть в неймспейсе \Maximaster\Coupanda\Generator\Template
Генератор последовательностей
Задача генератора сводится к тому, чтобы сформировать одну или несколько последовательностей на основании настроек шаблона. При генерации необходимо предусмотреть обработку уникальных и не уникальных значений. Если шаблон содержит всего 3 генерируемых цифры, то количество последовательностей не будет превышать 1000 штук (основная формула комбинаторики - 10*10*10). И если генератору дать задание генерить более 1000 значений, генератор должен адекватно реагировать на это.
Но тут встает другая проблема - если ввести достаточно длинный шаблон, то количество комбинаций запросто перевалит за PHP_INT_MAX. К счастью, php легко справится с достаточно большими числами, просто переведя количество во float. Но генератор должен давать возможность генерировать уникальные значения в рамках одного хита, а поэтому все сгенерированные купоны придется хранить в оперативке в виде хеш таблицы (читай - массива). Но на этапе запуска нам не нужно особо думать о том, чтобы давать возможность в рамках одного хита формировать огромные количества купонов. Поэтому просто ограничим максимальное количество последовательностей, сгенерированных за одно обращение к генератору каким-то достаточно большим числом.
На самом деле, проблем тут куда больше, и если копнуть достаточно глубоко, то можно ооочень сильно и ооочень надолго закопаться. Но мы просто не будем об этом думать. Вероятность, что мы наткнемся на проблемы - слишком мала (хоть по закону Мёрфи, это когда-нибудь и случится). Но я пока решил этим всем пренебречь и просто ввести ограничения.
Генератор купонов
На основании генератора последовательностей уже можно сделать и генератор купонов. Задача генератора купонов - обратиться к генератору последовательностей за набором последовательностей и добавить их в БД, а также проконтролировать уникальность добавляемых в БД записей. Все это нужно делать на основании настроек, которые указал пользователь в форме. Класс совсем простой и маленький получается.
Для общения с таблицей купонов у нас есть ORM сущность DiscountCoupon, которая наследуется от битриксовой одноименной сущности.
Процесс
На прошлых этапах я выделил 3 сущности, которые будут описывать сам процесс и все вспомогательные для него данные - ProcessSettings, ProcessProgress и ProcessReport. Начав реализовывать первый концепт, я сделал работу с ProcessProgress с помощью сессии, а ProcessSettings являлся одним из полей ProcessProgress. Однако немного поработав с этим стало понятно, что будет удобнее в качестве хранилища для прогресса использовать СУБД, а сама сущность Process, которую я уже заранее определил, по сути и является этим самым хранилищем для прогресса. В связи с этим я просто избавился от ProcessProgress в пользу класса Process, о котором чуть подробнее.
Процесс в понятиях моего модуля - это как некая книга учета событий. Если нам нужно запланировать какое-то событие, то мы создаем запись в календаре, что нам нужно начать его 15го числа такого-то месяца в 12 часов дня. Событию сопутствуют некоторые параметры - в моем случае это параметры генерации купона, но могут быть и другие параметры. Кроме этого событие имеет в себе флаг состояния (в моем случае - количество обработанных процессом купонов).
Каждый новый процесс создается в тот момент, когда администратор модуля нажимает кнопку «Начать генерацию». В БД заводится запись о новом процессе, которая хранит в себе все заданные пользователем настройки. А компонент, который работает с этим процессом, обращается к БД и обновляет процесс в БД по мере его выполнения.
Сам по себе процесс и является неким отчетом - он содержит данные о начале и окончании процесса, а также о количестве обработанных купонов.
Для удобства работы с процессом нужно представить его в виде класса. Однако класс этот связан с БД напрямую, значит я должен использовать ORM сущность. Но использовать ORM сущности неудобно в битриксе для хранения бизнес-логики из-за их статической природы и связи с БД, поэтому я решил создать прослойку, которая позволит более-менее удобно обращаться к данным из БД.
Репозиторий процессов
Смысл в том, что работать с массивами менее удобно, чем с объектами. Поэтому нужен какой-то слой, который будет преобразовывать массивы с описанием процессов в объекты процессов.
Кроме этого, перед преобразованием в объекты, данные нужно выбрать из БД. Возложим это все на наш «репозиторий», который будет являться реализацией паттерна DataMapper.
Задачи репозитория процессов сводятся к тому, чтобы:
- искать сущности в БД
- преобразовывать ORM сущности в объекты процессов (и отдавать их после поиска), и наоборот
- сохранять объекты процессов в БД
Класс репозитория тоже получается довольно простым:
namespace Maximaster\Coupanda\Process;
use Bitrix\Main\DB\DbException;
use Bitrix\Main\Entity\Query;
use Maximaster\Coupanda\Orm\ProcessTable;
class ProcessRepository
{
/**
* @param Query $query
* @return Process[]
*/
public static function find(Query $query)
{
$result = $query->exec();
$processList = [];
while ($process = $result->fetch()) {
$processList[] = Process::fromState($process);
}
return $processList;
}
/**
* @param Query $query
* @return Process
*/
public static function findOne(Query $query)
{
$query->setLimit(1);
return static::find($query)[0];
}
public static function findById($id)
{
return static::find(static::getByIdQuery($id));
}
public static function findOneById($id)
{
return static::findOne(static::getByIdQuery($id));
}
protected static function getByIdQuery($id)
{
return ProcessTable::query()->addFilter('ID', $id)->setSelect(['*']);
}
public static function save(Process $process)
{
$processData = Process::toState($process);
$result = $process->getId() > 0 ?
ProcessTable::update($process->getId(), $processData) :
ProcessTable::add($processData);
if (!$result->isSuccess()) {
throw new DbException(implode('. ', $result->getErrorMessages()));
}
if (!$process->getId()) {
$process->setId($result->getId());
}
return $result;
}
}
Класс процесса при этом будет содержать геттеры и сеттеры для каждого из полей ORM сущности и представляет собой простейший объект, который знать ничего не знает о СУБД и просто хранит свои поля в оперативке без обратной связи, а когда нам нужно сохранить данные этого объекта в постоянное хранилище, то просто отдаем его репозиторию, он сделает сам всё, что нужно. Класс процесса тоже очень простой - набор геттеров и сеттеров плюс пара методов для отслеживания прогресса выполнения процесса:
namespace Maximaster\Coupanda\Process;
use Bitrix\Main\Type\DateTime;
class Process
{
/** @var int */
protected $id;
/** @var DateTime */
protected $startedAt;
/** @var DateTime */
protected $finishedAt;
/** @var int */
protected $processedCount = 0;
/** @var ProcessSettings */
protected $settings;
public static function fromState(array $state)
{
$instance = new static();
isset($state['ID']) && $instance->setId($state['ID']);
isset($state['STARTED_AT']) && $instance->setStartedAt($state['STARTED_AT']);
isset($state['FINISHED_AT']) && $instance->setFinishedAt($state['FINISHED_AT']);
isset($state['PROCESSED_COUNT']) && $instance->setProcessedCount($state['PROCESSED_COUNT']);
isset($state['SETTINGS']) && $instance->setSettings($state['SETTINGS']);
return $instance;
}
public static function toState(Process $process)
{
return [
'ID' => $process->getId(),
'STARTED_AT' => $process->getStartedAt(),
'FINISHED_AT' => $process->getFinishedAt(),
'PROCESSED_COUNT' => $process->getProcessedCount(),
'SETTINGS' => $process->getSettings(),
];
}
/**
* @return int
*/
public function getId()
{
return $this->id;
}
/**
* @param int $id
*/
public function setId($id)
{
$this->id = (int)$id;
}
/**
* @return DateTime
*/
public function getStartedAt()
{
return $this->startedAt;
}
/**
* @param DateTime $startedAt
*/
public function setStartedAt(DateTime $startedAt)
{
$this->startedAt = $startedAt;
}
/**
* @return DateTime
*/
public function getFinishedAt()
{
return $this->finishedAt;
}
/**
* @param DateTime $finishedAt
*/
public function setFinishedAt(DateTime $finishedAt = null)
{
$this->finishedAt = $finishedAt;
}
/**
* @return int
*/
public function getProcessedCount()
{
return $this->processedCount;
}
/**
* @param int $processedCount
*/
public function setProcessedCount($processedCount)
{
$this->processedCount = (int)$processedCount;
}
/**
* @return ProcessSettings
*/
public function getSettings()
{
return $this->settings;
}
/**
* @param ProcessSettings $settings
*/
public function setSettings(ProcessSettings $settings)
{
$this->settings = $settings;
}
public function getProgressPercentage()
{
$percentage = $this->getProcessedCount() * 100 / $this->getSettings()->getCount();
return $percentage;
}
public function isInProgress()
{
return $this->getSettings()->getCount() > $this->getProcessedCount();
}
public function incrementProcessedCount($count = 1)
{
$this->setProcessedCount(
$this->getProcessedCount() + $count
);
}
}
Настройки процесса - это дополнительный, пока не особо структурированный объект, поэтому он хранится в БД в виде сериализованной строки (если у вас нет на то реальных причин - не делайте так в РСУБД!).
Ну и класс ProcessReport я удалил пока за ненадобностью. Возможно в будущем еще вернусь к нему, если мне понадобятся расширенные отчеты.
Таким образом, удалось уменьшить количество кода и разделить обязанности между классами, увеличив читабельность и удобство в поддержке.
Компонент
Для того, чтобы воплотить все наработки в жизнь, нужно лишь в компонент генератора добавить обращение к разработанным классам. В обработчике действия generation_start - создаем новый процесс, сохраняем туда дату начала и настройки.
В generation_process готовим инстанс шаблона, созданный из настроек, готовим генератор последовательностей на основе этого шаблона и достаем инстанс процесса, который был создан на предыдущем шаге. Все это скармливаем в CouponGenerator и в цикле генерируем по несколько купонов до достижения лимита.
В generation_finish сохраняем процесс в БД с датой окончания.
Естественно все это сопровождается валидацией и обработкой ошибок.
На фронтенде теперь появляется необходимость хранить идентификатор процесса, в связи с чем появляются накладные расходы на его обработку и передачу в ajax запросы, но это все мелочи.
Превью
Ну и осталось сделать только функцию превью. Тут достаточно обратиться к генератору последовательностей и сформировать несколько последовательностей (можно даже за уникальностью не следить) на основании заданного шаблона купона. Кода вообще минимум. Результат генерации выводится на фронтенде в отдельное всплывающее окно.

Выводы
На данный момент у нас уже есть полностью работоспособная версия модуля, которая уже может быть установлена и использована на проектах с кодировкой utf-8. На 100% уверен, что данная версия не является стабильной, и содержит набор багов. Но это еще совсем не конец разработки, еще много работы - это и управление правами доступа, логирование, локализация, документация, тестирование и исправление косяков. Кроме этого нужно еще разработать какой-то инструментарий для автоматической сборки обновлений в маркетплейс и запуска автотестов, сборки документации. А еще надо логотип нарисовать🙂 Так что впереди еще много интересного, следите за обновлениями в RSS и подписывайтесь на email рассылку! Исходники, как обычно, на гитхабе.