Как наши партнёры создавали решение для аутсорсинга техподдержки



Если кто-то решил начать своё дело и хочет предоставлять виртуальный хостинг, запустить онлайн-сервис по продаже цветов или кофе по франшизе, есть множество готовых инструментов для организации работы. С другой стороны, если запустить какой-то бизнес просто, надо быть готовым к большому количеству конкурентов в этой сфере. Вероятность “прогореть” повышается.
Если же заходить в какую-то мало освоенную область, где конкуренция меньше, то готовых инструментов для автоматизации задач может и не быть. Да, первое время можно всё делать вручную, однако когда количество клиентов подрастёт, придётся всерьёз задуматься над оптимизацией процессов.

При разработке средства автоматизации можно использовать фреймворк COREmanager как основу, скелет, продукта. Это сократит затраченное на кодирование время, а также позволит применить разные языки программирования для реализации различных функций.

ISPlicense — яркий пример компании, предлагающей нестандартные услуги в очень конкурентной сфере бизнеса. Они выбрали сферу хостинга, построив бизнес модель на обслуживании уже существующих и вновь приходящих на данный рынок игроков. Компания начала с реселлинга лицензий программных продуктов ISPsystem, став одним из наших крупных партнёров. Спустя время, пакет услуг компании дополнился оказанием технической поддержки на аутсорсинге, что оказалось очень востребованным. Нехватка ресурсов, географическая удалённость от большинства клиентов и другие причины побудили немало хостеров всерьёз задуматься о том, чтобы отдать техподдержку в ведение сторонней организации.

ISPlicense выполняет аутсорсинговую техподдержку по двум схемам:
  1. При работе с хостером, у которого для конечных пользователей техподдержка предоставляется бесплатно, выполняется учёт потраченного на ответы времени и выставление счёта хостеру согласно договора.
  2. Если поддержка для клиента не бесплатна, то стоимость формируется на основании прайса, где размер платы зависит от потраченного времени или от перечня предоставленных услуг. После выполнения запроса клиенту выставляется счёт от лица хостера, ISPlicense получает процент от суммы.

Теперь давайте предположим, как можно поставить процесс поддержки без использования инструментов автоматизации.

На первых порах можно организовывать сотрудничество с заказчиками по разным схемам: кто-то даёт логин-пароль своего сотрудника, кто-то заводит отдельного пользователя, к кому-то можно подключиться средствами BILLmanager. Но представьте, если количество обслуживаемых хостеров увеличилось настолько, что оператор техподдержки путается в десятках вкладок браузера и должен при этом отслеживать новые сообщения в тикетах.

Для предупреждения такой ситуации компания ISPlicense разработала на базе COREmanager систему техподдержки, интегрированную с BILLmanager и другими биллингами. Продукт получил название TicketManager.

Пожалуй, теперь стоит рассказать чуть подробнее о COREmanager. Это написанный на C++ фреймворк, конструктор.

Его разработка началась в 2010 году. COREmanager создавался для того, чтобы вынести общую функциональность наших продуктов в отдельную сущность и таким образом обеспечить согласованность компонентов. BILLmanager, ISPmanager, VMmanager, DCImanager и другие панели управления стали расширениями “ядра”, которое пишется отдельной командой из самых опытных разработчиков ISPsystem. В результате сократилось время разработки, уменьшилась вероятность появления багов и поднялась скорость работы конечных продуктов.

COREmanager распространяется бесплатно, имеет подробную документацию, описывающую его методы использования и применим в разработке инструментов для решения практически любой задачи. Создать структуру меню можно через веб-интерфейс или путём написания xml-файла, а для реализации механизма обработки событий допускается использовать любой язык программирования, если его интерпретатор установлен в операционной системе.

Поэтому ISPlicense выбрала COREmanager для создания TicketManager. Да, можно было воспользоваться готовыми решениями для техподдержки или решить задачу путём написания плагинов для BILLmanager, но программистам ISPlicense очень уж хотелось самим опробовать, что может COREmanager. :)

После запуска услуги аутсорсинга, с течением времени оформилась требующая решения проблематика и появилась потребность в разработке своей системы техподдержки. Образовались следующие предпосылки и задачи:
  • Если каждый раз заходить в панель каждого провайдера для консультации пользователей, это отнимает слишком много времени. Кроме этого, когда сотрудник техподдержки работает с несколькими провайдерами, может возникнуть путаница, поскольку работнику требуется открыть несколько вкладок или окон браузера. При этом хостеры могут использовать биллинги от разных разработчиков. Стало быть, требуется агрегировать потоки тикетов в один продукт и привести их к единому виду.
  • Поскольку тарификация поддержки почасовая, нужно реализовать удобный учёт потраченного на тикет времени.
  • Для удобства и экономии времени реализовать автоматическое формирование счетов, которые приходят конечным пользователям (например, за платное администрирование).
  • Создание отчётов и счетов для хостеров тоже автоматизировать.
  • Во избежание лишних вопросов как в сторону провайдера, так и в сторону ISPlicense конечный клиент не должен знать о том, что техподдержка аутсорсинговая.

Получившийся продукт состоит из двух частей: непосредственно самой системы тикетов и обработчика, который устанавливается в биллинг клиента. Также есть API, который позволяет выполнить интеграцию с другой системой техподдержки или биллингом.

Познакомимся с ключевыми особенностями панели техподдержки.
Список тикетов выполнен минималистично, инструменты для тонкой работы с заявками доступны в меню просмотра заявки.


Откроем любое обращение для примера и посмотрим, какие действия с ним можно совершить.

  • Ответить. Блокирует тикет за оператором, открывает форму ввода ответа и начинает учёт потраченного времени.
  • Заметка. Позволяет оставить для коллег примечание, привязанное к этому запросу. Оно не будет видно ни для провайдера ни для конечного клиента.
  • Клиент. Открывает большое текстовое поле для заметок, привязанное к конечному клиенту.
  • Проект. Вызывает аналогичное поле по хостеру.
  • Снять деньги. Списывает деньги за оказание услуг с конечного клиента в биллинговой системе хостера.
  • Закрыть. Возвращает к списку открытых тикетов.

Кроме этого, в окне показывается вся информация об инициаторе тикета. При щелчке на id клиента выполняется переход в его биллинг, при щелчке на имя сервера происходит переход в панель управления этим сервером.

Также отображается информация об услуге, в связи с которой поступило обращение, и указывается информация о сервере, где развёрнута услуга, в том числе данные для доступа.

Заблокируем тикет и посмотрим на активные элементы открывшейся формы ввода ответа.
  • Разблок. Отвязывает тикет от оператора и позволяет взяться за ответ другому человеку.
  • Отдел. Служит для передачи заявки в другое подразделение хостинг-провайдера. Например, от технических специалистов в бухгалтерию, если речь идёт о решении финансовых вопросов.
  • Внутренний комментарий. Позволяет написать в тикете сообщение, которое будет скрыто от клиента и от провайдера.
  • Потраченное время. Продолжительность ответа на тикет. Измеряется в минутах. При ручном изменении значения автоматический подсчёт времени останавливается.
  • Остановить счётчик времени. Используется в тех ситуациях, когда предполагается, что оператор не предпримет в ближайшее время никаких действий по решению запроса. Например, сотрудник перед развёртыванием ISPmanager ждёт пока завершится установка операционной системы на виртуальной машине.
  • Статус. Состояние тикета.
    • Открыт. Ведётся работа над запросом.
    • Закрыт. Запрос выполнен.
    • В процессе. Используется, если запрос выполняется третьим лицом, операция длительная, и требуется контроль процесса. Запрос находится в нижней части списка, чтобы не отвлекать от “горящих” задач.
    • Отложен. Тикет не отображается в общем списке до времени, которое указывается в поле “Отложить до”.
  • Отправить внутренний комментарий провайдеру. Написанное не будет видно конечному пользователю, но будет показано сотрудникам хостинг-провайдера.
  • Запретить закрытие тикета клиентом. Используется при решении вопросов задолженности или противозаконной деятельности.

В ходе консультации конечный пользователь не знает, что на его вопросы отвечает специалист ISPlicense; он видит, что разговор идёт с сотрудником его хостинг провайдера.

Реализация продукта заняла примерно 5000 строк кода для панели техподдержки и по 500 строк для модулей интеграции с BILLmanager и другими биллингами.

Желающие могут изучить API TicketManager, а ниже под спойлерами — исходный код модуля интеграции для BILLmanager.

Оказалось, что больше времени потратили на подбивку нужной функциональности в единый список, чем на кодирование, поскольку львиная доля нужных функций уже была реализована в COREmanager. Ну и интерфейс тоже не надо было писать, только указать где должны быть кнопки.
Makefile
MGR = billmgr
PLUGIN = ticketmgri
VERSION = 5.0.1
LIB += ticketmgri
ticketmgri_SOURCES = ticketmgri.cpp

WRAPPER += ticketmgri_syncticket
ticketmgri_syncticket_SOURCES = ticketmgri_syncticket.cpp
ticketmgri_syncticket_LDADD = -lbase

BASE ?= /usr/local/mgr5
include $(BASE)/src/isp.mk

billmgr_mod_ticketmgri.xml
<?xml version="1.0" encoding="UTF-8"?>
<mgrdata>
  <library name="ticketmgri" />
</mgrdata>

ticketmgri.cpp
#include <api/action.h>
#include <api/module.h>
#include <api/stdconfig.h>
#include <billmgr/db.h>
#include <mgr/mgrdb_struct.h>
#include <mgr/mgrlog.h>
#include <mgr/mgrtask.h>

MODULE("ticketmgri");

using namespace isp_api;

namespace {

StringVector allowedDepartments, hideDepartments;

/**
 * Синхронизирует тикет, запуская при помощи LongTask (фонового задания) бинарный файл sbin/ticketmgri_syncticket
 *
 * [in] _id Идентификатор тикета
 */
void SyncTicket(int _id) {
  string id = str::Str(_id);
  Warning("Sync %s", id.c_str());
  if (!_id) return;
  mgr_task::LongTask("sbin/ticketmgri_syncticket", "ticket_" + id,
                     "ticketmgri_sync")
      .SetParam(id)
      .Start();
}

/**
 * Базовая структура для обработки событий вызова функций редактирования и передачи тикета
 *
 * Получает идентификатор тикета и вызывает для него синхронизацию
 */
struct eTicketEdit : public Event {
	/**
	 * Конструктор
	 *
	 * Создает объект обработчика событий редактирования или передачи тикета
	 *
	 * ev	имя функции, на которую установить обработчик события
	 * elid_name	указывает способ получения данных для синхронизации. В зависимости от значения
	 * выбирается способ получения идентификатор тикета из базы данных или из сессии
	 */
  eTicketEdit(const string &ev, const string &elid_name = "elid")
      : Event(ev, "ticketmgri_" + ev), elid_name_(elid_name) {
    Warning("eTicketEdit created");
  }

  /**
   * Синхронизирует тикет при редактировании
   *
   * Событие выполняется после того, как завершилась функция
   * [in] ses	Текущая сессия
   */
  void AfterExecute(Session &ses) const override {
    Warning("subm %d cb %s elid %s", ses.IsSubmitted(),
            ses.Param("clicked_button").c_str(), ses.Param("elid").c_str());
    string button = ses.Param("clicked_button");

    string elid;
    if (elid_name_ == "elid_ticket2user") {
      elid = db->Query("SELECT ticket FROM ticket2user WHERE id='" +
                       ses.Param("elid") + "'")
                 ->Str();
    } else {
      elid = ses.Param("elid");
    }

    if ((ses.IsSubmitted() || ses.Param("sv_field") == "ok_message") &&
        (button == "ok" || button == "" || button == "ok_message")) {
      if (!ses.Has(elid_name_)) {
        SyncTicket(db->Query("SELECT MAX(id) FROM ticket")->Int());
      } else {
        SyncTicket(str::Int(elid));
      }
    }
  }

