---
type: code-review
work_item_id: ET-008
title: "Review: GPS-треки с публичных платформ на карте"
version: 2
status: REQUEST_CHANGES
created_at: 2026-06-01
updated_at: 2026-06-01
authors:
  - "agent:reviewer"
reviewed_branch: feature/ET-008-gps
base_branch: main
reviewed_commits:
  - 0060003 "feat(gps-tracks): ET-008 публичные GPS-треки с публичных платформ"
  - 3734b98 "feat(ET-008): GPS tracks pipeline, API, frontend layer"
verdict: REQUEST_CHANGES
findings_summary:
  P0: 1
  P1: 4
  P2: 3
  P3: 4
changelog:
  - "v1 (2026-06-01): REQUEST_CHANGES — на ветке отсутствовал код."
  - "v2 (2026-06-01): код появился (~7700 LOC). Проведено code-review против ТЗ v2, AC v2 и ADR-005..011."
---

# Code Review — ET-008 (v2)

## Verdict: **REQUEST_CHANGES**

На ветке появилась реализация (commits `0060003`, `3734b98`): backend
пакет `src/api/gps_tracks/`, pipeline `scripts/gps_collect.py`, фронт
`src/web/gps_tracks.js`, миграция, YAML-конфиги, docker-compose сервис
`gps-collector`, тесты, fixtures. Архитектурно сборка следует ADR-005…008
и REQ-F-01…F-03. Однако обнаружено **одно блокирующее (P0) расхождение**
с ТЗ, ломающее основной сценарий просмотра на детальном zoom, и
несколько P1-несоответствий контракту API.

## Что проверено

1. ✅ `docs/work-items/ET-008/02-trz.md` v2 (draft) — REQ-F-01…F-20, REQ-NF-01…NF-07.
2. ✅ `docs/work-items/ET-008/03-acceptance-criteria.md` v2 (draft) — AC-01…AC-17.
3. ✅ `docs/work-items/ET-008/06-adr/` — ADR-005…011 (5 accepted, 2 proposed/blocking).
4. ✅ `CLAUDE.md` — конвенции, фазы, правила для агентов.
5. ✅ Git diff `main...feature/ET-008-gps` — 53 файла, +7705/-1218.
6. ✅ Прочитан исходник backend (config.py, db.py, dedup.py, endpoint.py,
   mvt.py, models.py, sources/{base,osm,enduro_russia,ttrails}.py).
7. ✅ Прочитан исходник frontend (gps_tracks.js целиком, изменения в
   app.js, app.css, index.html).
8. ✅ Прочитана миграция, scripts/gps_collect.py, оба YAML, requirements.txt,
   docker-compose.yml.
9. ✅ Прочитаны 4 тест-файла (dedup, endpoint, mvt, sources/osm) и fixtures.

## Findings

### F-01 [P0]: GeoJSON-слой полностью скрывается из-за рассогласования имён properties

**Severity:** P0 (blocker — нарушено REQ-F-13/F-14, AC-04, AC-08; основной
визуальный сценарий слоя на z ≥ 12 не работает).

**Где:**
- `src/api/gps_tracks/endpoint.py`, `_row_to_geojson_feature()` (стр. 51–84)
- `src/web/gps_tracks.js`, `applyGpsFilter()` (стр. 246–267) и
  `_buildColorExpression()` (стр. 71–88)

**Что обнаружено:**

GeoJSON endpoint отдаёт в `properties` поля `activity_type` и `sources`
(массив):

```python
"properties": {
    ...
    "activity_type": row["activity_type"],
    ...
    "sources": sources,           # list
    "external_urls": ext_urls,
    ...
}
```

MVT-эндпойнт (правильно по ТЗ §4.3) отдаёт `activity` (скаляр) и `source`
(скаляр первой sources):

```python
props = {
    ...
    "activity": row["activity_type"] or "other",
    "source": first_source,
    "sources": sources_str,        # comma-string
    ...
}
```

Клиентский фильтр в `applyGpsFilter()` использует **только** имена из
MVT-схемы:

```js
const filter = ['all',
  ['in', ['get', 'activity'], ['literal', activities]],
  ['in', ['get', 'source'], ['literal', sources]]
];
map.setFilter(window.gpsTracksLayer.layerGeoId, filter);
```

Для feature из GeoJSON-source-а `get('activity')` и `get('source')`
возвращают `null` → `['in', null, ['literal', […]]]` = `false` → **все
features фильтруются из показа**. То же касается `line-color`:
`_buildColorExpression('source')` matches по `['get', 'source']` →
GeoJSON-features попадают в fallback `'#808080'`.

**Воспроизведение:**
1. Включить чекбокс «Публичные треки».
2. Увеличить zoom до 12+. (`_syncGpsLayersVisibility` делает
   `gps-tracks-layer-geo` видимым и скрывает MVT-слой.)
3. На карте — ни одного публичного трека, хотя `/api/gps-tracks?bbox=…`
   отдаёт >0 features (`returned > 0`, `total_in_bbox > 0`). Toast
   «Показаны N треков из M…» возможен, но карта пустая.

**Ссылка на правило:** Reviewer severity «не реализовано требование ТЗ» → P0.
Затронуты:
- REQ-F-14 (фильтры мгновенно действуют через setFilter).
- REQ-F-17 (стили `gps-tracks-layer` с paint `match`).
- AC-04 Scenario «Поля feature.properties» — `feature.properties` обязан
  содержать `length_km`, см. также F-02 ниже.
- AC-08 «Фильтрация по активности»: «на карте отображаются только enduro
  и moto треки» — невозможно, т.к. на z ≥ 12 ВСЕ треки отфильтрованы.

**Что починить:**
Унифицировать contract. Один из двух вариантов:
- (рекомендуется) В `_row_to_geojson_feature` добавить дублирующие
  поля `activity` (= `activity_type`) и `source` (= `sources[0]`) —
  не ломая существующих потребителей. Параллельно проверить попап:
  `_renderTrackPopupHtml` уже читает `props.activity_type || props.activity`
  — менять не нужно.
- Либо переписать `applyGpsFilter`/`_buildColorExpression` так, чтобы они
  ветвились по `['has', 'activity']` vs `['get', 'activity_type']` + для
  source использовать `['in', 'osm', ['get', 'sources']]` через
  index-of / contains (MapLibre expressions поддерживают `in` с массивом
  правой части).

Тест, который должен поймать это (отсутствует):
`tests/web/gps_tracks.test.js` — feature `{activity_type:'enduro'}` после
`applyGpsFilter` остаётся видимой. Добавить в test-plan.

---

### F-02 [P1]: GeoJSON `feature.properties` отдаёт `length_m`, ТЗ требует `length_km`

**Severity:** P1 (must-fix — нарушение контракта API, описанного в
REQ-F-10 и AC-04; UI-попап показывает «—»).

**Где:** `src/api/gps_tracks/endpoint.py`, `_row_to_geojson_feature()`
(стр. 51–84).

**ТЗ REQ-F-10** (ст. 280–305):
```json
"properties": {
    "name": "...",
    "activity_type": "...",
    "user": "...",
    "created_at": "...",
    "length_km": 47.3,
    "sources": [...],
    "external_urls": [...]
}
```

**AC-04 Scenario «Поля feature.properties»:**
> Then каждая feature содержит: name, activity_type, user, created_at,
> length_km, sources (array), external_urls (array)

**Имеется в коде:**
```python
"length_m": row["length_m"],
# и нет ни одного "length_km"
```

**Последствие:** В `_renderTrackPopupHtml` (`gps_tracks.js` стр. 325):
```js
const lengthKm = typeof props.length_km === 'number' ? props.length_km.toFixed(1) : '—';
```
для GeoJSON-feature (z ≥ 12) `props.length_km` всегда `undefined` → в
попапе постоянно «📏 — км». MVT-features (z 8…11) показывают правильно,
т.к. MVT-builder уже считает `length_km` (`mvt.py` стр. 148).

**Что починить:** добавить в properties `length_km` (как минимум).
Поле `length_m` оставить, если используется. Аналогично уточнить
`points_count` и `created_at` для popup (см. также F-04).

---

### F-03 [P1]: REQ-F-04 не реализован полностью — все OSM-треки сохраняются как `activity_type='other'`

