📋 Что получится
- Электронная очередь без обновления страницы
- "Ваш номер: 45, сейчас обслуживается: 42"
- Уведомление "Ваша очередь через 2 человека!"
- Push когда подошла очередь
- Табло для операторов
Применение: Банки, МФЦ, клиники, автосервисы, любые офлайн-точки с очередью.
🏗️ Архитектура
1
Клиент берёт талон (на терминале или в приложении)
↓
2
Получает номер и ссылку/QR для отслеживания
↓
3
Оператор вызывает следующего
↓
4
Pushler уведомляет всех в очереди
🗄️ База данных
SQL
-- Точки обслуживания (офисы)
CREATE TABLE service_points (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
code VARCHAR(10) UNIQUE NOT NULL -- Например: 'MFC-01'
);
-- Окна обслуживания
CREATE TABLE service_windows (
id INT PRIMARY KEY AUTO_INCREMENT,
point_id INT NOT NULL,
number INT NOT NULL, -- Номер окна: 1, 2, 3...
operator_id BIGINT NULL,
status ENUM('open', 'closed', 'busy') DEFAULT 'closed',
FOREIGN KEY (point_id) REFERENCES service_points(id)
);
-- Талоны в очереди
CREATE TABLE queue_tickets (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
point_id INT NOT NULL,
ticket_number INT NOT NULL, -- Номер талона: A001, A002...
prefix CHAR(1) DEFAULT 'A', -- Префикс для разных услуг
phone VARCHAR(20), -- Для SMS/Push
push_token VARCHAR(255), -- Для web push
status ENUM('waiting', 'called', 'serving', 'completed', 'cancelled') DEFAULT 'waiting',
window_id INT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
called_at TIMESTAMP NULL,
completed_at TIMESTAMP NULL,
FOREIGN KEY (point_id) REFERENCES service_points(id),
INDEX idx_point_status (point_id, status, ticket_number)
);
-- Счётчик талонов (сбрасывается ежедневно)
CREATE TABLE queue_counters (
point_id INT PRIMARY KEY,
prefix CHAR(1) NOT NULL,
current_number INT DEFAULT 0,
date DATE NOT NULL,
FOREIGN KEY (point_id) REFERENCES service_points(id)
);
💻 Код сервера
Получение талона
PHP
<?php
// get_ticket.php — взять талон в очередь
require_once 'vendor/autoload.php';
use PushlerRu\PushlerClient;
$pushler = new PushlerClient('key_ваш_ключ', 'secret_ваш_секрет');
$pointId = (int) $_POST['point_id'];
$phone = $_POST['phone'] ?? null;
$pdo = getDbConnection();
// Получаем следующий номер (атомарно)
$pdo->beginTransaction();
$stmt = $pdo->prepare('
SELECT current_number, prefix
FROM queue_counters
WHERE point_id = ? AND date = CURDATE()
FOR UPDATE
');
$stmt->execute([$pointId]);
$counter = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$counter) {
// Новый день — начинаем с 1
$pdo->prepare('
INSERT INTO queue_counters (point_id, prefix, current_number, date)
VALUES (?, "A", 1, CURDATE())
ON DUPLICATE KEY UPDATE current_number = 1, date = CURDATE()
')->execute([$pointId]);
$ticketNumber = 1;
$prefix = 'A';
} else {
$ticketNumber = $counter['current_number'] + 1;
$prefix = $counter['prefix'];
$pdo->prepare('UPDATE queue_counters SET current_number = ? WHERE point_id = ?')
->execute([$ticketNumber, $pointId]);
}
// Создаём талон
$stmt = $pdo->prepare('
INSERT INTO queue_tickets (point_id, ticket_number, prefix, phone)
VALUES (?, ?, ?, ?)
');
$stmt->execute([$pointId, $ticketNumber, $prefix, $phone]);
$ticketId = $pdo->lastInsertId();
$pdo->commit();
// Считаем позицию в очереди
$stmt = $pdo->prepare('
SELECT COUNT(*) FROM queue_tickets
WHERE point_id = ? AND status = "waiting" AND id < ?
');
$stmt->execute([$pointId, $ticketId]);
$position = $stmt->fetchColumn();
$ticketCode = $prefix . str_pad($ticketNumber, 3, '0', STR_PAD_LEFT); // A001
// Broadcast обновления очереди
$pushler->trigger(
"queue-{$pointId}",
'queue:updated',
[
'waiting_count' => getWaitingCount($pdo, $pointId),
'last_ticket' => $ticketCode
]
);
echo json_encode([
'ticket_id' => $ticketId,
'ticket_code' => $ticketCode,
'position' => $position,
'track_url' => "https://queue.example.com/track/{$ticketId}"
]);
Вызов следующего (оператор)
PHP
<?php
// call_next.php — оператор вызывает следующего
require_once 'vendor/autoload.php';
use PushlerRu\PushlerClient;
$pushler = new PushlerClient('key_ваш_ключ', 'secret_ваш_секрет');
$windowId = (int) $_POST['window_id'];
$operatorId = getCurrentUserId();
$pdo = getDbConnection();
// Получаем окно
$stmt = $pdo->prepare('SELECT * FROM service_windows WHERE id = ? AND operator_id = ?');
$stmt->execute([$windowId, $operatorId]);
$window = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$window) {
http_response_code(403);
exit;
}
$pointId = $window['point_id'];
// Завершаем текущего клиента (если есть)
$pdo->prepare('
UPDATE queue_tickets
SET status = "completed", completed_at = NOW()
WHERE window_id = ? AND status = "serving"
')->execute([$windowId]);
// Берём следующего из очереди
$stmt = $pdo->prepare('
SELECT * FROM queue_tickets
WHERE point_id = ? AND status = "waiting"
ORDER BY id ASC
LIMIT 1
FOR UPDATE
');
$stmt->execute([$pointId]);
$nextTicket = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$nextTicket) {
// Очередь пуста
$pdo->prepare('UPDATE service_windows SET status = "open" WHERE id = ?')->execute([$windowId]);
$pushler->trigger("queue-{$pointId}", 'window:free', [
'window' => $window['number']
]);
echo json_encode(['status' => 'empty']);
exit;
}
// Обновляем статус талона
$pdo->prepare('
UPDATE queue_tickets
SET status = "called", window_id = ?, called_at = NOW()
WHERE id = ?
')->execute([$windowId, $nextTicket['id']]);
$pdo->prepare('UPDATE service_windows SET status = "busy" WHERE id = ?')->execute([$windowId]);
$ticketCode = $nextTicket['prefix'] . str_pad($nextTicket['ticket_number'], 3, '0', STR_PAD_LEFT);
// === УВЕДОМЛЕНИЯ ===
// 1. Табло: показываем вызванного
$pushler->trigger(
"queue-{$pointId}",
'ticket:called',
[
'ticket_code' => $ticketCode,
'window' => $window['number'],
'waiting_count' => getWaitingCount($pdo, $pointId)
]
);
// 2. Уведомляем вызванного клиента (персонально)
$pushler->trigger(
"ticket-{$nextTicket['id']}",
'your_turn',
[
'ticket_code' => $ticketCode,
'window' => $window['number'],
'message' => "Ваша очередь! Пройдите к окну {$window['number']}"
]
);
// 3. Уведомляем следующих в очереди об изменении позиции
$stmt = $pdo->prepare('
SELECT id,
(SELECT COUNT(*) FROM queue_tickets t2
WHERE t2.point_id = queue_tickets.point_id
AND t2.status = "waiting" AND t2.id < queue_tickets.id) as position
FROM queue_tickets
WHERE point_id = ? AND status = "waiting"
ORDER BY id ASC
LIMIT 5
');
$stmt->execute([$pointId]);
while ($ticket = $stmt->fetch()) {
$pushler->trigger(
"ticket-{$ticket['id']}",
'position:updated',
[
'position' => $ticket['position'],
'message' => $ticket['position'] <= 2
? "Приготовьтесь! Осталось {$ticket['position']} чел."
: null
]
);
}
echo json_encode([
'ticket_code' => $ticketCode,
'ticket_id' => $nextTicket['id']
]);
Клиент начал обслуживаться
PHP
<?php
// start_serving.php — клиент подошёл к окну
$ticketId = (int) $_POST['ticket_id'];
$windowId = (int) $_POST['window_id'];
$pdo->prepare('
UPDATE queue_tickets SET status = "serving" WHERE id = ? AND window_id = ?
')->execute([$ticketId, $windowId]);
// Уведомляем клиента
$pushler->trigger("ticket-{$ticketId}", 'serving:started', [
'message' => 'Обслуживание началось'
]);
🌐 Код клиента — отслеживание талона
JavaScript
class QueueTracker {
constructor(ticketId, appKey) {
this.ticketId = ticketId;
this.appKey = appKey;
this.position = null;
this.pushler = null;
}
connect() {
this.pushler = new PushlerClient({ appKey: this.appKey });
this.pushler.on('connected', () => {
// Канал моего талона
const ticketChannel = this.pushler.subscribe(`ticket-${this.ticketId}`);
// Моя очередь!
ticketChannel.on('your_turn', (data) => {
this.showYourTurn(data);
this.playSound('your-turn');
this.sendBrowserNotification(data.message);
});
// Позиция изменилась
ticketChannel.on('position:updated', (data) => {
this.updatePosition(data.position);
if (data.message) {
this.showAlert(data.message);
}
});
// Обслуживание началось
ticketChannel.on('serving:started', (data) => {
this.showServing();
});
});
}
updatePosition(position) {
this.position = position;
document.getElementById('position').textContent = position;
document.getElementById('position-text').textContent = this.getPositionText(position);
}
getPositionText(position) {
if (position === 0) return 'Вы следующий!';
if (position === 1) return 'Перед вами 1 человек';
if (position <= 4) return `Перед вами ${position} человека`;
return `Перед вами ${position} человек`;
}
showYourTurn(data) {
const overlay = document.createElement('div');
overlay.className = 'your-turn-overlay';
overlay.innerHTML = `
<div class="your-turn-modal">
<div class="icon">🎉</div>
<h1>Ваша очередь!</h1>
<p>Талон <strong>${data.ticket_code}</strong></p>
<p class="window">Пройдите к окну <strong>${data.window}</strong></p>
</div>
`;
document.body.appendChild(overlay);
}
playSound(type) {
const audio = new Audio(`/sounds/${type}.mp3`);
audio.volume = 1;
audio.play().catch(() => {});
}
sendBrowserNotification(message) {
if (Notification.permission === 'granted') {
new Notification('Электронная очередь', {
body: message,
icon: '/icons/queue.png',
requireInteraction: true
});
}
}
showAlert(message) {
const toast = document.createElement('div');
toast.className = 'queue-toast';
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 5000);
}
showServing() {
document.getElementById('status').textContent = 'Обслуживание';
document.getElementById('status').className = 'status serving';
}
}
// Использование
const tracker = new QueueTracker(12345, 'key_ваш_ключ');
tracker.connect();
// Запрашиваем разрешение на уведомления
Notification.requestPermission();
📺 Табло в зале ожидания
JavaScript
class QueueDisplay {
constructor(pointId, appKey) {
this.pointId = pointId;
this.appKey = appKey;
this.currentCalls = []; // Последние вызовы
}
connect() {
this.pushler = new PushlerClient({ appKey: this.appKey });
this.pushler.on('connected', () => {
const channel = this.pushler.subscribe(`queue-${this.pointId}`);
// Вызван следующий
channel.on('ticket:called', (data) => {
this.announceCall(data);
this.updateDisplay(data);
this.playChime();
});
// Очередь обновилась
channel.on('queue:updated', (data) => {
this.updateWaitingCount(data.waiting_count);
});
});
}
announceCall(data) {
// Голосовое объявление (Web Speech API)
const utterance = new SpeechSynthesisUtterance(
`Талон ${data.ticket_code}, пройдите к окну ${data.window}`
);
utterance.lang = 'ru-RU';
utterance.rate = 0.9;
speechSynthesis.speak(utterance);
}
updateDisplay(data) {
// Добавляем в список вызовов
this.currentCalls.unshift({
ticket: data.ticket_code,
window: data.window,
time: new Date()
});
// Оставляем последние 5
this.currentCalls = this.currentCalls.slice(0, 5);
// Рендерим
const container = document.getElementById('calls-list');
container.innerHTML = this.currentCalls.map((call, i) => `
<div class="call-row ${i === 0 ? 'current' : ''}">
<span class="ticket">${call.ticket}</span>
<span class="arrow">→</span>
<span class="window">Окно ${call.window}</span>
</div>
`).join('');
// Анимация текущего
if (this.currentCalls.length > 0) {
document.getElementById('current-ticket').textContent = this.currentCalls[0].ticket;
document.getElementById('current-window').textContent = this.currentCalls[0].window;
}
}
updateWaitingCount(count) {
document.getElementById('waiting-count').textContent = count;
}
playChime() {
const audio = new Audio('/sounds/chime.mp3');
audio.volume = 0.7;
audio.play();
}
}
// Табло
const display = new QueueDisplay(1, 'key_ваш_ключ');
display.connect();
⚠️ Типичные ошибки
1. Дублирование номеров талонов
PHP
// ❌ Неправильно — race condition
$current = getCounter();
$next = $current + 1;
saveCounter($next);
createTicket($next);
// ✅ Правильно — атомарная операция с блокировкой
$pdo->beginTransaction();
$stmt = $pdo->prepare('SELECT ... FOR UPDATE');
// ...
$pdo->commit();
2. Клиент не получает уведомление
JavaScript
// ❌ Неправильно — канал с авторизацией
pushler.subscribe(`private-ticket-${id}`);
// Посетитель не авторизован!
// ✅ Правильно — публичный канал с уникальным ID
pushler.subscribe(`ticket-${id}`);
// ID талона достаточно уникален
Готовы попробовать?
Создайте бесплатный аккаунт и начните интеграцию за пару минут