diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 058cc5ce..6600a3ae 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -2420,7 +2420,8 @@ def analyze_parcel( COUNT(*) FILTER (WHERE deal_date BETWEEN NOW() - INTERVAL '12 months' AND NOW() - INTERVAL '6 months') - AS prior_n + AS prior_n, + MAX(deal_date) AS max_period FROM district_deals """), {"wkt": geom_wkt}, @@ -2428,7 +2429,26 @@ def analyze_parcel( .mappings() .first() ) + # SF#1871 P2: честность staleness. Источник сделок (rosreestr) может встать — + # тогда recent-окно (0-6 мес) пусто → recent_avg=NULL. Раньше market_trend + # обнулялся в голый None, и фронт/скоринг не могли отличить «нет данных рядом» + # от «источник устарел». Теперь явный status. (ETL-фикс — вне scope.) + _raw_max_period = trend_row["max_period"] if trend_row else None + # period_start_date — DATE; нормализуем на случай timestamp (.date()), чтобы + # сравнение/форматирование не падало на datetime vs date. + max_period: _dt.date | None + if isinstance(_raw_max_period, _dt.datetime): + max_period = _raw_max_period.date() + elif isinstance(_raw_max_period, _dt.date): + max_period = _raw_max_period + else: + max_period = None + recent_n_val = int(trend_row["recent_n"]) if trend_row and trend_row["recent_n"] else 0 + # Порог устаревания источника: latest deal старше ~3 мес (90 дней) от сегодня. + stale_cutoff = _dt.date.today() - _dt.timedelta(days=90) + if trend_row and trend_row["recent_avg"] and trend_row["prior_avg"]: + # Happy path — есть данные в обоих окнах. recent_p = float(trend_row["recent_avg"]) prior_p = float(trend_row["prior_avg"]) # 6-месячное изменение; ×2 даёт годовой эквивалент @@ -2442,6 +2462,7 @@ def analyze_parcel( else: perspective_label = "Падение — риск переоценки" market_trend = { + "status": "ok", "recent_avg_price_per_m2": round(recent_p), "prior_avg_price_per_m2": round(prior_p), "delta_6m_pct": delta_6m_pct, @@ -2450,6 +2471,25 @@ def analyze_parcel( "label": perspective_label, "radius_km": 3, } + elif recent_n_val == 0 and max_period is not None and max_period < stale_cutoff: + # Recent-окно пусто И latest deal старше 3 мес → источник устарел (не «нет рядом»). + market_trend = { + "status": "source_stale", + "as_of_date": max_period.isoformat(), + "label": ( + "Источник сделок устарел " + f"(данные до {max_period.strftime('%m.%Y')})" + ), + "recent_deals_count": 0, + } + else: + # Источник свежий (или вообще нет данных в радиусе), но по объекту 0 сделок + # в recent-окне → честно «нет сделок», а не подмена под устаревание. + market_trend = { + "status": "no_deals", + "label": "Нет сделок в радиусе за период", + "recent_deals_count": 0, + } except Exception as e: logger.warning("market_trend query failed for %s: %s", cad_num, e) market_trend = None diff --git a/backend/app/services/site_finder/velocity.py b/backend/app/services/site_finder/velocity.py index 75b68784..44621e3f 100644 --- a/backend/app/services/site_finder/velocity.py +++ b/backend/app/services/site_finder/velocity.py @@ -36,9 +36,12 @@ from sqlalchemy.orm import Session logger = logging.getLogger(__name__) -# Fallback если в БД нет данных за окно months_window. -# Эмпирика по ЕКБ: ~4 500 м²/мес на один ЖК (apartments, 2024-2025). -_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH: float = 4500.0 +# Fallback если в БД нет данных за окно months_window (DB-error / пустой _get_ekb_median). +# Источник (audit #1871): реальная медиана monthly velocity по ЕКБ — 593-766 м²/мес на +# один ЖК. Берём верхнюю границу 750.0 — консервативно (безопаснее переоценки рынка: +# чем больше знаменатель нормализации, тем ниже velocity_score). Прежнее 4500.0 было +# завышено ~7.6× и латентно занижало score при срабатывании fallback. +_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH: float = 750.0 # Порог: если доля конкурентов с Objective-маппингом < этого значения, # пытаемся rosreestr_fallback. @@ -83,6 +86,15 @@ class VelocityResult: # Источник данных: objective (основной), rosreestr_fallback (по кадастровому кварталу), # none (нет данных). velocity_source: Literal["objective", "rosreestr_fallback", "none"] = "objective" + # SF#1871 P2: честность покрытия / proxy-disclosure (additive — фронт/PDF не ломаются). + # objective_coverage_pct — доля конкурентов с Objective-маппингом И данными (mapped_ratio×100). + # 0.0 на early/none-путях, где покрытие неизвестно. + objective_coverage_pct: float = 0.0 + # proxy_used — True когда объём фабриковался через count×45 (rosreestr_fallback путь), + # а не брался из реального deals_total_vol_m2 Objective. + proxy_used: bool = False + # proxy_sqm_per_deal — допущение «м² на сделку» для fallback (45.0); None на objective/none. + proxy_sqm_per_deal: float | None = None def as_dict(self) -> dict[str, Any]: return { @@ -97,6 +109,9 @@ class VelocityResult: "by_room_bucket": self.by_room_bucket, "velocity_data_available": self.velocity_data_available, "velocity_source": self.velocity_source, + "objective_coverage_pct": self.objective_coverage_pct, + "proxy_used": self.proxy_used, + "proxy_sqm_per_deal": self.proxy_sqm_per_deal, } @@ -271,6 +286,8 @@ def compute_velocity( 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 + # SF#1871 P2: surface objective-покрытие в payload (доля конкурентов с маппингом+данными). + coverage_pct = round(mapped_ratio * 100, 1) ekb_median = ( _get_ekb_median(db, months_window=months_window) or _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH @@ -306,6 +323,7 @@ def compute_velocity( n_comps=n_comps, ekb_median=ekb_median, sample_competitors=sample_no_data, + objective_coverage_pct=coverage_pct, ) if rr_result is not None: return rr_result @@ -324,6 +342,9 @@ def compute_velocity( by_room_bucket={}, velocity_data_available=False, velocity_source="none", + objective_coverage_pct=coverage_pct, + proxy_used=False, + proxy_sqm_per_deal=None, ) # ── Step 2b: разбивка по комнатности (room_bucket) ─────────────────────── @@ -429,6 +450,7 @@ def compute_velocity( n_comps=n_comps, ekb_median=ekb_median, sample_competitors=sample_no_data, + objective_coverage_pct=coverage_pct, ) if rr_result is not None: return rr_result @@ -459,6 +481,9 @@ def compute_velocity( by_room_bucket={}, velocity_data_available=False, velocity_source="none", + objective_coverage_pct=coverage_pct, + proxy_used=False, + proxy_sqm_per_deal=None, ) # Среднемесячный объём = Σ(объём_i / месяцев_i) по активным конкурентам (#1354). @@ -522,6 +547,9 @@ def compute_velocity( by_room_bucket=by_room_bucket, velocity_data_available=True, velocity_source="objective", + objective_coverage_pct=coverage_pct, + proxy_used=False, + proxy_sqm_per_deal=None, ) @@ -532,6 +560,7 @@ def _compute_rosreestr_fallback( n_comps: int, ekb_median: float, sample_competitors: list[dict[str, Any]], + objective_coverage_pct: float = 0.0, ) -> VelocityResult | None: """Fallback velocity через rosreestr_deals JOIN по cad_quarter участка. @@ -618,6 +647,9 @@ def _compute_rosreestr_fallback( by_room_bucket={}, # rosreestr не даёт room_bucket velocity_data_available=True, velocity_source="rosreestr_fallback", + objective_coverage_pct=objective_coverage_pct, + proxy_used=True, # объём = deal_count × avg_area_per_deal (фабрикуется) + proxy_sqm_per_deal=avg_area_per_deal, ) diff --git a/backend/tests/test_velocity.py b/backend/tests/test_velocity.py index c5aff67b..c9334771 100644 --- a/backend/tests/test_velocity.py +++ b/backend/tests/test_velocity.py @@ -18,6 +18,7 @@ import pytest from app.services.site_finder.velocity import ( _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, VelocityResult, + _compute_rosreestr_fallback, compute_velocity, ) @@ -243,6 +244,9 @@ def test_as_dict_structure(): by_room_bucket={"1": {"units": 10, "sqm": 450.0, "complexes_count": 2}}, velocity_data_available=True, velocity_source="objective", + objective_coverage_pct=66.7, + proxy_used=False, + proxy_sqm_per_deal=None, ) d = vr.as_dict() assert "competitors_count" in d @@ -254,9 +258,13 @@ 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 + # OBJ-2: поля velocity_data_available и velocity_source assert d["velocity_data_available"] is True assert d["velocity_source"] == "objective" + # SF#1871 P2: coverage% + proxy disclosure + assert d["objective_coverage_pct"] == pytest.approx(66.7) + assert d["proxy_used"] is False + assert d["proxy_sqm_per_deal"] is None def test_sample_competitors_top5(): @@ -360,6 +368,73 @@ def test_mapping_confidence_gate_in_sales_query(): assert "cm.match_score >= 0.85" in sql +def test_fallback_constant_is_conservative(): + """SF#1871 P2 Fix1: fallback-медиана понижена 4500→750 (audit-verified 593-766).""" + assert _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH == pytest.approx(750.0) + + +def test_objective_path_surfaces_coverage_no_proxy(): + """SF#1871 P2 Fix2: objective-путь → proxy_used=False, coverage% посчитан, proxy_sqm=None.""" + n = 6 + comp_rows = [_comp_row(i) for i in range(1, n + 1)] + sales_rows = [_sales_row(i, total_sqm=4500.0, months=6) for i in range(1, n + 1)] + db = _make_db(comp_rows=comp_rows, sales_rows=sales_rows) + + with patch( + "app.services.site_finder.velocity._get_ekb_median", + return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, + ): + result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT) + + assert result is not None + assert result.velocity_source == "objective" + assert result.proxy_used is False + assert result.proxy_sqm_per_deal is None + # все 6 конкурентов mapped + с данными → coverage 100% + assert result.objective_coverage_pct == pytest.approx(100.0) + d = result.as_dict() + assert d["objective_coverage_pct"] == pytest.approx(100.0) + assert d["proxy_used"] is False + assert d["proxy_sqm_per_deal"] is None + + +def test_rosreestr_fallback_marks_proxy_disclosure(): + """SF#1871 P2 Fix2: rosreestr_fallback → proxy_used=True, proxy_sqm_per_deal=45.0, + coverage% пробрасывается из вызывающего кода.""" + row = MagicMock() + start_d = datetime.date.fromisoformat("2025-01-01") + end_d = datetime.date.fromisoformat("2025-06-01") + row.__getitem__ = lambda self, k: { + "total_deals": 60, + "period_start": start_d, + "period_end": end_d, + }[k] + result_mock = MagicMock() + result_mock.mappings.return_value.first.return_value = row + db = MagicMock() + db.execute.return_value = result_mock + + result = _compute_rosreestr_fallback( + db=db, + cad_quarter="66:41:0702048", + months_window=6, + n_comps=8, + ekb_median=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, + sample_competitors=[], + objective_coverage_pct=25.0, + ) + + assert result is not None + assert result.velocity_source == "rosreestr_fallback" + assert result.proxy_used is True + assert result.proxy_sqm_per_deal == pytest.approx(45.0) + assert result.objective_coverage_pct == pytest.approx(25.0) + d = result.as_dict() + assert d["proxy_used"] is True + assert d["proxy_sqm_per_deal"] == pytest.approx(45.0) + assert d["objective_coverage_pct"] == pytest.approx(25.0) + + def test_sample_competitors_include_by_room_bucket(): """sample_competitors каждого элемента содержит by_room_bucket.""" comp_rows = [_comp_row(1), _comp_row(2)] diff --git a/frontend/src/app/legacy/site-finder/page.tsx b/frontend/src/app/legacy/site-finder/page.tsx index 9519f1c9..dc2c7e57 100644 --- a/frontend/src/app/legacy/site-finder/page.tsx +++ b/frontend/src/app/legacy/site-finder/page.tsx @@ -268,7 +268,7 @@ function SiteFinderContent() { trendPct != null ? `${trendPct > 0 ? "+" : ""}${trendPct.toFixed(1)}%` : "—"; - const trendLabel = data?.market_trend + const trendLabel = data?.market_trend?.label ? `Тренд за 6 мес · ${data.market_trend.label}` : "Тренд рынка"; const trendKpiColor: KpiColor = diff --git a/frontend/src/components/site-finder/MarketTrendBlock.tsx b/frontend/src/components/site-finder/MarketTrendBlock.tsx index c4af54c0..733d0718 100644 --- a/frontend/src/components/site-finder/MarketTrendBlock.tsx +++ b/frontend/src/components/site-finder/MarketTrendBlock.tsx @@ -1,6 +1,6 @@ "use client"; -import type { MarketTrend } from "@/types/site-finder"; +import type { MarketTrend, MarketTrendStatus } from "@/types/site-finder"; interface Props { trend?: MarketTrend | null; @@ -32,59 +32,127 @@ function fmtPrice(v: number): string { return v.toLocaleString("ru-RU", { maximumFractionDigits: 0 }); } +/** + * P2 (#1871): дата as_of_date (YYYY-MM-DD) → «месяц год» для caption о + * устаревшем источнике. Возвращает null если дата отсутствует/некорректна. + */ +const RU_MONTHS = [ + "январь", + "февраль", + "март", + "апрель", + "май", + "июнь", + "июль", + "август", + "сентябрь", + "октябрь", + "ноябрь", + "декабрь", +]; + +function formatAsOfMonth(asOf: string | undefined): string | null { + if (!asOf) return null; + // Парсим YYYY-MM напрямую — НЕ через new Date() (UTC-полночь сдвинула бы + // месяц назад в TZ западнее UTC на границе, напр. 2026-01-01 → «декабрь 2025»). + const m = /^(\d{4})-(\d{2})/.exec(asOf); + if (!m) return null; + const year = Number(m[1]); + const monthIdx = Number(m[2]) - 1; + if (monthIdx < 0 || monthIdx > 11) return null; + return `${RU_MONTHS[monthIdx]} ${year}`; +} + +/** Приглушённое caveat-состояние (status-карточка без фейкового тренда). */ +function TrendCaveat({ title, message }: { title: string; message: string }) { + return ( +