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).
|
||||
|
||||
Источник: `deals` (source='rosreestr', ДКП-only), агрегированные по улице +
|
||||
rooms + площади ±15% за период. ADVISORY: показывается как тонкая референсная
|
||||
линия «коридор реальных сделок: X–Y млн»; если итоговая медиана ₽/м² выходит
|
||||
за [low,high]×slack — добавляется текстовая пометка. НЕ хард-клампит оценку.
|
||||
rooms + площади ±15% за период. Показывается как тонкая референсная линия
|
||||
«коридор реальных сделок: X–Y млн»; если итоговая медиана ₽/м² выходит за
|
||||
[low,high]×slack — добавляется текстовая пометка.
|
||||
None / count=0 если по улице нет сопоставимых сделок.
|
||||
|
||||
#3452: «advisory» здесь НЕ безусловно. При count >= estimate_corridor_clamp_min_n
|
||||
коридор участвует в цене (soft-кламп headline + radius-floor, estimator.py), ниже
|
||||
порога — не участвует. Что именно случилось с ЭТОЙ выборкой, говорит advisory_only.
|
||||
"""
|
||||
|
||||
count: int # число ДКП-сделок в выборке
|
||||
|
|
@ -183,6 +187,23 @@ class DkpCorridor(BaseModel):
|
|||
# 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):
|
||||
"""Одна точка месячного ₽/м² тренда для целевого дома / района (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):
|
||||
latest_deal = dd
|
||||
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
|
||||
# <3 (or 0) ДКП deals in the last 12 months even though the CITY overall
|
||||
|
|
@ -2246,6 +2251,7 @@ def _fetch_dkp_corridor(
|
|||
city,
|
||||
)
|
||||
ppm2_values = sorted(city_adjusted)
|
||||
scope = "city_wide"
|
||||
# #2846: коридор теперь описывает city-выборку — и возраст обязан
|
||||
# переехать вместе с числами, иначе подпись осталась бы от street-
|
||||
# выборки, которую на витрине уже никто не видит.
|
||||
|
|
@ -2273,6 +2279,7 @@ def _fetch_dkp_corridor(
|
|||
"high_ppm2": int(_percentile(ppm2_values, 0.90)),
|
||||
"period_months": period_months,
|
||||
"latest_deal_date": latest_deal,
|
||||
"scope": scope,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -5186,6 +5193,23 @@ async def estimate_quality(
|
|||
|
||||
# 6. Сохраняем в trade_in_estimates
|
||||
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)
|
||||
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 { useActiveBrandSlug, useBrand } from "@/lib/useBrand";
|
||||
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 { HeroTransparency } from "./HeroTransparency";
|
||||
|
||||
|
|
@ -203,6 +203,12 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
|
|||
// по сегодня», при том что свежайшая пачка Росреестра на проде — I кв. 2026.
|
||||
// null (оценка старше деплоя бэкенда, поля нет) → хвост просто исчезает.
|
||||
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
|
||||
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} сд.
|
||||
{dkpAsOf ? ` · ${dkpAsOf}` : ""}
|
||||
</span>
|
||||
{dkpAdvisory && <span className="bench-chip__sub">{dkpAdvisory}</span>}
|
||||
</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>
|
||||
{dkpAsOf ? ` · ${dkpAsOf}` : ""}
|
||||
</div>
|
||||
{dkpAdvisory && (
|
||||
<div style={{ marginTop: 6, fontSize: 12, color: "var(--muted)" }}>{dkpAdvisory}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ import type {
|
|||
} from "./types";
|
||||
import { tokens } from "./tokens";
|
||||
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
|
||||
// SOURCE_SLOTS.length (defined with the array below as TOTAL_SOURCES).
|
||||
|
|
@ -608,6 +608,13 @@ interface DealTier {
|
|||
bars: number[];
|
||||
/** #2846: «по I кв. 2026» — as-of ТОГО набора сделок, который дал числа выше. */
|
||||
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 сделкам,
|
||||
// так что максимум по ним = максимум по всей выборке, не по показанным.
|
||||
asOf: dealsAsOfLabel(...newestLot(sd.deals)),
|
||||
advisoryNote: null,
|
||||
};
|
||||
}
|
||||
const area = e.area_m2;
|
||||
|
|
@ -686,6 +694,9 @@ function resolveDealTier(
|
|||
// Не из actual_deals: гистограмма заимствует их только как форму, а
|
||||
// числа карточки (медиана/границы) — из коридора, у которого своя выборка.
|
||||
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) {
|
||||
|
|
@ -703,6 +714,7 @@ function resolveDealTier(
|
|||
// Fix #1/#8: same high-outlier guard as the ads card (see above).
|
||||
bars: bins8(guardPriceOutliers(ppms).clean),
|
||||
asOf: dealsAsOfLabel(...newestLot(e.actual_deals)),
|
||||
advisoryNote: null,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
|
|
@ -1231,6 +1243,8 @@ export function mapResultPanel(
|
|||
.join(" · ")
|
||||
: "—",
|
||||
bars: dealTier ? dealTier.bars : [],
|
||||
// #3452: «справочно» — числа из коридора, по которому цену не поправляли.
|
||||
note: dealTier?.advisoryNote ?? undefined,
|
||||
nav: 1,
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { DkpCorridor } from "@/types/trade-in";
|
||||
|
||||
export const ROSREESTR_REQUEST_URL =
|
||||
"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()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* #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> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(address);
|
||||
|
|
|
|||
|
|
@ -167,6 +167,12 @@ export interface DkpCorridor {
|
|||
// «2026-01-01» читается как «I кв. 2026», а не как 1 января. optional: оценки,
|
||||
// посчитанные до деплоя бэкенда, поля не содержат → подпись просто не рисуется.
|
||||
latest_deal_date?: string | null;
|
||||
// #3452: true = сделок меньше порога доверия (бэкенд считает порог сам,
|
||||
// estimate_corridor_clamp_min_n) → коридор показан, но цену по нему НЕ
|
||||
// поправляли: ни кламп сверху, ни floor снизу. Заголовок «advisory» у всего
|
||||
// коридора не про это — при count >= порога он в цену как раз идёт.
|
||||
// optional: оценки, посчитанные до деплоя бэкенда, поля не содержат.
|
||||
advisory_only?: boolean;
|
||||
}
|
||||
|
||||
export interface AggregatedEstimate {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue