Так масштабировался сервис с марта 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 других компаний».

Комментарии (0)