**Severity:** P1 (must-fix — функциональный пробел; пользователь не
сможет осмысленно использовать фильтр по активности, который явно
закреплён в AC-08).

**Где:** `src/api/gps_tracks/sources/osm.py`, `_parse_gpx_trackpoints()`
(стр. 154–284).

**ТЗ REQ-F-04** (ст. 119–144):
> Для треков с gpx_id — дополнительный запрос
> `GET /api/0.6/gpx/<id>` для метаданных (name, description, tags, user,
> timestamp). Этот запрос делаем отложенно в batch: накопить 100 id →
> запросить. Тип активности — из tags GPX-трека (`Tag: enduro/moto/mtb/hike/...`).

**Реализовано:**
- batch-запрос метаданных НЕ сделан;
- `name`, `description`, `tags`, `user` всегда `None`/`[]`;
- `activity_type` явно зашит как `"other"`:
  ```python
  track = TrackInsert(..., activity_type="other", ...)
  ```
- константа `OsmParser.MAPPING` (стр. 25–39) объявлена, но `map_activity`
  не вызывается — мёртвый код.

**Последствие:** в БД ВСЕ треки от OSM (единственного включённого
источника) попадают как `activity_type='other'`. Фильтр по активности
теряет смысл — пользователь видит только «Другое». AC-08 Scenario
«Фильтрация по активности» не может пройти на реальных данных.

**Что починить:** реализовать batch-fetch на `/api/0.6/gpx/<id>` по
накоплению ID, как описано в ТЗ. Использовать `self.map_activity(...)`
для tags. Если решено отложить — оформить ADR/коммент с явной отметкой
«частичная реализация REQ-F-04, follow-up tracked в …» и понизить
ожидания AC-08 в ТЗ (но это работа аналитика, не разработчика).

---

### F-04 [P1]: Health endpoint не соответствует REQ-F-12 / AC-06

**Severity:** P1 (must-fix — нарушение контракта; AC-06 явно перечисляет
обязательные поля, которых нет).

**Где:** `src/api/gps_tracks/endpoint.py`, `gps_health()` (стр. 196–232).

**ТЗ REQ-F-12 / AC-06** требуют поля:
| Поле               | Тип    |
| ------------------ | ------ |
| `db_path`          | str    |
| `db_size_mb`       | float  |
| `tracks_total`     | int    |
| `tracks_by_source` | dict   |
| `tracks_by_activity` | dict |
| `last_pipeline_run` | object с полями started/finished/regions/sources_ok/sources_error |
| `tile_cache_size`  | int    |

**Имеется:**
```python
return {
    "status": "ok",
    "db_path": db_path,
    "total_tracks": total_tracks,                # должно быть tracks_total
    "by_activity": by_activity,                  # должно быть tracks_by_activity
    "recent_pipeline_runs": recent_runs,         # должно быть last_pipeline_run (объект)
}
```

Отсутствуют: `db_size_mb`, `tracks_by_source`, `tile_cache_size`.
Переименованы: `tracks_total → total_tracks`, `tracks_by_activity →
by_activity`, `last_pipeline_run → recent_pipeline_runs` (массив, не
объект).

Также `recent_pipeline_runs` отдаёт «10 последних запусков», а ТЗ
требует ОДИН последний (агрегированно). Это влияет на UI/админский
view.

**Что починить:** привести JSON-схему к контракту. Минимум — добавить
`tracks_by_source` (вычислить по `sources_json` агрегацией в Python или
JSON_EACH в SQL), `db_size_mb` (через `os.path.getsize`), `tile_cache_size`
(через `len(_gps_tile_cache)` из `mvt.py`), `last_pipeline_run` объект
(берём первую строку из `pipeline_runs ORDER BY started_at DESC`,
агрегируем `sources_ok`/`sources_error` по последнему region).

Tests `test_i40_health_endpoint` сейчас закреплены на текущей неправильной
схеме — их тоже придётся обновить.

---

### F-05 [P1]: Z-order ET-006 не зафиксирован — `gps-tracks-layer` может оказаться выше `gpx-layer-*`

**Severity:** P1 (must-fix — нарушение REQ-F-17 paint requirements §7.1
и AC-10 Scenario «личный трек визуально выше публичных»).

**Где:** `src/web/gps_tracks.js`, `_findGpsInsertPosition()` (стр. 191–196).

**ТЗ §7.1:**
> На карте оба видны параллельно; z-order:
> `gps-tracks-layer` < `gpx-layer-*` (личные треки выше).

**AC-10 Scenario «Совместимость с ET-006»:**
> Then оба видны параллельно
> And личный трек визуально выше публичных

**Имеется:**
```js
function _findGpsInsertPosition(map) {
  const style = map.getStyle && map.getStyle();
  if (!style || !style.layers) return undefined;
  const routeLayer = style.layers.find(l => l.id === 'route-line' || l.id.startsWith('route-'));
  return routeLayer ? routeLayer.id : undefined;
}
```

Функция ищет только `route-*`. Если `gpx-layer-*` уже добавлен в стиль
(ET-006), но route-line ещё нет, gps-tracks-layer добавится **в конец**
(`before = undefined` → addLayer без beforeId → поверх всего стиля),
**в том числе поверх gpx-layer**. Это нарушает обязательное правило
из §7.1 / AC-10.

**Что починить:**
```js
function _findGpsInsertPosition(map) {
  const style = map.getStyle && map.getStyle();
  if (!style || !style.layers) return undefined;
  // Приоритет beforeId: gpx-layer-* (ET-006), затем route-* (если нет gpx).
  const gpxLayer = style.layers.find(l => l.id.startsWith('gpx-layer'));
  if (gpxLayer) return gpxLayer.id;
  const routeLayer = style.layers.find(l => l.id === 'route-line' || l.id.startsWith('route-'));
  return routeLayer ? routeLayer.id : undefined;
}
```

Соответствующий unit-тест добавить в `tests/web/gps_tracks.test.js`
(в test-plan он есть как WEB-INTEG).

---

### F-06 [P2]: Валидация bbox по площади отсутствует (REQ-NF-01)

**Severity:** P2 (should-fix).

**Где:** `src/api/gps_tracks/endpoint.py`, `_parse_bbox()` (стр. 17–48).

**ТЗ REQ-NF-01:**
> Bbox-параметр валидируется (диапазон координат, площадь).

**Имеется:** валидация диапазона `[-180,180]/[-90,90]`, проверка
`west<east`/`south<north`. Площадь не проверяется.

**Что починить:** добавить max площадь bbox (например, 10° × 10° = 100°²)
для предотвращения «штормового» сканирования БД.

---

### F-07 [P2]: Дефолт `gps-tracks-sources` в localStorage включает отключённые источники

**Severity:** P2.

**Где:** `src/web/gps_tracks.js`, `window.gpsTracksLayer.filters.sources`
(стр. 55).

```js
sources: ['osm', 'enduro_russia', 'ttrails'],
```

При том, что `config/gps_sources.yaml` явно держит `enduro_russia` и
`ttrails` в `enabled: false` (соответствует ADR-010/011 status=proposed).
ТЗ REQ-F-15 говорит дефолт = «все enabled». Не блокирует, но создаёт
галки в UI для источников, по которым в БД ничего нет.

**Что починить:** инициализировать sources списком из
`/api/gps-tracks/health.tracks_by_source.keys()` при отсутствии
localStorage-ключа. Это естественно подтянется после F-04.

---

### F-08 [P2]: «LRU-кэш» в `mvt.py` — на самом деле FIFO

**Severity:** P2.

**Где:** `src/api/gps_tracks/mvt.py`, `_gps_tile_cache` (стр. 12–24).

```python
def set_gps_cached_tile(z, x, y, data):
    if len(_gps_tile_cache) >= _GPS_TILE_CACHE_MAX:
        _gps_tile_cache.pop(next(iter(_gps_tile_cache)))   # FIFO, не LRU
    _gps_tile_cache[(z, x, y)] = data
```

ТЗ REQ-NF-04: «LRU-кэш в памяти процесса FastAPI: 1024 записи». Текущая
реализация — FIFO. Для тайлов это особо ухудшает: часто запрашиваемый
тайл, попавший в кэш первым, будет вытеснен раньше, чем редкий тайл,
попавший позже.

