diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py
index 923a7607..2c29d640 100644
--- a/tradein-mvp/backend/app/schemas/trade_in.py
+++ b/tradein-mvp/backend/app/schemas/trade_in.py
@@ -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):
diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py
index 7f386bb9..9acab16d 100644
--- a/tradein-mvp/backend/app/services/estimator.py
+++ b/tradein-mvp/backend/app/services/estimator.py
@@ -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,
}
diff --git a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py
index a89eadf6..8e02aae4 100644
--- a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py
+++ b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py
@@ -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)
| Количество сделок по аналогичном объектам |
{_mono(f"{n_deals} шт.")} |
- | Период сделок |
-
- {_mono(f"{period_start.strftime('%m.%Y')} – {today.strftime('%m.%Y')}")} |
+ {f'''| Сделки |
+ {_mono(deals_as_of)} |
'''
+ if deals_as_of else ""}
Источники данных
diff --git a/tradein-mvp/backend/tests/test_dkp_corridor_as_of_2846.py b/tradein-mvp/backend/tests/test_dkp_corridor_as_of_2846.py
new file mode 100644
index 00000000..85ed4502
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_dkp_corridor_as_of_2846.py
@@ -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
diff --git a/tradein-mvp/frontend/src/app/ui-preview/estimate/fixture.ts b/tradein-mvp/frontend/src/app/ui-preview/estimate/fixture.ts
index e441325a..ec10ab75 100644
--- a/tradein-mvp/frontend/src/app/ui-preview/estimate/fixture.ts
+++ b/tradein-mvp/frontend/src/app/ui-preview/estimate/fixture.ts
@@ -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 },
diff --git a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx
index 1b89abe9..652bd91e 100644
--- a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx
+++ b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx
@@ -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
{dkp.median_ppm2.toLocaleString("ru-RU")} ₽/м² · {dkp.count} сд.
+ {dkpAsOf ? ` · ${dkpAsOf}` : ""}
)}
@@ -601,8 +608,8 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
{formatMln(dkp.low_ppm2 * dkpArea)} – {formatMln(dkp.high_ppm2 * dkpArea)} ₽
- {dkp.median_ppm2.toLocaleString("ru-RU")} ₽/м² · {dkp.count}
ДКП за{" "}
- {dkp.period_months} мес
+ {dkp.median_ppm2.toLocaleString("ru-RU")} ₽/м² · {dkp.count}
ДКП
+ {dkpAsOf ? ` · ${dkpAsOf}` : ""}
)}
diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/dealsAsOf.test.ts b/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/dealsAsOf.test.ts
new file mode 100644
index 00000000..9b8f6480
--- /dev/null
+++ b/tradein-mvp/frontend/src/components/trade-in/v2/__tests__/dealsAsOf.test.ts
@@ -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 {
+ 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();
+ });
+});
diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts
index 2af10439..777918d1 100644
--- a/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts
+++ b/tradein-mvp/frontend/src/components/trade-in/v2/mappers.ts
@@ -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,
diff --git a/tradein-mvp/frontend/src/lib/rosreestr.ts b/tradein-mvp/frontend/src/lib/rosreestr.ts
index af89b100..6c2aa52e 100644
--- a/tradein-mvp/frontend/src/lib/rosreestr.ts
+++ b/tradein-mvp/frontend/src/lib/rosreestr.ts
@@ -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 {
try {
await navigator.clipboard.writeText(address);
diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts
index 38ecd503..c136e129 100644
--- a/tradein-mvp/frontend/src/types/trade-in.ts
+++ b/tradein-mvp/frontend/src/types/trade-in.ts
@@ -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 {