Разработка сайта туроператора на 1С-Битрикс
Сайт туроператора отличается от обычного каталога одним свойством: данные в нём живут. Цены меняются каждый день — раннее бронирование, горящие предложения, сезонные коэффициенты. Даты вылетов появляются и исчезают. Наличие мест зависит от внешних систем бронирования, которые отвечают по-разному: одна отдаёт JSON за 200 мс, другая — XML за 8 секунд. Если спроектировать каталог как статический инфоблок с ручным обновлением, через месяц менеджеры перестанут обновлять цены, а клиенты будут бронировать туры по устаревшим данным.
Ключевые технические задачи — поиск с фасетной фильтрацией по нескольким осям, интеграция с внешними API бронирования и динамическое ценообразование. Именно они определяют сложность и сроки проекта.
Каталог туров: инфоблоки и связи
Каталог строится на двух инфоблоках и одном Highload-блоке.
Инфоблок «Направления» — разделы инфоблока туров. Иерархия: «Европа» → «Италия» → «Тоскана». Свойства разделов через UF_* поля: UF_COUNTRY_CODE (ISO 3166-1), UF_CLIMATE_INFO (текст), UF_VISA_REQUIRED (булево), UF_GALLERY (множественный файл). Разделы используются и для фильтрации, и для SEO-страниц: /tours/italy/, /tours/italy/toscana/.
Инфоблок «Туры» (тип tours) — элементы внутри разделов-направлений. Свойства:
-
DURATION— число, количество дней. Фильтрация по диапазону -
DEPARTURE_DATES— множественное свойство типа «Дата». Каждый элемент содержит 5-20 дат вылета. Индекс наb_iblock_element_propertyпоIBLOCK_PROPERTY_ID+VALUE_DATEобязателен — без него фильтрация по датам на каталоге из 2000+ туров деградирует до секунд -
TOUR_TYPE— список: «Экскурсионный», «Пляжный», «Активный», «Круизный», «Комбинированный» -
DIFFICULTY— список от 1 до 5, для активных и горных маршрутов -
GROUP_SIZE_MIN,GROUP_SIZE_MAX— числовые -
INCLUDED_SERVICES— множественная строка: перелёт, трансфер, страховка, экскурсии, питание. Используется в детальной карточке и для фильтра «Что включено» -
BASE_PRICE— число, базовая цена в основной валюте. Фактическая цена рассчитывается динамически -
HOTEL_STARS— список: 2-5 звёзд, «Без размещения» -
BOOKING_SYSTEM_ID— строка, внешний идентификатор тура в системе бронирования (Samo, Sletat) -
GALLERY— множественный файл, фото направления и отеля -
VIDEO_URL— строка, YouTube/Vimeo
Привязка к торговому каталогу через CCatalog::Add() нужна только если бронирование проходит полностью через модуль sale. Если оплата уходит на внешнюю систему (Samo.Tourvisor), каталог Битрикс не подключается — инфоблок работает как витрина.
Highload-блок «Ценовые коэффициенты» (PriceCoefficients) — хранит правила динамического ценообразования. Поля: TOUR_ID, DATE_FROM, DATE_TO, COEFFICIENT (float), RULE_TYPE (список: «Раннее бронирование», «Горящий тур», «Сезонный», «Групповая скидка»), PRIORITY (число, порядок применения). Highload выбран потому, что записей будет тысячи — каждый тур × каждый сезон × каждый тип правила. Выборка через \Bitrix\Highloadblock\HighloadBlockTable::getList() с фильтром по TOUR_ID и текущей дате.
Поиск и фасетная фильтрация — ядро проекта
Поиск тура — это не текстовый поиск. Клиент думает категориями: «Италия, июнь, 7-10 дней, до $2000, экскурсионный». Пять фильтров одновременно, и результат должен появляться без перезагрузки страницы.
Стандартный catalog.smart.filter здесь не работает. Он рассчитан на товары с фиксированными свойствами. У тура дата вылета — множественное свойство, цена — вычисляемая, направление — иерархия разделов. Фасетный индекс (\Bitrix\Iblock\PropertyIndex\Manager::buildIndex()) покрывает только часть сценариев.
Решение — кастомный компонент project:tour.filter с собственной логикой:
Фильтрация по направлению. Иерархический выбор: страна → регион → курорт. При выборе страны фильтр подгружает регионы AJAX-запросом на /api/tours/regions/?country=IT. В component.php — CIBlockSection::GetList() с фильтром по SECTION_ID родителя. Кеширование через CPHPCache с тегом iblock_id_N.
Фильтрация по датам вылета. Клиент выбирает диапазон — например, «с 1 по 30 июня». В БД даты хранятся как множественные значения свойства. Запрос:
$filter = [
'>=PROPERTY_DEPARTURE_DATES' => '2025-06-01',
'<=PROPERTY_DEPARTURE_DATES' => '2025-06-30',
];
Проблема: стандартный CIBlockElement::GetList() с таким фильтром по множественному свойству работает медленно на больших объёмах. Решение — промежуточная таблица b_tour_departure_index, заполняемая агентом при обновлении тура. Таблица: TOUR_ID, DEPARTURE_DATE (DATE, с индексом). Фильтрация идёт JOIN'ом на эту таблицу, результат — массив TOUR_ID, который подставляется в CIBlockElement::GetList(['ID' => $tourIds]).
Фильтрация по цене. Цена вычисляется в момент запроса: BASE_PRICE × коэффициент сезона × коэффициент раннего бронирования. Прямой фильтр по вычисляемому значению невозможен. Два варианта:
-
Материализованная цена — агент пересчитывает актуальную цену каждого тура раз в час и записывает в свойство
CURRENT_PRICE. Фильтрация по нему стандартная. Минус — задержка до часа между изменением коэффициента и обновлением цены. - Двухэтапная фильтрация — сначала выбираются туры по всем остальным фильтрам (направление, даты, длительность), затем для каждого из них вычисляется цена и отсекаются не попадающие в диапазон. Работает точно, но при каталоге в 5000 туров второй этап может занять 200-500 мс. Решается кешированием вычисленных цен в Redis/Memcached с TTL 15 минут.
На практике выбирают первый вариант — клиент видит цену с погрешностью до часа, но фильтр работает мгновенно. Точная цена показывается на детальной странице тура и при оформлении заказа.
AJAX-фильтрация. Все фильтры отправляются одним GET-запросом: /api/tours/search/?destination=IT&date_from=2025-06-01&date_to=2025-06-30&duration_min=7&duration_max=10&price_max=2000&type=excursion. Контроллер в local/modules/project.tours/lib/controller/search.php наследует \Bitrix\Main\Engine\Controller, валидирует параметры, собирает фильтр CIBlockElement::GetList(), возвращает JSON с массивом туров и метаданными фасетов (сколько туров по каждому типу при текущих фильтрах).
Фасеты — счётчики рядом с каждым значением фильтра («Экскурсионный (23)», «Пляжный (45)»). Вычисляются отдельными запросами COUNT(*) по каждому свойству с остальными фильтрами. Это 4-6 дополнительных запросов, каждый — лёгкий при наличии индексов. Результат кешируется на 5 минут.
Интеграция с системами бронирования
Туроператор редко продаёт только собственные туры. Сайт агрегирует предложения из нескольких источников: собственные туры (в инфоблоке), пакетные туры из Samo.Tourvisor и предложения из агрегатора Sletat.ru.
Samo.Tourvisor API — RESTful JSON. Основные эндпоинты:
-
GET /api/search— поиск туров по параметрам (страна, курорт, дата, ночи, взрослые/дети). Ответ — массив предложений с ценой, отелем, датой вылета, оператором -
GET /api/hotel/{id}— детали отеля: фото, описание, координаты -
POST /api/order— создание заявки на бронирование
Интеграция реализуется через модуль local/modules/project.tourvisor/. Класс \Project\Tourvisor\Client оборачивает HTTP-запросы через \Bitrix\Main\Web\HttpClient. Метод search() принимает параметры фильтра сайта, маппит их в формат API, выполняет запрос, парсит ответ.
Критичный момент — время ответа. Samo.Tourvisor отвечает за 2-8 секунд в зависимости от количества операторов. Пользователь не должен ждать:
- Первая загрузка страницы поиска показывает результаты из локального инфоблока (собственные туры) — мгновенно
- Параллельно фронтенд отправляет AJAX-запрос на
/api/tourvisor/search/ - Бэкенд запрашивает Tourvisor API, кеширует результат в Redis с TTL 30 минут
- Ответ подгружается на страницу, объединяется с локальными результатами, сортируется по цене
Sletat.ru API — XML/SOAP. Старый протокол, но огромная база туров от сотен операторов. Основной метод — GetTours() с параметрами поиска. Ответ — XML, парсинг через SimpleXMLElement. Время ответа — 5-15 секунд.
Стратегия та же: асинхронная загрузка. Но у Sletat есть особенность — RequestId. Первый запрос возвращает RequestId, по которому нужно опрашивать второй эндпоинт GetSearchResult() каждые 2-3 секунды, пока статус не станет Completed. Это реализуется через polling на фронтенде:
async function pollSletat(requestId) {
const response = await fetch(`/api/sletat/result/?request_id=${requestId}`);
const data = await response.json();
if (data.status === 'completed') {
renderResults(data.tours);
} else {
setTimeout(() => pollSletat(requestId), 3000);
}
}
Объединение результатов из разных источников. На фронте — единый список с отметкой источника. Каждый результат содержит source (local / tourvisor / sletat), external_id, price, currency. Сортировка по цене требует конвертации валют через курс ЦБ, хранящийся в Highload-блоке CurrencyRates и обновляемый агентом раз в день.
Динамическое ценообразование
Три уровня ценообразования:
-
Сезонные коэффициенты — высокий сезон ×1.3, низкий ×0.8. Хранятся в Highload-блоке
PriceCoefficientsс диапазоном дат -
Раннее бронирование — скидка 10-20% при бронировании за 60+ дней до вылета. Правило: если
DEPARTURE_DATE - TODAY > 60, применить коэффициент 0.85 -
Горящие туры — скидка 15-40% за 3-7 дней до вылета при незаполненной группе. Коэффициент зависит от процента заполнения:
GROUP_FILLED < 50%→ 0.6
Расчёт цены в \Project\Tours\PriceCalculator::calculate($tourId, $departureDate):
$basePrice = $tour['BASE_PRICE'];
$coefficients = HighloadBlockTable::getList([
'filter' => [
'TOUR_ID' => $tourId,
'<=DATE_FROM' => $departureDate,
'>=DATE_TO' => $departureDate,
],
'order' => ['PRIORITY' => 'ASC'],
])->fetchAll();
$finalPrice = $basePrice;
foreach ($coefficients as $c) {
$finalPrice *= $c['COEFFICIENT'];
}
Приоритет определяет порядок применения. Сезонный коэффициент (приоритет 1) применяется первым, затем раннее бронирование (приоритет 2), затем горящее (приоритет 3). Правила не конфликтуют: раннее бронирование и горящий тур взаимоисключающи по определению.
Бронирование и частичная оплата
Поток оформления заказа через модуль sale:
- Клиент выбирает тур, дату вылета, количество участников
- Формирование заказа:
\Bitrix\Sale\Order::create(), корзина с одним товаром (тур), свойства заказа — данные пассажиров (ФИО, паспортные данные, дата рождения) - Частичная оплата — через правило доставки или кастомный обработчик. Первый платёж — 30-50% от стоимости. Второй — за 30 дней до вылета
- Реализация частичной оплаты: два платежа в заказе (
\Bitrix\Sale\Payment), первый — со статусом «К оплате», второй — «Отложен». ОбработчикOnSalePaymentEntitySavedпроверяет, оба ли платежа оплачены. Агент за 30 дней до вылета переводит второй платёж в статус «К оплате» и отправляет email с напоминанием
Документооборот: визы и страховки
Раздел «Документы» — отдельный инфоблок или Highload-блок с визовыми требованиями по странам. Поля: COUNTRY_CODE, VISA_TYPE (список: «Не требуется», «По прибытии», «В посольстве», «Электронная»), PROCESSING_DAYS, DOCUMENTS_LIST (текст), NOTES. На детальной странице тура автоматически выводится блок с визовой информацией по стране назначения — подтягивается по UF_COUNTRY_CODE раздела.
Страховка — обязательный допродаж. Реализуется как связанный товар в каталоге или через компонент project:tour.insurance с калькулятором стоимости по длительности и направлению.
Галереи и отзывы
Фотогалерея направления — множественное свойство UF_GALLERY раздела. Lightbox-просмотр через bx.lightbox или кастомный Swiper.js. Видео — свойство VIDEO_URL элемента, встраивается через iframe с lazy loading.
Отзывы — отдельный инфоблок reviews. Свойства: TOUR_ID (привязка), AUTHOR_ID (привязка к пользователю), RATING (число 1-5), TRAVEL_DATE, PHOTOS (множественный файл). Модерация — через workflow: новый отзыв сохраняется со статусом «На модерации», публикуется после проверки в админке. Средний рейтинг тура вычисляется агентом и записывается в свойство AVG_RATING.
B2B-портал для агентов
Отдельная группа пользователей agents с правами доступа через модуль main. Агент видит:
- Оптовые цены — рассчитываются отдельным типом цены в каталоге (
WHOLESALE) или отдельным коэффициентом в Highload-блоке - Комиссию по каждому бронированию — свойство заказа
AGENT_COMMISSION, вычисляемое процентом от стоимости тура - Отчёты по продажам — кастомный компонент с выборкой из
b_sale_orderпоUSER_IDагента
Авторизация — стандартная через main.auth, но с редиректом на /agents/ (отдельный раздел с шаблоном agent_cabinet). Регистрация агента — по заявке, подтверждаемой администратором.
SEO: Schema.org и гео-страницы
На детальной странице тура — JSON-LD разметка TouristTrip:
{
"@context": "https://schema.org",
"@type": "TouristTrip",
"name": "Тоскана: винные маршруты",
"touristType": "Cultural",
"itinerary": {
"@type": "ItemList",
"itemListElement": [...]
},
"offers": {
"@type": "AggregateOffer",
"lowPrice": "1200",
"highPrice": "1800",
"priceCurrency": "USD"
},
"subjectOf": {
"@type": "TravelAction",
"fromLocation": {"@type": "City", "name": "Москва"},
"toLocation": {"@type": "City", "name": "Флоренция"}
}
}
Формируется в result_modifier.php, выводится через $APPLICATION->AddHeadString(). AggregateOffer показывает диапазон цен по всем датам вылета — Google отображает его в сниппете.
Гео-страницы /tours/italy/, /tours/turkey/antalya/ — разделы инфоблока с уникальными UF_SEO_TITLE, UF_SEO_DESCRIPTION, UF_SEO_TEXT. Каждая страница — SEO-оптимизированный каталог с фильтрами, привязанными к данному направлению.
Этапы и сроки
| Масштаб проекта | Ориентировочные сроки |
|---|---|
| Витрина собственных туров без онлайн-бронирования | 3-5 недель |
| Каталог с фильтрацией, бронирование, одна интеграция (Tourvisor) | 6-10 недель |
| Полная платформа: несколько API, B2B-портал, динамические цены | 10-14 недель |
Основное время уходит на поисковый движок и интеграции. Каталог и шаблоны — типовая работа, 2-3 недели. Кастомный фильтр с фасетами — 2 недели. Интеграция с каждым внешним API — 1-2 недели на подключение, плюс неделя на тестирование граничных случаев (таймауты, изменение формата ответа, недоступность сервиса). B2B-портал — 2-3 недели, если нет специфичных требований к отчётности.







