# Техническое задание: сервис планирования мультимодальных поездок

**Версия:** 0.1 (черновик для старта разработки)
**Язык реализации:** Go
**Дата:** 13.08.2026

---

## 1. Концепция продукта

### 1.1 Проблема

Существующие агрегаторы (Aviasales, Туту.ру и др.) в основном монотранспортны (только самолёты или только поезда) и ограничивают глубину поиска 1–2 пересадками из соображений UX и производительности. В результате пользователь не видит:

- маршруты, комбинирующие разные виды транспорта (самолёт + поезд + автобус);
- маршруты через альтернативные (соседние) узлы, когда основной недоступен (например, аэропорт закрыт на ремонт);
- маршруты глубже 2 пересадок, даже если они дешевле или единственно возможны.

### 1.2 Отличительные особенности (УТП)

1. **Мультимодальный поиск** — единый граф из самолётов, поездов, электричек, автобусов на основе данных Яндекс.Расписаний.
2. **Произвольная глубина пересадок** — не ограничиваемся 1–2 пересадками, ищем по всему разумному пространству вариантов.
3. **Автоматическое использование соседних аэропортов/вокзалов**, включая автодетект закрытия основного узла.
4. **Визуализация всего маршрута на карте** — сквозная линия пути со всеми сегментами и пересадками.

### 1.3 Источник данных

API Яндекс.Расписаний (`/stations_list`, `/nearest_stations`, `/search`, `/schedule`, `/thread`). Ключевое ограничение: API **не отдаёт полный дамп расписания сети**, только ответы по конкретным запросам (точка А → точка Б на дату, или все рейсы через станцию). Это существенно влияет на архитектуру построения графа (см. раздел 7.2).

---

## 2. Технологический стек

| Компонент | Технология | Обоснование |
|---|---|---|
| Backend | Go | требование заказчика |
| Основная БД | PostgreSQL | справочники, история статусов станций, конфигурация |
| Кэш / очереди TTL | Redis | нативный TTL, низкая латентность на горячих ключах |
| Хранение геометрии маршрутов | PostGIS (опционально) или обычные lat/lon + GeoJSON на лету | для карты достаточно генерировать GeoJSON без полноценного GIS |
| Фронтенд карты | Leaflet + OpenStreetMap тайлы | без vendor lock-in на платные карто-провайдеры |
| Планировщик задач | cron внутри `cmd/cron` или системный cron + отдельный бинарник | обновление справочников и детект статусов станций |

---

## 3. Архитектура системы

```
                         ┌───────────────────┐
                         │   Yandex Schedules  │
                         │        API          │
                         └─────────┬───────────┘
                                   │
                     ┌─────────────▼─────────────┐
                     │     internal/yandex        │
                     │  клиент, rate limiter,     │
                     │  retry, circuit breaker    │
                     └─────────────┬─────────────┘
                                   │
        ┌──────────────────────────┼──────────────────────────┐
        │                          │                          │
┌───────▼────────┐       ┌─────────▼─────────┐      ┌─────────▼─────────┐
│  internal/cache │       │  internal/storage  │      │ internal/airports │
│  Redis (TTL)     │◄─────┤  Postgres репо      │      │ соседние аэропорты,│
│                  │       │  cities/stations/   │      │ автодетект закрытия│
└──────────────────┘       │  transfer_rules     │      └─────────┬─────────┘
                            └─────────┬─────────┘                │
                                      │                            │
                            ┌─────────▼────────────────────────────▼─────────┐
                            │              internal/routing                   │
                            │   граф, лениво расширяемый; RAPTOR-подобный      │
                            │   поиск с ограничением глубины; MCT-логика       │
                            └─────────────────────┬────────────────────────────┘
                                                   │
                                         ┌─────────▼─────────┐
                                         │     cmd/api         │
                                         │  HTTP-хендлеры,     │
                                         │  сборка GeoJSON      │
                                         └─────────────────────┘
```

Структура репозитория:

```
/cmd
  /api        — HTTP-сервер
  /cron       — обновление справочников, детект статусов станций
/internal
  /yandex     — клиент API, лимитер, ретраи, circuit breaker
  /cache      — интерфейс + Redis-реализация (cache-aside)
  /storage    — Postgres-репозитории
  /routing    — граф, алгоритм поиска, MCT-правила
  /airports   — соседние станции, автодетект закрытия
  /geo        — сборка GeoJSON для карты
```

---

## 4. Интеграция с Яндекс.Расписаниями

### 4.1 Используемые методы

