fix(tradein/dkp): «ФАКТИЧЕСКИЕ СДЕЛКИ» называют свой возраст, а не окно поиска #2847

Merged
bot-backend merged 1 commit from fix/dkp-corridor-as-of into main 2026-08-12 18:51:11 +00:00
10 changed files with 546 additions and 13 deletions

View file

@ -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):

View file

@ -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,
}

View file

@ -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%» на обложке), а не хардкод «1018%»,
@ -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>

View 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

View file

@ -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 },

View file

@ -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>
)}

View file

@ -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();
});
});

View file

@ -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,

View file

@ -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);

View file

@ -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 {