diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py
index 63e14e11..893da182 100644
--- a/tradein-mvp/backend/app/services/estimator.py
+++ b/tradein-mvp/backend/app/services/estimator.py
@@ -487,6 +487,32 @@ def _repair_coefficient(repair_state: str | None) -> float:
return _REPAIR_COEF.get(repair_state, 1.0)
+# Обратный `_IMV_REPAIR_MAP`: вокабуляр Avito → наш repair_state.
+_IMV_REPAIR_MAP_REV: dict[str, str] = {v: k for k, v in _IMV_REPAIR_MAP.items() if k and v}
+
+
+def _anchor_repair_factor(anchor_renovation: str | None, target_repair: str | None) -> float:
+ """#2677: множитель, приводящий домовой IMV-якорь к базису ремонта target'а.
+
+ Домовая строка `house_imv_evaluations` запрошена у Avito с ОДНИМ ремонтом
+ (`renovation_type`, до #2675 — литерал 'cosmetic' у всех 2685 строк), а медиана
+ к моменту blend'а уже домножена на `_repair_coefficient(target_repair)`. Сравнивать
+ и смешивать их напрямую — значит класть два разных базиса на одну шкалу: у клиента
+ с «требует ремонта» медиана −6%, якорь нет, порог `median×1.15` фактически падает до
+ ×1.081, а сработавший blend возвращает половину этих −6% обратно вверх.
+
+ Это НЕ заявка на правильность самих коэффициентов (`_REPAIR_COEF` — рыночная
+ эвристика, см. предупреждение выше): множитель берётся ровно тот, что код УЖЕ
+ применил к медиане, — чинится расхождение базисов, а не калибровка.
+
+ Неизвестный/незнакомый ремонт с любой стороны → 1.0 (no-op, прежнее поведение).
+ """
+ base = _repair_coefficient(_IMV_REPAIR_MAP_REV.get(anchor_renovation or ""))
+ if base <= 0:
+ return 1.0
+ return _repair_coefficient(target_repair) / base
+
+
# ── Asking→sold correction ratio lookup (#648 Stage 3) ──────────────────────
# Таблица asking_to_sold_ratios (migration 080) хранит per-rooms коэффициент
# ratio = median(SOLD ppm²) / median(ASKING ppm²) (~0.72–0.93). Estimator
@@ -1109,14 +1135,39 @@ def _fetch_house_imv_anchor(
"""Достаёт РЕАЛЬНУЮ Avito IMV-оценку target-дома из `house_imv_evaluations`.
В отличие от `avito_imv_evaluations` (keyed estimate_id — пустая, on-demand
- скрейп), `house_imv_evaluations` популирована (~2951 домов, fresh) и keyed по
- house_id. Резолвим строку: WHERE house_id = target_house_id, предпочитаем
- запись с ближайшими rooms+area (минимизируем |Δrooms|*10 + |Δarea%|), иначе
- самую свежую (fetched_at DESC). Best-effort: None при любой ошибке / отсутствии
- house_id / пустой таблице — estimator продолжает на гео-tier'ах (no regress).
+ скрейп), `house_imv_evaluations` keyed по house_id. Резолвим строку:
+ WHERE house_id = target_house_id + band-guard по rooms/area. Best-effort:
+ None при любой ошибке / отсутствии house_id / пустой таблице — estimator
+ продолжает на гео-tier'ах (no regress).
+
+ СОСТОЯНИЕ ТАБЛИЦЫ, замер 2026-08-12 (прежний текст обещал «популирована
+ ~2951 домов, fresh» — неправда по обоим пунктам, #2674):
+ 2680 строк, одна на дом (house_imv_eval_house_uniq_idx UNIQUE(house_id) —
+ поэтому ORDER BY ниже выбирает из ≤1 кандидата, а не ранжирует). 2633 из
+ них (98.2%) старше 40 суток, 2366 — один прогон 2026-05-24; свежих, после
+ фикса параметров 2026-08-06, всего 47.
+ Дореформенные 2633 спрошены у Авито с ЛИТЕРАЛОМ renovation_type='cosmetic'
+ и с дефолтом house_type='panel' при нераспознанном типе: мода ремонта по
+ объявлениям того же дома совпала лишь у 972 (36.9%), разошлась у 1217
+ (46.2%), у 444 моды нет; тип дома разошёлся у 74 из 2633 (46 раз ушла
+ «панель» вместо «монолита»).
+
+ ЧТО С ЭТИМ РЕШЕНО (замер на проде, 1081 персистированная оценка):
+ · Гейт «не брать дореформенные строки» — ОТКЛОНЁН: band-совместимая строка
+ находится у 224 оценок, и все 224 дореформенные. Гейт снял бы якорь у
+ 224 из 224, то есть просто выключил бы механизм.
+ · Массовая переоценка — не чинит корень и упирается в очередь: 1628 из
+ 2633 домов уже в imv_status='ok', а батч берёт только pending/
+ transient_error, где сейчас 7144 дома при темпе ~25/прогон.
+ · Корень — не свежесть, а разные базисы ремонта; починено в #2677
+ (`_anchor_repair_factor`), см. ниже.
Returns dict {recommended_price, lower_price, higher_price, market_count,
- rooms, area_m2} или None.
+ rooms, area_m2, renovation_type} или None.
+
+ #2677: renovation_type — ремонт, С КОТОРЫМ строка была запрошена у Avito.
+ Без него якорь нельзя привести к базису ремонта оцениваемой квартиры
+ (см. `_anchor_repair_factor`), и правка молча выродилась бы в no-op.
"""
if target_house_id is None:
return None
@@ -1126,7 +1177,7 @@ def _fetch_house_imv_anchor(
text(
"""
SELECT recommended_price, lower_price, higher_price,
- market_count, rooms, area_m2
+ market_count, rooms, area_m2, renovation_type
FROM house_imv_evaluations
WHERE house_id = CAST(:hid AS bigint)
AND recommended_price > 0
@@ -3065,6 +3116,7 @@ def _price_from_inputs(
anchor_total: int | None = None
anchor_higher: int | None = None
anchor_label: str | None = None
+ anchor_repair_note: str = ""
if imv_anchor is not None and imv_anchor.get("recommended_price"):
anchor_total = int(imv_anchor["recommended_price"])
anchor_higher = (
@@ -3083,6 +3135,22 @@ def _price_from_inputs(
_imv_mc is not None and _imv_mc < settings.avito_imv_thin_market_threshold
),
)
+ # #2677: карточка выше показывает СЫРУЮ оценку Avito (это чужое число, мы его
+ # не правим), а в blend уходит якорь, приведённый к базису ремонта target'а —
+ # тому же, в котором median_price уже посчитан выше (repair_coef).
+ _rep_k = _anchor_repair_factor(imv_anchor.get("renovation_type"), repair_state)
+ if _rep_k != 1.0:
+ anchor_total = round(anchor_total * _rep_k)
+ anchor_higher = round(anchor_higher * _rep_k) if anchor_higher else None
+ anchor_repair_note = ", приведённой к состоянию ремонта квартиры"
+ logger.info(
+ "imv_anchor repair-basis #2677: renovation=%s target_repair=%s k=%.3f"
+ " → anchor %d",
+ imv_anchor.get("renovation_type"),
+ repair_state,
+ _rep_k,
+ anchor_total,
+ )
elif imv_eval is not None and imv_eval.recommended_price:
anchor_total = int(imv_eval.recommended_price)
anchor_higher = int(imv_eval.higher_price) if imv_eval.higher_price else None
@@ -3132,8 +3200,8 @@ def _price_from_inputs(
median_price = new_median
median_ppm2 = new_ppm2
explanation = (explanation or "") + (
- f" Оценка скорректирована по {anchor_label} "
- f"({anchor_used / 1_000_000:.1f} млн ₽)."
+ f" Оценка скорректирована по {anchor_label}"
+ f"{anchor_repair_note} ({anchor_used / 1_000_000:.1f} млн ₽)."
)
sources_used_pre = sorted(set(sources_used_pre) | {"avito_imv"})
# Диапазон расширяем даже если медиану не двигали.
diff --git a/tradein-mvp/backend/data/sql/259_data_quality_drop_pct_cadastr.sql b/tradein-mvp/backend/data/sql/259_data_quality_drop_pct_cadastr.sql
new file mode 100644
index 00000000..16312e53
--- /dev/null
+++ b/tradein-mvp/backend/data/sql/259_data_quality_drop_pct_cadastr.sql
@@ -0,0 +1,145 @@
+-- 259_data_quality_drop_pct_cadastr.sql
+-- Purpose (#2674, третий показатель того же класса): убрать v_data_quality.pct_cadastr.
+--
+-- 214 убрала outliers_flagged, 216 — price_disagreements_count по одному доводу: ноль,
+-- гарантированный устройством системы, читается как «проверили — чисто», хотя честно он
+-- означает «мы это не считаем». pct_cadastr — третий такой же, поэтому и действие то же:
+-- не переключать источник, а снять показатель.
+--
+-- ── ЧИСЛА С ПРОДА (2026-08-13, точный count) ────────────────────────────────
+-- v_data_quality.pct_cadastr .................... 0.000000000000000000000000
+-- знаменатель витрины (listings_active) ......... 45 198 (в listings всего 99 304)
+-- listings.cadastral_number IS NOT NULL ......... 0 из 99 304 (и 0 из 45 198 активных)
+-- deals.cadastral_number ........................ 0 из 96 974
+-- houses.cadastral_number (DaData) .............. 2 648 из 9 468 ← ДРУГОЙ объект
+-- listings.building_cadastral_number ............ 30 970 из 99 304 ← ДРУГОЙ объект
+--
+-- ── ЭТО НЕ ДЕФЕКТ ИЗМЕРИТЕЛЯ (контроль на здоровом образце в тех же данных) ──
+-- Тот же CTE active_listings и тот же шаблон `count(*) WHERE
IS NOT NULL * 100.0
+-- / NULLIF(count(*), 0)` в соседних строках витрины даёт 95.61% (pct_geocoded), 39.82%
+-- (pct_description), 65.10% (pct_year_built). Ровно 0% — про колонку, а не про арифметику.
+--
+-- ── ПОЧЕМУ НОЛЬ СТРУКТУРНЫЙ ─────────────────────────────────────────────────
+-- listings.cadastral_number — кадастр КВАРТИРЫ. Единственное место в коде, которое его
+-- вообще читает, — providers/cian/serp.py:886 (`offer.get("cadastralNumber")`); в парсерах
+-- avito/yandex/domclick/n1 слов cadastr/kadastr нет ни разу, то есть для ЧЕТЫРЁХ площадок
+-- из пяти ноль гарантирован НАШИМ кодом и о предметной области не говорит ничего. Пусто
+-- при этом везде, где мы этот номер храним (три таблицы выше) — то же уже записано в
+-- app/services/matching/houses.py: «площадки кадастр не отдают».
+--
+-- ── ПОЧЕМУ НЕЛЬЗЯ «ПОЧИНИТЬ ОДНОЙ СТРОКОЙ», ПЕРЕКЛЮЧИВ НА СОСЕДНЮЮ КОЛОНКУ ──
+-- Напрашивается считать по listings.building_cadastral_number (31.19% всего, 29.47% у
+-- активных). Под подписью «доля объявлений с кадастром» это НОВАЯ ложь вместо старой:
+-- * это кадастр ЗДАНИЯ, и в listings у него РОВНО ОДИН писатель — наш ночной KNN ≤50 м
+-- по локальному зеркалу ЕГРН (tasks/cadastral_geo_match.py:161; проверено `git grep`
+-- по origin/main: других INSERT/UPDATE этой колонки нет). Он не «тот же кадастр из
+-- другого места», а наша производная;
+-- * #2674 замерил ключ как неинъективный (656 из 3 260 значений накрывают >1 здание ГАР,
+-- 20.1%; 751 из 2 864 зданий получают >1 значение, 26.2%) и прямо запретил считать его
+-- идентичностью здания;
+-- * разброс по площадкам среди активных геокодированных (cian 33.8%, yandex 20.3%,
+-- avito 42.0%, domclick 49.7%) — про точность НАШИХ координат и охват зеркала по ЕКБ,
+-- а не про качество объявления.
+-- Переименовать подпись мало: честное имя было бы «доля объявлений, которым ночной KNN
+-- подобрал здание в 50 м» — это другой показатель, и заводить его надо отдельно и
+-- осознанно, а не под видом починки этого. Авторитетный кадастр здания у нас есть —
+-- houses.cadastral_number из DaData (2 648/9 468 домов), но он про ДОМА, а витрина считает
+-- ОБЪЯВЛЕНИЯ; подставить его в эту строку — снова назвать одно другим.
+--
+-- ── ЦЕНА ПРАВКИ ────────────────────────────────────────────────────────────
+-- Читателей у витрины в коде нет (grep по /app/app в живом backend-контейнере пуст;
+-- /api/v1/admin/scraper/data-quality считает свои метрики сам и кадастр не показывает
+-- вовсе) — это ручной psql-снимок. Зависимых объектов у view тоже нет (pg_depend по
+-- 'v_data_quality'::regclass, прод 13.08: 0 строк), поэтому CASCADE не нужен и не должен
+-- появиться: в этом продукте `DROP ... CASCADE` уже терял гранты FDW-пользователю (C3).
+--
+-- ── ПОРЯДОК И БЛОКИРОВКА ───────────────────────────────────────────────────
+-- CREATE OR REPLACE VIEW колонку УДАЛИТЬ не может → DROP VIEW → CREATE VIEW (тот же
+-- порядок, что 214/216). DROP VIEW берёт ACCESS EXCLUSIVE, поэтому `SET LOCAL
+-- lock_timeout` (см. scripts/check-migration-lock-timeout.py). В отличие от 222, которая
+-- обошлась CREATE OR REPLACE, здесь COMMENT ON VIEW надо выставить ЗАНОВО: DROP уносит
+-- комментарий вместе с объектом.
+--
+-- Тело SELECT скопировано из 222_db_audit_cleanup.sql (последний DDL; сверено с живым
+-- pg_get_viewdef на проде 13.08 — совпадает) минус строка pct_cadastr. Из CTE убран
+-- ставший ненужным cadastral_number: 222 завела явный список колонок ровно затем, чтобы
+-- view не держал column-level зависимость на то, чего не показывает.
+--
+-- Dependencies: 216_dead_code_sweep.sql (текст COMMENT ON VIEW), 222_db_audit_cleanup.sql
+-- (последний DDL v_data_quality).
+-- Apply after: 258_houses_imv_transient_attempts.sql
+-- Идемпотентно: DROP VIEW IF EXISTS + CREATE VIEW + COMMENT — повторный прогон даёт тот
+-- же результат.
+
+BEGIN;
+
+-- Ждём лок не дольше 5 s: сам DROP мгновенный, но ждущий ACCESS EXCLUSIVE встаёт в
+-- очередь ПЕРЕД новыми запросами (#2791/#2792).
+SET LOCAL lock_timeout = '5s';
+
+DROP VIEW IF EXISTS v_data_quality;
+
+-- DDL идентичен 222, минус строка pct_cadastr и минус cadastral_number в CTE.
+CREATE VIEW v_data_quality AS
+WITH active_listings AS (
+ SELECT id, lat, description, house_id_fk, is_active
+ FROM listings
+ WHERE is_active = true
+)
+SELECT
+ (SELECT count(*) FROM houses) AS houses_total,
+ (SELECT count(*) FROM houses h
+ WHERE EXISTS (SELECT 1 FROM house_sources hs WHERE hs.house_id = h.id)) AS houses_with_source,
+ (SELECT count(*) FROM houses h
+ WHERE EXISTS (SELECT 1 FROM house_sources hs
+ WHERE hs.house_id = h.id AND hs.ext_source = 'avito')) AS houses_with_avito,
+ (SELECT count(*) FROM houses h
+ WHERE EXISTS (SELECT 1 FROM house_sources hs
+ WHERE hs.house_id = h.id AND hs.ext_source LIKE 'cian%')) AS houses_with_cian,
+ (SELECT count(*) FROM houses h
+ WHERE EXISTS (SELECT 1 FROM house_sources hs
+ WHERE hs.house_id = h.id AND hs.ext_source = 'yandex')) AS houses_with_yandex,
+ (SELECT count(*) FROM (
+ SELECT house_id FROM house_sources GROUP BY house_id HAVING count(*) >= 2
+ ) sub) AS houses_2plus_sources,
+ (SELECT count(*) FROM (
+ SELECT house_id FROM house_sources GROUP BY house_id HAVING count(*) >= 3
+ ) sub) AS houses_3plus_sources,
+ (SELECT count(*) FROM active_listings) AS listings_active,
+ (SELECT count(*) FROM (
+ SELECT listing_id FROM listing_sources
+ WHERE listing_id IN (SELECT id FROM active_listings)
+ GROUP BY listing_id HAVING count(*) >= 2
+ ) sub) AS listings_dedup_2sources,
+ (SELECT count(*) FROM active_listings WHERE lat IS NOT NULL) * 100.0
+ / NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_geocoded,
+ (SELECT count(*) FROM active_listings WHERE description IS NOT NULL) * 100.0
+ / NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_description,
+ (SELECT count(*) FROM active_listings l
+ JOIN houses h ON h.id = l.house_id_fk
+ WHERE h.year_built IS NOT NULL) * 100.0
+ / NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_year_built,
+ NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'avito') AS avito_last_scrape_ago,
+ NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'cian') AS cian_last_scrape_ago,
+ NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'yandex') AS yandex_last_scrape_ago;
+
+-- Текст 216 + абзац про pct_cadastr. Выставляем заново, потому что DROP VIEW выше унёс
+-- прежний комментарий вместе с объектом.
+COMMENT ON VIEW v_data_quality IS
+ 'KPI-снимок для РУЧНЫХ psql-запросов. Читателей в коде нет (проверено #2674): '
+ '/api/v1/admin/scraper/data-quality считает свои метрики сам и этот view не трогает. '
+ '#2674: price_disagreements_count убран — у всех 89 699 объявлений ровно один '
+ 'источник, поэтому показатель структурно не мог быть ненулевым и ноль читался как '
+ '«расхождений нет» вместо «мы не сравниваем». listings_dedup_2sources оставлен '
+ 'намеренно: он ту же пустоту называет своим именем («объявлений с 2+ источниками»), '
+ 'ноль в нём — честный ответ, а не мнимое благополучие. '
+ '#2674 (мигр. 259): pct_cadastr убран по тому же доводу — считал '
+ 'listings.cadastral_number (кадастр КВАРТИРЫ), а его не отдаёт ни одна площадка: '
+ '0 из 99 304 объявлений, 0 из 96 974 deals, единственный читающий его парсер — '
+ 'cian/serp.py. Показатель НЕ переведён на listings.building_cadastral_number: та '
+ 'колонка — кадастр ЗДАНИЯ и на 100% производная нашего ночного KNN ≤50 м '
+ '(tasks/cadastral_geo_match.py), неинъективного как ключ здания (#2674: 20.1% '
+ 'значений накрывают >1 здание ГАР); под подписью «доля объявлений с кадастром» она '
+ 'мерила бы покрытие нашего геокодера, а не качество объявлений.';
+
+COMMIT;
diff --git a/tradein-mvp/backend/data/sql/_manifest_applied.txt b/tradein-mvp/backend/data/sql/_manifest_applied.txt
index 119f1fb0..61f36e02 100644
--- a/tradein-mvp/backend/data/sql/_manifest_applied.txt
+++ b/tradein-mvp/backend/data/sql/_manifest_applied.txt
@@ -247,3 +247,4 @@
254_listings_backfill_avito_rating_glued_address.sql
257_listings_backfill_yandex_source_url.sql
258_houses_imv_transient_attempts.sql
+259_data_quality_drop_pct_cadastr.sql
diff --git a/tradein-mvp/backend/tests/test_2674_dead_admin_metrics.py b/tradein-mvp/backend/tests/test_2674_dead_admin_metrics.py
index ced39ac9..37d2df85 100644
--- a/tradein-mvp/backend/tests/test_2674_dead_admin_metrics.py
+++ b/tradein-mvp/backend/tests/test_2674_dead_admin_metrics.py
@@ -275,25 +275,62 @@ def test_migration_drops_every_dead_column() -> None:
assert "DROP COLUMN IF EXISTS is_outlier" in sql
+_VIEW_MARKER = re.compile(r"CREATE\s+(?:OR\s+REPLACE\s+)?VIEW\s+v_data_quality\b")
+
+
+def _latest_v_data_quality() -> tuple[str, str]:
+ """(текст последней миграции, создающей v_data_quality; тело её SELECT).
+
+ Ищем обе формы DDL (`CREATE VIEW` и `CREATE OR REPLACE VIEW`): миграция с парой
+ DROP+CREATE иначе оказалась бы невидимой, и тест продолжил бы проверять старую
+ миграцию, пока показатель уже вернулся в прод. Порядок = лексикографический:
+ деплой применяет файлы отсортированными, последний по имени — последний в проде.
+ """
+ creators = sorted(
+ p for p in _SQL_DIR.glob("*.sql") if _VIEW_MARKER.search(p.read_text("utf-8"))
+ )
+ assert creators, "не найдено ни одной миграции, создающей v_data_quality"
+ sql = creators[-1].read_text(encoding="utf-8")
+ hit = _VIEW_MARKER.search(sql)
+ assert hit is not None
+ return sql, sql[hit.end() :].split(";")[0]
+
+
def test_latest_v_data_quality_no_longer_reports_outliers() -> None:
- """Действующее определение v_data_quality (последняя миграция, которая его
- создаёт) не упоминает is_outlier.
+ """Действующее определение v_data_quality не упоминает is_outlier.
Red на origin/main: там последним был 095_dead_schema.sql со строкой
`(SELECT count(*) FROM listings WHERE is_outlier = true) AS outliers_flagged`
— показатель, который не мог быть ненулевым, потому что колонку не писал никто.
-
- Ищем обе формы DDL (`CREATE VIEW` и `CREATE OR REPLACE VIEW`): миграция с парой
- DROP+CREATE иначе оказалась бы невидимой, и тест продолжил бы проверять эту
- миграцию, пока показатель уже вернулся в прод. Порядок = лексикографический:
- деплой применяет файлы отсортированными, последний по имени — последний в проде.
"""
- marker = re.compile(r"CREATE\s+(?:OR\s+REPLACE\s+)?VIEW\s+v_data_quality\b")
- creators = sorted(p for p in _SQL_DIR.glob("*.sql") if marker.search(p.read_text("utf-8")))
- assert creators, "не найдено ни одной миграции, создающей v_data_quality"
- latest = creators[-1].read_text(encoding="utf-8")
- hit = marker.search(latest)
- assert hit is not None
- body = latest[hit.end() :].split(";")[0]
+ _, body = _latest_v_data_quality()
assert "outliers_flagged" not in body
assert "is_outlier" not in body
+
+
+def test_latest_v_data_quality_no_longer_reports_flat_cadastre() -> None:
+ """Тот же класс, третий случай: pct_cadastr (мигр. 259).
+
+ Считался по listings.cadastral_number — кадастру КВАРТИРЫ, которого не отдаёт ни
+ одна площадка (прод 13.08: 0 из 99 304 объявлений, 0 из 96 974 deals), поэтому
+ показатель не мог быть ненулевым, а «0.000000» рядом с pct_geocoded 95.61%
+ читался как измеренное качество данных.
+
+ Red на origin/main: последний DDL там — 222_db_audit_cleanup.sql, в нём строка
+ `... WHERE cadastral_number IS NOT NULL ... AS pct_cadastr` на месте.
+
+ Замена источника на listings.building_cadastral_number — НЕ починка: та колонка
+ про ЗДАНИЕ и целиком производная нашего ночного KNN ≤50 м, который #2674
+ замерил как неинъективный ключ здания. Поэтому тест запрещает и её появление
+ в этой витрине.
+ """
+ sql, body = _latest_v_data_quality()
+ assert "pct_cadastr" not in body, "показатель вернулся в v_data_quality"
+ assert "cadastral_number" not in body, (
+ "в витрину подставили другой кадастр — под подписью «доля объявлений с "
+ "кадастром» это новая ложь вместо старой (см. шапку 259)"
+ )
+ # DROP VIEW уносит COMMENT вместе с объектом — миграция, которая дропает, обязана
+ # выставить его заново, иначе объяснение «почему показателя нет» молча теряется.
+ if re.search(r"DROP\s+VIEW\s+(?:IF\s+EXISTS\s+)?v_data_quality\b", sql):
+ assert "COMMENT ON VIEW v_data_quality" in sql
diff --git a/tradein-mvp/backend/tests/test_2677_house_anchor_repair_basis.py b/tradein-mvp/backend/tests/test_2677_house_anchor_repair_basis.py
new file mode 100644
index 00000000..6a4ce8be
--- /dev/null
+++ b/tradein-mvp/backend/tests/test_2677_house_anchor_repair_basis.py
@@ -0,0 +1,226 @@
+"""#2677: домовой IMV-якорь приводится к базису ремонта оцениваемой квартиры.
+
+Дефект: `median_price` к моменту blend'а уже домножен на `_repair_coefficient`
+(«требует ремонта» −6%, «евро» +10%), а домовой якорь из `house_imv_evaluations`
+запрошен у Avito с ОДНИМ ремонтом (`renovation_type`). Порог `anchor > median×1.15`
+и сам blend клали два разных базиса на одну шкалу — и, поскольку blend
+однонаправленный (только вверх), у клиента с «требует ремонта» это возвращало
+половину его −6% обратно наверх.
+
+Замер на проде (1061 персистированная оценка, 2026-08-10): blend не сработал ни
+разу (0 маркеров в confidence_explanation), но из 240 оценок с домовым якорем порог
+пересекали 19 — 9 из них «требует ремонта», и на них старый код давал медиану
+на 3.3–9.8 % выше базис-согласованной (суммарно +4.13 млн ₽).
+"""
+
+import os
+from typing import Any
+
+# Settings требует DATABASE_URL при инициализации (fail-fast, C-3).
+os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import anyio
+
+from app.services.estimator import _fetch_house_imv_anchor
+
+# `_anchor_repair_factor` импортируется ВНУТРИ тестов намеренно: на коде без правки
+# модуль обязан импортироваться, чтобы сквозные тесты ниже дошли до выполнения и
+# упали на ЦИФРАХ (красный по существу), а не на collection error.
+
+# Аналоги дают ровно 150 000 ₽/м² медианой → при area 40 м² pre-repair медиана 6 000 000 ₽.
+_PPM2 = 150_000.0
+_AREA = 40.0
+_BASE_MEDIAN = int(_PPM2 * _AREA)
+
+
+def _make_listing(price_per_m2: float) -> dict[str, Any]:
+ return {
+ "id": int(price_per_m2),
+ "source": "avito",
+ "address": "ЕКБ, ул. Учителей, 18",
+ "price_rub": int(price_per_m2 * _AREA),
+ "price_per_m2": price_per_m2,
+ "area_m2": _AREA,
+ "rooms": 1,
+ "floor": 4,
+ "total_floors": 16,
+ "lat": 56.838,
+ "lon": 60.595,
+ "listing_date": None,
+ "days_on_market": None,
+ "source_url": f"https://example.test/{int(price_per_m2)}",
+ "photo_urls": [],
+ "distance_m": 10.0,
+ }
+
+
+_ANALOGS = [
+ _make_listing(135_000.0),
+ _make_listing(140_000.0),
+ _make_listing(145_000.0),
+ _make_listing(_PPM2),
+ _make_listing(155_000.0),
+ _make_listing(160_000.0),
+ _make_listing(165_000.0),
+]
+
+
+def _run_estimate(anchor: dict[str, Any], repair_state: str | None) -> Any:
+ """estimate_quality со всеми I/O застабленными; домовой якорь форсирован."""
+ from app.schemas.trade_in import TradeInEstimateInput
+ from app.services.estimator import estimate_quality
+ from app.services.geocoder import GeocodeResult
+
+ payload = TradeInEstimateInput(
+ address="ЕКБ, ул. Учителей, 18",
+ area_m2=_AREA,
+ rooms=1,
+ floor=4,
+ total_floors=16,
+ repair_state=repair_state,
+ )
+ geo = GeocodeResult(
+ lat=56.838,
+ lon=60.595,
+ full_address="Свердловская обл., Екатеринбург, ул. Учителей, 18",
+ provider="nominatim",
+ )
+
+ async def _run() -> Any:
+ with (
+ patch("app.core.config.settings.estimate_hedonic_correction_enabled", new=False),
+ patch("app.services.estimator.geocode", new=AsyncMock(return_value=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._fetch_dkp_corridor", return_value=None),
+ 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._get_asking_sold_ratio", return_value=(0.8, "per_rooms")),
+ patch("app.services.estimator._fetch_house_imv_anchor", return_value=anchor),
+ ):
+ return await estimate_quality(payload, MagicMock())
+
+ return anyio.run(_run)
+
+
+def _anchor(recommended: int, renovation: str | None) -> dict[str, Any]:
+ return {
+ "recommended_price": recommended,
+ "lower_price": int(recommended * 0.97),
+ "higher_price": int(recommended * 1.05),
+ "market_count": 500,
+ "rooms": 1,
+ "area_m2": _AREA,
+ "renovation_type": renovation,
+ }
+
+
+# ── чистая функция ───────────────────────────────────────────────────────────
+
+
+def test_repair_factor_is_ratio_of_the_same_coefficients() -> None:
+ """Множитель = coef(ремонт клиента) / coef(ремонт строки якоря), а не что-то новое."""
+ from app.services.estimator import _anchor_repair_factor
+
+ # строка 'cosmetic' (=standard, 1.00) → клиент «требует ремонта» (0.94)
+ assert _anchor_repair_factor("cosmetic", "needs_repair") == 0.94
+ # строка 'euro' (=good, 1.05) → клиент со стандартным ремонтом (1.00)
+ assert _anchor_repair_factor("euro", "standard") == 1.0 / 1.05
+ # строка 'required' (=needs_repair, 0.94) → клиент с евро (1.10)
+ assert _anchor_repair_factor("required", "excellent") == 1.10 / 0.94
+
+
+def test_repair_factor_unknown_anchor_renovation_falls_back_to_standard_basis() -> None:
+ """Незнакомый/пустой `renovation_type` считается стандартным базисом, а НЕ «не трогать».
+
+ Так строка и рождается: `house_imv_backfill._map_renovation_type` при неизвестном
+ ремонте дома шлёт Avito 'cosmetic' (=standard) как медиану популяции. Фолбэк в
+ 1.0 при дрейфе вокабуляра тихо вернул бы дефект — поэтому базис, а не no-op.
+ """
+ from app.services.estimator import _anchor_repair_factor
+
+ assert _anchor_repair_factor("что-то-новое", "needs_repair") == 0.94
+ assert _anchor_repair_factor(None, "needs_repair") == 0.94
+ # Неизвестен ремонт КЛИЕНТА — правки нет: медиана тоже осталась без коэффициента.
+ assert _anchor_repair_factor(None, None) == 1.0
+ assert _anchor_repair_factor("cosmetic", None) == 1.0
+ assert _anchor_repair_factor(None, "standard") == 1.0
+
+
+# ── SQL-граница: без колонки правка молча выродится в no-op ──────────────────
+
+
+def test_anchor_query_selects_renovation_type() -> None:
+ """`renovation_type` обязан быть в SELECT: без него `.get()` вернёт None → k=1.0.
+
+ Проверка строковая намеренно — это единственная точка, где видно, доедет ли
+ колонка из БД до `_anchor_repair_factor`; поштучный unit-тест по ту сторону
+ границы получает dict уже от вызывающего и такую регрессию не увидит.
+ """
+ db = MagicMock()
+ db.execute.return_value.mappings.return_value.first.return_value = None
+ _fetch_house_imv_anchor(db, target_house_id=11308, rooms=1, area=_AREA)
+ sql = str(db.execute.call_args[0][0])
+ assert "renovation_type" in sql.split("FROM house_imv_evaluations")[0]
+
+
+# ── сквозь estimate_quality: цена, которую видит пользователь ────────────────
+
+
+def test_needs_repair_client_not_lifted_by_cosmetic_anchor() -> None:
+ """«Требует ремонта»: якорь между старым и базис-согласованным порогом → blend НЕ идёт.
+
+ median = 6 000 000 × 0.94 = 5 640 000. Старый порог: 5 640 000 × 1.15 = 6 486 000
+ — якорь 6 700 000 его перекрывает и старый код поднимает медиану до 6 170 000.
+ Базис-согласованно: якорь 'cosmetic' в базисе «требует ремонта» = 6 298 000
+ < 6 486 000 → медиана остаётся 5 640 000 (−8.6 % к старому поведению).
+ """
+ est = _run_estimate(_anchor(6_700_000, "cosmetic"), "needs_repair")
+ assert est.median_price_rub == int(_BASE_MEDIAN * 0.94)
+ assert "скорректирована по оценке Avito IMV" not in (est.confidence_explanation or "")
+
+
+def test_euro_anchor_not_applied_raw_to_unknown_repair_client() -> None:
+ """Сценарий из #2677: дом переснят как «евро», ремонт клиента неизвестен.
+
+ median = 6 000 000 (coef 1.0). Старый порог 6 900 000 — якорь 7 000 000 его
+ перекрывает и поднимает медиану до 6 500 000. В базисе клиента якорь 'euro'
+ стоит 7 000 000 / 1.05 = 6 666 667 < 6 900 000 → медиана не двигается.
+ """
+ est = _run_estimate(_anchor(7_000_000, "euro"), None)
+ assert est.median_price_rub == _BASE_MEDIAN
+
+
+def test_blend_still_fires_and_reports_the_rebased_anchor() -> None:
+ """Правка не глушит механизм: якорь, крупный и после пересчёта, по-прежнему блендится.
+
+ median = 5 640 000, якорь 'cosmetic' 10 000 000 → в базисе клиента 9 400 000
+ > 6 486 000 → blend = (5 640 000 + 9 400 000) / 2 = 7 520 000. В пояснении —
+ ТО ЖЕ число, что ушло в расчёт, и пометка про пересчёт (иначе текст спорил бы
+ с карточкой, где показана сырая оценка Avito).
+ """
+ est = _run_estimate(_anchor(10_000_000, "cosmetic"), "needs_repair")
+ assert est.median_price_rub == (int(_BASE_MEDIAN * 0.94) + 9_400_000) // 2
+ explanation = est.confidence_explanation or ""
+ assert "9.4 млн ₽" in explanation
+ assert "приведённой к состоянию ремонта квартиры" in explanation
+ # Карточка Avito остаётся сырой — чужое число мы не правим.
+ assert est.avito_imv is not None
+ assert est.avito_imv.recommended_price == 10_000_000