| Метод | Назначение | Частота обновления |
|---|---|---|
| `/stations_list` | полный справочник станций/городов | 1 раз в месяц |
| `/nearest_stations` | поиск ближайших станций к точке (для соседних аэропортов) | по требованию, кэш надолго (координаты не меняются) |
| `/search` | рейсы между двумя станциями на дату | на неделю вперёд (см. TTL-политику ниже) |
| `/schedule` | все рейсы через конкретную станцию на дату | используется отдельно для детекта закрытия узла |
| `/thread` | детали конкретного рейса (опционально, real-time статус) | не кэшируется / TTL в минутах |

### 4.2 Квоты и защита

- У Яндекс.Расписаний ограниченная суточная квота (порядка сотен запросов на бесплатном тарифе) — агрессивное кэширование не оптимизация, а необходимое условие работоспособности.
- `internal/yandex` должен включать:
  - **rate limiter** (token bucket) на исходящие запросы;
  - **circuit breaker** — при массовых ошибках/таймаутах API временно переключаемся на отдачу устаревшего кэша вместо падения;
  - **retry с экспоненциальным backoff** на транзиентные ошибки.

---

## 5. Стратегия кэширования

### 5.1 Слои и TTL

| Данные | Хранилище | TTL | Комментарий |
|---|---|---|---|
| Справочник городов/станций | Postgres (источник истины) + Redis (горячий доступ) | 30 дней | полный пересбор раз в месяц cron-джобой |
| `/nearest_stations` результаты | Redis | 30 дней | координаты статичны, можно кэшировать вместе со справочником |
| `/search` (рейсы на конкретную дату) | Redis | 7 дней | см. уточнение ниже |
| `/schedule` (рейсы через станцию, для детекта закрытия) | Redis + запись в Postgres `station_status` | 1 день | нужна свежесть для детекта аномалий |
| `/thread` (статус конкретного рейса) | не кэшируется или TTL 1–5 мин | только если добавляем real-time статусы |

**Уточнение по `/search`:** единый TTL в неделю недостаточно точен для дат, которые наступают уже завтра/послезавтра — там риск устаревания расписания выше (изменения рейсов, отмены). Рекомендуется дифференцировать:
- дата поездки в пределах 2 дней от текущей — TTL 2–6 часов;
- дата поездки 3+ дней вперёд — TTL 7 дней (как договорились).

### 5.2 Паттерн доступа

Cache-aside для всех слоёв: сначала Redis → промах → Postgres/Яндекс API → запись обратно в Redis. Ключи вида:

```
schedule:{from_station_id}:{to_station_id}:{date}
nearest:{lat}:{lon}:{radius}
station_flights:{station_id}:{date}
```

---

## 6. Модель данных (Postgres)

```sql
-- Справочник городов
CREATE TABLE cities (
    id            BIGINT PRIMARY KEY,
    yandex_code   TEXT UNIQUE NOT NULL,
    name          TEXT NOT NULL,
    country       TEXT NOT NULL,
    region        TEXT,
    lat           DOUBLE PRECISION,
    lon           DOUBLE PRECISION,
    tier          TEXT CHECK (tier IN ('small', 'million_plus')) NOT NULL,
    population    INT
);

-- Справочник станций (аэропорты, ж/д, автовокзалы)
CREATE TABLE stations (
    id            BIGINT PRIMARY KEY,
    yandex_code   TEXT UNIQUE NOT NULL,
    city_id       BIGINT REFERENCES cities(id),
    station_type  TEXT CHECK (station_type IN ('airport', 'train_station', 'bus_station')) NOT NULL,
    name          TEXT NOT NULL,
    lat           DOUBLE PRECISION,
    lon           DOUBLE PRECISION
);

-- Статус станции (для автодетекта закрытия)
CREATE TABLE station_status (
    station_id        BIGINT PRIMARY KEY REFERENCES stations(id),
    status            TEXT CHECK (status IN ('active', 'closed')) NOT NULL DEFAULT 'active',
    zero_since        TIMESTAMP,       -- с какого момента наблюдается 0 рейсов подряд
    last_seen_flight  TIMESTAMP,       -- дата последнего реального рейса
    updated_at        TIMESTAMP NOT NULL DEFAULT now()
);

-- Кандидаты соседних станций для города (геопоиск + ручной оверрайд)
CREATE TABLE station_neighbors (
    city_id       BIGINT REFERENCES cities(id),
    station_id    BIGINT REFERENCES stations(id),
    distance_km   NUMERIC,
    priority      INT,              -- порядок предпочтения при равных условиях
    source        TEXT CHECK (source IN ('geo', 'manual')) NOT NULL,
    is_excluded   BOOLEAN DEFAULT FALSE,  -- ручной блэклист (напр. неудобная логистика)
    PRIMARY KEY (city_id, station_id)
);

-- Правила минимального времени пересадки (MCT)
CREATE TABLE transfer_rules (
    id            SERIAL PRIMARY KEY,
    node_type     TEXT CHECK (node_type IN (
                      'airport_internal',
                      'station_internal',
                      'airport_to_city',
                      'city_to_city_via_airport'
                  )) NOT NULL,
    city_tier     TEXT CHECK (city_tier IN ('small', 'million_plus', NULL)),
    checkin_type  TEXT CHECK (checkin_type IN ('through', 'separate', 'n/a')) NOT NULL,
    min_minutes   INT NOT NULL
);

-- Базовые значения (см. раздел 7.4)
INSERT INTO transfer_rules (node_type, city_tier, checkin_type, min_minutes) VALUES
    ('airport_internal', NULL, 'through', 30),
    ('airport_internal', NULL, 'separate', 60),
    ('station_internal', NULL, 'n/a', 30),
    ('airport_to_city', 'small', 'n/a', 60),
    ('airport_to_city', 'million_plus', 'n/a', 90);

-- Сырой кэш ответов /search и /schedule (для отладки и восстановления после сбоев Redis)
CREATE TABLE schedule_cache (
    id            BIGSERIAL PRIMARY KEY,
    from_station  BIGINT REFERENCES stations(id),
    to_station    BIGINT REFERENCES stations(id),
    date          DATE NOT NULL,
    raw_response  JSONB NOT NULL,
    cached_at     TIMESTAMP NOT NULL DEFAULT now(),
    expires_at    TIMESTAMP NOT NULL
);
```

