Возможности Как это работает Тарифы Документация Cookbook Партнёрам Личный кабинет
Бизнес-кейсы
🎫

Электронная очередь

12 мин чтения Средне

Талоны, табло, уведомления "Ваша очередь!". Для банков, МФЦ, клиник.

Queue PHP JavaScript

📋 Что получится

  • Электронная очередь без обновления страницы
  • "Ваш номер: 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 талона достаточно уникален

Готовы попробовать?

Создайте бесплатный аккаунт и начните интеграцию за пару минут