diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index d8a93c0e..c4a69ebc 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -772,7 +772,14 @@ def _save_yandex_history_items( ) rows = [] + skipped_area = 0 for item in result.history_items: + # Фильтруем items с нулевой/отрицательной/отсутствующей площадью — битый парс + # («0,5 м²» и пр.) сохраняет мусор в house_placement_history и искажает + # price_trend. Estimator защищён NULLIF, но грязь копится → чистим на входе. + if item.area_m2 is None or item.area_m2 <= 0: + skipped_area += 1 + continue ext_seed = ( f"{result.address}|{item.publish_date}|{item.area_m2}|{item.floor}|" f"{item.start_price}|{item.last_price}" @@ -797,6 +804,14 @@ def _save_yandex_history_items( } ) + if skipped_area > 0: + logger.info( + "yandex_valuation: skipped %d/%d history items with area_m2 <= 0 or None" " (addr=%r)", + skipped_area, + len(result.history_items), + result.address, + ) + sql = text( """ INSERT INTO house_placement_history ( diff --git a/tradein-mvp/backend/tests/test_yandex_history_area_filter.py b/tradein-mvp/backend/tests/test_yandex_history_area_filter.py new file mode 100644 index 00000000..ce31b8f3 --- /dev/null +++ b/tradein-mvp/backend/tests/test_yandex_history_area_filter.py @@ -0,0 +1,201 @@ +"""Tests for Fix 3: yandex history items с area_m2 <= 0 или None отброшены при сохранении. + +_save_yandex_history_items должен: +- пропустить item если area_m2 is None или area_m2 <= 0 +- сохранить валидные items (area_m2 > 0) без потерь +- не ронять батч из-за одного битого item +- лого кол-во отброшенных items +""" + +from __future__ import annotations + +import os +from datetime import date +from unittest.mock import MagicMock, patch + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.services.estimator import _save_yandex_history_items +from app.services.scrapers.yandex_valuation import ( + ValuationHistoryItem, + ValuationHouseMeta, + YandexValuationResult, +) + + +def _make_result(items: list[ValuationHistoryItem]) -> YandexValuationResult: + return YandexValuationResult( + address="Россия, Свердловская область, Екатеринбург, ул. Куйбышева, 106", + offer_category="APARTMENT", + offer_type="SELL", + page=1, + source_url="https://realty.yandex.ru/otsenka-kvartiry-po-adresu-onlayn/?address=test", + house=ValuationHouseMeta(year_built=2010, total_floors=16), + history_items=items, + ) + + +def _valid_item(area_m2: float = 50.0) -> ValuationHistoryItem: + return ValuationHistoryItem( + area_m2=area_m2, + rooms=2, + floor=5, + start_price=9_000_000, + last_price=8_800_000, + publish_date=date(2024, 4, 10), + exposure_days=30, + status="Снято", + ) + + +def _item_with_area(area_m2: float | None) -> ValuationHistoryItem: + return ValuationHistoryItem( + area_m2=area_m2, + rooms=1, + floor=2, + start_price=6_000_000, + last_price=6_000_000, + publish_date=date(2024, 1, 1), + exposure_days=10, + ) + + +# ── тест 1: item с area=None отброшен ──────────────────────────────────────── + + +def test_item_with_area_none_is_skipped() -> None: + """Item с area_m2=None должен быть пропущен, валидный item сохранён.""" + items = [ + _item_with_area(None), # битый — пропускаем + _valid_item(50.0), # валидный — сохраняем + ] + db = MagicMock() + result = _make_result(items) + + with patch( + "app.services.estimator.match_or_create_house", + return_value=(99, 0.9, "fp"), + ): + saved = _save_yandex_history_items(db, result) + + assert saved == 1, f"Ожидали 1 сохранённый item, получили {saved}" + rows = db.execute.call_args_list[0].args[1] + assert len(rows) == 1 + assert rows[0]["area"] == 50.0 + + +# ── тест 2: item с area=0 отброшен ─────────────────────────────────────────── + + +def test_item_with_area_zero_is_skipped() -> None: + """Item с area_m2=0 должен быть пропущен.""" + items = [ + _item_with_area(0.0), # area=0 — пропускаем + _item_with_area(0), # area=0 (int) — пропускаем + _valid_item(55.0), # валидный + ] + db = MagicMock() + result = _make_result(items) + + with patch( + "app.services.estimator.match_or_create_house", + return_value=(1, 1.0, "new"), + ): + saved = _save_yandex_history_items(db, result) + + assert saved == 1 + rows = db.execute.call_args_list[0].args[1] + assert rows[0]["area"] == 55.0 + + +# ── тест 3: item с area < 0 отброшен ───────────────────────────────────────── + + +def test_item_with_negative_area_is_skipped() -> None: + """Item с area_m2 < 0 должен быть пропущен.""" + items = [ + _item_with_area(-5.0), # отрицательная площадь + _valid_item(42.0), + ] + db = MagicMock() + result = _make_result(items) + + with patch( + "app.services.estimator.match_or_create_house", + return_value=(2, 0.7, "geo"), + ): + saved = _save_yandex_history_items(db, result) + + assert saved == 1 + + +# ── тест 4: все битые → 0 сохранено, но батч не падает ─────────────────────── + + +def test_all_invalid_area_returns_zero_no_crash() -> None: + """Все items с area=None или 0 → saved=0, ни одного db.execute INSERT-вызова с данными.""" + items = [ + _item_with_area(None), + _item_with_area(0.0), + _item_with_area(None), + ] + db = MagicMock() + result = _make_result(items) + + with patch( + "app.services.estimator.match_or_create_house", + return_value=(3, 0.9, "fp"), + ): + saved = _save_yandex_history_items(db, result) + + assert saved == 0 + # db.execute не должен вызываться для пустого rows (нет INSERT) + # Commit вызывается, rollback — нет. + db.commit.assert_called_once() + db.rollback.assert_not_called() + + +# ── тест 5: все валидные — всё сохранено ───────────────────────────────────── + + +def test_all_valid_items_all_saved() -> None: + """Все items с корректной площадью сохраняются без потерь.""" + items = [_valid_item(float(40 + i * 5)) for i in range(5)] + db = MagicMock() + result = _make_result(items) + + with patch( + "app.services.estimator.match_or_create_house", + return_value=(10, 0.9, "fp"), + ): + saved = _save_yandex_history_items(db, result) + + assert saved == 5 + + +# ── тест 6: микс валидных и битых ───────────────────────────────────────────── + + +def test_mixed_items_only_valid_saved() -> None: + """Микс: 2 валидных, 3 битых → saved=2.""" + items = [ + _valid_item(40.0), + _item_with_area(None), + _valid_item(60.0), + _item_with_area(0.0), + _item_with_area(-1.0), + ] + db = MagicMock() + result = _make_result(items) + + with patch( + "app.services.estimator.match_or_create_house", + return_value=(20, 0.9, "fp"), + ): + saved = _save_yandex_history_items(db, result) + + assert saved == 2 + rows = db.execute.call_args_list[0].args[1] + assert len(rows) == 2 + areas = {r["area"] for r in rows} + assert areas == {40.0, 60.0}