fix(tradein/dkp): «ФАКТИЧЕСКИЕ СДЕЛКИ» называют свой возраст, а не окно поиска (#2847)
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m13s
Deploy Trade-In / test (push) Successful in 3m33s
Deploy Trade-In / build-backend (push) Successful in 1m39s
Deploy Trade-In / deploy (push) Successful in 7m1s
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m13s
Deploy Trade-In / test (push) Successful in 3m33s
Deploy Trade-In / build-backend (push) Successful in 1m39s
Deploy Trade-In / deploy (push) Successful in 7m1s
This commit is contained in:
parent
1ff6699b95
commit
e59a102b16
10 changed files with 546 additions and 13 deletions
|
|
@ -153,7 +153,20 @@ class DkpCorridor(BaseModel):
|
|||
low_ppm2: int # P10 ₽/м² по сделкам (робастный коридор)
|
||||
median_ppm2: int # медиана ₽/м²
|
||||
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):
|
||||
|
|
|
|||
|
|
@ -1772,6 +1772,10 @@ def _fetch_dkp_corridor(
|
|||
series = _load_sber_index_series(db, region=SBER_TIME_ADJUST_REGION)
|
||||
adjusted: list[float] = []
|
||||
factors_applied: list[float] = []
|
||||
# #2846: возраст выборки. Копится по ТЕМ ЖЕ строкам, что попали в adjusted —
|
||||
# `continue` выше выкидывает и дату тоже, иначе сделка без ppm² подняла бы
|
||||
# заявленную свежесть коридора, не участвуя в его границах.
|
||||
latest_deal: date | None = None
|
||||
for r in rows:
|
||||
ppm2 = r["price_per_m2"]
|
||||
if not ppm2:
|
||||
|
|
@ -1783,6 +1787,8 @@ def _fetch_dkp_corridor(
|
|||
factor = _sber_time_factor(series, deal_month)
|
||||
adjusted.append(float(ppm2) * 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)
|
||||
|
||||
# #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_adjusted: list[float] = []
|
||||
city_latest: date | None = None
|
||||
for r in city_rows:
|
||||
ppm2 = r["price_per_m2"]
|
||||
if not ppm2:
|
||||
|
|
@ -1845,6 +1852,8 @@ def _fetch_dkp_corridor(
|
|||
deal_month = date(dd.year, dd.month, 1)
|
||||
factor = _sber_time_factor(series, deal_month)
|
||||
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):
|
||||
logger.info(
|
||||
|
|
@ -1855,6 +1864,10 @@ def _fetch_dkp_corridor(
|
|||
city,
|
||||
)
|
||||
ppm2_values = sorted(city_adjusted)
|
||||
# #2846: коридор теперь описывает city-выборку — и возраст обязан
|
||||
# переехать вместе с числами, иначе подпись осталась бы от street-
|
||||
# выборки, которую на витрине уже никто не видит.
|
||||
latest_deal = city_latest
|
||||
|
||||
if not ppm2_values:
|
||||
return None
|
||||
|
|
@ -1877,6 +1890,7 @@ def _fetch_dkp_corridor(
|
|||
"median_ppm2": int(_percentile(ppm2_values, 0.5)),
|
||||
"high_ppm2": int(_percentile(ppm2_values, 0.90)),
|
||||
"period_months": period_months,
|
||||
"latest_deal_date": latest_deal,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1516,11 +1516,41 @@ def _examples_rows(lots: list[AnalogLot]) -> str:
|
|||
|
||||
# ── 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]
|
||||
n_deals = len(estimate.actual_deals)
|
||||
today = dt.date.today()
|
||||
period_start = today - dt.timedelta(days=estimate.period_months * 30)
|
||||
# #2846: «Период сделок» показывал окно поиска правым концом = сегодня.
|
||||
# Реальная граница — as-of по показанным сделкам; окна поиска на странице
|
||||
# больше нет (оно ничего не говорило о данных). None → строку не печатаем.
|
||||
deals_as_of = deals_as_of_label(estimate)
|
||||
|
||||
# Баннер дисконта ссылается на РЕАЛЬНЫЙ рассчитанный дисконт запрос→продажа
|
||||
# (тот же _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;">
|
||||
<tr><td style="padding:4pt 0;">Количество сделок по аналогичном объектам</td>
|
||||
<td class="bold" style="text-align:right;">{_mono(f"{n_deals} шт.")}</td></tr>
|
||||
<tr><td style="padding:4pt 0;">Период сделок</td>
|
||||
<td class="bold" style="text-align:right;">
|
||||
{_mono(f"{period_start.strftime('%m.%Y')} – {today.strftime('%m.%Y')}")}</td></tr>
|
||||
{f'''<tr><td style="padding:4pt 0;">Сделки</td>
|
||||
<td class="bold" style="text-align:right;">{_mono(deals_as_of)}</td></tr>'''
|
||||
if deals_as_of else ""}
|
||||
</table>
|
||||
<div style="margin-top:14pt;font-size:{_FS_SM};color:{_MUTED};">
|
||||
<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,
|
||||
high_ppm2: 178_000,
|
||||
period_months: 24,
|
||||
// #2846: свежайшая сделка ВЫБОРКИ. Совпадает с прод-максимумом на 2026-08-12
|
||||
// (deals.max(deal_date) = 2026-01-01) — оффлайн-превью должно показывать
|
||||
// ту же подпись «по I кв. 2026», что и живая страница.
|
||||
latest_deal_date: "2026-01-01",
|
||||
},
|
||||
price_trend: [
|
||||
{ month: "2025-11", ppm2: 168_000 },
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import type { AggregatedEstimate, TradeInEstimateInput, HouseType, RepairState,
|
|||
import { asHouseType, asRepairState } from "@/types/trade-in";
|
||||
import { useActiveBrandSlug, useBrand } from "@/lib/useBrand";
|
||||
import { sourceDot, sourceLabel } from "@/lib/source-registry";
|
||||
import { dealsAsOfLabel } from "@/lib/rosreestr";
|
||||
import { HeroTransparency } from "./HeroTransparency";
|
||||
|
||||
interface Props {
|
||||
|
|
@ -175,6 +176,11 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
|
|||
const dkpArea = estimate.area_m2 ?? input.area_m2 ?? 0;
|
||||
const showDkp =
|
||||
!!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
|
||||
const needsHouseType = estimate.house_type === null;
|
||||
|
|
@ -547,6 +553,7 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
|
|||
</span>
|
||||
<span className="bench-chip__sub mono">
|
||||
{dkp.median_ppm2.toLocaleString("ru-RU")} ₽/м² · {dkp.count} сд.
|
||||
{dkpAsOf ? ` · ${dkpAsOf}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -601,8 +608,8 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
|
|||
{formatMln(dkp.low_ppm2 * dkpArea)} – {formatMln(dkp.high_ppm2 * dkpArea)} ₽
|
||||
</div>
|
||||
<div className="price-figure-perm2 mono">
|
||||
{dkp.median_ppm2.toLocaleString("ru-RU")} ₽/м² · {dkp.count} <abbr title="Договор купли-продажи (реальные сделки Росреестра)">ДКП</abbr> за{" "}
|
||||
{dkp.period_months} мес
|
||||
{dkp.median_ppm2.toLocaleString("ru-RU")} ₽/м² · {dkp.count} <abbr title="Договор купли-продажи (реальные сделки Росреестра)">ДКП</abbr>
|
||||
{dkpAsOf ? ` · ${dkpAsOf}` : ""}
|
||||
</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";
|
||||
import { tokens } from "./tokens";
|
||||
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
|
||||
// SOURCE_SLOTS.length (defined with the array below as TOTAL_SOURCES).
|
||||
|
|
@ -599,12 +600,27 @@ interface DealTier {
|
|||
medianPpm: number;
|
||||
count: 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
|
||||
* real ₽ totals, then (2) the dkp_corridor ₽/м² × area, then (3) the estimate's
|
||||
* own actual_deals. null when no deal data at all.
|
||||
*
|
||||
* #2846: каждая ветка тянет СВОЙ as-of — три ветки это три РАЗНЫЕ выборки
|
||||
* (street-deals не режет ppm²-выбросы, коридор режет и умеет расширяться до
|
||||
* города), и подпись обязана описывать ту, чьи числа сейчас на экране.
|
||||
*/
|
||||
function resolveDealTier(
|
||||
e: AggregatedEstimate,
|
||||
|
|
@ -621,6 +637,9 @@ function resolveDealTier(
|
|||
// 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.
|
||||
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;
|
||||
|
|
@ -636,6 +655,9 @@ function resolveDealTier(
|
|||
bars: bins8(
|
||||
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) {
|
||||
|
|
@ -652,6 +674,7 @@ function resolveDealTier(
|
|||
count: e.actual_deals.length,
|
||||
// Fix #1/#8: same high-outlier guard as the ads card (see above).
|
||||
bars: bins8(guardPriceOutliers(ppms).clean),
|
||||
asOf: dealsAsOfLabel(...newestLot(e.actual_deals)),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
|
|
@ -1135,11 +1158,26 @@ export function mapResultPanel(
|
|||
range: dealTier
|
||||
? rangeLine(dealTier.loRub, dealTier.hiRub)
|
||||
: "нет данных",
|
||||
// #2846: хвост «· по I кв. 2026» — возраст ОТОБРАННЫХ сделок. Слово
|
||||
// «ФАКТИЧЕСКИЕ» в заголовке читается как «недавние», а свежайшая пачка
|
||||
// Росреестра на проде — I кв. 2026; молчание тут обещало свежесть,
|
||||
// которой нет. Отдельной строки не заводим: ppm-строка по контракту
|
||||
// ResultCard уже носит хвост «· N сделок», третий факт про тот же набор
|
||||
// встаёт в тот же ряд и читается одним взглядом.
|
||||
// dealTier == null (сделок нет) → «—», подписи нет: без чисел датировать
|
||||
// нечего, а «по I кв. 2026» под прочерком выглядело бы как скрытые данные.
|
||||
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 : [],
|
||||
nav: 1,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,41 @@
|
|||
export const ROSREESTR_REQUEST_URL =
|
||||
"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> {
|
||||
try {
|
||||
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)
|
||||
// 'T1_per_street' — street-level only (default for all rosreestr deals)
|
||||
tier?: string | null;
|
||||
// #1995 (бэкенд уже слал, фронт игнорировал; поднято #2846): честная точность
|
||||
// listing_date. 'quarter' — rosreestr: дата = первый день КВАРТАЛА, а не день
|
||||
// сделки. 'day' — объявления. undefined у старых оценок и когда source неизвестен.
|
||||
date_precision?: "day" | "quarter" | null;
|
||||
// ── ANALYTICS surface — гео-координаты лота для карты (MapCard). Optional +
|
||||
// nullable: старые оценки и лоты без геокодинга их не содержат. Пропускаем
|
||||
// пины с null lat/lon.
|
||||
|
|
@ -153,7 +157,12 @@ export interface DkpCorridor {
|
|||
low_ppm2: number; // P10 ₽/м² по сделкам (робастный коридор)
|
||||
median_ppm2: number; // медиана ₽/м²
|
||||
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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue