Розробка сайту туроператора на 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 з 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) отримує всі відповідні коефіцієнти, впорядковані за PRIORITY, та множить послідовно. Пріоритет визначає порядок: сезонний (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",
"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 тижні, якщо немає специфічних вимог до звітності.