  string elid_name_;
};

/**
 * Структура, обрабатывающая список отделов
 */
struct eClientTicketEdit : public eTicketEdit {
  eClientTicketEdit() : eTicketEdit("clientticket.edit") {}

  /**
   * Удаляет из списка отделов, отображаемого клиентам, скрытые отделы
   *
   * Событие выполняется после того, как завершилась функция
   *
   * [in] ses	Текущая сессия
   */
  void AfterExecute(Session &ses) const override {
    eTicketEdit::AfterExecute(ses);
    for (auto &i : hideDepartments) {
      ses.xml.RemoveNodes("//slist[@name='client_department']/val[@key='" + i +
                          "']");
    }
  }
};

/**
 * Структура, устанавливающая фильтр по клиенту
 */
struct aTicketintegrationSetFilter : public Action {
  aTicketintegrationSetFilter()
      : Action("ticketintegration.setfilter", MinLevel(lvAdmin)) {}

  /**
   * Устанавливает фильтр по клиенту
   *
   * Выполняет внутренний вызов установки фильтра по клиенту
   *
   * [in] ses	Текущая сессия
   */
  void Execute(Session &ses) const override {
    InternalCall(ses, "account.setfilter", "elid=" + ses.Param("elid"));
    ses.Ok(ses.okTop);
  }
};

/**
 * Структура сохраняющая тикет
 */
struct aTicketintegrationPost : public Action {
  aTicketintegrationPost()
      : Action("ticketintegration.post", MinLevel(lvAdmin)) {}

  void Execute(Session &ses) const override { Execute(ses, true); }

  /**
   * Функция сохраняет тикет локально
   *
   * [in] ses	Текущая сессия
   * [in] retry	параметр, отвечающий за передачу тикета первому разрешенному отделу,
   * если нет открытых тикетов
   */
  void Execute(Session &ses, bool retry) const {
    auto openTickets = db->Query("SELECT id FROM ticket2user WHERE ticket=" +
                                 ses.Param("elid") + " AND user IN (" +
                                 str::Join(allowedDepartments, ",") + ")");
    string elid;
    if (openTickets->Eof()) {
      if (ses.Param("type") == "setstatus" && ses.Param("status") == "closed") {
        ses.NewNode("ok");
        return;
      }
      if (retry) {
        InternalCall(ses, "support_tool_responsible",
                     "set_responsible_default=off&sok=ok&set_responsible=e%5F" +
                         allowedDepartments[0] + "&elid=" + ses.Param("elid"));
        Execute(ses, false);
        return;
      } else {
        throw mgr_err::Error("cannot_open_ticket");
      }
    } else {
      elid = openTickets->Str();
    }

    if (ses.Param("type") == "setstatus" && ses.Param("status") == "new") {
      return;
    }

    auto ret2 = InternalCall(
        ses, "ticket.edit",
        string() + "sok=ok&show_optional=on" + "&clicked_button=" +
            (ses.Param("status") == "new" ? "ok_message" : "ok") + "&" +
            (!ses.Checked("internal") ? "message" : "note_message") + "=" +
            str::url::Encode(ses.Param("message")) + "&elid=" + elid);
    // TODO: attachments, sender_name
    ses.NewNode("ok");
  }
};

/**
 * Структура, описывающая таблицу, содержащую последний комментарий к тикету
 */
struct TicketmgriLastNote : public mgr_db::CustomTable {
  mgr_db::ReferenceField Ticket;
  mgr_db::ReferenceField LastNote;

