fix(tradein): коридор ДКП с малым числом сделок помечен справочным
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m16s
CI Trade-In / backend-tests (pull_request) Successful in 5m32s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m16s
CI Trade-In / backend-tests (pull_request) Successful in 5m32s
Показ коридора открывается с трёх сделок (DKP_CORRIDOR_CITY_WIDE_MIN_N), а обе ценовые страховки по нему — soft-кламп headline сверху и radius-floor снизу — включаются с десяти (estimate_corridor_clamp_min_n). В зоне n=3..9 коридор существует, показывается и участвует в fallback-путях, но цену не держит, и на экране это неотличимо от работающего коридора. PR #3445 (снятие предиката d.rooms) переносит туда реальных клиентов: замерено 248 → 3 и 72 → 8 сделок. Решение — advisory-only. Порог показа не поднят (это отняло бы у клиента информацию), кламп по трём сделкам не включён (был бы хуже своего отсутствия), но зона теперь названа вслух: - DkpCorridor.advisory_only — computed-поле от count и ЕДИНСТВЕННОГО порога estimate_corridor_clamp_min_n, так что верно во всех конструкторах коридора (POST /estimate и GET-rehydrate) и не дублирует порог вторым числом; - лог INFO с маркером corridor_advisory_zone (n, порог, scope street/city_wide, id оценки) — одна строка на оценку, считается за сутки одним grep -c; - _fetch_dkp_corridor отдаёт служебный ключ scope: «мало сделок на улице» и «мало сделок во всём городе» — разные новости, и лог обязан их различать; - на экране (v1 hero + плитка ДКП в v2) подпись «справочно: мало сделок — оценку по ним не корректировали». Подпись молчит, когда headline ПОСТРОЕН из этого же коридора (n_analogs = 0, deals-fallback): там показанная цена и есть медиана этих сделок, и подпись была бы ложью в другую сторону. Тесты по значению (test_3452_corridor_advisory_zone.py) гоняют настоящий estimate_quality с коридором, потолок которого заведомо ниже медианы аналогов: в зоне headline НЕ прижат и метка стоит, выше порога — прижат к cap и метки нет. Захардкоженный флаг в любую сторону и снятый порог клампа роняют тесты (проверено руками). Closes #3452 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
9fa01e7ebe
commit
b71f3f9957
7 changed files with 317 additions and 5 deletions
|
|
@ -158,10 +158,14 @@ class DkpCorridor(BaseModel):
|
||||||
"""Коридор реальных ДКП-сделок Росреестра для target (#652).
|
"""Коридор реальных ДКП-сделок Росреестра для target (#652).
|
||||||
|
|
||||||
Источник: `deals` (source='rosreestr', ДКП-only), агрегированные по улице +
|
Источник: `deals` (source='rosreestr', ДКП-only), агрегированные по улице +
|
||||||
rooms + площади ±15% за период. ADVISORY: показывается как тонкая референсная
|
rooms + площади ±15% за период. Показывается как тонкая референсная линия
|
||||||
линия «коридор реальных сделок: X–Y млн»; если итоговая медиана ₽/м² выходит
|
«коридор реальных сделок: X–Y млн»; если итоговая медиана ₽/м² выходит за
|
||||||
за [low,high]×slack — добавляется текстовая пометка. НЕ хард-клампит оценку.
|
[low,high]×slack — добавляется текстовая пометка.
|
||||||
None / count=0 если по улице нет сопоставимых сделок.
|
None / count=0 если по улице нет сопоставимых сделок.
|
||||||
|
|
||||||
|
#3452: «advisory» здесь НЕ безусловно. При count >= estimate_corridor_clamp_min_n
|
||||||
|
коридор участвует в цене (soft-кламп headline + radius-floor, estimator.py), ниже
|
||||||
|
порога — не участвует. Что именно случилось с ЭТОЙ выборкой, говорит advisory_only.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
count: int # число ДКП-сделок в выборке
|
count: int # число ДКП-сделок в выборке
|
||||||
|
|
@ -183,6 +187,23 @@ class DkpCorridor(BaseModel):
|
||||||
# None = сделки без даты (в проде не встречается) — потребитель молчит.
|
# None = сделки без даты (в проде не встречается) — потребитель молчит.
|
||||||
latest_deal_date: date | None = None
|
latest_deal_date: date | None = None
|
||||||
|
|
||||||
|
@computed_field # type: ignore[prop-decorator]
|
||||||
|
@property
|
||||||
|
def advisory_only(self) -> bool:
|
||||||
|
"""#3452: True = коридор показан, но в цену не пошёл — сделок меньше порога.
|
||||||
|
|
||||||
|
Порог один и тот же (`estimate_corridor_clamp_min_n`) у обоих ценовых
|
||||||
|
путей коридора: soft-кламп headline и radius-floor (estimator.py). Ниже
|
||||||
|
него коридор всё ещё виден клиенту и участвует в fallback-путях, но
|
||||||
|
ценовой страховки по нему нет — зона n=3..9 на экране была неотличима от
|
||||||
|
работающей. Производное от count, поэтому верно во ВСЕХ конструкторах
|
||||||
|
DkpCorridor автоматически (POST /estimate и GET-rehydrate) и не
|
||||||
|
дублирует порог вторым числом.
|
||||||
|
"""
|
||||||
|
from app.core.config import settings # локально: schemas остаётся import-light
|
||||||
|
|
||||||
|
return self.count < settings.estimate_corridor_clamp_min_n
|
||||||
|
|
||||||
|
|
||||||
class PriceTrendPoint(BaseModel):
|
class PriceTrendPoint(BaseModel):
|
||||||
"""Одна точка месячного ₽/м² тренда для целевого дома / района (web TREND chart).
|
"""Одна точка месячного ₽/м² тренда для целевого дома / района (web TREND chart).
|
||||||
|
|
|
||||||
|
|
@ -2171,6 +2171,11 @@ def _fetch_dkp_corridor(
|
||||||
if dd is not None and (latest_deal is None or dd > latest_deal):
|
if dd is not None and (latest_deal is None or dd > latest_deal):
|
||||||
latest_deal = dd
|
latest_deal = dd
|
||||||
ppm2_values = sorted(adjusted)
|
ppm2_values = sorted(adjusted)
|
||||||
|
# #3452: чем получен коридор — улицей или расширением до города (widen ниже).
|
||||||
|
# Нужен логу зоны n < estimate_corridor_clamp_min_n: «мало сделок на улице» и
|
||||||
|
# «мало сделок во всём городе» — разные новости. В DkpCorridor ключ не уходит
|
||||||
|
# (pydantic игнорирует лишние kwargs), это служебная метка для логов.
|
||||||
|
scope = "street"
|
||||||
|
|
||||||
# #oblast-D widen: a single street in a small non-EKB town can easily have
|
# #oblast-D widen: a single street in a small non-EKB town can easily have
|
||||||
# <3 (or 0) ДКП deals in the last 12 months even though the CITY overall
|
# <3 (or 0) ДКП deals in the last 12 months even though the CITY overall
|
||||||
|
|
@ -2246,6 +2251,7 @@ def _fetch_dkp_corridor(
|
||||||
city,
|
city,
|
||||||
)
|
)
|
||||||
ppm2_values = sorted(city_adjusted)
|
ppm2_values = sorted(city_adjusted)
|
||||||
|
scope = "city_wide"
|
||||||
# #2846: коридор теперь описывает city-выборку — и возраст обязан
|
# #2846: коридор теперь описывает city-выборку — и возраст обязан
|
||||||
# переехать вместе с числами, иначе подпись осталась бы от street-
|
# переехать вместе с числами, иначе подпись осталась бы от street-
|
||||||
# выборки, которую на витрине уже никто не видит.
|
# выборки, которую на витрине уже никто не видит.
|
||||||
|
|
@ -2273,6 +2279,7 @@ def _fetch_dkp_corridor(
|
||||||
"high_ppm2": int(_percentile(ppm2_values, 0.90)),
|
"high_ppm2": int(_percentile(ppm2_values, 0.90)),
|
||||||
"period_months": period_months,
|
"period_months": period_months,
|
||||||
"latest_deal_date": latest_deal,
|
"latest_deal_date": latest_deal,
|
||||||
|
"scope": scope,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -5186,6 +5193,23 @@ async def estimate_quality(
|
||||||
|
|
||||||
# 6. Сохраняем в trade_in_estimates
|
# 6. Сохраняем в trade_in_estimates
|
||||||
estimate_id = uuid4()
|
estimate_id = uuid4()
|
||||||
|
|
||||||
|
# #3452: коридор ДКП показан, но в цену не пошёл — сделок меньше порога
|
||||||
|
# доверия (тот же estimate_corridor_clamp_min_n гейтит и soft-кламп headline,
|
||||||
|
# и radius-floor). Зона n=3..9 на экране неотличима от работающего коридора,
|
||||||
|
# поэтому попадание в неё пишется явной строкой: число оценок за сутки —
|
||||||
|
# `docker logs <backend> --since 24h | grep -c corridor_advisory_zone`.
|
||||||
|
# Ровно одна строка на оценку: GET-rehydrate сюда не заходит и счёт не двоит.
|
||||||
|
if dkp_corridor is not None and dkp_corridor.advisory_only:
|
||||||
|
logger.info(
|
||||||
|
"corridor_advisory_zone #3452: id=%s n=%d min_n=%d scope=%s city=%s",
|
||||||
|
estimate_id,
|
||||||
|
dkp_corridor.count,
|
||||||
|
settings.estimate_corridor_clamp_min_n,
|
||||||
|
(dkp_raw or {}).get("scope", "unknown"),
|
||||||
|
target_city or "unknown",
|
||||||
|
)
|
||||||
|
|
||||||
now = datetime.now(tz=UTC)
|
now = datetime.now(tz=UTC)
|
||||||
expires_at = now + timedelta(hours=settings.trade_in_estimate_retention_hours)
|
expires_at = now + timedelta(hours=settings.trade_in_estimate_retention_hours)
|
||||||
|
|
||||||
|
|
|
||||||
210
tradein-mvp/backend/tests/test_3452_corridor_advisory_zone.py
Normal file
210
tradein-mvp/backend/tests/test_3452_corridor_advisory_zone.py
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
"""#3452: зона n = 3..9 — коридор ДКП виден, но цену по нему не поправляют.
|
||||||
|
|
||||||
|
Два порога на одну выборку. Показ коридора открывается с трёх сделок
|
||||||
|
(`DKP_CORRIDOR_CITY_WIDE_MIN_N`, ниже — city-wide widen), а обе ценовые страховки
|
||||||
|
по коридору — soft-кламп headline сверху и radius-floor снизу — гейтятся
|
||||||
|
`estimate_corridor_clamp_min_n` (10). Между ними лежит зона, где коридор
|
||||||
|
существует, показывается и участвует в fallback-путях, а цену не держит; на
|
||||||
|
экране это неотличимо от работающего коридора. PR #3445 (снятие предиката
|
||||||
|
`d.rooms`) переносит в эту зону реальных клиентов: замерено 248 → 3 и 72 → 8
|
||||||
|
сделок.
|
||||||
|
|
||||||
|
Решение — advisory-only: порог показа НЕ поднимаем (это отняло бы у клиента
|
||||||
|
информацию), кламп по трём сделкам НЕ включаем (он был бы хуже своего
|
||||||
|
отсутствия), но зона помечается в ответе (`DkpCorridor.advisory_only`) и пишется
|
||||||
|
в лог маркером `corridor_advisory_zone`.
|
||||||
|
|
||||||
|
Тесты ПО ЗНАЧЕНИЮ: прогоняется настоящий estimate_quality с коридором, ПОТОЛОК
|
||||||
|
которого заведомо ниже медианы аналогов, — то есть кламп прижал бы headline,
|
||||||
|
если бы ему позволил порог. Проверяется не только метка, но и факт: в зоне цена
|
||||||
|
НЕ прижата, выше порога — прижата. Захардкоженный флаг (константой в любую
|
||||||
|
сторону) роняет один из двух тестов, снятое условие клампа — тоже.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import anyio
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.services.estimator import DKP_CORRIDOR_CITY_WIDE_MIN_N
|
||||||
|
|
||||||
|
# Медиана аналогов заведомо выше потолка коридора × (1 + slack) — кламп, если он
|
||||||
|
# включён порогом, ОБЯЗАН сработать и прижать headline к cap.
|
||||||
|
_ANALOG_PPM2 = 200_000.0
|
||||||
|
_CORRIDOR_HIGH_PPM2 = 100_000
|
||||||
|
_MARKER = "corridor_advisory_zone"
|
||||||
|
|
||||||
|
|
||||||
|
def _cap_ppm2() -> float:
|
||||||
|
"""Потолок клампа: corridor_high × (1 + slack) — см. _apply_corridor_clamp."""
|
||||||
|
return _CORRIDOR_HIGH_PPM2 * (1.0 + settings.estimate_corridor_clamp_slack)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_listing(*, price_per_m2: float, area_m2: float = 50.0) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"source": "cian",
|
||||||
|
"source_url": "https://cian.ru/offer/1",
|
||||||
|
"address": "ЕКБ, ул. Учителей, 18",
|
||||||
|
"lat": 56.838,
|
||||||
|
"lon": 60.595,
|
||||||
|
"rooms": 2,
|
||||||
|
"area_m2": area_m2,
|
||||||
|
"floor": 5,
|
||||||
|
"total_floors": 16,
|
||||||
|
"price_rub": price_per_m2 * area_m2,
|
||||||
|
"price_per_m2": price_per_m2,
|
||||||
|
"listing_date": datetime(2026, 5, 1),
|
||||||
|
"days_on_market": 10,
|
||||||
|
"photo_urls": [],
|
||||||
|
"scraped_at": datetime(2026, 5, 20, tzinfo=UTC),
|
||||||
|
"distance_m": 150.0,
|
||||||
|
"relevance_score": 0.1,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_geo():
|
||||||
|
from app.services.geocoder import GeocodeResult
|
||||||
|
|
||||||
|
return GeocodeResult(
|
||||||
|
lat=56.838,
|
||||||
|
lon=60.595,
|
||||||
|
full_address="Свердловская обл., Екатеринбург, ул. Учителей, 18",
|
||||||
|
provider="nominatim",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_payload():
|
||||||
|
from app.schemas.trade_in import TradeInEstimateInput
|
||||||
|
|
||||||
|
return TradeInEstimateInput(
|
||||||
|
address="ЕКБ, ул. Учителей, 18",
|
||||||
|
area_m2=50.0,
|
||||||
|
rooms=2,
|
||||||
|
floor=5,
|
||||||
|
total_floors=16,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _corridor(count: int) -> dict[str, Any]:
|
||||||
|
"""Коридор из `count` сделок с потолком ниже медианы аналогов."""
|
||||||
|
return {
|
||||||
|
"count": count,
|
||||||
|
"low_ppm2": 80_000,
|
||||||
|
"median_ppm2": 90_000,
|
||||||
|
"high_ppm2": _CORRIDOR_HIGH_PPM2,
|
||||||
|
"period_months": 12,
|
||||||
|
"latest_deal_date": None,
|
||||||
|
"scope": "street",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _run_estimate(dkp_raw: dict[str, Any]) -> Any:
|
||||||
|
from app.services.estimator import estimate_quality
|
||||||
|
|
||||||
|
# Шесть объявлений — выше HEADLINE_LISTINGS_MIN_N: иначе срабатывает
|
||||||
|
# #oblast-E и headline уступается коридору (тогда проверялся бы не кламп,
|
||||||
|
# а deals-fallback, у которого свой порог DEALS_HEADLINE_FALLBACK_MIN_N=3).
|
||||||
|
analogs = [
|
||||||
|
_make_listing(price_per_m2=_ANALOG_PPM2 + delta)
|
||||||
|
for delta in (-10_000, -5_000, 0.0, 0.0, 5_000, 10_000)
|
||||||
|
]
|
||||||
|
db = MagicMock()
|
||||||
|
payload = _make_payload()
|
||||||
|
|
||||||
|
async def _run() -> Any:
|
||||||
|
with (
|
||||||
|
patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_geo())),
|
||||||
|
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
|
||||||
|
patch("app.services.estimator.match_house_readonly", return_value=None),
|
||||||
|
patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
|
||||||
|
patch(
|
||||||
|
"app.services.estimator._fetch_analogs",
|
||||||
|
return_value=(list(analogs), False, "S"),
|
||||||
|
),
|
||||||
|
patch("app.services.estimator._fetch_deals", return_value=[]),
|
||||||
|
patch(
|
||||||
|
"app.services.estimator._get_or_fetch_imv_cached",
|
||||||
|
new=AsyncMock(return_value=None),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"app.services.estimator._get_or_fetch_yandex_valuation_cached",
|
||||||
|
new=AsyncMock(return_value=None),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"app.services.estimator.estimate_via_cian_valuation",
|
||||||
|
new=AsyncMock(return_value=None),
|
||||||
|
),
|
||||||
|
patch("app.services.estimator._fetch_dkp_corridor", return_value=dkp_raw),
|
||||||
|
patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
|
||||||
|
):
|
||||||
|
return await estimate_quality(payload, db)
|
||||||
|
|
||||||
|
return anyio.run(_run)
|
||||||
|
|
||||||
|
|
||||||
|
def test_zone_exists_at_all() -> None:
|
||||||
|
"""Премиса issue: порог показа коридора ниже порога клампа — зона непуста.
|
||||||
|
|
||||||
|
Если пороги когда-нибудь сведут в один, этот тест скажет об этом прямо,
|
||||||
|
а не оставит два теста ниже молча проверять пустое множество.
|
||||||
|
"""
|
||||||
|
assert settings.estimate_corridor_clamp_min_n > DKP_CORRIDOR_CITY_WIDE_MIN_N, (
|
||||||
|
"зона 3..9 схлопнулась — пороги показа и клампа сравнялись, "
|
||||||
|
"advisory-only решение #3452 больше не описывает реальность"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_corridor_in_zone_is_flagged_and_does_not_clamp(
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""n на единицу ниже порога: метка стоит, headline НЕ прижат, лог написан."""
|
||||||
|
n = settings.estimate_corridor_clamp_min_n - 1
|
||||||
|
with caplog.at_level(logging.INFO, logger="app.services.estimator"):
|
||||||
|
est = _run_estimate(_corridor(n))
|
||||||
|
|
||||||
|
assert est.dkp_corridor is not None, "коридор обязан остаться видимым — порог показа ниже"
|
||||||
|
assert est.dkp_corridor.count == n
|
||||||
|
assert est.dkp_corridor.advisory_only is True, (
|
||||||
|
f"n={n} < порога {settings.estimate_corridor_clamp_min_n} — коридор справочный, "
|
||||||
|
"ответ обязан это называть"
|
||||||
|
)
|
||||||
|
# ФАКТ, а не только метка: кламп прижал бы headline к cap, но порог ему не дал.
|
||||||
|
assert est.median_price_per_m2 > _cap_ppm2(), (
|
||||||
|
f"headline={est.median_price_per_m2} ₽/м² не должен быть прижат к "
|
||||||
|
f"cap={_cap_ppm2():.0f} — при n={n} кламп выключен порогом"
|
||||||
|
)
|
||||||
|
hits = [r for r in caplog.records if _MARKER in r.getMessage()]
|
||||||
|
assert len(hits) == 1, f"ожидалась ровно одна строка {_MARKER}, получено {len(hits)}"
|
||||||
|
msg = hits[0].getMessage()
|
||||||
|
assert f"n={n}" in msg and f"min_n={settings.estimate_corridor_clamp_min_n}" in msg, msg
|
||||||
|
assert "scope=street" in msg, msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_corridor_above_threshold_is_not_flagged_and_clamps(
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""n выше порога: метки нет, headline прижат к потолку коридора, лога нет."""
|
||||||
|
n = settings.estimate_corridor_clamp_min_n + 2
|
||||||
|
with caplog.at_level(logging.INFO, logger="app.services.estimator"):
|
||||||
|
est = _run_estimate(_corridor(n))
|
||||||
|
|
||||||
|
assert est.dkp_corridor is not None
|
||||||
|
assert est.dkp_corridor.advisory_only is False, (
|
||||||
|
f"n={n} >= порога {settings.estimate_corridor_clamp_min_n} — коридор в цене участвует, "
|
||||||
|
"справочным его называть нельзя"
|
||||||
|
)
|
||||||
|
assert est.median_price_per_m2 <= round(_cap_ppm2()), (
|
||||||
|
f"headline={est.median_price_per_m2} ₽/м² обязан быть прижат к cap={_cap_ppm2():.0f}"
|
||||||
|
)
|
||||||
|
assert not [r for r in caplog.records if _MARKER in r.getMessage()], (
|
||||||
|
"строка зоны не должна писаться, когда коридор реально клампит"
|
||||||
|
)
|
||||||
|
|
@ -14,7 +14,7 @@ import type { AggregatedEstimate, AvitoImvSummary, TradeInEstimateInput, HouseTy
|
||||||
import { asHouseType, asRepairState } from "@/types/trade-in";
|
import { asHouseType, asRepairState } from "@/types/trade-in";
|
||||||
import { useActiveBrandSlug, useBrand } from "@/lib/useBrand";
|
import { useActiveBrandSlug, useBrand } from "@/lib/useBrand";
|
||||||
import { sourceDot, sourcePublicLabel } from "@/lib/source-registry";
|
import { sourceDot, sourcePublicLabel } from "@/lib/source-registry";
|
||||||
import { dealsAsOfLabel } from "@/lib/rosreestr";
|
import { corridorAdvisoryNote, dealsAsOfLabel } from "@/lib/rosreestr";
|
||||||
import { SHORT_ESTIMATE_DISCLAIMER } from "@/lib/legal-copy";
|
import { SHORT_ESTIMATE_DISCLAIMER } from "@/lib/legal-copy";
|
||||||
import { HeroTransparency } from "./HeroTransparency";
|
import { HeroTransparency } from "./HeroTransparency";
|
||||||
|
|
||||||
|
|
@ -203,6 +203,12 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
|
||||||
// по сегодня», при том что свежайшая пачка Росреестра на проде — I кв. 2026.
|
// по сегодня», при том что свежайшая пачка Росреестра на проде — I кв. 2026.
|
||||||
// null (оценка старше деплоя бэкенда, поля нет) → хвост просто исчезает.
|
// null (оценка старше деплоя бэкенда, поля нет) → хвост просто исчезает.
|
||||||
const dkpAsOf = dealsAsOfLabel(dkp?.latest_deal_date, "quarter");
|
const dkpAsOf = dealsAsOfLabel(dkp?.latest_deal_date, "quarter");
|
||||||
|
// #3452: коридор ниже порога доверия — цену по нему не поправляли (ни клампом
|
||||||
|
// сверху, ни floor'ом снизу). Рядом с числами обязан стоять этот факт, иначе
|
||||||
|
// они читаются как участвующие в расчёте.
|
||||||
|
// n_analogs === 0 → headline посчитан по сделкам ДКП, а не по объявлениям
|
||||||
|
// (та же причина, что у analogsCaption ниже) — подпись тогда не рисуется.
|
||||||
|
const dkpAdvisory = corridorAdvisoryNote(dkp, estimate.n_analogs === 0);
|
||||||
|
|
||||||
// Progressive enrichment state
|
// Progressive enrichment state
|
||||||
const needsHouseType = estimate.house_type === null;
|
const needsHouseType = estimate.house_type === null;
|
||||||
|
|
@ -581,6 +587,7 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
|
||||||
{dkp.median_ppm2.toLocaleString("ru-RU")} ₽/м² · {dkp.count} сд.
|
{dkp.median_ppm2.toLocaleString("ru-RU")} ₽/м² · {dkp.count} сд.
|
||||||
{dkpAsOf ? ` · ${dkpAsOf}` : ""}
|
{dkpAsOf ? ` · ${dkpAsOf}` : ""}
|
||||||
</span>
|
</span>
|
||||||
|
{dkpAdvisory && <span className="bench-chip__sub">{dkpAdvisory}</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -638,6 +645,9 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
|
||||||
{dkp.median_ppm2.toLocaleString("ru-RU")} ₽/м² · {dkp.count} <abbr title="Договор купли-продажи (реальные сделки Росреестра)">ДКП</abbr>
|
{dkp.median_ppm2.toLocaleString("ru-RU")} ₽/м² · {dkp.count} <abbr title="Договор купли-продажи (реальные сделки Росреестра)">ДКП</abbr>
|
||||||
{dkpAsOf ? ` · ${dkpAsOf}` : ""}
|
{dkpAsOf ? ` · ${dkpAsOf}` : ""}
|
||||||
</div>
|
</div>
|
||||||
|
{dkpAdvisory && (
|
||||||
|
<div style={{ marginTop: 6, fontSize: 12, color: "var(--muted)" }}>{dkpAdvisory}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ import type {
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { tokens } from "./tokens";
|
import { tokens } from "./tokens";
|
||||||
import { sourcePublicLabel } from "@/lib/source-registry";
|
import { sourcePublicLabel } from "@/lib/source-registry";
|
||||||
import { dealsAsOfLabel } from "@/lib/rosreestr";
|
import { corridorAdvisoryNote, dealsAsOfLabel } from "@/lib/rosreestr";
|
||||||
|
|
||||||
// Total number of source slots — meta "N / M". Single source of truth is
|
// Total number of source slots — meta "N / M". Single source of truth is
|
||||||
// SOURCE_SLOTS.length (defined with the array below as TOTAL_SOURCES).
|
// SOURCE_SLOTS.length (defined with the array below as TOTAL_SOURCES).
|
||||||
|
|
@ -608,6 +608,13 @@ interface DealTier {
|
||||||
bars: number[];
|
bars: number[];
|
||||||
/** #2846: «по I кв. 2026» — as-of ТОГО набора сделок, который дал числа выше. */
|
/** #2846: «по I кв. 2026» — as-of ТОГО набора сделок, который дал числа выше. */
|
||||||
asOf: string | null;
|
asOf: string | null;
|
||||||
|
/**
|
||||||
|
* #3452: подпись «справочно», если числа плитки пришли из ДКП-коридора, по
|
||||||
|
* которому цену не поправляли (сделок меньше порога доверия). null у прочих
|
||||||
|
* веток: street-deals и actual_deals в кламп не ходят вообще, и обещать там
|
||||||
|
* «страховка выключена» было бы новостью не про эту выборку.
|
||||||
|
*/
|
||||||
|
advisoryNote: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Свежайшая дата среди лотов + её точность (лоты не всегда отсортированы). */
|
/** Свежайшая дата среди лотов + её точность (лоты не всегда отсортированы). */
|
||||||
|
|
@ -668,6 +675,7 @@ function resolveDealTier(
|
||||||
// sd.deals — top-10 из ORDER BY deal_date DESC по всем sd.count сделкам,
|
// sd.deals — top-10 из ORDER BY deal_date DESC по всем sd.count сделкам,
|
||||||
// так что максимум по ним = максимум по всей выборке, не по показанным.
|
// так что максимум по ним = максимум по всей выборке, не по показанным.
|
||||||
asOf: dealsAsOfLabel(...newestLot(sd.deals)),
|
asOf: dealsAsOfLabel(...newestLot(sd.deals)),
|
||||||
|
advisoryNote: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const area = e.area_m2;
|
const area = e.area_m2;
|
||||||
|
|
@ -686,6 +694,9 @@ function resolveDealTier(
|
||||||
// Не из actual_deals: гистограмма заимствует их только как форму, а
|
// Не из actual_deals: гистограмма заимствует их только как форму, а
|
||||||
// числа карточки (медиана/границы) — из коридора, у которого своя выборка.
|
// числа карточки (медиана/границы) — из коридора, у которого своя выборка.
|
||||||
asOf: dealsAsOfLabel(c.latest_deal_date, "quarter"),
|
asOf: dealsAsOfLabel(c.latest_deal_date, "quarter"),
|
||||||
|
// n_analogs === 0 → headline и есть медиана этого коридора (dealsOnlyPrice
|
||||||
|
// ниже по файлу разбирает тот же случай) — подписи тогда нет.
|
||||||
|
advisoryNote: corridorAdvisoryNote(c, e.n_analogs === 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (e.actual_deals.length > 0) {
|
if (e.actual_deals.length > 0) {
|
||||||
|
|
@ -703,6 +714,7 @@ function resolveDealTier(
|
||||||
// Fix #1/#8: same high-outlier guard as the ads card (see above).
|
// Fix #1/#8: same high-outlier guard as the ads card (see above).
|
||||||
bars: bins8(guardPriceOutliers(ppms).clean),
|
bars: bins8(guardPriceOutliers(ppms).clean),
|
||||||
asOf: dealsAsOfLabel(...newestLot(e.actual_deals)),
|
asOf: dealsAsOfLabel(...newestLot(e.actual_deals)),
|
||||||
|
advisoryNote: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -1231,6 +1243,8 @@ export function mapResultPanel(
|
||||||
.join(" · ")
|
.join(" · ")
|
||||||
: "—",
|
: "—",
|
||||||
bars: dealTier ? dealTier.bars : [],
|
bars: dealTier ? dealTier.bars : [],
|
||||||
|
// #3452: «справочно» — числа из коридора, по которому цену не поправляли.
|
||||||
|
note: dealTier?.advisoryNote ?? undefined,
|
||||||
nav: 1,
|
nav: 1,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import type { DkpCorridor } from "@/types/trade-in";
|
||||||
|
|
||||||
export const ROSREESTR_REQUEST_URL =
|
export const ROSREESTR_REQUEST_URL =
|
||||||
"https://rosreestr.gov.ru/eservices/request_info_from_egrn/";
|
"https://rosreestr.gov.ru/eservices/request_info_from_egrn/";
|
||||||
|
|
||||||
|
|
@ -36,6 +38,31 @@ export function dealsAsOfLabel(
|
||||||
return `по ${ROMAN_QUARTER[Math.floor(d.getUTCMonth() / 3)]} кв. ${d.getUTCFullYear()}`;
|
return `по ${ROMAN_QUARTER[Math.floor(d.getUTCMonth() / 3)]} кв. ${d.getUTCFullYear()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #3452: «справочно» под коридором ДКП — сделок меньше порога доверия.
|
||||||
|
*
|
||||||
|
* Порог (`estimate_corridor_clamp_min_n`) считает бэкенд и отдаёт готовый
|
||||||
|
* advisory_only: ниже него коридор ВИДЕН, но цену по нему не поправляют — ни
|
||||||
|
* кламп headline сверху, ни radius-floor снизу. Молчание тут выдавало бы
|
||||||
|
* выключенную страховку за работающую: на экране такой коридор ничем не
|
||||||
|
* отличается от того, что цену действительно держит.
|
||||||
|
*
|
||||||
|
* null (флага нет — оценка старше деплоя, либо сделок хватает) → подписи нет.
|
||||||
|
*/
|
||||||
|
export function corridorAdvisoryNote(
|
||||||
|
dkp: DkpCorridor | null | undefined,
|
||||||
|
headlineFromDeals: boolean,
|
||||||
|
): string | null {
|
||||||
|
if (dkp?.advisory_only !== true) return null;
|
||||||
|
// Молчим, когда headline ПОСТРОЕН из этого же коридора (deals-fallback,
|
||||||
|
// n_analogs === 0 — похожих объявлений не нашлось): там показанная цена и
|
||||||
|
// есть медиана этих сделок, и «оценку по ним не корректировали» было бы
|
||||||
|
// ложью в другую сторону. Метка и лог на бэкенде при этом остаются: факт
|
||||||
|
// «страховка клампом выключена» от источника headline не зависит.
|
||||||
|
if (headlineFromDeals) return null;
|
||||||
|
return "справочно: мало сделок — оценку по ним не корректировали";
|
||||||
|
}
|
||||||
|
|
||||||
export async function openRosreestrWithAddress(address: string): Promise<void> {
|
export async function openRosreestrWithAddress(address: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(address);
|
await navigator.clipboard.writeText(address);
|
||||||
|
|
|
||||||
|
|
@ -167,6 +167,12 @@ export interface DkpCorridor {
|
||||||
// «2026-01-01» читается как «I кв. 2026», а не как 1 января. optional: оценки,
|
// «2026-01-01» читается как «I кв. 2026», а не как 1 января. optional: оценки,
|
||||||
// посчитанные до деплоя бэкенда, поля не содержат → подпись просто не рисуется.
|
// посчитанные до деплоя бэкенда, поля не содержат → подпись просто не рисуется.
|
||||||
latest_deal_date?: string | null;
|
latest_deal_date?: string | null;
|
||||||
|
// #3452: true = сделок меньше порога доверия (бэкенд считает порог сам,
|
||||||
|
// estimate_corridor_clamp_min_n) → коридор показан, но цену по нему НЕ
|
||||||
|
// поправляли: ни кламп сверху, ни floor снизу. Заголовок «advisory» у всего
|
||||||
|
// коридора не про это — при count >= порога он в цену как раз идёт.
|
||||||
|
// optional: оценки, посчитанные до деплоя бэкенда, поля не содержат.
|
||||||
|
advisory_only?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AggregatedEstimate {
|
export interface AggregatedEstimate {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue