הטמעת מערכת כרטוס תמיכה
תארו לעצמכם: חנות מסחר אלקטרוני עם עשרות אלפי מבקרים ביום. פניות נשפכות לתיבת דואר משותפת — הולכות לאיבוד, משוכפלות, נשכחות. לקוחות ממתינים ימים. נציגים מבזבזים שעות במיון הבלגן. אנחנו פותרים את הכאב הזה על ידי הטמעת מערכת כרטוס מקצועית המשולבת ישירות באתר שלכם. מערכת כרטוס מהירה פי 3 מדוא"ל לתגובה ראשונה — אלה מספרים אמיתיים מהפרויקטים שלנו. לדוגמה, עבור חנות מקוונת אחת, זמן התגובה הממוצע ירד מ-8 שעות ל-15 דקות. החיסכון בעלויות התמיכה משמעותי ותלוי בדרישות הספציפיות שלכם.
מערכת הכרטוס מארגנת פניות משתמשים: כל פנייה מקבלת מספר ייחודי, סטטוס, עדיפות ונציג מוקצה. בניגוד לצ'אט חי — תקשורת אסינכרונית עם היסטוריית הודעות מלאה. בניגוד לדוא"ל — בקרת SLA שקופה ודיווח.
למה דוא"ל לא מספיק
דוא"ל רגיל הוא קופסה שחורה. אין ערובה שההודעה תגיע לאדם הנכון. מערכת כרטוס מקצרת את זמן התגובה הראשונה פי 3 (לפי המדידות שלנו). כל פנייה מתועדת, מוקצית ומוסלמת אם מפספסים מועדים.
איך לתכנן את מסד הנתונים לכרטוסים
טבלת הליבה 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);
אנחנו משתמשים באינדקסים על 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) — מה שמבטיח מהירות שאילתות גם עם 100,000+ כרטוסים. (agent_id, status, priority) מחזיר UPDATE בהתבסס על תוצאת הנעילה — נעילה אופטימית למניעת התנגשויות.
Laravel: API ליבה
ב-true/false, יישמנו 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();
}
}מתודת TicketController בוחרת את הנציג עם העומס הנמוך ביותר בקטגוריה — round-robin תוך התחשבות בכרטוסים פתוחים נוכחיים. הנציג מקבל הודעה דרך 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 עם תורים דרך NewTicketAssignedNotification — הודעות דוא"ל לא חוסמות את התגובה.
איך הקצאת כרטוסים אוטומטית עובדת
ראוי לציון: כשהלקוח יוצר כרטוס, המערכת קובעת את הקטגוריה (למשל, "תמיכה טכנית" או "החזרות"), בוחרת נציג מהקבוצה עם מספר הכרטוסים הפתוחים המינימלי. אם נציג חורג מהמגבלה (20 כרטוסים), הכרטוס מוסלם למנהל. זה מבטיח חלוקת עומס שווה ותגובה מהירה.
SLA והסלמה
בקרת SLA היא הלב של המערכת שלנו. לכל עדיפות מוגדר זמן קצוב: דחוף — שעתיים, גבוה — 8, רגיל — 24, נמוך — 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();
משימת cron רצה כל 15 דקות. אם כרטוס חורג מהמועד, הרשומה מסומנת Laravel Notifications, והמנהל מקבל Redis. הגדרנו משלוח דרך Telegram Bot API — תגובה מיידית.
React: פורטל משתמש ולוח נציגים
הפרונטאנד ללקוח ולנציג בנוי עם React תוך שימוש ב-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(); לקאש ורענון אוטומטי כל 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>
);
}פורטל הלקוח מציג את הכרטוסים שלו עם סטטוסים ועדיפויות. לוח הנציגים מציג ספירה של כרטוסים פתוחים, באיחור וסגורים היום, יחד עם תור ממוין לפי עדיפות.
קבלת כרטוסים בדוא"ל
אנחנו משלבים דוא"ל נכנס דרך 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);
}
} השוואת פתרונות
| פרמטר | מערכת כרטוס | דוא"ל | צ'אט חי |
|---|---|---|---|
| זמן תגובה ראשונה | 15 דקות | 4 שעות | מיידי |
| בקרת SLA | כן | לא | חלקית |
| היסטוריית הודעות | מלאה | מפוזרת | מוגבלת |
| דיווח | מפורט | אין | בסיסי |
| עומס נציגים | שווה | כאוטי | גבוה |
עוד על הניסיון שלנו
הטמענו מערכות כרטוס ל-20+ פרויקטים של מסחר אלקטרוני עם תעבורה שנעה בין 1,000 ל-500,000 מבקרים ביום. זמן הטמעה ממוצע — 8 ימים. לפי סטטיסטיקות פנימיות, החיסכון בעלויות התמיכה מגיע עד 30%.
ציר זמן
| משימה | משך |
|---|---|
| מערכת בסיסית (יצירה, תשובה, סטטוסים) | 4–5 ימים |
| פורטל משתמש (React) | 2–3 ימים |
| לוח נציגים + הקצאה | 2–3 ימים |
| SLA + הסלמה + התראות | +2–3 ימים |
| דוא"ל נכנס + קבלת כרטוסים במייל | +2–3 ימים |
| מערכת מלאה | 10–14 ימים |
צירי הזמן משתנים בהתאם למורכבות האינטגרציה ולנפח הנתונים.
מה כלול
- תיעוד API מלא (OpenAPI 3.0)
- קוד מקור ב-GitHub/GitLab w







