From 6e8c261ff8cbefec1951e0e6eb3a20f7f6cdef00 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 13:25:29 +0500 Subject: [PATCH 1/2] =?UTF-8?q?fix(tradein/domclick):=20=D1=81=D0=BE=D0=B1?= =?UTF-8?q?=D1=81=D1=82=D0=B2=D0=B5=D0=BD=D0=BD=D0=B8=D0=BA=D0=B8,=20?= =?UTF-8?q?=D0=BD=D1=83=D0=BB=D0=B5=D0=B2=D1=8B=D0=B5=20=D0=BF=D0=BB=D0=BE?= =?UTF-8?q?=D1=89=D0=B0=D0=B4=D0=B8=20=D0=B8=20=D0=B6=D0=B8=D0=BB=D0=B0?= =?UTF-8?q?=D1=8F=20=D0=BF=D0=BB=D0=BE=D1=89=D0=B0=D0=B4=D1=8C=20=D0=BA?= =?UTF-8?q?=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B8=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=B0=D0=BB=D0=B8=20=D1=82=D0=B5=D1=80=D1=8F?= =?UTF-8?q?=D1=82=D1=8C=D1=81=D1=8F=20(#3252)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сверка парсера с живым __SSR_STATE__ трёх карточек (17.09) нашла три тихие дыры: 1. owners_count. egrnData отдаёт поля ЕГРН обёрткой {status, value}; int() от словаря молча давал None. Прод: 0 из ~2970 карточек, обогащённых с 29.08, против 5075 из 6296 у ручного прогона 18.07, который обёртку разворачивал. Ключ egrnData.area подтверждён — гадания rosreestrArea/object_area удалены, в raw_payload обёртка остаётся как есть (status = сверка с объявлением). 2. livingArea/kitchenArea = 0 у Домклика значит «не указано», а в колонку ложился 0.00 и затирал известное значение. Прод: 303 кухни и 84 жилые площади = 0, только domklik. Разбор: _pos_float; строки: миграция 320 ставит NULL. 3. Жилую площадь и балконы стирал переобход выдачи: апсерт scraper_kit.base писал их сырым EXCLUDED, а SERP Домклика и Авито этих полей не отдаёт. Прод: domklik, переобойдённые после обогащения, living_area_m2 0 из 5217 (без переобхода 2990 из 4058); avito 1 из 8639 (4122 из 6505). COALESCE в SET, в гейте #2992 и на reconcile-пути. Co-Authored-By: Claude Opus 5 --- ...20_listings_domclick_zero_area_to_null.sql | 29 +++ .../tests/scrapers/test_domclick_detail.py | 8 +- tradein-mvp/backend/tests/skip_allowlist.txt | 7 + .../tests/test_3252_domclick_card_fields.py | 192 ++++++++++++++++++ .../scraper-kit/src/scraper_kit/base.py | 26 ++- .../scraper_kit/providers/domclick/detail.py | 43 +++- 6 files changed, 286 insertions(+), 19 deletions(-) create mode 100644 tradein-mvp/backend/data/sql/320_listings_domclick_zero_area_to_null.sql create mode 100644 tradein-mvp/backend/tests/test_3252_domclick_card_fields.py diff --git a/tradein-mvp/backend/data/sql/320_listings_domclick_zero_area_to_null.sql b/tradein-mvp/backend/data/sql/320_listings_domclick_zero_area_to_null.sql new file mode 100644 index 00000000..e2147b23 --- /dev/null +++ b/tradein-mvp/backend/data/sql/320_listings_domclick_zero_area_to_null.sql @@ -0,0 +1,29 @@ +-- 320_listings_domclick_zero_area_to_null.sql +-- Issue #3252: Домклик отдаёт `livingArea: 0` / `kitchenArea: 0` как «не указано» +-- (живая карточка 2078257603, 17.09: обе площади 0, ремонт пустой). Парсер карточки +-- писал этот 0 в колонку как настоящую площадь; COALESCE(:new, old) в UPDATE при этом +-- затирал нулём ранее известное значение. Писатель починен в том же PR +-- (providers/domclick/detail.py::_pos_float), но починка разбора строк не чинит: +-- COALESCE(NULL, 0) оставит 0 навсегда. +-- +-- ЗАМЕР ПРОДА 2026-09-17 (SELECT source, count(*) FILTER (WHERE kitchen_area_m2=0) ...): +-- domklik: kitchen_area_m2 = 0 — 303 строки, living_area_m2 = 0 — 84 строки; +-- все обогащены карточкой 26.08–14.09 (detail_enriched_at), ни одной до этого. +-- avito / cian / yandex / n1: нулей нет вовсе. +-- Ожидаемо тронуто: 303 + 84 строки. Отрицательных площадей нет ни у кого. +-- +-- Idempotent: повторный прогон — 0 строк. + +BEGIN; + +UPDATE listings + SET kitchen_area_m2 = NULL + WHERE source = 'domklik' + AND kitchen_area_m2 = 0; + +UPDATE listings + SET living_area_m2 = NULL + WHERE source = 'domklik' + AND living_area_m2 = 0; + +COMMIT; diff --git a/tradein-mvp/backend/tests/scrapers/test_domclick_detail.py b/tradein-mvp/backend/tests/scrapers/test_domclick_detail.py index 68f606d4..8bcccd4e 100644 --- a/tradein-mvp/backend/tests/scrapers/test_domclick_detail.py +++ b/tradein-mvp/backend/tests/scrapers/test_domclick_detail.py @@ -83,9 +83,9 @@ _SSR_LITERAL = """{ }, "legalOptions": {"saleType": "Свободная продажа"}, "egrnData": { - "area": 38.2, - "floor": 5, - "owners_count": 1, + "area": {"status": "success", "value": 38.2}, + "floor": {"status": "success", "value": 5}, + "owners_count": {"status": "success", "value": 1}, "collateral": true, "collateral_sber": false }, @@ -264,7 +264,7 @@ def test_parse_detail_html_raw_extra() -> None: # wall/floor type живут ТОЛЬКО в raw_extra, НЕ в listings.house_type. assert "house_type" not in raw assert raw["domclick_building_guid"] == "abc-guid-123" - assert raw["egrn_area"] == 38.2 + assert raw["egrn_area"] == {"status": "success", "value": 38.2} assert raw["demand"]["calls"] == 5 assert raw["demand"]["favorites"] == 12 # AVM (Layer C, top-level pricePrediction) → raw_extra.avm diff --git a/tradein-mvp/backend/tests/skip_allowlist.txt b/tradein-mvp/backend/tests/skip_allowlist.txt index 02194917..6908757f 100644 --- a/tradein-mvp/backend/tests/skip_allowlist.txt +++ b/tradein-mvp/backend/tests/skip_allowlist.txt @@ -146,3 +146,10 @@ tests/test_3463_db_timeouts.py::test_statement_over_ceiling_is_cancelled_not_hun tests/test_3463_db_timeouts.py::test_lock_wait_over_ceiling_is_aborted tests/test_3463_db_timeouts.py::test_set_local_statement_timeout_overrides_session_ceiling tests/test_3463_db_timeouts.py::test_set_local_is_scoped_to_its_transaction + +# Жилая площадь и балконы переживают переобход выдачи (#3252) — тот же `_live_session()`. +# Проверяют ПОВЕДЕНИЕ апсерта на живой схеме: SERP без полей не стирает то, что +# добыла карточка (COALESCE), и настоящее новое значение всё ещё перезаписывает. +# В ci-tradein.yml бегут по-настоящему (postgres-сервис); без БД — skip. +tests/test_3252_domclick_card_fields.py::test_serp_rescrape_keeps_detail_living_area_and_balconies +tests/test_3252_domclick_card_fields.py::test_real_new_value_still_overwrites diff --git a/tradein-mvp/backend/tests/test_3252_domclick_card_fields.py b/tradein-mvp/backend/tests/test_3252_domclick_card_fields.py new file mode 100644 index 00000000..d5af85af --- /dev/null +++ b/tradein-mvp/backend/tests/test_3252_domclick_card_fields.py @@ -0,0 +1,192 @@ +"""#3252: три тихие дыры в полях карточки Домклика. + +1. owners_count. egrnData отдаёт поля ЕГРН обёрткой ``{"status", "value"}``, а парсер + делал ``int()`` от словаря и молча получал None: на проде 0 из ~2970 карточек, + обогащённых с 29.08 (при 5075 из 6296 у ручного прогона 18.07, который обёртку + разворачивал). +2. Нулевые площади. ``livingArea: 0`` / ``kitchenArea: 0`` у Домклика значит «не + указано», а в колонку ложился 0.00 (303 кухни и 84 жилые площади). +3. Жилая площадь и балконы стирались переобходом выдачи: апсерт в scraper_kit.base + писал их сырым ``EXCLUDED``, а SERP Домклика и Авито этих полей не отдаёт. У + domklik, переобойдённых после обогащения, living_area_m2 = 0 из 5217. + +Состояние карточек ниже — урезанный ``__SSR_STATE__`` живых карточек 2074362051 и +2078257603, снятый 17.09 (значения как есть, лишние ветки выкинуты). +""" + +from __future__ import annotations + +import json +import os +import uuid +from decimal import Decimal +from typing import Any +from unittest.mock import MagicMock + +import pytest +from scraper_kit.base import ScrapedLot, save_listings +from scraper_kit.providers.domclick.detail import parse_detail_html +from sqlalchemy import text + +_LIVE_2074362051 = { + "productCard": { + "objectInfo": { + "area": 59.8, + "balconies": 1, + "kitchenArea": 14.9, + "livingArea": 25.8, + "renovation": "Косметический", + }, + "egrnData": { + "area": {"status": "success", "value": 59.8}, + "collateral": False, + "collateral_sber": False, + "floor": {"status": "success", "value": 14}, + "owners_count": {"status": "error", "value": 6}, + }, + }, +} + +_LIVE_2078257603 = { + "productCard": { + "objectInfo": { + "area": 67.4, + "balconies": 0, + "kitchenArea": 0, + "livingArea": 0, + "renovation": "", + }, + "egrnData": { + "area": {"status": "error", "value": 65.3}, + "collateral": False, + "collateral_sber": False, + "floor": {"status": "success", "value": 21}, + "owners_count": {"status": "success", "value": 1}, + }, + }, +} + + +def _parse(state: dict, card_id: str): + html = f"" + return parse_detail_html(html, f"https://ekaterinburg.domclick.ru/card/sale__flat__{card_id}") + + +# ── 1-2. Разбор живой формы ────────────────────────────────────────────────── + + +def test_owners_count_is_read_from_egrn_wrapper_whatever_the_status() -> None: + # status=error у owners_count = «много собственников», value — сам факт ЕГРН. + assert _parse(_LIVE_2074362051, "2074362051").owners_count == 6 + assert _parse(_LIVE_2078257603, "2078257603").owners_count == 1 + + +def test_egrn_area_keeps_wrapper_in_raw_payload() -> None: + e = _parse(_LIVE_2078257603, "2078257603") + assert e.raw_extra["egrn_area"] == {"status": "error", "value": 65.3} + + +def test_zero_areas_mean_unknown_not_zero() -> None: + zero = _parse(_LIVE_2078257603, "2078257603") + assert (zero.living_area_m2, zero.kitchen_area_m2) == (None, None) + real = _parse(_LIVE_2074362051, "2074362051") + assert (real.living_area_m2, real.kitchen_area_m2) == (25.8, 14.9) + + +# ── 3. Переобход выдачи не стирает то, что добыла карточка (живой Postgres) ── + + +def _live_session() -> Any | None: + try: + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + + dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "") + if not dsn or "localhost:5432/test" in dsn: + return None + engine = create_engine(dsn, future=True) + with engine.connect() as conn: + conn.execute(text("SELECT 1")) + return sessionmaker(bind=engine, future=True)() + except Exception: + return None + + +def _matcher() -> MagicMock: + m = MagicMock() + m.match_or_create_house.return_value = (None, 0.0, "no_address") + m.upsert_listing_source.return_value = None + return m + + +def _serp_lot(sid: str, **detail: Any) -> ScrapedLot: + return ScrapedLot( + source="domklik", + source_url=f"https://ekaterinburg.domclick.ru/card/sale__flat__t3252{sid}", + source_id=f"t3252-{sid}", + price_rub=7_000_000, + **detail, + ) + + +def _row(db: Any, sid: str) -> Any: + return db.execute( + text( + "SELECT living_area_m2, balconies_count FROM listings " + "WHERE source='domklik' AND source_id = :sid" + ), + {"sid": f"t3252-{sid}"}, + ).fetchone() + + +def _cleanup(db: Any) -> None: + try: + db.rollback() + ids = "(SELECT id FROM listings WHERE source='domklik' AND source_id LIKE 't3252-%')" + db.execute(text(f"DELETE FROM listings_snapshots WHERE listing_id IN {ids}")) + db.execute(text("DELETE FROM listing_sources WHERE ext_id LIKE 't3252-%'")) + db.execute(text("DELETE FROM listings WHERE source='domklik' AND source_id LIKE 't3252-%'")) + db.commit() + finally: + db.close() + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_serp_rescrape_keeps_detail_living_area_and_balconies() -> None: + db = _live_session() + sid = uuid.uuid4().hex[:8] + try: + save_listings( + db, + [_serp_lot(sid, living_area_m2=25.8, balconies_count=1)], + matcher=_matcher(), + region_code=66, + ) + save_listings(db, [_serp_lot(sid)], matcher=_matcher(), region_code=66) # выдача: полей нет + row = _row(db, sid) + assert (row.living_area_m2, row.balconies_count) == (Decimal("25.80"), 1) + finally: + _cleanup(db) + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_real_new_value_still_overwrites() -> None: + db = _live_session() + sid = uuid.uuid4().hex[:8] + try: + save_listings( + db, + [_serp_lot(sid, living_area_m2=25.8, balconies_count=1)], + matcher=_matcher(), + region_code=66, + ) + save_listings( + db, + [_serp_lot(sid, living_area_m2=30.5, balconies_count=2)], + matcher=_matcher(), + region_code=66, + ) + row = _row(db, sid) + assert (row.living_area_m2, row.balconies_count) == (Decimal("30.50"), 2) + finally: + _cleanup(db) diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py index d8676724..f470f51e 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/base.py @@ -587,9 +587,18 @@ def save_listings( price_rub = EXCLUDED.price_rub, price_per_m2 = EXCLUDED.price_per_m2, -- Cian-specific: обновляем при каждом re-scrape - living_area_m2 = EXCLUDED.living_area_m2, + -- #3252: жилая площадь и балконы — COALESCE по той же причине, что + -- #3063 ниже: их добывает detail (avito, domklik), а SERP этих + -- источников отдаёт NULL. Прод 17.09: у domklik, переобойдённых + -- после обогащения, living_area_m2 = 0 из 5217 (без переобхода — + -- 2990 из 4058), у avito — 1 из 8639 (4122 из 6505). + living_area_m2 = COALESCE( + EXCLUDED.living_area_m2, listings.living_area_m2 + ), bedrooms_count = EXCLUDED.bedrooms_count, - balconies_count = EXCLUDED.balconies_count, + balconies_count = COALESCE( + EXCLUDED.balconies_count, listings.balconies_count + ), loggias_count = EXCLUDED.loggias_count, description_minhash = EXCLUDED.description_minhash, cadastral_number = EXCLUDED.cadastral_number, @@ -742,8 +751,11 @@ def save_listings( listings.price_previous_rub, listings.newbuilding_id, listings.newbuilding_url, listings.card_hash, listings.is_active ) IS DISTINCT FROM ( - EXCLUDED.price_rub, EXCLUDED.price_per_m2, EXCLUDED.living_area_m2, - EXCLUDED.bedrooms_count, EXCLUDED.balconies_count, EXCLUDED.loggias_count, + EXCLUDED.price_rub, EXCLUDED.price_per_m2, + COALESCE(EXCLUDED.living_area_m2, listings.living_area_m2), + EXCLUDED.bedrooms_count, + COALESCE(EXCLUDED.balconies_count, listings.balconies_count), + EXCLUDED.loggias_count, EXCLUDED.description_minhash, EXCLUDED.cadastral_number, EXCLUDED.building_cadastral_number, -- #3063: те же COALESCE, что в SET выше. Правая часть гейта ОБЯЗАНА @@ -823,9 +835,9 @@ def save_listings( is_active = true, price_rub = :price_rub, price_per_m2 = :ppm2, - living_area_m2 = :living_area_m2, + living_area_m2 = COALESCE(:living_area_m2, living_area_m2), bedrooms_count = :bedrooms_count, - balconies_count = :balconies_count, + balconies_count = COALESCE(:balconies_count, balconies_count), loggias_count = :loggias_count, description_minhash = :description_minhash, cadastral_number = :cadastral_number, @@ -940,7 +952,7 @@ def save_listings( skip_snapshot = today_row is not None if skip_snapshot: logger.debug( - "save_listings:snapshot_skipped (card unchanged) " "source=%s listing_id=%s", + "save_listings:snapshot_skipped (card unchanged) source=%s listing_id=%s", lot.source, listing_id, ) diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/detail.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/detail.py index 66912922..2a845426 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/detail.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/detail.py @@ -27,7 +27,8 @@ _extract_ssr_state делает balanced-brace scan (с пропуском ск Форма SSR-стейта подтверждена на живой карточке 2075729321 (2026-06-27): productCard.objectInfo.{renovation,livingArea,kitchenArea}, productCard.priceInfo. priceHistory ({date ISO8601+tz, price, diff, state}), productCard.egrnData -(snake_case owners_count/collateral/collateral_sber), productCard.legalOptions. +(snake_case; area/floor/owners_count — обёртка {status, value}, collateral/ +collateral_sber — bool; перепроверено 17.09, #3252), productCard.legalOptions. saleType, productCard.viewsCount/callsCount, houseInfo.info.{wallType,floorType}, и ТОП-УРОВНЕМ pricePrediction (DomClick AVM → raw_extra.avm). @@ -283,6 +284,29 @@ def _to_int(value: Any) -> int | None: return None +def _pos_float(value: Any) -> float | None: + """float > 0, иначе None (#3252). + + Домклик отдаёт ``livingArea: 0`` / ``kitchenArea: 0`` как «не указано» (живая + карточка 2078257603, 17.09). Без этого в колонку ложился 0.00, а COALESCE в + UPDATE затирал им ранее известную площадь: на проде 303 кухни и 84 жилые + площади = 0 — только у domklik и только у обогащённых после 26.08. + """ + f = _to_float(value) + return f if f is not None and f > 0 else None + + +def _egrn_value(value: Any) -> Any: + """Узел egrnData → значение (#3252). + + Поля ЕГРН приходят обёрткой ``{"status": "success"|"warning"|"error", "value": X}`` + (живые карточки 17.09: area, floor, owners_count). ``status`` — сверка с тем, что + указал продавец, а ``value`` — сам факт из ЕГРН, поэтому берём value при любом + статусе. Скаляр пропускаем как есть. + """ + return value.get("value") if isinstance(value, dict) else value + + def _parse_change_time(value: Any) -> datetime | None: """Нормализует дату изменения цены → timezone-aware datetime. @@ -436,8 +460,8 @@ def parse_detail_html(html: str, source_url: str) -> DomClickDetailEnrichment: repair_state = _map_repair_state(repair_type) # площади - living_area_m2 = _to_float(oi.get("livingArea")) - kitchen_area_m2 = _to_float(oi.get("kitchenArea")) + living_area_m2 = _pos_float(oi.get("livingArea")) + kitchen_area_m2 = _pos_float(oi.get("kitchenArea")) # балконы: int → count+флаг; иное (строка/др.) → raw_extra, counts None balconies = oi.get("balconies") @@ -454,10 +478,11 @@ def parse_detail_html(html: str, source_url: str) -> DomClickDetailEnrichment: sale_type = (pc.get("legalOptions") or {}).get("saleType") - # owners_count — защищаемся от двух написаний ключа - owners_count = _to_int(egrn.get("owners_count")) + # owners_count — обёртка {status, value} (см. _egrn_value); до #3252 int() + # от словаря молча давал None: 0 из ~2970 карточек, обогащённых с 29.08. + owners_count = _to_int(_egrn_value(egrn.get("owners_count"))) if owners_count is None: - owners_count = _to_int(egrn.get("ownersCount")) + owners_count = _to_int(_egrn_value(egrn.get("ownersCount"))) # encumbrances_clean (ИНВЕРСИЯ): collateral/collateral_sber обозначают # НАЛИЧИЕ обременения. Любой truthy → clean=False; оба отсутствуют/false @@ -523,8 +548,10 @@ def parse_detail_html(html: str, source_url: str) -> DomClickDetailEnrichment: # raw_extra → merge в listings.raw_payload. wall/floor type ОСТАЮТСЯ тут, # НЕ пишем в listings.house_type (кросс-источниковый словарь грязный). - # egrn area key — точное написание не подтверждено; пробуем несколько. - egrn_area = egrn.get("area") or egrn.get("rosreestrArea") or egrn.get("object_area") + # egrnData.area — ключ подтверждён на живых карточках 17.09 (#3252). В raw_payload + # кладём обёртку {status, value} целиком: status говорит, сошлась ли площадь + # ЕГРН с площадью объявления, и уже лежит в таком виде у 2156 строк. + egrn_area = egrn.get("area") demand = _compact( { "calls": pc.get("callsCount"), From 635381f7b8e671fc7e066f0f5c18371a8b1b6140 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 14:45:54 +0500 Subject: [PATCH 2/2] =?UTF-8?q?fix(tests):=20=D0=BA=D1=80=D0=B0=D1=81?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20main=20=E2=80=94=20=D1=82=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D1=8B=20#3466=20=D1=87=D0=B8=D1=82=D0=B0=D0=BB=D0=B8=20?= =?UTF-8?q?=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B8,=20?= =?UTF-8?q?=D0=BA=D0=BE=D1=82=D0=BE=D1=80=D1=8B=D0=B5=20#3556=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B2=D1=91=D0=BB=20=D0=B2=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD=D1=81=D1=82=D0=B0=D0=BD=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Семантический конфликт двух PR, смерженных 17.09 подряд. Текстового конфликта не было, CI каждого PR был зелёным на своей голове, а объединённое дерево падало: test_3466_corridor_tier_a.py (2), test_estimator_radius_floor.py (2) AttributeError: 'Settings' object has no attribute 'estimate_corridor_clamp_slack' TypeError: _run_estimate() got an unexpected keyword argument 'radius_floor_factor' #3554 (Tier A и advisory_only) писал тесты против settings.estimate_corridor_clamp_*, а #3556 (#2380) убрал эти поля из Settings в константы CORRIDOR_CLAMP_SLACK (estimator) и CORRIDOR_CLAMP_MIN_N (app.core.config) и снял параметр radius_floor_factor у хелпера. Продуктовый код не затронут: в app/ старые имена остались только в комментарии, на проде AttributeError не было. Но красный test блокировал деплой МЕРЫ: на проде до сих пор образ до второй пачки мержей. Тесты переведены на константы, значения прежние (0.40, 10, 0.8). pytest tests/ — 6374 passed, 44 skipped, rc=0; ruff check/format — rc=0. Co-Authored-By: Claude Opus 5 --- .../tests/test_3466_corridor_tier_a.py | 3 +-- .../tests/test_estimator_radius_floor.py | 20 +++++++++---------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/tradein-mvp/backend/tests/test_3466_corridor_tier_a.py b/tradein-mvp/backend/tests/test_3466_corridor_tier_a.py index 4350f621..72542446 100644 --- a/tradein-mvp/backend/tests/test_3466_corridor_tier_a.py +++ b/tradein-mvp/backend/tests/test_3466_corridor_tier_a.py @@ -26,7 +26,6 @@ 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 @@ -45,7 +44,7 @@ _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) + return _CORRIDOR["high_ppm2"] * (1.0 + estimator.CORRIDOR_CLAMP_SLACK) def _call( diff --git a/tradein-mvp/backend/tests/test_estimator_radius_floor.py b/tradein-mvp/backend/tests/test_estimator_radius_floor.py index c532ed95..0f3e8485 100644 --- a/tradein-mvp/backend/tests/test_estimator_radius_floor.py +++ b/tradein-mvp/backend/tests/test_estimator_radius_floor.py @@ -8,8 +8,8 @@ - radius median выше dkp_low × factor → no-op (медиана не изменена) - dkp_raw is None → no-op (нет базы для floor) - anchor-путь (anchor_tier != None) → не затронут floor'ом -- коридор ниже estimate_corridor_clamp_min_n → floor не применяется (#3466) -- коридор ровно estimate_corridor_clamp_min_n → floor применяется (#3466) +- коридор ниже CORRIDOR_CLAMP_MIN_N → floor не применяется (#3466) +- коридор ровно CORRIDOR_CLAMP_MIN_N → floor применяется (#3466) """ from __future__ import annotations @@ -194,23 +194,23 @@ def test_no_dkp_raw_no_floor() -> None: def test_floor_not_applied_below_clamp_min_n() -> None: - """n = estimate_corridor_clamp_min_n − 1: тот же floor, что в тесте 1, но выключен. + """n = 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 + from app.core.config import CORRIDOR_CLAMP_MIN_N analogs = _six(80_000.0) dkp_raw = { - "count": settings.estimate_corridor_clamp_min_n - 1, + "count": 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) + est = _run_estimate(analogs, dkp_raw) assert est.median_price_per_m2 < 100_000, ( f"median_ppm2={est.median_price_per_m2}: коридор из " @@ -222,18 +222,18 @@ def test_floor_not_applied_below_clamp_min_n() -> None: def test_floor_applied_at_exactly_clamp_min_n() -> None: - """n = estimate_corridor_clamp_min_n: advisory_only=False → floor обязан поднять. + """n = CORRIDOR_CLAMP_MIN_N: advisory_only=False → floor обязан поднять. Тест 4 стережёт «ниже порога — нет», тест 1 — «15 сделок — да». Граница между ними (`>=` против `>`) не стереглась: при n == min_n витрина не пишет подпись «справочно», значит коридор обязан войти в цену. """ - from app.core.config import settings + from app.core.config import CORRIDOR_CLAMP_MIN_N from app.schemas.trade_in import DkpCorridor analogs = _six(80_000.0) dkp_raw = { - "count": settings.estimate_corridor_clamp_min_n, + "count": CORRIDOR_CLAMP_MIN_N, "low_ppm2": 150_000, "median_ppm2": 180_000, "high_ppm2": 220_000, @@ -242,7 +242,7 @@ def test_floor_applied_at_exactly_clamp_min_n() -> None: # Та же граница с другой стороны: на ней коридор уже не справочный. assert DkpCorridor(**dkp_raw).advisory_only is False - est = _run_estimate(analogs, dkp_raw, radius_floor_factor=0.8) + est = _run_estimate(analogs, dkp_raw) assert est.median_price_per_m2 == 150_000 * 0.8, ( f"median_ppm2={est.median_price_per_m2}: коридор из {dkp_raw['count']} сделок "