FIX A (#1955) «Что хорошо продаётся»: убран фантомный класс 'Comfort'.
- Миграция 172: v_bucket_success_score COALESCE(obj_class,'Comfort')
→ COALESCE(obj_class,'не указан'). Английский литерал заполнял 397 NULL
и сливался отдельным классом от русского 'Комфорт' → визуальные дубли
бакетов в UI. Источник уже канонически-русский (проверено на проде),
synonym-mapping не нужен.
- parcels.py: obj_class протаскивается в success-ranking query + dict.
- TS SuccessRankingBucket.obj_class добавлен.
FIX B (#1960) «Медиана рынка» = 64k (квартальная росреестровская n=1 ДКП):
- district.median_price_per_m2 больше не COALESCE(median_12m, ekb_ref) в SQL.
Basis-приоритет (newbuild-first): Objective по имени района →
geo_radius (Objective в 3км) → ekb_districts reference →
квартальная росреестровская медиана ТОЛЬКО при deals_count≥5.
Для 66:41:0205010:287: 64k → 132690 (geo_radius, newbuild-consistent).
- median_price_basis добавлен в payload + TS type (nullable median).
- Frontend null-guards для нового nullable median.
Tests: +4 (geo_radius basis, objective-приоритет, deals-guard, obj_class
passthrough); обновлены district-моки в 9 analyze-тестах под новую
SQL-сигнатуру.
Phase B продолжает hot-path latency cut'ы analyze_parcel поверх Phase A (PR #1194):
1. get_air_quality_cached в weather_cache (третий TTL-слот рядом с forecast/climate):
- hit-TTL 1h (hourly bucket Open-Meteo), negative-TTL 5min.
- timeout 5s → 2s.
- single-flight под per-cache threading.Lock.
- Снимает 0.26с на cache-hit и до 5с при DNS-fail (тот же антипаттерн,
что был у forecast/climate до Phase A).
2. _neighbors_summary: 2 SQL → 1 statement.
- WITH neighbors AS (... LIMIT 30), overlaps AS (... LIMIT 5)
- + COALESCE(json_agg(row_to_json(...)), '[]'::json) в основном SELECT.
- Один round-trip вместо двух (~-47 ms на каждый analyze).
- Семантика идентична: те же ST_DWithin(geom, point, 100m) для neighbors,
ST_Intersects + ST_Area для overlaps. Anti-fakes guards (_COST_PER_M2_MIN/MAX,
float() try/except, >50 m² overlap) сохранены.
Формат возвращаемых dict не меняется (frontend контракт). 5 новых юнит-тестов в
test_weather_cache.py (TestAirQualityCache: hot-hit, negative, empty-hourly,
separate-from-weather, ttl-expires). 7 файлов API-тестов обновили patch-цель
_fetch_air_quality_sync → get_air_quality_cached.
Refs #1130
Два внешних HTTP-вызова в analyze (_fetch_weather_sync + _fetch_seasonal_weather_sync)
сидели без кэша. На проде в private network с restricted egress DNS до *.open-meteo.com
фейлит и каждый replay висит полный timeout (5s + 15s = до 20с лишних на analyze).
Профиль cProfile одного replay: 2.38с в httpx + 1.73с в SSL recv.
Вынес логику в app/services/weather_cache.py:
- TTL hit: forecast 6h, climate normals 7d. Negative-cache: 5min (DNS-fail не повторяет
timeout на каждый analyze, восстановление подхватывается через ~5 минут).
- Ключ — округлённые до 0.01° (~1 км) координаты.
- Single-flight: per-cache threading.Lock + check-then-fetch-then-store.
- Тайм-ауты сокращены: forecast 5s→2s, climate 15s→3s.
Контракт для caller'а не изменился: None по-прежнему допустим (env_ok-флаг в
_build_environmental_score). 7 файлов API-тестов обновили patch-цели на новые имена
get_weather_cached / get_seasonal_weather_cached. 11 новых юнит-тестов в
tests/services/test_weather_cache.py (single-flight через threading.Barrier(16), TTL
expire через monkeypatch _now, изоляция forecast vs climate).
Refs #1130
Проблема: POST /custom-pois возвращал 201 с id=N, но row не сохранялась
в БД — user_custom_pois оставалась пустой после request close.
Симптом возникает если service-функция не вызывает db.commit() явно:
get_db.finally → db.close() без commit → psycopg3 rollbacks pending tx.
Код (#257) содержит db.commit() во всех 3 мутациях, но тесты полностью
мокировали service layer через patch() — regression в commit не ловился.
Fix: добавлены 6 service-level тестов с паттерном "два сеанса":
- MagicMock(db) + db.commit.assert_called_once() для create/update/delete
- db.commit.assert_not_called() для not-found и empty-payload случаев
Closes#261