Розробка платформи для продажу квитків (Ticketing)

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

Це лише деякі з технічних типів сайтів, з якими ми працюємо, і кожен із них може мати свої специфічні особливості та функціональність, а також бути адаптованим під конкретні потреби та цілі клієнта.

Пропоновані послуги
Показано 1 з 1 послугУсі 2065 послуг
Розробка платформи для продажу квитків (Ticketing)
Складна
від 2 тижнів до 3 місяців
Часті питання
Наші компетенції:
Етапи розробки
Останні роботи
  • image_website-b2b-advance_0.png
    Розробка сайту компанії B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    874
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Розробка веб-сайту для компанії ФІКСПЕР
    851

Розробка тикетингової платформи

Тикетингова платформа — це система з жорсткими вимогами до узгодженості даних. Кількох користувачів одночасно купують останній квиток у ряду A. Race condition у цьому випадку означає місця, продані "двічі" — і повернення грошей, та пошкоджена репутація. Правильна архітектура починається з вибору механізму блокування.

Модель місць та резервування

CREATE TABLE events (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    organizer_id    UUID NOT NULL REFERENCES users(id),
    title           VARCHAR(300) NOT NULL,
    slug            VARCHAR(300) UNIQUE NOT NULL,
    venue_id        UUID REFERENCES venues(id),
    starts_at       TIMESTAMPTZ NOT NULL,
    ends_at         TIMESTAMPTZ,
    status          VARCHAR(20) NOT NULL DEFAULT 'draft'
                    CHECK (status IN ('draft','on_sale','sold_out','cancelled','completed')),
    timezone        VARCHAR(50) NOT NULL DEFAULT 'Europe/Moscow',
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE ticket_types (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    event_id        UUID NOT NULL REFERENCES events(id),
    name            VARCHAR(200) NOT NULL,   -- 'VIP', 'Стандарт', 'Студентський'
    price           NUMERIC(12,2) NOT NULL,
    currency        CHAR(3) NOT NULL DEFAULT 'RUB',
    total_qty       INTEGER NOT NULL,
    reserved_qty    INTEGER NOT NULL DEFAULT 0,
    sold_qty        INTEGER NOT NULL DEFAULT 0,
    sale_starts_at  TIMESTAMPTZ,
    sale_ends_at    TIMESTAMPTZ,
    max_per_order   INTEGER NOT NULL DEFAULT 10,
    CONSTRAINT qty_valid CHECK (sold_qty + reserved_qty <= total_qty)
);

CREATE TABLE seats (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    ticket_type_id  UUID NOT NULL REFERENCES ticket_types(id),
    section         VARCHAR(50),
    row             VARCHAR(10),
    number          VARCHAR(10),
    status          VARCHAR(20) NOT NULL DEFAULT 'available'
                    CHECK (status IN ('available','reserved','sold','blocked')),
    reserved_until  TIMESTAMPTZ,    -- коли закінчується резервування
    order_id        UUID,
    UNIQUE (ticket_type_id, section, row, number)
);

CREATE INDEX idx_seats_available
    ON seats(ticket_type_id, status)
    WHERE status = 'available';

Оптимістичне блокування місць

Наївний SELECT → UPDATE призведе до race condition. Правильний підхід — SELECT FOR UPDATE SKIP LOCKED:

from django.db import transaction
from django.utils import timezone
from datetime import timedelta


def reserve_seats(ticket_type_id: str, qty: int, session_id: str) -> list:
    """
    Зарезервувати N місць на 15 хвилин для сесії покупки.
    Повертає список seat_id або викидає виключення, якщо недостатньо.
    """
    with transaction.atomic():
        # Заблокувати рядки без очікування — інші транзакції пропускають зайняті
        seats = (
            Seat.objects
            .select_for_update(skip_locked=True)
            .filter(
                ticket_type_id=ticket_type_id,
                status='available'
            )
            .order_by('section', 'row', 'number')[:qty]
        )

        if len(seats) < qty:
            raise InsufficientSeatsError(
                f'Доступно {len(seats)} місць, запрошено {qty}'
            )

        seat_ids = [seat.id for seat in seats]
        reserved_until = timezone.now() + timedelta(minutes=15)

        Seat.objects.filter(id__in=seat_ids).update(
            status='reserved',
            reserved_until=reserved_until,
            order_id=None,  # буде заповнено після оплати
        )

        TicketType.objects.filter(id=ticket_type_id).update(
            reserved_qty=F('reserved_qty') + qty
        )

        # Зберегти в Redis для швидкого доступу
        redis_client.setex(
            f'reservation:{session_id}:{ticket_type_id}',
            900,  # 15 хвилин у секундах
            json.dumps(seat_ids)
        )

        return seat_ids

Звільнення просрочених резервувань

@shared_task
def release_expired_reservations():
    """Celery beat: кожні 2 хвилини"""
    now = timezone.now()
    expired_seats = Seat.objects.filter(
        status='reserved',
        reserved_until__lt=now,
        order_id__isnull=True
    )

    seat_ids = list(expired_seats.values_list('id', flat=True))
    if not seat_ids:
        return

    # Групувати за ticket_type для оновлення лічильників
    type_counts = (
        Seat.objects
        .filter(id__in=seat_ids)
        .values('ticket_type_id')
        .annotate(cnt=Count('id'))
    )

    with transaction.atomic():
        Seat.objects.filter(id__in=seat_ids).update(
            status='available',
            reserved_until=None,
        )
        for row in type_counts:
            TicketType.objects.filter(id=row['ticket_type_id']).update(
                reserved_qty=F('reserved_qty') - row['cnt']
            )

    # Сповістити через WebSocket — квитки знову доступні
    for row in type_counts:
        channel_layer.group_send(
            f'event_{row["ticket_type_id"]}',
            {'type': 'seats_released', 'count': row['cnt']}
        )

QR-код квитка та верифікація на вході

import qrcode
import hmac
import hashlib
from io import BytesIO


def generate_ticket_token(ticket_id: str, secret: str) -> str:
    """HMAC-підписаний токен для QR-кода"""
    msg = f'ticket:{ticket_id}'.encode()
    signature = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()
    return f'{ticket_id}:{signature[:16]}'


def verify_ticket_token(token: str, secret: str) -> str | None:
    """Повертає ticket_id, якщо токен валіден, інакше None"""
    parts = token.split(':')
    if len(parts) != 2:
        return None

    ticket_id, provided_sig = parts
    expected_token = generate_ticket_token(ticket_id, secret)
    expected_sig = expected_token.split(':')[1]

    if hmac.compare_digest(provided_sig, expected_sig):
        return ticket_id
    return None


def generate_qr_image(ticket) -> bytes:
    token = generate_ticket_token(str(ticket.id), settings.TICKET_SECRET)
    qr = qrcode.QRCode(version=1, error_correction=qrcode.constants.ERROR_CORRECT_H)
    qr.add_data(f'https://tickets.site.com/verify/{token}')
    qr.make(fit=True)
    img = qr.make_image(fill_color='black', back_color='white')
    buf = BytesIO()
    img.save(buf, format='PNG')
    return buf.getvalue()

API для сканерів на вході:

# POST /api/v1/tickets/verify
def verify_ticket_view(request):
    token = request.data.get('token')
    ticket_id = verify_ticket_token(token, settings.TICKET_SECRET)

    if not ticket_id:
        return Response({'valid': False, 'reason': 'invalid_token'}, status=400)

    ticket = Ticket.objects.select_related('event', 'seat').get(id=ticket_id)

    if ticket.scanned_at:
        return Response({
            'valid': False,
            'reason': 'already_used',
            'scanned_at': ticket.scanned_at.isoformat(),
        }, status=400)

    if ticket.event.starts_at.date() != date.today():
        return Response({'valid': False, 'reason': 'wrong_date'}, status=400)

    ticket.scanned_at = timezone.now()
    ticket.save()

    return Response({
        'valid': True,
        'holder': ticket.holder_name,
        'seat': f'{ticket.seat.section} ряд {ticket.seat.row} місце {ticket.seat.number}',
        'ticket_type': ticket.ticket_type.name,
    })

Динамічне ціноутворення

Ціна зростає зі заповненням залу — стандартна практика для концертів:

def get_current_price(ticket_type) -> Decimal:
    """Динамічна ціна на основі % проданих квитків"""
    sold_ratio = ticket_type.sold_qty / ticket_type.total_qty

    # Пороги заповнення
    tiers = [
        (0.0,  0.0),   # 0–50%: базова ціна
        (0.5,  0.10),  # 50–70%: +10%
        (0.7,  0.25),  # 70–85%: +25%
        (0.85, 0.50),  # 85–95%: +50%
        (0.95, 1.00),  # 95–100%: +100%
    ]

    multiplier = Decimal('1.0')
    for threshold, increase in reversed(tiers):
        if sold_ratio >= threshold:
            multiplier = Decimal(str(1 + increase))
            break

    return (ticket_type.base_price * multiplier).quantize(Decimal('1'))

Схема платіжного потоку

  1. Користувач обирає місця → reserve_seats() → 15-хвилинне тримання
  2. Перейти до оплати → створити Order зі статусом pending
  3. Оплата через YooKassa або Stripe → webhook підтверджує
  4. Order.status = 'paid' → місця переходять на sold → генерувати PDF-квитки
  5. Email з квитками → PDF-вкладення + QR-код

Строк

Базова тикетингова платформа без плану залу (тільки типи квитків, кількість): 4–6 тижнів. Повна система з інтерактивним планом залу (SVG), динамічним ціноутворенням, сканером та панеллю організатора: 3–4 місяці.