**Что починить:** `from functools import lru_cache` нельзя из-за
mutable invalidation; использовать `collections.OrderedDict` с
`move_to_end` при чтении, либо `cachetools.LRUCache`. Совместимая идея —
сделать `get_gps_cached_tile`:
```python
def get_gps_cached_tile(z, x, y):
    key = (z, x, y)
    if key in _gps_tile_cache:
        _gps_tile_cache.move_to_end(key)   # OrderedDict
        return _gps_tile_cache[key]
    return None
```

---

### F-09 [P3]: Мёртвое поле конфигурации `save_user_field`

**Severity:** P3 (nice-to-have).

**Где:** `config/gps_sources.yaml` — поле задано (`save_user_field:
true|false`); ни один модуль его не читает. AC-16 Scenario «Pipeline не
сохраняет запрещённые поля» рассчитывает, что это поле уважается.

**Что починить:** в `OsmParser`/upsert обрабатывать `save_user_field=false`
→ `user=None`. Сейчас OSM всё равно ставит `user=None` (см. F-03), но
поле должно работать как контракт для будущих источников.

---

### F-10 [P3]: Лишний импорт `pytest_asyncio` в `tests/api/test_gps_tracks_endpoint.py`

**Severity:** P3.

`import pytest_asyncio` есть, но `@pytest_asyncio.fixture` нигде не
используется (только `@pytest.mark.asyncio`). Не блокирует, но в чистом
коде убирается.

---

### F-11 [P3]: `MockRow(dict)` в `tests/api/test_gps_tracks_mvt.py`

**Severity:** P3.

Тесты используют `class MockRow(dict)` как замену `sqlite3.Row`. Работает
для текущего кода, но `sqlite3.Row` не поддерживает `__contains__` так
же, как dict. Если в `mvt.py` появится `if "x" in row:`, тесты
разойдутся с продом. Безопаснее использовать `sqlite3.Row` напрямую через
`open_db + INSERT + fetchone()`.

---

### F-12 [P3]: Лишняя проверка `"source_priority" in existing.keys()` в `db.py`

**Severity:** P3.

`src/api/gps_tracks/db.py` стр. 116:
```python
existing_priority = existing["source_priority"] if "source_priority" in existing.keys() else 999
```
Колонка `source_priority` объявлена в миграции (`migrations/gps_tracks_001_init.sql`
ст. 22) с `NOT NULL DEFAULT 999`. Проверка избыточна (read-protection
осталась от какой-то ранней итерации). Лучше убрать — иначе создаётся
впечатление, что колонка опциональна.

---

## Соответствие ADR

| ADR | Status | Соблюдение в коде | Замечания |
|-----|--------|-------------------|-----------|
| ADR-005 storage-schema | accepted | ✅ соблюдено | таблица `tracks` (+`source_priority`), индексы и `pipeline_runs` совпадают |
| ADR-006 dedup-algorithm | accepted | ✅ соблюдено | `compute_dedup_key` 1-в-1; покрыт unit-тестами U-10…U-14 |
| ADR-007 pipeline-architecture | accepted | ✅ соблюдено | сервис `gps-collector` с `profiles:["batch"]`, license-guard в `_check_license_adr` |
| ADR-008 tile-vs-geojson | accepted | ⚠️ частично | переключение по zoom есть; но contract фич GeoJSON vs MVT расходится (см. F-01) |
| ADR-009 osm-licensing | accepted | ✅ соблюдено | attribution в source, рабочий парсер |
| ADR-010 enduro-russia (proposed) | proposed | ✅ соблюдено | parser — заглушка, `enabled: false`, license-guard сработает |
| ADR-011 ttrails (proposed) | proposed | ✅ соблюдено | то же |

License-guard в `scripts/gps_collect.py` `_check_license_adr()` корректно
читает YAML front-matter ADR. Покрытия unit-тестом нет — рекомендую
добавить (`test_pipeline_skips_unaccepted_source`).

## Тесты — оценка

| Тест-файл | Покрытие test-plan | Качество |
|-----------|-------------------|----------|
| `test_gps_tracks_dedup.py` | U-10…U-14 | ✅ хорошо |
| `test_gps_tracks_mvt.py` | U-50…U-52 | ✅ адекватно (см. F-11) |
| `test_gps_tracks_endpoint.py` | I-20…I-23, I-30…I-31, I-40 | ⚠️ AC-06 не покрыт корректно — тест зафиксирован на неверной схеме (F-04) |
| `test_gps_tracks_sources_osm.py` | U-42…U-44 | ✅ defusedxml проверен фикстурой xxe-payload.gpx |

**Не покрыто тестами:**
- `tests/web/gps_tracks.test.js` — заявлен в ТЗ §5 ст. 787 и
  `04-test-plan.yaml`, ОТСУТСТВУЕТ. Был бы первой защитой от F-01.
- pipeline (`scripts/gps_collect.py`) — нет ни одного теста на
  `_check_license_adr`, `_collect_source_for_region`, dry-run, exit-code.
  AC-02 Scenarios «Падение одного источника не валит остальные» и
  «Dry-run» по факту не верифицированы.
- bbox area-валидация — отсутствует и в коде (F-06), и в тестах.

## Замечания, не доходящие до P-finding

- `_simplify_coords` в `mvt.py` дублирует `simplify_coords` из `src/api/main.py`.
  Не критично, но напрашивается общая утилита.
- `src/api/main.py` ст. 17–20: `GPS_TRACKS_DB_PATH` вычисляется **до**
  импорта shapely/fastapi, в строгом смысле это «нечистый» импорт-time
  side-effect. Не блок.
- В `endpoint.py` `init_db` вызывается на каждый запрос (`_get_conn`).
  Это означает, что `executescript` выполняется на каждый запрос. SQL
  использует `IF NOT EXISTS`, так что функционально ок, но это лишний
  I/O. Рекомендую инициализировать БД один раз при `create_gps_router`.
- `src/api/main.py` ст. 1255: `app.include_router(gps_router)` хорошо
  встроено перед `StaticFiles` mount — порядок правильный.

## Воспроизведение P0 для разработчика

```bash
# 1. Запустить с тестовой БД (см. test_i20_geojson_basic):
pytest tests/api/test_gps_tracks_endpoint.py::test_i20_geojson_basic -q

# 2. Вытащить feature.properties — наблюдать "activity_type", "sources" (list),
#    отсутствие "length_km".

# 3. Открыть DevTools в браузере на dev-стенде, проверить:
window._map.queryRenderedFeatures({layers:['gps-tracks-layer-geo']})
# → пустой массив на z >= 12, потому что setFilter с ['get','activity'] всё скрыл.

# 4. Временный обход для отладки:
window._map.setFilter('gps-tracks-layer-geo', null)
# → треки появятся, но серые (line-color fallback) — что подтверждает F-01.
```

## Рекомендация для CI

- `pytest tests/api/` сейчас зелёный, потому что:
  - `test_i40_health_endpoint` фиксирует текущую неправильную схему
    (F-04) → нужно поправить вместе с фиксом;
  - frontend-тесты отсутствуют (F-01 не отлавливается).
- `make lint` ожидаемо проходит.
- Перед закрытием задачи CI должен прогонять и frontend-тесты
  (`tests/web/gps_tracks.test.js`), которые на данный момент не написаны.
- Не закрывать ET-008 в Plane как Done.

## Итог

**REQUEST_CHANGES.** Минимальный объём правок:
1. F-01 (P0) — унифицировать имена properties между MVT и GeoJSON, +
   web-тест на `applyGpsFilter`.
2. F-02 (P1) — добавить `length_km` в GeoJSON.
3. F-03 (P1) — реализовать batch-fetch `/api/0.6/gpx/<id>` и mapping
   `activity_type` (или явная декларация частичной реализации REQ-F-04).
4. F-04 (P1) — выровнять `gps_health()` под REQ-F-12 / AC-06.
5. F-05 (P1) — корректный `beforeId` в `_findGpsInsertPosition`.

После исправлений P0/P1 — повторить ревью; P2/P3 могут пройти отдельным
PR follow-up.