  TicketmgriLastNote()
      : mgr_db::CustomTable("ticketmgri_last_note"),
        Ticket(this, "ticket", mgr_db::rtRestrict),
        LastNote(this, "last_note", "ticket_note", mgr_db::rtRestrict) {
    Ticket.info().set_primary();
  }
};

/**
 * Класс, добавляющий last_note в таблицу ticketmgri_last_note
 */
struct aTicketintegrationLastNote : public Action {
  aTicketintegrationLastNote()
      : Action("ticketintegraion.last_note", MinLevel(lvSuper)) {}

  /**
   * Функция, сохраняющая и возвращающая значение last_note для тикета
   * в таблице ticketmgri_last_note
   *
   * [in] ses	Текущая сессия
   */
  void Execute(Session &ses) const override {
    auto t = db->Get<TicketmgriLastNote>();
    if (!t->Find(ses.Param("elid"))) {
      t->New();
      t->Ticket = str::Int(ses.Param("elid"));
    }
    if (ses.IsSubmitted()) {
      t->LastNote = str::Int(ses.Param("last_note"));
      t->Post();
      ses.Ok();
    } else {
      ses.NewNode("last_note", t->LastNote);
    }
  }
};

/**
 * Структура, перезапускающая фоновые задания синхронизации тикетов, которые завершились с ошибкой
 */
struct aTicketintegrationPushTasks : public Action {
  aTicketintegrationPushTasks()
      : Action("ticketintegraion.push_tasks", MinLevel(lvSuper)) {}

  /**
   * Получает список фоновых заданий синхронизации тикетов, которые завершились с ошибкой и
   * снова запускает их
   *
   * [in] ses	Текущая сессия
   */
  void Execute(Session &ses) const override {
    mgr_xml::XPath xpath =
        InternalCall("longtask", "filter=yes&state=err&queue=ticketmgri_sync")
            .GetNodes("//elem[queue='ticketmgri_sync' and status='err']");
    for (auto elem : xpath) {
      auto data = InternalCall("longtask.edit",
                               "elid=" + elem.FindNode("pidfile").Str());
      mgr_task::LongTask(data.GetNode("//realname"), data.GetNode("//id"),
                         "ticketmgri_sync")
          .SetParam(data.GetNode("//params"))
          .Start();
    }
  }
};

/**
 * Структура для получения значения баланса клиента
 */
struct aTicketintegrationGetBalance : public Action {
  aTicketintegrationGetBalance()
      : Action("ticketintegration.getbalance", MinLevel(lvAdmin)) {}

  /**
   * При помощи внутреннего вызова запрашивает значение баланса клиента
   *
   * [in] ses	Текущая сессия
   */
  void Execute(Session &ses) const override {
    ses.NewNode("balance",
                InternalCall(ses, "account.edit", "elid=" + ses.Param("elid"))
                    .GetNode("//balance")
                    .Str());
  }

  bool IsModify(const Session &) const override { return false; }
};

/**
 * Структура, списывающая средства со счета клиента
 */
struct aTicketintegrationDeduct : public Action {
  aTicketintegrationDeduct()
      : Action("ticketintegration.deduct", MinLevel(lvAdmin)) {}

  /**
   * Функция для списания денежных средств за тикет
   *
   * При помощи SQL-запроса ищет тикет в разрешенных отделах.
   * Далее через внутрениий запрос вызывается списание денежных средств за тикет.
   * Если тикет не найден, бросается исключение
   *
   * [in] ses	Текущая сессия
   */
  void Execute(Session &ses) const override {
    auto openTickets = db->Query("SELECT id FROM ticket2user WHERE ticket=" +
                                 ses.Param("ticket") + " AND user IN (" +
                                 str::Join(allowedDepartments, ",") + ")");
    if (openTickets->Eof()) {
      throw mgr_err::Value("ticket");
    }
    string elid = openTickets->AsString(0);
    InternalCall(ses, "ticket.edit", "sok=ok&show_optional=on&elid=" + elid +
                                         "&ticket_expense=" +
                                         ses.Param("amount"));
  }
};

}  // namespace

//Инициализация модуля, добавление параметров в конфигурационный файл,
//регистрация таблицы в базе данных
MODULE_INIT(ticketmgri, "") {
  Warning("Init TICKETmanager integtration");
  mgr_cf::AddParam("TicketmgrUrl",
                   "https://tickets.isplicense.ru:1500/ticketmgr");
  mgr_cf::AddParam("TicketmgrLogin");
  mgr_cf::AddParam("TicketmgrPassword");
  mgr_cf::AddParam("TicketmgrBillmgrUrl");
  mgr_cf::AddParam("TicketmgrUserId");
  mgr_cf::AddParam("TicketmgrAllowedDepartments");
  mgr_cf::AddParam("TicketmgrHideDepartments");
  str::Split(mgr_cf::GetParam("TicketmgrAllowedDepartments"), ",",
             allowedDepartments);
  if (allowedDepartments.empty()) {
    allowedDepartments.push_back(0);
  }
  str::Split(mgr_cf::GetParam("TicketmgrHideDepartments"), ",",
             hideDepartments);
  db->Register<TicketmgriLastNote>();
  new eClientTicketEdit;
  new eTicketEdit("ticket.edit", "elid_ticket2user");
  new eTicketEdit("support_tool_responsible", "plid");
  new aTicketintegrationSetFilter;
  new aTicketintegrationPost;
  new aTicketintegrationLastNote;
  new aTicketintegrationPushTasks;
  new aTicketintegrationGetBalance;
  new aTicketintegrationDeduct;
}

ticketmgri_syncticket.cpp
#include <billmgr/db.h>
#include <billmgr/defines.h>
#include <billmgr/sbin_utils.h>
#include <ispbin.h>
#include <mgr/mgrclient.h>
#include <mgr/mgrdb_struct.h>
#include <mgr/mgrenv.h>
#include <mgr/mgrlog.h>
#include <mgr/mgrproc.h>
#include <mgr/mgrrpc.h>

MODULE("syncticket");

using sbin::DB;
using sbin::GetMgrConfParam;
using sbin::Client;
using sbin::ClientQuery;

/**
 * Инициализация клиента для выполнения запросов к удаленной системе Ticketmanager
 *
 * Адрес системы и данные для авторизации берутся из соответствующих параметров
 * конфигурационного файла
 */
mgr_client::Client &ticketmgr() {
  static mgr_client::Client *ret = []() {
    mgr_client::Remote *ret =
        new mgr_client::Remote(GetMgrConfParam("TicketmgrUrl"));
    ret->AddParam("authinfo", GetMgrConfParam("TicketmgrLogin") + ":" +
                                  GetMgrConfParam("TicketmgrPassword"));
    return ret;
  }();
  return *ret;
}

/**
 * Сохранение тикетов в системе TICKETmanager
 *
 * Получает данные из таблицы, формирует xml документ c информацией о клиенте,
 * пользователе, ссылкой, модуле обработки, тикете
 */
void PostTicket(const string &elid) {
  //получение информации о тикете, клиенте, пользователе
  auto ticket = DB()->Query("SELECT * FROM ticket WHERE id=" + elid);
  if (ticket->Eof()) throw mgr_err::Missed("ticket");
  auto account = DB()->Query("SELECT * FROM account WHERE id=" +
                             ticket->AsString("account_client"));
  if (account->Eof()) throw mgr_err::Missed("account");
  auto user = DB()->Query("SELECT * FROM user WHERE account=" +
                          account->AsString("id") + " ORDER BY id LIMIT 1");
  if (user->Eof()) throw mgr_err::Missed("user");

  //формирование xml-документа с информацией о клиенте и пользователе
  mgr_xml::Xml infoXml;
  auto info = infoXml.GetRoot();
  auto customer = info.AppendChild("customer");
  customer.AppendChild("id", account->AsString("id"));
  customer.AppendChild("name", account->AsString("name"));
  customer.AppendChild("email", user->AsString("email"));
  customer.AppendChild("phone", user->AsString("phone"));
  customer.AppendChild("link",
                       GetMgrConfParam("TicketmgrBillmgrUrl") +
                           "?startform=ticketintegration.setfilter&elid=" +
                           account->AsString("id"));

  if (!ticket->IsNull("item")) {
    auto item =
        DB()->Query("SELECT id, name, processingmodule FROM item WHERE id=" +
                    ticket->AsString("item"));
    if (item->Eof()) throw mgr_err::Missed("item");
    auto iteminfo = info.AppendChild("item");
	//добавление информации о модуле обработки
    iteminfo.SetProp("selected", "yes");
    iteminfo.AppendChild("id", item->AsString("id"));
    iteminfo.AppendChild("name", item->AsString("name"));
    iteminfo.AppendChild("serverid", item->AsString("processingmodule"));
	//добавление информации о параметрах услуги
    ForEachQuery(DB(), "SELECT intname, value FROM itemparam WHERE item=" +
                           ticket->AsString("item"),
                 i) {
      if (i->AsString(0) == "ip") {
        iteminfo.AppendChild("ip", i->AsString(1));
      } else if (i->AsString(0) == "username") {
        iteminfo.AppendChild("login", i->AsString(1));
      } else if (i->AsString(0) == "password") {
        iteminfo.AppendChild("password", i->AsString(1));
      } else if (i->AsString(0) == "domain") {
        iteminfo.AppendChild("domain", i->AsString(1));
      }
    }
  }

  //формирование информации о тикете для системы Ticketmanager
  StringMap args = {{"remoteid", ticket->AsString("id")},
                    {"department", ticket->AsString("responsible")},
                    {"info", infoXml.Str()},
                    {"subject", ticket->AsString("name")}};

  ticketmgr().Query("func=clientticket.add&sok=ok", args);
}

int ISP_MAIN(int ac, char **av) {
  if (ac != 2) {
    fprintf(stderr, "Usage: ticketmgri_syncticket ID");
    return 1;
  }

  string elid = av[1];

  try {
    mgr_log::Init("ticketmgri");
    string status = "closed";
    int lastmessage = 0;

	//проверка статуса тикета, находящегося в указанных отделах
    string newStatus =
        DB()->Query("SELECT COUNT(*) FROM ticket2user WHERE ticket=" + elid +
                    " AND user IN (" +
                    GetMgrConfParam("TicketmgrAllowedDepartments") + ")")
                ->Int()
            ? "new"
            : "closed";
    bool inDepartment =
        DB()->Query("SELECT COUNT(*) FROM ticket WHERE id=" + elid +
                    " AND responsible IN (" +
                    GetMgrConfParam("TicketmgrAllowedDepartments") + ")")
            ->Int();
    if (newStatus != "new" && !inDepartment) {
      LogNote("Skip ticket %s: status=%s, inDepartment=%d", elid.c_str(),
              newStatus.c_str(), inDepartment);
      return 0;
    }
    try {
	  //получение информации о тикете системе Ticketmanager
      auto r = ticketmgr().Query("func=clientticket.info&remoteid=?", elid);
      status = r.value("status");
      lastmessage = str::Int(r.value("lastmessage"));
    } catch (mgr_err::Error &e) {
      if (e.type() == "missed" && e.object() == "remoteid") {
		//создание тикета, если он не найден в системе
        PostTicket(elid);
      } else {
        throw;
      }
    }

	//получение last_note для тикета
    int lastnote =
        str::Int(Client()
                     .Query("func=ticketintegraion.last_note&elid=" + elid)
                     .value("last_note"));

	//получение сообщений для тикета
    auto msg = DB()->Query(
        string() +
        "SELECT ticket_message.id, user.realname AS username, user.level AS "
        "userlevel, message, 1 AS type, ticket_message.date_post " +
        "FROM ticket_message " + "JOIN user ON ticket_message.user=user.id " +
        "WHERE ticket_message.id > " + str::Str(lastmessage) + " " +
        "AND user != " + GetMgrConfParam("TicketmgrUserId") + " " +
        "AND ticket = " + elid + " " +
        "UNION "
        "SELECT ticket_note.id, user.realname AS username, user.level AS "
        "userlevel, note AS message, 2 AS type, ticket_note.date_post " +
        "FROM ticket_note " + "JOIN user ON ticket_note.user=user.id " +
        "WHERE ticket_note.id > " + str::Str(lastnote) + " " + "AND user != " +
        GetMgrConfParam("TicketmgrUserId") + " " + "AND ticket = " + elid +
        " " + "ORDER BY date_post");

	//если сообщений не найдено и статус не совпадает, то сохранение статуса в Ticketmanager
    if (msg->Eof() && status != newStatus) {
      StringMap params = {
          {"remoteid", elid}, {"status", newStatus},
      };
      ticketmgr().Query(
          "func=clientticket.post&sok=ok&sender=staff&sender_name=System&type="
          "setstatus",
          params);
    } else {
		//сохранение сообщений в Ticketmanager
      lastnote = 0;
      for (msg->First(); !msg->Eof(); msg->Next()) {
        StringMap params = {
            {"remoteid", elid},
            {"status", newStatus},
            {"sender_name", msg->AsString("username")},
            {"sender", msg->AsInt("userlevel") >= 28 ? "staff" : "client"},
            {"message", msg->AsString("message")},
        };

        int attachments = 0;

        if (msg->AsInt("type") == 1) {
          params["messageid"] = msg->AsString("id");
			//добавление вложений
          ForEachQuery(
              DB(),
              "SELECT * FROM ticket_message_attach WHERE ticket_message=" +
                  msg->AsString("id"),
              attach) {
            string id = str::Str(attachments++);
            auto info =
                ClientQuery("func=ticket.file&elid=" + attach->AsString("id"));
            params["attachment_name_" + id] =
                info.xml.GetNode("//content/name").Str();
            params["attachment_content_" + id] = str::base64::Encode(
                mgr_file::Read(info.xml.GetNode("//content/data").Str()));
          }
        } else {
          lastnote = std::max(lastnote, msg->AsInt("id"));
          params["internal"] = "on";
        }
        params["attachments"] = str::Str(attachments);

        ticketmgr().Query("func=clientticket.post&sok=ok&type=message", params);
      }
	  // сохранение last_note
      if (lastnote) {
        Client().Query("func=ticketintegraion.last_note&sok=ok&elid=" + elid +
                       "&last_note=" + str::Str(lastnote));
      }
    }
  } catch (std::exception &e) {
    fprintf(stderr, "%s\n", e.what());
    return 1;
  }
  return 0;
}


Итог разработки — продукт, который сделал работу операторов техподдержки удобнее, обеспечил автоматическое формирование документов, а запросы конечных пользователей стали обрабатываться быстрее.

В дальнейших планах ISPlicense — реализация десктопного мини-приложения, которое будет подавать сигнал при поступлении новой заявки, а также позволит останавливать и возобновлять учёт потраченного на тот или иной тикет времени в два клика.

В завершение хочется добавить, что COREmanager стал основой не только TicketManager и всех наших продуктов. На его основе реализована система учёта библиотечного фонда, сервис для взаимодействия с переводчиками, инструмент организации совместных поездок, система постановки задач для тестировщиков, и это только четверть списка. Благодаря тому, что модули можно писать на любом языке при наличии установленного на сервере интерпретатора, вы можете написать действительно уникальный продукт, идеально вписывающийся в вашу модель бизнеса.

В одной из следующих статей мы расскажем о применении COREmanager в игровой индустрии, на примере MMO проекта, в котором решение используется учёта пользователей, управления серверами, аналитики и многих других задач.
Выделенные серверы OVH
Выделенные серверы Hetzner

0 комментариев

Оставить комментарий