# Додаток A. Підсистема пошуку вантажів, транспорту та маршрутів

## Модуль SEARCH логістичної біржі (6 видів транспорту)

Нормативний додаток до рамкового ТЗ «Логістична біржа з шістьма видами транспорту» (файл `ТЗ-рамкове-логістична-біржа.md`). Нумерація розділів цього додатка має префікс `A.`; посилання виду «розділ 4.4» без префікса стосуються рамкового ТЗ. За розбіжності між рамковим ТЗ і цим додатком у питаннях пошуку та маршрутизації пріоритет має додаток A.

Версія 3.14 (додаток A) · документ доповнює загальне ТЗ логістичної біржі та формалізує **блок-схему № 3.1.1** і документ «Моделювання системою» у вигляді реалізовної специфікації, вбудованої в загальну логіку біржі. Блок-схема № 3.1.1 має статус основного алгоритму пошуку для всіх мультимодальних перевезень (розділ A.6).

---

## A.1. Призначення та межі модуля

### A.1.1. Мета

Модуль SEARCH — це ядро біржі, яке перетворює запит користувача «перевезти вантаж V із пункту A в пункт B» у впорядкований перелік конкретних заявок і маршрутів, доступних у системі. Основним алгоритмом пошуку маршрутів є блок-схема № 3.1.1 (авто + залізниця + море) — вона застосовується щоразу, коли перевезення потребує більш ніж одного виду транспорту, і розширюється на шість видів транспорту без зміни структури графа. Документ «Моделювання системою» задає постановку задачі, критерії й коефіцієнт переваги, а також fallback-рівні (перелік компаній напрямку, перелік можливих сполучень).

Ключова проблема, яку вирішує модуль (формулювання з документа моделювання): якщо прямого маршруту з A в B не існує або немає перевізника, користувач мусить самостійно шукати проміжні пункти C1, C2, оцінювати економіку, наявність складів і часові рамки — це складна і трудомістка задача. Система має робити це замість користувача.

### A.1.2. Що входить у модуль

| № | Підсистема | Зміст |
|---|---|---|
| S1 | Простий пошук заявок | Пошук по одному розділу (виду транспорту) за фільтрами: країна, регіон, пункт, дата, тип транспорту, тип завантаження, маса, об'єм |
| S2 | Розширений фільтр «інші напрямки» | Два незалежні списки пунктів відправлення / призначення |
| S3 | Пошук зворотних рейсів | Пошук заявок, виставлених **до** пункту відправлення (ймовірний зворотний рейс) |
| S4 | Пошук мультимодального маршруту | Основний алгоритм — блок-схема № 3.1.1; побудова ланцюга заявок з 2–4 плечами по 6 видах транспорту |
| S5 | Fallback «компанії напрямку» | Перелік компаній, які коли-небудь перевозили в цьому напрямку |
| S6 | Fallback «можливі сполучення» | Перелік станцій/портів/аеропортів/точок дрона, між якими існує сполучення |
| S7 | Ранжування та коефіцієнт переваги | Сортування результатів за критерієм «вартість» або «швидкість» + успадкований коефіцієнт переваги |
| S8 | Збереження та повторний запуск запиту | Історія запитів, підписка на запит, сповіщення про нові відповідні заявки |

### A.1.3. Що не входить

Модуль не виконує: створення заявок, ставки/торги, обмін документами, розрахунок рейтингу, оплату доступу до розділів. Модуль лише **читає** ці дані і **передає** знайдені лоти в підсистему ставок (див. розділ A.9).

---

## A.2. Місце модуля в загальній логіці біржі

Модуль SEARCH не є ізольованим — він працює строго в межах успадкованої бізнес-логіки біржі. Обов'язкові інваріанти, які пошук не має права порушувати:

| Інваріант загальної логіки | Як його реалізує пошук |
|---|---|
| Доступ до розділу відкривається лише після реєстрації та оплати цього розділу | Кожен пошуковий запит фільтрується по `user.paid_sections`; заявки чужих (неоплачених) розділів у результати не потрапляють, крім розділу «Мультимодальні перевезення», який має доступ до всіх заявок системи |
| Непрофесійні (безкоштовні) акаунти бачать нові заявки із затримкою | Фільтр `lot.published_at <= now() − delay(user.tariff, section)`; параметр затримки задає адміністратор окремо для кожного з шести розділів |
| Непрофесійні акаунти обмежені в кількості заявок і пропозицій | Пошук показує лот, але кнопка «зробити ставку» блокується квотою; пошук повертає прапорець `can_bid` для кожного лота |
| Відповіді на заявку бачить лише власник заявки | Пошук ніколи не повертає тіла ставок; повертає лише `bids_count` і `has_my_bid` |
| Прямий контакт для безкоштовних акаунтів закритий | Пошук повертає контакти лише за умови `viewer.is_professional = true`, інакше — тільки кнопку внутрішнього повідомлення |
| Чорний список і зони надійності | Заявки компаній з чорного списку виводяться в окрему вкладку або приховуються — режим задає користувач у налаштуваннях пошуку |
| Рейтинг компанії | Рейтинг є одним із факторів ранжування і критерієм «незадовільного результату» в алгоритмі (див. A.6.13) |
| Автовидалення застарілих заявок | Пошук працює лише з `lot.status = 'active'`; архівні заявки використовуються лише в S5 (компанії напрямку) і в аналітиці |

---

## A.3. Позначення та формальна модель

### A.3.1. Види транспорту

| Код | Вид транспорту | Базова база пунктів | Статус |
|---|---|---|---|
| `A` | Автомобільний | міста (`city`) | успадковано |
| `T` | Залізничний | ЖД-станції (`rail_station`) | успадковано |
| `F` | Авіаційний | аеропорти (`airport`) | успадковано |
| `M` | Морський | порти (`seaport`) | успадковано |
| `X` | Мультимодальний | всі типи пунктів | успадковано (окремий розділ, не окреме плече) |
| `D` | Дрони | точки старту/приземлення (`drone_pad`) | **новий** |

### A.3.2. Вхідні дані запиту (успадковано з моделювання, розширено)

| Символ | Поле API | Обов'язковість | Опис |
|---|---|---|---|
| `P1` | `origin` | так | Місце відправлення (id пункту або текст із геокодуванням) |
| `P2` | `destination` | так | Місце призначення |
| `Tin1` | `depart_from`, `depart_to` | так | Вікно відправлення (дата з / дата по) |
| `Tout1` | `arrive_by` | ні | Крайній час прибуття |
| `Vaga` | `weight_t` | так | Вага вантажу, **тонни** — одна одиниця для всіх розділів, включно з дронами |
| `Scope` | `volume_m3` | ні | Об'єм, м³ |
| — | `dims{l,w,h}` | ні | Габарити |
| — | `cargo_type` | ні | Тип вантажу зі списку розділу (з можливістю вводу з клавіатури) |
| — | `adr` | ні | Клас небезпеки 1–9 |
| — | `modes[]` | ні | Дозволені види транспорту; за замовчуванням усі оплачені розділи |
| — | `criterion` | так | `cost` \| `time` \| `balanced` |
| — | `max_transships` | ні | Максимум перевантажень, за замовчуванням 2 |
| — | `radius_km{origin,dest}` | ні | Радіус пошуку проміжних пунктів; дефолт задає адмін |
| — | `min_rating` | ні | Мінімальний рейтинг компанії-перевізника |

**Одиниці ваги.** Поле запиту `weight_t` завжди несе тонни — без виключень для розділу дронів. Обмеження дрона `max_payload_kg` (розділ A.4.5) і поле відповіді `legs[].payload_kg` задані в кілограмах, тому перерахунок виконує модуль пошуку за тотожністю \( 1\,\text{т} = 1000\,\text{кг} \), а не клієнт. Двозначних полів у контракті немає: одиниця завжди видна з суфікса імені поля (`_t` або `_kg`), і це правило перевіряється прогіном `ci/check_contract_text.py` (сценарії U1–U5, розділ A.13.2).

### A.3.3. Вихідні дані (успадковано)

Результат — послідовність блоків формату:

```
Транспорт: пункт1[час відправлення] → пункт2[час прибуття]
```

Три класи відповіді, у порядку зниження цінності:
1. **Маршрут** — ланцюг із 1–4 плечей, кожне плече прив'язане до реальної активної заявки.
2. **Компанії напрямку** — реквізити фірм, що перевозили в цьому напрямку (заявки втратили актуальність).
3. **Можливі сполучення** — пункти, між якими фізично існує сполучення, без активних заявок.

### A.3.4. Модель маршруту

Маршрут `R` — упорядкований список плечей:

\[ R = \{ (m_1, p_0 \to p_1, l_1), (m_2, p_1 \to p_2, l_2), \ldots, (m_k, p_{k-1} \to p_k, l_k) \} \]

де \( m_i \in \{A,T,F,M,D\} \) — вид транспорту плеча, \( p_i \) — пункт, \( l_i \) — id заявки (лота). Кількість перевантажень \( N_{tr} = k - 1 \).

---

## A.4. Структура даних

### A.4.1. Довідник пунктів (єдина таблиця замість чотирьох окремих баз)

```sql
CREATE TABLE points (
  id            BIGSERIAL PRIMARY KEY,
  kind          TEXT NOT NULL CHECK (kind IN
                ('city','rail_station','seaport','airport','drone_pad','warehouse','border_crossing')),
  name          TEXT NOT NULL,
  name_i18n     JSONB NOT NULL DEFAULT '{}',   -- ua/en/pl/de
  country_code  CHAR(2) NOT NULL,
  region        TEXT,
  geo           GEOGRAPHY(POINT,4326) NOT NULL,
  codes         JSONB,      -- IATA/ICAO для аеропортів, UN/LOCODE для портів, код станції для ЖД
  parent_city_id BIGINT REFERENCES points(id),  -- станція/порт/аеропорт належить місту
  attrs         JSONB,      -- глибина причалу, довжина ЗПС, тип платформи, наявність рампи
  is_active     BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE INDEX idx_points_geo  ON points USING GIST (geo);
CREATE INDEX idx_points_kind ON points (kind, country_code);
CREATE INDEX idx_points_name ON points USING GIN (to_tsvector('simple', name));
```

Ця таблиця замінює окремі бази ЖД-станцій, портів і аеропортів, вимога створення яких зафіксована в базовому ТЗ, і додає новий тип `drone_pad` для шостого розділу. Автоматичне підтягування країни й регіону за назвою пункту (вимога шаблонів заявок і фільтрів) реалізується запитом до `points` з `parent_city_id`.

### A.4.2. Заявки (лоти) — уніфікована таблиця

```sql
CREATE TABLE lots (
  id             BIGSERIAL PRIMARY KEY,
  mode           CHAR(1) NOT NULL,           -- A/T/F/M/D
  lot_type       TEXT NOT NULL CHECK (lot_type IN ('free_cargo','free_transport','tender','multimodal')),
  company_id     BIGINT NOT NULL,
  origin_id      BIGINT NOT NULL REFERENCES points(id),
  dest_id        BIGINT NOT NULL REFERENCES points(id),
  load_from      DATE NOT NULL,              -- «Загрузка з»
  load_to        DATE NOT NULL,              -- «Загрузка по»
  arrive_est     TIMESTAMPTZ,                -- прогнозований час прибуття
  weight_t       NUMERIC(12,3),
  volume_m3      NUMERIC(12,3),
  dims           JSONB,                      -- {l,w,h}
  cargo_type     TEXT,
  adr_class      SMALLINT,
  transport_type TEXT,                       -- тент/реф/контейнеровоз/балкер/мультикоптер...
  load_kind      TEXT,                       -- боковая/задняя/верхняя/...
  load_fill      TEXT CHECK (load_fill IN ('full','partial')),
  customs        TEXT[],                     -- CMR, T1, TIR, медкнижка...
  freight_value  NUMERIC(14,2),
  freight_unit   TEXT,                        -- EUR, USD, UAH, EUR/km, UAH/km...
  pay_form       TEXT,
  params         JSONB,                       -- специфічні поля розділу (тип судна, дальність дрона, дозвіл на політ)
  status         TEXT NOT NULL DEFAULT 'active',
  published_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  valid_until    DATE NOT NULL,               -- «Заявка до»
  updated_at     TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_lots_search ON lots (mode, status, origin_id, dest_id, load_from);
CREATE INDEX idx_lots_rev    ON lots (mode, status, dest_id, origin_id, load_from);
CREATE INDEX idx_lots_pub    ON lots (published_at DESC);
CREATE INDEX idx_lots_params ON lots USING GIN (params);
```

Індекс `idx_lots_rev` існує спеціально для пошуку зворотних рейсів (крок 1.2 алгоритму моделювання) — без нього цей крок деградує в full scan.

### A.4.3. Граф сполучень (матеріалізований шар для швидкого пошуку)

```sql
CREATE MATERIALIZED VIEW link_stats AS
SELECT mode, origin_id, dest_id,
       count(*) FILTER (WHERE status='active')          AS active_lots,
       count(*)                                        AS lots_total_365d,
       count(DISTINCT company_id)                      AS companies,
       percentile_cont(0.5) WITHIN GROUP (ORDER BY freight_norm_eur) AS median_freight_eur,
       percentile_cont(0.5) WITHIN GROUP (ORDER BY transit_hours)    AS median_transit_h,
       max(published_at)                               AS last_seen
FROM lots_history
WHERE published_at > now() - INTERVAL '365 days'
GROUP BY mode, origin_id, dest_id;
```

`link_stats` — основа для S6 (можливі сполучення) і для оцінки плеча, коли фрахт у заявці не вказано. Оновлення — щогодини (`REFRESH MATERIALIZED VIEW CONCURRENTLY`).

### A.4.4. Таблиці історії та підписок

- `lots_history` — усі коли-небудь створені заявки (успадкована вимога зберігати заявки для аналітики: періоди активності, затребуваний транспорт). Джерело S5.
- `saved_searches` — збережений запит користувача + канал сповіщення (внутрішнє повідомлення, e-mail).
- `search_log` — журнал запитів: параметри, кількість результатів, обраний варіант. Потрібен для налаштування алгоритму та для звітів адміністратора.

### A.4.5. Параметри розділу дронів

Специфіка розділу D, яку алгоритм зобов'язаний враховувати (у `lots.params`):

| Поле | Тип | Роль у пошуку |
|---|---|---|
| `max_payload_kg` | number, **кг** | Жорсткий відсів: `weight_t × 1000 > max_payload_kg` → плече неможливе. Вага в запиті — тонни (розділ A.3.2), ліміт лота — кілограми; порівнювати числа без перерахунку заборонено |
| `range_km` | number | Жорсткий відсів: відстань плеча > `range_km × drone_range_margin` (дефолт 0,8) → плече неможливе. Значення множника задається в адмін-панелі (розділ A.10), а не в коді |
| `drone_type` | enum | мультикоптер / конвертоплан / фіксоване крило / будь-який |
| `flight_permit` | bool | Дозвіл конкретного лота. Не плутати з воротами 2 модуля `POST_DRONE` (A.6.8): ворота перевіряють **зону** `П2` і за відсутності дозволу зупиняють модуль з кодом `no_permit`, тоді як `flight_permit = false` у лоті лише додає попередження `no_permit` до варіанта, не відсіваючи його |
| `weather_gate` | derived | Перевірка вітру / видимості / опадів у зоні польоту через сервіс погоди; при перевищенні порогів плече помічається `weather_risk` |

---

## A.5. Нормалізація вартості й часу

Заявки містять фрахт у різних валютах і одиницях (`EUR`, `USD`, `UAH`, `PLN`, `CZK`, `RON`, `GBP`, `USDC`, `USDT`, а також похідні `EUR/км`, `UAH/км` тощо), тому пряме порівняння неможливе. Перелік валют не «прошивається» в модулі: він читається з довідника валют, який веде адміністратор (рамкове ТЗ, розділ 13.1). Перед ранжуванням модуль нормалізує кожне плече:

\[ C_i = \begin{cases} f_i \cdot k_{fx} & \text{якщо одиниця абсолютна} \\ f_i \cdot d_i \cdot k_{fx} & \text{якщо одиниця за км} \end{cases} \]

де \( d_i \) — відстань плеча (км), \( k_{fx} \) — курс до базової валюти на дату запиту. Базова валюта за замовчуванням — EUR; це та сама налаштовна величина, що й базова валюта біржі (рамкове ТЗ, розділ 13.4), двох незалежних базових валют у системі бути не може. Курс береться з того самого джерела, що й розрахунки, разом із часом фіксації; конвертація записується в журнал конвертацій (рамкове ТЗ, розділ 13.3).

Повна вартість маршруту:

\[ C(R) = \sum_{i=1}^{k} C_i + \sum_{j=1}^{k-1} H_j \]

де \( H_j \) — вартість перевантаження в пункті \( p_j \) (довідник `handling_costs`, задає адміністратор за типом пункту й видом транспорту; за відсутності даних — параметр за замовчуванням).

Повний час маршруту:

\[ T(R) = \sum_{i=1}^{k} t_i + \sum_{j=1}^{k-1} w_j \]

де \( t_i \) — транзитний час плеча (з заявки або з `link_stats.median_transit_h`), \( w_j \) — час простою на перевантаженні.

Якщо для плеча немає ні фрахту в заявці, ні медіани в `link_stats`, вартість вважається невідомою: плече отримує `cost_known: false`, не бере участі в сортуванні за критерієм `cost` і виводиться в окремому блоці «ціну треба запитати». Нормалізована сума є внутрішньою величиною ранжування: користувачеві показується оригінальна ставка у валюті автора заявки плюс перерахунок у валюту користувача, а порядок видачі не залежить ні від валюти відображення, ні від локалі (рамкове ТЗ, розділи 13.3 і 14.7). Не плутати з випадком, коли вартість узята з медіани `link_stats`: там `cost_known: true` і прапорець `estimated_price` (розділ A.7). Поле відповіді `total_cost_eur` має історичну назву й містить суму в **налаштованій базовій валюті**, а не обовʼязково в євро; фактичний код валюти передається окремим полем відповіді `base_currency_code` (розділ A.8.4.2), а збіг базової валюти з таблицею рамкового ТЗ, розділ 13.1, перевіряється сценарієм C2 розділу 13.7.

Поле `normalization_base` до валюти відношення не має і ніколи не містить коду валюти: це перелік `route_id`, які увійшли в базу min-max нормалізації скора (розділ A.7). Два поля незалежні й по-різному присутні у відповіді: `base_currency_code` повертається завжди, коли у відповіді є хоч одна нормалізована сума, а `normalization_base` — лише при `drone.diagnostics: true` (розділ A.8.4.1).

---

## A.6. Основний алгоритм пошуку маршрутів (мультимодальні перевезення)

**Нормативне джерело: блок-схема № 3.1.1** — алгоритм пошуку маршруту для перевезення, що задіює три види транспорту (автомобільний, залізничний, морський).

Графічне подання алгоритму — сім аркушів, рисунки 3.1.1-1 … 3.1.1-7 (перелік — розділ A.14). Аркуші є нормативною частиною цього ТЗ: у разі розбіжності між текстом розділу A.6 і рисунком пріоритет має рисунок.

Статус алгоритму: **основний і єдиний** алгоритм пошуку для будь-якого перевезення, яке потребує більш ніж одного виду транспорту. Розділ «Мультимодальні перевезення» використовує його без винятків; розділи окремих видів транспорту використовують його автоматично, коли прямого плеча (шар L0) не знайдено. Пошук з одним видом транспорту (L0) залишається окремим спрощеним випадком і виконується першим.

### A.6.1. Вхідні дані (вхідний паралелограм блок-схеми)

```
П1 — початковий пункт завантаження
П2 — кінцевий пункт обвантаження (вивантаження)
АВТОМОБІЛЬНИЙ ТРАНСПОРТ
ЗАЛІЗНИЧНИЙ ТРАНСПОРТ
МОРСЬКИЙ ТРАНСПОРТ
```

Далі — вузол напрямку `П1 → П2` з параметрами вантажу і дат (у блок-схемі — овал). У розширенні на шість видів до переліку транспорту додаються `АВІАЦІЙНИЙ` та `ДРОН` (див. A.6.8).

### A.6.2. Три джерела заявок на кожен вид транспорту

Кожен вузол запиту блок-схеми (прямокутник із трьома рядками) — це **три паралельні запити**, а не один. Це пряме відображення успадкованої вимоги використовувати дані двох бірж (міжнародної та внутрішньої) плюс мультимодальної біржі.

| Позначення на блок-схемі | Джерело | Поле `source` |
|---|---|---|
| `Б-р <вид>(звідки, куди)` | Міжнародна біржа відповідного розділу | `intl` |
| `Б-р мульт <вид>(звідки, куди)` | Мультимодальна біржа | `multi` |
| `Б-р внутр <вид>(звідки, куди)` | Внутрішня (національна) біржа | `domestic` |

Правила вузла запиту:

1. Три запити виконуються паралельно з однаковими параметрами `(звідки, куди, вікно дат, параметри вантажу)`.
2. Результати об'єднуються з дедуплікацією за ключем `(company_id, origin_id, dest_id, load_from, load_to, mode)`; при дублі перевага джерелу `intl`, потім `multi`, потім `domestic`.
3. Джерело кожного знайденого лота зберігається і виводиться користувачу — це впливає на доступність лота залежно від оплачених розділів.
4. Якщо одне з трьох джерел недоступне, вузол повертає результат із прапорцем `partial: true` і перелік недоступних джерел; вигадувати дані заборонено.

### A.6.3. Типи вузлів блок-схеми та їх реалізація

Відповідність форм, кольорів і позначень — див. рисунок 3.1.1-1 (легенда). Таблиця нижче пов'язує кожну форму з типом вузла і місцем реалізації.

| Форма на схемі | Тип вузла | Семантика | Реалізація |
|---|---|---|---|
| Паралелограм (вхід) | `INPUT` | П1, П2, перелік видів транспорту | Параметри запиту API `/search/routes` |
| Овал | `DIRECTION` | Напрямок `П1 → П2` | Контекст пошуку |
| Прямокутник, 3 рядки | `QUERY` | Запит до трьох бірж по одному виду транспорту | Розділ A.6.2 |
| Ромб `П = П1` / `П = П2` | `MATCH` | Чи збігається пункт знайденої заявки з потрібним | Порівняння `point_id`, з урахуванням `parent_city_id` (станція/порт міста = місто) |
| Ромб `Б-р <вид> з X до Y` | `LEG_EXISTS` | Чи існує заявка на конкретному плечі X→Y | Запит до трьох бірж по цьому виду транспорту |
| Прямокутник із подвійною рамкою `П ≈ П21 (найближчий до П2) по координатах` | `SUBSTITUTE` | Заміна недосяжного пункту на найближчий за координатами | `nearest_points()`, розділ A.6.10 |
| Паралелограм (вихід) `ДАННІ ...` | `OUTPUT` | Готовий маршрут або його частина | Елемент `routes[]` відповіді API |

Обхід графа — в глибину, з фіксованим порядком гілок: `yes` перед `no` не має пріоритету — пріоритет визначає порядок видів транспорту (A.6.4). Кожен вузол `OUTPUT` не завершує пошук: маршрут додається в результат, обхід продовжується, поки не вичерпані гілки або не спрацював `search_timeout_ms`. Користувач отримує всі знайдені варіанти, впорядковані за розділом 7.

### A.6.4. Магістральний принцип (порядок гілок верхнього рівня)

Див. рисунок 3.1.1-2 (верхній рівень). Блок-схема має три гілки верхнього рівня, які перевіряються послідовно:

| № | Гілка | Магістральне плече | Фідерні плечі |
|---|---|---|---|
| 1 | **Морська** | `море` | `жд`, потім `авто` |
| 2 | **Залізнична** | `жд` | `авто` |
| 3 | **Автомобільна** | `авто` | — |

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

Вибір гілки, з якої починається обхід:

- відстань `П1 → П2` перетинає море або континенти, або користувач указав `M` у `modes` → починати з морської гілки;
- критерій `time` і вантаж проходить авіаційні обмеження ваги/об'єму → починати з авіаційної гілки (аналог морської, база аеропортів замість портів);
- обидва пункти в межах континенту → починати з залізничної гілки;
- відстань менша за `single_mode_max_km` (налаштування, дефолт 1500 км) → починати з автомобільної гілки.

### A.6.5. Канонічний фрагмент: морська гілка (нормативний псевдокод)

Псевдокод дослівно відтворює послідовність вузлів блок-схеми для морської гілки — див. рисунок 3.1.1-3. Залізнична й автомобільна гілки будуються за тим самим шаблоном із заміною видів транспорту (рисунки 3.1.1-4 та 3.1.1-5 відповідно).

```
# --- Морська гілка ---
S ← QUERY(море, звідки = П1, куди = П2)          # 3 біржі: Б-р море / Б-р мульт море / Б-р внутр море

if MATCH(S.origin = П1) and MATCH(S.dest = П2):
      OUTPUT «ДАННІ 3-ох Б-р море»                # прямий морський маршрут, 0 перевантажень
      # обхід продовжується для пошуку альтернатив

if MATCH(S.origin = П1) and not MATCH(S.dest = П2):
      S ← QUERY(море, звідки = П1, куди = *)
      П21 ← SUBSTITUTE(найближчий до П2 по координатах, kind = seaport)
      R ← QUERY(жд, звідки = П21, куди = П2)
      if LEG_EXISTS(Б-р жд з П21 до П2):
            OUTPUT «ДАННІ Б-р море з П1 до П21» + «ДАННІ Б-р жд з П21 до П2»    # 1 перевантаження
      else:
            R ← QUERY(жд, звідки = П21, куди = *)
            П2_2 ← SUBSTITUTE(наступний найближчий до П2 по координатах, kind = rail_station)
            if LEG_EXISTS(Б-р авто з П2_2 до П2):
                  OUTPUT «ДАННІ Б-р море з П1 до П21»
                       + «ДАННІ Б-р жд з П21 до П2_2»
                       + «ДАННІ Б-р авто з П2_2 до П2»                          # 2 перевантаження
            else:
                  OUTPUT «ДАННІ Б-р море з П1 до П21»
                       + «ДАННІ Б-р жд з П21 до П2_2»
                       + «авто з П2_2 до П2»       # плече без активних заявок — лише сполучення

if not MATCH(S.origin = П1):
      П11 ← SUBSTITUTE(найближчий до П1 по координатах, kind = seaport)
      # далі — дзеркальна логіка: підвіз П1 → П11 залізницею, потім авто;
      # магістраль море П11 → П2 або П11 → П21 з подальшим довозом
```

Ключові особливості, які **обов'язково** зберегти при реалізації:

1. **Повторний запит після негативного предикату.** У блок-схемі після кожного `no` вузол `QUERY` виконується повторно з розширеним діапазоном (`куди = *` замість конкретного пункту) — це потрібно, щоб отримати перелік реально досяжних пунктів перед підстановкою найближчого.
2. **Підстановка виконується від пункту призначення до пункту відправлення.** Спочатку заміняється `П2` (`П21`, потім `П2/2`), і лише коли гілка вичерпана — заміняється `П1` (`П11`, `П1/2`).
3. **Часткові виходи допустимі.** Плече без префікса `ДАННІ` означає: сполучення існує (є в `link_stats`), але активних заявок немає. Такий маршрут виводиться з позначкою `leg_status: "no_lots"` і кнопкою «створити заявку на це плече».
4. **Кожен вихід — окремий варіант.** Пошук не зупиняється на першому знайденому маршруті.

### A.6.6. Дзеркальність гілок

Порівняння рисунків 3.1.1-3, 3.1.1-4 і 3.1.1-5 показує однакову структуру всіх гілок. Блок-схема побудована симетрично: підгілка «пункт відправлення не збігається» повторює структуру підгілки «пункт призначення не збігається» із заміною `П2 → П1`, `П21 → П11`, `П2/2 → П1/2` і зворотним порядком плечей. Реалізація має бути одним параметризованим модулем, а не двома копіями коду; напрямок підстановки задається параметром `side ∈ {origin, destination, both}`.

### A.6.7. Обмеження глибини

| Параметр | Значення | Опис |
|---|---|---|
| `max_substitutions_per_side` | 2 (`П21`, `П2/2`) | Скільки пунктів-кандидатів перевіряти з кожного боку |
| `max_transships` | 2 (дефолт), до 3 | Максимум перевантажень у маршруті. Верхня межа зумовлена `max_legs`: перевантажень не більше `max_legs − 1` |
| `max_legs` | 4 | Максимум плечей |
| `max_modes_per_route` | 3 (дефолт), 4 з дроном | Скільки різних видів транспорту допускається в одному маршруті |

Обмеження `max_modes_per_route = 3` відповідає успадкованому спостереженню: всі чотири види транспорту практично ніколи не використовуються в одному перевезенні.

### A.6.8. Розширення блок-схеми на шість видів транспорту

Блок-схема описує три види транспорту. Розширення виконується **без зміни структури графа** — додаються лише альтернативні гілки за тим самим шаблоном:

Графічне подання розширення: рисунок 3.1.1-6 (гілка АВІА) і рисунок 3.1.1-7 (пост-обробка ДРОН). Обидва аркуші побудовані за шаблоном рисунка 3.1.1-3.

| Вид | Роль у графі | Правило підстановки |
|---|---|---|
| `M` море | Магістральне плече (гілка 1) | база портів (`kind = seaport`) |
| `F` авіа | **Магістральне плече — альтернатива морю** | Повна копія морської гілки з базою аеропортів (`kind = airport`). Обирається при критерії `time` або коли морського сполучення немає. Вантаж проходить перевірку авіаційних обмежень ваги/об'єму перед входом у гілку. Див. рисунок 3.1.1-6 |
| `T` жд | Фідерне плече 1-го рівня | база станцій (`kind = rail_station`) |
| `A` авто | Фідерне плече 2-го рівня, «остання миля» | база міст (`kind = city`) |
| `D` дрон | **Не є плечем графа. Пост-обробка вже знайденого маршруту** — альтернатива останньому авто-плечу | база майданчиків (`kind = drone_pad`). Запускається після того, як гілка повернула готовий варіант; допускається при `d ≤ range_km × drone_range_margin` та `weight_t × 1000 ≤ max_payload_kg`. Повний ланцюг перевірок — рисунок 3.1.1-7 |
| `X` мультимодальний | Не окреме плече | Розділ сайту, який використовує цей алгоритм як основний і має доступ до заявок усіх розділів |

#### Дрон: модель «пост-обробка», а не гілка графа

Нормативним є рисунок 3.1.1-7. Дрон **не додає гілки й не змінює вузлів** блок-схеми: гілки МОРЕ, ЗАЛІЗНИЦЯ, АВТО та АВІА виконуються без згадки про дрон і повертають готові варіанти. Дрон-модуль запускається **після** них, окремим проходом по вже зібраних варіантах, і лише для останнього плеча з видом `авто`. Це дає одну реалізацію замість чотирьох копій умови в кожній гілці.

Порядок воріт (кожне «ні» — вихід із модуля, авто-плече лишається без змін, дрон-варіант не створюється):

