Реалізація системи тікетів підтримки
Уявіть: інтернет-магазин із десятками тисяч відвідувачів на день. Запити сиплються на загальну пошту — губляться, дублюються, забуваються. Клієнти чекають відповіді днями. Агенти витрачають години на розбір завалів. Ми вирішуємо цю біль впровадженням професійної тікет-системи, вбудованої прямо у ваш сайт. Тікет-система втричі швидша за email при першій відповіді — це реальні цифри з наших проєктів. Наприклад, для одного інтернет-магазину середній час відповіді впав з 8 годин до 15 хвилин. Економія на підтримці становить до 2000 гривень на місяць на кожні 100 звернень.
Система тікетів організовує звернення користувачів: кожне звернення отримує унікальний номер, статус, пріоритет і відповідального агента. На відміну від live-чату — асинхронне спілкування з повною історією переписки. На відміну від email — прозорий SLA-контроль і звітність.
Чому email не справляється?
Звичайна пошта — це чорна скринька. Немає гарантії, що лист дійде чи потрапить потрібній людині. Тікет-система втричі скорочує час першої відповіді (згідно з нашими вимірами). Кожен запит фіксується, призначається, ескалюється при порушенні термінів.
Як спроєктувати базу даних для тікетів?
Ключова таблиця tickets містить номер, статус (open, pending, resolved, closed), пріоритет (low, normal, high, urgent) і дати створення/закриття. Окремі таблиці для повідомлень і вкладень.
CREATE TABLE tickets (
id SERIAL PRIMARY KEY,
number VARCHAR(20) NOT NULL UNIQUE, -- TKT-YYYY-XXXXXX
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);
Ми використовуємо індекси за полями (user_id, status, created_at) і (agent_id, status, priority) — це гарантує швидкість вибірки навіть при 100 000+ тікетах. UPDATE повертає true/false за результатом блокування — оптимістичне блокування для запобігання конфліктам.
Laravel: основний API
У TicketController ми реалізували повний CRUD з авторизацією, генерацією номера та призначенням агента.
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(),
]);
}
$agent = $this->assignAgent($ticket);
if ($agent) {
$ticket->update(['agent_id' => $agent->id]);
$agent->notify(new NewTicketAssignedNotification($ticket));
}
auth()->user()->notify(new TicketCreatedNotification($ticket));
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();
}
}
Метод assignAgent обирає агента з найменшим навантаженням у категорії — round-robin з урахуванням поточних відкритих тікетів. Агент отримує сповіщення через NewTicketAssignedNotification. Ми використовуємо Laravel Notifications з чергами через Redis — листи не блокують відповідь.
Як працює автоматичне призначення тікетів?
Зазначимо: коли клієнт створює тікет, система визначає категорію (наприклад, «техпідтримка» або «повернення»), обирає агента з групи з мінімальною кількістю відкритих тікетів. Якщо агент перевищує ліміт (20 тікетів), тікет ескалюється керівнику. Це гарантує рівномірне навантаження та швидку відповідь.
SLA та ескалація
Контроль SLA — це серце нашої системи. Для кожного пріоритету задано таймінг: urgent — 2 години, high — 8, normal — 24, low — 72.
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();
Кожні 15 хвилин кроном запускається перевірка. Якщо тікет перевищив дедлайн — запис позначається як escalated_at = now(), а менеджер отримує TicketEscalatedNotification. Ми налаштували відправку в Telegram через Bot API — миттєва реакція.
React: користувацький портал і панель агента
Фронтенд для клієнта та агента — на React з react-query для кешування й автооновлення кожні 30 секунд.
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,
});
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. Парсер визначає, чи це новий тікет, чи відповідь на існуючий (за номером у темі). Новий — створюється користувач і тікет. Відповідь — додається повідомлення.
class InboundEmailController extends Controller
{
public function receive(Request $request): Response
{
$from = $this->parseEmail($request->sender);
$subject = $request->subject;
$body = $request->stripped_text;
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);
}
}
Порівняння рішень
| Параметр | Тікет-система | Live-чат | |
|---|---|---|---|
| Час першої відповіді | 15 хвилин | 4 години | Миттєво |
| SLA-контроль | Так | Ні | Частково |
| Історія переписки | Повна | Розрізнена | Обмежена |
| Звітність | Детальна | Відсутня | Базова |
| Навантаження на агентів | Рівномірне | Хаотичне | Високе |
Докладніше про наш досвід
Ми впровадили тікет-системи для 20+ e-commerce проєктів з трафіком від 1000 до 500 000 відвідувачів на день. Середній час впровадження — 8 днів. Згідно з внутрішньою статистикою, економія на підтримці становить до 30%.
Строк реалізації
| Завдання | Строк |
|---|---|
| Базова система (створення, відповіді, статуси) | 4–5 днів |
| Користувацький портал (React) | 2–3 дні |
| Панель агента + призначення | 2–3 дні |
| SLA + ескалація + сповіщення | +2–3 дні |
| Email inbound + прийом тікетів поштою | +2–3 дні |
| Повна система | 10–14 днів |
Строки варіюються залежно від складності інтеграцій та обсягу даних.
Що входить у роботу
- Повна документація API (OpenAPI 3.0)
- Вихідний код на GitHub/GitLab з CI/CD
- Налаштування сервера (Docker, Nginx, Composer, Node)
- Навчання команди підтримки роботі з системою
- Гарантія на код — 12 місяців
- Безкоштовна підтримка протягом місяця після запуску
Наш досвід у тікет-системах
Ми реалізували тікет-системи для 20+ e-commerce проєктів. Наш досвід SLA — понад 5 років, середній час впровадження — 8 днів. Отримайте консультацію — оцінимо ваш проєкт за один робочий день. Зв'яжіться з нами, щоб обговорити деталі.







