diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 36b1f7d9..fe848ea8 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -72,6 +72,7 @@ from app.services.site_finder.custom_pois import ( from app.services.site_finder.developer_attribution import get_developer_attribution from app.services.site_finder.gate_verdict import compute_gate_verdict from app.services.site_finder.ird_analyze import build_ird_analyze_block +from app.services.site_finder.market_metrics import compute_offer_price_trend from app.services.site_finder.osrm_client_local import ( OsrmLocalUnavailableError, get_road_distances_m, @@ -2786,6 +2787,46 @@ def analyze_parcel( # Порог устаревания источника: latest deal старше ~3 мес (90 дней) от сегодня. stale_cutoff = _dt.date.today() - _dt.timedelta(days=90) + # #2178: ВТОРОЙ источник — динамика цен ПРЕДЛОЖЕНИЯ из objective_lots_history + # (ЖК в том же радиусе 3 км). Живёт, когда rosreestr встал (сделки stale). + # Считаем всегда; ниже решаем, основной он или дополнительный к сделкам. + # Своя savepoint — offer-запрос не должен ронять уже посчитанный сделки-тренд. + offer_block: dict[str, Any] | None = None + try: + with db.begin_nested(): + # Предохранитель: на плотном центре ЕКБ холодный прогон доходит + # до ~5.5 с (EXPLAIN 2026-07-02) — эндпоинт синхронный, дольше + # 8 с ждать нельзя. Таймаут → QueryCanceled → откат savepoint → + # offer_block=None (прежнее поведение). После запроса возвращаем + # серверный дефолт (0, проверено на проде), т.к. RELEASE SAVEPOINT + # сохранил бы SET LOCAL до конца внешней транзакции. + db.execute(text("SET LOCAL statement_timeout = '8000'")) + offer_trend = compute_offer_price_trend( + db, center_lat=centroid_lat, center_lon=centroid_lon + ) + db.execute(text("SET LOCAL statement_timeout = 0")) + if offer_trend.has_data: + _offer_delta = offer_trend.delta_pct + if _offer_delta is None: + _offer_label = "Динамика цен предложения (Объектив)" + elif _offer_delta > 8: + _offer_label = "Цены предложения заметно растут" + elif _offer_delta > 0: + _offer_label = "Цены предложения умеренно растут" + elif _offer_delta > -5: + _offer_label = "Цены предложения стабильны" + else: + _offer_label = "Цены предложения снижаются" + offer_block = { + "source": "Объектив, цены предложения", + "label": _offer_label, + "radius_km": 3, + **offer_trend.as_dict(), + } + except Exception as e: + logger.warning("offer_price_trend query failed for %s: %s", cad_num, e) + offer_block = None + if trend_row and trend_row["recent_avg"] and trend_row["prior_avg"]: # Happy path — есть данные в обоих окнах. recent_p = float(trend_row["recent_avg"]) @@ -2810,8 +2851,39 @@ def analyze_parcel( "label": perspective_label, "radius_km": 3, } + # #2178: Росреестр свежий → показываем ОБА блока (сделки + предложение). + if offer_block is not None: + market_trend["offer_trend"] = offer_block + elif offer_block is not None: + # #2178: сделки устарели/отсутствуют, НО есть живая динамика предложения — + # она становится ОСНОВНЫМ содержимым карточки (status='offer_only'). + # as_of_date отдаём как дату последнего снапшота предложения, чтобы фронт + # честно показал «по состоянию на …». Сохраняем максимум контекста сделок. + _offer_as_of = offer_block["points"][-1]["date"] if offer_block["points"] else None + _deals_stale = ( + recent_n_val == 0 and max_period is not None and max_period < stale_cutoff + ) + # Разные причины отсутствия сделок → честная микрокопия: устаревание + # источника vs просто нет сделок рядом (источник свеж). Обе показывают + # предложение как основной контент. + _offer_only_label = ( + "Сделки устарели — показываем динамику цен предложения" + if _deals_stale + else "Нет сделок рядом — показываем динамику цен предложения" + ) + market_trend = { + "status": "offer_only", + "label": _offer_only_label, + "deals_stale": _deals_stale, + "recent_deals_count": recent_n_val, + "deals_as_of_date": (max_period.isoformat() if max_period is not None else None), + "as_of_date": _offer_as_of, + "offer_trend": offer_block, + } elif recent_n_val == 0 and max_period is not None and max_period < stale_cutoff: # Recent-окно пусто И latest deal старше 3 мес → источник устарел (не «нет рядом»). + # #2178: сюда попадаем ТОЛЬКО когда и offer-данных нет (offer_block is None) — + # прежний source_stale остаётся честным «нечего показать». market_trend = { "status": "source_stale", "as_of_date": max_period.isoformat(), diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py index 18c15083..7912880c 100644 --- a/backend/app/schemas/parcel.py +++ b/backend/app/schemas/parcel.py @@ -753,6 +753,9 @@ class AnalyzeResponse(BaseModel): market_data_coverage_pct: float | None = None pipeline_24mo: dict[str, Any] | None = None velocity: dict[str, Any] | None = None + # market_trend: {status: ok|offer_only|source_stale|no_deals, ...}. #2178 добавил + # вложенный offer_trend (динамика цен предложения из objective_lots_history) и + # status='offer_only'. Схема loose (dict) — новые ключи проходят без правки модели. market_trend: dict[str, Any] | None = None market_price: dict[str, Any] | None = None diff --git a/backend/app/services/site_finder/market_metrics.py b/backend/app/services/site_finder/market_metrics.py index aba9550f..20f32a8f 100644 --- a/backend/app/services/site_finder/market_metrics.py +++ b/backend/app/services/site_finder/market_metrics.py @@ -716,3 +716,218 @@ def _price_sensitivity( ) return None, None return float(elast["elasticity"]), str(elast["source"]) + + +# ────────────────────────────────────────────────────────────────────────────── +# Offer-price trend (#2178) — динамика цен ПРЕДЛОЖЕНИЯ из objective_lots_history. +# +# Мотивация: §4.3 «Тренд рынка» строится на rosreestr_deals, чей поток встал +# (max дата 2026-01-01) → recent-окно пусто → status='source_stale' и пустая +# карточка. objective_lots_history даёт weekly-снапшоты цен предложения активных +# лотов — это ВТОРОЙ источник, живой (снимки до 2026-06-30), которым мы заполняем +# карточку, когда сделки устарели. +# +# СВЯЗКА С ЖК РАДИУСА — тот же путь, что best_layouts/competitors (маппинг, НЕ +# ненадёжный complex_id-FK, который джойнит лишь ~28% и мультигородской, см. +# 131_district_vocab_map.sql): +# radius (ST_DWithin по domrf_kn_objects) +# → domrf_obj_id +# → objective_complex_mapping.domrf_obj_id → objective_complex_name +# → objective_lots.project_name → objective_lot_id-набор +# → objective_lots_history (per-snapshot цены этих лотов). +# objective_lots_history НЕ содержит project_name — поэтому набор objective_lot_id +# фиксируем из objective_lots (текущее состояние), а историю берём по нему. +# +# ДЕДУП задвоенного снапшота (КРИТИЧНО, #2178): 2026-06-22 был просвипан дважды +# (~621k строк = два свипа в один день). На (objective_lot_id, snapshot_date) в +# истории стоит UNIQUE-констрейнт → физически дубля быть не должно, НО на проде +# он есть (два ETL-пути до починки триггера). Безусловно дедупим до одной строки +# на (objective_lot_id, snapshot_date): DISTINCT ON (...) ORDER BY fetched_at DESC +# — берём последний свип дня. Медиану считаем УЖЕ по дедуплицированному набору, +# иначе задвоенный день весит ×2 и смещает медиану. +# +# ФИЛЬТР «в продаже» (честный, по реальным status в objective_lots_history — +# "резерв"|"свободна"|"продан"|"бронь", см. 68_schema_objective.sql): лот активен +# = is_sold IS NOT TRUE И LOWER(status) не равен 'продан'. Бронь/резерв считаем +# «в продаже» (цена ещё котируется рынку). contract_date-признак здесь не +# используем: в истории он часто NULL у активных лотов, а нам важна котировка. +# +# ФИЛЬТР ВМЕНЯЕМОСТИ ЦЕНЫ: price_per_m2_rub BETWEEN 10000 AND 1000000 — отсекает +# ETL-мусор (0 / гросс-суммы, ошибочно попавшие в per-m2). Границы шире, чем у +# сделок-тренда (30k–500k), т.к. предложение включает премиум/апартаменты. +_OFFER_TREND_SQL = text( + """ + WITH radius_obj AS ( + SELECT DISTINCT ON (o.obj_id) o.obj_id + FROM domrf_kn_objects o + WHERE o.latitude IS NOT NULL AND o.longitude IS NOT NULL + AND ST_DWithin( + ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, + ST_SetSRID( + ST_MakePoint( + CAST(:center_lon AS float), CAST(:center_lat AS float) + ), + 4326 + )::geography, + CAST(:radius_m AS float) + ) + ORDER BY o.obj_id, o.snapshot_date DESC NULLS LAST + ), + radius_projects AS ( + SELECT DISTINCT cm.objective_complex_name AS project_name + FROM objective_complex_mapping cm + WHERE cm.domrf_obj_id IN (SELECT obj_id FROM radius_obj) + ), + radius_lots AS ( + SELECT DISTINCT ol.objective_lot_id + FROM objective_lots ol + JOIN radius_projects rp ON rp.project_name = ol.project_name + WHERE ol.premise_kind = CAST(:premise_kind AS text) + ), + hist AS ( + -- Дедуп задвоенного снапшота: одна строка на (лот, день), последний свип. + SELECT DISTINCT ON (h.objective_lot_id, h.snapshot_date) + h.snapshot_date, + h.price_per_m2_rub + FROM objective_lots_history h + JOIN radius_lots rl ON rl.objective_lot_id = h.objective_lot_id + WHERE h.price_per_m2_rub BETWEEN CAST(:price_min AS numeric) + AND CAST(:price_max AS numeric) + AND (h.is_sold IS NOT TRUE) + AND LOWER(COALESCE(h.status, '')) <> 'продан' + ORDER BY h.objective_lot_id, h.snapshot_date, h.fetched_at DESC + ) + SELECT + snapshot_date AS snapshot_date, + PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price_per_m2_rub) AS median_price_m2, + COUNT(*) AS lots_count, + (SELECT COUNT(*) FROM radius_projects) AS complexes_covered + FROM hist + GROUP BY snapshot_date + ORDER BY snapshot_date + """ +) + +# Границы вменяемости цены предложения (руб/м²). Шире, чем сделки-тренд (30k–500k): +# предложение включает премиум/апартаменты; отсекаем только явный ETL-мусор. +_OFFER_PRICE_MIN: int = 10000 +_OFFER_PRICE_MAX: int = 1000000 + +# Радиус ЖК-подбора — зеркало market_trend-хендлера parcels.py (3 км от центроида). +_OFFER_TREND_RADIUS_M: int = 3000 + + +@dataclass(frozen=True) +class OfferPriceTrend: + """Динамика медианной цены предложения (руб/м²) по снапшотам Объектива (#2178). + + Источник — objective_lots_history (weekly-снапшоты активных лотов ЖК в радиусе). + Всегда конструируется через ``from_points``; пустой набор точек → None-поля, + ``points=[]`` (никогда не бросает на отсутствии данных). + """ + + points: list[dict[str, Any]] # [{date: 'YYYY-MM-DD', median_price_m2, lots_count}] + delta_pct: float | None # (последняя медиана − первая) / первая × 100 + lots_latest: int | None # lots_count последнего снапшота + complexes_covered: int # сколько ЖК (project_name) попало в радиус-выборку + + def as_dict(self) -> dict[str, Any]: + return { + "points": self.points, + "delta_pct": _round_or_none(self.delta_pct, 1), + "lots_latest": self.lots_latest, + "complexes_covered": self.complexes_covered, + } + + @property + def has_data(self) -> bool: + """True если есть ≥1 снапшот с медианой — тогда карточку можно показать.""" + return len(self.points) > 0 + + +def compute_offer_price_trend( + db: Session, + *, + center_lat: float, + center_lon: float, + radius_m: int = _OFFER_TREND_RADIUS_M, + premise_kind: str = "квартира", +) -> OfferPriceTrend: + """Медианная цена предложения по снапшотам Объектива для ЖК в радиусе (#2178). + + ЖК определяются тем же путём, что market_trend-хендлер / best_layouts: ЖК в + радиусе ``radius_m`` от (``center_lat``, ``center_lon``) по domrf_kn_objects → + objective_complex_mapping → objective_lots. По их лотам считается per-snapshot + медиана price_per_m2_rub активных лотов (дедуп задвоенного дня, фильтр + вменяемости цены). + + Возвращает OfferPriceTrend ВСЕГДА (даже на пустых данных: points=[], поля None, + complexes_covered=0). Никогда не бросает на отсутствии данных — на ошибке SQL + логирует и возвращает пустой результат (graceful, зеркало _query_stock). + """ + params: dict[str, Any] = { + "center_lat": float(center_lat), + "center_lon": float(center_lon), + "radius_m": float(radius_m), + "premise_kind": premise_kind, + "price_min": _OFFER_PRICE_MIN, + "price_max": _OFFER_PRICE_MAX, + } + try: + rows = db.execute(_OFFER_TREND_SQL, params).mappings().all() + except Exception: + logger.exception( + "market_metrics: offer-trend query failed (lat=%s lon=%s radius=%s)", + center_lat, + center_lon, + radius_m, + ) + rows = [] + + points: list[dict[str, Any]] = [] + complexes_covered = 0 + for r in rows: + # complexes_covered — константа по всем строкам (scalar-subquery), берём + # из первой встреченной строки. При пустом hist rows=[] → остаётся 0. + complexes_covered = int(r["complexes_covered"] or 0) + median = r["median_price_m2"] + if median is None: + continue + snap = r["snapshot_date"] + # snapshot_date — DATE; нормализуем (isoformat) вне зависимости от того, + # date или datetime отдал драйвер. + date_str = snap.isoformat() if hasattr(snap, "isoformat") else str(snap) + points.append( + { + "date": date_str, + "median_price_m2": round(float(median)), + "lots_count": int(r["lots_count"] or 0), + } + ) + + delta_pct: float | None = None + lots_latest: int | None = None + if points: + first_median = points[0]["median_price_m2"] + last_median = points[-1]["median_price_m2"] + lots_latest = points[-1]["lots_count"] + if first_median and first_median > 0: + delta_pct = (last_median - first_median) / first_median * 100.0 + + logger.info( + "offer_price_trend: lat=%.5f lon=%.5f radius=%d snapshots=%d " + "lots_latest=%s delta_pct=%s", + center_lat, + center_lon, + radius_m, + len(points), + lots_latest, + _round_or_none(delta_pct, 1), + ) + + return OfferPriceTrend( + points=points, + delta_pct=delta_pct, + lots_latest=lots_latest, + complexes_covered=complexes_covered, + ) diff --git a/backend/tests/services/site_finder/test_market_metrics.py b/backend/tests/services/site_finder/test_market_metrics.py index 626fa451..8dd1928a 100644 --- a/backend/tests/services/site_finder/test_market_metrics.py +++ b/backend/tests/services/site_finder/test_market_metrics.py @@ -13,6 +13,7 @@ psycopg v3 правило проверяется явно: bind-параметр from __future__ import annotations +import datetime as _dt import os from collections.abc import Iterator from unittest.mock import MagicMock, patch @@ -23,6 +24,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost: from app.services.site_finder.market_metrics import ( MarketMetrics, + OfferPriceTrend, _absorption_rate, _confidence, _demand_concentration, @@ -34,6 +36,7 @@ from app.services.site_finder.market_metrics import ( _room_bucket, _sell_through_pct, compute_market_metrics, + compute_offer_price_trend, ) _ELAST = "app.services.site_finder.market_metrics._elasticity_coef" @@ -811,3 +814,199 @@ class TestSalesWindowSource: assert m.n_sold == 41_091 assert m.sell_through_pct is not None assert round(m.sell_through_pct, 1) == round(41_091 / 50_000 * 100, 1) + + +# ── compute_offer_price_trend (#2178): динамика цен предложения ──────────────── +# objective_lots_history-медианы. Единственный execute → .mappings().all(). +# Медиана считается в SQL (PERCENTILE_CONT) — mock подаёт УЖЕ посчитанные строки +# per-snapshot, тест проверяет форму SQL (дедуп/фильтры/CAST) и Python-обвязку +# (delta_pct, lots_latest, complexes_covered, порядок, graceful-on-empty). + + +def _mock_offer_db(rows: list[dict]) -> MagicMock: + """Сессия с одним execute (offer-trend) → .mappings().all() = rows.""" + db = MagicMock() + result = MagicMock() + result.mappings.return_value.all.return_value = rows + db.execute.return_value = result + return db + + +class TestOfferPriceTrendSql: + """Форма SQL: psycopg v3 CAST, дедуп задвоенного снапшота, фильтры, radius-путь.""" + + def test_sql_uses_cast_not_double_colon(self) -> None: + db = _mock_offer_db([]) + compute_offer_price_trend(db, center_lat=56.83, center_lon=60.6) + sql = _executed_sql(db, 0) + assert "CAST(:center_lon AS float)" in sql + assert "CAST(:center_lat AS float)" in sql + assert "CAST(:radius_m AS float)" in sql + assert "CAST(:premise_kind AS text)" in sql + assert "CAST(:price_min AS numeric)" in sql + assert "CAST(:price_max AS numeric)" in sql + for bind in ("center_lon", "center_lat", "radius_m", "price_min", "price_max"): + assert f":{bind}::" not in sql + + def test_sql_dedups_doubled_snapshot(self) -> None: + # Дедуп задвоенного дня: DISTINCT ON (лот, день) ORDER BY fetched_at DESC. + db = _mock_offer_db([]) + compute_offer_price_trend(db, center_lat=56.83, center_lon=60.6) + sql = _executed_sql(db, 0) + assert "DISTINCT ON (h.objective_lot_id, h.snapshot_date)" in sql + assert "h.fetched_at DESC" in sql + + def test_sql_filters_active_and_price_sanity(self) -> None: + db = _mock_offer_db([]) + compute_offer_price_trend(db, center_lat=56.83, center_lon=60.6) + sql = _executed_sql(db, 0) + # «в продаже»: не продан флагом и не 'продан' статусом. + assert "h.is_sold IS NOT TRUE" in sql + assert "LOWER(COALESCE(h.status, '')) <> 'продан'" in sql + # Вменяемость цены — bind-границы (10000..1000000). + assert "price_per_m2_rub BETWEEN CAST(:price_min AS numeric)" in sql + # Радиус-путь через маппинг (НЕ complex_id-FK). + assert "objective_complex_mapping" in sql + assert "domrf_kn_objects" in sql + assert "ST_DWithin" in sql + assert "PERCENTILE_CONT(0.5)" in sql + + def test_params_center_radius_price(self) -> None: + db = _mock_offer_db([]) + compute_offer_price_trend(db, center_lat=56.83, center_lon=60.6) + p = _executed_params(db, 0) + assert p["center_lat"] == 56.83 + assert p["center_lon"] == 60.6 + assert p["radius_m"] == 3000.0 + assert p["premise_kind"] == "квартира" + assert p["price_min"] == 10000 + assert p["price_max"] == 1000000 + + +class TestOfferPriceTrendCompute: + """Python-обвязка: медианы по снапшотам, delta_pct, lots_latest, covered.""" + + def _rows(self) -> list[dict]: + # 3 снапшота, медиана растёт 100k → 110k; covered=5 (константа scalar-subq). + return [ + { + "snapshot_date": _dt.date(2026, 5, 17), + "median_price_m2": 100_000.0, + "lots_count": 120, + "complexes_covered": 5, + }, + { + "snapshot_date": _dt.date(2026, 6, 3), + "median_price_m2": 105_000.0, + "lots_count": 118, + "complexes_covered": 5, + }, + { + "snapshot_date": _dt.date(2026, 6, 30), + "median_price_m2": 110_000.0, + "lots_count": 130, + "complexes_covered": 5, + }, + ] + + def test_points_shape_and_order(self) -> None: + db = _mock_offer_db(self._rows()) + res = compute_offer_price_trend(db, center_lat=56.83, center_lon=60.6) + assert isinstance(res, OfferPriceTrend) + assert [pt["date"] for pt in res.points] == ["2026-05-17", "2026-06-03", "2026-06-30"] + assert [pt["median_price_m2"] for pt in res.points] == [100_000, 105_000, 110_000] + assert [pt["lots_count"] for pt in res.points] == [120, 118, 130] + + def test_delta_pct_last_vs_first(self) -> None: + db = _mock_offer_db(self._rows()) + res = compute_offer_price_trend(db, center_lat=56.83, center_lon=60.6) + # (110000 − 100000) / 100000 × 100 = 10.0 + assert res.delta_pct == 10.0 + + def test_lots_latest_and_covered(self) -> None: + db = _mock_offer_db(self._rows()) + res = compute_offer_price_trend(db, center_lat=56.83, center_lon=60.6) + assert res.lots_latest == 130 + assert res.complexes_covered == 5 + assert res.has_data is True + + def test_single_snapshot_delta_zero(self) -> None: + rows = [ + { + "snapshot_date": _dt.date(2026, 6, 30), + "median_price_m2": 90_000.0, + "lots_count": 40, + "complexes_covered": 2, + } + ] + db = _mock_offer_db(rows) + res = compute_offer_price_trend(db, center_lat=56.83, center_lon=60.6) + # Один снапшот: first==last → delta 0.0 (не None, не crash). + assert res.delta_pct == 0.0 + assert res.lots_latest == 40 + assert len(res.points) == 1 + + def test_null_median_rows_skipped(self) -> None: + # Снапшот, где медиана NULL (все лоты отфильтрованы) — не попадает в points, + # но complexes_covered всё равно считывается (радиус покрыт). + rows = [ + { + "snapshot_date": _dt.date(2026, 5, 17), + "median_price_m2": None, + "lots_count": 0, + "complexes_covered": 3, + }, + { + "snapshot_date": _dt.date(2026, 6, 30), + "median_price_m2": 95_000.0, + "lots_count": 22, + "complexes_covered": 3, + }, + ] + db = _mock_offer_db(rows) + res = compute_offer_price_trend(db, center_lat=56.83, center_lon=60.6) + assert [pt["date"] for pt in res.points] == ["2026-06-30"] + assert res.complexes_covered == 3 + assert res.delta_pct == 0.0 # один валидный снапшот + + def test_empty_complex_set_returns_empty(self) -> None: + # Пустой набор ЖК в радиусе → rows=[] → пустой результат, НЕ crash/None-поля. + db = _mock_offer_db([]) + res = compute_offer_price_trend(db, center_lat=56.83, center_lon=60.6) + assert res.points == [] + assert res.delta_pct is None + assert res.lots_latest is None + assert res.complexes_covered == 0 + assert res.has_data is False + + def test_query_exception_graceful(self) -> None: + db = MagicMock() + db.execute.side_effect = RuntimeError("db down") + res = compute_offer_price_trend(db, center_lat=56.83, center_lon=60.6) + # Ошибка SQL → пустой результат (graceful), не пробрасывается. + assert res.points == [] + assert res.has_data is False + + def test_as_dict_rounds_delta(self) -> None: + rows = [ + { + "snapshot_date": _dt.date(2026, 5, 17), + "median_price_m2": 100_000.0, + "lots_count": 10, + "complexes_covered": 1, + }, + { + "snapshot_date": _dt.date(2026, 6, 30), + "median_price_m2": 103_333.0, + "lots_count": 12, + "complexes_covered": 1, + }, + ] + db = _mock_offer_db(rows) + res = compute_offer_price_trend(db, center_lat=56.83, center_lon=60.6) + d = res.as_dict() + assert set(d.keys()) == {"points", "delta_pct", "lots_latest", "complexes_covered"} + # (103333 − 100000)/100000×100 = 3.333 → round(1) = 3.3 + assert d["delta_pct"] == 3.3 + assert d["lots_latest"] == 12 + assert d["complexes_covered"] == 1 diff --git a/frontend/src/components/site-finder/MarketTrendBlock.tsx b/frontend/src/components/site-finder/MarketTrendBlock.tsx index 733d0718..ad84dfda 100644 --- a/frontend/src/components/site-finder/MarketTrendBlock.tsx +++ b/frontend/src/components/site-finder/MarketTrendBlock.tsx @@ -1,6 +1,11 @@ "use client"; -import type { MarketTrend, MarketTrendStatus } from "@/types/site-finder"; +import type { + MarketTrend, + MarketTrendStatus, + OfferTrend, + OfferTrendPoint, +} from "@/types/site-finder"; interface Props { trend?: MarketTrend | null; @@ -63,6 +68,204 @@ function formatAsOfMonth(asOf: string | undefined): string | null { return `${RU_MONTHS[monthIdx]} ${year}`; } +/** + * #2178: дата YYYY-MM-DD → «DD.MM» или «DD.MM.YYYY» (при withYear). Парсим + * компоненты напрямую — НЕ через new Date() (TZ-сдвиг на полночь UTC). + */ +function formatShortDate(iso: string, withYear: boolean): string | null { + const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso); + if (!m) return null; + const [, year, month, day] = m; + return withYear ? `${day}.${month}.${year}` : `${day}.${month}`; +} + +/** #2178: «DD.MM–DD.MM.YYYY» для диапазона точек тренда (минус — Unicode en-dash). */ +function formatPeriod(points: OfferTrendPoint[]): string | null { + if (points.length === 0) return null; + const first = formatShortDate(points[0].date, false); + const last = formatShortDate(points[points.length - 1].date, true); + if (first == null || last == null) return null; + return `${first}–${last}`; +} + +/** + * #2178: знаковый delta-процент с Unicode-минусом (−, не дефис) и одним знаком. + * `+8.1%` / `−8.1%` / `0.0%`. + */ +function formatSignedPct(delta: number): string { + const rounded = delta.toFixed(1); + if (delta > 0) return `+${rounded}%`; + if (delta < 0) return `−${rounded.replace("-", "")}%`; + return `${rounded}%`; +} + +/** #2178: цвет delta-бейджа предложения — danger при падении, success при росте. */ +function offerDeltaStyle(delta: number): { bg: string; color: string } { + if (delta > 1) return { bg: "#dcfce7", color: "#15803d" }; + if (delta < -1) return { bg: "#fecaca", color: "#b91c1c" }; + return { bg: "#f3f4f6", color: "#374151" }; +} + +/** + * #2178: SVG-спарклайн по точкам медианы предложения. Одиночная линия — цвет + * #374151 (правило ui-tokens: цвет серии только для сравнения). Без зависимостей. + * Каждая точка — с датой и ценой «₽/м²» (native hover-tooltip с единицей). + */ +function OfferSparkline({ points }: { points: OfferTrendPoint[] }) { + const W = 260; + const H = 48; + const PAD = 4; + if (points.length < 2) return null; + + const values = points.map((p) => p.median_price_m2); + const min = Math.min(...values); + const max = Math.max(...values); + const span = max - min || 1; + const stepX = (W - PAD * 2) / (points.length - 1); + + const coords = points.map((p, i) => { + const x = PAD + i * stepX; + // Инвертируем Y: выше цена — выше на графике (меньше y). + const y = PAD + (1 - (p.median_price_m2 - min) / span) * (H - PAD * 2); + return { x, y }; + }); + + const line = coords + .map((c) => `${c.x.toFixed(1)},${c.y.toFixed(1)}`) + .join(" "); + + return ( + <svg + width="100%" + height={H} + viewBox={`0 0 ${W} ${H}`} + preserveAspectRatio="none" + role="img" + aria-label="Динамика медианной цены предложения, ₽/м²" + style={{ display: "block", overflow: "visible" }} + > + <polyline + points={line} + fill="none" + stroke="#374151" + strokeWidth={1.5} + strokeLinejoin="round" + strokeLinecap="round" + vectorEffect="non-scaling-stroke" + /> + {coords.map((c, i) => ( + <circle key={i} cx={c.x} cy={c.y} r={2} fill="#374151"> + <title> + {formatShortDate(points[i].date, true) ?? points[i].date} ·{" "} + {fmtPrice(points[i].median_price_m2)} ₽/м² + + + ))} + + ); +} + +/** + * #2178: полноценная карточка тренда по предложению (Объектив) для status + * "offer_only". Нейтральные токены (--fg-primary / --bg-card), НЕ выглядит + * ошибкой. deals_stale управляет второй строкой-caveat. + */ +function OfferOnlyCard({ + title, + offer, + dealsStale, + dealsAsOfDate, +}: { + title: string; + offer: OfferTrend; + dealsStale: boolean; + dealsAsOfDate: string | null | undefined; +}) { + const latest = offer.points[offer.points.length - 1]; + const delta = offer.delta_pct; + const period = formatPeriod(offer.points); + const deltaStyle = delta != null ? offerDeltaStyle(delta) : null; + + // Источник — полным предложением: «По ценам предложения N ЖК рядом (Объектив, + // M лотов) · DD.MM–DD.MM.YYYY». + const lots = offer.lots_latest; + const sourceParts = [ + `По ценам предложения ${offer.complexes_covered} ЖК рядом`, + lots != null ? `(Объектив, ${fmtPrice(lots)} лотов)` : "(Объектив)", + ]; + const sourceLine = + sourceParts.join(" ") + (period != null ? ` · ${period}` : ""); + + // Caveat — почему показываем предложение вместо сделок. + const dealsMonth = formatAsOfMonth(dealsAsOfDate ?? undefined); + const caveat = dealsStale + ? dealsMonth != null + ? `Сделки Росреестра устарели (до ${dealsMonth}) — показана динамика цен предложения.` + : "Сделки Росреестра устарели — показана динамика цен предложения." + : "Сделок Росреестра рядом нет — показана динамика цен предложения."; + + return ( +
+
+ {title} +
+ + {/* Main value: текущая медиана предложения + delta-бейдж за период */} +
+ + {fmtPrice(latest.median_price_m2)} ₽/м² + + {delta != null && deltaStyle != null && ( + + {formatSignedPct(delta)} + + )} +
+
+ медиана предложения · за период +
+ + {/* Мини-график динамики предложения */} +
+ +
+ + {/* Источник полным предложением + caveat */} +
+
{sourceLine}
+
{caveat}
+
+
+ ); +} + /** Приглушённое caveat-состояние (status-карточка без фейкового тренда). */ function TrendCaveat({ title, message }: { title: string; message: string }) { return ( @@ -100,6 +303,29 @@ export function MarketTrendBlock({ trend }: Props) { trend.radius_km != null ? ` в радиусе ${trend.radius_km} км` : ""; const title = `Тренд рынка${radiusSuffix}`; + // #2178: сделки устарели/отсутствуют, но живёт динамика предложения — + // полноценная карточка тренда по предложению (Объектив), НЕ caveat-тупик. + if (status === "offer_only") { + const offer = trend.offer_trend; + // Defensive: без валидного offer-блока падаем в честный caveat, не крашимся. + if (offer && offer.points.length > 0) { + return ( + + ); + } + return ( + + ); + } + // P2 (#1871): источник сделок устарел — caveat вместо фейкового тренда. if (status === "source_stale") { const asOfMonth = formatAsOfMonth(trend.as_of_date); @@ -234,6 +460,24 @@ export function MarketTrendBlock({ trend }: Props) { )} + {/* #2178: компактная вторая строка динамики предложения под сделко-трендом. */} + {trend.offer_trend && + trend.offer_trend.points.length > 0 && + (() => { + const offer = trend.offer_trend; + const latest = offer.points[offer.points.length - 1]; + const deltaTxt = + offer.delta_pct != null + ? ` · Δ ${formatSignedPct(offer.delta_pct)}` + : ""; + return ( +
+ Предложение: медиана {fmtPrice(latest.median_price_m2)} ₽/м² + {deltaTxt} (Объектив) +
+ ); + })()} + {/* Weak data warning: 30 ≤ n < 100 */} {n < N_STRONG && (
diff --git a/frontend/src/types/site-finder.ts b/frontend/src/types/site-finder.ts index 1ba483c3..06624738 100644 --- a/frontend/src/types/site-finder.ts +++ b/frontend/src/types/site-finder.ts @@ -200,14 +200,38 @@ export interface NeighborsSummary { // P2 (#1871) — staleness/coverage статус источника сделок. // "ok" — тренд посчитан; "source_stale" — источник сделок устарел (тренд не -// рисуем); "no_deals" — нет сделок в радиусе за период. -export type MarketTrendStatus = "ok" | "source_stale" | "no_deals"; +// рисуем); "no_deals" — нет сделок в радиусе за период; +// "offer_only" (#2178) — сделки устарели/отсутствуют, но есть живая динамика +// цен предложения (Объектив) → она становится основным содержимым карточки. +export type MarketTrendStatus = + "ok" | "source_stale" | "no_deals" | "offer_only"; + +// #2178 — одна точка снапшота динамики цен предложения (Объектив). +export interface OfferTrendPoint { + date: string; // YYYY-MM-DD (дата weekly-снапшота) + median_price_m2: number; // медианная цена предложения, ₽/м² + lots_count: number; // сколько активных лотов в снапшоте +} + +// #2178 — динамика цен предложения (Объектив): саб-блок при status "ok" и +// основной контент при status "offer_only". Форма зеркалит OfferPriceTrend +// .as_dict() + метаданные offer_block (source/label/radius_km) из хендлера. +export interface OfferTrend { + source: string; // «Объектив, цены предложения» + label: string; // человекочитаемый вердикт по динамике + radius_km: number; + points: OfferTrendPoint[]; + delta_pct: number | null; // (последняя медиана − первая) / первая × 100 + lots_latest: number | null; // lots_count последнего снапшота + complexes_covered: number; // сколько ЖК попало в радиус-выборку +} export interface MarketTrend { // P2 (#1871): обязателен в новых ответах; optional для backward-compat со // старыми/кэшированными ответами без поля (трактуем отсутствие как "ok"). status?: MarketTrendStatus; // YYYY-MM-DD — дата последней доступной сделки источника (для caption staleness). + // При status "offer_only" — дата последнего снапшота предложения. as_of_date?: string; recent_deals_count: number; // Поля ниже присутствуют только при status === "ok" (старый контракт). @@ -217,6 +241,14 @@ export interface MarketTrend { prior_deals_count?: number; label?: string; radius_km?: number; + // #2178 — динамика цен предложения. При status "ok" — дополнительный саб-блок + // под сделко-трендом; при "offer_only" — основной контент карточки. + offer_trend?: OfferTrend | null; + // #2178 (только status "offer_only"): устарел ли источник сделок (true) или + // сделок просто нет рядом при живом источнике (false). + deals_stale?: boolean; + // #2178 (только status "offer_only"): YYYY-MM-DD последней сделки источника. + deals_as_of_date?: string | null; } // Market pulse — district absorption + competitor counts (/analyze market layer).