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) - - + {f''' + ''' + if deals_as_of else ""}
Количество сделок по аналогичном объектам {_mono(f"{n_deals} шт.")}
Период сделок - {_mono(f"{period_start.strftime('%m.%Y')} – {today.strftime('%m.%Y')}")}
Сделки{_mono(deals_as_of)}
Источники данных
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 {