---

## 7. Расчёт маршрутов

### 7.1 Модель графа

**Вершины** — станции (аэропорт/вокзал/автовокзал) либо абстрактный "город" как логическая точка входа/выхода.

**Рёбра** двух типов:
1. **Реальные** — конкретный рейс из Яндекс.Расписаний (время отправления/прибытия, перевозчик, номер рейса).
2. **Синтетические** — переезды без прямого рейса в данных: город↔аэропорт, город↔соседний город через аэропорт. Время берётся из константной оценки (раздел 7.4), а не из API.

```go
type NodeType int

const (
    NodeStation NodeType = iota
    NodeCity
)

type Node struct {
    ID   int64
    Type NodeType
}

type EdgeKind int

const (
    EdgeFlight EdgeKind = iota // реальный рейс
    EdgeSynthetic              // синтетический переезд
)

type Edge struct {
    From, To   Node
    Kind       EdgeKind
    Departure  time.Time // для synthetic — не используется, время считается относительно предыдущего сегмента
    Arrival    time.Time
    Duration   time.Duration
    Carrier    string    // для реальных рейсов; для оценки through check-in
    TransportType string // plane / train / bus
}
```

### 7.2 Ограничение API и стратегия построения графа

**Важно:** Яндекс.Расписания не отдают дамп всей транспортной сети (в отличие от GTFS-фидов), только точечные ответы "откуда → куда → когда" или "все рейсы через станцию". Это значит, что классический RAPTOR в его "учебной" форме (когда весь timetable сети загружен в память) неприменим напрямую — иначе для многопересадочного поиска пришлось бы делать запросы по всем возможным парам станций, что моментально исчерпает квоту API.

**Решение — ленивое (lazy) расширение графа с ограничением по хабам:**

1. Строим список **опорных узлов (hub stations)** — крупные транспортные узлы (областные центры, крупные ж/д станции, основные аэропорты). Список формируется вручную/по населению города + автоматически по числу исходящих рейсов.
2. Поиск маршрута — не полный RAPTOR по всей сети, а **BFS/Dijkstra с ограничением глубины (максимум 4–5 пересадок)**, где на каждом шаге расширения графа мы:
   - запрашиваем `/search` только от текущего узла к следующим узлам-кандидатам (соседние хабы + станции в окрестности точки назначения), а не ко всем станциям сети;
   - кэшируем результат немедленно, чтобы повторные запросы в рамках того же поиска (и от других пользователей) не тратили квоту повторно.
3. Финальный набор маршрутов ранжируется и фильтруется до **Pareto-фронта** (см. 7.5), а не единственного "оптимального" пути.

Это компромисс между "честным" RAPTOR (нужен полный фид, недоступен) и наивной попарной Дейкстрой (не обрабатывает time-dependent расписания). При росте нагрузки эту стратегию можно уточнять: например, кэшировать не только точечные ответы, но и постепенно накапливать локальный "теневой" timetable по наиболее популярным направлениям.

### 7.3 Мультимодальность

Граф не различает вид транспорта на уровне алгоритма поиска — `TransportType` используется только как атрибут для отображения и, при необходимости, для пользовательских фильтров ("не показывать автобусы"). Правила пересадок (7.4) зависят от типа узла, а не от вида транспорта напрямую.

### 7.4 Минимальное время пересадки (MCT)

Время пересадки — функция трёх параметров: тип узла, тип регистрации, размер города (только для узла "аэропорт↔город").

| node_type | city_tier | checkin_type | min_minutes |
|---|---|---|---|
| airport_internal | — | through (единый перевозчик/через регистрация) | 30 |
| airport_internal | — | separate (разные перевозчики / нет единого билета) | 60 |
| station_internal | — | n/a | 30 |
| airport_to_city | small | n/a | 60 |
| airport_to_city | million_plus | n/a | 90 |

```go
type CheckinType int

const (
    CheckinThrough CheckinType = iota
    CheckinSeparate
)

type TransferContext struct {
    NodeType    string // соответствует enum в transfer_rules
    CheckinType CheckinType
    CityTier    string // "small" / "million_plus", актуально только для airport_to_city
}

func MinTransferTime(ctx TransferContext) time.Duration {
    // читает значение из transfer_rules (закэшировано в памяти при старте сервиса)
}
```

**Определение `checkin_type`:** данные Яндекс.Расписаний не содержат информации о тарифных/интерлайн-соглашениях между перевозчиками, поэтому это поле нельзя определить достоверно. Правило по умолчанию:

- если оба сегмента выполняет один и тот же перевозчик (совпадает код авиакомпании в данных) → допускаем эвристику `through`;
- во всех остальных случаях, включая отсутствие данных о перевозчике → безопасный дефолт `separate` (закладываем большее время, чтобы не предлагать нереализуемые стыковки).

**Соседний аэропорт как композиция рёбер:** узел `city_to_city_via_airport` — не отдельная константа, а сумма `airport_to_city(город назначения, tier)` + реальный сегмент переезда между городами (если он есть в расписании Яндекса, например автобус/электричка) либо ещё один синтетический переезд, если прямого сообщения нет.

### 7.5 Ранжирование результатов (Pareto-фронт)

Поскольку критериев несколько (время в пути, число пересадок, приблизительная стоимость, если доступна), пользователю показывается не один "лучший" маршрут, а набор недоминируемых вариантов:

- маршрут A доминирует над B, если A не хуже B по всем критериям и строго лучше хотя бы по одному;
- в выдачу попадают только недоминируемые маршруты, с сортировкой по умолчанию "быстрее всего" и возможностью переключить на "меньше пересадок" / "дешевле" (если есть данные о цене).

---

## 8. Соседние аэропорты и автодетект закрытия

### 8.1 Формирование кандидатов

1. **Геопоиск** — через `/nearest_stations` находим все станции в радиусе N км от города (радиус настраиваемый, например 150–200 км для аэропортов).
2. **Ручной оверрайд** — таблица `station_neighbors.source = 'manual'` позволяет:
   - добавить кандидата, которого геопоиск не находит или занижает в приоритете (например, при неудобной геометрии, но хорошей логистике);
   - исключить кандидата через `is_excluded = true` (например, географически близкий аэропорт без нормального наземного сообщения).
3. Итоговый список кандидатов для города = объединение geo + manual, за вычетом excluded, отсортированное по `priority`.

### 8.2 Автодетект закрытия

Логика — бинарный сигнал по числу рейсов через станцию (не сложный статистический baseline, простого порога "N дней подряд ноль" достаточно, так как естественная сезонность/праздники снижают число рейсов, но не обнуляют его полностью):

1. Ежедневная cron-джоба вызывает `/schedule` для каждой отслеживаемой станции, считает число рейсов на ближайшие даты.
2. Если сегодня 0 рейсов, а на предыдущих замерах было > 0 — фиксируем `zero_since = now()`, статус пока не меняем (защита от разового сбоя API).
3. Если 0 рейсов держится **N дней подряд** (рекомендуемое значение N = 3, настраиваемое) — статус станции переключается в `closed`. Станция исключается из построения маршрутов; вместо неё автоматически подставляются соседние кандидаты из `station_neighbors`.
4. Возврат в `active` — сразу при первом появлении >0 рейсов (гистерезис на открытие не нужен: ложное срабатывание "уже открыт" не критично, в худшем случае пользователю покажут лишний вариант маршрута).
5. Ручной статус (например, объявление о ремонте до конкретной даты) может быть выставлен через админку заранее, не дожидаясь автоматического детекта постфактум — таблица `station_status` поддерживает и автоматические, и ручные записи (нужно добавить поле `source enum('auto','manual')`, см. риски).

---

## 9. Визуализация маршрута на карте

- Каждый найденный маршрут (реальные + синтетические сегменты) конвертируется в `GeoJSON FeatureCollection`:
  - `LineString` для каждого сегмента пути (координаты станций начала/конца сегмента);
  - `Point`-маркеры для каждой пересадочной станции с popup-инфо (время стыковки, тип пересадки);
  - визуально различать реальные сегменты (сплошная линия, цвет по виду транспорта) и синтетические (пунктир).
- Рендер на фронтенде — Leaflet + OSM-тайлы, без зависимости от платных карто-провайдеров.
- Эндпоинт отдаёт готовый GeoJSON, фронт не должен пересчитывать геометрию.

---

## 10. API (REST, черновой список эндпоинтов)

| Метод | Путь | Описание |
|---|---|---|
| GET | `/v1/cities?query=` | автокомплит городов из справочника |
| GET | `/v1/cities/{id}/stations` | список станций города (с учётом соседних, если основная закрыта) |
| POST | `/v1/routes/search` | тело: `from_city_id, to_city_id, date, filters`. Ответ: Pareto-набор маршрутов |
| GET | `/v1/routes/{search_id}/{route_id}/geojson` | геометрия конкретного маршрута для карты |
| GET | `/v1/stations/{id}/status` | текущий статус станции (для отладки/админки) |
| POST | `/internal/admin/stations/{id}/status` | ручное переопределение статуса станции (требует авторизации) |

---

## 11. Нефункциональные требования

- **Производительность:** ответ на `/v1/routes/search` — не более 3–5 секунд для маршрутов, полностью покрытых кэшем; для холодного кэша с несколькими сегментами — до 15 секунд (с учётом ленивых запросов к API Яндекса), с индикацией прогресса на фронте при долгом поиске.
- **Отказоустойчивость:** при недоступности API Яндекса — деградация до отдачи устаревшего кэша с явной пометкой "данные могут быть неактуальны", не полный отказ в обслуживании.
- **Наблюдаемость:** метрики — hit-rate кэша по слоям, остаток квоты API Яндекса, число ошибок circuit breaker, среднее время поиска маршрута.
- **Тестирование:** алгоритм поиска маршрута (раздел 7) должен покрываться unit-тестами на синтетических timetable-фикстурах, без реальных вызовов API.

---

## 12. Этапы разработки (Roadmap)

**Этап 1 — MVP**
- Один вид транспорта (например, поезда) без синтетических рёбер.
- Прямые маршруты + максимум 1 пересадка.
- Базовая визуализация на карте.
- Кэш справочников и `/search` по описанным TTL.

**Этап 2 — Мультимодальность и MCT**
- Добавление самолётов и автобусов в граф.
- Синтетические рёбра "город↔аэропорт" с правилами MCT.
- Ручные соседние аэропорты (`station_neighbors.source = 'manual'`).

**Этап 3 — Глубокий поиск и автодетект**
- Ленивое расширение графа через hub-узлы, глубина до 4–5 пересадок.
- Pareto-ранжирование результатов.
- Автодетект закрытия станций и автоматическое переключение на соседние.

**Этап 4 — Полировка**
- Учёт цены как критерия (если появится источник данных).
- Уведомления об изменении/отмене рейсов в уже построенном маршруте.
- Персонализация (сохранённые города, история поиска).

---

## 13. Открытые вопросы и риски

1. **Источник цены** — Яндекс.Расписания не всегда отдают стоимость билета; если цена нужна как критерий ранжирования, потребуется дополнительный источник данных (риск для этапа 4).
2. **Достоверность `checkin_type`** — эвристика "один перевозчик → through check-in" может ошибаться (например, при кодшеринге). Стоит заложить дополнительный буфер времени или пометку "оценочно" в UI.
3. **Разделение `station_status.source` на `auto`/`manual`** — нужно решить, как ручной статус взаимодействует с автодетектом (не должен ли автодетект перезаписывать ручную запись раньше времени).
4. **Квота API при росте нагрузки** — стратегия lazy-hub-expansion снижает число запросов, но при масштабировании потребуется либо повышенный тариф API, либо собственный накопительный кэш ("теневой" timetable) по популярным направлениям.
5. **Актуальность hub-списка** — список опорных узлов для построения графа нужно периодически пересматривать (новые маршруты, изменение трафика по городам).
