📜 ⬆️ ⬇️

Development based on the COREmanager framework. How our partners created a solution for outsourcing technical support



If someone decided to start his own business and wants to provide virtual hosting, start an online service for selling flowers or coffee in a franchise, there are many ready-made tools for organizing work. On the other hand, if you start a business simply, you must be prepared for a large number of competitors in this area. The chance to “burn out” increases.
If you enter some little developed area where there is less competition, then there may not be ready-made tools for automating tasks. Yes, at first you can do everything manually, but when the number of customers grows up, you will have to seriously think about optimizing processes.

When developing an automation tool, you can use the COREmanager framework as the basis, skeleton, product. This will reduce the time spent on coding, as well as allow the use of different programming languages ​​to implement various functions.
')
Under the cut - the details of developing a system for outsourcing technical support by ISPlicense.

ISPlicense is a prime example of a company offering non-standard services in a very competitive business area. They chose the domain of hosting by building a business model to serve existing players and newcomers to the market. The company began with reselling licenses of ISPsystem software products, becoming one of our major partners. After a while, the company's service package was supplemented with the provision of technical support for outsourcing , which turned out to be very popular. Lack of resources, geographical distance from most clients and other reasons led many hosters to think seriously about giving technical support to a third-party organization.

ISPlicense performs outsourcing technical support in two ways:

  1. When working with a hoster, in which technical support is provided free of charge for end users, the time spent on responses and invoicing to the hoster according to the contract are recorded.
  2. If customer support is not free, the cost is based on a price list, where the fee depends on the time spent or on the list of services provided. After completing the request, the client is billed on behalf of the host, ISPlicense receives a percentage of the amount.

Now let's assume how you can put the support process without using automation tools.

At first, it is possible to organize cooperation with customers according to different schemes: someone gives the login password of his employee, someone gets a separate user, someone can connect with BILLmanager. But imagine if the number of hosters hosted has increased so much that the technical support operator is confused in dozens of browser tabs and should at the same time keep track of new messages in tickets.

To prevent this situation, ISPlicense has developed a technical support system based on COREmanager, integrated with BILLmanager and other billing systems. The product is called TicketManager.

Perhaps now it is worth telling a little more about COREmanager . It is written in a C ++ framework, constructor.

Its development began in 2010. COREmanager was created in order to bring the overall functionality of our products into a separate entity and thus ensure the consistency of the components. BILLmanager, ISPmanager, VMmanager, DCImanager and other control panels have become extensions of the “kernel”, which is written by a separate team of the most experienced developers of ISPsystem. As a result, development time was reduced, the probability of bugs appearance decreased, and the speed of work of final products increased.

COREmanager is distributed free of charge, has detailed documentation describing its methods of use and is applicable in the development of tools to solve almost any problem. You can create a menu structure through a web interface or by writing an xml file, and you can use any programming language to implement the event handling mechanism if its interpreter is installed in the operating system.

Therefore, ISPlicense chose COREmanager to create a TicketManager. Yes, you could use ready-made solutions for technical support or solve the problem by writing plug-ins for BILLmanager, but the ISPlicense programmers really wanted to try out what COREmanager could do. :)

After the launch of the outsourcing service, over time, the problems that need to be addressed emerged, and there was a need to develop their technical support system. The following prerequisites and tasks were formed:


The resulting product consists of two parts: the ticket system itself and the processor that is installed in the customer’s billing. There is also an API that allows you to integrate with another technical support system or billing.

Let's get acquainted with the key features of the tech support panel.
The list of tickets is minimalist; tools for fine work with requests are available in the menu of viewing the application.



Let's open any appeal for an example and see what actions you can take with it.




In addition, the window shows all information about the initiator of the ticket. When clicking on the client's id, a transition is made to its billing; when clicking on the server name, a transition is made to the control panel of this server.

Information about the service in connection with which the request was received is also displayed, and information about the server where the service is deployed, including access data, is indicated.

We block the ticket and look at the active elements of the opened response input form.


During the consultation, the end user does not know that an ISPlicense specialist answers his questions; He sees that the conversation goes with the employee of his hosting provider.

The implementation of the product took about 5000 lines of code for the technical support panel and 500 lines each for the modules for integration with BILLmanager and other billing systems.

Those interested can learn the TicketManager API , and below, under the spoilers, the source code of the integration module for BILLmanager.

It turned out that more time was spent on tamping the necessary functionality into a single list than on coding, since the lion's share of the necessary functions was already implemented in COREmanager. Well, the interface also did not need to write, just specify where the buttons should be.

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; } 

The result of the development is a product that made the work of technical support operators more convenient, provided automatic generation of documents, and requests from end users began to be processed faster.

The future plans of ISPlicense include the implementation of a desktop mini-application that will give a signal when a new application is received, and will also allow you to stop and resume the accounting of time spent on one or another time in two clicks.

In conclusion, I want to add that COREmanager has become the basis not only of TicketManager and all our products. On its basis, a library fund accounting system, a service for interacting with translators, a tool for organizing joint trips, a task setting system for testers, and this is only a quarter of the list, is implemented. Due to the fact that modules can be written in any language if you have an interpreter installed on the server, you can write a truly unique product that fits perfectly into your business model.

In one of the following articles we will talk about the use of COREmanager in the gaming industry, using the example of an MMO project, in which the solution is used for user accounting, server management, analytics, and many other tasks.

PS If you want to learn more about COREmanager, then you can use the installation instructions and product documentation .

Source: https://habr.com/ru/post/314886/


All Articles