fix(report): newbuild-consistent медиана + obj_class/Comfort dedup + recommend_mix regression (#1955 #1960) #1978

Merged
bot-backend merged 3 commits from fix/report-data-1953 into main 2026-06-27 11:41:37 +00:00
16 changed files with 674 additions and 130 deletions

View file

@ -153,6 +153,12 @@ _GEO_PRICE_RADIUS_M: float = 3000.0 # 3 км — городской радиу
_GEO_PRICE_MIN_LOTS: int = 10
_GEO_PRICE_MIN_COMPLEXES: int = 2
# #1960 «Медиана рынка»: минимум сделок квартальной росреестровской MV
# (mv_quarter_price_per_m2.deals_count, окно 24 мес), чтобы её медиана вообще
# могла служить последним fallback'ом для карточки district.median_price_per_m2.
# Тонкие кварталы (1 ДКП → 64k) больше не «выигрывают» у newbuild-basis.
_QUARTER_MEDIAN_MIN_DEALS: int = 5
# Эмпирические пороги score для ЕКБ: средний диапазон 15-30, max редко >40.
SCORE_THRESHOLDS: dict[str, float] = {"плохо": 5.0, "средне": 15.0, "хорошо": 25.0, "отлично": 40.0}
SCORE_MAX_REFERENCE: float = 40.0
@ -1646,13 +1652,21 @@ def analyze_parcel(
geom_wkt: str = geom_row["wkt"] # type: ignore[index]
# 2) District context — ближайший район ЕКБ
# median_price_per_m2: предпочитаем median_12m из mv_quarter_price_per_m2 (12 мес),
# fallback на ekb_districts.median_price_per_m2 (24 мес).
district_row = (
# #1960: НЕ COALESCE'им median_12m в SQL. Раньше карточка «Медиана рынка» брала
# median_12m из mv_quarter_price_per_m2 — это РОСРЕЕСТРОВСКАЯ квартальная медиана
# (ДКП), которая для тонких кварталов считается по 1 сделке (=64k для
# 66:41:0205010:287) и противоречит остальной странице, построенной на NEWBUILD
# Objective/DOM.РФ basis (geo_radius ≈132k). Поэтому возвращаем компоненты по
# отдельности — финальный выбор basis делается в Python (см. блок «#1960» ниже),
# где доступны district_price_block (Objective по имени района) и geo_radius_price.
# ekb_districts.median_price_per_m2 — справочный DDU-fallback (24 мес).
district_row_raw = (
db.execute(
text("""
SELECT d.district_name,
COALESCE(mq.median_12m, d.median_price_per_m2) AS median_price_per_m2,
d.median_price_per_m2 AS ekb_reference_median,
mq.median_12m AS quarter_median_12m,
mq.deals_count AS quarter_deals_count,
ST_Distance(
d.geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
@ -1674,6 +1688,19 @@ def analyze_parcel(
.mappings()
.first()
)
# district_row сохраняет тот же контракт, что и раньше (district_name,
# median_price_per_m2, dist_to_center). median_price_per_m2 пока = справочный
# ekb-fallback; финальный newbuild-consistent basis проставляется ниже (#1960),
# когда посчитаны district_price_block + geo_radius_price.
district_row: dict[str, Any] | None
if district_row_raw is not None:
district_row = {
"district_name": district_row_raw["district_name"],
"median_price_per_m2": district_row_raw["ekb_reference_median"],
"dist_to_center": district_row_raw["dist_to_center"],
}
else:
district_row = None
# 3) POI в радиусе 1 км — список с distance_m (straight-line, ST_Distance).
# В dict, а не RowMapping (read-only) — чтобы при включённом OSRM (#39 A2)
@ -2761,7 +2788,8 @@ def analyze_parcel(
success_rows = (
db.execute(
text("""
SELECT bucket, success_score, n_deals, avg_price_per_m2, avg_area_m2,
SELECT bucket, obj_class, success_score, n_deals,
avg_price_per_m2, avg_area_m2,
velocity_z, price_z, area_z
FROM v_bucket_success_score
WHERE district_name = :dn
@ -2784,6 +2812,10 @@ def analyze_parcel(
"ranking": [
{
"bucket": r["bucket"],
# #1955: obj_class теперь различает строки с одинаковым
# area-label (Комфорт vs Типовой vs «не указан») — убирает
# визуальные дубли бакетов и фантом english 'Comfort'.
"obj_class": r["obj_class"],
"success_score": round(float(r["success_score"]), 2),
"n_deals": int(r["n_deals"]),
"avg_price_per_m2": (
@ -3290,6 +3322,50 @@ def analyze_parcel(
logger.warning("financial_estimate bridge failed for %s: %s", cad_num, e)
financial_estimate = None
# #1960: карточка «Медиана рынка» (district.median_price_per_m2) должна быть
# КОНСИСТЕНТНА с остальной страницей, построенной на NEWBUILD Objective/DOM.РФ
# basis (geo_radius_price.median, market_avg ≈197k), а не на загрязнённой
# квартальной росреестровской ДКП-медиане (median_12m, для 66:41:0205010:287 = 64k
# по 1 сделке). Раньше SQL делал COALESCE(median_12m, ekb_reference) → выигрывала
# тонкая квартальная медиана.
#
# Basis-приоритет (newbuild-first, с устойчивыми fallback'ами):
# 1. district_price_block.district_price_per_m2_median — Objective-лоты по ИМЕНИ
# района (objective_lots.district). NULL для 5/9 районов ЕКБ без name-match.
# 2. geo_radius_price.median — Objective-лоты в 3 км радиусе вокруг центроида
# (n≥10 лотов, ≥2 ЖК). Закрывает name-match-пробел; для 66:41:0205010:287 ≈132k.
# Это ТОТ ЖЕ источник, что и market_pulse/financial_estimate → консистентно.
# 3. ekb_districts.median_price_per_m2 — справочная DDU-медиана района (24 мес).
# 4. mv_quarter_price_per_m2.median_12m — росреестровская квартальная медиана,
# ТОЛЬКО если deals_count ≥ _QUARTER_MEDIAN_MIN_DEALS (5). Тонкие кварталы
# (1 ДКП → 64k) сюда не проходят и медиана честно остаётся None.
if district_row is not None:
_newbuild_basis: float | None = None
_basis_source = "none"
_dpm = district_price_block.get("district_price_per_m2_median")
_geo_m = geo_radius_price.get("median")
_ekb_ref = district_row_raw["ekb_reference_median"] if district_row_raw else None
_q_median = district_row_raw["quarter_median_12m"] if district_row_raw else None
_q_deals = district_row_raw["quarter_deals_count"] if district_row_raw else None
if _dpm is not None:
_newbuild_basis = float(_dpm)
_basis_source = "objective_district"
elif _geo_m is not None:
_newbuild_basis = float(_geo_m)
_basis_source = "objective_geo_radius"
elif _ekb_ref is not None:
_newbuild_basis = float(_ekb_ref)
_basis_source = "ekb_districts_reference"
elif (
_q_median is not None
and _q_deals is not None
and (int(_q_deals) >= _QUARTER_MEDIAN_MIN_DEALS)
):
_newbuild_basis = float(_q_median)
_basis_source = "quarter_rosreestr"
district_row["median_price_per_m2"] = _newbuild_basis
district_row["median_price_basis"] = _basis_source
result_payload: dict[str, Any] = {
"cad_num": cad_num,
"source": source,

View file

@ -1814,8 +1814,7 @@ def _active_competitors_count(
# #38: реальный obj_class в приоритете, иначе obj_class_fallback.
if target_class:
n = _q(
"AND district_name = :dn"
" AND COALESCE(obj_class, obj_class_fallback) = :cls",
"AND district_name = :dn" " AND COALESCE(obj_class, obj_class_fallback) = :cls",
{"rc": region_code, "dn": district_name, "cls": target_class},
)
if n >= 2:
@ -2221,9 +2220,7 @@ def _competitors_two_dim(
return 0, n, float(n), scope
# #38: реальный obj_class в приоритете, иначе obj_class_fallback.
class_filter = (
"AND COALESCE(obj_class, obj_class_fallback) = :cls" if target_class else ""
)
class_filter = "AND COALESCE(obj_class, obj_class_fallback) = :cls" if target_class else ""
params: dict[str, Any] = {
"rc": region_code,
"dn": district_name,
@ -2291,6 +2288,17 @@ def _bucket_success_ranking(
Возвращает список dict {bucket, success_score, n_deals, velocity_z,
price_z, area_z}, sorted DESC by success_score. Пустой список если
данных нет или district_name не передан.
target_class ДОЛЖЕН быть в словаре БД (русский) caller переводит через
_class_to_db_vocab. Default-класс (когда target_class is None) = 'Комфорт':
после миграции 172 view хранит только канонические русские классы
('Комфорт'/'Типовой'/'Бизнес'/'Элит'/'Премиум'/'не указан'), английский
'Comfort' больше не существует и матчил бы 0 строк (#1955). 'Комфорт' —
самый массовый класс ЕКБ (723 объекта) разумный baseline для
«класс не указан в запросе». NULL-class строки ('не указан', бывшие
obj_class IS NULL) в default-путь НЕ попадают намеренно: для рекомендации
квартирографии лучше показать реальный массовый класс, чем агрегат «не
указан»; явный запрос с target_class='не указан' их при этом достанет.
"""
if not district_name:
return []
@ -2301,7 +2309,7 @@ def _bucket_success_ranking(
SELECT bucket, success_score, n_deals, velocity_z, price_z, area_z
FROM v_bucket_success_score
WHERE district_name = :dn
AND obj_class = COALESCE(:cls, 'Comfort')
AND obj_class = COALESCE(:cls, 'Комфорт')
ORDER BY success_score DESC
"""
),
@ -2742,8 +2750,11 @@ def recommend_mix(
# #22 Noise penalty: плотность шумных объектов района → штраф до -10% цены
noise_penalty, noise_breakdown = _noise_penalty_factor(db, district_row["district_name"])
# #25 Success-driven ranking из v_bucket_success_score
success_ranking = _bucket_success_ranking(db, district_row["district_name"], target_class)
# #25 Success-driven ranking из v_bucket_success_score.
# #1955: передаём target_class_db (русский словарь БД), НЕ сырой английский
# target_class — иначе obj_class = :cls молча даёт ноль строк ("Comfort" !=
# "Комфорт"), success-boost тихо деградирует в [].
success_ranking = _bucket_success_ranking(db, district_row["district_name"], target_class_db)
# 5b-3) Per-bucket project velocity at price_factor=1.0:
# bucket_market_v = per-bucket velocity из objective или rosreestr/N_active_region.

View file

@ -70,7 +70,9 @@ def _make_db_for_analyze(
_make_mapping(
{
"district_name": "Октябрьский",
"median_price_per_m2": 120000,
"ekb_reference_median": 120000,
"quarter_median_12m": None,
"quarter_deals_count": 0,
"dist_to_center": 1500.0,
}
)
@ -91,7 +93,7 @@ def _make_db_for_analyze(
first_val = geom_row
elif "AS wkt" in sql:
first_val = wkt_row
elif "AS median_price_per_m2" in sql and "district_name" in sql:
elif "AS ekb_reference_median" in sql and "district_name" in sql:
first_val = district_row
elif "AS lon" in sql and "AS lat" in sql:
first_val = centroid_row

View file

@ -53,11 +53,19 @@ def _make_db_for_analyze(
geom_found: bool = True,
district_found: bool = True,
market_price_row: dict[str, Any] | None = None,
district_row_override: dict[str, Any] | None = None,
success_rows: list[dict[str, Any]] | None = None,
geo_radius_row: dict[str, Any] | None = None,
district_price_row: dict[str, Any] | None = None,
) -> MagicMock:
"""Сконструировать mock DB Session для analyze_parcel.
market_price_row=None имитирует "нет данных в MV" (mp_row is None source='no_data').
market_price_row={...} имитирует найденную строку в MV.
district_row_override переопределяет raw-district mapping (#1960 basis chain).
success_rows строки v_bucket_success_score (#1955 obj_class passthrough).
geo_radius_row строка geo_radius_price (objective lots в радиусе, #1960 basis #2).
district_price_row строка district_price_block (objective по имени, #1960 basis #1).
"""
db = MagicMock()
@ -67,20 +75,20 @@ def _make_db_for_analyze(
else None
)
wkt_row = _make_mapping({"wkt": _WKT}) if geom_found else None
district_row = (
_make_mapping(
{
"district_name": "Октябрьский",
"median_price_per_m2": 120000,
"dist_to_center": 1500.0,
}
)
if district_found
else None
)
_district_data = district_row_override or {
"district_name": "Октябрьский",
"ekb_reference_median": 120000,
"quarter_median_12m": None,
"quarter_deals_count": 0,
"dist_to_center": 1500.0,
}
district_row = _make_mapping(_district_data) if district_found else None
centroid_row = _make_mapping({"lat": 56.84, "lon": 60.605})
mp_mock = _make_mapping(market_price_row) if market_price_row is not None else None
success_mocks = [_make_mapping(r) for r in success_rows] if success_rows is not None else []
geo_mock = _make_mapping(geo_radius_row) if geo_radius_row is not None else None
dp_mock = _make_mapping(district_price_row) if district_price_row is not None else None
def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:
# Нормализуем SQL для сигнатурного матчинга (collapse whitespace).
@ -94,13 +102,22 @@ def _make_db_for_analyze(
first_val = geom_row
elif "AS wkt" in sql:
first_val = wkt_row
elif "AS median_price_per_m2" in sql and "district_name" in sql:
elif "AS ekb_reference_median" in sql and "district_name" in sql:
first_val = district_row
elif "AS lon" in sql and "AS lat" in sql:
first_val = centroid_row
# market-price (#33) — уникальная сигнатура трёх скользящих медиан.
elif "median_6m" in sql and "median_12m" in sql and "median_24m" in sql:
first_val = mp_mock
# #1955: success-ranking из v_bucket_success_score.
elif "v_bucket_success_score" in sql:
all_val = success_mocks
# #1960 basis #1: district_price_block (Objective по имени района).
elif "AS sample_size" in sql and "objective_lots" in sql:
first_val = dp_mock
# #1960 basis #2: geo_radius_price (Objective в радиусе).
elif "AS n_complexes" in sql and "objective_lots" in sql:
first_val = geo_mock
r = MagicMock()
r.mappings.return_value.first.return_value = first_val
@ -254,3 +271,201 @@ def test_market_price_invalid_cad_returns_404() -> None:
finally:
app.dependency_overrides.clear()
_stop_patches()
# ── #1960: «Медиана рынка» = newbuild-consistent basis ──────────────────────────
def test_district_median_uses_geo_radius_not_quarter_rosreestr() -> None:
"""#1960: при тонкой росреестровской квартальной медиане (deals<5) карточка
district.median_price_per_m2 берёт newbuild geo_radius медиану, НЕ 64k.
Репродукция бага для 66:41:0205010:287: quarter_median_12m=63694 (1 ДКП),
geo_radius медиана=132690. Objective-по-имени отсутствует (name-match gap)
basis должен упасть на geo_radius_price.
"""
from app.core.db import get_db
db = _make_db_for_analyze(
district_row_override={
"district_name": "Железнодорожный",
"ekb_reference_median": 69687,
"quarter_median_12m": 63694, # загрязнённая росреестровская квартальная
"quarter_deals_count": 6, # 24-мес окно ≥5, но 12-мес — тонкое
"dist_to_center": 0.0,
},
# Objective по имени района отсутствует (5/9 районов ЕКБ без name-match).
district_price_row=None,
# geo_radius — newbuild basis (что и остальная страница).
geo_radius_row={"median": 132690.0, "n": 12085, "n_complexes": 14},
)
app.dependency_overrides[get_db] = _override_db(db)
_start_patches()
try:
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
assert resp.status_code == 200, resp.text
body = resp.json()
district = body["district"]
assert district["median_price_basis"] == "objective_geo_radius"
assert district["median_price_per_m2"] == pytest.approx(132690.0)
# КЛЮЧЕВОЕ: загрязнённая 64k росреестровская медиана НЕ выигрывает.
assert district["median_price_per_m2"] != pytest.approx(63694.0)
# И значение в newbuild-диапазоне (~130-200k).
assert 130000 <= district["median_price_per_m2"] <= 200000
finally:
app.dependency_overrides.clear()
_stop_patches()
def test_district_median_prefers_objective_district_when_available() -> None:
"""#1960: если есть Objective-медиана по имени района — она имеет приоритет
над geo_radius и квартальной росреестровской."""
from app.core.db import get_db
db = _make_db_for_analyze(
district_row_override={
"district_name": "Октябрьский",
"ekb_reference_median": 120000,
"quarter_median_12m": 64000,
"quarter_deals_count": 6,
"dist_to_center": 1500.0,
},
district_price_row={
"price_min": 90000,
"price_max": 250000,
"price_median": 165000,
"sample_size": 42,
},
geo_radius_row={"median": 132690.0, "n": 12085, "n_complexes": 14},
)
app.dependency_overrides[get_db] = _override_db(db)
_start_patches()
try:
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
assert resp.status_code == 200, resp.text
district = resp.json()["district"]
assert district["median_price_basis"] == "objective_district"
assert district["median_price_per_m2"] == pytest.approx(165000.0)
finally:
app.dependency_overrides.clear()
_stop_patches()
def test_district_median_quarter_rosreestr_gated_by_min_deals() -> None:
"""#1960: квартальная росреестровская медиана может стать basis ТОЛЬКО как
последний fallback и ТОЛЬКО при deals_count 5. Тонкий квартал (deals<5)
median None (не показываем загрязнённое значение)."""
from app.core.db import get_db
# Нет Objective-данных вообще, нет ekb_reference, тонкий квартал (1 сделка).
db = _make_db_for_analyze(
district_row_override={
"district_name": "Глухой",
"ekb_reference_median": None,
"quarter_median_12m": 64000,
"quarter_deals_count": 1, # < 5 → не проходит guard
"dist_to_center": 3000.0,
},
district_price_row=None,
geo_radius_row=None,
)
app.dependency_overrides[get_db] = _override_db(db)
_start_patches()
try:
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
assert resp.status_code == 200, resp.text
district = resp.json()["district"]
assert district["median_price_basis"] == "none"
assert district["median_price_per_m2"] is None
finally:
app.dependency_overrides.clear()
_stop_patches()
def test_district_median_quarter_rosreestr_selected_as_last_resort() -> None:
"""#1960: если ВСЕ newbuild-fallback'и None (нет Objective-по-имени, нет
geo_radius, нет ekb_reference), но квартальная росреестровская медиана имеет
deals_count 5 она выбирается как basis (4-й fallback) с явным источником."""
from app.core.db import get_db
db = _make_db_for_analyze(
district_row_override={
"district_name": "Глухой",
"ekb_reference_median": None, # нет справочной DDU-медианы
"quarter_median_12m": 88000,
"quarter_deals_count": 7, # ≥ 5 → проходит guard
"dist_to_center": 3000.0,
},
district_price_row=None, # нет Objective по имени
geo_radius_row=None, # нет geo_radius
)
app.dependency_overrides[get_db] = _override_db(db)
_start_patches()
try:
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
assert resp.status_code == 200, resp.text
district = resp.json()["district"]
assert district["median_price_basis"] == "quarter_rosreestr"
assert district["median_price_per_m2"] == pytest.approx(88000.0)
finally:
app.dependency_overrides.clear()
_stop_patches()
# ── #1955: obj_class passthrough в success_recommendation ────────────────────────
def test_success_recommendation_carries_obj_class() -> None:
"""#1955: ranking-строки несут obj_class — UI может различать строки с
одинаковым area-label (Комфорт vs Типовой vs «не указан»)."""
from app.core.db import get_db
success = [
{
"bucket": "Студии 15-30",
"obj_class": "Комфорт",
"success_score": 1.2,
"n_deals": 4857,
"avg_price_per_m2": 194000,
"avg_area_m2": 25.0,
"velocity_z": 0.5,
"price_z": 0.3,
"area_z": -0.2,
},
{
"bucket": "Студии 15-30",
"obj_class": "не указан",
"success_score": 0.4,
"n_deals": 165,
"avg_price_per_m2": 142000,
"avg_area_m2": 26.0,
"velocity_z": 0.1,
"price_z": -0.1,
"area_z": 0.0,
},
]
db = _make_db_for_analyze(success_rows=success)
app.dependency_overrides[get_db] = _override_db(db)
_start_patches()
try:
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
assert resp.status_code == 200, resp.text
rec = resp.json()["success_recommendation"]
assert rec is not None
ranking = rec["ranking"]
assert len(ranking) == 2
assert ranking[0]["obj_class"] == "Комфорт"
assert ranking[1]["obj_class"] == "не указан"
# Один area-label, но разные классы → больше не визуально-дубль.
assert ranking[0]["bucket"] == ranking[1]["bucket"]
assert ranking[0]["obj_class"] != ranking[1]["obj_class"]
# И никакого английского фантома 'Comfort'.
assert all(r["obj_class"] != "Comfort" for r in ranking)
finally:
app.dependency_overrides.clear()
_stop_patches()

View file

@ -40,14 +40,14 @@ def _make_mapping(data: dict[str, Any]) -> MagicMock:
def _make_db_for_analyze(poi_rows: list[Any]) -> MagicMock:
db = MagicMock()
geom_row = _make_mapping(
{"geom_geojson": _GEOJSON, "geom_wkb": None, "source": "cad_quarter"}
)
geom_row = _make_mapping({"geom_geojson": _GEOJSON, "geom_wkb": None, "source": "cad_quarter"})
wkt_row = _make_mapping({"wkt": _WKT})
district_row = _make_mapping(
{
"district_name": "Октябрьский",
"median_price_per_m2": 120000,
"ekb_reference_median": 120000,
"quarter_median_12m": None,
"quarter_deals_count": 0,
"dist_to_center": 1500.0,
}
)
@ -63,7 +63,7 @@ def _make_db_for_analyze(poi_rows: list[Any]) -> MagicMock:
first_val = geom_row
elif "AS wkt" in sql:
first_val = wkt_row
elif "AS median_price_per_m2" in sql and "district_name" in sql:
elif "AS ekb_reference_median" in sql and "district_name" in sql:
first_val = district_row
elif "AS lon" in sql and "AS lat" in sql:
# И centroid_row для блока 6, и OSRM-origin SELECT (тот же shape).
@ -170,9 +170,7 @@ def test_flag_on_osrm_replaces_distance(monkeypatch) -> None:
monkeypatch.setattr(settings, "use_osrm_distances", True)
_start_patches()
try:
with patch(
"app.api.v1.parcels.get_road_distances_m", return_value=[700.0]
) as mock_osrm:
with patch("app.api.v1.parcels.get_road_distances_m", return_value=[700.0]) as mock_osrm:
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
assert resp.status_code == 200, resp.text

View file

@ -65,7 +65,9 @@ def _make_db_for_analyze(
district_row = _make_mapping(
{
"district_name": "Октябрьский",
"median_price_per_m2": 120000,
"ekb_reference_median": 120000,
"quarter_median_12m": None,
"quarter_deals_count": 0,
"dist_to_center": 1500.0,
}
)

View file

@ -64,7 +64,9 @@ def _make_db_for_analyze(
district_row = _make_mapping(
{
"district_name": "Октябрьский",
"median_price_per_m2": 120000,
"ekb_reference_median": 120000,
"quarter_median_12m": None,
"quarter_deals_count": 0,
"dist_to_center": 1500.0,
}
)
@ -83,7 +85,7 @@ def _make_db_for_analyze(
first_val = geom_row
elif "AS wkt" in sql:
first_val = wkt_row
elif "AS median_price_per_m2" in sql and "district_name" in sql:
elif "AS ekb_reference_median" in sql and "district_name" in sql:
first_val = district_row
elif "AS lon" in sql and "AS lat" in sql:
first_val = centroid_row

View file

@ -110,7 +110,9 @@ def _make_db_for_analyze() -> MagicMock:
district_row = _make_mapping(
{
"district_name": "Октябрьский",
"median_price_per_m2": 120000,
"ekb_reference_median": 120000,
"quarter_median_12m": None,
"quarter_deals_count": 0,
"dist_to_center": 1500.0,
}
)

View file

@ -259,7 +259,9 @@ def _make_db_for_analyze() -> MagicMock:
district_row = _make_mapping_analyze(
{
"district_name": "Октябрьский",
"median_price_per_m2": 120000,
"ekb_reference_median": 120000,
"quarter_median_12m": None,
"quarter_deals_count": 0,
"dist_to_center": 1500.0,
}
)

View file

@ -77,7 +77,13 @@ def _make_db_for_analyze(geom_found: bool = True) -> MagicMock:
)
wkt_row = _make_mapping({"wkt": _WKT}) if geom_found else None
district_row = _make_mapping(
{"district_name": "Октябрьский", "median_price_per_m2": 120000, "dist_to_center": 1500.0}
{
"district_name": "Октябрьский",
"ekb_reference_median": 120000,
"quarter_median_12m": None,
"quarter_deals_count": 0,
"dist_to_center": 1500.0,
}
)
centroid_row = _make_mapping({"lat": 56.84, "lon": 60.605})
@ -88,7 +94,7 @@ def _make_db_for_analyze(geom_found: bool = True) -> MagicMock:
first_val = geom_row
elif "AS wkt" in sql:
first_val = wkt_row
elif "AS median_price_per_m2" in sql and "district_name" in sql:
elif "AS ekb_reference_median" in sql and "district_name" in sql:
first_val = district_row
elif "AS lon" in sql and "AS lat" in sql:
first_val = centroid_row
@ -157,9 +163,7 @@ def test_horizon_default_12_and_enqueued() -> None:
_start_patches()
delay_mock = MagicMock()
try:
with patch(
"app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock
):
with patch("app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock):
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
assert resp.status_code == 200, resp.text
@ -186,9 +190,7 @@ def test_horizon_valid_values_accepted() -> None:
_start_patches()
delay_mock = MagicMock()
try:
with patch(
"app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock
):
with patch("app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock):
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze?horizon={h}")
assert resp.status_code == 200, f"horizon={h}: {resp.text}"
@ -213,9 +215,7 @@ def test_horizon_invalid_returns_422() -> None:
_start_patches()
delay_mock = MagicMock()
try:
with patch(
"app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock
):
with patch("app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock):
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze?horizon={h}")
assert resp.status_code == 422, f"horizon={h}: {resp.text}"
@ -234,9 +234,7 @@ def test_enqueue_passes_created_by_header() -> None:
_start_patches()
delay_mock = MagicMock()
try:
with patch(
"app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock
):
with patch("app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock):
client = TestClient(app)
resp = client.post(
f"/api/v1/parcels/{_CAD}/analyze",
@ -261,9 +259,7 @@ def test_enqueue_failure_returns_200_unavailable() -> None:
_start_patches()
delay_mock = MagicMock(side_effect=RuntimeError("redis down"))
try:
with patch(
"app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock
):
with patch("app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock):
client = TestClient(app)
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
assert resp.status_code == 200, resp.text
@ -314,9 +310,7 @@ def test_get_forecast_ready_when_run_present() -> None:
try:
# 1-й вызов — §22-ран ("1.0"); 2-й — analyze-ран для gate_caveat (#1740),
# здесь None (gate не применяется).
with patch(
"app.api.v1.parcels.latest_run_for", side_effect=[fake_run, None]
) as lrf:
with patch("app.api.v1.parcels.latest_run_for", side_effect=[fake_run, None]) as lrf:
client = TestClient(app)
resp = client.get(f"/api/v1/parcels/{_CAD}/forecast")
assert resp.status_code == 200, resp.text
@ -354,9 +348,7 @@ def test_get_forecast_db_error_returns_pending_not_500() -> None:
db = MagicMock()
app.dependency_overrides[get_db] = _override_db(db)
try:
with patch(
"app.api.v1.parcels.latest_run_for", side_effect=RuntimeError("db gone")
):
with patch("app.api.v1.parcels.latest_run_for", side_effect=RuntimeError("db gone")):
client = TestClient(app)
resp = client.get(f"/api/v1/parcels/{_CAD}/forecast")
assert resp.status_code == 202, resp.text

View file

@ -54,7 +54,13 @@ def _make_db_for_analyze(geom_found: bool = True) -> MagicMock:
)
wkt_row = _make_mapping({"wkt": _WKT}) if geom_found else None
district_row = _make_mapping(
{"district_name": "Октябрьский", "median_price_per_m2": 120000, "dist_to_center": 1500.0}
{
"district_name": "Октябрьский",
"ekb_reference_median": 120000,
"quarter_median_12m": None,
"quarter_deals_count": 0,
"dist_to_center": 1500.0,
}
)
centroid_row = _make_mapping({"lat": 56.84, "lon": 60.605})
@ -65,7 +71,7 @@ def _make_db_for_analyze(geom_found: bool = True) -> MagicMock:
first_val = geom_row
elif "AS wkt" in sql:
first_val = wkt_row
elif "AS median_price_per_m2" in sql and "district_name" in sql:
elif "AS ekb_reference_median" in sql and "district_name" in sql:
first_val = district_row
elif "AS lon" in sql and "AS lat" in sql:
first_val = centroid_row

View file

@ -14,6 +14,7 @@ analytics_queries + прямые unit-тесты helper-функций.
from __future__ import annotations
import contextlib
from typing import Any
from unittest.mock import MagicMock, patch
@ -36,9 +37,7 @@ _CITY_BUCKET_DEALS = {
_TOTAL_DEALS = sum(_CITY_BUCKET_DEALS.values()) # 3800
def _make_bucket_row(
bucket_id: str, deals: int, area_avg: float = 40.0
) -> MagicMock:
def _make_bucket_row(bucket_id: str, deals: int, area_avg: float = 40.0) -> MagicMock:
r = MagicMock()
data = {
"bucket": bucket_id,
@ -111,7 +110,9 @@ class TestVelocityBaselinePerBucket:
]:
r = MagicMock()
r.__getitem__ = lambda self, k, _bid=bid, _med=median_pm, _obs=obs: {
"bucket_id": _bid, "median_pm": _med, "observations": _obs,
"bucket_id": _bid,
"median_pm": _med,
"observations": _obs,
}[k]
rows.append(r)
db.execute.return_value.mappings.return_value.all.return_value = rows
@ -131,12 +132,14 @@ class TestVelocityBaselinePerBucket:
db = MagicMock()
rows = []
for bid, median_pm, obs in [
("1-Студия", 3.0, 2), # < 3 наблюдений → пропускаем
("2-1-к", 5.0, 10), # OK
("1-Студия", 3.0, 2), # < 3 наблюдений → пропускаем
("2-1-к", 5.0, 10), # OK
]:
r = MagicMock()
r.__getitem__ = lambda self, k, _bid=bid, _med=median_pm, _obs=obs: {
"bucket_id": _bid, "median_pm": _med, "observations": _obs,
"bucket_id": _bid,
"median_pm": _med,
"observations": _obs,
}[k]
rows.append(r)
db.execute.return_value.mappings.return_value.all.return_value = rows
@ -157,7 +160,9 @@ class TestVelocityBaselinePerBucket:
for bid, obs in [("1-Студия", 1), ("2-1-к", 2)]:
r = MagicMock()
r.__getitem__ = lambda self, k, _bid=bid, _obs=obs: {
"bucket_id": _bid, "median_pm": 3.0, "observations": _obs,
"bucket_id": _bid,
"median_pm": 3.0,
"observations": _obs,
}[k]
rows.append(r)
db.execute.return_value.mappings.return_value.all.return_value = rows
@ -208,7 +213,7 @@ class TestRosreestrFallbackPerBucketVelocity:
def test_velocity_scales_with_competitor_count(self) -> None:
"""При большем n_comp velocity одного проекта меньше (делим рынок на больше ЖК)."""
v_few = 710 / 24 / 5 # 5 конкурентов
v_few = 710 / 24 / 5 # 5 конкурентов
v_many = 710 / 24 / 15 # 15 конкурентов
assert v_few > v_many
assert v_few == pytest.approx(710 / 24 / 5, rel=0.001)
@ -241,30 +246,35 @@ def _make_full_mock_db(has_class_data: bool = False) -> MagicMock:
"mean_price_per_m2": 112_000.0,
}[k]
# Sequence для прямых db.execute calls
calls: list[MagicMock] = []
# Сигнатурный диспетчер вместо позиционной последовательности — устойчив к
# доп. запросам class-пути (#1955: class_multiplier yandex_realty_zk
# выполняется ТОЛЬКО когда target_class задан → лишний db.execute, который
# ломал бы жёсткую нумерацию).
def _dispatch(stmt: Any, params: Any = None) -> MagicMock:
sql = " ".join(str(stmt).split())
r = MagicMock()
# district_row
if "FROM ekb_districts" in sql and "district_name ILIKE" in sql:
r.mappings.return_value.first.return_value = dr
# has_class_data
elif "FROM domrf_kn_objects" in sql and "obj_class_fallback" in sql:
r.scalar.return_value = 1 if has_class_data else None
# class_multiplier (yandex_realty_zk) — только class-путь
elif "comfort_avg" in sql or "yandex_realty_zk" in sql:
cr = MagicMock()
cr.__getitem__ = lambda self, k: {"class_avg": None, "comfort_avg": None}[k]
r.mappings.return_value.first.return_value = cr
# comparables → пусто
elif "latest_agg" in sql or "domrf_kn_sales_agg" in sql:
r.mappings.return_value.all.return_value = []
# city_median (любой оставшийся scalar)
else:
r.scalar.return_value = 110_000.0
r.mappings.return_value.all.return_value = []
r.mappings.return_value.first.return_value = None
return r
# 1) district_row
r1 = MagicMock()
r1.mappings.return_value.first.return_value = dr
calls.append(r1)
# 2) city_median scalar
r2 = MagicMock()
r2.scalar.return_value = 110_000.0
calls.append(r2)
# 3) has_class_data scalar
r3 = MagicMock()
r3.scalar.return_value = 1 if has_class_data else None
calls.append(r3)
# 4) comparables query → пустой
r4 = MagicMock()
r4.mappings.return_value.all.return_value = []
calls.append(r4)
db.execute.side_effect = calls
db.execute.side_effect = _dispatch
return db
@ -276,6 +286,8 @@ def _run_recommend_mix_full(
area_total_m2: float = 12_000.0,
horizon_months: int | None = None,
cad_num: str | None = None,
target_class: str | None = None,
patch_success_ranking: bool = True,
) -> dict[str, Any]:
"""Запускает recommend_mix с правильным набором моков.
@ -284,10 +296,16 @@ def _run_recommend_mix_full(
horizon_months/cad_num #982 forecast-overlay opt-in (по умолчанию None →
живой микс БАЙТ-в-БАЙТ как раньше; существующие вызовы не затронуты).
target_class #1955: проверка перевода english→русский перед success-ranking.
has_class_data=True когда target_class задан, чтобы class-путь вернул строки.
patch_success_ranking если False, НЕ патчим _bucket_success_ranking (даём
реальной функции/внешнему spy отработать; #1955 translation-тест).
"""
from app.services.analytics_queries import recommend_mix
db = _make_full_mock_db()
db = _make_full_mock_db(has_class_data=target_class is not None)
patches = [
patch(f"{_MOD}._bucket_distribution", return_value=_city_bucket_rows()),
@ -321,32 +339,19 @@ def _run_recommend_mix_full(
),
patch(f"{_MOD}._current_mortgage_rate", return_value=(None, None)),
patch(f"{_MOD}._noise_penalty_factor", return_value=(1.0, [])),
patch(f"{_MOD}._bucket_success_ranking", return_value=[]),
patch(f"{_MOD}._recommend_data_last_updated", return_value=None),
]
if patch_success_ranking:
patches.append(patch(f"{_MOD}._bucket_success_ranking", return_value=[]))
with (
patches[0],
patches[1],
patches[2],
patches[3],
patches[4],
patches[5],
patches[6],
patches[7],
patches[8],
patches[9],
patches[10],
patches[11],
patches[12],
patches[13],
patches[14],
):
with contextlib.ExitStack() as stack:
for p in patches:
stack.enter_context(p)
return recommend_mix(
db,
district_name="Ленинский",
area_total_m2=area_total_m2,
target_class=None,
target_class=target_class,
months_window=24,
region_code=66,
horizon_months=horizon_months,
@ -417,9 +422,9 @@ class TestRealisticSrokFallback:
)
srok = result["summary"]["months_to_sellout_total"]
assert srok is not None
assert lo <= srok <= hi, (
f"n_comp={n_comp}, area={area}: срок {srok:.1f} вне [{lo}, {hi}]"
)
assert (
lo <= srok <= hi
), f"n_comp={n_comp}, area={area}: срок {srok:.1f} вне [{lo}, {hi}]"
def test_scope_has_n_competitors(self) -> None:
"""scope.n_competitors присутствует и равен district+class competitors."""
@ -487,9 +492,10 @@ class TestPerBucketVelocityVariesByBucket:
)
for b in result["buckets"]:
assert "velocity_source" in b, f"Бакет '{b['bucket']}' не имеет velocity_source"
assert b["velocity_source"] in ("rosreestr_fallback", "objective_per_bucket"), (
f"Неожиданное velocity_source='{b['velocity_source']}'"
)
assert b["velocity_source"] in (
"rosreestr_fallback",
"objective_per_bucket",
), f"Неожиданное velocity_source='{b['velocity_source']}'"
class TestObjectivePerBucketPath:
@ -516,9 +522,9 @@ class TestObjectivePerBucketPath:
# Studio: macro_mult = sat_factor × trend_factor = 1.0 × 1.0 = 1.0
studio = bkt_map.get("Студии 15-30")
assert studio is not None
assert studio["velocity_per_month"] == pytest.approx(3.5, rel=0.01), (
f"Studio velocity={studio['velocity_per_month']:.3f}, ожидалось 3.5"
)
assert studio["velocity_per_month"] == pytest.approx(
3.5, rel=0.01
), f"Studio velocity={studio['velocity_per_month']:.3f}, ожидалось 3.5"
assert studio.get("velocity_source") == "objective_per_bucket"
def test_objective_velocities_vary(self) -> None:
@ -621,3 +627,96 @@ class TestForecastOverlayOptIn:
assert result["scope"]["forecast"]["advisory"] is True
assert "boom" in result["scope"]["forecast"]["error"]
assert set(result.keys()) == {"scope", "buckets", "summary", "comparables"}
# ── #1955: _bucket_success_ranking использует русский default (НЕ english 'Comfort')
def _make_ranking_row(bucket: str, score: float, n: int) -> MagicMock:
r = MagicMock()
data = {
"bucket": bucket,
"success_score": score,
"n_deals": n,
"velocity_z": 0.1,
"price_z": 0.2,
"area_z": -0.1,
}
r.__getitem__ = lambda self, k: data[k]
return r
class TestBucketSuccessRankingDefault:
"""#1955: после миграции 172 view хранит только русские классы — default
в _bucket_success_ranking ДОЛЖЕН быть 'Комфорт', не english 'Comfort'
(иначе obj_class = 'Comfort' матчит 0 строк success-boost тихо пустеет)."""
def test_default_class_is_russian_komfort_in_sql(self) -> None:
"""SQL использует COALESCE(:cls, 'Комфорт'), НЕ 'Comfort'."""
from app.services.analytics_queries import _bucket_success_ranking
captured: dict[str, Any] = {}
def _exec(stmt: Any, params: Any) -> MagicMock:
captured["sql"] = " ".join(str(stmt).split())
captured["params"] = params
# Реальный запрос отрабатывает и возвращает строки (как было бы с
# русским default-классом в обновлённом view).
return _make_mapping_result([_make_ranking_row("1-к 30-45", 1.2, 1029)])
db = MagicMock()
db.execute.side_effect = _exec
# target_class=None → должен сработать default-путь.
result = _bucket_success_ranking(db, "Железнодорожный", None)
assert "'Комфорт'" in captured["sql"], captured["sql"]
assert "'Comfort'" not in captured["sql"], "english 'Comfort' default — баг #1955"
# Реальный запрос (не замокан в []) вернул непустой ranking.
assert result, "success_ranking пуст — default-класс не матчит view (#1955)"
assert result[0]["bucket"] == "1-к 30-45"
assert result[0]["n_deals"] == 1029
def test_explicit_russian_class_passed_through(self) -> None:
"""Явный русский класс уходит в bind как есть (caller уже перевёл)."""
from app.services.analytics_queries import _bucket_success_ranking
captured: dict[str, Any] = {}
def _exec(stmt: Any, params: Any) -> MagicMock:
captured["params"] = params
return _make_mapping_result([_make_ranking_row("Студии 15-30", 0.9, 4857)])
db = MagicMock()
db.execute.side_effect = _exec
result = _bucket_success_ranking(db, "Академический", "Комфорт")
assert captured["params"]["cls"] == "Комфорт"
assert result and result[0]["n_deals"] == 4857
class TestRecommendMixTranslatesClassForSuccessRanking:
"""#1955: recommend_mix ДОЛЖЕН передавать переведённый русский класс
(target_class_db) в _bucket_success_ranking, а не сырой english target_class
из UI ('Comfort' != 'Комфорт' 0 строк)."""
def test_english_ui_class_translated_before_ranking_query(self) -> None:
from app.services.analytics_queries import _class_to_db_vocab
captured: dict[str, Any] = {}
def _spy_ranking(db: Any, dn: str | None, cls: str | None) -> list[dict]:
captured["cls"] = cls
return []
with patch(f"{_MOD}._bucket_success_ranking", side_effect=_spy_ranking):
# UI шлёт english 'Business' → recommend_mix должен передать русский 'Бизнес'.
# patch_success_ranking=False → helper НЕ перепатчивает наш spy на [].
_run_recommend_mix_full(
objective_per_bucket=None,
target_class="Business",
patch_success_ranking=False,
)
assert captured["cls"] == _class_to_db_vocab("Business") == "Бизнес"
assert captured["cls"] != "Business", "сырой english класс утёк в ranking-запрос (#1955)"

View file

@ -0,0 +1,124 @@
-- 172_fix_v_bucket_success_score_obj_class.sql
-- Issue #1955 (audit epic #1953) — «Что хорошо продаётся»: дубли бакетов + фантом Comfort.
--
-- Проблема:
-- Старый view (86_v_bucket_success_score.sql) использовал
-- `COALESCE(o.obj_class, 'Comfort')` — АНГЛИЙСКИЙ литерал, которым заполнялись
-- NULL-классы (на проде: 397 объектов с obj_class IS NULL, регион 66). В итоге
-- эти строки сливались в фантомный класс 'Comfort', отдельный от реального
-- русского 'Комфорт' (723 объекта). В UI таблица проецирует только bucket-label,
-- поэтому строки разных obj_class с одинаковым area-label визуально дублировались
-- («Студии 15-30» = Комфорт 8256 И Comfort 150; «1-к 30-45» = Типовой И Бизнес).
--
-- Фикс:
-- 1. NULL obj_class → честный `'не указан'` (НЕ домысливаем comfort).
-- 2. obj_class уже присутствует в выходных колонках (был и раньше) — backend
-- теперь протаскивает его в API (parcels.py success-ranking), чтобы UI мог
-- различать классы и не схлопывать строки.
--
-- Нормализация синонимов: проверены distinct obj_class на проде (регион 66, latest
-- snapshot per obj): только канонические русские значения —
-- 'Комфорт' (723), NULL (397), 'Типовой' (105), 'Бизнес' (84), 'Элит' (7),
-- 'Премиум' (6). Английских/нестандартных литералов в ИСТОЧНИКЕ нет (фантом
-- 'Comfort' рождался ИСКЛЮЧИТЕЛЬНО из COALESCE). Поэтому synonym-mapping не нужен —
-- замена COALESCE-литерала полностью решает проблему. Если в будущем появятся
-- реальные не-канонические значения (Comfort/Economy/Business/...) — добавить CASE
-- normalization здесь.
--
-- Dependencies:
-- domrf_kn_objects (obj_id, district_name, obj_class, region_cd, snapshot_date),
-- domrf_kn_flats, domrf_kn_sale_graph.
-- Idempotent: CREATE OR REPLACE VIEW (структура колонок не меняется — backend-safe).
-- Deploy: auto-applied by deploy.yml via _schema_migrations tracking.
BEGIN;
CREATE OR REPLACE VIEW v_bucket_success_score AS
WITH
-- Latest snapshot per object (both tables are time-series)
latest_obj AS (
SELECT DISTINCT ON (obj_id)
obj_id, district_name, obj_class, region_cd
FROM domrf_kn_objects
WHERE district_name IS NOT NULL
ORDER BY obj_id, snapshot_date DESC
),
bucket_aggs AS (
SELECT
o.district_name,
-- #1955: NULL obj_class → честный 'не указан' (НЕ английский 'Comfort').
-- Это убирает фантомный класс и сливание с реальным русским 'Комфорт'.
COALESCE(o.obj_class, 'не указан') AS obj_class,
CASE
WHEN f.rooms = 0 OR f.total_area < 30 THEN 'Студии 15-30'
WHEN f.total_area < 45 THEN '1-к 30-45'
WHEN f.total_area < 60 THEN '2-к 45-60'
WHEN f.total_area < 80 THEN '3-к 60-80'
ELSE '80+ м²'
END AS bucket,
AVG(NULLIF(f.price_per_m2, 0)) AS avg_price_per_m2,
AVG(NULLIF(f.total_area, 0)) AS avg_area_m2,
COUNT(*) AS n_deals,
-- Velocity proxy: avg monthly contracted units per object, over last 24 months
AVG(
(SELECT AVG(sg.contracted)
FROM domrf_kn_sale_graph sg
WHERE sg.obj_id = o.obj_id
AND sg.report_month > NOW() - INTERVAL '24 months')
) AS avg_velocity_per_month
FROM domrf_kn_flats f
JOIN latest_obj o ON o.obj_id = f.obj_id
WHERE f.total_area BETWEEN 15 AND 200
AND f.price_per_m2 BETWEEN 30000 AND 500000
AND o.region_cd = 66
AND f.snapshot_date > CURRENT_DATE - INTERVAL '24 months'
GROUP BY 1, 2, 3
HAVING COUNT(*) >= 15 -- мин 15: weak-confidence (15-29), strong (≥30)
),
z_scores AS (
SELECT
district_name, obj_class, bucket,
n_deals, avg_price_per_m2, avg_area_m2, avg_velocity_per_month,
-- Z-score нормированный по группе (district × class)
(avg_velocity_per_month
- AVG(avg_velocity_per_month) OVER (PARTITION BY district_name, obj_class))
/ NULLIF(STDDEV(avg_velocity_per_month) OVER (PARTITION BY district_name, obj_class), 0)
AS velocity_z,
(avg_price_per_m2
- AVG(avg_price_per_m2) OVER (PARTITION BY district_name, obj_class))
/ NULLIF(STDDEV(avg_price_per_m2) OVER (PARTITION BY district_name, obj_class), 0)
AS price_z,
(avg_area_m2
- AVG(avg_area_m2) OVER (PARTITION BY district_name, obj_class))
/ NULLIF(STDDEV(avg_area_m2) OVER (PARTITION BY district_name, obj_class), 0)
AS area_z
FROM bucket_aggs
)
SELECT
district_name,
obj_class,
bucket,
n_deals,
avg_price_per_m2,
avg_area_m2,
avg_velocity_per_month,
COALESCE(velocity_z, 0) * 0.5
+ COALESCE(price_z, 0) * 0.3
- COALESCE(area_z, 0) * 0.2 AS success_score,
COALESCE(velocity_z, 0) AS velocity_z,
COALESCE(price_z, 0) AS price_z,
COALESCE(area_z, 0) AS area_z
FROM z_scores
ORDER BY district_name, obj_class, success_score DESC;
COMMENT ON VIEW v_bucket_success_score IS
'Success-driven bucket ranking per (district, class). '
'Z-scores: velocity (+) приоритет, price (+) приоритет, area (-) приоритет. '
'Min 15 сделок в группе (15-29 = weak confidence, ≥30 = strong). '
'NULL obj_class → ''не указан'' (#1955: убран фантом ''Comfort''). '
'Используется recommend_mix + analyze success_recommendation (issue #25, #1955).';
COMMIT;

View file

@ -257,7 +257,9 @@ function SiteFinderContent() {
// (≈0 внутри района), а не до центра города (#1414).
const distToCenterKm = data?.location?.distance_to_center_km;
const districtLabel = data?.district
? `${(data.district.median_price_per_m2 / 1000).toFixed(0)} тыс ₽/м²` +
? (data.district.median_price_per_m2 != null
? `${(data.district.median_price_per_m2 / 1000).toFixed(0)} тыс ₽/м²`
: "нет данных") +
(distToCenterKm != null
? ` · ${distToCenterKm.toFixed(1)} км от центра`
: "")

View file

@ -76,9 +76,11 @@ export function OverviewTab({ data, onIsochronesResult }: Props) {
<div style={{ fontSize: 13, color: "#6b7280" }}>
Медиана:{" "}
<strong style={{ color: "#374151" }}>
{(data.district.median_price_per_m2 / 1000).toFixed(0)} тыс /м²
{data.district.median_price_per_m2 != null
? `${(data.district.median_price_per_m2 / 1000).toFixed(0)} тыс ₽/м²`
: "нет данных"}
</strong>
<span className="text-xs text-slate-500 ml-1">(12 мес)</span>
<span className="text-xs text-slate-500 ml-1">(новостройки)</span>
<span style={{ marginLeft: 12 }}>
{(data.district.dist_to_center / 1000).toFixed(1)} км до центра
</span>

View file

@ -57,7 +57,13 @@ export interface ParcelAnalysisCompetitor {
export interface ParcelAnalysisDistrict {
district_name: string;
median_price_per_m2: number;
/** #1960: newbuild-consistent медиана цены района (Objective/DOM.РФ basis).
* null, если ни один basis не разрешился. См. median_price_basis. */
median_price_per_m2: number | null;
/** #1960: какой источник дал median_price_per_m2
* objective_district | objective_geo_radius | ekb_districts_reference |
* quarter_rosreestr | none. */
median_price_basis?: string;
dist_to_center: number;
}
@ -376,6 +382,9 @@ export interface GeometrySuitability {
export interface SuccessRankingBucket {
bucket: string;
/** #1955: класс ЖК ('Комфорт' / 'Типовой' / 'Бизнес' / 'не указан' / )
* различает строки с одинаковым area-label, убирает визуальные дубли бакетов. */
obj_class: string;
success_score: number;
n_deals: number;
avg_price_per_m2: number | null;