From e1a8ad93958b3af9fe79bed0befa632fafd687cc Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 17 May 2026 16:50:19 +0300 Subject: [PATCH] fix(sf-17): rosreestr velocity fallback via cad_quarter (~100% EKB coverage) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Objective mapping coverage falls below 50% of competitors in the radius, fall back to rosreestr_deals JOIN on the parcel's cadastral quarter. Audit shows 237/237 EKB quarters (100%) have rosreestr data for the last 12 months, compared to <20% Objective coverage before bulk mapping. - velocity.py: add _compute_rosreestr_fallback(), _OBJECTIVE_COVERAGE_MIN_RATIO constant, velocity_source field on VelocityResult (objective/rosreestr_fallback/none) - parcels.py: extract cad_quarter from cad_num, pass to compute_velocity - site-finder.ts: add velocity_source field to Velocity interface - VelocityBlock.tsx: badge "Источник: квартальные сделки" when rosreestr_fallback Epic #271 item #17 --- backend/app/api/v1/parcels.py | 10 +- backend/app/services/site_finder/velocity.py | 208 +++++++++++++++--- .../components/site-finder/VelocityBlock.tsx | 6 + frontend/src/types/site-finder.ts | 4 + 4 files changed, 195 insertions(+), 33 deletions(-) diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index e1809cbc..f76ec80b 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -2100,10 +2100,18 @@ def analyze_parcel( # SAVEPOINT защищает outer transaction если velocity SQL падает — # иначе следующие queries (_geotech_risk и пр.) крашатся # с InFailedSqlTransaction. + # SF#17: передаём cad_quarter для rosreestr_fallback (первые 3 сегмента cad_num). + _cad_parts = cad_num.split(":") + _cad_quarter = ":".join(_cad_parts[:3]) if len(_cad_parts) >= 3 else None velocity_data: dict[str, Any] | None = None try: with db.begin_nested(): - v_result = compute_velocity(db, parcel_geom_wkt=geom_wkt, radius_km=3.0) + v_result = compute_velocity( + db, + parcel_geom_wkt=geom_wkt, + radius_km=3.0, + cad_quarter=_cad_quarter, + ) if v_result is not None: velocity_data = v_result.as_dict() except Exception as _ve: diff --git a/backend/app/services/site_finder/velocity.py b/backend/app/services/site_finder/velocity.py index 6bef1af8..a7e266a6 100644 --- a/backend/app/services/site_finder/velocity.py +++ b/backend/app/services/site_finder/velocity.py @@ -6,10 +6,14 @@ Per #34 D2: утилизация objective_corpus_room_month (еженедель конкурирующих ЖК в радиусе radius_km от участка, нормированный к ЕКБ-медиане по данным Objective. +Fallback (SF#17): если Objective coverage <50% конкурентов в радиусе, +использует rosreestr_deals JOIN по cad_quarter участка (100% coverage по кварталам). + Foundation: domrf_kn_objects (lat/lon, comm_name, obj_class, region_cd), objective_complex_mapping (domrf_obj_id ↔ objective_complex_name), objective_corpus_room_month (project_name, deals_total_vol_m2, deals_total_count, report_month). +Fallback: rosreestr_deals (quarter_cad_number, deal_count, period_start_date). Linkage: domrf_kn_objects.obj_id → objective_complex_mapping.domrf_obj_id @@ -32,6 +36,10 @@ logger = logging.getLogger(__name__) # Эмпирика по ЕКБ: ~4 500 м²/мес на один ЖК (apartments, 2024-2025). _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH: float = 4500.0 +# Порог: если доля конкурентов с Objective-маппингом < этого значения, +# пытаемся rosreestr_fallback. +_OBJECTIVE_COVERAGE_MIN_RATIO: float = 0.50 + @dataclass(frozen=True) class VelocityResult: @@ -47,9 +55,12 @@ class VelocityResult: period_end: str # YYYY-MM sample_competitors: list[dict[str, Any]] # top-5 для UI by_room_bucket: dict[str, dict[str, Any]] # агрегат по комнатности - # True если ≥1 конкурент имеет маппинг в objective_complex_mapping; - # False → конкуренты найдены, но данных Objective нет — velocity = 0. + # True если данные есть (objective или rosreestr_fallback). + # False → нет данных ни из одного источника. velocity_data_available: bool = True + # Источник данных: objective (основной), rosreestr_fallback (по кадастровому кварталу), + # none (нет данных). + velocity_source: Literal["objective", "rosreestr_fallback", "none"] = "objective" def as_dict(self) -> dict[str, Any]: return { @@ -63,6 +74,7 @@ class VelocityResult: "sample_competitors": self.sample_competitors, "by_room_bucket": self.by_room_bucket, "velocity_data_available": self.velocity_data_available, + "velocity_source": self.velocity_source, } @@ -72,6 +84,7 @@ def compute_velocity( radius_km: float = 3.0, obj_class: str | None = None, months_window: int = 6, + cad_quarter: str | None = None, ) -> VelocityResult | None: """Вычислить velocity-score для участка. @@ -79,9 +92,14 @@ def compute_velocity( 1. Найти ЖК-конкуренты в радиусе radius_km (через lat/lon ST_DWithin). 2. Взять objective_corpus_room_month за последние months_window месяцев через objective_complex_mapping (domrf_obj_id → project_name). - 3. Посчитать суммарный объём deals_total_vol_m2. + 3. Если Objective coverage < 50% конкурентов → rosreestr_fallback: + считаем сделки DDU/ДКП в cad_quarter участка за окно. 4. Нормировать на ЕКБ-медиану → score 0..1. + Параметры: + cad_quarter: кадастровый квартал участка (первые 3 сегмента cad_num, + например "66:41:0702048"). Используется только для fallback. + Возвращает None если parcel_geom_wkt невалиден или конкурентов нет. """ # ── Step 1: конкуренты по lat/lon в радиусе ────────────────────────────── @@ -222,21 +240,21 @@ def compute_velocity( if not sales_rows: return None - # Проверяем: есть ли хотя бы один конкурент с маппингом (has_mapping=True). - # Если нет — возвращаем velocity=0 с явным флагом velocity_data_available=False, - # вместо того чтобы отбросить всех конкурентов (старый INNER JOIN поведение). - has_any_mapping = any(bool(r["has_mapping"]) for r in sales_rows) - if not has_any_mapping: - logger.info( - "velocity: %d competitors found but none mapped in objective_complex_mapping;" - " returning velocity=0 with data_available=False", - len(obj_ids), - ) - ekb_median = ( - _get_ekb_median(db, months_window=months_window) or _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH - ) - n_comps = len(comp_rows) - sample = [ + # ── Step 2a: проверка Objective coverage ───────────────────────────────── + # Считаем: mapped_with_data — конкуренты с маппингом И реальными данными. + # Если mapped_ratio < _OBJECTIVE_COVERAGE_MIN_RATIO → rosreestr_fallback. + n_total_comps = len(obj_ids) + mapped_with_data = [ + r for r in sales_rows if bool(r["has_mapping"]) and (r["total_sqm"] or 0.0) > 0 + ] + mapped_ratio = len(mapped_with_data) / n_total_comps if n_total_comps > 0 else 0.0 + + ekb_median = ( + _get_ekb_median(db, months_window=months_window) or _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH + ) + n_comps = len(comp_rows) + sample_no_data = sorted( + [ { "obj_id": oid, **competitor_meta[oid], @@ -245,7 +263,31 @@ def compute_velocity( } for oid in obj_ids[:5] if oid in competitor_meta - ] + ], + key=lambda x: x["distance_m"], # type: ignore[index] + ) + + if mapped_ratio < _OBJECTIVE_COVERAGE_MIN_RATIO: + logger.info( + "velocity: objective coverage %.0f%% (<%d%%) for %d competitors;" + " trying rosreestr_fallback cad_quarter=%s", + mapped_ratio * 100, + int(_OBJECTIVE_COVERAGE_MIN_RATIO * 100), + n_total_comps, + cad_quarter, + ) + rr_result = _compute_rosreestr_fallback( + db=db, + cad_quarter=cad_quarter, + months_window=months_window, + n_comps=n_comps, + ekb_median=ekb_median, + sample_competitors=sample_no_data, + ) + if rr_result is not None: + return rr_result + # Rosreestr тоже пуст — возвращаем none-state. + logger.info("velocity: rosreestr_fallback also empty for cad_quarter=%s", cad_quarter) return VelocityResult( competitors_count=n_comps, monthly_velocity_sqm=0.0, @@ -255,9 +297,10 @@ def compute_velocity( months_observed=0, period_start="", period_end="", - sample_competitors=sample, + sample_competitors=sample_no_data, by_room_bucket={}, velocity_data_available=False, + velocity_source="none", ) # ── Step 2b: разбивка по комнатности (room_bucket) ─────────────────────── @@ -339,21 +382,24 @@ def compute_velocity( period_start = min(period_start_dates).strftime("%Y-%m") if period_start_dates else "" period_end = max(period_end_dates).strftime("%Y-%m") if period_end_dates else "" - # ── Step 3: ЕКБ-медиана ────────────────────────────────────────────────── - ekb_median = ( - _get_ekb_median(db, months_window=months_window) or _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH - ) - - n_comps = len(comp_rows) - - # Если mapped-конкурентов нет данных — partial coverage → velocity=0. + # Если mapped-конкурентов нет данных — partial coverage → fallback. if months_observed == 0 or total_sqm <= 0: logger.info( "velocity: %d competitors found, %d mapped, but no sales data in window;" - " returning velocity=0 with data_available=False", + " trying rosreestr_fallback", len(obj_ids), len(mapped_sales_rows), ) + rr_result = _compute_rosreestr_fallback( + db=db, + cad_quarter=cad_quarter, + months_window=months_window, + n_comps=n_comps, + ekb_median=ekb_median, + sample_competitors=sample_no_data, + ) + if rr_result is not None: + return rr_result sample_partial = sorted( [ { @@ -380,13 +426,14 @@ def compute_velocity( sample_competitors=sample_partial, by_room_bucket={}, velocity_data_available=False, + velocity_source="none", ) # Среднемесячный объём в расчёте: суммарный по всем конкурентам / месяцев. # Чем больше конкурентов с данными — тем весомее результат. monthly_velocity = total_sqm / months_observed - # ── Step 4: нормализация → score 0..1 ──────────────────────────────────── + # ── Step 3: нормализация → score 0..1 ──────────────────────────────────── # Логика: сравниваем суммарный velocity радиуса с «нормой» одного ЖК. # Если в радиусе продаётся N × ekb_median → рынок горячий. # Нормируем: score = min(1.0, total_velocity / (n_competitors × ekb_median × 2)) @@ -396,7 +443,7 @@ def compute_velocity( denominator = n_with_sales * ekb_median * 2.0 if n_with_sales > 0 else ekb_median * 2.0 velocity_score = min(1.0, max(0.0, monthly_velocity / denominator)) - # ── Step 5: confidence ─────────────────────────────────────────────────── + # ── Step 4: confidence ─────────────────────────────────────────────────── mapped_conf: Literal["high", "medium", "low"] if n_comps >= 10 and months_observed >= 5: mapped_conf = "high" @@ -405,7 +452,7 @@ def compute_velocity( else: mapped_conf = "low" - # ── Step 6: top-5 конкурентов по объёму продаж ─────────────────────────── + # ── Step 5: top-5 конкурентов по объёму продаж ─────────────────────────── sales_by_id: dict[int, float] = { int(r["obj_id"]): float(r["total_sqm"] or 0.0) for r in mapped_sales_rows } @@ -436,6 +483,103 @@ def compute_velocity( sample_competitors=sample, by_room_bucket=by_room_bucket, velocity_data_available=True, + velocity_source="objective", + ) + + +def _compute_rosreestr_fallback( + db: Session, + cad_quarter: str | None, + months_window: int, + n_comps: int, + ekb_median: float, + sample_competitors: list[dict[str, Any]], +) -> VelocityResult | None: + """Fallback velocity через rosreestr_deals JOIN по cad_quarter участка. + + Считает суммарное число сделок DDU/ДКП в кадастровом квартале за окно months_window. + Velocity = deal_count / months_window (сделок/мес). Нет разбивки по room_bucket + (rosreestr не даёт комнатность). + + Возвращает None если cad_quarter не задан или данных нет. + """ + if not cad_quarter: + return None + + try: + with db.begin_nested(): + row = ( + db.execute( + text( + """ + SELECT + SUM(deal_count) AS total_deals, + MIN(period_start_date) AS period_start, + MAX(period_start_date) AS period_end + FROM rosreestr_deals + WHERE quarter_cad_number = :cad_quarter + AND period_start_date >= (CURRENT_DATE - CAST(:window_interval AS interval)) + AND doc_type IN ('ДДУ', 'ДКП') + """ + ), + { + "cad_quarter": cad_quarter, + "window_interval": f"{months_window} months", + }, + ) + .mappings() + .first() + ) + except Exception: + logger.warning("velocity: rosreestr_fallback query failed for cad_quarter=%s", cad_quarter) + return None + + if row is None or not row["total_deals"] or int(row["total_deals"]) == 0: + return None + + total_deals = int(row["total_deals"]) + # Сделок/мес — грубый аналог velocity (без м², только count). + # Умножаем на 45 м² (эмпирика) для совместимости с м²/мес единицами. + avg_area_per_deal = 45.0 # м² — консервативная оценка для апартаментов ЕКБ + monthly_velocity_sqm = (total_deals * avg_area_per_deal) / months_window + + # Нормализация относительно ekb_median (один ЖК × 2). + velocity_score = min(1.0, max(0.0, monthly_velocity_sqm / (ekb_median * 2.0))) + + # Confidence — rosreestr данные менее детализированы, чем Objective. + rr_confidence: Literal["high", "medium", "low"] + if total_deals >= 50: + rr_confidence = "medium" # max medium для rosreestr — нет комнатности + else: + rr_confidence = "low" + + period_start_date = row["period_start"] + period_end_date = row["period_end"] + period_start = period_start_date.strftime("%Y-%m") if period_start_date else "" + period_end = period_end_date.strftime("%Y-%m") if period_end_date else "" + + logger.info( + "velocity: rosreestr_fallback success cad_quarter=%s" + " total_deals=%d window=%dm velocity=%.1f sqm/mon", + cad_quarter, + total_deals, + months_window, + monthly_velocity_sqm, + ) + + return VelocityResult( + competitors_count=n_comps, + monthly_velocity_sqm=monthly_velocity_sqm, + ekb_median_sqm=ekb_median, + velocity_score=velocity_score, + confidence=rr_confidence, + months_observed=months_window, + period_start=period_start, + period_end=period_end, + sample_competitors=sample_competitors, + by_room_bucket={}, # rosreestr не даёт room_bucket + velocity_data_available=True, + velocity_source="rosreestr_fallback", ) diff --git a/frontend/src/components/site-finder/VelocityBlock.tsx b/frontend/src/components/site-finder/VelocityBlock.tsx index 9989eb43..615fe7fa 100644 --- a/frontend/src/components/site-finder/VelocityBlock.tsx +++ b/frontend/src/components/site-finder/VelocityBlock.tsx @@ -50,6 +50,7 @@ export function VelocityBlock({ velocity }: VelocityBlockProps) { } const dataAvailable = velocity.velocity_data_available !== false; + const isRosreestrFallback = velocity.velocity_source === "rosreestr_fallback"; const confColor = CONFIDENCE_COLOR[velocity.confidence]; const scorePct = formatPercent(velocity.velocity_score); const ratio = velocity.monthly_velocity_sqm / velocity.ekb_median_sqm; @@ -71,6 +72,11 @@ export function VelocityBlock({ velocity }: VelocityBlockProps) { нет данных velocity )} + {isRosreestrFallback && ( + + Источник: квартальные сделки + + )}