Так масштабировался сервис с марта 2020. Каждый цвет — группа операторов.
В Skyeng есть несколько отделов, которые сопровождают учеников. Например, отделы, отвечающие за входящую телефонную линию и техподдержку в чате на сайте. Есть группа Awake, работающая с учениками, которые брали перерыв в обучении. Есть группа Quality Control — она проверяет кейсы качества: например, что-то случилось на уроке и ученик оставил жалобу.
Путь обработки задачи для групп операторов одинаковый: взять задачу, выполнить, закрыть. Интерфейс взятия новой задачи идентичен: одна кнопка и большая автоматизация под капотом.
Но так было не всегда. Расскажу, как мы прошли путь от «завязанности» на ручном перетаскивании карточек задач и ручном выставлении приоритетов до единого сервиса, который экономит ресурсы операторов и разработки.
О жизни с внешними сервисами
Для работы с обращениями мы использовали такие системы как Usedesk, Omnidesk и Google Sheets. Это накладывало ограничения:
- Операторам и менеджерам приходилось вручную создавать задачи. Такая рутина забирала много времени. Ошибиться проще простого.
- Нельзя создать подробную аналитику. Например, посмотреть на время работы оператора над задачей или логику распределения задач на операторов. Тебе никто не даст доступ к БД. Соответственно, не было гибкой кастомизации. Под каждую группу операторов строилась доска с ответственным и правилами ведения.
- Нельзя гибко проставлять и обновлять приоритеты. Только статичность. Но ведь у задач разная срочность и важность? Да. Меняли приоритеты руками.
- Высокий порог входа: нужно разобраться, как работает внешняя система, CRM… И надо дождаться человека, который объяснит правила жизни доски той группы операторов, куда пришел новичок.
Интерфейс доски задач выглядел так:
Оператор попадал в список, где лежали задачи для всех отделов. А дальше искал свои задачи с помощью кучи фильтров в шапке таблицы.
Вечный вопрос: где грань между «использовать внешний инструмент» и «пилить свой»?
Все очевидно. Внешние сервисы хороши для простых задач и небольших проектов. Чем больше проект, тем чаще вы сталкиваетесь с ограничениями внешних сервисов и необходимостью «что-то докрутить». А если хотите доработок, будьте готовы к сценарию очереди таких желающих и: «У нас еще фич-реквесты от 40 компаний. Вашу сделаем через 4 месяца», а ждать придется все 6.
Это снижает гибкость, тормозит бизнес-процессы отдела сопровождения и внедрение новых продуктов. Вот и мы дошли до момента, когда стопорящие факторы стали перевешивать. Первым была цена обслуживания одного ученика в месяц — из-за сложности процессов она стала недопустимой. Второй фактор — падающее качество обслуживания (CSAT).
Переезд операторов в единый сервис
Мы используем PHP и Symfony на бэкенде и Angular на фронтенде. Интерфейс приложения внедрили в CRM, ведь оператору нужно обращаться за данными о пользователе. Всё на расстоянии пары кликов.
Проектируем систему. Основные сущности:
- Сама задача.
- «Шаблон» и параметры задачи.
- Оператор и его отдел (группа).
- Резолюции задачи и категоризатор для разметки обращений (дополнительный селект после выбора резолюции).
Проблемы обозначили, экономию подсчитали, дизайн нарисовали, MVP спроектировали. Поехали писать код? Так как главная сущность в этом проекте – задача, то начали с неё.
Технически, задача — экземпляр «шаблона». Мы называем его «бизнес-процессом» (БП). Бизнес-процесс — это класс-агрегат для настроек задачи, которые будут использоваться далее.
Продуктово, задача — это бизнес-цель, которую должен достичь оператор. Например «вывести из отпуска» или «вернуть к обучению». А уже от этой цели строится «бизнес-процесс» и проектируются свойства, атрибуты и прочие параметры.
Настройки задачи у нас следующие:
- Время на обработку задачи, за которое оператор должен ее выполнить.
- Общее время выполнения БП, за которое БП должен быть закрыт.
- Список резолюций — причин закрытия задачи: задача выполнена; задача не выполнена; перенести задачу на другое время и так далее.
- Категоризатор. Это список из двух компонентов: Тип запрос и Тематика запроса.
При создании задачи мы задаем ей БП, которому она будет следовать, то есть настройки. Помимо настроек задача имеет свойства. Например, статус; оператор, ученик, услуга и другие.
Настройки и свойства влияют на взятие задачи в работу. На основе БП и времени открытия мы приоритезируем бэклог оператора. Прям SQL-запросом. Быстро и просто. А что такого?
Поднимаем докеры, рисуем фронтенды, пишем миграции, бэкенды. И вот мы в проде.
Но на сортировке SQL-запросами далеко не уедешь. Дополнительно мы стали подсчитывать вес задачи, который определяет ее приоритет в бэклоге.
Вес задачи появляется у задачи, а не у БП, потому что в рамках одного БП могут быть задачи с разным приоритетом. У кого-то идет урок — он первый на помощи, а кто-то может подождать пару минут.
Отдельная история — расчет приоритетов
Если раньше приоритет задачи менялся ручным перетаскиванием на доске, то теперь все завязано на формулах. На приоритет задачи влияют много условий: расписание ученика, идет ли у него урок, сколько занятий осталось на балансе и так далее. Если какой-то фактор меняется, приоритет задачи пересчитывается.
Оператору не нужно выбирать: «Тут приоритет выше, надо брать» или «Вот это неинтересно, пусть кто-нибудь подхватит». Убираем одной кнопкой муки выбора, к которым приводит бэклог в виде списка задач, и не страдают метрики от того, что кто-то забирает себе понравившиеся задачки. И теперь нельзя добавить ручные приоритеты — раньше это была дыра для фрода и прокрастинации.
Так вот, к расчету весов.
Первый прототип подсчета веса задачи был почти «в лоб» — опирались на БП: для одного вес 5, для другого 10. Но появилось требование увеличивать вес задачи, если она «отложилась» на перезвон.
Придумали формулу: Вес задачи = Вес БП + 2 * Вес резолюции. Так и жили, пока не потребовалось внедрять различные слагаемые к БП.
Тогда спроектировали компонент, а в нем:
- Независимые конфигурируемые «коэффициенты». Коэффициент включал в себя настройку-вес и поведение. Например, межсервисный запрос, обращение в БД или другую эвристику.
- Класс-агрегат настроек и БП. Мы назвали его «Стратегия». Стратегия задавала множество коэффициентов, которые применялись к тому или иному БП, и имела формулу подсчета значений.
- Интерфейс калькулятора и единую реализацию. Интерфейс позволял получить вес по одному методу calculate($task): int.
Под капотом: калькулятор искал нужную стратегию и передавал управление в неё. Она конфигурировала коэффициенты и выполняла их. Старую стратегию подсчета веса удалось вписать в рамки новой концепции.
Чтобы коэффициенты умели принимать в зависимости любые классы (http-client, repository, etc), мы внедрили Dependency Injection. Любители Symfony могут извратиться с required, но это выглядит дико, честно. Помимо DI, коэффициент должен был настраиваться теми самыми настройками, поэтому мы выбрали путь с immutable-объектами.
Стратегия упрощенно выглядит примерно так:
class Strategy extends AbstractStrategy
{
private array $coefficients;
public function __construct(
ActiveLessonCoefficient $activeLessonCoefficient,
DeadlineCoefficient $deadlineCoefficient,
// ...
)
{
$this->coefficients = [
Type::CALL_STUDENT => [
$activeLessonCoefficient->withWeight(10),
$deadlineCoefficient,
],
Type::CALL_TEACHER => [
$activeLessonCoefficient->withWeight(30),
$deadlineCoefficient,
],
];
}
/**
* @return CoefficientInterface[]
*/
public function getCoefficients(): array
{
return $this->coefficients;
}
public function supports(Task $task): bool
{
return $task->hasOption(Option::SOME_WEIGHT_STRATEGY);
}
}
Коэффициент мог быть таким:
class ActiveLessonCoefficient implements CoefficientInterface
{
private int $weight = 1;
private int $defaultWeight = 0;
public function __construct(private AuthHttpClient $client)
{
}
public function withWeight(int $weight): static
{
$new = clone $this;
$new->weight = $weight;
return $this;
}
public function calculate(Task $task): int
{
if ($this->calculateInner($task)) {
return $this->weight;
}
return $this->defaultWeight;
}
private function calculateInner(Task $task)
{
// call api or something else
// $client->get('...')
}
}
AbstractStrategy реализовывает логику обработки значений, полученных из всех задействованных при обработке задачи коэффициентов.
С новой концепцией калькулятора веса прежняя формула подсчета нас не устраивала, и мы воспользовались упрощенной версией формулы теории игр.
Новая формула выглядела так:
Вес задачи = W1 + W2 + … + Wn, где Wn — полученный вес коэффициента.
Но вскоре понадобились «глобальные множители». Одним из таких у нас был коэффициент «дедлайн». Он считался внутри себя по Task, потом домножался на полученную сумму коэффициентов.
Пришли к формуле:
Вес задачи = (W11 + W12 + … + W1n) * W21 * W22 * … * W2n, где W1n и W2n — полученные веса summary и multiply коэффициентов соответственно.
Интерфейсы не поменялись, поменялся лишь класс AbstractStrategy. Коэффициент не помечается как «коэффициент для сложения» или «коэффициент для умножения» — один и тот же можно использовать в обеих частях выражения.
Теперь стратегия стала выглядеть так:
class Strategy extends AbstractStrategy
{
private array $coefficients;
private array $multiplyCoefficients;
public function __construct(
ActiveLessonCoefficient $activeLessonCoefficient,
CrisisStudentCoefficient $crisisStudentCoefficient,
DeadlineCoefficient $deadlineCoefficient,
// ...
)
{
$this->coefficients = [
Type::CALL_STUDENT => [
$activeLessonCoefficient->withWeight(10),
$crisisStudentCoefficient->withWeight(20),
],
Type::CALL_TEACHER => [
$activeLessonCoefficient->withWeight(30),
$crisisStudentCoefficient->withWeight(20),
],
];
$this->multiplyCoefficients = [
Type::CALL_STUDENT => [
$deadlineCoefficient,
],
Type::CALL_TEACHER => [
$deadlineCoefficient,
],
];
}
/**
* @return CoefficientInterface[]
*/
public function getCoefficients(): array
{
return $this->coefficients;
}
/**
* @return CoefficientInterface[]
*/
public function getMultiplyCoefficients(): array
{
return $this->multiplyCoefficients;
}
public function supports(Task $task): bool
{
return $task->hasOption(Option::SOME_WEIGHT_STRATEGY);
}
}
После втаскивания новой системы расчета весов к нам долго приходили и просили: «Дайте список, мы не видим приоритеты». Для операторов процесс расстановки приоритетов по формулам был непонятен: жмешь кнопку «Следующая задача», а почему выпадает такая задача — черный ящик.
Тогда мы построили табличку в Grafana и дали доступ руководителям групп. Если возникает вопрос по задачам, руководитель может открыть табличку и посмотреть ситуацию с приоритетами. А еще полистать историю и проверить правильность приоритетов закрытых задач.
Если же оператор взял задачу и не хочет ее делать, то сценария два: он либо откладывает ее «немного на потом» (недоступно для срочных задач), либо (при бездействии оператора) задача выпадает другому человеку. Но это отражается на метриках производительности сотрудника и группы.
Интерфейс
Так выглядит задача у оператора:
Левая и правая колонки – часть CRM, мы ими не управляем. Центральная колонка наша. Здесь есть пара интересных моментов.
У нас на фронте несколько типов блоков. Мы их называем виджетами. Они делятся на динамические и статические.
Контент статического виджета неизменен всю жизнь задачи. Например, какие-то изначальные данные, которые указал при создании задачи оператор или высчитала и добавила система. На скриншоте выше есть 2 блока статических виджетов: «Что произошло» и «Дата отпуска». При создании задачи оператор обязан добавить эти данные.
Динамические виджеты – противоположность статическим. Для показа динамического виджета может потребоваться начальный payload. Например, user_id. Блок «Данные об ученике» содержит виджет «Номер телефона». Мы не храним номер телефона у себя в приложении. Он получается с помощью обращения к сервису авторизации. Для получения данных о пользователе достаточно user_id.
Динамические виджеты выглядят привлекательнее статических — меньше информации хранить в БД. Но это 2 разные концепции, которые будут существовать всегда.
Количество виджетов и компонентов у нас уходит далеко за 100. И всё можно включить за несколько кликов в админке. Или попросить программиста написать миграцию.
Техническая сторона
Теперь расскажу, как к этому пришли, и подробнее про профит для разработки.
Когда мы проектировали систему, то не знали, сколько будет бизнес-процессов, команд и возможных вариаций. За отображение каждого виджета отвечал такой код:
<div *ngIf="['needed_type_name1', 'needed_type_name2', 'needed_type_name3', ...].includes(task.type.name)">
<div class="header">Номер телефона</div>
<div class="content">{{ getPhoneNumber() }}</div>
</div>
Это простое и понятное решение.
Плюсы | Минусы |
---|---|
Легко найти причину, почему виджет не отображается. Легко добавить новый БП к отображению. |
Создание «копии» БП занимает время. Нужно искать все места использования названия копируемого БП и добавлять название нового. Фронтенд становится «умным». А мы, бэкендеры, которые фулстеки, не любим, когда кто-то умнее нас или нашего бэкенда! Это значит, что за логику отображения виджетов или работу других частей системы отвечают уже обе части: и клиент, и сервер. Например, помимо простого отображения виджета, может существовать логика валидации и прочее. Нельзя добавить новый БП к этим проверкам без задачи в разработку. |
Когда количество бизнес-процессов приблизилось к 100 — стало грустно. Когда подкатилось к 500 — совсем печально. Начали думать над решением проблемы и пришли к концепции «фича-флагов», управляемых с бэкенда.
Спроектировали дополнение. Назвали «опциями», так как данные, отображаемые за флагом, не являются фичами. Это просто конфигурация, дополнительная опция к БП.
Теперь БП имеет уникальный список опций. За опцией скрываем всё, что может динамически меняться от БП к БП, что можно к ней привязать и имеет в этом целесообразность:
- Отображение виджетов на фронте.
- Отображение виджетов на форме создания запроса.
- Различную логику валидации БП.
- Различные поведения БП в системе: подсчет веса задачи, участие БП в межсервисном взаимодействии и так далее.
Плюсы решения | Минусы решения |
---|---|
Фронтенд стал «тупее», а бэкенд — «умнее». В целом, этого было бы достаточно, но не конструктивно :) Поведение БП может контролироваться из админки. Разработчик освободился от рутины. Почти. Клонирование БП ускорилось. Достаточно составить идентичный набор опций и все заработает как у копируемого БП. Без нужды не нужно лезть на фронт. Упростили поиск проблем в отображении виджетов — достаточно взглянуть на список опций. |
Опции уехали в БД, а значит усложнилось написание юнит-тестов. Из-за админки усложнилась синхронизация наборов опций с продом. Раньше можно было быть уверенным в том, что деплой одной версии приложения будет предоставлять конечный набор опций. |
Как теперь выглядят проверки на фронтенде
Вместо условий:
<div *ngIf="['needed_type_name1', 'needed_type_name2', 'needed_type_name3', ...].includes(task.type.name)">
<div class="header">Номер телефона</div>
<div class="content">{{ getPhoneNumber() }}</div>
</div>
Осталась проверка:
<div *ngIf="task.type.options.includes(['needed_option_name')">
<div class="header">Номер телефона</div>
<div class="content">{{ getPhoneNumber() }}</div>
</div>
Список опций и задач у нас был представлен в виде ENUM для удобства поиска использования по системе. Поиск использования опции обычно завершается одним использованием.
Развитие концепции опций
Нам понравилось использовать опции для конфигурирования поведения задач. И мы решили переиспользовать эту концепцию для «групп операторов» и для «резолюций».
Помимо условий вида:
<div *ngIf="['needed_type_name1', 'needed_type_name2', 'needed_type_name3', ...].includes(task.type.name)">
<div class="header">Номер телефона</div>
<div class="content">{{ getPhoneNumber() }}</div>
</div>
У нас были такие условия фронте:
<div *ngIf="['needed_operator_group_name'].includes(task.operator.group.name)">
Какой-то контент
</div>
<div *ngIf="['needed_resolution_name'].includes(task.resolution?.name)">
<div class="warning">Не забудьте отправить письмо</div>
</div>
На бэкенде были аналогичные проверки с завязкой на имя какой-то сущности.
Всё элегантно заменилось при проверке на опции, связи с фронта ушли на бэкенд, а управление ими — в базу данных и админку.
Что по результатам. Стоило того?
Так выглядит заветная кнопка «Взять новую задачу», к которой все свелось:
Затем добавили в интерфейс полезной оператору селф-аналитики. Например, SLA взятия и закрытия задач:
Один из главных итогов – операторы берут и закрывают задачи быстрее. Для сравнения:
Время обработки задачи |
Декабрь 2019 | Весна 2022 |
---|---|---|
Входящее письмо | 720 секунд | 160 секунд (-78%) |
Входящий звонок | 400 секунд | 360 секунд (-10%) |
Исходящий звонок активному ученику | 535 секунд | 330 секунд (-38%) |
Исходящий звонок уснувшему ученику | 660 секунд | 213 секунд (-68%) |
В целом, плюсов много — для операторов, разработки, бизнеса:
- Мы убрали рутинную часть работы оператора по заполнению табличек за счет автоматики. Все решает система, а у оператора нет бэклога.
- Проще стало с аналитикой: меняешь в SQL-запросе одну группу на другую, и вот у тебя уже готов под нее дашборд.
- Стало легче завозить операторов и масштабироваться. Любой оператор из любой группы может подсказать, как работает платформа.
- Все данные в одном месте: одна БД, один датасорс, один набор метрик.
- Живем по принципу одного окна: сервисы интегрируются по API и взаимодействуют с оператором через единое рабочее место.
- Конфигурирование отображения и поведения системы для отдельной группы или конкретного бизнес-процесса решается несколькими кликами в админке, прямо на проде.
Скрин с админки редактирования БП
А мы, как команда, которая владеет инструментом, можем быстро втаскивать новые фичи и не ждать своей очереди среди «40 других компаний».