From b405ae291d234bc7a10e8825dd0b0c8628226b23 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 14 Jun 2026 16:10:02 +0000 Subject: [PATCH] feat(site-finder): additive objective_lots price fallback for competitors (#307 OBJ-3) (#1332) --- backend/app/schemas/parcel.py | 5 + .../app/services/site_finder/competitors.py | 57 ++++++++++ .../tests/api/v1/test_parcel_competitors.py | 105 +++++++++++++++++- frontend/src/lib/api-types.ts | 2 + 4 files changed, 168 insertions(+), 1 deletion(-) diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py index 4ef73c02..a0394991 100644 --- a/backend/app/schemas/parcel.py +++ b/backend/app/schemas/parcel.py @@ -198,6 +198,11 @@ class Competitor(BaseModel): sold_pct: float | None velocity_per_month: float avg_price_per_m2: float | None + # OBJ-3 (#307): источник avg_price_per_m2 для прозрачности инвест-рекомендаций. + # "domrf" — per-flat цена domrf_kn_flats (приоритет); "objective" — медиана + # objective_lots.price_per_m2_rub через objective_complex_mapping (additive + # fallback, заполняет пробелы где domrf пуст); None — цены нет ни там, ни там. + price_source: str | None = None is_active: bool # #949 PR B (ТЗ §9.1, §16): детерминированный relevance_weight ∈ [0,1] — # взвешенная комбинация geo_proximity / class_similarity / price_similarity / diff --git a/backend/app/services/site_finder/competitors.py b/backend/app/services/site_finder/competitors.py index 1e25af95..b03b560c 100644 --- a/backend/app/services/site_finder/competitors.py +++ b/backend/app/services/site_finder/competitors.py @@ -473,6 +473,37 @@ _AVG_PRICE_SQL = text(""" GROUP BY f.obj_id """) +# OBJ-3 (#307): additive fallback цены м² из objective_lots, когда domrf-цены нет. +# Замер прода 2026-06: на ПОСЛЕДНЕМ snapshot domrf_kn_flats.price_per_m2 = 0 строк +# (скрапер перестал отдавать per-flat цены), поэтому _AVG_PRICE_SQL выше даёт +# avg_price_per_m2=None почти всем конкурентам. objective_lots.price_per_m2_rub +# покрывает ~80% (919795/1123771 строк) и мостится к domrf-obj_id через +# objective_complex_mapping (то же звено, что _INLINE_VELOCITY_SQL в best_layouts). +# +# Мост: domrf_kn_objects.obj_id == objective_complex_mapping.domrf_obj_id +# → objective_complex_mapping.objective_complex_name == objective_lots.project_name. +# Маппинг 1:1 (прод: 263 строки, 263 distinct obj_id, 0 obj_id с >1 именем), поэтому +# на obj_id приходится РОВНО один project_name; корпуса проекта агрегируются в один +# проект естественно. Берём МЕДИАНУ price_per_m2_rub по всем лотам проекта +# (percentile_cont 0.5) — устойчива к хвостовым выбросам пентхаусов/студий, в отличие +# от AVG. Прод-санити: медиана медиан 136k руб/м² (диапазон 80k–250k для ЕКБ ✓). +# +# Additive-контракт: возвращаем ТОЛЬКО obj_id, у которых objective-цена есть; в Python +# заполняем пробелы (domrf-цена приоритетна, objective — fallback). Существующие +# непустые domrf-выводы НЕ меняются. price_source делает источник прозрачным. +_OBJECTIVE_PRICE_FALLBACK_SQL = text(""" + SELECT + cm.domrf_obj_id AS obj_id, + percentile_cont(0.5) WITHIN GROUP (ORDER BY ol.price_per_m2_rub) + AS median_price_per_m2 + FROM objective_complex_mapping cm + JOIN objective_lots ol + ON ol.project_name = cm.objective_complex_name + WHERE cm.domrf_obj_id = ANY(:obj_ids) + AND ol.price_per_m2_rub IS NOT NULL + GROUP BY cm.domrf_obj_id +""") + def _quarter_from_cad(cad_num: str) -> str: """Извлечь кадастровый квартал из номера участка/здания. @@ -579,6 +610,7 @@ def get_competitors( # Читаем через _row_get: существующие тесты мокируют только avg_price_per_m2, # отсутствующий ключ flats_sold → None (не KeyError) → нейтральный stage. avg_price_map: dict[int, float] = {} + price_source_map: dict[int, str] = {} sold_count_map: dict[int, int] = {} try: price_rows = db.execute(_AVG_PRICE_SQL, {"obj_ids": obj_ids}).mappings().all() @@ -587,12 +619,36 @@ def get_competitors( price = _row_get(r, "avg_price_per_m2") if price is not None: avg_price_map[oid] = float(price) + price_source_map[oid] = "domrf" sold = _row_get(r, "flats_sold") if sold is not None: sold_count_map[oid] = int(sold) except Exception: logger.warning("competitors: avg_price query failed, continuing without prices") + # ── 4a. OBJ-3 (#307) additive fallback: где domrf-цены НЕТ → objective_lots ── + # Заполняем ТОЛЬКО пробелы (obj_id без domrf-цены). Существующие domrf-выводы + # не трогаем (precedence: domrf > objective). Прод 2026-06: domrf на последнем + # snapshot = 0 цен → почти все obj_id попадают в этот fallback (~80% покрытие). + missing_price_ids = [oid for oid in obj_ids if oid not in avg_price_map] + if missing_price_ids: + try: + obj_price_rows = ( + db.execute(_OBJECTIVE_PRICE_FALLBACK_SQL, {"obj_ids": missing_price_ids}) + .mappings() + .all() + ) + for r in obj_price_rows: + oid = int(r["obj_id"]) + price = _row_get(r, "median_price_per_m2") + if price is not None and oid not in avg_price_map: + avg_price_map[oid] = float(price) + price_source_map[oid] = "objective" + except Exception: + logger.warning( + "competitors: objective price fallback failed, continuing with domrf only" + ) + # ── 4b. Локальные эталоны для relevance (медиана цены, доминирующий класс) ─ radius_m = request.radius_km * 1000.0 local_median_ppm2 = _median(list(avg_price_map.values())) @@ -656,6 +712,7 @@ def get_competitors( sold_pct=sold_pct, velocity_per_month=velocity, avg_price_per_m2=avg_price_map.get(obj_id), + price_source=price_source_map.get(obj_id), is_active=is_active, relevance_weight=round(relevance_weight, 3), relevance_breakdown=relevance_breakdown, diff --git a/backend/tests/api/v1/test_parcel_competitors.py b/backend/tests/api/v1/test_parcel_competitors.py index 908bdfc5..ef6e5f7d 100644 --- a/backend/tests/api/v1/test_parcel_competitors.py +++ b/backend/tests/api/v1/test_parcel_competitors.py @@ -53,6 +53,13 @@ def _price_row(obj_id: int, price: float) -> MagicMock: return r +def _obj_price_row(obj_id: int, price: float) -> MagicMock: + """Строка objective-fallback цены (_OBJECTIVE_PRICE_FALLBACK_SQL, OBJ-3 #307).""" + r = MagicMock() + r.__getitem__ = lambda self, k: {"obj_id": obj_id, "median_price_per_m2": price}[k] + return r + + # ── Построение mock DB ──────────────────────────────────────────────────────── @@ -60,13 +67,17 @@ def _make_db( coord: MagicMock | None = None, obj_rows: list[MagicMock] | None = None, price_rows: list[MagicMock] | None = None, + obj_price_rows: list[MagicMock] | None = None, ) -> MagicMock: """Сконструировать mock Session. Порядок вызовов execute: 1. centroid query → coord 2. competitors query → obj_rows - 3. avg_price query → price_rows + 3. domrf avg_price query → price_rows + 4. objective price fallback (OBJ-3 #307) → obj_price_rows + (вызывается ТОЛЬКО если у части obj_id нет domrf-цены — почти всегда, + т.к. domrf на последнем snapshot пуст; по умолчанию [] = fallback пуст) """ db = MagicMock() @@ -75,6 +86,7 @@ def _make_db( (coord, True), (obj_rows or [], False), (price_rows or [], False), + (obj_price_rows or [], False), ]: result = MagicMock() if is_first: @@ -390,6 +402,97 @@ def test_competitors_avg_price_populated() -> None: assert comp["avg_price_per_m2"] == pytest.approx(150_000.0), ( "avg_price_per_m2 должен быть не None — регрессия #227 status='sold' filter" ) + # OBJ-3 #307: domrf-hit → price_source='domrf'. + assert comp["price_source"] == "domrf" + finally: + app.dependency_overrides.clear() + + +# ── OBJ-3 (#307) additive fallback из objective_lots ──────────────────────────── + + +def test_competitors_objective_price_fallback_fills_gap() -> None: + """domrf-цены НЕТ → objective_lots заполняет avg_price_per_m2 (OBJ-3 #307). + + Прод 2026-06: domrf_kn_flats.price_per_m2 на последнем snapshot = 0 строк, + поэтому domrf даёт None почти всем. objective_lots (через + objective_complex_mapping) покрывает ~80% — этот fallback восстанавливает цену. + """ + rows = [_obj_row(obj_id=1)] + # domrf price пуст → fallback на objective median. + obj_price_rows = [_obj_price_row(obj_id=1, price=136_000.0)] + db = _make_db( + coord=_coord_row(), obj_rows=rows, price_rows=[], obj_price_rows=obj_price_rows + ) + + from app.core.db import get_db + + app.dependency_overrides[get_db] = _override_db(db) + try: + client = TestClient(app) + resp = client.post( + "/api/v1/parcels/66:41:0303161:5/competitors", + json={"radius_km": 1.0, "time_window": "last_quarter"}, + ) + assert resp.status_code == 200, resp.text + comp = resp.json()["competitors"][0] + assert comp["avg_price_per_m2"] == pytest.approx(136_000.0) + assert comp["price_source"] == "objective" + finally: + app.dependency_overrides.clear() + + +def test_competitors_domrf_price_wins_over_objective() -> None: + """domrf-цена приоритетна: objective НЕ перетирает существующий domrf-вывод. + + Additive-контракт OBJ-3: где domrf есть → не трогаем; objective только пробелы. + """ + rows = [_obj_row(obj_id=1)] + price_rows = [_price_row(obj_id=1, price=150_000.0)] + # objective тоже вернул бы цену, но domrf уже есть → fallback её игнорирует. + obj_price_rows = [_obj_price_row(obj_id=1, price=99_000.0)] + db = _make_db( + coord=_coord_row(), + obj_rows=rows, + price_rows=price_rows, + obj_price_rows=obj_price_rows, + ) + + from app.core.db import get_db + + app.dependency_overrides[get_db] = _override_db(db) + try: + client = TestClient(app) + resp = client.post( + "/api/v1/parcels/66:41:0303161:5/competitors", + json={"radius_km": 1.0, "time_window": "last_quarter"}, + ) + assert resp.status_code == 200, resp.text + comp = resp.json()["competitors"][0] + assert comp["avg_price_per_m2"] == pytest.approx(150_000.0) + assert comp["price_source"] == "domrf" + finally: + app.dependency_overrides.clear() + + +def test_competitors_no_price_anywhere_is_none() -> None: + """Ни domrf, ни objective → avg_price_per_m2 None, price_source None (graceful).""" + rows = [_obj_row(obj_id=1)] + db = _make_db(coord=_coord_row(), obj_rows=rows, price_rows=[], obj_price_rows=[]) + + from app.core.db import get_db + + app.dependency_overrides[get_db] = _override_db(db) + try: + client = TestClient(app) + resp = client.post( + "/api/v1/parcels/66:41:0303161:5/competitors", + json={"radius_km": 1.0, "time_window": "last_quarter"}, + ) + assert resp.status_code == 200, resp.text + comp = resp.json()["competitors"][0] + assert comp["avg_price_per_m2"] is None + assert comp["price_source"] is None finally: app.dependency_overrides.clear() diff --git a/frontend/src/lib/api-types.ts b/frontend/src/lib/api-types.ts index d1d9c8e1..b9020492 100644 --- a/frontend/src/lib/api-types.ts +++ b/frontend/src/lib/api-types.ts @@ -3070,6 +3070,8 @@ export interface components { velocity_per_month: number; /** Avg Price Per M2 */ avg_price_per_m2: number | null; + /** Price Source */ + price_source?: string | null; /** Is Active */ is_active: boolean; /** Relevance Weight */