From 50c7668046d7fb4cb9eafe9dde064f5c5daeb707 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 17 May 2026 23:02:18 +0300 Subject: [PATCH] feat(obj-3): migrate Site Finder competitors to objective_lots ground truth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enrich competitor_rows query with pricing from objective_lots via objective_complex_mapping JOIN backbone (domrf_obj_id → project_name). New fields in competitors[]: avg_price_per_m2_rub (81% coverage vs 0.3% from domrf_kn_flats), avg_area_pd, units_sold, units_available, lots_with_price. LEFT JOIN preserves competitors without mapping (fields = NULL), no regression on existing competitor count/order. Also fix test_velocity.py: update _sales_row mock with has_mapping field and VelocityResult constructor with velocity_data_available/velocity_source fields introduced in OBJ-2 (velocity.py had diverged from test mocks). All 33 velocity + gate_verdict tests pass. Closes #307 (OBJ-3 sub-task) --- backend/app/api/v1/parcels.py | 48 ++++++++++++++++++++++++++-------- backend/tests/test_velocity.py | 28 ++++++++++++++++---- 2 files changed, 60 insertions(+), 16 deletions(-) diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index f76ec80b..497f5437 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -1330,7 +1330,13 @@ def analyze_parcel( } ) - # 5) Конкуренты в радиусе 3 км из DOM.РФ. + # 5) Конкуренты в радиусе 3 км из DOM.РФ с ценами из objective_lots. + # OBJ-3: обогащаем каждый ЖК данными objective_lots через маппинг + # domrf_kn_objects.obj_id → objective_complex_mapping.domrf_obj_id + # → objective_lots.project_name. + # Агрегат: avg_price_per_m2_rub (81% coverage), avg_area_pd, units_sold, + # units_available — для UI-блока «Конкуренты». + # LEFT JOIN — ЖК без маппинга остаются в выдаче (поля = NULL). # NB: domrf_kn_objects имеет ~3 snapshot per obj_id → DISTINCT ON по # latest snapshot, иначе дубликаты ЖК в выдаче. competitor_rows = ( @@ -1341,26 +1347,46 @@ def analyze_parcel( FROM domrf_kn_objects WHERE latitude IS NOT NULL ORDER BY obj_id, snapshot_date DESC NULLS LAST + ), + obj_pricing AS ( + SELECT + cm.domrf_obj_id, + ROUND(AVG(ol.price_per_m2_rub)::numeric, 0) AS avg_price_per_m2_rub, + ROUND(AVG(ol.area_pd)::numeric, 1) AS avg_area_pd, + COUNT(*) FILTER (WHERE ol.is_sold) AS units_sold, + COUNT(*) FILTER (WHERE NOT ol.is_sold) AS units_available, + COUNT(*) FILTER ( + WHERE ol.price_per_m2_rub IS NOT NULL + ) AS lots_with_price + FROM objective_complex_mapping cm + JOIN objective_lots ol ON ol.project_name = cm.objective_complex_name + GROUP BY cm.domrf_obj_id ) - SELECT obj_id, - comm_name, - dev_name, - obj_class, - flat_count, - district_name, - site_status, - ready_dt, + SELECT o.obj_id, + o.comm_name, + o.dev_name, + o.obj_class, + o.flat_count, + o.district_name, + o.site_status, + o.ready_dt, + p.avg_price_per_m2_rub, + p.avg_area_pd, + p.units_sold, + p.units_available, + p.lots_with_price, ST_Distance( ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography ) AS distance_m FROM latest_obj o + LEFT JOIN obj_pricing p ON p.domrf_obj_id = o.obj_id WHERE ST_DWithin( ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography, 3000 ) - ORDER BY CASE site_status WHEN 'Строящиеся' THEN 0 ELSE 1 END, + ORDER BY CASE o.site_status WHEN 'Строящиеся' THEN 0 ELSE 1 END, distance_m ASC LIMIT 20 """), @@ -2146,7 +2172,7 @@ def analyze_parcel( "competitors": [dict(c) for c in competitor_rows], # D4 (#36): 24-month pipeline competition "pipeline_24mo": pipeline_24mo, - # D2 (#34): velocity-score из domrf_kn_sale_graph + # D2 (#34): velocity-score из objective_corpus_room_month (OBJ-3 migrated) "velocity": velocity_data, "noise": { "score": round(noise_score, 2), diff --git a/backend/tests/test_velocity.py b/backend/tests/test_velocity.py index ed71d960..aa25fdbb 100644 --- a/backend/tests/test_velocity.py +++ b/backend/tests/test_velocity.py @@ -2,8 +2,9 @@ Mock-based — не требуют живой БД. Источник данных — objective_corpus_room_month (мигрировано с domrf_kn_sale_graph). -Mock shape совместим: sales query возвращает те же aliases (obj_id, total_sqm, -months_with_data, period_start, period_end) через GROUP BY domrf_obj_id. +Mock shape совместим: sales query возвращает aliases (obj_id, total_sqm, +months_with_data, period_start, period_end, has_mapping) через LEFT JOIN +all_competitors + mapped (OBJ-2: ALL competitors included, unmapped has_mapping=False). Третий вызов execute — bucket_rows (obj_id, room_bucket, units_sold, sqm_sold). """ @@ -46,6 +47,7 @@ def _sales_row( months: int, start: str = "2024-11-01", end: str = "2025-04-01", + has_mapping: bool = True, ) -> MagicMock: r = MagicMock() start_d = datetime.date.fromisoformat(start) @@ -56,6 +58,9 @@ def _sales_row( "months_with_data": months, "period_start": start_d, "period_end": end_d, + # OBJ-2: LEFT JOIN all_competitors — все конкуренты включены, + # has_mapping=True если есть маппинг в objective_complex_mapping. + "has_mapping": has_mapping, }[k] return r @@ -199,9 +204,14 @@ def test_score_capped_at_1(): def test_score_zero_when_no_sales_sqm(): - """total_sqm=0 → None (нет данных, не score=0).""" + """total_sqm=0 → VelocityResult с velocity_data_available=False, score=0. + + OBJ-2: функция больше не возвращает None при нулевых продажах — + возвращает «пустое» состояние (velocity_data_available=False, source='none'). + Rosreestr-fallback пропускается т.к. cad_quarter не передан. + """ comp_rows = [_comp_row(1)] - # total_sqm=0 — нет продаж → должен вернуть None + # total_sqm=0 — нет продаж → velocity_data_available=False sales_rows = [_sales_row(1, total_sqm=0.0, months=5)] db = _make_db(comp_rows=comp_rows, sales_rows=sales_rows) @@ -212,7 +222,10 @@ def test_score_zero_when_no_sales_sqm(): ): result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT) - assert result is None + assert result is not None + assert result.velocity_data_available is False + assert result.velocity_score == pytest.approx(0.0) + assert result.velocity_source == "none" def test_as_dict_structure(): @@ -228,6 +241,8 @@ def test_as_dict_structure(): period_end="2025-02", sample_competitors=[], by_room_bucket={"1": {"units": 10, "sqm": 450.0, "complexes_count": 2}}, + velocity_data_available=True, + velocity_source="objective", ) d = vr.as_dict() assert "competitors_count" in d @@ -239,6 +254,9 @@ def test_as_dict_structure(): assert d["velocity_score"] == pytest.approx(0.333, abs=1e-3) assert "by_room_bucket" in d assert d["by_room_bucket"]["1"]["units"] == 10 + # OBJ-2: новые поля velocity_data_available и velocity_source + assert d["velocity_data_available"] is True + assert d["velocity_source"] == "objective" def test_sample_competitors_top5(): -- 2.45.3