Каждый сценарий проходит один и тот же путь: найти санаторий → выбрать номер → выбрать программу → забронировать. Показано, что фильтруется на каждом шаге, из каких полей АСБ это берётся (file:line) и где сценарии расходятся. Всё сверено с кодом и живыми пробами тест-контура.
Бронируется место (путёвка), не номер. Источник каждого: ДОКА — документация АСБ (read_me.html / letter_help.json, ниже прямая цитата); КОД — наш код; ПРОБА — установлено экспериментом (в доке описания нет).
| Параметр | Уровень | Что значит (цитата доки / вывод пробы) | Источник |
|---|---|---|---|
catcod | категория | «Код категории» | ДОКА |
catbednum | категория | «кол-во основных мест» → наш capacity | ДОКА normalize.py:160 |
catdopnum | категория | «кол-во доп мест» (занимать только детям) → наш dop_capacity | ДОКА normalize.py:162 |
oneplacefl | категория | дока: «Возможность размещения 1 гостя во весь номер, 1-возможно / 0-нет». Проба уточнила операционно: 0 → одно отдельное место продать нельзя (частичная загрузка → -35); 1 → один гость бронируется (по цене места) | ДОКА + ПРОБА |
oneplacedates | категория | «пары дат ограничений по подселению в номер» | ДОКА |
dopplacefl | гость | «Код доп места: 0-осн, 1-доп» | ДОКА |
agecod | гость | «Возраст: 1-Вз, 2-Реб» | ДОКА |
sexcod / countintcod | гость | «Пол: 1-М, 2-Ж» / «код страны ISO, РФ 643» | ДОКА |
concod | программа | «код договора» — квоты раздельны по договору | ДОКА |
queryfl | заказ | «0-заказ (проходит проверку квоты), 1-запрос (не проходит)» | ДОКА |
catplacefl | номер | В доке только "catplacefl": 0 без описания. Установлено пробой: 0 = бронь по местам; 1 = выкуп всего номера под одного (~1.5× цены места; setletter реально забронировал весь номер, refs 3396675/76) | ПРОБА diag_oneplace_enforce.py |
catcod). Категория — это тип номера, а не конкретная комната: класс одинаковых номеров с общей ценой, вместимостью, фото и флагами. Например catcod=6518 = «Двухместный Мансардный без балкона корпус 4» — таких физических номеров в пансионате может быть много, но catcod у них один. Отдельных комнат (с номерами дверей) АСБ не отдаёт — всё считается на категорию: квота (quoteval) = сколько единиц этого типа свободно; цена — по паре (категория × программа); вместимость (catbednum/catdopnum) и oneplacefl — свойства категории. В нашей БД запись Room = категория (room_id = "objid:catcod", напр. 1456:6518), не физическая комната. Бронируешь ты место в категории.Что делает наш код КОД: всегда шлёт catplacefl=0, а oneplacefl в БД не хранит (синк его не читает — слой 1). Отсюда баг сценария ③.
-35 «Booking of one main seat is not available!»Это сырой текст ошибки из ответа АСБ setletter, когда ref = -35. Дословно: «то одно основное место, что ты покупаешь, недоступно».
Ты просишь одно отдельное основное место — частичную загрузку (гостей меньше, чем основных коек категории), и наш код шлёт catplacefl=0 (по местам). АСБ пытается продать тебе единичное основное место и не может.
| Причина (проверено пробами) | Что значит |
|---|---|
категория oneplacefl=0 | отдельное место продавать нельзя в принципе — нужно занять все основные места или выкупить номер |
| нет места-квоты | по этой категории/договору/датам поштучной квоты нет |
-21| Код | Что просил | Что значит |
|---|---|---|
| -21 «Quota is empty» | весь номер / все основные места / выкуп | нет квоты номеро-единиц этой категории |
| -35 «one main seat…» | одно отдельное место (частично) | поштучная продажа места недоступна |
-35 бывает даже когда места есть. На пробе: объект с непустой квотой, но oneplacefl=0 → один гость получает -35, а двое (полная загрузка) или выкуп (catplacefl=1) — бронируются. Значит здесь -35 = «поштучно нельзя», а не «всё занято».Одиночка всегда шлёт catplacefl=0 → на любом номере с oneplacefl=0 ловит -35 (на 115 из 169 объектов — всегда). Обход: занять все основные места (тогда максимум -21, если нет квоты) или выкупить номер catplacefl=1 — он работает независимо от oneplacefl, но наш код его не шлёт (это и есть незакрытый gap сценария ③).
Форма поиска: даты + состав гостей → в URL (rooms=2- / 2-7 / 1-). Каталог фильтруется в services/search.py.
| Фильтры санаториев | Учитывают состав гостей? |
|---|---|
| тип · гео (страна/регион/город) · цена · звёзды · профиль лечения · удобства/питание · болезнь · сортировка | Нет. Число гостей в фильтр санаториев не входит — оно едет в URL для след. шагов. Список санаториев для одного, для пары и для семьи одинаков search.py:110-226 |
type←objtyps, гео←geoobject, stars←objstars) или производные (price_from=min цен, удобства=детект по услугам). Подробно — в слое 2.Внутри номера показываются программы (RoomProgramPicker.tsx:39-58).
| Как отбираются программы номера |
|---|
Доступные = те, у кого есть кэш-цена для этого номера (room.program_prices). Сортировка: профсоюзные — в конец, внутри — по цене «за ночь / место». Цена берётся из синканного кэша prices; наличие по программе (availability{progid}) — по договору concod (слой 3). Учёта числа гостей / oneplacefl при отборе программ нет. |
rooms=2-dopplacefl=0dopplacefl=0| Что проверяется | Результат для 2 взрослых | file:line |
|---|---|---|
| Наличие (3 состояния) | «Нет мест» / «подтвердим при бронировании» / «Выбрать» — по квоте номера | RoomsList.tsx:70-71 |
Вместимость: взрослых ≤ capacity | нужен номер с cap ≥ 2. Двухместные подходят; одноместный (cap=1) → «до 1 взрослых», кнопка «Не подходит по местам» | RoomsList.tsx:76-81 |
Фит-чек здесь работает правильно: верхняя граница вместимости — и есть реальное ограничение для двух взрослых.
setletter, 2 основных места (catplacefl=0) — полная загрузка. oneplacefl не важен.
ref=-21 «Quota is empty». Самый надёжный сценарий.rooms=2-7 (возраст ребёнка)dopplacefl=0dopplacefl=0dopplacefl=1 · agecod=2| Что проверяется | Результат для 2 взр. + ребёнка | file:line |
|---|---|---|
| Наличие | как обычно, по квоте номера | RoomsList.tsx:70-71 |
Вместимость: взрослых ≤ cap, всего ≤ cap+dop | нужен номер с cap ≥ 2 И доп. местом (dop ≥ 1). Двухместный без доп. (dop=0) → «до 2 гостей», не подходит; с доп. — подходит | RoomsList.tsx:76-81 |
| Возраст ребёнка (на брони) | validate_booking: доп. место только ребёнку; возраст на заезд ≤ child_age объекта и = заявленному | booking_jsdata.py:93-142 |
Фит-чек тоже правильный: отсекает номера без доп. места. Ограничение — верхняя граница вместимости.
setletter, 2 осн. места + 1 доп. (ребёнок). АСБ проверяет осн. квоту (2) и доступность доп. места.
rooms=1-dopplacefl=0| Что проверяется | Результат для 1 взрослого | file:line |
|---|---|---|
| Наличие | по квоте номера (не по продаваемости одного места) | RoomsList.tsx:70-71 |
| Вместимость (только верхняя граница) | 1 ≤ cap — проходит в любой номер с cap ≥ 1. Двухместный показывается как «подходит», кнопка «Выбрать» | RoomsList.tsx:76-81 |
Проверка oneplacefl | ОТСУТСТВУЕТ. Двухместный с oneplacefl=0 покажется одиночке доступным. Фронт флага не знает — синк его не сохраняет (слой 1) | — (нигде) |
Здесь фит-чек недостаточен: верхняя граница пройдена, но реальное ограничение — можно ли продать одно место (oneplacefl) — не проверяется.
catplacefl=0 — частичная загрузкаref=-35 «one main seat is not available»oneplacefl=1 (есть у 54 из 169 объектов). На остальных 115 — всегда -35, на любом выбранном номере, даже когда места есть: система нигде на пути не учитывает одноместность, а выкуп (catplacefl=1) мы не шлём. Это и есть кейс «Кристалл Уют».oneplacefl, которого система не знает). В шаге D ① и ② срываются только при отсутствии квоты (-21), а ③ — ещё и на oneplacefl=0 (-35).Детали под сквозным путём: как каталог АСБ попадает в наши таблицы, механика фильтра/показа/расчёта и что после брони.
Крон дёргает read-методы АСБ и раскладывает ответы в Postgres. Оркестратор — services/catalog_sync.py, маппинг — asb/normalize.py.
properties (+ objtyps → type)| Поле АСБ | → колонка | file:line |
|---|---|---|
objid | Property.id (PK); основа room_id/id программы/ключей цен-квот | catalog_sync.py:179 |
objnam / objnot | name / description | normalize.py:123,135 |
objtypid + objtyps.objtypnam | type | normalize.py:123 |
objstars / objphone | stars / phone | normalize.py:134,136 |
hourin / hourout | hour_in / hour_out | normalize.py:137-138 |
child_start_age / child_age | одноимённые (0→NULL) | normalize.py:141-142 |
geoobject.Locality/AdministrativeArea/Country Name | city / region / country | normalize.py:124-131 |
geoobject.Latitude/Longitude | lat / lon | normalize.py:143-144 |
img_link только наличие | есть ли главное фото; URL не хранится | catalog_sync.py:239 |
rooms| Поле АСБ | → колонка | file:line |
|---|---|---|
catcod / catnam / catnot | catcod / name / description | normalize.py:157-159 |
catbednum | capacity — основные койки | normalize.py:160 |
catdopnum | dop_capacity — доп. места | normalize.py:162 |
catpropids / catgalery / imgcount | features+bed / photos / photos_count | normalize.py:150-166 |
programs · prices → prices · quotes → quotes| Поле АСБ | → колонка | file:line |
|---|---|---|
programms.progid/pricelistid/prognam | progid/pricelist_id/name(+is_union) | normalize.py:179-183 |
programms.concod | concod — договор (квоты раздельны) | normalize.py:181 |
programms.minlength/maxlength/datein/dateout | min_nights/max_nights/date_in/date_out | normalize.py:185-188 |
programms.servcods коды→имена | included — имена услуг; коды не хранятся | normalize.py:172-176 |
prices.catcod/pricelistid/datein/price | prices: интервалы→по-дневно, 77777 отброшена | normalize.py:84-115 |
quotes.resdate/catcod/concod/quoteval | quotes: сумма по (catcod,дата,договор); может прийти false | catalog_sync.py:336-349 |
price_from (min цен), is_union (из имени), bed (из features), удобства/питание/профили (детект по текстам), rating/reviews (наши отзывы).| Поле / метод | Почему важно | file:line |
|---|---|---|
categs.oneplacefl | флаг одноместного размещения — не хранится, поэтому система не знает, где можно одному (сценарий ③) | normalize.py:148-167 (нет) |
categs.oneplacedates | даты, когда одноместное разрешено — теряются | normalize.py (нет) |
programms.quotestartdays / jsonrestriction | окно открытия квоты / даты-ограничения — не хранятся (проверяет только АСБ) | (нет) |
check_price_by_object | метод есть, синком не вызывается — нулевой вклад | client.py:113 |
| Фильтр | Логика | Поле-основа (источник) | file:line |
|---|---|---|---|
| Тип | точное совпадение | type ← objtyps | search.py:114 |
| Текст | LIKE имя/город/регион | ← objects/geoobject | search.py:117 |
| Гео | OR по 3 уровням | country/region/city ← geoobject | search.py:125 |
| Цена | ≥/≤, NULL не прячем | price_from ← производное | search.py:136 |
| Звёзды | IN; «1» включает 0 | stars ← objstars | search.py:144 |
| Профиль | OR | детект по текстам | search.py:153 |
| Удобства / пул / питание | AND / OR / OR | детект по услугам АСБ | search.py:168-196 |
| Болезнь | OR через программы | локальный маппинг | search.py:200 |
Счётчики фасетов считаются отдельно (GET /filters) глобально по живым объектам — не сужаются под текущий выбор. Ни один фильтр не зависит от числа гостей.
В АСБ нет отдельной «цены номера» и «цены программы» — одна цена за ночь/место, зависящая от номера И программы → матрица номер×программа.
| Что | Поля / логика | file:line |
|---|---|---|
| Номер (RoomItem) | capacity(=catbednum), dop_capacity(=catdopnum), features, price_from, program_prices{progid}, available, quota_known | properties.py:482-495 |
| Матрица цен | min(price) по (room,pricelist) из кэша prices | properties.py:451-462 |
| Наличие (страница) | по room_id: все ночи qty>0; нет данных → True+quota_known=False | properties.py:378-406 |
Наличие (POST /price) | по concod: availability{progid:bool}; нет данных → True | properties.py:604-624 |
/price) и «от» (/rooms-price) — живой calcsum.calcsumЖивой вызов, queryfl=1 («только расчёт, квоту не проверяет»). По каждому гостю — клиент (первые capacity осн., дальше доп.). Из ответа берём одно поле — price pricing.py:53-58. Запрос: catplacefl=0 (всегда), dopplacefl, agecod, pricelistid=progid.
Поле setletter | Что делаем | file:line |
|---|---|---|
ref (строкой) | int → letcod; ≤0 → бронь отклонена → Order.letcod | booking_jsdata.py:208-215 |
mes | отказ → текст ошибки; успех → № заказа → Order.letregnr | booking_jsdata.py:214-222 |
price | → Order.price | booking_jsdata.py:216-219 |
paysysurl | → Order.paysys_url (основа ссылки оплаты) | booking_jsdata.py:224 |
paysysid не храним | парсится, но в Order не сохраняется | booking_jsdata.py:225 |
Оплата: режим real → всегда paysysurl; stub → paysysurl или наша /pay/{token}. Статус GET /bookings/{token} читает наш Order (не АСБ letters). Документы: url_let_docs → ref-ссылка (http) → Order.docs_url. Фронт: «Забронировано» → опрос 3с → «Оплачено» + «Скачать документы».
setletter| ref | Смысл | Когда |
|---|---|---|
| >0 | заказ создан (letcod, №, price, paysysurl) | места есть |
| -2 | дата заезда в прошлом | datein < сегодня |
| -21 | Quota is empty — нет квоты на весь номер | полная загрузка / выкуп, а мест нет |
| -35 | one main seat — нельзя продать отдельное место | частично при oneplacefl=0 или нет места-квоты |
| -36 | время заезда ≠ времени программы | datein-время не совпало |