Compare commits
1 commit
main
...
fix/dkp-co
| Author | SHA1 | Date | |
|---|---|---|---|
| aef17d1a6a |
10 changed files with 546 additions and 13 deletions
|
|
@ -153,7 +153,20 @@ class DkpCorridor(BaseModel):
|
||||||
low_ppm2: int # P10 ₽/м² по сделкам (робастный коридор)
|
low_ppm2: int # P10 ₽/м² по сделкам (робастный коридор)
|
||||||
median_ppm2: int # медиана ₽/м²
|
median_ppm2: int # медиана ₽/м²
|
||||||
high_ppm2: int # P90 ₽/м² по сделкам (робастный коридор)
|
high_ppm2: int # P90 ₽/м² по сделкам (робастный коридор)
|
||||||
period_months: int # окно поиска сделок
|
period_months: int # окно ПОИСКА сделок — НЕ возраст данных (см. latest_deal_date)
|
||||||
|
# #2846: max(deal_date) по ОТОБРАННЫМ сделкам (по тем самым, что дали low/
|
||||||
|
# median/high — включая city-wide widen, если сработал), НЕ по всей таблице.
|
||||||
|
# period_months отвечает на «где искали», а не «насколько свежи сделки»: прод
|
||||||
|
# 2026-08-12 — окно 12 мес, свежайшая сделка в БД I кв. 2026, и у 8.7% выборок
|
||||||
|
# даже она отсутствует (свежайшая — IV кв. 2025). Общий max по таблице был бы
|
||||||
|
# враньём в пользу свежести именно для них.
|
||||||
|
# Precision — КВАРТАЛ: Rosreestr open dataset пишет deal_date = первый день
|
||||||
|
# квартала (#1995, _date_precision_for_source). Прод-замер 2026-08-12: 96 974
|
||||||
|
# сделки, 9 различных deal_date, day-of-month = 1 у 100%, месяцы ровно
|
||||||
|
# {01,04,07,10} → метка пачки, а не дата регистрации. Отсюда и форма подписи
|
||||||
|
# на витрине — «по I кв. 2026», не «12.01.2026» и не «223 дня назад».
|
||||||
|
# None = сделки без даты (в проде не встречается) — потребитель молчит.
|
||||||
|
latest_deal_date: date | None = None
|
||||||
|
|
||||||
|
|
||||||
class PriceTrendPoint(BaseModel):
|
class PriceTrendPoint(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -1772,6 +1772,10 @@ def _fetch_dkp_corridor(
|
||||||
series = _load_sber_index_series(db, region=SBER_TIME_ADJUST_REGION)
|
series = _load_sber_index_series(db, region=SBER_TIME_ADJUST_REGION)
|
||||||
adjusted: list[float] = []
|
adjusted: list[float] = []
|
||||||
factors_applied: list[float] = []
|
factors_applied: list[float] = []
|
||||||
|
# #2846: возраст выборки. Копится по ТЕМ ЖЕ строкам, что попали в adjusted —
|
||||||
|
# `continue` выше выкидывает и дату тоже, иначе сделка без ppm² подняла бы
|
||||||
|
# заявленную свежесть коридора, не участвуя в его границах.
|
||||||
|
latest_deal: date | None = None
|
||||||
for r in rows:
|
for r in rows:
|
||||||
ppm2 = r["price_per_m2"]
|
ppm2 = r["price_per_m2"]
|
||||||
if not ppm2:
|
if not ppm2:
|
||||||
|
|
@ -1783,6 +1787,8 @@ def _fetch_dkp_corridor(
|
||||||
factor = _sber_time_factor(series, deal_month)
|
factor = _sber_time_factor(series, deal_month)
|
||||||
adjusted.append(float(ppm2) * factor)
|
adjusted.append(float(ppm2) * factor)
|
||||||
factors_applied.append(factor)
|
factors_applied.append(factor)
|
||||||
|
if dd is not None and (latest_deal is None or dd > latest_deal):
|
||||||
|
latest_deal = dd
|
||||||
ppm2_values = sorted(adjusted)
|
ppm2_values = sorted(adjusted)
|
||||||
|
|
||||||
# #oblast-D widen: a single street in a small non-EKB town can easily have
|
# #oblast-D widen: a single street in a small non-EKB town can easily have
|
||||||
|
|
@ -1835,6 +1841,7 @@ def _fetch_dkp_corridor(
|
||||||
city_rows = []
|
city_rows = []
|
||||||
|
|
||||||
city_adjusted: list[float] = []
|
city_adjusted: list[float] = []
|
||||||
|
city_latest: date | None = None
|
||||||
for r in city_rows:
|
for r in city_rows:
|
||||||
ppm2 = r["price_per_m2"]
|
ppm2 = r["price_per_m2"]
|
||||||
if not ppm2:
|
if not ppm2:
|
||||||
|
|
@ -1845,6 +1852,8 @@ def _fetch_dkp_corridor(
|
||||||
deal_month = date(dd.year, dd.month, 1)
|
deal_month = date(dd.year, dd.month, 1)
|
||||||
factor = _sber_time_factor(series, deal_month)
|
factor = _sber_time_factor(series, deal_month)
|
||||||
city_adjusted.append(float(ppm2) * factor)
|
city_adjusted.append(float(ppm2) * factor)
|
||||||
|
if dd is not None and (city_latest is None or dd > city_latest):
|
||||||
|
city_latest = dd
|
||||||
|
|
||||||
if len(city_adjusted) > len(ppm2_values):
|
if len(city_adjusted) > len(ppm2_values):
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -1855,6 +1864,10 @@ def _fetch_dkp_corridor(
|
||||||
city,
|
city,
|
||||||
)
|
)
|
||||||
ppm2_values = sorted(city_adjusted)
|
ppm2_values = sorted(city_adjusted)
|
||||||
|
# #2846: коридор теперь описывает city-выборку — и возраст обязан
|
||||||
|
# переехать вместе с числами, иначе подпись осталась бы от street-
|
||||||
|
# выборки, которую на витрине уже никто не видит.
|
||||||
|
latest_deal = city_latest
|
||||||
|
|
||||||
if not ppm2_values:
|
if not ppm2_values:
|
||||||
return None
|
return None
|
||||||
|
|
@ -1877,6 +1890,7 @@ def _fetch_dkp_corridor(
|
||||||
"median_ppm2": int(_percentile(ppm2_values, 0.5)),
|
"median_ppm2": int(_percentile(ppm2_values, 0.5)),
|
||||||
"high_ppm2": int(_percentile(ppm2_values, 0.90)),
|
"high_ppm2": int(_percentile(ppm2_values, 0.90)),
|
||||||
"period_months": period_months,
|
"period_months": period_months,
|
||||||
|
"latest_deal_date": latest_deal,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1516,11 +1516,41 @@ def _examples_rows(lots: list[AnalogLot]) -> str:
|
||||||
|
|
||||||
# ── Page 3: Deals ────────────────────────────────────────────────────────────
|
# ── Page 3: Deals ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_ROMAN_QUARTER = ("I", "II", "III", "IV")
|
||||||
|
|
||||||
|
|
||||||
|
def deals_as_of_label(estimate: AggregatedEstimate) -> str | None:
|
||||||
|
"""#2846: «по I кв. 2026» — до какого момента доходят ПОКАЗАННЫЕ сделки.
|
||||||
|
|
||||||
|
Раньше страница печатала «Период сделок: 08.2025 – 08.2026» — окно ПОИСКА,
|
||||||
|
посчитанное как `today - period_months*30 … today`. Правым концом оно обещало
|
||||||
|
сделки сегодняшним днём, тогда как свежайшая пачка Росреестра на проде
|
||||||
|
(замер 2026-08-12) — I кв. 2026. Считаем по estimate.actual_deals, т.е. ровно
|
||||||
|
по тем сделкам, из которых страница строит диапазон и таблицу примеров.
|
||||||
|
|
||||||
|
Гранулярность — квартал: rosreestr пишет deal_date = первый день квартала
|
||||||
|
(#1995, ровно то, что помечает AnalogLot.date_precision == "quarter").
|
||||||
|
Поэтому «223 дня назад» было бы ЛОЖНОЙ точностью в сторону состаривания —
|
||||||
|
сделка из этой пачки могла быть и 31 марта. Источник с day-precision (пока
|
||||||
|
такого нет) подписывается месяцем.
|
||||||
|
|
||||||
|
None — сделок нет либо ни у одной нет даты: подписывать нечего.
|
||||||
|
"""
|
||||||
|
dated = [(d.listing_date, d.date_precision) for d in estimate.actual_deals if d.listing_date]
|
||||||
|
if not dated:
|
||||||
|
return None
|
||||||
|
newest, precision = max(dated, key=lambda p: p[0])
|
||||||
|
if precision == "day":
|
||||||
|
return f"по {newest.strftime('%m.%Y')}"
|
||||||
|
return f"по {_ROMAN_QUARTER[(newest.month - 1) // 3]} кв. {newest.year}"
|
||||||
|
|
||||||
|
|
||||||
def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> str: # type: ignore[no-untyped-def,type-arg]
|
def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> str: # type: ignore[no-untyped-def,type-arg]
|
||||||
n_deals = len(estimate.actual_deals)
|
n_deals = len(estimate.actual_deals)
|
||||||
today = dt.date.today()
|
# #2846: «Период сделок» показывал окно поиска правым концом = сегодня.
|
||||||
period_start = today - dt.timedelta(days=estimate.period_months * 30)
|
# Реальная граница — as-of по показанным сделкам; окна поиска на странице
|
||||||
|
# больше нет (оно ничего не говорило о данных). None → строку не печатаем.
|
||||||
|
deals_as_of = deals_as_of_label(estimate)
|
||||||
|
|
||||||
# Баннер дисконта ссылается на РЕАЛЬНЫЙ рассчитанный дисконт запрос→продажа
|
# Баннер дисконта ссылается на РЕАЛЬНЫЙ рассчитанный дисконт запрос→продажа
|
||||||
# (тот же _discount_pct, что chip «−N%» на обложке), а не хардкод «10–18%»,
|
# (тот же _discount_pct, что chip «−N%» на обложке), а не хардкод «10–18%»,
|
||||||
|
|
@ -1597,9 +1627,9 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
|
||||||
<table style="width:100%;border-collapse:collapse;">
|
<table style="width:100%;border-collapse:collapse;">
|
||||||
<tr><td style="padding:4pt 0;">Количество сделок по аналогичном объектам</td>
|
<tr><td style="padding:4pt 0;">Количество сделок по аналогичном объектам</td>
|
||||||
<td class="bold" style="text-align:right;">{_mono(f"{n_deals} шт.")}</td></tr>
|
<td class="bold" style="text-align:right;">{_mono(f"{n_deals} шт.")}</td></tr>
|
||||||
<tr><td style="padding:4pt 0;">Период сделок</td>
|
{f'''<tr><td style="padding:4pt 0;">Сделки</td>
|
||||||
<td class="bold" style="text-align:right;">
|
<td class="bold" style="text-align:right;">{_mono(deals_as_of)}</td></tr>'''
|
||||||
{_mono(f"{period_start.strftime('%m.%Y')} – {today.strftime('%m.%Y')}")}</td></tr>
|
if deals_as_of else ""}
|
||||||
</table>
|
</table>
|
||||||
<div style="margin-top:14pt;font-size:{_FS_SM};color:{_MUTED};">
|
<div style="margin-top:14pt;font-size:{_FS_SM};color:{_MUTED};">
|
||||||
<span class="bullet-dot" style="margin-right:5pt;"></span>Источники данных</div>
|
<span class="bullet-dot" style="margin-right:5pt;"></span>Источники данных</div>
|
||||||
|
|
|
||||||
208
tradein-mvp/backend/tests/test_dkp_corridor_as_of_2846.py
Normal file
208
tradein-mvp/backend/tests/test_dkp_corridor_as_of_2846.py
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
"""#2846 — возраст выборки ДКП-коридора обязан доезжать до ответа.
|
||||||
|
|
||||||
|
Плитка «ДКП · РОСРЕЕСТР (ФАКТИЧЕСКИЕ СДЕЛКИ)» несла count/low/median/high и
|
||||||
|
period_months — ОКНО ПОИСКА. Возраста самих сделок в схеме не было вовсе, и
|
||||||
|
слово «ФАКТИЧЕСКИЕ» читалось как «недавние».
|
||||||
|
|
||||||
|
Замер прода 2026-08-12 (docker exec tradein-postgres psql -U tradein -d tradein):
|
||||||
|
|
||||||
|
deals source='rosreestr': 96 974 строки, max(deal_date) = 2026-01-01
|
||||||
|
(223 дня назад), 9 различных deal_date, day-of-month = 1 у 100% строк,
|
||||||
|
месяцы ровно {01, 04, 07, 10} → deal_date это метка КВАРТАЛЬНОЙ пачки.
|
||||||
|
|
||||||
|
Реплей выборок 881 реальной оценки за 90 суток (улица+город+rooms+area±15%
|
||||||
|
+окно 12 мес + ppm²-банды, включая city-wide widen):
|
||||||
|
свежайшая I кв. 2026 — 654 (74.2%)
|
||||||
|
коридора нет вовсе — 165 (18.7%)
|
||||||
|
свежайшая IV кв. 2025 — 62 ( 7.0%)
|
||||||
|
→ у 8.7% выборок с коридором свежайшая сделка на КВАРТАЛ старше общего
|
||||||
|
максимума таблицы, т.е. max по всей таблице был бы враньём в их пользу.
|
||||||
|
|
||||||
|
Красный прогон на origin/main: тесты падают на отсутствии latest_deal_date в
|
||||||
|
dict/схеме (AttributeError / KeyError), а не на ожидании, списанном с настройки.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import date
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
from app.schemas.trade_in import AnalogLot, DkpCorridor
|
||||||
|
from app.services.estimator import _fetch_dkp_corridor
|
||||||
|
|
||||||
|
_ADDRESS = "Екатеринбург, ул. Гоголя, 18"
|
||||||
|
|
||||||
|
|
||||||
|
def _deal(ppm2: int | None, deal_date: date | None) -> dict[str, Any]:
|
||||||
|
return {"price_per_m2": ppm2, "deal_date": deal_date}
|
||||||
|
|
||||||
|
|
||||||
|
def _db(street_rows: list[dict[str, Any]], city_rows: list[dict[str, Any]] | None = None) -> Any:
|
||||||
|
"""Session-двойник: 1-й execute — СберИндекс, 2-й — улица, 3-й — city-widen.
|
||||||
|
|
||||||
|
Порядок жёстко следует _fetch_dkp_corridor: сначала street-выборка, затем
|
||||||
|
_load_sber_index_series, затем (опционально) city-wide widen.
|
||||||
|
"""
|
||||||
|
db = MagicMock()
|
||||||
|
calls: list[Any] = []
|
||||||
|
|
||||||
|
def _execute(query: Any, params: dict[str, Any] | None = None) -> MagicMock:
|
||||||
|
result = MagicMock()
|
||||||
|
calls.append(query)
|
||||||
|
sql = str(query)
|
||||||
|
if "sber" in sql.lower():
|
||||||
|
result.mappings.return_value.all.return_value = []
|
||||||
|
elif "deal_city_price_bands" in sql and "d.address ILIKE" in sql:
|
||||||
|
result.mappings.return_value.all.return_value = street_rows
|
||||||
|
elif "deal_city_price_bands" in sql:
|
||||||
|
result.mappings.return_value.all.return_value = city_rows or []
|
||||||
|
else:
|
||||||
|
result.mappings.return_value.all.return_value = []
|
||||||
|
return result
|
||||||
|
|
||||||
|
db.execute.side_effect = _execute
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch(street_rows: list[dict[str, Any]], city_rows: list[dict[str, Any]] | None = None): # type: ignore[no-untyped-def]
|
||||||
|
return _fetch_dkp_corridor(
|
||||||
|
_db(street_rows, city_rows),
|
||||||
|
address=_ADDRESS,
|
||||||
|
rooms=2,
|
||||||
|
area=60.0,
|
||||||
|
city="екатеринбург" if city_rows is None else "нижний тагил",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── КРАСНОЕ ЯДРО: возраст доезжает до схемы ─────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_latest_deal_date_reaches_the_response_schema() -> None:
|
||||||
|
"""Главный красный: DkpCorridor обязан НЕСТИ дату свежайшей сделки.
|
||||||
|
|
||||||
|
На origin/main падает AttributeError — поля в схеме нет, а Pydantic v2
|
||||||
|
молча игнорирует лишний kwarg, так что «оно как-нибудь пролезет» — нет.
|
||||||
|
"""
|
||||||
|
raw = _fetch([_deal(200_000, date(2025, 10, 1)), _deal(210_000, date(2026, 1, 1))])
|
||||||
|
assert raw is not None
|
||||||
|
corridor = DkpCorridor(**raw)
|
||||||
|
assert corridor.latest_deal_date == date(2026, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_age_is_taken_from_the_selected_sample_not_the_table_max() -> None:
|
||||||
|
"""Выборка кончается IV кв. 2025 → подпись обязана назвать IV кв. 2025.
|
||||||
|
|
||||||
|
Это те 7.0% реальных оценок из шапки модуля. Общий максимум таблицы
|
||||||
|
(2026-01-01) для них — чужая, более свежая дата.
|
||||||
|
"""
|
||||||
|
raw = _fetch([_deal(190_000, date(2025, 7, 1)), _deal(195_000, date(2025, 10, 1))])
|
||||||
|
assert raw is not None
|
||||||
|
assert raw["latest_deal_date"] == date(2025, 10, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_deal_without_price_does_not_donate_its_freshness() -> None:
|
||||||
|
"""Строка без ppm² не входит в границы коридора — не входит и в его возраст.
|
||||||
|
|
||||||
|
Иначе самая свежая, но выброшенная сделка омолаживала бы подпись, не
|
||||||
|
участвуя ни в одном из чисел под ней.
|
||||||
|
"""
|
||||||
|
raw = _fetch([_deal(200_000, date(2025, 10, 1)), _deal(None, date(2026, 1, 1))])
|
||||||
|
assert raw is not None
|
||||||
|
assert raw["count"] == 1
|
||||||
|
assert raw["latest_deal_date"] == date(2025, 10, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_city_wide_widen_moves_the_age_together_with_the_numbers() -> None:
|
||||||
|
"""#oblast-D widen: числа переехали на city-выборку — дата обязана тоже.
|
||||||
|
|
||||||
|
Street-выборка тут СВЕЖЕЕ (I кв. 2026) и её дата осталась бы приятнее для
|
||||||
|
глаза; но на экране после widen'а стоят city-числа, которые кончаются
|
||||||
|
III кв. 2025. Подпись описывает то, что показано.
|
||||||
|
"""
|
||||||
|
street = [_deal(200_000, date(2026, 1, 1))]
|
||||||
|
city = [_deal(150_000, date(2025, 4, 1)), _deal(160_000, date(2025, 7, 1))]
|
||||||
|
raw = _fetch(street, city)
|
||||||
|
assert raw is not None
|
||||||
|
assert raw["count"] == 2, "widen должен был сработать (street n=1 < 3)"
|
||||||
|
assert raw["latest_deal_date"] == date(2025, 7, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_deals_means_no_corridor_and_nothing_to_date() -> None:
|
||||||
|
"""count=0 → коридора нет вовсе; новое поле не создаёт повода что-то рисовать."""
|
||||||
|
assert _fetch([]) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_period_months_is_not_a_freshness_claim() -> None:
|
||||||
|
"""Регресс-якорь: окно поиска и возраст данных — РАЗНЫЕ числа.
|
||||||
|
|
||||||
|
Ровно та подмена, из-за которой v2 отказался печатать «за N месяцев»
|
||||||
|
(mappers.ts) — а v1 печатал.
|
||||||
|
"""
|
||||||
|
raw = _fetch([_deal(200_000, date(2025, 10, 1))])
|
||||||
|
assert raw is not None
|
||||||
|
assert raw["period_months"] == 12
|
||||||
|
assert raw["latest_deal_date"] == date(2025, 10, 1)
|
||||||
|
|
||||||
|
|
||||||
|
# ── PDF: то же обещание, та же подпись ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _estimate_with_deals(dates: list[date]) -> Any:
|
||||||
|
est = MagicMock()
|
||||||
|
est.actual_deals = [
|
||||||
|
AnalogLot(
|
||||||
|
address="Екатеринбург, ул. Гоголя",
|
||||||
|
area_m2=60.0,
|
||||||
|
rooms=2,
|
||||||
|
floor=None,
|
||||||
|
total_floors=None,
|
||||||
|
price_rub=12_000_000,
|
||||||
|
price_per_m2=200_000,
|
||||||
|
listing_date=d,
|
||||||
|
days_on_market=None,
|
||||||
|
date_precision="quarter",
|
||||||
|
source="rosreestr",
|
||||||
|
)
|
||||||
|
for d in dates
|
||||||
|
]
|
||||||
|
return est
|
||||||
|
|
||||||
|
|
||||||
|
def test_pdf_deals_page_dates_the_deals_it_shows() -> None:
|
||||||
|
"""PDF §03 «ФАКТИЧЕСКИЕ СДЕЛКИ» — то же обещание, что и плитка.
|
||||||
|
|
||||||
|
До правки страница печатала «Период сделок: 08.2025 – 08.2026», где правый
|
||||||
|
конец = сегодня: окно поиска, выданное за период данных.
|
||||||
|
"""
|
||||||
|
from app.services.exporters.trade_in_pdf import deals_as_of_label
|
||||||
|
|
||||||
|
assert deals_as_of_label(_estimate_with_deals([date(2025, 10, 1), date(2026, 1, 1)])) == (
|
||||||
|
"по I кв. 2026"
|
||||||
|
)
|
||||||
|
assert deals_as_of_label(_estimate_with_deals([])) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_quarter_label_matches_every_deal_date_present_on_prod() -> None:
|
||||||
|
"""Все 9 живых deal_date проекции 2026-08-12 → корректная римская метка.
|
||||||
|
|
||||||
|
Список не выдуман: это ровно `SELECT DISTINCT deal_date FROM deals`.
|
||||||
|
"""
|
||||||
|
from app.services.exporters.trade_in_pdf import deals_as_of_label
|
||||||
|
|
||||||
|
expected = {
|
||||||
|
date(2026, 1, 1): "по I кв. 2026",
|
||||||
|
date(2025, 10, 1): "по IV кв. 2025",
|
||||||
|
date(2025, 7, 1): "по III кв. 2025",
|
||||||
|
date(2025, 4, 1): "по II кв. 2025",
|
||||||
|
date(2025, 1, 1): "по I кв. 2025",
|
||||||
|
date(2024, 10, 1): "по IV кв. 2024",
|
||||||
|
date(2024, 7, 1): "по III кв. 2024",
|
||||||
|
date(2024, 4, 1): "по II кв. 2024",
|
||||||
|
date(2024, 1, 1): "по I кв. 2024",
|
||||||
|
}
|
||||||
|
for d, label in expected.items():
|
||||||
|
assert deals_as_of_label(_estimate_with_deals([d])) == label
|
||||||
|
|
@ -175,6 +175,10 @@ export const FIXTURE_ESTIMATE: AggregatedEstimate = {
|
||||||
median_ppm2: 162_000,
|
median_ppm2: 162_000,
|
||||||
high_ppm2: 178_000,
|
high_ppm2: 178_000,
|
||||||
period_months: 24,
|
period_months: 24,
|
||||||
|
// #2846: свежайшая сделка ВЫБОРКИ. Совпадает с прод-максимумом на 2026-08-12
|
||||||
|
// (deals.max(deal_date) = 2026-01-01) — оффлайн-превью должно показывать
|
||||||
|
// ту же подпись «по I кв. 2026», что и живая страница.
|
||||||
|
latest_deal_date: "2026-01-01",
|
||||||
},
|
},
|
||||||
price_trend: [
|
price_trend: [
|
||||||
{ month: "2025-11", ppm2: 168_000 },
|
{ month: "2025-11", ppm2: 168_000 },
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import type { AggregatedEstimate, TradeInEstimateInput, HouseType, RepairState,
|
||||||
import { asHouseType, asRepairState } from "@/types/trade-in";
|
import { asHouseType, asRepairState } from "@/types/trade-in";
|
||||||
import { useActiveBrandSlug, useBrand } from "@/lib/useBrand";
|
import { useActiveBrandSlug, useBrand } from "@/lib/useBrand";
|
||||||
import { sourceDot, sourceLabel } from "@/lib/source-registry";
|
import { sourceDot, sourceLabel } from "@/lib/source-registry";
|
||||||
|
import { dealsAsOfLabel } from "@/lib/rosreestr";
|
||||||
import { HeroTransparency } from "./HeroTransparency";
|
import { HeroTransparency } from "./HeroTransparency";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -175,6 +176,11 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
|
||||||
const dkpArea = estimate.area_m2 ?? input.area_m2 ?? 0;
|
const dkpArea = estimate.area_m2 ?? input.area_m2 ?? 0;
|
||||||
const showDkp =
|
const showDkp =
|
||||||
!!dkp && dkp.count > 0 && dkpArea > 0 && dkp.low_ppm2 > 0 && dkp.high_ppm2 > 0;
|
!!dkp && dkp.count > 0 && dkpArea > 0 && dkp.low_ppm2 > 0 && dkp.high_ppm2 > 0;
|
||||||
|
// #2846: возраст ОТОБРАННЫХ сделок. Раньше здесь стояло «за {period_months}
|
||||||
|
// мес» — окно ПОИСКА, выданное за период данных: «12 мес» читалось как «сделки
|
||||||
|
// по сегодня», при том что свежайшая пачка Росреестра на проде — I кв. 2026.
|
||||||
|
// null (оценка старше деплоя бэкенда, поля нет) → хвост просто исчезает.
|
||||||
|
const dkpAsOf = dealsAsOfLabel(dkp?.latest_deal_date, "quarter");
|
||||||
|
|
||||||
// Progressive enrichment state
|
// Progressive enrichment state
|
||||||
const needsHouseType = estimate.house_type === null;
|
const needsHouseType = estimate.house_type === null;
|
||||||
|
|
@ -547,6 +553,7 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
|
||||||
</span>
|
</span>
|
||||||
<span className="bench-chip__sub mono">
|
<span className="bench-chip__sub mono">
|
||||||
{dkp.median_ppm2.toLocaleString("ru-RU")} ₽/м² · {dkp.count} сд.
|
{dkp.median_ppm2.toLocaleString("ru-RU")} ₽/м² · {dkp.count} сд.
|
||||||
|
{dkpAsOf ? ` · ${dkpAsOf}` : ""}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -601,8 +608,8 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
|
||||||
{formatMln(dkp.low_ppm2 * dkpArea)} – {formatMln(dkp.high_ppm2 * dkpArea)} ₽
|
{formatMln(dkp.low_ppm2 * dkpArea)} – {formatMln(dkp.high_ppm2 * dkpArea)} ₽
|
||||||
</div>
|
</div>
|
||||||
<div className="price-figure-perm2 mono">
|
<div className="price-figure-perm2 mono">
|
||||||
{dkp.median_ppm2.toLocaleString("ru-RU")} ₽/м² · {dkp.count} <abbr title="Договор купли-продажи (реальные сделки Росреестра)">ДКП</abbr> за{" "}
|
{dkp.median_ppm2.toLocaleString("ru-RU")} ₽/м² · {dkp.count} <abbr title="Договор купли-продажи (реальные сделки Росреестра)">ДКП</abbr>
|
||||||
{dkp.period_months} мес
|
{dkpAsOf ? ` · ${dkpAsOf}` : ""}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,175 @@
|
||||||
|
// #2846 — плитка «ДКП · РОСРЕЕСТР (ФАКТИЧЕСКИЕ СДЕЛКИ)» обязана назвать возраст.
|
||||||
|
//
|
||||||
|
// До правки автор mappers.ts честно писал: «No "ЗА N МЕСЯЦЕВ" — we have no honest
|
||||||
|
// period to show», потому что единственным кандидатом был period_months — ОКНО
|
||||||
|
// ПОИСКА. Он был прав про поле и неправ про молчание: слово «ФАКТИЧЕСКИЕ»
|
||||||
|
// читается как «недавние», а свежайшая пачка Росреестра на проде (замер
|
||||||
|
// 2026-08-12) — I кв. 2026, 223 дня от её начала.
|
||||||
|
//
|
||||||
|
// Плитку кормят ТРИ РАЗНЫЕ выборки (street-deals → dkp_corridor → actual_deals),
|
||||||
|
// поэтому здесь проверяется каждая: подпись обязана описывать тот набор, чьи
|
||||||
|
// числа сейчас на экране, а не тот, который удобнее.
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { mapResultPanel } from "../mappers";
|
||||||
|
import { dealsAsOfLabel } from "@/lib/rosreestr";
|
||||||
|
import { FIXTURE_ESTIMATE } from "@/app/ui-preview/estimate/fixture";
|
||||||
|
import type {
|
||||||
|
AggregatedEstimate,
|
||||||
|
AnalogLot,
|
||||||
|
StreetDealsResponse,
|
||||||
|
} from "@/types/trade-in";
|
||||||
|
|
||||||
|
/** ppm-строка плитки ДКП — там же, где «₽/м² · N сделок». */
|
||||||
|
function dkpPpm(
|
||||||
|
e: AggregatedEstimate,
|
||||||
|
sd: StreetDealsResponse | null = null,
|
||||||
|
): string {
|
||||||
|
const card = mapResultPanel(e, sd).cards.find((c) =>
|
||||||
|
c.title.join(" ").includes("ДКП"),
|
||||||
|
);
|
||||||
|
if (!card) throw new Error("плитка ДКП пропала из панели результата");
|
||||||
|
return card.ppm;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deal(over: Partial<AnalogLot>): AnalogLot {
|
||||||
|
return {
|
||||||
|
address: "ул. Гоголя, 18",
|
||||||
|
area_m2: 60,
|
||||||
|
rooms: 2,
|
||||||
|
floor: 4,
|
||||||
|
total_floors: 9,
|
||||||
|
price_rub: 12_000_000,
|
||||||
|
price_per_m2: 200_000,
|
||||||
|
listing_date: "2026-01-01",
|
||||||
|
days_on_market: null,
|
||||||
|
photo_url: null,
|
||||||
|
source: "rosreestr",
|
||||||
|
source_url: null,
|
||||||
|
distance_m: null,
|
||||||
|
tier: "T1_per_street",
|
||||||
|
date_precision: "quarter",
|
||||||
|
lat: null,
|
||||||
|
lon: null,
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function streetDeals(deals: AnalogLot[]): StreetDealsResponse {
|
||||||
|
return {
|
||||||
|
street: "Гоголя",
|
||||||
|
period_from: "2025-08-12",
|
||||||
|
period_to: "2026-08-12",
|
||||||
|
count: deals.length,
|
||||||
|
median_price_rub: 12_000_000,
|
||||||
|
median_price_per_m2: 200_000,
|
||||||
|
range_low_rub: 10_000_000,
|
||||||
|
range_high_rub: 14_000_000,
|
||||||
|
deals,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ветка 2: только коридор, без street-deals и без actual_deals.
|
||||||
|
const CORRIDOR_ONLY: AggregatedEstimate = {
|
||||||
|
...FIXTURE_ESTIMATE,
|
||||||
|
actual_deals: [],
|
||||||
|
area_m2: 60,
|
||||||
|
dkp_corridor: {
|
||||||
|
count: 9,
|
||||||
|
low_ppm2: 150_000,
|
||||||
|
median_ppm2: 162_000,
|
||||||
|
high_ppm2: 178_000,
|
||||||
|
period_months: 24,
|
||||||
|
latest_deal_date: "2026-01-01",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("#2846 возраст выборки ДКП на плитке", () => {
|
||||||
|
it("коридор: возраст выборки доезжает до ppm-строки", () => {
|
||||||
|
// Красный на origin/main: latest_deal_date некуда положить и некому прочесть.
|
||||||
|
expect(dkpPpm(CORRIDOR_ONLY)).toContain("по I кв. 2026");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("подпись называет ВЕРХНЮЮ границу свежести, а не окно поиска", () => {
|
||||||
|
// period_months = 24 у той же карточки. «за 24 месяца» при сделках января —
|
||||||
|
// ровно та подмена, из-за которой подписи не было вовсе.
|
||||||
|
const ppm = dkpPpm(CORRIDOR_ONLY);
|
||||||
|
expect(ppm).not.toContain("24");
|
||||||
|
expect(ppm).not.toContain("мес");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("street-deals: подпись берётся у выборки, которая реально на экране", () => {
|
||||||
|
// Эта ветка приоритетнее коридора в resolveDealTier. Коридор тут заявляет
|
||||||
|
// I кв. 2026 — если бы подпись читалась из него, она омолодила бы чужие числа.
|
||||||
|
const ppm = dkpPpm(
|
||||||
|
CORRIDOR_ONLY,
|
||||||
|
streetDeals([
|
||||||
|
deal({ listing_date: "2025-10-01" }),
|
||||||
|
deal({ listing_date: "2025-07-01" }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(ppm).toContain("по IV кв. 2025");
|
||||||
|
expect(ppm).not.toContain("2026");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("actual_deals: третья ветка тоже датирована", () => {
|
||||||
|
const ppm = dkpPpm({
|
||||||
|
...FIXTURE_ESTIMATE,
|
||||||
|
dkp_corridor: null,
|
||||||
|
actual_deals: [
|
||||||
|
deal({ listing_date: "2025-04-01" }),
|
||||||
|
deal({ listing_date: "2025-07-01" }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(ppm).toContain("по III кв. 2025");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("сделок нет вовсе — подписи нет, а не «по …» под прочерком", () => {
|
||||||
|
const ppm = dkpPpm({
|
||||||
|
...FIXTURE_ESTIMATE,
|
||||||
|
dkp_corridor: null,
|
||||||
|
actual_deals: [],
|
||||||
|
});
|
||||||
|
expect(ppm).toBe("—");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("оценка старше деплоя бэкенда (поля нет) — молчим, как раньше", () => {
|
||||||
|
const legacy = { ...CORRIDOR_ONLY.dkp_corridor! };
|
||||||
|
delete (legacy as { latest_deal_date?: string | null }).latest_deal_date;
|
||||||
|
const ppm = dkpPpm({ ...CORRIDOR_ONLY, dkp_corridor: legacy });
|
||||||
|
expect(ppm).toContain("сделок");
|
||||||
|
expect(ppm).not.toContain("кв.");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("#2846 форма подписи", () => {
|
||||||
|
it("все 9 живых deal_date прода → римский квартал", () => {
|
||||||
|
// Не выдумано: ровно `SELECT DISTINCT deal_date FROM deals` на 2026-08-12.
|
||||||
|
expect(dealsAsOfLabel("2026-01-01")).toBe("по I кв. 2026");
|
||||||
|
expect(dealsAsOfLabel("2025-10-01")).toBe("по IV кв. 2025");
|
||||||
|
expect(dealsAsOfLabel("2025-07-01")).toBe("по III кв. 2025");
|
||||||
|
expect(dealsAsOfLabel("2025-04-01")).toBe("по II кв. 2025");
|
||||||
|
expect(dealsAsOfLabel("2025-01-01")).toBe("по I кв. 2025");
|
||||||
|
expect(dealsAsOfLabel("2024-10-01")).toBe("по IV кв. 2024");
|
||||||
|
expect(dealsAsOfLabel("2024-07-01")).toBe("по III кв. 2024");
|
||||||
|
expect(dealsAsOfLabel("2024-04-01")).toBe("по II кв. 2024");
|
||||||
|
expect(dealsAsOfLabel("2024-01-01")).toBe("по I кв. 2024");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("date-only ISO не уезжает на предыдущий квартал в западных зонах", () => {
|
||||||
|
// "2026-01-01" — UTC-полночь; локальные геттеры дали бы 31.12.2025 → IV кв.
|
||||||
|
expect(dealsAsOfLabel("2026-01-01")).toBe("по I кв. 2026");
|
||||||
|
expect(dealsAsOfLabel("2026-04-01")).toBe("по II кв. 2026");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("источник с настоящей датой подписывается месяцем, а не кварталом", () => {
|
||||||
|
expect(dealsAsOfLabel("2026-05-12", "day")).toBe("по 05.2026");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("датировать нечего — null, а не заглушка", () => {
|
||||||
|
expect(dealsAsOfLabel(null)).toBeNull();
|
||||||
|
expect(dealsAsOfLabel(undefined)).toBeNull();
|
||||||
|
expect(dealsAsOfLabel("не дата")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -76,6 +76,7 @@ import type {
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { tokens } from "./tokens";
|
import { tokens } from "./tokens";
|
||||||
import { sourceLabel as registrySourceLabel } from "@/lib/source-registry";
|
import { sourceLabel as registrySourceLabel } from "@/lib/source-registry";
|
||||||
|
import { dealsAsOfLabel } from "@/lib/rosreestr";
|
||||||
|
|
||||||
// Total number of source slots — meta "N / M". Single source of truth is
|
// Total number of source slots — meta "N / M". Single source of truth is
|
||||||
// SOURCE_SLOTS.length (defined with the array below as TOTAL_SOURCES).
|
// SOURCE_SLOTS.length (defined with the array below as TOTAL_SOURCES).
|
||||||
|
|
@ -599,12 +600,27 @@ interface DealTier {
|
||||||
medianPpm: number;
|
medianPpm: number;
|
||||||
count: number;
|
count: number;
|
||||||
bars: number[];
|
bars: number[];
|
||||||
|
/** #2846: «по I кв. 2026» — as-of ТОГО набора сделок, который дал числа выше. */
|
||||||
|
asOf: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Свежайшая дата среди лотов + её точность (лоты не всегда отсортированы). */
|
||||||
|
function newestLot(lots: AnalogLot[]): [string | null, "day" | "quarter" | null] {
|
||||||
|
let best: AnalogLot | null = null;
|
||||||
|
for (const l of lots) {
|
||||||
|
if (l.listing_date && (!best?.listing_date || l.listing_date > best.listing_date)) best = l;
|
||||||
|
}
|
||||||
|
return [best?.listing_date ?? null, best?.date_precision ?? null];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the "ФАКТИЧЕСКИЕ СДЕЛКИ" tier, preferring (1) street DKP deals with
|
* Resolve the "ФАКТИЧЕСКИЕ СДЕЛКИ" tier, preferring (1) street DKP deals with
|
||||||
* real ₽ totals, then (2) the dkp_corridor ₽/м² × area, then (3) the estimate's
|
* real ₽ totals, then (2) the dkp_corridor ₽/м² × area, then (3) the estimate's
|
||||||
* own actual_deals. null when no deal data at all.
|
* own actual_deals. null when no deal data at all.
|
||||||
|
*
|
||||||
|
* #2846: каждая ветка тянет СВОЙ as-of — три ветки это три РАЗНЫЕ выборки
|
||||||
|
* (street-deals не режет ppm²-выбросы, коридор режет и умеет расширяться до
|
||||||
|
* города), и подпись обязана описывать ту, чьи числа сейчас на экране.
|
||||||
*/
|
*/
|
||||||
function resolveDealTier(
|
function resolveDealTier(
|
||||||
e: AggregatedEstimate,
|
e: AggregatedEstimate,
|
||||||
|
|
@ -621,6 +637,9 @@ function resolveDealTier(
|
||||||
// same way the ads card does — otherwise a single mis-scraped lot bins
|
// same way the ads card does — otherwise a single mis-scraped lot bins
|
||||||
// over [min,max] and crushes the real deals into the left buckets.
|
// over [min,max] and crushes the real deals into the left buckets.
|
||||||
bars: bins8(guardPriceOutliers(sd.deals.map((d) => d.price_per_m2)).clean),
|
bars: bins8(guardPriceOutliers(sd.deals.map((d) => d.price_per_m2)).clean),
|
||||||
|
// sd.deals — top-10 из ORDER BY deal_date DESC по всем sd.count сделкам,
|
||||||
|
// так что максимум по ним = максимум по всей выборке, не по показанным.
|
||||||
|
asOf: dealsAsOfLabel(...newestLot(sd.deals)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const area = e.area_m2;
|
const area = e.area_m2;
|
||||||
|
|
@ -636,6 +655,9 @@ function resolveDealTier(
|
||||||
bars: bins8(
|
bars: bins8(
|
||||||
guardPriceOutliers(e.actual_deals.map((d) => d.price_per_m2)).clean,
|
guardPriceOutliers(e.actual_deals.map((d) => d.price_per_m2)).clean,
|
||||||
),
|
),
|
||||||
|
// Не из actual_deals: гистограмма заимствует их только как форму, а
|
||||||
|
// числа карточки (медиана/границы) — из коридора, у которого своя выборка.
|
||||||
|
asOf: dealsAsOfLabel(c.latest_deal_date, "quarter"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (e.actual_deals.length > 0) {
|
if (e.actual_deals.length > 0) {
|
||||||
|
|
@ -652,6 +674,7 @@ function resolveDealTier(
|
||||||
count: e.actual_deals.length,
|
count: e.actual_deals.length,
|
||||||
// Fix #1/#8: same high-outlier guard as the ads card (see above).
|
// Fix #1/#8: same high-outlier guard as the ads card (see above).
|
||||||
bars: bins8(guardPriceOutliers(ppms).clean),
|
bars: bins8(guardPriceOutliers(ppms).clean),
|
||||||
|
asOf: dealsAsOfLabel(...newestLot(e.actual_deals)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -1135,11 +1158,26 @@ export function mapResultPanel(
|
||||||
range: dealTier
|
range: dealTier
|
||||||
? rangeLine(dealTier.loRub, dealTier.hiRub)
|
? rangeLine(dealTier.loRub, dealTier.hiRub)
|
||||||
: "нет данных",
|
: "нет данных",
|
||||||
|
// #2846: хвост «· по I кв. 2026» — возраст ОТОБРАННЫХ сделок. Слово
|
||||||
|
// «ФАКТИЧЕСКИЕ» в заголовке читается как «недавние», а свежайшая пачка
|
||||||
|
// Росреестра на проде — I кв. 2026; молчание тут обещало свежесть,
|
||||||
|
// которой нет. Отдельной строки не заводим: ppm-строка по контракту
|
||||||
|
// ResultCard уже носит хвост «· N сделок», третий факт про тот же набор
|
||||||
|
// встаёт в тот же ряд и читается одним взглядом.
|
||||||
|
// dealTier == null (сделок нет) → «—», подписи нет: без чисел датировать
|
||||||
|
// нечего, а «по I кв. 2026» под прочерком выглядело бы как скрытые данные.
|
||||||
ppm: dealTier
|
ppm: dealTier
|
||||||
? `${fmtPpm(dealTier.medianPpm)} · ${dealTier.count} ${pluralRu(
|
? [
|
||||||
dealTier.count,
|
fmtPpm(dealTier.medianPpm),
|
||||||
["сделка", "сделки", "сделок"],
|
`${dealTier.count} ${pluralRu(dealTier.count, [
|
||||||
)}`
|
"сделка",
|
||||||
|
"сделки",
|
||||||
|
"сделок",
|
||||||
|
])}`,
|
||||||
|
dealTier.asOf,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ")
|
||||||
: "—",
|
: "—",
|
||||||
bars: dealTier ? dealTier.bars : [],
|
bars: dealTier ? dealTier.bars : [],
|
||||||
nav: 1,
|
nav: 1,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,41 @@
|
||||||
export const ROSREESTR_REQUEST_URL =
|
export const ROSREESTR_REQUEST_URL =
|
||||||
"https://rosreestr.gov.ru/eservices/request_info_from_egrn/";
|
"https://rosreestr.gov.ru/eservices/request_info_from_egrn/";
|
||||||
|
|
||||||
|
const ROMAN_QUARTER = ["I", "II", "III", "IV"] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #2846: «по I кв. 2026» — до какого момента доходят ПОКАЗАННЫЕ ДКП-сделки.
|
||||||
|
*
|
||||||
|
* Гранулярность — квартал, и это не округление ради красоты: Росреестр
|
||||||
|
* публикует ДКП пачками, deal_date = первый день квартала (бэкенд помечает это
|
||||||
|
* `date_precision: "quarter"`, #1995). Прод-замер 2026-08-12: 96 974 сделки,
|
||||||
|
* 9 различных deal_date, day-of-month = 1 у 100%, месяцы ровно {01,04,07,10}.
|
||||||
|
* Поэтому «223 дня назад» было бы ложной точностью в сторону состаривания:
|
||||||
|
* сделка из пачки «2026-01-01» могла случиться и 31 марта. «по I кв. 2026» —
|
||||||
|
* ровно то, что известно, и ни бита сверх.
|
||||||
|
*
|
||||||
|
* «по» (а не «за») намеренно: выборка обычно накрывает несколько кварталов,
|
||||||
|
* и подпись называет ВЕРХНЮЮ границу свежести, а не период целиком.
|
||||||
|
*
|
||||||
|
* @param iso ISO-дата свежайшей сделки ВЫБОРКИ (не всей таблицы).
|
||||||
|
* @param precision 'day' у источника с настоящей датой; иначе квартал.
|
||||||
|
* @returns null, если датировать нечего — подпись тогда не рисуется вовсе.
|
||||||
|
*/
|
||||||
|
export function dealsAsOfLabel(
|
||||||
|
iso: string | null | undefined,
|
||||||
|
precision?: "day" | "quarter" | null,
|
||||||
|
): string | null {
|
||||||
|
if (!iso) return null;
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return null;
|
||||||
|
// ISO date-only ("2026-01-01") парсится как UTC-полночь — читаем UTC-поля,
|
||||||
|
// иначе в отрицательных зонах квартал уезжает на предыдущий.
|
||||||
|
if (precision === "day") {
|
||||||
|
return `по ${String(d.getUTCMonth() + 1).padStart(2, "0")}.${d.getUTCFullYear()}`;
|
||||||
|
}
|
||||||
|
return `по ${ROMAN_QUARTER[Math.floor(d.getUTCMonth() / 3)]} кв. ${d.getUTCFullYear()}`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function openRosreestrWithAddress(address: string): Promise<void> {
|
export async function openRosreestrWithAddress(address: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(address);
|
await navigator.clipboard.writeText(address);
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,10 @@ export interface AnalogLot {
|
||||||
// 'T0_per_house' — kadastr_num exact match (currently not available in open dataset)
|
// 'T0_per_house' — kadastr_num exact match (currently not available in open dataset)
|
||||||
// 'T1_per_street' — street-level only (default for all rosreestr deals)
|
// 'T1_per_street' — street-level only (default for all rosreestr deals)
|
||||||
tier?: string | null;
|
tier?: string | null;
|
||||||
|
// #1995 (бэкенд уже слал, фронт игнорировал; поднято #2846): честная точность
|
||||||
|
// listing_date. 'quarter' — rosreestr: дата = первый день КВАРТАЛА, а не день
|
||||||
|
// сделки. 'day' — объявления. undefined у старых оценок и когда source неизвестен.
|
||||||
|
date_precision?: "day" | "quarter" | null;
|
||||||
// ── ANALYTICS surface — гео-координаты лота для карты (MapCard). Optional +
|
// ── ANALYTICS surface — гео-координаты лота для карты (MapCard). Optional +
|
||||||
// nullable: старые оценки и лоты без геокодинга их не содержат. Пропускаем
|
// nullable: старые оценки и лоты без геокодинга их не содержат. Пропускаем
|
||||||
// пины с null lat/lon.
|
// пины с null lat/lon.
|
||||||
|
|
@ -153,7 +157,12 @@ export interface DkpCorridor {
|
||||||
low_ppm2: number; // P10 ₽/м² по сделкам (робастный коридор)
|
low_ppm2: number; // P10 ₽/м² по сделкам (робастный коридор)
|
||||||
median_ppm2: number; // медиана ₽/м²
|
median_ppm2: number; // медиана ₽/м²
|
||||||
high_ppm2: number; // P90 ₽/м² по сделкам (робастный коридор)
|
high_ppm2: number; // P90 ₽/м² по сделкам (робастный коридор)
|
||||||
period_months: number; // окно поиска сделок
|
period_months: number; // окно ПОИСКА сделок — НЕ возраст данных
|
||||||
|
// #2846: ISO-дата свежайшей из ОТОБРАННЫХ сделок (не из всей таблицы). Точность
|
||||||
|
// — квартал: Росреестр публикует deal_date = первым днём квартала, поэтому
|
||||||
|
// «2026-01-01» читается как «I кв. 2026», а не как 1 января. optional: оценки,
|
||||||
|
// посчитанные до деплоя бэкенда, поля не содержат → подпись просто не рисуется.
|
||||||
|
latest_deal_date?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AggregatedEstimate {
|
export interface AggregatedEstimate {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue