Оценка: при якоре того же дома видно, что коридор сделок цену не ограничивал #3554
15 changed files with 529 additions and 34 deletions
|
|
@ -718,6 +718,7 @@ def load_estimate(
|
||||||
_qc_geo_to_precision,
|
_qc_geo_to_precision,
|
||||||
_resolve_target_city,
|
_resolve_target_city,
|
||||||
_source_counts,
|
_source_counts,
|
||||||
|
analog_tier_from_explanation,
|
||||||
rehydrate_search_radius_m,
|
rehydrate_search_radius_m,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -896,6 +897,10 @@ def load_estimate(
|
||||||
# осознанно НЕ заполняем — payload.radius_m не персистится, и подставить
|
# осознанно НЕ заполняем — payload.radius_m не персистится, и подставить
|
||||||
# сюда дефолт значило бы выдать догадку за то, что просил пользователь.
|
# сюда дефолт значило бы выдать догадку за то, что просил пользователь.
|
||||||
search_radius_m=search_radius_m,
|
search_radius_m=search_radius_m,
|
||||||
|
# #3466: якорный тир (same_building/micro_radius) восстанавливается из подписи
|
||||||
|
# якорного блока в confidence_explanation; радиусный — null, колонки нет.
|
||||||
|
# Без него ссылка ?id= теряла признак «коридор в цену не вошёл» (Tier A).
|
||||||
|
analog_tier=analog_tier_from_explanation(row.confidence_explanation),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,10 @@ class DkpCorridor(BaseModel):
|
||||||
#3452: «advisory» здесь НЕ безусловно. При count >= estimate_corridor_clamp_min_n
|
#3452: «advisory» здесь НЕ безусловно. При count >= estimate_corridor_clamp_min_n
|
||||||
коридор участвует в цене (soft-кламп headline + radius-floor, estimator.py), ниже
|
коридор участвует в цене (soft-кламп headline + radius-floor, estimator.py), ниже
|
||||||
порога — не участвует. Что именно случилось с ЭТОЙ выборкой, говорит advisory_only.
|
порога — не участвует. Что именно случилось с ЭТОЙ выборкой, говорит advisory_only.
|
||||||
|
#3466: исключение сверх порога — якорь того же дома (AggregatedEstimate.analog_tier
|
||||||
|
== "same_building"): кламп его не трогает, radius-floor тоже, и коридор в цену не
|
||||||
|
входит при любом count. Признак живёт в analog_tier, а не здесь: тир не выводится
|
||||||
|
из коридора, а поле обязано быть верным и на GET-rehydrate.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
count: int # число ДКП-сделок в выборке
|
count: int # число ДКП-сделок в выборке
|
||||||
|
|
@ -195,7 +199,10 @@ class DkpCorridor(BaseModel):
|
||||||
Порог один и тот же (`estimate_corridor_clamp_min_n`) у обоих СТРАХОВОЧНЫХ
|
Порог один и тот же (`estimate_corridor_clamp_min_n`) у обоих СТРАХОВОЧНЫХ
|
||||||
путей коридора: soft-кламп headline сверху и radius-floor снизу
|
путей коридора: soft-кламп headline сверху и radius-floor снизу
|
||||||
(estimator.py). Ниже него коридор всё ещё виден клиенту, но не держит
|
(estimator.py). Ниже него коридор всё ещё виден клиенту, но не держит
|
||||||
цену — зона n=3..9 на экране была неотличима от работающей.
|
цену — зона n=1..9 на экране была неотличима от работающей.
|
||||||
|
|
||||||
|
False тоже НЕ значит «коридор в цену вошёл»: при analog_tier="same_building"
|
||||||
|
обе страховки выключены якорем, а не порогом (#3466).
|
||||||
|
|
||||||
ВНИМАНИЕ, поле НЕ значит «коридор в цену не вошёл»: гейт Tier C
|
ВНИМАНИЕ, поле НЕ значит «коридор в цену не вошёл»: гейт Tier C
|
||||||
(#1795 шаг 3) сравнивает якорь с потолком коридора БЕЗ порога вообще, и
|
(#1795 шаг 3) сравнивает якорь с потолком коридора БЕЗ порога вообще, и
|
||||||
|
|
@ -344,6 +351,9 @@ class AggregatedEstimate(BaseModel):
|
||||||
# "city" — radius Tier W: широкий fallback
|
# "city" — radius Tier W: широкий fallback
|
||||||
# null — нет данных / оценка не построена
|
# null — нет данных / оценка не построена
|
||||||
# НЕ удаляет/заменяет confidence_explanation (фронт fallback'ает на него).
|
# НЕ удаляет/заменяет confidence_explanation (фронт fallback'ает на него).
|
||||||
|
# #3466: на GET-rehydrate колонки нет — якорные тиры (same_building/micro_radius)
|
||||||
|
# восстанавливаются из подписи якорного блока в confidence_explanation
|
||||||
|
# (estimator.analog_tier_from_explanation), радиусные остаются null.
|
||||||
analog_tier: Literal["same_building", "micro_radius", "district", "city"] | None = None
|
analog_tier: Literal["same_building", "micro_radius", "district", "city"] | None = None
|
||||||
# search_radius_m — фактический радиус (метры), по которому реально отбирались
|
# search_radius_m — фактический радиус (метры), по которому реально отбирались
|
||||||
# listings-аналоги (estimator.py, #2632). Может ОТЛИЧАТЬСЯ от requested_radius_m:
|
# listings-аналоги (estimator.py, #2632). Может ОТЛИЧАТЬСЯ от requested_radius_m:
|
||||||
|
|
|
||||||
|
|
@ -283,6 +283,32 @@ def radius_from_relaxations(relaxations: list[str] | None) -> int | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# #3466: тир якоря → подпись в confidence_explanation и enum ответа (analog_tier).
|
||||||
|
# Колонки под analog_tier нет, поэтому подпись, которую пишет якорный блок
|
||||||
|
# _price_from_inputs, — единственное место, где тир попадает в persisted-состояние.
|
||||||
|
# Отсюда же его читает GET-rehydrate (analog_tier_from_explanation): тот же приём,
|
||||||
|
# что у радиуса (#2632). Нужен витрине: при same_building коридор ДКП в цену не
|
||||||
|
# входит вовсе (кламп exempt, radius-floor только без якоря), и без тира ссылка
|
||||||
|
# ?id= показывала бы такой коридор как работающий.
|
||||||
|
# ponytail: разбор прозы, а не колонка; колонка analog_tier — когда понадобятся
|
||||||
|
# радиусные тиры на GET или подпись начнут переписывать мимо этой константы.
|
||||||
|
ANCHOR_TIER_LABELS: dict[str, str] = {"A": "того же дома", "C": "ближайшего окружения (≤500 м)"}
|
||||||
|
ANCHOR_API_TIERS: dict[str, str] = {"A": "same_building", "C": "micro_radius"}
|
||||||
|
|
||||||
|
|
||||||
|
def analog_tier_from_explanation(explanation: str | None) -> str | None:
|
||||||
|
"""Якорный analog_tier сохранённой оценки по подписи якорного блока, иначе None.
|
||||||
|
|
||||||
|
Ищется вся фраза «Оценка построена по N аналогам из <тир>», а не хвост «из того
|
||||||
|
же дома»: радиусный тир S пишет рядом «(аналоги из того же дома)», и это другой
|
||||||
|
путь цены — коридор там клампит. None = радиусный путь либо старая формулировка.
|
||||||
|
"""
|
||||||
|
for tier, label in ANCHOR_TIER_LABELS.items():
|
||||||
|
if re.search(rf"Оценка построена по \d+ аналогам из {re.escape(label)}", explanation or ""):
|
||||||
|
return ANCHOR_API_TIERS[tier]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def rehydrate_search_radius_m(
|
def rehydrate_search_radius_m(
|
||||||
relaxations: list[str] | None, analog_distances_m: list[int | None]
|
relaxations: list[str] | None, analog_distances_m: list[int | None]
|
||||||
) -> int | None:
|
) -> int | None:
|
||||||
|
|
@ -3699,10 +3725,10 @@ def _price_from_inputs(
|
||||||
range_low = new_range_low
|
range_low = new_range_low
|
||||||
range_high = new_range_high
|
range_high = new_range_high
|
||||||
confidence = anchor["confidence"]
|
confidence = anchor["confidence"]
|
||||||
tier_label = "того же дома" if anchor_tier == "A" else "ближайшего окружения (≤500 м)"
|
# #695: explanation описывает ИМЕННО якорные комплы. #3466: подпись читает
|
||||||
# #695: explanation описывает ИМЕННО якорные комплы.
|
# GET-rehydrate (analog_tier_from_explanation) — менять только через константу.
|
||||||
explanation = (
|
explanation = (
|
||||||
f"Оценка построена по {anchor['n']} аналогам из {tier_label}"
|
f"Оценка построена по {anchor['n']} аналогам из {ANCHOR_TIER_LABELS[anchor_tier]}"
|
||||||
f"{' (топ-уровень в доме)' if anchor['used_uplift'] else ''}."
|
f"{' (топ-уровень в доме)' if anchor['used_uplift'] else ''}."
|
||||||
) + repair_note
|
) + repair_note
|
||||||
# #695 (QA fixup): n_analogs по anchor-популяции.
|
# #695 (QA fixup): n_analogs по anchor-популяции.
|
||||||
|
|
@ -5313,8 +5339,9 @@ async def estimate_quality(
|
||||||
# #3452: коридор ДКП показан, но его ценовые страховки выключены — сделок
|
# #3452: коридор ДКП показан, но его ценовые страховки выключены — сделок
|
||||||
# меньше порога доверия (тот же estimate_corridor_clamp_min_n гейтит и
|
# меньше порога доверия (тот же estimate_corridor_clamp_min_n гейтит и
|
||||||
# soft-кламп headline, и radius-floor; гейт Tier C порога не имеет вовсе).
|
# soft-кламп headline, и radius-floor; гейт Tier C порога не имеет вовсе).
|
||||||
# Зона n=3..9 на экране неотличима от работающего коридора, поэтому
|
# Зона n=1..9 на экране неотличима от работающего коридора, поэтому
|
||||||
# попадание в неё пишется явной строкой: число оценок за сутки —
|
# попадание в неё пишется явной строкой (#3466: n=1..2 достижимы — уличный
|
||||||
|
# коридор отдаётся с одной сделки, widen только вне ЕКБ): число оценок за сутки —
|
||||||
# `docker logs tradein-backend --since 24h 2>&1 | grep -c corridor_advisory_zone`.
|
# `docker logs tradein-backend --since 24h 2>&1 | grep -c corridor_advisory_zone`.
|
||||||
# Ровно одна строка на оценку: GET-rehydrate сюда не заходит и счёт не двоит.
|
# Ровно одна строка на оценку: GET-rehydrate сюда не заходит и счёт не двоит.
|
||||||
if dkp_corridor is not None and dkp_corridor.advisory_only:
|
if dkp_corridor is not None and dkp_corridor.advisory_only:
|
||||||
|
|
@ -5573,10 +5600,8 @@ async def estimate_quality(
|
||||||
# radius-путь: analog_tier "W" → "city", остальные → "district".
|
# radius-путь: analog_tier "W" → "city", остальные → "district".
|
||||||
# None только если нет аналогов (median=0, insufficient_data=True).
|
# None только если нет аналогов (median=0, insufficient_data=True).
|
||||||
if median_price > 0:
|
if median_price > 0:
|
||||||
if anchor_tier == "A":
|
if anchor_tier in ANCHOR_API_TIERS:
|
||||||
api_analog_tier: str | None = "same_building"
|
api_analog_tier: str | None = ANCHOR_API_TIERS[anchor_tier]
|
||||||
elif anchor_tier == "C":
|
|
||||||
api_analog_tier = "micro_radius"
|
|
||||||
elif analog_tier == "W":
|
elif analog_tier == "W":
|
||||||
api_analog_tier = "city"
|
api_analog_tier = "city"
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
"""#3452: зона n = 3..9 — коридор ДКП виден, но цену по нему не поправляют.
|
"""#3452: зона n = 1..9 — коридор ДКП виден, но цену по нему не поправляют.
|
||||||
|
|
||||||
Два порога на одну выборку. Показ коридора открывается с трёх сделок
|
Два порога на одну выборку. Показ коридора открывается с одной сделки (ниже
|
||||||
(`DKP_CORRIDOR_CITY_WIDE_MIN_N`, ниже — city-wide widen), а обе ценовые страховки
|
`DKP_CORRIDOR_CITY_WIDE_MIN_N` вне Екатеринбурга пробуется city-wide widen, но
|
||||||
|
уличная выборка из 1-2 сделок отдаётся и без него, #3466), а обе ценовые страховки
|
||||||
по коридору — soft-кламп headline сверху и radius-floor снизу — гейтятся
|
по коридору — soft-кламп headline сверху и radius-floor снизу — гейтятся
|
||||||
`estimate_corridor_clamp_min_n` (10). Между ними лежит зона, где коридор
|
`estimate_corridor_clamp_min_n` (10). Между ними лежит зона, где коридор
|
||||||
существует, показывается и участвует в fallback-путях, а цену не держит; на
|
существует, показывается и участвует в fallback-путях, а цену не держит; на
|
||||||
|
|
|
||||||
176
tradein-mvp/backend/tests/test_3466_corridor_tier_a.py
Normal file
176
tradein-mvp/backend/tests/test_3466_corridor_tier_a.py
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
"""#3466: при якоре Tier A коридор ДКП в цену не входит — ответ обязан это нести.
|
||||||
|
|
||||||
|
`DkpCorridor.advisory_only` (#3452) считается из размера выборки, а кламп headline
|
||||||
|
выключается ещё и якорем того же дома (`_apply_corridor_clamp`: Tier A exempt;
|
||||||
|
radius-floor требует `anchor_tier is None`). Ревьюер PR #3462 воспроизвёл: n=20,
|
||||||
|
advisory_only=False, headline 202 100 ₽/м² против потолка 140 000 — поле молчит, а
|
||||||
|
коридор цену не тронул.
|
||||||
|
|
||||||
|
Признак — `analog_tier == "same_building"`, уже структурный в POST-ответе. На
|
||||||
|
GET-rehydrate колонки под него нет, тир восстанавливается из подписи якорного
|
||||||
|
блока (`analog_tier_from_explanation`). Тесты ПО ЗНАЧЕНИЮ:
|
||||||
|
|
||||||
|
* цена Tier A не зависит от коридора вовсе — снятие exempt в клампе роняет тест;
|
||||||
|
* тот же коридор на радиусном пути цену прижимает — иначе первый тест пуст;
|
||||||
|
* подпись, которую пишет якорный блок, читается обратно в тот же тир, а соседняя
|
||||||
|
подпись радиусного тира S («аналоги из того же дома») — нет;
|
||||||
|
* полоса счёта маркера `corridor_advisory_zone` — n = 1..9, а не 3..9: уличный
|
||||||
|
коридор отдаётся с одной сделки (widen только вне Екатеринбурга).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.services import estimator
|
||||||
|
from app.services.geocoder import GeocodeResult
|
||||||
|
|
||||||
|
# Коридор выше порога доверия, потолок заведомо ниже цены якоря: кламп, если ему
|
||||||
|
# позволить, ОБЯЗАН прижать headline к cap = 100 000 × (1 + slack).
|
||||||
|
_CORRIDOR: dict[str, Any] = {
|
||||||
|
"count": 20,
|
||||||
|
"low_ppm2": 80_000,
|
||||||
|
"median_ppm2": 90_000,
|
||||||
|
"high_ppm2": 100_000,
|
||||||
|
"period_months": 12,
|
||||||
|
"latest_deal_date": None,
|
||||||
|
"scope": "street",
|
||||||
|
}
|
||||||
|
_ANCHOR_PPM2 = (190_000, 195_000, 200_000, 205_000, 210_000)
|
||||||
|
|
||||||
|
|
||||||
|
def _cap() -> float:
|
||||||
|
return _CORRIDOR["high_ppm2"] * (1.0 + settings.estimate_corridor_clamp_slack)
|
||||||
|
|
||||||
|
|
||||||
|
def _call(
|
||||||
|
*,
|
||||||
|
listings_ppm2: tuple[float, ...],
|
||||||
|
anchor_tier_fetched: str | None,
|
||||||
|
dkp_raw: dict[str, Any] | None,
|
||||||
|
analog_tier: str = "W",
|
||||||
|
) -> estimator.PricingResult:
|
||||||
|
return estimator._price_from_inputs(
|
||||||
|
listings=[
|
||||||
|
{"price_per_m2": p, "address": f"ул. Тестовая, {i}", "source": "avito"}
|
||||||
|
for i, p in enumerate(listings_ppm2)
|
||||||
|
],
|
||||||
|
area_m2=50.0,
|
||||||
|
rooms=2,
|
||||||
|
repair_state=None,
|
||||||
|
floor=5,
|
||||||
|
total_floors=10,
|
||||||
|
target_year=None,
|
||||||
|
analog_tier=analog_tier,
|
||||||
|
fallback_used=False,
|
||||||
|
area_widened=False,
|
||||||
|
anchor_comps=(
|
||||||
|
[{"price_per_m2": p, "area_m2": 50.0, "rooms": 2} for p in _ANCHOR_PPM2]
|
||||||
|
if anchor_tier_fetched
|
||||||
|
else []
|
||||||
|
),
|
||||||
|
anchor_tier_fetched=anchor_tier_fetched,
|
||||||
|
dkp_raw=dkp_raw,
|
||||||
|
imv_anchor=None,
|
||||||
|
imv_eval=None,
|
||||||
|
yandex_val_present=False,
|
||||||
|
cian_val_present=False,
|
||||||
|
ratio_resolver=lambda _appm2: (None, None),
|
||||||
|
quarter_index_lookup=lambda _q: None,
|
||||||
|
quarter_indexes_lookup=lambda _qs: {},
|
||||||
|
target_house_cadnum=None,
|
||||||
|
dadata_coarse=False,
|
||||||
|
geo=GeocodeResult(
|
||||||
|
lat=56.838, lon=60.597, full_address="ул. Тестовая, 1", provider="nominatim"
|
||||||
|
),
|
||||||
|
dadata_qc_geo=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _price(pr: estimator.PricingResult) -> tuple[float, int, int, int]:
|
||||||
|
return (pr.median_ppm2, pr.median_price, pr.range_low, pr.range_high)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tier_a_price_does_not_depend_on_corridor() -> None:
|
||||||
|
"""Ядро признака: при same_building коридор не двигает НИ ОДНО ценовое число."""
|
||||||
|
listings = (100_000.0,) * 6
|
||||||
|
with_corridor = _call(listings_ppm2=listings, anchor_tier_fetched="A", dkp_raw=_CORRIDOR)
|
||||||
|
without = _call(listings_ppm2=listings, anchor_tier_fetched="A", dkp_raw=None)
|
||||||
|
|
||||||
|
assert with_corridor.anchor_tier == "A", "якорь не сработал — тест проверял бы радиусный путь"
|
||||||
|
# Предпосылка — на цене БЕЗ коридора: она выше потолка, т.е. клампу было что резать.
|
||||||
|
assert without.median_ppm2 > _cap(), (
|
||||||
|
f"цена якоря {without.median_ppm2:.0f} не выше cap {_cap():.0f} — "
|
||||||
|
"кламп не понадобился бы и без exempt, случай #3466 не воспроизведён"
|
||||||
|
)
|
||||||
|
assert _price(with_corridor) == _price(without), (
|
||||||
|
"коридор из 20 сделок изменил цену Tier A — значит он в цену вошёл, и признак "
|
||||||
|
f"same_building на витрине врёт: {_price(with_corridor)} против {_price(without)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_corridor_clamps_radius_path() -> None:
|
||||||
|
"""Контроль: тот же коридор без якоря прижимает цену — первый тест не пустой."""
|
||||||
|
pr = _call(
|
||||||
|
listings_ppm2=(195_000.0, 200_000.0, 200_000.0, 205_000.0, 210_000.0, 190_000.0),
|
||||||
|
anchor_tier_fetched=None,
|
||||||
|
dkp_raw=_CORRIDOR,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert pr.anchor_tier is None
|
||||||
|
assert round(pr.median_ppm2) == round(_cap()), (
|
||||||
|
f"без якоря коридор n=20 обязан прижать headline к cap={_cap():.0f}, "
|
||||||
|
f"получено {pr.median_ppm2:.0f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_anchor_sentence_reads_back_to_tier() -> None:
|
||||||
|
"""Подпись якорного блока → тот же enum, что POST кладёт в analog_tier."""
|
||||||
|
tier_a = _call(listings_ppm2=(100_000.0,) * 6, anchor_tier_fetched="A", dkp_raw=None)
|
||||||
|
tier_c = _call(listings_ppm2=(200_000.0,) * 6, anchor_tier_fetched="C", dkp_raw=None)
|
||||||
|
assert (tier_a.anchor_tier, tier_c.anchor_tier) == ("A", "C")
|
||||||
|
|
||||||
|
assert estimator.analog_tier_from_explanation(tier_a.explanation) == "same_building"
|
||||||
|
assert estimator.analog_tier_from_explanation(tier_c.explanation) == "micro_radius"
|
||||||
|
# #conf-cap ставит свою фразу ПЕРЕД подписью — тир обязан читаться и тогда.
|
||||||
|
capped = "Оценка построена с расширенными параметрами подбора — точность снижена. "
|
||||||
|
assert estimator.analog_tier_from_explanation(capped + tier_a.explanation) == "same_building"
|
||||||
|
|
||||||
|
|
||||||
|
def test_radius_same_house_note_is_not_anchor() -> None:
|
||||||
|
"""Радиусный тир S пишет «(аналоги из того же дома)», но коридор там клампит."""
|
||||||
|
pr = _call(
|
||||||
|
listings_ppm2=(100_000.0,) * 6, anchor_tier_fetched=None, dkp_raw=None, analog_tier="S"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert pr.anchor_tier is None
|
||||||
|
assert "из того же дома" in pr.explanation, pr.explanation
|
||||||
|
assert estimator.analog_tier_from_explanation(pr.explanation) is None
|
||||||
|
assert estimator.analog_tier_from_explanation(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_street_corridor_is_returned_below_three_deals() -> None:
|
||||||
|
"""Полоса маркера #3452 — n = 1..9: улица Екатеринбурга не расширяется до города."""
|
||||||
|
rows = [
|
||||||
|
{"price_per_m2": 150_000, "deal_date": None},
|
||||||
|
{"price_per_m2": 160_000, "deal_date": None},
|
||||||
|
]
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute.return_value.mappings.return_value.all.return_value = rows
|
||||||
|
with patch.object(estimator, "_load_sber_index_series", return_value={}):
|
||||||
|
raw = estimator._fetch_dkp_corridor(
|
||||||
|
db,
|
||||||
|
address="Екатеринбург, ул. Учителей, 18",
|
||||||
|
rooms=2,
|
||||||
|
area=50.0,
|
||||||
|
city="екатеринбург",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert raw is not None and raw["count"] == 2, raw
|
||||||
|
assert raw["scope"] == "street"
|
||||||
|
assert raw["count"] < estimator.DKP_CORRIDOR_CITY_WIDE_MIN_N
|
||||||
|
|
@ -28,6 +28,8 @@ import pytest # noqa: E402
|
||||||
from fastapi import FastAPI # noqa: E402
|
from fastapi import FastAPI # noqa: E402
|
||||||
from fastapi.testclient import TestClient # noqa: E402
|
from fastapi.testclient import TestClient # noqa: E402
|
||||||
|
|
||||||
|
from app.services.estimator import analog_tier_from_explanation # noqa: E402
|
||||||
|
|
||||||
_ESTIMATE_ID = "11111111-1111-1111-1111-111111111111"
|
_ESTIMATE_ID = "11111111-1111-1111-1111-111111111111"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -170,6 +172,9 @@ def _stub_precision_and_pdf():
|
||||||
# фикстур нет ни подписи каскада, ни расстояний → None и есть настоящее
|
# фикстур нет ни подписи каскада, ни расстояний → None и есть настоящее
|
||||||
# поведение (см. tests/test_estimator_search_radius_2632.py).
|
# поведение (см. tests/test_estimator_search_radius_2632.py).
|
||||||
rehydrate_search_radius_m=lambda *a, **k: None,
|
rehydrate_search_radius_m=lambda *a, **k: None,
|
||||||
|
# #3466: якорный тир из подписи в confidence_explanation. У фикстуры там
|
||||||
|
# «ok» → None и есть настоящее поведение; положительный случай — ниже.
|
||||||
|
analog_tier_from_explanation=lambda _e: None,
|
||||||
)
|
)
|
||||||
real_estimator = sys.modules.get("app.services.estimator")
|
real_estimator = sys.modules.get("app.services.estimator")
|
||||||
sys.modules["app.services.estimator"] = estimator_stub # type: ignore[assignment]
|
sys.modules["app.services.estimator"] = estimator_stub # type: ignore[assignment]
|
||||||
|
|
@ -273,6 +278,26 @@ def test_get_estimate_rehydrates_post_only_fields(trade_in_app: FastAPI) -> None
|
||||||
assert body["last_scraped_at"] is not None
|
assert body["last_scraped_at"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_estimate_restores_anchor_tier(trade_in_app: FastAPI) -> None:
|
||||||
|
"""#3466: ссылка ?id= на оценку Tier A отдаёт analog_tier=same_building.
|
||||||
|
|
||||||
|
Без него витрина не отличает коридор, который цену не трогал (якорь того же
|
||||||
|
дома), от работающего. Разбор — настоящий, из estimator, по подписи, которую
|
||||||
|
пишет якорный блок.
|
||||||
|
"""
|
||||||
|
est = sys.modules["app.services.estimator"]
|
||||||
|
est.analog_tier_from_explanation = analog_tier_from_explanation # type: ignore[attr-defined]
|
||||||
|
row = _make_estimate_row(created_by="kopylov")
|
||||||
|
row.confidence_explanation = "Оценка построена по 20 аналогам из того же дома."
|
||||||
|
client = _client_with(trade_in_app, _make_db_mock(row), role="pilot")
|
||||||
|
resp = client.get(
|
||||||
|
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
|
||||||
|
headers={"X-Authenticated-User": "kopylov"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["analog_tier"] == "same_building"
|
||||||
|
|
||||||
|
|
||||||
def test_get_estimate_requires_authenticated_user(trade_in_app: FastAPI) -> None:
|
def test_get_estimate_requires_authenticated_user(trade_in_app: FastAPI) -> None:
|
||||||
"""No X-Authenticated-User header → 401."""
|
"""No X-Authenticated-User header → 401."""
|
||||||
db_mock = _make_db_mock(_make_estimate_row(created_by="kopylov"))
|
db_mock = _make_db_mock(_make_estimate_row(created_by="kopylov"))
|
||||||
|
|
|
||||||
|
|
@ -277,6 +277,8 @@ def _estimator_stub():
|
||||||
# этой фикстуры analogs пусты → None и есть настоящее поведение
|
# этой фикстуры analogs пусты → None и есть настоящее поведение
|
||||||
# (см. tests/test_estimator_search_radius_2632.py).
|
# (см. tests/test_estimator_search_radius_2632.py).
|
||||||
rehydrate_search_radius_m=lambda *a, **k: None,
|
rehydrate_search_radius_m=lambda *a, **k: None,
|
||||||
|
# #3466: подписи якоря у строк фикстуры нет → None (см. test_estimate_idor.py).
|
||||||
|
analog_tier_from_explanation=lambda _e: None,
|
||||||
estimate_quality=_default_estimate_quality,
|
estimate_quality=_default_estimate_quality,
|
||||||
)
|
)
|
||||||
sys.modules["app.services.estimator"] = stub # type: ignore[assignment]
|
sys.modules["app.services.estimator"] = stub # type: ignore[assignment]
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@
|
||||||
- radius median выше dkp_low × factor → no-op (медиана не изменена)
|
- radius median выше dkp_low × factor → no-op (медиана не изменена)
|
||||||
- dkp_raw is None → no-op (нет базы для floor)
|
- dkp_raw is None → no-op (нет базы для floor)
|
||||||
- anchor-путь (anchor_tier != None) → не затронут floor'ом
|
- anchor-путь (anchor_tier != None) → не затронут floor'ом
|
||||||
|
- коридор ниже estimate_corridor_clamp_min_n → floor не применяется (#3466)
|
||||||
|
- коридор ровно estimate_corridor_clamp_min_n → floor применяется (#3466)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -114,17 +116,25 @@ def _run_estimate(
|
||||||
return anyio.run(_run)
|
return anyio.run(_run)
|
||||||
|
|
||||||
|
|
||||||
|
def _six(center: float) -> list[dict[str, Any]]:
|
||||||
|
"""Шесть лотов вокруг center — выше HEADLINE_LISTINGS_MIN_N (5).
|
||||||
|
|
||||||
|
Три лота (как было до #3466) уводили оценку в #oblast-E: headline уступался
|
||||||
|
медиане коридора (180k), и floor не исполнялся вовсе — тесты 1 и 2 зеленели на
|
||||||
|
deals-fallback, а снятие гейта min_n у floor ничего не роняло.
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
_make_listing(price_per_m2=center + d)
|
||||||
|
for d in (-5_000.0, -2_500.0, 0.0, 0.0, 2_500.0, 5_000.0)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
# ── тест 1: radius median ниже floor → поднята ───────────────────────────────
|
# ── тест 1: radius median ниже floor → поднята ───────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def test_radius_median_below_dkp_floor_is_lifted() -> None:
|
def test_radius_median_below_dkp_floor_is_lifted() -> None:
|
||||||
"""Radius median = 80k, dkp_low = 150k, factor 0.8 → floor = 120k → median поднята."""
|
"""Radius median = 80k, dkp_low = 150k, factor 0.8 → floor = 120k → median поднята."""
|
||||||
# analogs с median ~80k
|
analogs = _six(80_000.0)
|
||||||
analogs = [
|
|
||||||
_make_listing(price_per_m2=75_000.0),
|
|
||||||
_make_listing(price_per_m2=80_000.0),
|
|
||||||
_make_listing(price_per_m2=85_000.0),
|
|
||||||
]
|
|
||||||
dkp_raw = {
|
dkp_raw = {
|
||||||
"count": 15,
|
"count": 15,
|
||||||
"low_ppm2": 150_000,
|
"low_ppm2": 150_000,
|
||||||
|
|
@ -135,8 +145,9 @@ def test_radius_median_below_dkp_floor_is_lifted() -> None:
|
||||||
est = _run_estimate(analogs, dkp_raw, radius_floor_factor=0.8)
|
est = _run_estimate(analogs, dkp_raw, radius_floor_factor=0.8)
|
||||||
|
|
||||||
floor_ppm2 = 150_000 * 0.8 # 120_000
|
floor_ppm2 = 150_000 * 0.8 # 120_000
|
||||||
assert est.median_price_per_m2 >= floor_ppm2, (
|
# Ровно floor, а не «не ниже»: медиана коридора (180k) тоже «не ниже».
|
||||||
f"median_ppm2={est.median_price_per_m2} должна быть >= floor={floor_ppm2}"
|
assert est.median_price_per_m2 == floor_ppm2, (
|
||||||
|
f"median_ppm2={est.median_price_per_m2} должна быть поднята ровно до floor={floor_ppm2}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -145,11 +156,7 @@ def test_radius_median_below_dkp_floor_is_lifted() -> None:
|
||||||
|
|
||||||
def test_radius_median_above_dkp_floor_unchanged() -> None:
|
def test_radius_median_above_dkp_floor_unchanged() -> None:
|
||||||
"""Radius median = 200k > dkp_low × factor = 150k × 0.8 = 120k → no-op."""
|
"""Radius median = 200k > dkp_low × factor = 150k × 0.8 = 120k → no-op."""
|
||||||
analogs = [
|
analogs = _six(200_000.0)
|
||||||
_make_listing(price_per_m2=190_000.0),
|
|
||||||
_make_listing(price_per_m2=200_000.0),
|
|
||||||
_make_listing(price_per_m2=210_000.0),
|
|
||||||
]
|
|
||||||
dkp_raw = {
|
dkp_raw = {
|
||||||
"count": 15,
|
"count": 15,
|
||||||
"low_ppm2": 150_000,
|
"low_ppm2": 150_000,
|
||||||
|
|
@ -163,9 +170,9 @@ def test_radius_median_above_dkp_floor_unchanged() -> None:
|
||||||
# Медиана должна остаться в диапазоне аналогов (190-210k), а не подняться к floor.
|
# Медиана должна остаться в диапазоне аналогов (190-210k), а не подняться к floor.
|
||||||
floor_ppm2 = 150_000 * 0.8 # 120_000
|
floor_ppm2 = 150_000 * 0.8 # 120_000
|
||||||
assert est.median_price_per_m2 > floor_ppm2, "median должна быть выше floor (no-op)"
|
assert est.median_price_per_m2 > floor_ppm2, "median должна быть выше floor (no-op)"
|
||||||
# Медиана соответствует аналогам (~200k), а не floor
|
# Медиана — ровно медиана аналогов, а не floor и не медиана коридора (180k).
|
||||||
assert 180_000 <= est.median_price_per_m2 <= 220_000, (
|
assert est.median_price_per_m2 == 200_000, (
|
||||||
f"median_ppm2={est.median_price_per_m2} должна остаться в диапазоне аналогов (no-op)"
|
f"median_ppm2={est.median_price_per_m2} должна остаться медианой аналогов (no-op)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -185,3 +192,63 @@ def test_no_dkp_raw_no_floor() -> None:
|
||||||
assert est.median_price_per_m2 < 100_000, (
|
assert est.median_price_per_m2 < 100_000, (
|
||||||
f"median_ppm2={est.median_price_per_m2} без dkp_raw не должна расти"
|
f"median_ppm2={est.median_price_per_m2} без dkp_raw не должна расти"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── тест 4: коридор ниже порога доверия → floor не поднимает (#3466) ─────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_floor_not_applied_below_clamp_min_n() -> None:
|
||||||
|
"""n = estimate_corridor_clamp_min_n − 1: тот же floor, что в тесте 1, но выключен.
|
||||||
|
|
||||||
|
`DkpCorridor.advisory_only` (#3452) обосновывает себя общим порогом у клампа
|
||||||
|
headline И у radius-floor. Половина про кламп стережётся test_3452_*, эта —
|
||||||
|
здесь: без гейта по count медиана 80k поднялась бы до 120k.
|
||||||
|
"""
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
analogs = _six(80_000.0)
|
||||||
|
dkp_raw = {
|
||||||
|
"count": settings.estimate_corridor_clamp_min_n - 1,
|
||||||
|
"low_ppm2": 150_000,
|
||||||
|
"median_ppm2": 180_000,
|
||||||
|
"high_ppm2": 220_000,
|
||||||
|
"period_months": 12,
|
||||||
|
}
|
||||||
|
est = _run_estimate(analogs, dkp_raw, radius_floor_factor=0.8)
|
||||||
|
|
||||||
|
assert est.median_price_per_m2 < 100_000, (
|
||||||
|
f"median_ppm2={est.median_price_per_m2}: коридор из "
|
||||||
|
f"{dkp_raw['count']} сделок ниже порога не должен поднимать цену до floor=120000"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── тест 5: ровно на пороге floor уже работает (#3466) ──────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_floor_applied_at_exactly_clamp_min_n() -> None:
|
||||||
|
"""n = estimate_corridor_clamp_min_n: advisory_only=False → floor обязан поднять.
|
||||||
|
|
||||||
|
Тест 4 стережёт «ниже порога — нет», тест 1 — «15 сделок — да». Граница между
|
||||||
|
ними (`>=` против `>`) не стереглась: при n == min_n витрина не пишет подпись
|
||||||
|
«справочно», значит коридор обязан войти в цену.
|
||||||
|
"""
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.schemas.trade_in import DkpCorridor
|
||||||
|
|
||||||
|
analogs = _six(80_000.0)
|
||||||
|
dkp_raw = {
|
||||||
|
"count": settings.estimate_corridor_clamp_min_n,
|
||||||
|
"low_ppm2": 150_000,
|
||||||
|
"median_ppm2": 180_000,
|
||||||
|
"high_ppm2": 220_000,
|
||||||
|
"period_months": 12,
|
||||||
|
}
|
||||||
|
# Та же граница с другой стороны: на ней коридор уже не справочный.
|
||||||
|
assert DkpCorridor(**dkp_raw).advisory_only is False
|
||||||
|
|
||||||
|
est = _run_estimate(analogs, dkp_raw, radius_floor_factor=0.8)
|
||||||
|
|
||||||
|
assert est.median_price_per_m2 == 150_000 * 0.8, (
|
||||||
|
f"median_ppm2={est.median_price_per_m2}: коридор из {dkp_raw['count']} сделок "
|
||||||
|
"(ровно порог) должен поднять цену ровно до floor=120000"
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ from fastapi.testclient import TestClient # noqa: E402
|
||||||
from app.services.estimator import ( # noqa: E402
|
from app.services.estimator import ( # noqa: E402
|
||||||
DEFAULT_RADIUS_M,
|
DEFAULT_RADIUS_M,
|
||||||
_compute_confidence,
|
_compute_confidence,
|
||||||
|
analog_tier_from_explanation,
|
||||||
radius_from_relaxations,
|
radius_from_relaxations,
|
||||||
radius_relaxation_label,
|
radius_relaxation_label,
|
||||||
rehydrate_search_radius_m,
|
rehydrate_search_radius_m,
|
||||||
|
|
@ -193,6 +194,8 @@ def _light_estimator_stub():
|
||||||
_source_counts=lambda *a, **k: {},
|
_source_counts=lambda *a, **k: {},
|
||||||
_canonical_sources=lambda *a, **k: ["cian"],
|
_canonical_sources=lambda *a, **k: ["cian"],
|
||||||
rehydrate_search_radius_m=rehydrate_search_radius_m,
|
rehydrate_search_radius_m=rehydrate_search_radius_m,
|
||||||
|
# #3466: радиусная строка («Найдено 42 аналогов») → None, разбор настоящий.
|
||||||
|
analog_tier_from_explanation=analog_tier_from_explanation,
|
||||||
)
|
)
|
||||||
sys.modules["app.services.estimator"] = stub # type: ignore[assignment]
|
sys.modules["app.services.estimator"] = stub # type: ignore[assignment]
|
||||||
yield stub
|
yield stub
|
||||||
|
|
|
||||||
|
|
@ -207,7 +207,11 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
|
||||||
// с числами обязан стоять размер выборки, иначе коридор читается как опора
|
// с числами обязан стоять размер выборки, иначе коридор читается как опора
|
||||||
// расчёта. n_analogs === 0 → headline посчитан по этим же сделкам, а не по
|
// расчёта. n_analogs === 0 → headline посчитан по этим же сделкам, а не по
|
||||||
// объявлениям (та же причина, что у analogsCaption ниже) → подпись другая.
|
// объявлениям (та же причина, что у analogsCaption ниже) → подпись другая.
|
||||||
const dkpAdvisory = corridorAdvisoryNote(dkp, estimate.n_analogs === 0);
|
const dkpAdvisory = corridorAdvisoryNote(
|
||||||
|
dkp,
|
||||||
|
estimate.n_analogs === 0,
|
||||||
|
estimate.analog_tier,
|
||||||
|
);
|
||||||
|
|
||||||
// Progressive enrichment state
|
// Progressive enrichment state
|
||||||
const needsHouseType = estimate.house_type === null;
|
const needsHouseType = estimate.house_type === null;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
/**
|
||||||
|
* #3466 — подпись «цена по дому, коридор её не ограничивает» на корневой /trade-in.
|
||||||
|
*
|
||||||
|
* corridorAdvisoryNote проверена сама по себе и через v2 mapResultPanel
|
||||||
|
* (v2/__tests__/corridorAdvisoryNote.test.ts). Вызов в v1 HeroSummary — отдельный:
|
||||||
|
* без аргумента estimate.analog_tier функция молча вернёт null при n ≥ порога,
|
||||||
|
* и vitest этого не видел (ревью #3554, мутация F2). Здесь проверяется то, что
|
||||||
|
* реально стоит на экране hero.
|
||||||
|
*/
|
||||||
|
import { render } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { HeroSummary } from "../HeroSummary";
|
||||||
|
import { FIXTURE_ESTIMATE, FIXTURE_INPUT } from "@/app/ui-preview/estimate/fixture";
|
||||||
|
import type { AggregatedEstimate, AnalogTier } from "@/types/trade-in";
|
||||||
|
|
||||||
|
// Как в thinMarketBadge.test.tsx: бренд и блок прозрачности ходят в сеть.
|
||||||
|
vi.mock("@/lib/useBrand", () => ({
|
||||||
|
useActiveBrandSlug: () => null,
|
||||||
|
useBrand: () => ({ data: null }),
|
||||||
|
}));
|
||||||
|
vi.mock("../HeroTransparency", () => ({ HeroTransparency: () => null }));
|
||||||
|
|
||||||
|
const SAME_BUILDING =
|
||||||
|
"справочно: цена посчитана по аналогам в этом же доме — коридор её не ограничивает";
|
||||||
|
|
||||||
|
function heroText(analog_tier: AnalogTier | null): string {
|
||||||
|
const estimate: AggregatedEstimate = {
|
||||||
|
...FIXTURE_ESTIMATE,
|
||||||
|
analog_tier,
|
||||||
|
// n=20 ≥ порога: advisory_only=false, подпись может дать только тир.
|
||||||
|
dkp_corridor: { ...FIXTURE_ESTIMATE.dkp_corridor!, count: 20, advisory_only: false },
|
||||||
|
};
|
||||||
|
const { container } = render(
|
||||||
|
<HeroSummary estimate={estimate} input={FIXTURE_INPUT} onResubmit={() => {}} />,
|
||||||
|
);
|
||||||
|
return container.textContent ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("#3466 подпись коридора в v1 HeroSummary", () => {
|
||||||
|
it("same_building + n=20 → подпись про дом стоит в hero", () => {
|
||||||
|
const text = heroText("same_building");
|
||||||
|
// Коридор отрисован (20 ДКП) и рядом — подпись, а не просто число.
|
||||||
|
expect(text).toContain("20 сд.");
|
||||||
|
expect(text).toContain(SAME_BUILDING);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("micro_radius + n=20 → подписи нет: коридор цену держит", () => {
|
||||||
|
const text = heroText("micro_radius");
|
||||||
|
expect(text).toContain("20 сд.");
|
||||||
|
expect(text).not.toContain("коридор её не ограничивает");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
// #3452 / #3466 — подпись под коридором ДКП: когда он цену НЕ держит, экран
|
||||||
|
// обязан это сказать, а молчание — значить только «держит».
|
||||||
|
//
|
||||||
|
// Две разные причины выключенной страховки:
|
||||||
|
// * сделок меньше порога (advisory_only, бэкенд считает порог сам) — говорим
|
||||||
|
// про РАЗМЕР выборки: гейт Tier C держит цену и по пяти сделкам;
|
||||||
|
// * цена посчитана по аналогам того же дома (analog_tier "same_building") —
|
||||||
|
// кламп этот тир не трогает при любом count, ревьюер #3462 воспроизвёл
|
||||||
|
// n=20, headline 202 100 против потолка 140 000 и пустую подпись.
|
||||||
|
//
|
||||||
|
// Образец — dealsAsOf.test.ts: проверяется и сама функция, и то, что подпись
|
||||||
|
// доезжает до плитки v2 через mapResultPanel.
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { mapResultPanel } from "../mappers";
|
||||||
|
import { corridorAdvisoryNote } from "@/lib/rosreestr";
|
||||||
|
import { FIXTURE_ESTIMATE } from "@/app/ui-preview/estimate/fixture";
|
||||||
|
import type { AggregatedEstimate, DkpCorridor } from "@/types/trade-in";
|
||||||
|
|
||||||
|
const SAME_BUILDING =
|
||||||
|
"справочно: цена посчитана по аналогам в этом же доме — коридор её не ограничивает";
|
||||||
|
|
||||||
|
function corridor(count: number, advisory_only?: boolean): DkpCorridor {
|
||||||
|
const c: DkpCorridor = {
|
||||||
|
count,
|
||||||
|
low_ppm2: 80_000,
|
||||||
|
median_ppm2: 90_000,
|
||||||
|
high_ppm2: 100_000,
|
||||||
|
period_months: 12,
|
||||||
|
latest_deal_date: "2026-01-01",
|
||||||
|
};
|
||||||
|
if (advisory_only !== undefined) c.advisory_only = advisory_only;
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("corridorAdvisoryNote", () => {
|
||||||
|
it("мало сделок — подпись про размер выборки", () => {
|
||||||
|
expect(corridorAdvisoryNote(corridor(5, true), false)).toBe(
|
||||||
|
"справочно: сделок мало (5) — коридор ориентировочный",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("мало сделок и цена из них же — подпись прямее", () => {
|
||||||
|
expect(corridorAdvisoryNote(corridor(5, true), true)).toBe(
|
||||||
|
"оценка построена на этих сделках — их всего 5",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("сделок достаточно, радиусный путь — молчим: коридор цену держит", () => {
|
||||||
|
expect(corridorAdvisoryNote(corridor(20, false), false, "district")).toBeNull();
|
||||||
|
expect(corridorAdvisoryNote(corridor(20, false), false, "micro_radius")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("#3466: сделок много, но цена по дому — подпись есть", () => {
|
||||||
|
// Красный на origin/main: advisory_only=false → null, при том что кламп
|
||||||
|
// Tier A exempt и коридор цену не тронул.
|
||||||
|
expect(corridorAdvisoryNote(corridor(20, false), false, "same_building")).toBe(
|
||||||
|
SAME_BUILDING,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("#3466: дом и мало сделок — говорим про дом, это причина сильнее", () => {
|
||||||
|
expect(corridorAdvisoryNote(corridor(5, true), false, "same_building")).toBe(
|
||||||
|
SAME_BUILDING,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("оценка старше деплоя (полей нет) — молчим, как раньше", () => {
|
||||||
|
expect(corridorAdvisoryNote(corridor(20), false)).toBeNull();
|
||||||
|
expect(corridorAdvisoryNote(corridor(20), false, null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("коридора нет — подписи нет и у дома", () => {
|
||||||
|
expect(corridorAdvisoryNote(null, false, "same_building")).toBeNull();
|
||||||
|
expect(corridorAdvisoryNote(undefined, false)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Подпись плитки «ДКП · РОСРЕЕСТР» — ветка коридора (street-deals нет). */
|
||||||
|
function dkpNote(over: Partial<AggregatedEstimate>): string | undefined {
|
||||||
|
const e: AggregatedEstimate = {
|
||||||
|
...FIXTURE_ESTIMATE,
|
||||||
|
actual_deals: [],
|
||||||
|
area_m2: 60,
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
const card = mapResultPanel(e, null).cards.find((c) =>
|
||||||
|
c.title.join(" ").includes("ДКП"),
|
||||||
|
);
|
||||||
|
if (!card) throw new Error("плитка ДКП пропала из панели результата");
|
||||||
|
return card.note;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("#3466 подпись доезжает до плитки v2", () => {
|
||||||
|
it("same_building + n=20 → подпись про дом", () => {
|
||||||
|
expect(
|
||||||
|
dkpNote({ dkp_corridor: corridor(20, false), analog_tier: "same_building" }),
|
||||||
|
).toBe(SAME_BUILDING);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("district + n=20 → без подписи", () => {
|
||||||
|
expect(
|
||||||
|
dkpNote({ dkp_corridor: corridor(20, false), analog_tier: "district" }),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("district + n=9 → подпись про выборку", () => {
|
||||||
|
expect(
|
||||||
|
dkpNote({ dkp_corridor: corridor(9, true), analog_tier: "district" }),
|
||||||
|
).toBe("справочно: сделок мало (9) — коридор ориентировочный");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -696,7 +696,7 @@ function resolveDealTier(
|
||||||
asOf: dealsAsOfLabel(c.latest_deal_date, "quarter"),
|
asOf: dealsAsOfLabel(c.latest_deal_date, "quarter"),
|
||||||
// n_analogs === 0 → headline и есть медиана этого коридора (dealsOnlyPrice
|
// n_analogs === 0 → headline и есть медиана этого коридора (dealsOnlyPrice
|
||||||
// ниже по файлу разбирает тот же случай) — подпись тогда прямее.
|
// ниже по файлу разбирает тот же случай) — подпись тогда прямее.
|
||||||
advisoryNote: corridorAdvisoryNote(c, e.n_analogs === 0),
|
advisoryNote: corridorAdvisoryNote(c, e.n_analogs === 0, e.analog_tier),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (e.actual_deals.length > 0) {
|
if (e.actual_deals.length > 0) {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { DkpCorridor } from "@/types/trade-in";
|
import type { AnalogTier, 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/";
|
||||||
|
|
@ -53,13 +53,23 @@ export function dealsAsOfLabel(
|
||||||
* min_n — коридор из пяти сделок там способен уронить headline на треть.
|
* min_n — коридор из пяти сделок там способен уронить headline на треть.
|
||||||
* Утверждение про размер выборки истинно во всех достижимых состояниях.
|
* Утверждение про размер выборки истинно во всех достижимых состояниях.
|
||||||
*
|
*
|
||||||
|
* #3466: сделок может быть и много, а коридор цену всё равно не держит — когда
|
||||||
|
* она посчитана по аналогам того же дома (analog_tier "same_building"): кламп
|
||||||
|
* этот тир не трогает, radius-floor тоже. Здесь утверждение про алгоритм уже
|
||||||
|
* истинно при любом count (гейт Tier C к этому тиру не относится) — его и говорим.
|
||||||
|
*
|
||||||
* null (флага нет — оценка старше деплоя, либо сделок хватает) → подписи нет.
|
* null (флага нет — оценка старше деплоя, либо сделок хватает) → подписи нет.
|
||||||
*/
|
*/
|
||||||
export function corridorAdvisoryNote(
|
export function corridorAdvisoryNote(
|
||||||
dkp: DkpCorridor | null | undefined,
|
dkp: DkpCorridor | null | undefined,
|
||||||
headlineFromDeals: boolean,
|
headlineFromDeals: boolean,
|
||||||
|
analogTier?: AnalogTier | null,
|
||||||
): string | null {
|
): string | null {
|
||||||
if (dkp?.advisory_only !== true) return null;
|
if (!dkp) return null;
|
||||||
|
if (analogTier === "same_building") {
|
||||||
|
return "справочно: цена посчитана по аналогам в этом же доме — коридор её не ограничивает";
|
||||||
|
}
|
||||||
|
if (dkp.advisory_only !== true) return null;
|
||||||
// headline ПОСТРОЕН из этого же коридора (deals-fallback, n_analogs === 0 —
|
// headline ПОСТРОЕН из этого же коридора (deals-fallback, n_analogs === 0 —
|
||||||
// похожих объявлений рядом не нашлось): коридор тут не сбоку от цены, он и
|
// похожих объявлений рядом не нашлось): коридор тут не сбоку от цены, он и
|
||||||
// есть цена, и клиент обязан знать, на скольких сделках она стоит.
|
// есть цена, и клиент обязан знать, на скольких сделках она стоит.
|
||||||
|
|
|
||||||
|
|
@ -170,7 +170,8 @@ export interface DkpCorridor {
|
||||||
// #3452: true = сделок меньше порога доверия (бэкенд считает порог сам,
|
// #3452: true = сделок меньше порога доверия (бэкенд считает порог сам,
|
||||||
// estimate_corridor_clamp_min_n) → коридор показан, но цену по нему НЕ
|
// estimate_corridor_clamp_min_n) → коридор показан, но цену по нему НЕ
|
||||||
// поправляли: ни кламп сверху, ни floor снизу. Заголовок «advisory» у всего
|
// поправляли: ни кламп сверху, ни floor снизу. Заголовок «advisory» у всего
|
||||||
// коридора не про это — при count >= порога он в цену как раз идёт.
|
// коридора не про это — при count >= порога он в цену как раз идёт, кроме
|
||||||
|
// analog_tier === "same_building" (#3466: якорь того же дома, кламп exempt).
|
||||||
// optional: оценки, посчитанные до деплоя бэкенда, поля не содержат.
|
// optional: оценки, посчитанные до деплоя бэкенда, поля не содержат.
|
||||||
advisory_only?: boolean;
|
advisory_only?: boolean;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue