- PVSM.RU - https://www.pvsm.ru -
Если кто-то решил начать своё дело и хочет предоставлять
Если же заходить в какую-то мало освоенную область, где конкуренция меньше, то готовых инструментов для автоматизации задач может и не быть. Да, первое время можно всё делать вручную, однако когда количество клиентов подрастёт, придётся всерьёз задуматься над оптимизацией процессов.
При разработке средства автоматизации можно использовать фреймворк COREmanager как основу, скелет, продукта. Это сократит затраченное на кодирование время, а также позволит применить разные языки программирования для реализации различных функций.
Под катом — подробности разработки системы для аутсорс-техподдержки компанией ISPlicense.
ISPlicense [2] — яркий пример компании, предлагающей нестандартные услуги в очень конкурентной сфере бизнеса. Они выбрали сферу
ISPlicense выполняет аутсорсинговую техподдержку по двум схемам:
Теперь давайте предположим, как можно поставить процесс поддержки без использования инструментов автоматизации.
На первых порах можно организовывать сотрудничество с заказчиками по разным схемам: кто-то даёт логин-пароль своего сотрудника, кто-то заводит отдельного пользователя, к кому-то можно подключиться средствами BILLmanager. Но представьте, если количество обслуживаемых хостеров увеличилось настолько, что оператор техподдержки путается в десятках вкладок браузера и должен при этом отслеживать новые сообщения в тикетах.
Для предупреждения такой ситуации компания ISPlicense разработала на базе COREmanager систему техподдержки, интегрированную с BILLmanager и другими биллингами. Продукт получил название TicketManager.
Пожалуй, теперь стоит рассказать чуть подробнее о COREmanager [4]. Это написанный на C++ фреймворк, конструктор.
Его разработка началась в 2010 году. COREmanager создавался для того, чтобы вынести общую функциональность наших продуктов в отдельную сущность и таким образом обеспечить согласованность компонентов. BILLmanager, ISPmanager, VMmanager, DCImanager и другие панели управления стали расширениями “ядра”, которое пишется отдельной командой из самых опытных разработчиков ISPsystem. В результате сократилось время разработки, уменьшилась вероятность появления багов и поднялась скорость работы конечных продуктов.
COREmanager распространяется бесплатно, имеет подробную документацию, описывающую его методы использования и применим в разработке инструментов для решения практически любой задачи. Создать структуру меню можно через веб-интерфейс или путём написания xml-файла, а для реализации механизма обработки событий допускается использовать любой язык программирования, если его интерпретатор установлен в операционной системе.
Поэтому ISPlicense выбрала COREmanager для создания TicketManager. Да, можно было воспользоваться готовыми решениями для техподдержки или решить задачу путём написания плагинов для BILLmanager, но программистам ISPlicense очень уж хотелось самим опробовать, что может COREmanager. :)
После запуска услуги аутсорсинга, с течением времени оформилась требующая решения проблематика и появилась потребность в разработке своей системы техподдержки. Образовались следующие предпосылки и задачи:
Получившийся продукт состоит из двух частей: непосредственно самой системы тикетов и обработчика, который устанавливается в биллинг клиента. Также есть API, который позволяет выполнить интеграцию с другой системой техподдержки или биллингом.
Познакомимся с ключевыми особенностями панели техподдержки.
Список тикетов выполнен минималистично, инструменты для тонкой работы с заявками доступны в меню просмотра заявки.
Откроем любое обращение для примера и посмотрим, какие действия с ним можно совершить.
Кроме этого, в окне показывается вся информация об инициаторе тикета. При щелчке на id клиента выполняется переход в его биллинг, при щелчке на имя сервера происходит переход в панель управления этим сервером.
Также отображается информация об услуге, в связи с которой поступило обращение, и указывается информация о сервере, где развёрнута услуга, в том числе данные для доступа.
Заблокируем тикет и посмотрим на активные элементы открывшейся формы ввода ответа.
В ходе консультации конечный пользователь не знает, что на его вопросы отвечает специалист ISPlicense; он видит, что разговор идёт с сотрудником его
Реализация продукта заняла примерно 5000 строк кода для панели техподдержки и по 500 строк для модулей интеграции с BILLmanager и другими биллингами.
Желающие могут изучить API TicketManager [5], а ниже под спойлерами — исходный код модуля интеграции для BILLmanager.
Оказалось, что больше времени потратили на подбивку нужной функциональности в единый список, чем на кодирование, поскольку львиная доля нужных функций уже была реализована в COREmanager. Ну и интерфейс тоже не надо было писать, только указать где должны быть кнопки.
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
<?xml version="1.0" encoding="UTF-8"?>
<mgrdata>
<library name="ticketmgri" />
</mgrdata>
#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;
}
#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, "%sn", e.what());
return 1;
}
return 0;
}
Итог разработки — продукт, который сделал работу операторов техподдержки удобнее, обеспечил автоматическое формирование документов, а запросы конечных пользователей стали обрабатываться быстрее.
В дальнейших планах ISPlicense — реализация десктопного мини-приложения, которое будет подавать сигнал при поступлении новой заявки, а также позволит останавливать и возобновлять учёт потраченного на тот или иной тикет времени в два клика.
В завершение хочется добавить, что COREmanager стал основой не только TicketManager и всех наших продуктов. На его основе реализована система учёта библиотечного фонда, сервис для взаимодействия с переводчиками, инструмент организации совместных поездок, система постановки задач для тестировщиков, и это только четверть списка. Благодаря тому, что модули можно писать на любом языке при наличии установленного на сервере интерпретатора, вы можете написать действительно уникальный продукт, идеально вписывающийся в вашу модель бизнеса.
В одной из следующих статей мы расскажем о применении COREmanager в игровой индустрии, на примере MMO проекта, в котором решение используется учёта пользователей, управления серверами, аналитики и многих других задач.
P.S. Если вы хотите подробнее познакомиться с COREmanager, то к вашим услугам инструкция по установке [6] и документация [7] по продукту.
Автор: ISPsystem
Источник [8]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/programmirovanie/208377
Ссылки в тексте:
[1] виртуальный хостинг: https://www.reg.ru/?rlink=reflink-717
[2] ISPlicense: https://www.isplicense.ru/
[3] технической поддержки на аутсорсинге: https://www.isplicense.ru/services/administration/outsource/
[4] COREmanager: https://www.ispsystem.ru/software/coremanager
[5] API TicketManager: https://github.com/bdolgov/ticketmgri/wiki/Client-API
[6] инструкция по установке: https://www.ispsystem.ru/software/coremanager/download
[7] документация: http://doc.ispsystem.ru/index.php/%D0%9A%D0%B0%D1%82%D0%B5%D0%B3%D0%BE%D1%80%D0%B8%D1%8F:COREmanager
[8] Источник: https://habrahabr.ru/post/314886/?utm_source=habrahabr&utm_medium=rss&utm_campaign=best
Нажмите здесь для печати.