1. Плече є **останнім** у маршруті і його вид — `авто`.
2. Зона дозволена: є дозвіл/реєстрація, немає заборони (прикордонна смуга, зона аеропорту, воєнна зона) — інакше прапорець `no_permit`.
3. `d(X, П2) ≤ range_km × drone_range_margin`.
4. Вантаж допустимий: `weight_t × 1000 ≤ max_payload_kg` (вага запиту — тонни, ліміт лота — кілограми, розділ A.3.2), габарити в межах, не НГ (ADR), не живі тварини.
5. Запит `ДРОН` до трьох бірж (`X → П2`). Якщо лота немає — повторний запит `X → *`, підстановка найближчої зареєстрованої площадки `П2′` і догруз `авто П2′ → П2`; це єдиний випадок, коли **модуль `POST_DRONE`** додає плече до вже зібраного маршруту. Плече додається лише якщо загальна кількість не перевищує `max_legs` = 4; інакше ворота 5 повертають `max_legs_exceeded` (T20). Мультимодальні гілки самі теж можуть давати чотириплечові маршрути (три перевантаження, A.6.9) — тоді дрон-варіант для такого маршруту не будується.
6. Погода у вікні вильоту (`вітер ≤ drone_wind_limit_ms`, без опадів та обледеніння). Ця перевірка **не відсіює** варіант: за її невиконання варіант видається з прапорцем `weather_risk = true` і знижується в ранжуванні. Якщо сервіс погоди недоступний, погода вважається **невідомою**: `weather.ok = null`, варіант видається з тим самим прапорцем `weather_risk = true` і відповідь отримує `partial: true` (принцип чесності даних, розділ A.11) — вигадувати сприятливу погоду заборонено.

```
POST_DRONE(route):
    leg = route.legs[-1]
    if leg.mode != 'auto': return None
    if not permit_ok(leg.from, leg.to): return None            # no_permit
    if dist(leg.from, leg.to) > range_km * drone_range_margin: return None
    if V.weight_t * 1000 > max_payload_kg: return None         # ворота 4: тонни → кг
    if not cargo_ok(V): return None                            # габарити, ADR, живі тварини
    d = QUERY('drone', leg.from, leg.to)                        # 3 біржі
    if d.empty:
        pads = QUERY('drone', leg.from, '*')
        pad  = NEAREST(pads, leg.to, kind='drone_pad')
        if not pad: return None
        tail = LEG_EXISTS('auto', pad, leg.to)
        if not tail: return CTA('створити заявку', pad, leg.to)
        alt = route.replace_last(['drone': leg.from→pad, 'auto': pad→leg.to])
    else:
        alt = route.replace_last(['drone': leg.from→leg.to])
    alt.weather_risk = not weather_ok(alt)
    return alt                                                  # авто-варіант НЕ видаляється
```

Обидва варіанти — авто й дрон — видаються учаснику паралельно; вибір за ним. Магістральним або середнім плечем дрон бути не може.

### A.6.9. Групи схем маршрутів і графічні еталони

Виходи блок-схеми відповідають графічним схемам маршрутів (файли-еталони, надані до ТЗ). Кожна схема має коефіцієнт переваги: кількість перевантажень мінус 1 за кожне використання залізниці (найекономічніший вид). Менший коефіцієнт — вищий пріоритет.

| Еталон | Схема маршруту | Перевантажень | Коеф. переваги |
|---|---|---|---|
| `s1` | море | 0 | 0 |
| `s11` | жд → море | 1 | 0 |
| `s12` | море → жд | 1 | 0 |
| `s13` | авто → море | 1 | 1 |
| `s14` | море → авто | 1 | 1 |
| `s21` | жд → море → жд | 2 | 0 |
| `s22` | авто → жд → море | 2 | 1 |
| `s23` | море → жд → авто | 2 | 1 |
| `s24` | авто → море → авто | 2 | 2 |

Схеми з трьома перевантаженнями (`авто → жд → море → жд`, `жд → море → жд → авто`, `авто → море → жд → авто`, `авто → жд → море → авто`) генеруються тим самим графом при `max_transships = 3` і отримують коефіцієнти переваги 1, 1, 2 та 2 відповідно. Це стеля моделі: чотири перевантаження означають п'ять плечей, що суперечить `max_legs` = 4 (A.6.7) і відхиляється схемою контракту (`legs.maxItems` = 4). Схема `авто → жд → море → жд → авто` можлива лише після підняття `max_legs` до 5 і в межах цього ТЗ не реалізується.

Для авіаційної гілки перелік схем ідентичний із заміною `море → авіа`; для дронів варіант отримують **лише ті схеми, останнє плече яких — авто** (`s14`, `s23`, `s24`): заміна останнього авто-плеча на `дрон`, коефіцієнт переваги `+1`. Схеми, що завершуються морем або залізницею (`s1`, `s11`, `s12`, `s13`, `s21`, `s22`), дрон-варіанта не мають — це ворота 1 модуля `POST_DRONE` (A.6.8).

### A.6.10. Підстановка найближчого пункту «по координатах»

Вузол `SUBSTITUTE` реалізується геозапитом:

```sql
-- nearest_points(P, kind, radius_km, limit, exclude[])
SELECT p.id, p.name, p.country_code,
       ST_Distance(p.geo, (SELECT geo FROM points WHERE id = :P))/1000 AS dist_km
FROM points p
WHERE p.kind = :kind AND p.is_active
  AND p.id <> ALL(:exclude)
  AND ST_DWithin(p.geo, (SELECT geo FROM points WHERE id = :P), :radius_km*1000)
ORDER BY p.geo <-> (SELECT geo FROM points WHERE id = :P)
LIMIT :limit;
```

Ітеративне розширення радіуса: `radius_km` збільшується кроками `radius_steps_km` (дефолт 100 → 300 → 500) до `radius_max_km`, поки не знайдено пункт із наявним сполученням у потрібному напрямку. `П21` — перший кандидат, `П2/2` — наступний (перший додається в `exclude`). Це реалізує умову «пошук здійснюємо доти, доки не вийдемо за вказані рамки (віддаленість у кілометрах від пункту)».

Пункт вважається придатним, якщо: збігається тип (`seaport` для морського плеча, `rail_station` для залізничного, `drone_pad` для дрона), він активний, і в `link_stats` є сполучення в потрібному напрямку хоча б за 365 днів.

### A.6.11. Стикування плечей

Маршрут вважається зібраним, якщо для кожної пари сусідніх плечей виконується:

\[ t^{arr}_i + w_i \le t^{dep}_{i+1} \le t^{arr}_i + w_i + \text{max\_dwell} \]

де \( w_i \) — час перевантаження в пункті (довідник `handling_costs`), `max_dwell` — максимально допустимий простій (дефолт 72 год). Маршрут, який не проходить перевірку прибуття до `arrive_by`, позначається `late: true` і виводиться в окремому блоці, а не відкидається.

### A.6.12. Спрощений випадок: один вид транспорту (L0)

Виконується **до** входу в блок-схему:

1. Прямий пошук: заявки `П1 → П2` у трьох біржах відповідного розділу, вікно дат перетинається з `Tin1`, параметри вантажу допустимі.
2. Якщо результату немає — пошук зворотних рейсів: заявки `П2 → П1`, де `Tin1` трактується як час **прибуття** в `П1` (успадкована евристика: транспорт, що заїхав у пункт, з нього і виїжджатиме).
3. Якщо результату немає або він незадовільний (розділ A.6.13) — вхід у блок-схему (A.6.4).

### A.6.13. Критерій «незадовільного результату»

Формалізація успадкованої умови «результат незадовільний (не задовольняє користувача, кількість пропозицій мала, рейтинги фірми перевізника низькі)». Результат вважається незадовільним, якщо виконується будь-яка умова:

- кількість варіантів < `min_offers` (дефолт 3);
- усі варіанти мають рейтинг компанії < `min_rating` (дефолт 2 з 5);
- усі варіанти виходять за `arrive_by` або за `budget_max`;
- користувач натиснув «шукати інші варіанти».

При незадовільному результаті обхід переходить до наступної гілки або до наступного кандидата підстановки.

### A.6.14. Fallback: компанії напрямку

Виконується, коли блок-схема повністю обійдена і жодного маршруту не зібрано. У процесі обходу система вже знайшла заявки, які **втратили актуальність** на момент запиту; ці компанії, ймовірно, досі працюють у напрямку. Вивести їхні реквізити, рейтинг, дату останньої заявки і кількість заявок за 365 днів (`lots_history` + `link_stats`), сортування за рейтингом і свіжістю.

### A.6.15. Fallback: можливі сполучення

Останній рівень. Вивести пункти (порти, станції, аеропорти, майданчики дронів) біля `П1` і `П2` та наявні між ними сполучення з `link_stats`, навіть без активних заявок — щоб користувач міг спланувати маршрут вручну або створити власну заявку на потрібне плече.

### A.6.16. Схема обходу (компактне подання)

Текстовий еквівалент рисунків 3.1.1-2 … 3.1.1-7 для швидкого читання; за розбіжностей нормативними є рисунки.

```
INPUT(П1, П2, види транспорту)
   │
   ├─ L0: прямий пошук (1 вид) ────── знайдено і задовільно → OUTPUT, стоп
   │        └─ зворотні рейси
   │
   ├─ Гілка МОРЕ (або АВІА)
   │     QUERY(море: 3 біржі) ── П=П1 & П=П2 → OUTPUT «3-ох Б-р море»
   │       ├─ П=П1, П≠П2 → SUBSTITUTE(П21) → QUERY(жд) ── є → OUTPUT (море+жд)
   │       │                                    └─ нема → SUBSTITUTE(П2/2) → QUERY(авто)
   │       │                                                 ├─ є → OUTPUT (море+жд+авто)
   │       │                                                 └─ нема → OUTPUT частковий
   │       └─ П≠П1 → SUBSTITUTE(П11) → дзеркальна логіка підвозу
   │
   ├─ Гілка ЗАЛІЗНИЦЯ  (та сама структура, магістраль = жд, фідер = авто)
   ├─ Гілка АВТО       (та сама структура, без фідера)
   ├─ Гілка АВІА       (структура гілки МОРЕ, магістраль = авіа, фідер = авто)
   │
   ├─ POST_DRONE (пост-обробка кожного зібраного варіанта, рисунок 3.1.1-7)
   │     шість воріт → дрон-варіант поряд із авто-варіантом або відмова з кодом
   │
   ├─ Fallback 1: компанії напрямку
   └─ Fallback 2: можливі сполучення
```

---

## A.7. Ранжування результатів

Кожен маршрут отримує підсумковий скор:

\[ S(R) = w_c \cdot \hat{C}(R) + w_t \cdot \hat{T}(R) + w_k \cdot \hat{K}(R) + w_r \cdot (1 - \hat{Rt}(R)) + w_n \cdot \frac{N_{tr}}{N_{max}} \]

де \( \hat{X} \) — min-max нормалізація, \( K \) — коефіцієнт переваги (A.6.9), \( Rt \) — середній рейтинг компаній маршруту. Менший скор — вищий пріоритет.

**База нормалізації — лише варіанти гілок** (`origin` не дорівнює `post_drone`). Дрон-варіанти нормалізуються за тими самими min/max, але в базу не входять. Це обов'язкова умова: інакше додавання дрон-варіанта змінювало б `score` усіх варіантів гілок і НФВ «незалежність від гілок» (розділ A.11, сценарій T24) була б недосяжною. База не може бути порожньою за наявності хоча б одного дрон-варіанта: батьківський варіант гілки завжди лишається у видачі (правило 1 розділу A.8.4), а `variant_of` кожного дрон-варіанта обов'язково вказує на присутній маршрут. Отже випадок «усі варіанти — дрон-варіанти» неможливий; якщо ж `routes` порожній, нормалізація не виконується взагалі.

| Критерій користувача | \(w_c\) | \(w_t\) | \(w_k\) | \(w_r\) | \(w_n\) |
|---|---|---|---|---|---|
| `cost` | 0.55 | 0.10 | 0.15 | 0.10 | 0.10 |
| `time` | 0.10 | 0.55 | 0.10 | 0.10 | 0.15 |
| `balanced` | 0.30 | 0.30 | 0.15 | 0.15 | 0.10 |

До скора додається штраф за прапорці-попередження:

\[ S'(R) = S(R) + \sum_j p_j \cdot 1[\text{flag}_j \in R] \]

| Прапорець | \(p_j\) (дефолт) | Обґрунтування |
|---|---|---|
| `weather_risk` | 0.08 | Ризик погоди або невідома погода у дрон-плечі (див. обмеження порядку нижче) |
| `no_permit` | 0.10 | Плече без дозволу вимагає дій учасника |
| `customs_missing` | 0.06 | Документ по плечу відсутній |
| `estimated_price` | 0.03 | Вартість не з лота, а з медіан `link_stats` |

Штрафи не гарантують порядок самі по собі (дешевший дрон-варіант може перекрити 0,08 різницею у вартості), тому додається **жорстке обмеження порядку**: дрон-варіант із `weather_risk` ніколи не стоїть у видачі вище свого батьківського маршруту `variant_of`. Саме воно перевіряється сценарієм T21.

**Процедура впорядкування видачі (нормативна).** Обмеження порядку не можна реалізувати ні в `ORDER BY`, ні у звичайному порівнянні при сортуванні: штрафи за прапорці й самі дрон-варіанти зʼявляються **після** запиту до бази, у пост-обробці, а критерій «стояти нижче конкретного елемента» не є функцією від одного маршруту, тому не задає відношення порядку. Через це процесор видачі виконує рівно таку послідовність кроків, і саме її порядок є частиною контракту:

1. **Збір.** Зібрати всі маршрути гілок і додані модулем `POST_DRONE` варіанти в один список. Порядок, у якому їх повернула база, не має значення й не є гарантованим.
2. **Скор.** Обчислити \( S'(R) \) для кожного маршруту — нормалізація за `normalization_base`, ваги критерію, потім штрафи за прапорці. Далі скор не змінюється.
3. **Стабільне сортування.** Відсортувати список за зростанням \( S'(R) \) **стабільним** алгоритмом; при рівних скорах порядок задає `route_id` за зростанням. Стабільність і тай-брейк обовʼязкові: без них два запуски на тих самих даних можуть дати різну видачу, і сценарії T21, T26 стануть недетермінованими.
4. **Позначення закріплень.** Для кожного дрон-варіанта з прапорцем `weather_risk` виставити `order_pinned_below: <variant_of>`. Поле допустиме лише у варіанта з `origin: "post_drone"`.
5. **Пас закріплення (ре-індексування).** Для кожного закріпленого варіанта в порядку зростання позиції його якоря: якщо позиція варіанта менша за позицію якоря — **вилучити** варіант зі списку і **вставити безпосередньо після якоря**; позиції решти елементів перерахувати. Це не обмін сусідів: варіант може стояти на кілька позицій вище якоря, і обмін лише сусідніх елементів не виправив би порядок.
6. **Завершення за один прохід.** Якорем завжди є батьківський маршрут гілки, а він сам ніколи не закріплений (`order_pinned_below` допустиме лише у `post_drone`), тому ланцюжків закріплень не буває, і кожен варіант переміщується щонайбільше один раз. Кількість переміщень не перевищує кількості дрон-варіантів; повторні проходи не потрібні.
7. **Видача.** Масив `routes` віддається клієнту вже у фінальному порядку. Жодних полів позиції в контракті немає: **позиція в масиві і є рангом**.

Вимога до фронтенду: список рендериться в порядку, у якому надійшов. Сортування за колонкою на клієнті допустиме лише як явний режим «сортування користувача» з видимою позначкою, і в цьому режимі значок обмеження порядку зберігається біля закріпленого варіанта, щоб користувач бачив, що дрон-варіант із ризиком погоди стоїть вище батьківського маршруту через його власне сортування, а не за оцінкою системи. Автоматичне перевпорядкування без такого режиму заборонене.

Перевіряється це двома програмними перевірками CI (розділ A.8.4.3): монотонність скора по масиву та фактична позиція закріпленого варіанта відносно свого якоря, включно з випадком, коли якір узагалі відсутній у видачі.

Ваги \(w\) і штрафи \(p\) зберігаються в конфігурації і редагуються адміністратором без правки коду.

Додатково до кожного маршруту виводяться прапорці-попередження: `weather_risk` (для дронів), `no_permit`, `customs_missing` (у плечі немає потрібного документа CMR/T1/TIR), `low_rating`, `blacklisted`, `estimated_price`.

---

## A.8. API

### A.8.1. Пошук заявок (S1–S3)

```
POST /api/v1/search/lots
{
  "mode": "A",
  "origin": {"point_id": 1042},
  "destination": {"country": "PL", "region": "Mazowieckie"},
  "depart_from": "2026-09-01", "depart_to": "2026-09-05",
  "weight_t": 18.5, "volume_m3": 82,
  "transport_type": "тент", "load_kind": "задняя", "load_fill": "full",
  "customs": ["CMR","T1"],
  "include_reverse": true,
  "sort": "fresh",
  "page": 1, "per_page": 50
}
```

Відповідь: `items[]` з полями лота, `company{id,name,rating,badges,is_blacklisted}`, `can_bid`, `bids_count`, `contacts` (або `null` для безкоштовного акаунта), `is_reverse` (лот знайдено як зворотний рейс), `freshness_color` (успадковане колірне маркування за часом створення).

### A.8.2. Розширений фільтр «інші напрямки» (S2)

```
POST /api/v1/search/lots
{ "mode":"T",
  "origin_set":      [{"country":"UA"},{"country":"PL"}],
  "destination_set": [{"country":"DE"},{"country":"UA"}],
  ... }
```

Одна й та сама країна може бути в обох списках — тоді пошук виконується по території цієї країни (успадкована вимога фільтрів).

### A.8.3. Пошук маршруту (S4–S7)

```
POST /api/v1/search/routes
{
  "origin": {"text":"Луцьк, UA"},
  "destination": {"text":"Shanghai, CN"},
  "depart_from":"2026-09-10", "arrive_by":"2026-11-01",
  "weight_t": 24, "volume_m3": 60, "cargo_type":"контейнерный груз",
  "modes": ["A","T","M","D"],
  "criterion": "cost",
  "max_transships": 3,
  "radius_km": {"origin": 300, "destination": 300},
  "min_rating": 2,
  "drone": {"allow": true, "allow_extra_leg": true, "diagnostics": false}
}
```

Відповідь:

```json
{
  "search_id": "srch_7f31",
  "branch_reached": "море → жд → авто",
  "routes": [{
    "route_id":"rt_1",
    "scheme":"A — авто A1 — ЖД A2 — море — B",
    "transships": 2,
    "preference_coeff": 1,
    "total_cost_eur": 4120.00, "cost_known": true, "estimated_price": false,
    "total_time_h": 742,
    "score": 0.31,
    "warnings": [],
    "legs":[
      {"mode":"A","from":{"id":1042,"name":"Луцьк"},"to":{"id":1180,"name":"Ковель"},
       "lot_id":88121,"depart":"2026-09-10T08:00Z","arrive":"2026-09-10T12:00Z",
       "company":{"id":55,"name":"...","rating":4.2},"cost_eur":180},
      {"mode":"T","...":"..."},
      {"mode":"M","...":"..."}
    ]
  }],
  "companies_on_direction": [],
  "possible_links": []
}
```

Запити з двома і більше перевантаженнями виконуються асинхронно: `202 Accepted` + `search_id`, результат забирається через `GET /api/v1/search/routes/{search_id}` або приходить через WebSocket/SSE.

### A.8.4. Контракт модуля `POST_DRONE` (рисунок 3.1.1-7)

Модуль не має власного ендпоїнта: він виконується всередині `/search/routes` після гілок і **додає** елементи в той самий масив `routes`. Дрон-варіант — окремий об'єкт маршруту, а не модифікація існуючого.

Вхід (блок `drone` запиту A.8.3):

| Поле | Тип | Дефолт | Призначення |
|---|---|---|---|
| `drone.allow` | bool | `true`, якщо `"D"` у `modes` | Вимкнення модуля без зміни `modes` |
| `drone.allow_extra_leg` | bool | `true` | Дозвіл на четверте плече через підстановку площадки (ворота 5) |
| `drone.diagnostics` | bool | `false` | Повернути результат кожних воріт (для тестів T16–T27 і адмін-режиму) |

Вихід — додатковий об'єкт у `routes`:

```json
{
  "route_id": "rt_1_d",
  "variant_of": "rt_1",
  "origin": "post_drone",
  "scheme": "A — авто A1 — ЖД A2 — море — дрон — B",
  "transships": 3,
  "preference_coeff": 2,
  "warnings": ["weather_risk"],
  "legs": [
    {"mode": "M", "...": "..."},
    {"mode": "D", "from": {"id": 1180, "kind": "city"},
     "to": {"id": 9041, "kind": "drone_pad", "name": "Майданчик Кремінна-1"},
     "lot_id": 91277, "range_used_km": 12.4, "payload_kg": 8,
     "permit_id": "UA-DR-2026-0417", "weather": {"wind_ms": 15, "ok": false}}
  ]
}
```

Правила контракту, обов'язкові до перевірки:

1. Авто-варіант `rt_1` **залишається** у `routes` завжди. Дрон-варіант не замінює й не видаляє його — інваріант розділу A.12.1.
2. `variant_of` вказує на батьківський маршрут; `origin: "post_drone"` відрізняє додані модулем варіанти від варіантів гілок.
3. `weather_risk` — елемент `warnings`, а не причина відмови: варіант присутній у видачі, отримує штраф 0,08 і не може стояти вище свого `variant_of` (розділ A.7). Значення `weather.ok`: `true` — норма, `false` — порушено порог, `null` — сервіс погоди недоступний (у відповіді додатково `partial: true`).
4. Коли модуль не створив варіанта, у масив `routes` **не додається нічого** — жодних порожніх об'єктів. Причина повертається лише при `drone.diagnostics: true`.
5. Виняток із п. 4 — два поля, які повертаються завжди:
   - `no_permit` дописується у `warnings` **батьківського авто-маршруту** (`rt_1`), бо це попередження для учасника, а не внутрішня деталь;
   - `drone_cta` (див. нижче) — коли площадка знайдена, але авто-лота `П2′ → П2` немає.
6. Масив `routes` повертається **у порядку ранжування**: за зростанням `score`, де `score` — підсумковий скор \( S'(R) \) після штрафів за прапорці. Єдиний виняток — жорстке обмеження порядку: варіант із `order_pinned_below` стоїть після вказаного маршруту навіть за меншого `score`. Покрокова процедура впорядкування — стабільне сортування, пас закріплення з вилученням і вставкою після якоря, ре-індексування — задана в розділі A.7 («Процедура впорядкування видачі»); позиція в масиві і є рангом, клієнт не перевпорядковує видачу самовільно.
7. Перевищення бюджету модуля (розділ A.11): у відповіді `partial: true`, `drone_skipped: "timeout"`, масив `routes` містить лише варіанти гілок. Це не помилка запиту — код відповіді не змінюється.

Коди причин (`drone_diagnostics[].reason`) прив'язані до воріт рисунка 3.1.1-7:

| Ворота | Код причини | Сценарій |
|---|---|---|
| 1 | `not_last_auto_leg` | T16 |
| 2 | `no_permit` | T17 |
| 3 | `out_of_range` | T18 |
| 4 | `cargo_not_allowed` | T19 |
| 5 | `no_drone_lot` / `no_landing_pad` / `max_legs_exceeded` / `extra_leg_disabled` | T20, T22, T23 |
| 6 | — (не відмова, прапорець `weather_risk`) | T21 |
| — | `drone_disabled` (`drone.allow: false`) | T24 |
| — | `timeout` (бюджет розділу A.11) | T25 |

`extra_leg_disabled` повертається, коли `drone.allow_extra_leg: false`, а прямого лота `X → П2` немає: підстановка площадки заборонена запитом, тому модуль зупиняється на воротах 5.

```json
"drone_diagnostics": [
  {"parent_route": "rt_2", "gate": 3, "reason": "out_of_range",
   "detail": {"distance_km": 42, "limit_km": 40}}
]
```

`drone_diagnostics` — поле верхнього рівня відповіді `/search/routes`; при `diagnostics: false` воно відсутнє (не порожній масив). Поля `partial`, `drone_skipped` і `drone_cta` — також верхнього рівня.

Окремий випадок «сполучення є, лота немає» (він же червоний вузол рисунка 3.1.1-7):

```json
"drone_cta": {
  "parent_route": "rt_1",
  "pad": {"id": 9041, "name": "Майданчик Кремінна-1", "distance_to_dest_km": 6.0},
  "missing_leg": {"mode": "A", "from": 9041, "to": 1205},
  "action": "create_lot"
}
```

#### A.8.4.1. Приклади відповідей за сценаріями T16–T27

Скорочені відповіді `/search/routes`. Поля, не істотні для сценарію, замінені на `"..."`. У всіх прикладах `rt_1` — варіант гілки (авто в останньому плечі), `rt_1_d` — дрон-варіант. Приклади нормативні: тест вважається пройденим, коли структура відповіді збігається з наведеною.

**T16 · ворота 1 — останнє плече не авто.** Запит із `drone.diagnostics: true`, останнє плече `T` (ЖД).

```json
{ "search_id":"srch_t16", "partial": false,
  "routes":[ {"route_id":"rt_1","origin":"branch","legs":[{"mode":"M"},{"mode":"T"}],"warnings":[]} ],
  "drone_diagnostics":[ {"parent_route":"rt_1","gate":1,"reason":"not_last_auto_leg",
                         "detail":{"last_leg_mode":"T"}} ] }
```

**T17 · ворота 2 — зона без дозволу.** `no_permit` — у `warnings` батьківського маршруту, повертається навіть без діагностики.

```json
{ "search_id":"srch_t17", "partial": false,
  "routes":[ {"route_id":"rt_1","origin":"branch",
              "legs":[{"mode":"M"},{"mode":"A","from":1180,"to":1205}],
              "warnings":["no_permit"]} ],
  "drone_diagnostics":[ {"parent_route":"rt_1","gate":2,"reason":"no_permit",
                         "detail":{"zone":"border_strip","permit_id":null}} ] }
```

**T18 · ворота 3 — поза запасом дальності.** `range_km` = 50, множник 0,8, відстань 42 км.

```json
{ "routes":[ {"route_id":"rt_1","origin":"branch","warnings":[]} ],
  "drone_diagnostics":[ {"parent_route":"rt_1","gate":3,"reason":"out_of_range",
                         "detail":{"distance_km":42,"limit_km":40,"range_km":50,
                                   "margin":0.8}} ] }
```

При відстані 39 км відповідь містить `rt_1` і `rt_1_d`, `drone_diagnostics` порожнє поле відсутнє.

**T19 · ворота 4 — вантаж недопустимий.** Три підваріанти дають один код із різним `detail`.

```json
{ "routes":[ {"route_id":"rt_1","origin":"branch","warnings":[]} ],
  "drone_diagnostics":[ {"parent_route":"rt_1","gate":4,"reason":"cargo_not_allowed",
                         "detail":{"rule":"payload","weight_kg":80,"max_payload_kg":20}} ] }
```

`detail.rule` для підваріантів (б) і (в): `"adr"` з `{"adr_class":3}` та `"live_animals"`.

**T20 · ворота 5 — четверте плече через площадку.** Успішний випадок, `max_legs` = 4.

```json
{ "routes":[
    {"route_id":"rt_1","origin":"branch","transships":2,"preference_coeff":1,"warnings":[]},
    {"route_id":"rt_1_d","variant_of":"rt_1","origin":"post_drone",
     "transships":3,"preference_coeff":2,"warnings":[],
     "legs":[{"mode":"M"},{"mode":"T"},
             {"mode":"D","to":{"id":9041,"kind":"drone_pad"},"range_used_km":18.2},
             {"mode":"A","from":9041,"to":1205,"lot_id":88990}]} ] }
```

При `max_legs` = 3 — `{"gate":5,"reason":"max_legs_exceeded","detail":{"legs_required":4,"max_legs":3}}`, `rt_1_d` відсутній. При `drone.allow_extra_leg: false` — той самий формат із `reason: "extra_leg_disabled"`.

**T21 · ворота 6 — ризик погоди, дрон дешевший.** Обмеження порядку розділу A.7: `rt_1_d` стоїть у масиві **після** `rt_1`, попри менший `total_cost_eur`.

```json
{ "routes":[
    {"route_id":"rt_1","origin":"branch","total_cost_eur":1200,"score":0.42,"warnings":[]},
    {"route_id":"rt_1_d","variant_of":"rt_1","origin":"post_drone",
     "total_cost_eur":840,"score":0.31,"order_pinned_below":"rt_1",
     "warnings":["weather_risk"],
     "legs":["...",{"mode":"D","weather":{"wind_ms":15,"ok":false,
                                          "limit_ms":12}}]} ] }
```

Поле `order_pinned_below` — ознака застосованого обмеження; клієнт не має права перевпорядковувати такий варіант вище вказаного. При вітрі 8 м/с поля `order_pinned_below` і `weather_risk` відсутні, сортування звичайне.

**T22 · немає ні лота, ні площадки.** Мінімальна відповідь: жодного дрон-поля.

```json
{ "search_id":"srch_t22", "partial": false,
  "routes":[ {"route_id":"rt_1","origin":"branch","warnings":[]} ] }
```

З `diagnostics: true` додається `[{"gate":5,"reason":"no_drone_lot"},{"gate":5,"reason":"no_landing_pad"}]`.

**T23 · площадка є, авто-лота з неї немає — `drone_cta`.** Повертається завжди, без діагностики.

```json
{ "routes":[ {"route_id":"rt_1","origin":"branch","warnings":[]} ],
  "drone_cta":{ "parent_route":"rt_1",
                "pad":{"id":9041,"name":"Майданчик Кремінна-1","distance_to_dest_km":6.0},
                "missing_leg":{"mode":"A","from":9041,"to":1205},
                "action":"create_lot" } }
```

**T24 · модуль вимкнено.** Два запити, порівнюється масив `routes` без об'єктів `origin: "post_drone"`.

```json
{ "routes":[ {"route_id":"rt_1","origin":"branch","score":0.42,"warnings":[]} ],
  "drone_diagnostics":[ {"reason":"drone_disabled"} ] }
```

Умова проходження: серіалізований `routes` (без дрон-варіантів) у відповідях із `drone.allow: true` і `false` — байт у байт однаковий, включно з `score` і порядком.

**T25 · перевищено бюджет модуля.** Код відповіді той самий (`200` або `202` для асинхронного).

```json
{ "search_id":"srch_t25", "partial": true, "drone_skipped":"timeout",
  "routes":[ {"route_id":"rt_1","origin":"branch","score":0.42,"warnings":[]},
             {"route_id":"rt_2","origin":"branch","score":0.55,"warnings":[]} ],
  "drone_diagnostics":[ {"reason":"timeout",
                         "detail":{"budget_ms":360,"spent_ms":404}} ] }
```

**T26 · сервіс погоди недоступний.** Варіант видається, погода — невідома, значення не вигадуються.

```json
{ "partial": true,
  "routes":[
    {"route_id":"rt_1","origin":"branch","warnings":[]},
    {"route_id":"rt_1_d","variant_of":"rt_1","origin":"post_drone",
     "warnings":["weather_risk"],"order_pinned_below":"rt_1",
     "legs":["...",{"mode":"D","weather":{"wind_ms":null,"ok":null,
                                          "source_error":"weather_service_unavailable"}}]} ] }
```

**T27 · база нормалізації.** Три варіанти гілок і два дрон-варіанти; `score` гілок не залежить від наявності дрон-варіантів.

```json
{ "routes":[
    {"route_id":"rt_1_d","variant_of":"rt_1","origin":"post_drone","score":0.28},
    {"route_id":"rt_1","origin":"branch","score":0.31},
    {"route_id":"rt_2","origin":"branch","score":0.47},
    {"route_id":"rt_3","origin":"branch","score":0.62},
    {"route_id":"rt_3_d","variant_of":"rt_3","origin":"post_drone","score":0.71} ],
  "normalization_base":["rt_1","rt_2","rt_3"] }
```

Поле `normalization_base` повертається при `drone.diagnostics: true` і робить перевірку T27 машинною: у ньому не може бути жодного `route_id` з `origin: "post_drone"`. Це перелік ідентифікаторів, а не валюта: код базової валюти несе окреме поле `base_currency_code` (розділ A.5), яке не залежить від `diagnostics`.

Зведення полів, які з'являються лише в дрон-сценаріях:

| Поле | Рівень | Коли присутнє | Сценарій |
|---|---|---|---|
| `variant_of` | об'єкт `routes[]` | у кожному дрон-варіанті | T20, T21, T26, T27 |
| `origin` | об'єкт `routes[]` | завжди (`branch` або `post_drone`) | усі |
| `order_pinned_below` | об'єкт `routes[]` | коли застосовано обмеження порядку | T21, T26 |
| `drone_cta` | верхній | площадка є, авто-лота немає | T23 |
| `drone_skipped` | верхній | перевищено бюджет | T25 |
| `partial` | верхній | недоступне джерело або таймаут | T25, T26 |
| `drone_diagnostics` | верхній | лише при `diagnostics: true` | T16–T20, T22, T24, T27 |
| `normalization_base` | верхній | лише при `diagnostics: true` | T27 |

Поле `base_currency_code` у цю таблицю не входить: воно не дрон-специфічне й повертається з кожною відповіддю, що містить нормалізовані суми (розділ A.5).

#### A.8.4.2. JSON Schema контракту

Машинозчитувана специфікація: `schemas/post-drone.schema.json` (JSON Schema draft 2020-12, `$id` = `post-drone-1.0.json`). Схема — нормативна частина контракту: приймання розділу A.12.1 виконується її валідатором, а не візуальним порівнянням.

Схема описує дрон-частину відповіді `/search/routes` і залишає решту полів вільними (`additionalProperties: true`), тому її можна застосовувати до повної відповіді без опису всього API.

Формалізовані обмеження (кожне — умовна схема `if/then`, а не комментар):

| Обмеження | Джерело в ТЗ |
|---|---|
| `origin: "post_drone"` вимагає `variant_of`; варіант гілки не має права його мати | A.8.4, правило 2 |
| `weather_risk` у дрон-варіанті вимагає `order_pinned_below` | розділ A.7, обмеження порядку |
| `order_pinned_below` допустиме лише у варіанта з `origin: "post_drone"` | розділ A.7 |
| `drone_skipped` вимагає `partial: true` і забороняє дрон-варіанти у видачі | розділ A.11, бюджет |
| `weather.ok = null` вимагає `source_error` і `wind_ms = null` | розділ A.11, чесність даних |
| Коди воріт 1–5 вимагають поля `gate`; `drone_disabled` і `timeout` — забороняють | A.8.4, таблиця причин |
| `normalization_base` не приймає ідентифікаторів дрон-варіантів | розділ A.7, база нормалізації |
| `base_currency_code` — рядок `^[A-Z]{3,4}$`; код валюти в `normalization_base` відхиляється як `route_id` невірного формату | розділ A.5 |
| `legs` — не більше 4 елементів; плече з `mode: "D"` валідується як дрон-плече | A.6.7, A.6.8 |
| `warnings` і `reason` — закриті перерахування, довільні значення відхиляються | розділ A.7, A.8.4 |

Фрагмент, що ілюструє спосіб запису обмежень:

```json
"allOf": [
  { "if":   { "properties": { "origin": { "const": "post_drone" } },
              "required": ["origin"] },
    "then": { "required": ["variant_of"] },
    "else": { "not": { "required": ["variant_of"] } } },
  { "if":   { "properties": { "origin": { "const": "post_drone" },
                              "warnings": { "contains": { "const": "weather_risk" } } },
              "required": ["origin","warnings"] },
    "then": { "required": ["order_pinned_below"] } }
]
```

Порядок приймання контракту:

1. Усі дванадцять прикладів розділу A.8.4.1 валідні за схемою (позитивні кейси).
2. Набір негативних кейсів відхиляється схемою — по одному на кожне обмеження таблиці вище: дрон-варіант без `variant_of`, варіант гілки з `variant_of`, `weather_risk` без `order_pinned_below`, `order_pinned_below` у варіанта гілки, `drone_skipped` без `partial`, дрон-варіант при таймауті, `weather.ok = null` без `source_error`, невідомий код причини, код воріт без `gate`, `timeout` із `gate`, п'ять плечей, дрон-варіант у `normalization_base`, порожній масив `drone_diagnostics`, невідомий прапорець у `warnings`.
3. Кожен сценарій T16–T27 у CI перевіряється двома крокaми: відповідь валідна за схемою **і** містить очікувані значення (`reason`, `order_pinned_below`, `drone_cta`, `score`).

#### A.8.4.3. Результат перевірки схеми на сценаріях T16–T27

Перевірка виконується скриптом `schemas/check_schema.py`: він читає приклади безпосередньо з розділу A.8.4.1 цього документа, валідує кожен схемою (позитивний бік), після чого вносить у кожен приклад точкові пошкодження, які порушують саме те правило, що перевіряє відповідний сценарій, і вимагає їх відхилення (негативний бік).

| Сценарій | Позитив | Відхилено мутацій | З них програмною перевіркою |
|---|---|---|---|
| T16 | так | 3 з 3 | 1 — `assert_no_variant_after_refusal` |
| T17 | так | 2 з 2 | 0 |
| T18 | так | 2 з 2 | 0 |
| T19 | так | 1 з 1 | 0 |
| T20 | так | 4 з 4 | 0 |
| T21 | так | 5 з 5 | 2 — `assert_pinned_after_anchor` |
| T22 | так | 2 з 2 | 0 |
| T23 | так | 6 з 6 | 2 — `assert_cta_consistency` |
| T24 | так | 2 з 2 | 0 |
| T25 | так | 4 з 4 | 0 |
| T26 | так | 4 з 4 | 1 — `assert_pinned_after_anchor` |
| T27 | так | 2 з 2 | 0 |

Разом: 12 позитивних прикладів валідні, 37 із 37 мутацій відхилено — 31 схемою, шість програмними перевірками (див. нижче). Скрипт повертає ненульовий код виходу за будь-якої з трьох умов: невалідний позитивний приклад, порушення програмної перевірки, непокрита мутація.

**Обмеження схеми, яке треба покривати кодом тесту.** JSON Schema не вміє звіряти значення між різними масивами: правило «якщо для маршруту `rt_N` діагностика містить відмову воріт 1–5, то у видачі не може бути варіанта з `variant_of: "rt_N"`» вимагає зіставлення `parent_route` з `variant_of`, чого мова схеми не виражає. Загальна заборона тут неприпустима: відмова для `rt_1` цілком сумісна з наявністю дрон-варіанта для `rt_2`. Тому сценарії T16, T18 і T19 додатково містять програмну перевірку:

```python
refused = {d["parent_route"] for d in resp.get("drone_diagnostics", [])
           if d.get("gate") in (1, 2, 3, 4, 5)}
assert not [r for r in resp["routes"] if r.get("variant_of") in refused]
```

Правила ж «код `no_permit` у діагностиці зобовʼязує повернути прапорець `no_permit` у видачі» і «`drone_disabled` виключає дрон-варіанти» виражені через конструкцію `contains` і перевіряються самою схемою.

**Склад програмних перевірок CI.** Реалізовані у `schemas/check_schema.py` і застосовуються до відповіді після успішної валідації схемою:

| Перевірка | Правило | Сценарії |
|---|---|---|
| `assert_no_variant_after_refusal` | відмова воріт 1–5 для `rt_N` виключає варіант із `variant_of: "rt_N"`; звіряються пари, а не наявність дрон-варіантів узагалі | T16, T18, T19 |
| `assert_parent_present` | батьківський маршрут із `variant_of` присутній у тій самій видачі (правило 2 розділу A.8.4) | T17, T20, T21, T26, T27 |
| `assert_diag_parents_known` | кожен `parent_route` діагностики існує у видачі | T16–T19, T23 |
| `assert_cta_consistency` | `drone_cta.parent_route` існує у видачі, і для цього маршруту **немає** дрон-варіанта: пропозиція створити лот несумісна з готовим варіантом | T22, T23 |
| `assert_pinned_after_anchor` | маршрут із `order_pinned_below` стоїть у масиві **після** свого якоря, і якір присутній у видачі (процедура розділу A.7) | T21, T26 |
| `assert_norm_base_matches` | `normalization_base` дорівнює рівно набору варіантів гілок видачі (розділ A.7) | T27 |

**Межа виразності контракту (задокументоване обмеження).** Два звʼязки перевіряються тільки кодом, бо JSON Schema не порівнює значення між полями: `drone_cta.parent_route` проти `route_id` у масиві `routes` і позиція елемента з `order_pinned_below` проти позиції його якоря. Схема бачить лише **повноту** обʼєкта `drone_cta` (усі чотири поля обовʼязкові, `additionalProperties: false`) і **наявність** поля `order_pinned_below`, а не їхню узгодженість із рештою відповіді. Окремо: якщо в запиті `diagnostics: false`, у відповіді немає ознак відмови воріт 5, тому випадок «повернули `drone_cta`, хоча придатної площадки не було» на рівні контракту нерозрізнюваний — його ловить лише перевірка на боці сервера або той самий сценарій із `diagnostics: true`. Схемою виражені й перевіряються нею: `no_landing_pad` у діагностиці виключає `drone_cta`, а перевищення бюджету (`drone_skipped`) виключає і дрон-варіанти, і `drone_cta`.

Самі перевірки покриті самоперевіркою — блок `SELF` подає на кожну функцію відповідь, що порушує її правило, і вимагає спрацювання (11 випадків, включно з новими перевірками `drone_cta` й обмеження порядку). Окремо перевіряється відсутність хибних спрацювань: відмова воріт для `rt_1` разом із дрон-варіантом для `rt_2` мусить проходити. Без цього блоку перевірка, що завжди повертає `None`, виглядала б як успішна.

Схема версіонується окремо від ТЗ: зміна `$id` обов'язкова при будь-якій несумісній правці контракту, старі клієнти продовжують валідуватися попередньою версією.

### A.8.5. Дії з результатом

| Ендпоїнт | Призначення |
|---|---|
| `POST /api/v1/routes/{route_id}/bid-packet` | Створити пакет ставок на всі плечі маршруту одним дією; кожна ставка йде у свою заявку за стандартною логікою біржі |
| `POST /api/v1/routes/{route_id}/to-multimodal-lot` | Перетворити знайдений маршрут у мультимодальну заявку (успадкований формат: загальна інформація по маршруту + деталізація по плечах) |
| `POST /api/v1/searches` | Зберегти запит і підписатися на сповіщення про нові відповідні заявки |
| `GET /api/v1/search/points?q=Луцьк&kind=city` | Автодоповнення пункту з автопідтягуванням країни та регіону |
| `GET /api/v1/search/calculator?from=&to=&mode=` | Калькулятор відстані та орієнтовної вартості (за медіанами `link_stats`) |
| `POST /api/v1/routes/{route_id}/bid-packet` з дрон-плечем | Ставка на плече `D` йде у дрон-біржу за тією ж логікою; якщо в пакеті є плече `D` і плече `авто` того самого відрізка — приймається лише одне з них |

---

## A.9. Взаємодія з підсистемою ставок і рейтингів

1. Користувач обирає маршрут → `bid-packet` створює по одній ставці в кожному лоті плеча.
2. Пакет має статус `partial` доки не всі плечі підтверджені. Якщо хоча б одне плече відхилено, система автоматично перезапускає пошук **тільки для цього плеча** (локальний пошук між `p_{i-1}` і `p_i`) і пропонує заміну, не руйнуючи решту маршруту.
3. Після підтвердження всіх плечей — стандартний обмін документами (водій, авто, паспортні дані, фрахт) по кожному плечу окремо.
4. Після виконання — обов'язковий обмін відгуками за кожним плечем; кілометраж, вага, час у дорозі та сума перевезення додаються до рейтингу відповідних компаній.
5. Пошук пише в `search_log`: яка гілка блок-схеми дала результат, який маршрут вибрано, чи дійшов пакет до угоди. Ці дані — вхід для налаштування ваг ранжування і для аналітики адміністратора.

---

## A.10. Налаштування адміністратором

Усі параметри алгоритму винесені в адмін-панель (розділ «Налаштування пошуку»), окремо для кожного з шести розділів сайту:

| Параметр | Дефолт | Опис |
|---|---|---|
| `radius_steps_km` | 100, 300, 500 | Кроки розширення радіуса пошуку проміжних пунктів |
| `radius_max_km` | 800 | Максимальний радіус |
| `n_near` | 5 | Скільки найближчих пунктів перевіряти на кожному кроці |
| `max_transships` | 2 (дефолт), до 3 | Максимум перевантажень; більше за `max_legs − 1` встановити не можна |
| `max_dwell_h` | 72 | Максимальний простій між плечима |
| `min_offers` | 3 | Порог «незадовільного результату» |
| `min_rating` | 2.0 | Порог рейтингу |
| `free_account_delay_min` | 30 | Затримка показу нових заявок для безкоштовних акаунтів |
| `rank_weights` | див. 7 | Ваги ранжування \(w\) |
| `flag_penalties` | `weather_risk` 0.08, `no_permit` 0.10, `customs_missing` 0.06, `estimated_price` 0.03 | Штрафи за прапорці-попередження \(p\) (розділ A.7) |
| `post_drone_budget_ms` | 800 | Абсолютний бюджет модуля `POST_DRONE` |
| `post_drone_budget_pct` | 12 | Частка часу маршрутного пошуку; діє менша з двох величин |
| `handling_costs` | таблиця | Вартість і час перевантаження за типом пункту |
| `drone_range_margin` | 0.8 | Коефіцієнт запасу дальності дрона |
| `drone_wind_limit_ms` | 12 | Порог вітру для прапорця `weather_risk` |
| `search_timeout_ms` | 3000 / 15000 | Ліміт для синхронного (L0 і 1 перевантаження) та асинхронного (2+ перевантаження) пошуку |

---

## A.11. Нефункціональні вимоги

| Показник | Вимога |
|---|---|
| Час відповіді S1 (простий пошук) | p95 ≤ 400 мс при 1 млн активних лотів |
| Час відповіді L0 і маршрутів з 1 перевантаженням | p95 ≤ 3 с |
| Час відповіді маршрутів з 2–3 перевантаженнями | p95 ≤ 15 с, асинхронно, з проміжною видачею перших знайдених маршрутів |
| Пропускна здатність | 50 одночасних маршрутних пошуків без деградації простого пошуку (окремий пул воркерів) |
| Кешування | Результати `nearest_points` і `link_stats` — Redis, TTL 1 год; результати маршрутного пошуку — TTL 10 хв за хешем параметрів |
| Мовна підтримка | Назви пунктів і типів вантажу віддаються в мові інтерфейсу (UA/EN/PL/DE) |
| Захист | Контроль SQL-запитів і параметризація, rate limit на пошук (антискрейпінг), логування запитів |
| Чесність даних | Якщо джерело (погода, курси валют) недоступне — у відповіді `partial: true` і явна позначка в інтерфейсі; заборонено підставляти вигадані значення |
| Аудит | Кожен маршрут відтворюваний: `search_id` + збережені параметри дають ідентичну видачу на тому ж зрізі даних |
| Бюджет модуля `POST_DRONE` | `min(800 мс, 12 % часу маршрутного пошуку)` — діє менша з двох величин (для синхронного пошуку 3 с це 360 мс, для асинхронного 15 с — 800 мс). При перевищенні модуль перериває роботу, видача повертається без дрон-варіантів, із `partial: true` і `drone_skipped: "timeout"` (A.8.4) — гілки B–E не блокуються |
| Ідемпотентність `POST_DRONE` | Повторний виклик на тому самому зрізі даних дає той самий набір дрон-варіантів; подвійний запуск модуля не створює дублікатів (перевіряється за парою `variant_of` + `legs[-1].mode`) |
| Незалежність від гілок | Вимкнення `drone.allow` не змінює жодного поля варіантів гілок B–E — побайтова рівність `routes` без об'єктів з `origin: "post_drone"` |

---

## A.12. Тестові сценарії та критерії приймання

| № | Сценарій | Очікуваний результат |
|---|---|---|
| T1 | Є прямий лот A з `P1` в `P2` у вікні дат | L0 повертає лот, вхід у блок-схему не виконується |
| T2 | Прямих лотів немає, є лот `P2 → P1` | L0 (крок зворотних рейсів) повертає лот із прапорцем `is_reverse: true` |
| T3 | `P1` — місто без ЖД-станції, за 120 км є станція з ЖД-лотом до `P2` | Залізнична гілка повертає маршрут авто + ЖД, `transships: 1` |
| T4 | `P2` — пункт без дорожнього доступу, у 20 км є `drone_pad` з активним лотом D, вага 8 кг, погода в нормі | Модуль `POST_DRONE` створює **додатковий** варіант із плечем `D` поряд із авто-варіантом (детальні перевірки — T16–T27) |
| T5 | Міжконтинентальний запит UA → CN, критерій `cost` | Морська гілка повертає схеми `s1`, `s11`–`s14`, `s21`–`s24`, відсортовані за коефіцієнтом переваги; авіа-схеми — нижче |
| T6 | Той самий запит, критерій `time` | Авіа-схеми піднімаються у топ, морські — вниз |
| T7 | Активних лотів у напрямку немає, в історії є 4 компанії | Fallback «компанії напрямку» (A.6.14) повертає перелік компаній з рейтингом і датою останньої заявки |
| T8 | Ні лотів, ні історії | Fallback «можливі сполучення» (A.6.15) повертає перелік сполучень; порожня видача супроводжується CTA «створити заявку» |
| T9 | Безкоштовний акаунт, лот опубліковано 5 хв тому, затримка 30 хв | Лот у видачі відсутній; для акаунта «ПРОФЕСІОНАЛ» — присутній |
| T10 | Користувач оплатив лише розділ «авто», запит з `modes: ["A","M"]` | Морські плечі не повертаються; система пропонує оплатити розділ або перейти в мультимодальний |
| T11 | Компанія в чорному списку | Лот у окремій вкладці або приховано, залежно від налаштування користувача |
| T12 | Одне плече маршруту відхилено після ставки | Система пропонує замінне плече, решта пакета збережена |
| T13 | Фрахт у лоті вказано в `грн/км` | Нормалізація до базової валюти виконана, `cost_known: true`, прапорець `estimated_price` відсутній |
| T14 | Сервіс курсів валют недоступний | Видача з `partial: true`, сортування за `cost` заблоковане з поясненням; останній відомий курс показується з позначкою «курс застарілий», підставлений курс заборонений (рамкове ТЗ, розділ 13.3, сценарій C7) |
| T15 | Заявка прострочена (`valid_until` + n днів) | У пошуку відсутня, наявна лише у fallback «компанії напрямку» і в аналітиці |

### A.12.1. Сценарії модуля `POST_DRONE` (рисунок 3.1.1-7)

Група з дванадцяти сценаріїв (T16–T27) перевіряє модель «пост-обробка»: дрон не є плечем графа, запускається після гілок і **лише додає** варіант. Головний інваріант усіх сценаріїв — авто-варіант присутній у видачі в кожному з них, незалежно від результату модуля. Кожні ворота рисунка 3.1.1-7 покриті щонайменше одним сценарієм, кожне поле контракту A.8.4 — щонайменше одним.

| № | Сценарій | Очікуваний результат |
|---|---|---|
| T16 | Маршрут море + ЖД, останнє плече — `жд`, у 10 км від `P2` є `drone_pad` з активним лотом | Модуль не виконується (ворота 1): дрон-варіант не створюється. Перевіряє, що дрон не підмінює середнє чи магістральне плече |
| T17 | Останнє плече `авто`, дальність і вантаж у нормі, але зона `P2` — прикордонна смуга без дозволу | Ворота 2: дрон-варіант не створюється; `no_permit` дописано у `warnings` **батьківського** авто-маршруту (правило 5 розділу A.8.4) і повертається без `diagnostics`; решта полів авто-варіанта без змін |
| T18 | `range_km` = 50, `drone_range_margin` = 0,8, відстань останнього плеча 42 км | Ворота 3: варіант не створюється (42 > 40). При відстані 39 км — створюється. Межова перевірка коефіцієнта запасу |
| T19 | Відстань 12 км, лот D активний, вантаж: (а) 80 кг при `max_payload_kg` = 20; (б) НГ клас ADR 3; (в) живі тварини | Ворота 4: у всіх трьох випадках дрон-варіант не створюється; авто-варіант лишається. Перевіряє повний перелік критеріїв допустимості |
| T20 | Прямого лота `X → P2` на трьох дрон-біржах немає; є лот `X → площадка П2′` (6 км від `P2`) і авто-лот `П2′ → P2` | Ворота 5: створюється маршрут із **чотирьох** плечей (`max_legs` = 4). При `max_legs` = 3 — `max_legs_exceeded`, варіант відкидається з поясненням, а не обрізається. При `drone.allow_extra_leg: false` — `extra_leg_disabled`, четверте плече не будується |
| T21 | Лот D знайдено, вітер 15 м/с при `drone_wind_limit_ms` = 12; дрон-варіант дешевший за авто-варіант на 30 % | Ворота 6: варіант **видається** з `weather_risk = true`, `weather.ok = false`. Навіть будучи дешевшим, він стоїть у видачі **нижче** свого `variant_of` — перевіряється жорстке обмеження порядку розділу A.7, а не лише штраф 0,08. При вітрі 8 м/с — без прапорця й без обмеження |
| T22 | Лота `X → P2` немає, придатних площадок теж немає | `no_drone_lot` + `no_landing_pad`: у `routes` не додано жодного об'єкта, `drone_cta` відсутнє, `drone_diagnostics` відсутнє при `diagnostics: false`. Видача містить рівно один варіант останнього плеча — авто, без дублювання |
| T23 | Прямого лота немає; площадка `П2′` у 6 км є, але авто-лота `П2′ → P2` немає | Дрон-варіант не створюється, натомість повертається `drone_cta` з `pad`, `missing_leg` і `action: "create_lot"` (правило 5 розділу A.8.4). Перевіряє випадок «сполучення є, лота немає» — червоний вузол рисунка 3.1.1-7 |
| T24 | Той самий запит двічі: `drone.allow: true` і `drone.allow: false` | Масив `routes` без об'єктів з `origin: "post_drone"` **побайтово однаковий** в обох відповідях (НФВ «незалежність від гілок»); при `false` повертається `drone_disabled` лише в діагностиці |
| T25 | Штучне перевищення бюджету модуля (розділ A.11) | `partial: true`, `drone_skipped: "timeout"`, дрон-варіантів немає, усі варіанти гілок B–E присутні й незмінені; код відповіді не змінюється |
| T26 | Сервіс погоди недоступний, решта воріт пройдено | Варіант видається з `weather.ok = null`, `weather_risk = true`, `partial: true`; вигадані погодні значення відсутні. Перевіряє принцип чесності даних для дрон-плеча |
| T27 | Видача містить 3 варіанти гілок і 2 дрон-варіанти | `score` кожного варіанта гілки збігається зі значенням у запиті з `drone.allow: false` — база нормалізації не включає дрон-варіанти (розділ A.7) |

Кожен сценарій групи перевіряється **на рівні контракту**: очікувана структура відповіді для кожного з T16–T27 наведена в розділі A.8.4.1, а машинна перевірка виконується схемою розділу A.8.4.2, а не лише візуально: наявність `rt_N` поряд з `rt_N_d`, коректний `variant_of`, `origin: "post_drone"`, очікуваний код `reason` при `drone.diagnostics: true`, `weather_risk` у `warnings` замість відмови.

Приймання модуля вважається успішним, коли всі 27 сценаріїв проходять на тестовому наборі не менше 100 000 лотів, а показники розділу A.11 виконуються — включно з бюджетом 800 мс, ідемпотентністю та побайтовою незалежністю від гілок B–E. Сценарії T16–T27 виконуються також у регресії після кожної зміни гілок B–E: модель «пост-обробка» ламається саме тоді, коли умову дрона починають вносити всередину гілок.

---
### A.12.2. Валютні та мовні сценарії (C1–C9, L1–L6)

Сценарії, що перевіряють валютну й мовну моделі, специфіковані в рамковому ТЗ, розділи 13.7 і 14.8, і виконуються тим самим способом, що й T16–T27, — окремим прогоном без бази та застосунку: `python schemas/check_i18n.py`. Для підсистеми пошуку критичні три з них:

| № | Що доводить для модуля SEARCH |
|---|---|
| C2 | базова валюта нормалізації розділу A.5 і базова валюта розділу 13.1 рамкового ТЗ — одне значення; розбіжність падає в CI, а не виявляється на продакшені |
| C8 | форматування суми залежить від локалі користувача, а не від валюти, тому нормалізована величина ранжування не змішується з поданням |
| C9 | порядок масиву `routes` не залежить ні від валюти відображення, ні від локалі, а маршрут із `cost_known: false` не бере участі в сортуванні за `cost` (правило 6 розділу A.8.4) |

Сценарії C1, C3–C7 і L1–L6 стосуються угод, документів і локалізації інтерфейсу, тому належать рамковому ТЗ; тут вони перелічені лише для повноти обовʼязкової перевірки CI.

## A.13. Етапи реалізації

| Етап | Зміст | Результат |
|---|---|---|
| 1 | Довідник `points` (міста, ЖД-станції, порти, аеропорти, майданчики дронів) + геоіндекси | Наповнена база пунктів, API автодоповнення |
| 2 | Уніфікована таблиця `lots` + індекси, міграція з існуючих розділів | Дані готові до пошуку |
| 3 | S1–S3: простий пошук, фільтр «інші напрямки», зворотні рейси | Робочий пошук по кожному з шести розділів |
| 4 | Нормалізація вартості й часу, `link_stats`, калькулятор | Порівнянні ціни, орієнтовна вартість плеча |
| 5 | L0 + морська/залізнична гілки з 1 перевантаженням, синхронний режим | Перші мультимодальні маршрути |
| 6 | Повний обхід блок-схеми № 3.1.1 (2–3 перевантаження, схеми `s1`, `s11`–`s14`, `s21`–`s24`, коефіцієнт переваги), асинхронний режим | Повний маршрутний пошук |
| 7 | Модуль `POST_DRONE` (A.6.8, контракт A.8.4): шість воріт, `weather_gate`, підстановка площадки, `drone_cta`, сценарії T16–T27. Схема `schemas/post-drone.schema.json` пишеться **до** реалізації модуля (A.13.1) | Шостий вид транспорту як додатковий варіант останньої милі |
| 8 | Fallback «компанії напрямку» і «можливі сполучення» (A.6.14–A.6.15) | Порожня видача перестає бути тупиком |
| 9 | Ранжування, прапорці-попередження, `bid-packet`, конвертація в мультимодальну заявку | Інтеграція з логікою біржі |
| 10 | Адмін-налаштування, `search_log`, збережені запити й сповіщення | Керований і вимірюваний модуль |
| 11 | Навантажувальне тестування, оптимізація, приймальні сценарії T1–T27 | Здача модуля |

### A.13.1. Порядок робіт зі схемою

Схема — не документація за фактом, а вхідний артефакт етапу 7. Порядок обовʼязковий:

1. **Схема раніше за код.** `schemas/post-drone.schema.json` і приклади розділу A.8.4.1 створюються до реалізації `POST_DRONE`. На цьому кроці `check_schema.py` уже проходить: приклади валідні, мутації відхиляються. Це дає фронтенду й партнерському API готовий контракт до появи бекенду — заглушки повертають приклади A.8.4.1 і вже валідні за схемою.
2. **Реалізація під наявний контракт.** Відповідь модуля валідується схемою у кожному інтеграційному тесті етапу 7, а не лише у приймальних сценаріях етапу 11.
3. **Правка контракту — лише парою.** Будь-яка зміна структури відповіді вимагає одночасної правки схеми, прикладів A.8.4.1 і, за потреби, `$id`. Прогін `check_schema.py` читає приклади безпосередньо з тексту ТЗ, тому розбіжність між документом і схемою падає одразу.

### A.13.2. Кроки CI

П'ять кроків, у наведеному порядку; кожен блокує наступний.

| Крок | Команда | Що падає |
|---|---|---|
| Контракт | `python schemas/validate_schema.py` і `python schemas/check_schema.py` | невалідний приклад A.8.4.1, порушення програмної перевірки, непокрита мутація, непокритий код `reason`, зламаний синтаксис схеми |
| Правило гілки | `python ci/check_ruleset.py` | правило гілки розійшлося з конвеєром: перевірка з правила не відповідає жодному job-у, прогінний job не є обовʼязковим, gate не залежить від нового job-а, у тригері зʼявився фільтр `paths`, знято вимогу оновленої гілки чи схвалення, у правилі не названо групу сценаріїв, сценарій із правила не описаний у ТЗ |
| Числа в документації | `python ci/check_doc_counts.py` (і `--self-test`) | твердження ТЗ про кількість обовʼязкових перевірок, правил R1–RN, сценаріїв pull request або груп сценаріїв розійшлися з фактичним складом правила гілки й конвеєрів |
| Валюти й мови | `python schemas/validate_schema.py schemas/i18n.schema.json` і `python schemas/check_i18n.py` | розбіжність конфігурації `i18n/` з таблицями 13.1 або 14.2 рамкового ТЗ, різна базова валюта в розділі A.5 і розділі 13.1, застарілий курс без позначки, різний порядок маршрутів у різних валютах відображення, різні набори ключів у каталогах мов, перекладений термін глосарію, непокрита мутація |
| Юніт і інтеграція | прогін тестів модуля з валідацією кожної відповіді схемою | розбіжність реалізації з контрактом |
| Приймання | сценарії T1–T27 із перевіркою очікуваних значень | розбіжність поведінки з ТЗ |

Кроки «Контракт» і «Валюти й мови» не потребують ні бази, ні застосунку — лише `jsonschema`, фікстури та самі документи, тому виконуються першими і за секунди. Вони ж є єдиними кроками, обовʼязковими для pull-request, що змінює ТЗ, схему або каталог `i18n/`, навіть без правок коду. В GitHub Actions їм відповідають job `schema` і job `i18n`; обовʼязковими в правилі гілки є і вони самі, і gate `contract-gate`, який залежить від усіх прогінних job-ів конвеєра (розділ A.13.4).

Умови ненульового коду виходу `check_schema.py`:

- невалідний позитивний приклад — розбіжність між 8.4.1 і схемою;
- порушення програмної перевірки на позитивному прикладі — приклад суперечить правилам, які схема не виражає;
- непокрита мутація — у ТЗ зʼявилося правило, для якого немає ні обмеження схеми, ні перевірки;
- провал самоперевірки блоку `SELF` — перевірка перестала спрацьовувати або дає хибне спрацювання;
- непокритий код `reason` — значення перерахування не використовується жодним прикладом, отже його вилучення пройшло б непоміченим.

### A.13.3. Приклад конфігурації CI для pull request

Готові файли: `ci/github-actions-contract.yml` (GitHub Actions) і `ci/gitlab-ci-contract.yml` (GitLab CI, підключається через `include: local`). Обидва реалізують кроки «Контракт» і «Валюти й мови» розділу A.13.2; вибирається один файл — той, що відповідає репозиторію проєкту. Наведений нижче фрагмент — скорочена ілюстрація job `schema`; повні файли додатково містять job `i18n` (прогін `schemas/check_i18n.py` на фікстурах `i18n/`) і gate-job `contract-gate`. Залежності зафіксовані в `schemas/requirements.txt` (`jsonschema` з точною версією, щоб прогін не «поплив» після оновлення бібліотеки).

```yaml
name: contract

on:
  pull_request:
    paths:
      - "ТЗ-алгоритм-пошуку-вантажів-і-маршрутів.md"
      - "schemas/**"
      - "ci/**"
  push:
    branches: [main]

concurrency:
  group: contract-${{ github.head_ref || github.ref }}
  cancel-in-progress: true

jobs:
  schema:
    name: Схема і сценарії T16-T27
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
          cache: pip
          cache-dependency-path: schemas/requirements.txt
      - run: pip install -r schemas/requirements.txt
      - name: Приклади 8.4.1, мутації та програмні перевірки
        run: python schemas/check_schema.py | tee contract-report.txt
```

Повна конфігурація містить девʼять кроків у job `schema` і вісім у job `i18n`; істотні рішення в ній:

| Рішення | Причина |
|---|---|
| Фільтр змінених файлів включає обидва документи ТЗ і каталог `i18n/` | Прогони читають приклади, таблицю валют і склад мов із тексту документів, тому правка ТЗ без правки коду мусить запускати перевірку |
| Окремий крок `check_schema(s)` перед прогоном | Зламаний синтаксис самої схеми має давати зрозумілу помилку, а не падіння всередині валідації прикладів |
| `timeout-minutes: 5`, без бази й застосунку | Крок навмисно легкий: він блокує решту конвеєра і мусить давати відповідь за секунди |
| `concurrency` з `cancel-in-progress` | Новий коміт у pull request скасовує попередній прогін |
| Звіт у `$GITHUB_STEP_SUMMARY` і артефакт | Таблиця покриття мутацій видна прямо в pull request, без розгортання логів |
| Попередження при зміні схеми без зміни `$id` | Автоматична підказка про розділ A.13.6; попередження, не помилка, бо сумісні правки `$id` не змінюють |
| `pip` з кешем за `requirements.txt` | Повторні прогони не перевстановлюють залежності |
| Окремий крок `ci/check_contract_text.py` (U1–U5, N1–N5) у job `proto` | Текст ТЗ, схема й proto — три носії одного контракту, і вони вже одного разу розійшлись (розбіжності D-1 і D-2 в `proto/README.md`). Крок перевіряє носія коду базової валюти й одиниці ваги одночасно в усіх трьох файлах; прогін `--self-test` мутує копії і вимагає, щоб кожен сценарій ловив свою поламку |

Три технічні пастки, враховані у файлах. Перша: `python -c` у кроках взято в **одинарні** лапки — у подвійних оболонка підставила б порожній рядок замість `$id`, бо це схоже на змінну середовища, і перевірка версії тихо порівнювала б порожні значення. Друга: порівняння схем виконується `cmp` над файлом із базової гілки, а не `diff` по тексту, тому переформатування JSON без змістових змін не дає хибного попередження. Третя: перелік змінених файлів читається як `git -c core.quotepath=false diff --name-only` — типово git виводить неASCII-імена у вигляді `"\320\242\320\227-..."`, шаблон на «ТЗ-» не збігається, і правка лише документа ТЗ тихо пропускала б усі три прогінні job-и; пастку виявив сценарій PR-17 симуляції, а не ручний огляд.

Крок «Контракт» слід зробити обовʼязковим (required check) для злиття в основну гілку — інакше правка ТЗ або схеми проходить без перевірки, а сам сенс кроку зникає.

### A.13.4. Обовʼязкова перевірка для злиття

Правило захисту основної гілки зберігається у файлі `ci/ruleset-contract.json` і застосоване в репозиторії [Dymytrii524/TA](https://github.com/Dymytrii524/TA) як ruleset `main: обовʼязковий контракт` (id 22136211, режим `active`). Порядок установлення — `ci/README.md` і розділ A.13.5.

**Обовʼязкових перевірок дванадцять: три gate-и і девʼять прогінних.** Ключова умова працездатності — перевірка мусить **зʼявитися** в кожному pull request. Обовʼязкова перевірка, для якої не створено жодного запуску, залишає pull request у стані очікування назавжди, і саме тому фільтр `paths` прибрано з `on:`: без нього workflow не запускався б на частині pull request. Прогінні job-и не мають job-level `if:`, а їх перший крок (`actions/checkout`) виконується без умови, тому job завжди завершується статусом `success` — навіть коли pull request не торкнувся контракту й решта кроків пропущена. Отже, їх можна безпечно призначати обовʼязковими: стану «перевірка не зʼявилася» не виникає. Додатково документація GitHub зазначає, що required status check вважається виконаною при статусі successful, skipped або neutral ([GitHub Docs](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches)).

Навіщо тоді gate, якщо прогінні перевірки вже обовʼязкові: стале імʼя, незалежне від структури конвеєра; явний провал при `cancelled`, який до успішних статусів не належить; єдина точка для майбутніх job-ів через `needs`. Навіщо, з іншого боку, називати прогінні перевірки в правилі, якщо gate уже покриває їх через `needs`: правило гілки стає самодокументованим — у ньому прямо видно, які групи сценаріїв блокують злиття (`T16-T27`, `C1-C13, L1-L12`, `U1-U8`), а видалення job-а з конвеєра ламає правило явно, а не тихо. Ціна рішення — привʼязка до `name:` job-ів; саме цю привʼязку перевіряє прогін `ci/check_ruleset.py` (нижче).

| Конвеєр | Job | Назва перевірки в правилі |
|---|---|---|
| `ci/github-actions-contract.yml` | `schema` | `Схема і сценарії T16-T27` |
| | `i18n` | `Валютні та мовні сценарії C1-C13, L1-L12` |
| | `proto` | `Контракт proto модуля SEARCH` |
| | `ui` | `UI-сценарії U1-U8 вибору виду транспорту` |
| | `contract-gate` | `contract-gate` |
| `ci/backend-contract.yml` | `backend-contract` | `Контракт бекенду Спринту 0` |
| | `backend-db` | `Приймальні тести БД Спринту 0` |
| | `verification-contract` | `Контракт модуля верифікації` |
| | `verification-db` | `Інваріанти верифікації` |
| | `backend-gate` | `backend-gate` |
| `ci/web3.yml` | `web3-tests` | `web3-sprint0-tests` |
| | `blockchain-gate` | `blockchain-gate` |
| `ci/source-probe.yml` | `sources` | `Стан джерел верифікації` — **не** обовʼязкова: job має job-level `if:` і за правилом R5 не може бути required |

Кожен файл у `ci/` — дзеркало: GitHub виконує лише `.github/workflows/`, тому обидві копії мусять збігатися побайтово. За цим стежить правило R14.

Склад правила гілки — поле в поле за `ci/ruleset-contract.json`:

| Поле правила | Значення в файлі |
|---|---|
| `name`, `target` | `main: обовʼязковий контракт`, ціль — `branch` |
| `enforcement` | `active` — режиму `evaluate` (звіт без блокування) тут недостатньо; перевіряє R8 |
| `conditions.ref_name.include` | `~DEFAULT_BRANCH` — правило привʼязане до основної гілки репозиторію, а не до імені `main`, тому перейменування гілки не знімає захист; `exclude` порожній |
| `required_status_checks` | дванадцять перевірок із таблиці вище, без повторів (R11) |
| `strict_required_status_checks_policy` | `true` — гілка мусить бути оновлена з основної перед злиттям |
| `pull_request` | пʼять параметрів блоку, усі задані явно: щонайменше одне схвалення (`required_approving_review_count: 1`); скидання застарілих схвалень при новому пуші; обовʼязкове закриття коментарів огляду; схвалення власника коду вимкнено (`require_code_owner_review: false`) — файла CODEOWNERS у репозиторії немає; схвалення останнього пуша вимкнено (`require_last_push_approval: false`) — при одному ревʼюерові воно заблокувало б злиття повністю |
| `deletion`, `non_fast_forward` | заборона видалення основної гілки й перезапису історії |
| `bypass_actors` | єдиний виняток — роль `Repository admin` (`actor_id: 5`, `bypass_mode: always`). Виняток тимчасовий: він існує лише тому, що в репозиторії один рев'юер, і вилучається з появою другого (розділ 11.2 ТЗ бекенду Спринту 0). Склад винятків тримає R13 |

Чого в правилі **немає** і чого воно не обіцяє: дозволеними способами злиття (merge, squash, rebase), лінійною історією, підписаними комітами й обмеженнями на створення гілки керують настройки репозиторію або окремі типи правил, яких у цьому файлі немає. Приписувати їх правилу гілки — це обіцяти захист, якого немає (саме цю розбіжність спіймала перевірка D8 прогону `ci/check_doc_counts.py`, яка тепер звіряє кожен параметр блоку `pull_request` із цим абзацом і не дозволяє ні забути параметр, ні назвати відсутній).

Перше створення правила — `POST`, оновлення наявного — `PUT` із його id:

```bash
gh api --method POST repos/:owner/:repo/rulesets --input ci/ruleset-contract.json
gh api --method PUT  repos/:owner/:repo/rulesets/22136211 --input ci/ruleset-contract.json
```

Порядок має значення: доки job жодного разу не виконався на основній гілці, GitHub не знає імені перевірки, і pull request зависне в очікуванні статусу, якого ніхто не виставить. Тому конвеєр спершу зливається й відпрацьовує на `main`, і лише потім його job-и додаються до правила.

Розбіжність між файлом правила і живим правилом у GitHub звіряє окремий прогін `python ci/check_live_ruleset.py` (коди виходу: 0 — збіг, 1 — розбіжності, 2 — не вдалося прочитати правило; «не вдалося перевірити» ніколи не видається за «все гаразд»). Він не є обовʼязковою перевіркою, бо потребує доступу до GitHub API.

**Машинна перевірка правила.** Правило посилається на перевірки за іменем, тому розходиться з конвеєром безшумно. Прогін `python ci/check_ruleset.py` читає `ci/github-actions-contract.yml`, `ci/backend-contract.yml`, `ci/source-probe.yml`, `ci/ruleset-contract.json` і документи ТЗ:

| № | Перевірка |
|---|---|
| R1 | кожна обовʼязкова перевірка відповідає наявному job-у котрогось конвеєра |
| R2 | кожен job обовʼязкового конвеєра названий у правилі гілки |
| R3 | gate кожного конвеєра присутній і в конвеєрі, і в правилі |
| R4 | gate залежить від усіх прогінних job-ів свого конвеєра і виконується завжди (`if: always()`) |
| R5 | прогінні job-и не мають job-level `if:` — інакше перевірка може не зʼявитися в pull request |
| R6 | у тригері немає фільтра `paths` |
| R7 | правило прямо називає групи сценаріїв `T16-T27` і `C1-C13, L1-L12` |
| R8 | правило активне, вимагає оновленої гілки, pull request і щонайменше одного схвалення, забороняє видалення гілки й перезапис історії |
| R9 | gate-и та ідентифікатори сценаріїв із правила описані в документах ТЗ |
| R10 | кожен крок із конвеєром `\|` виконується під оболонкою з `pipefail` — інакше провал прогону маскується кодом виходу останньої команди |
| R11 | у правилі немає повторів перевірок: дублікат маскує втрату справжньої перевірки |
| R12 | gate звіряє результат кожного job-а, від якого залежить, а не лише перелічує їх у `needs` |
| R13 | список винятків оголошений явно, містить лише дозволені ролі й описаний у ТЗ |
| R14 | кожне дзеркало в `ci/` побайтово збігається з робочим конвеєром у `.github/workflows/` |

Прогін супроводжується мутаціями: перейменування job-а, вилучення job-а з правила, зняття `needs`, поява job-level `if:`, фільтр `paths` у тригері, режим `evaluate`, зняття вимоги схвалення або оновленої гілки, безіменні перевірки без груп сценаріїв, зняття `pipefail` з оболонки, дубльований контекст, gate без звіряння результату, тихо розширений список винятків, розбіжність дзеркала з робочим конвеєром. Мутації двох останніх правил змінюють не структуру в памʼяті, а самі файли — прогін для них виконується на копії дерева з підміненим коренем. Станом на цю редакцію: **перевірок правила 14, мутацій 35, виявлено 35 із 35**. Кожна мутація мусить бути виявлена, інакше код виходу ненульовий.

**Іменування.** Рядок `name:` у job-і — контракт із правилом гілки. Перейменування job-а розриває цей звʼязок, і перевірка тихо перестає бути обовʼязковою: правило чекатиме на статус, якого більше не існує. При будь-якому перейменуванні правило оновлюється тим самим викликом із новим `context`. Розбіжність ловлять перевірки R1 і R2 прогону `ci/check_ruleset.py`.

**Перевірка правила на справжніх pull request.** Прогін `ci/pr_simulation.py` створює справжній git-репозиторій із віддаленим `origin`, для кожного сценарію окрему гілку з комітом і виконує **власні команди workflow** — включно з кроком відбору змінених файлів, який порівнює гілку з базовою через `git diff`. Далі обчислюється висновок кожного job-а за семантикою GitHub Actions (крок із невиконаною умовою `if` — skipped; job, усі кроки якого пропущені, все одно завершується як success), запускається gate-job і застосовується `ci/ruleset-contract.json`: злиття дозволене лише тоді, коли кожна обовʼязкова перевірка має статус success, skipped або neutral. Перевірка, для якої в гілці немає job-а з відповідним іменем, трактується як `pending` — злиття блокується. Перевірки конвеєра `ci/backend-contract.yml` у симуляції позначені як поза її межами: вона виконує лише конвеєр контракту.

| Pull request | `schema` | `i18n` | `proto` | `ui` | `contract-gate` | Злиття |
|---|---|---|---|---|---|---|
| PR-1 без дотику до контракту | success | success | success | success | success | дозволене |
| PR-2 сумісна правка мов (новий ключ у 4 каталогах) | success | success | success | success | success | дозволене |
| PR-3 валюту GBP вилучено з конфігурації | success | failure (C1) | success | success | failure | блоковане |
| PR-4 у німецькому каталозі втрачено ключ | success | failure (L1) | success | success | failure | блоковане |
| PR-5 термін глосарію перекладено | success | failure (L3) | success | success | failure | блоковане |
| PR-6 застарілий курс без позначки | success | failure (C7) | success | success | failure | блоковане |
| PR-7 базову валюту змінено лише в конфігурації | success | failure (C2) | success | success | failure | блоковане |
| PR-8 звужено перерахування `reason` у схемі | failure | success | failure | success | failure | блоковане |
| PR-9 job перейменовано без оновлення правила | failure (R1, R2) | success | success | success | failure | блоковане |
| PR-10 gate більше не залежить від `i18n` | failure (R4) | success | success | success | failure | блоковане |
| PR-11 перевірку i18n вилучено з правила | failure (R2, R7) | success | success | success | failure | блоковане |
| PR-12 змінено JSON-рядок `wire` у proto | success | success | failure (P1, B6) | success | failure | блоковане |
| PR-13 сумісне додавання поля в proto | success | success | success | success | success | дозволене |
| PR-14 job верифікації перейменовано без оновлення правила | failure (R1, R2) | success | success | success | failure | блоковане |
| PR-15 зонд джерел повернуто в обовʼязкові | failure (R5) | success | success | success | failure | блоковане |
| PR-16 `backend-gate` не звіряє результат інваріантів | failure (R12) | success | success | success | failure | блоковане |
| PR-17 право обходу правила розширено на роль write | failure (R13) | success | success | success | failure | блоковане |
| PR-18 регресія вибору виду транспорту на головній | success | success | success | failure (U3–U6) | failure | блоковане |
| PR-19 косметична правка головної сторінки | success | success | success | success | success | дозволене |

Усі **девʼятнадцять** сценаріїв дали очікуваний результат, розбіжностей з очікуванням — 0. Прогін має негативний контроль: при штучно перевернутому очікуванні для PR-3 він звітує розбіжність і код виходу 1, тобто «усе зелене» не є артефактом самого прогону. Відколи в конвеєрі зʼявився UI-набір, прогін триває десятки хвилин (UI-сценарії йдуть у швидкому режимі, `HEROMODE_FULL=0`), тому запускається вручну перед кожною зміною конвеєра або правила гілки, а не в CI — власний крок у тому ж workflow дав би рекурсію:

```bash
python ci/pr_simulation.py      # таблиця результатів
python ci/pr_simulation.py -v   # покроковий журнал кожного job-а
```

**Історія: перевірка редакції 1 правила.** Нижче — перевірка першої редакції правила, у якій обовʼязкових перевірок було три (`contract-gate`, `Схема і сценарії T16-T27`, `Валютні та мовні сценарії C1-C9, L1-L6`), а сценаріїв — одинадцять. Групи сценаріїв відтоді розширені до `C1-C13, L1-L12`, і назва перевірки в правилі змінена відповідно.

**Перевірка в справжньому репозиторії GitHub.** Крім локального прогону, правило й конвеєр перевірено безпосередньо в GitHub на репозиторії [Dymytrii524/TA](https://github.com/Dymytrii524/TA). Порядок був такий: окрема тестова базова гілка з конвеєром і файлами контракту (основна гілка не змінювалася), правило гілки, застосоване через `gh api --method POST repos/:owner/:repo/rulesets`, і одинадцять справжніх pull request із тими самими мутаціями, що в локальному прогоні. Одна відмінність від файла `ci/ruleset-contract.json` була вимушена: кількість обовʼязкових схвалень для тесту виставлена в нуль, бо власний pull request схвалити неможливо, і злиття блокувалося б завжди, приховавши результат самих перевірок. Решта правил — активний режим, вимога оновленої гілки, заборона видалення та перезапису історії, три обовʼязкові перевірки — застосована без змін.

Правило спрацювало ще до pull request: спроба запушити виправлення прямо в захищену гілку відхилена сервером із повідомленням `GH013: Repository rule violations found` і двома причинами — «Changes must be made through a pull request» і «3 of 3 required status checks are expected». Виправлення довелося провести окремим pull request, який пройшов усі три перевірки й був злитий.

Результат одинадцяти справжніх pull request (статуси з `gh pr view --json mergeStateStatus,statusCheckRollup`) збігся з локальним прогоном у всіх одинадцяти рядках: PR-1 і PR-2 — `CLEAN` (злиття дозволене), PR-3…PR-11 — `BLOCKED`. У PR-9 перевірка `Валютні та мовні сценарії C1-C9, L1-L6` у переліку pull request **відсутня** — замість неї звітує перейменований job під іменем `i18n-checks`, тому обовʼязкова перевірка ніколи не завершується, і злиття блокується навіть без урахування провалу схеми. Після перевірки всі тестові pull request закриті, тестові гілки й правило видалені, у репозиторії лишилася лише основна гілка.

**Виправлення попередньої редакції.** Раніше в цьому розділі стан прогінного job-а на pull request без змін контракту описувався як `skipped`. Це неточно: пропускаються **кроки** (кроковою умовою `if`), а сам job завершується висновком `success` — що й видно в рядках PR-1 і PR-2. Саме тому прогінні перевірки безпечно призначати обовʼязковими (розділ A.13.4, аргументація вище).

Прогони на справжніх pull request виявили пʼять дефектів конфігурації, які статичний аналіз не бачить:

1. **Скрипт читав файли за абсолютним шляхом.** У CI це означало валідацію сторонньої копії документа замість тієї, що в pull request: несумісна правка схеми проходила перевірку успішно. Корінь тепер визначається як каталог на рівень вище файла скрипта, з можливістю перевизначити через `CONTRACT_ROOT`.
2. **Код `timeout` у перерахуванні `reason` не використовувався жодним прикладом.** Вилучення значення з перерахування — несумісна правка — лишалося непоміченим. До прикладу T25 додано діагностику з цим кодом, а в прогін — перевірку покриття: кожне значення `reason` мусить зустрічатися в розділі A.8.4.1, інакше ненульовий код виходу.
3. **`python -c` із `$id` в одинарних лапках** давав зауваження `shellcheck` SC2016, а в подвійних — тихо ламався. Обидва місця винесено в окремі файли `schemas/validate_schema.py` і `ci/schema_id.py`.
4. **`git fetch --depth=0` — недійсна команда.** Крок відбору змінених файлів у обох прогінних job-ах викликав `git fetch --depth=0`, а git відповідає `fatal: depth 0 is not a positive number` і завершується кодом 128. У реальному CI це означало б провал обох перевірок на **кожному** pull request. Глибина там не потрібна взагалі: `actions/checkout` уже виконано з `fetch-depth: 0`, тобто з повною історією. Прапорець прибрано, залишено `git fetch origin "$base" --quiet`. Дефект знайдено першим же запуском прогону pull request і не міг бути виявлений ні `actionlint`, ні `shellcheck` — синтаксис команди коректний, недійсне лише значення.

### A.13.5. Налаштування через веб-інтерфейс GitHub

Варіант для тих, хто не працює з `gh api`. Результат ідентичний файлу `ci/ruleset-contract.json`.

**Передумова.** Спершу workflow має відпрацювати хоча б один раз на основній гілці. Доки перевірка `contract-gate` не зʼявилася в жодному запуску, GitHub не знає її імені й не запропонує у списку — це найчастіша причина, чому поле пошуку перевірок здається порожнім.

**Крок 1. Відкрити правила репозиторію.** Головна сторінка репозиторію → **Settings** (якщо вкладки не видно, вона під меню **…**) → у лівій панелі, розділ **Code and automation** → **Rules** → **Rulesets** ([GitHub Docs](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/creating-rulesets-for-a-repository)).

**Крок 2. Створити ruleset.** **New ruleset** → **New branch ruleset**. У полі **Ruleset name** — назва, наприклад `main: обовʼязковий контракт`. Статус застосування за замовчуванням **Disabled**; натиснути його й обрати **Active**, інакше правило створиться, але не діятиме.

**Крок 3. Обрати гілки.** Розділ **Target branches** → **Add a target** → включити основну гілку (варіант із замовчуванням репозиторію або шаблон `fnmatch`).

**Крок 4. Увімкнути перевірку.** Розділ **Branch protections** → чекбокс **Require status checks before merging** → у **Additional settings** ввести назви дванадцяти перевірок із таблиці розділу A.13.4 і підтвердити кожну: `contract-gate`, `Схема і сценарії T16-T27`, `Валютні та мовні сценарії C1-C13, L1-L12`, `Контракт proto модуля SEARCH`, `UI-сценарії U1-U8 вибору виду транспорту`, `backend-gate`, `Контракт бекенду Спринту 0`, `Приймальні тести БД Спринту 0`, `Контракт модуля верифікації`, `Інваріанти верифікації`, `web3-sprint0-tests`, `blockchain-gate`. Назви вводяться точно так, як у полі `name:` job-а, разом із пробілами й дефісами. Додатково увімкнути **Require branches to be up to date before merging** — це відповідає `strict_required_status_checks_policy: true` у JSON-варіанті.

**Крок 5. Решта правил.** У тому ж розділі: **Require a pull request before merging** з одним схваленням, скиданням застарілих схвалень при новому пуші й обовʼязковим закриттям коментарів; **Restrict deletions** і **Block force pushes** відповідають правилам `deletion` і `non_fast_forward`. Чекбокси **Require review from Code Owners** і **Require approval of the most recent reviewable push** лишаються знятими — у файлі правила обидва параметри задані значенням `false` (розділ A.13.4).

**Крок 6. Зберегти.** **Create**. При статусі **Active** правило діє негайно.

За потреби винятків: розділ **Bypass list** → **Add bypass** → обрати роль, команду або застосунок → **Add Selected**, за бажанням перевести з **Always allow** на **For pull requests only**. Для кроку «Контракт» винятки не рекомендуються: перевірка виконується за секунди й не потребує ні бази, ні застосунку. Єдиний чинний виняток — роль `Repository admin`, і він тимчасовий (розділ A.13.4).

**Перевірка налаштування.** Створити pull request, що змінює лише `schemas/post-drone.schema.json` у спосіб, який порушує контракт (наприклад, звузити перерахування `reason`), і переконатися, що злиття блокується з посиланням на `contract-gate` і на `Схема і сценарії T16-T27`. Другий тест — правка `i18n/currencies.json`, що вилучає валюту з конфігурації: злиття мусить блокуватися перевіркою `Валютні та мовні сценарії C1-C13, L1-L12`. Порожній pull request, який не торкається контракту, мусить зливатися без затримки — це перевірка на той самий капкан із пропущеним прогоном.

**Класичні branch protection rules.** Якщо в репозиторії використовуються вони, а не rulesets, потрібен той самий чекбокс **Require status checks before merging** у правилі гілки, ті самі десять назв перевірок і чекбокс **Require branches to be up to date before merging** для строгого режиму. Джерело статусу слід вказати як GitHub Actions, а не **any source**: інакше статус із тим самим імʼям, виставлений сторонньою інтеграцією, буде прийнятий за успішний ([GitHub Docs](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches)).

### A.13.6. Версіонування контракту

`$id` схеми (`post-drone-1.0.json`) змінюється при кожній несумісній правці: видалення поля, звуження перерахування, нове обовʼязкове поле. Сумісні правки (нове необовʼязкове поле, розширення перерахування) `$id` не змінюють. Старі версії схем зберігаються в `schemas/` і не видаляються, доки лишається хоча б один клієнт на попередній версії; партнерське API вказує версію контракту у відповіді. Мінорна версія ТЗ і `$id` схеми не звʼязані: ТЗ правиться частіше.

---

## A.14. Перелік рисунків

Блок-схема № 3.1.1 подана у вигляді семи панелей (аркушів). Кожен аркуш — окремий файл SVG у теці `blok-shema-3.1.1-paneli`, зведений файл — `blok-shema-3.1.1.svg`.

| № рисунка | Назва | Аркуш | Файл | Розділ ТЗ |
|---|---|---|---|---|
| Рисунок 3.1.1-1 | Легенда, форми вузлів та система позначень | 1 із 7 | `...-01-legenda.svg` | A.6.3 |
| Рисунок 3.1.1-2 | Верхній рівень: порядок гілок і збір варіантів | 2 із 7 | `...-02-verkhniy-riven.svg` | A.6.4 |
| Рисунок 3.1.1-3 | Гілка 1 — МОРЕ (магістраль), фідери ЗАЛІЗНИЦЯ → АВТО | 3 із 7 | `...-03-more.svg` | A.6.5 |
| Рисунок 3.1.1-4 | Гілка 2 — ЗАЛІЗНИЦЯ (магістраль), фідер АВТО | 4 із 7 | `...-04-zaliznytsia.svg` | A.6.6 |
| Рисунок 3.1.1-5 | Гілка 3 — АВТО | 5 із 7 | `...-05-avto.svg` | A.6.6 |
| Рисунок 3.1.1-6 | Гілка-альтернатива — АВІА (магістраль), фідер АВТО | 6 із 7 | `...-06-avia.svg` | A.6.8 |
| Рисунок 3.1.1-7 | ДРОН — пост-обробка останнього плеча («остання миля») | 7 із 7 | `...-07-dron.svg` | A.6.8, A.8.4, A.12.1 |

Правило посилань у тексті: «див. рисунок 3.1.1-N». Підпис рисунка нанесено безпосередньо у файлі під схемою, тому при вставці в документ окремий підпис засобами редактора не потрібен.

## Джерела

Документ побудований на матеріалах проєкту та еталонної системи:

1. **Блок-схема № 3.1.1** «Алгоритм пошуку маршрутів (авто + залізниця + море)» — нормативне джерело розділу A.6: структура вузлів, три джерела заявок на кожен вид транспорту, предикати `П=П1`/`П=П2`, підстановка найближчого пункту по координатах, перелік термінальних виходів.
2. Графічні еталони схем маршрутів `s1`, `s11`–`s14`, `s21`–`s24` — перелік допустимих комбінацій плечей і базис для коефіцієнта переваги (розділ A.6.9).
3. «Моделювання системою» (Modeliuvannia-sistemoiu.docx) — постановка задачі, вхідні/вихідні дані, покрокові алгоритми для 2 і 3 видів транспорту, групи схем маршрутів і коефіцієнт переваги.
4. ТЗ Махомед «Транс-Атлас — Технічне завдання (вимоги замовника)» — фільтри пошуку (п. 2.11), пошук заявок (п. 2.5), мультимодалка (п. 2.14), бази ЖД-станцій/портів/аеропортів, алгоритм автовидалення заявок, рейтинги, права доступу, платні розділи.
5. Tender (шаблони заявок АВТО/ЖД/АВІА/МОРЕ) — повний перелік полів заявок «вільний вантаж» і «вільний транспорт», списки типів вантажу, транспорту, завантаження, митного оформлення, фрахту та форм оплати.
6. «Вимоги до адмінки» — керування ціновою політикою розділів і контентом, звідки виведено вимогу винести всі параметри пошуку в адмін-панель.
7. Діюча система [Trans-Atlas](https://www.trans-atlas.net/) — еталон структури розділів і біржової видачі.
8. Стартовий зразок «та нет» — принцип чесних даних для сервісів (погода, кордони), який поширено на прапорці `partial` і `weather_risk` у пошуку.
9. Аналіз сайтів-конкурентів (della, lardi-trans, searates, cargotrax, transbank, cargo.lt) — калькулятор відстані/вартості, стрічка свіжих заявок, чорний список у видачі.
5. **Провал прогону маскувався конвеєром `| tee`.** Кроки виглядали як `python schemas/check_schema.py | tee contract-report.txt`. Типова оболонка GitHub Actions на Linux-runner-і — `bash -e` **без** `pipefail`, тому код виходу кроку дорівнював коду `tee`, тобто нулю, а ненульовий код перевірки втрачався. Наслідок у справжньому репозиторії: перший прогін одинадцяти pull request дав `CLEAN` для PR-1…PR-8 — тобто шість несумісних змін (вилучена валюта, втрачений ключ перекладу, перекладений термін глосарію, застарілий курс без позначки, змінена базова валюта, звужене перерахування `reason`) були б злиті попри провал перевірок. У журналі запуску при цьому чітко видно `C1 … ПРОВАЛ`. Виправлення: у workflow додано `defaults: run: shell: bash -euo pipefail {0}`, у GitLab-конвеєр — `set -o pipefail` перед обома кроками з `tee`. Дефект був невидимий для локального прогону, бо той виконував кроки під `bash -eo pipefail`; тепер `ci/pr_simulation.py` бере оболонку з самого workflow (`defaults.run.shell`, потім рівень job-а й кроку), а за відсутності — типове `bash -e`, тобто відтворює саме поведінку GitHub. Контрольний дослід: якщо прибрати `defaults` із конвеєра, локальний прогін звітує рівно шість розбіжностей — ті самі PR-3…PR-8, які помилково проходили в GitHub. Додатково додано правило R10, яке забороняє крок із конвеєром під оболонкою без `pipefail`.

6. **Фільтр змінених файлів не бачив документів ТЗ.** Крок відбору в усіх трьох прогінних job-ах порівнював гілки як `git diff --name-only "$base"...HEAD | grep -Eq '^(ТЗ-алгоритм-пошуку-вантажів-і-маршрутів\.md|…)'`. Типово git виводить неASCII-шляхи в C-екранованому вигляді (`"\320\242\320\227-\320\260…"`), тому шаблон на «ТЗ-» не збігався ніколи: **pull request, який правив лише текст ТЗ, пропускав усі перевірки контракту й зливався як чистий** — рівно той випадок, під який фільтр і будувався (рішення «Фільтр змінених файлів включає обидва документи ТЗ», розділ A.13.2). Дефект не бачили ні `actionlint`, ні `shellcheck`, ні попередні шістнадцять сценаріїв симуляції, бо жоден із них не мутував документ ТЗ; він виявився одразу, як сценарії PR-17 і PR-18 почали повертати в текст розбіжності D-1 і D-2: обидва pull request звітували `success` для job-а `proto` з рядком «proto-контракт не змінювався». Виправлення: перелік читається як `git -c core.quotepath=false diff --name-only` у всіх трьох кроках. Контрольний дослід: після виправлення PR-17 і PR-18 блокуються провалом сценаріїв N4 і U3, а прогін дає 18 сценаріїв і 0 розбіжностей.
