Реализация системы тикетов поддержки на сайте

Наша компания занимается разработкой, поддержкой и обслуживанием сайтов любой сложности. От простых одностраничных сайтов до масштабных кластерных систем построенных на микро сервисах. Опыт разработчиков подтвержден сертификатами от вендоров.
Разработка и обслуживание любых видов сайтов:
Информационные сайты или веб-приложения
Сайты визитки, landing page, корпоративные сайты, онлайн каталоги, квиз, промо-сайты, блоги, новостные ресурсы, информационные порталы, форумы, агрегаторы
Сайты или веб-приложения электронной коммерции
Интернет-магазины, B2B-порталы, маркетплейсы, онлайн-обменники, кэшбэк-сайты, биржи, дропшиппинг-платформы, парсеры товаров
Веб-приложения для управления бизнес-процессами
CRM-системы, ERP-системы, корпоративные порталы, системы управления производством, парсеры информации
Сайты или веб-приложения электронных услуг
Доски объявлений, онлайн-школы, онлайн-кинотеатры, конструкторы сайтов, порталы предоставления электронных услуг, видеохостинги, тематические порталы

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

Предлагаемые услуги
Показано 1 из 1 услугВсе 2065 услуг
Реализация системы тикетов поддержки на сайте
Сложная
~2-4 недели
Часто задаваемые вопросы
Наши компетенции:
Этапы разработки
Последние работы
  • image_website-b2b-advance_0.png
    Разработка сайта компании B2B ADVANCE
    1214
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1161
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    852
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1041
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    823
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Разработка веб-сайта для компании ФИКСПЕР
    815

Реализация системы тикетов поддержки

Система тикетов организует обращения пользователей: каждое обращение получает номер, статус, приоритет, ответственного агента. В отличие от live-чата — асинхронное общение с историей переписки.

Структура базы данных

CREATE TABLE tickets (
    id          SERIAL PRIMARY KEY,
    number      VARCHAR(20)  NOT NULL UNIQUE,  -- TKT-2024-001234
    user_id     INTEGER REFERENCES users(id),
    agent_id    INTEGER REFERENCES users(id),
    subject     VARCHAR(255) NOT NULL,
    status      VARCHAR(20)  NOT NULL DEFAULT 'open',  -- open|pending|resolved|closed
    priority    VARCHAR(20)  NOT NULL DEFAULT 'normal', -- low|normal|high|urgent
    category    VARCHAR(100),
    channel     VARCHAR(20)  NOT NULL DEFAULT 'web',   -- web|email|api
    created_at  TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
    resolved_at TIMESTAMPTZ,
    closed_at   TIMESTAMPTZ
);

CREATE TABLE ticket_messages (
    id         SERIAL PRIMARY KEY,
    ticket_id  INTEGER  NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
    user_id    INTEGER  REFERENCES users(id),
    body       TEXT     NOT NULL,
    is_private BOOLEAN  NOT NULL DEFAULT false,  -- внутренние заметки агентов
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE ticket_attachments (
    id         SERIAL PRIMARY KEY,
    message_id INTEGER NOT NULL REFERENCES ticket_messages(id) ON DELETE CASCADE,
    filename   VARCHAR(255) NOT NULL,
    s3_key     TEXT         NOT NULL,
    size       INTEGER      NOT NULL
);

CREATE INDEX ON tickets(user_id, status, created_at DESC);
CREATE INDEX ON tickets(agent_id, status, priority);
CREATE INDEX ON ticket_messages(ticket_id, created_at);

Laravel: основной API

class TicketController extends Controller
{
    // Создать обращение
    public function store(StoreTicketRequest $request): JsonResponse
    {
        $ticket = Ticket::create([
            'number'   => $this->generateNumber(),
            'user_id'  => auth()->id(),
            'subject'  => $request->subject,
            'priority' => $request->priority ?? 'normal',
            'category' => $request->category,
            'status'   => 'open',
            'channel'  => 'web',
        ]);

        // Первое сообщение — описание проблемы
        $message = $ticket->messages()->create([
            'user_id' => auth()->id(),
            'body'    => $request->body,
        ]);

        // Загрузить вложения
        foreach ($request->file('attachments', []) as $file) {
            $key = Storage::disk('s3')->putFile("tickets/{$ticket->id}", $file);
            $message->attachments()->create([
                'filename' => $file->getClientOriginalName(),
                's3_key'  => $key,
                'size'    => $file->getSize(),
            ]);
        }

        // Назначить агента по категории или round-robin
        $agent = $this->assignAgent($ticket);
        if ($agent) {
            $ticket->update(['agent_id' => $agent->id]);
            $agent->notify(new NewTicketAssignedNotification($ticket));
        }

        // Уведомить пользователя
        auth()->user()->notify(new TicketCreatedNotification($ticket));

        // Уведомить superadmin о новом тикете
        event(new TicketCreatedEvent($ticket));

        return response()->json(TicketResource::make($ticket->load('messages')), 201);
    }

    // Ответить на тикет
    public function reply(Request $request, Ticket $ticket): JsonResponse
    {
        $this->authorize('reply', $ticket);

        $request->validate(['body' => 'required|string|max:10000']);

        $isAgent = auth()->user()->hasRole('support');

        $message = $ticket->messages()->create([
            'user_id'    => auth()->id(),
            'body'       => $request->body,
            'is_private' => $request->boolean('is_private') && $isAgent,
        ]);

        // Обновить статус тикета
        if ($isAgent) {
            $ticket->update(['status' => 'pending', 'agent_id' => auth()->id()]);
            // Уведомить пользователя о ответе
            $ticket->user->notify(new TicketReplyNotification($ticket, $message));
        } else {
            $ticket->update(['status' => 'open']);
            // Уведомить агента
            $ticket->agent?->notify(new TicketUserReplyNotification($ticket));
        }

        return response()->json(TicketMessageResource::make($message), 201);
    }

    // Закрыть тикет
    public function resolve(Ticket $ticket): JsonResponse
    {
        $this->authorize('resolve', $ticket);

        $ticket->update([
            'status'      => 'resolved',
            'resolved_at' => now(),
            'agent_id'    => auth()->id(),
        ]);

        $ticket->user->notify(new TicketResolvedNotification($ticket));

        return response()->json(['status' => 'resolved']);
    }

    private function generateNumber(): string
    {
        $year  = now()->year;
        $count = Ticket::whereYear('created_at', $year)->count() + 1;
        return sprintf('TKT-%d-%06d', $year, $count);
    }

    private function assignAgent(Ticket $ticket): ?User
    {
        // Назначить агента с наименьшей нагрузкой в категории
        return User::role('support')
            ->where('is_available', true)
            ->withCount(['tickets' => fn($q) => $q->whereIn('status', ['open', 'pending'])])
            ->orderBy('tickets_count')
            ->first();
    }
}

SLA и эскалация

class TicketSlaService
{
    const SLA_HOURS = [
        'urgent' => 2,
        'high'   => 8,
        'normal' => 24,
        'low'    => 72,
    ];

    public function checkEscalations(): void
    {
        Ticket::whereIn('status', ['open', 'pending'])
            ->get()
            ->each(function (Ticket $ticket) {
                $slaHours = self::SLA_HOURS[$ticket->priority];
                $deadline = $ticket->created_at->addHours($slaHours);

                if (now()->gt($deadline) && !$ticket->escalated_at) {
                    $ticket->update(['escalated_at' => now()]);

                    // Уведомить руководителя
                    User::role('support-manager')->get()
                        ->each(fn($m) => $m->notify(new TicketEscalatedNotification($ticket)));
                }
            });
    }
}

// В schedule
$schedule->call(fn() => app(TicketSlaService::class)->checkEscalations())->everyFifteenMinutes();

React: пользовательский портал

function TicketPortal() {
  const { data: tickets } = useQuery({ queryKey: ['tickets'], queryFn: () => api.get('/api/tickets') });

  return (
    <div>
      <header>
        <h1>Мои обращения</h1>
        <a href="/tickets/create" className="btn btn--primary">Создать обращение</a>
      </header>

      <table>
        <thead>
          <tr>
            <th>Номер</th><th>Тема</th><th>Статус</th><th>Приоритет</th><th>Дата</th>
          </tr>
        </thead>
        <tbody>
          {tickets?.data.map(ticket => (
            <tr key={ticket.id}>
              <td><a href={`/tickets/${ticket.id}`}>{ticket.number}</a></td>
              <td>{ticket.subject}</td>
              <td><TicketStatusBadge status={ticket.status} /></td>
              <td><PriorityBadge priority={ticket.priority} /></td>
              <td>{formatDate(ticket.created_at)}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

function TicketStatusBadge({ status }: { status: string }) {
  const colors: Record<string, string> = {
    open:     'bg-blue-100 text-blue-800',
    pending:  'bg-yellow-100 text-yellow-800',
    resolved: 'bg-green-100 text-green-800',
    closed:   'bg-gray-100 text-gray-600',
  };

  const labels: Record<string, string> = {
    open:     'Открыт',
    pending:  'Ожидание',
    resolved: 'Решён',
    closed:   'Закрыт',
  };

  return (
    <span className={`badge ${colors[status]}`}>{labels[status] ?? status}</span>
  );
}

Панель агента поддержки

function AgentDashboard() {
  const { data } = useQuery({
    queryKey: ['agent-tickets'],
    queryFn: () => api.get('/api/agent/tickets?status=open,pending&sort=priority'),
    refetchInterval: 30000,  // обновлять каждые 30 секунд
  });

  return (
    <div className="agent-dashboard">
      <div className="stats">
        <StatCard label="Открытых" value={data?.stats.open} />
        <StatCard label="Просроченных" value={data?.stats.overdue} color="red" />
        <StatCard label="Решено сегодня" value={data?.stats.resolved_today} color="green" />
      </div>

      <div className="ticket-queue">
        {data?.tickets.map(ticket => (
          <TicketCard key={ticket.id} ticket={ticket} />
        ))}
      </div>
    </div>
  );
}

Получение тикетов по email

// Парсинг входящей почты через Mailgun Inbound или similar
class InboundEmailController extends Controller
{
    public function receive(Request $request): Response
    {
        $from    = $this->parseEmail($request->sender);
        $subject = $request->subject;
        $body    = $request->stripped_text;  // Mailgun

        // Найти существующий тикет по subject (Re: TKT-2024-001234)
        preg_match('/TKT-\d{4}-\d{6}/', $subject, $matches);

        if ($matches) {
            $ticket = Ticket::where('number', $matches[0])->first();
            $ticket?->messages()->create(['body' => $body, 'user_id' => $ticket->user_id]);
        } else {
            // Новый тикет
            $user = User::firstOrCreate(['email' => $from['email']], ['name' => $from['name']]);
            // ... создать тикет
        }

        return response('OK', 200);
    }
}

Срок реализации

Задача Срок
Базовая система (создание, ответы, статусы) 4–5 дней
Пользовательский портал (React) 2–3 дня
Панель агента + назначение 2–3 дня
SLA + эскалация + уведомления +2–3 дня
Email inbound + приём тикетов по почте +2–3 дня
Полная система 10–14 дней