From 03d7884a802d7b732940948a99b9a1cc385caeef Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 4 Jul 2026 03:46:47 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein/houses):=20=D0=BF=D0=B5=D1=80=D1=81?= =?UTF-8?q?=D0=B8=D1=81=D1=82=D0=B8=D1=82=D1=8C=20house=5Fclass=20=D0=B8?= =?UTF-8?q?=D0=B7=20avito=20=C2=AB=D0=9A=D0=BB=D0=B0=D1=81=D1=81=20=D0=B6?= =?UTF-8?q?=D0=B8=D0=BB=D1=8C=D1=8F=C2=BB=20=D0=B8=20yandex=20parse=20(#20?= =?UTF-8?q?03)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit houses.house_class (comfort/business/premium/economy) заполнен у 0% из 6958 домов. Два независимых бага: Bug 2 (quick win): avito EXPAND_PARAMS_MAP не извлекал «Класс жилья» (developmentPage/новостройки) в колонку — добавлена нормализация RU→канон + COALESCE-write в _persist_house (avito = топ-приоритетный источник, перезаписывает при наличии значения, None не затирает существующее). Зеркальная правка в scraper-kit копии (parity-тест этого требует). Bug 1 (частично): resolve_house_field/HOUSE_FIELD_PRIORITY (conflict_resolution.py) подтверждён dead code (0 прод-вызовов) — не трогал. Вместо этого починен yandex_newbuilding_sweep.py: парсил house_class, но нигде не персистил (терялось безвозвратно) — добавлен fill-only UPDATE (COALESCE(houses.house_class, :new) — yandex_realty_nb низший приоритет, не перетирает более приоритетный источник). --- .../app/services/scrapers/avito_houses.py | 46 ++++++++++++ .../app/tasks/yandex_newbuilding_sweep.py | 27 ++++++- .../tasks/test_yandex_newbuilding_sweep.py | 49 +++++++++++++ .../backend/tests/test_avito_houses_dedup.py | 48 +++++++++++++ .../backend/tests/test_avito_houses_parse.py | 71 +++++++++++++++++++ .../src/scraper_kit/providers/avito/houses.py | 46 ++++++++++++ 6 files changed, 286 insertions(+), 1 deletion(-) diff --git a/tradein-mvp/backend/app/services/scrapers/avito_houses.py b/tradein-mvp/backend/app/services/scrapers/avito_houses.py index 1b6ffb8f..60c238f9 100644 --- a/tradein-mvp/backend/app/services/scrapers/avito_houses.py +++ b/tradein-mvp/backend/app/services/scrapers/avito_houses.py @@ -86,6 +86,10 @@ EXPAND_PARAMS_MAP: dict[str, tuple[str, str]] = { # вернёт None (хранится только в raw_characteristics). "Пассажирских лифтов": ("passenger_elevators", "int"), "Грузовых лифтов": ("cargo_elevators", "int"), + # Класс жилья (#2003) — ТОЛЬКО developmentPage (новостройки); housePage + # (вторичка) этот параметр не отдаёт. Сырое значение "Комфорт"/"Бизнес"/... → + # каноничный enum houses.house_class через _normalize_house_class. + "Класс жилья": ("house_class", "house_class"), } # URL-маркер новостройки: /catalog/novostroyki/... (vs вторичка /catalog/houses/...). @@ -104,6 +108,21 @@ HOUSE_TYPE_MAP: dict[str, str] = { "деревянный": "wood", } +# Нормализация класса жилья из Avito "Класс жилья" в canonical houses.house_class +# (#2003). Avito сам отдаёт только 4 значения (Комфорт/Бизнес/Премиум/Эконом — +# live-подтверждено), но карта дополнена "Комфорт+"/"Элит" для согласованности с +# вокабуляром yandex_helpers.parse_house_class (тот же столбец пишут оба источника). +HOUSE_CLASS_MAP: dict[str, str] = { + "эконом": "economy", + "комфорт": "comfort", + "комфорт+": "comfort_plus", + "комфорт плюс": "comfort_plus", + "бизнес": "business", + "премиум": "premium", + "элит": "elite", + "элитный": "elite", +} + # --------------------------------------------------------------------------- # Dataclasses @@ -122,6 +141,7 @@ class HouseInfo: year_built: int | None = None total_floors: int | None = None house_type: str | None = None # нормализован: monolith/panel/brick/... + house_class: str | None = None # нормализован: comfort/business/premium/economy/... (#2003) material_floors: str | None = None hot_water: str | None = None passenger_elevators: int | None = None @@ -244,6 +264,18 @@ def _normalize_house_type(raw: str | None) -> str | None: return HOUSE_TYPE_MAP.get(raw.lower(), "other") +def _normalize_house_class(raw: str | None) -> str | None: + """Нормализует класс жилья: "Комфорт" → "comfort" (#2003). + + В отличие от _normalize_house_type, неизвестное значение → None (НЕ "other"): + house_class используется как точный фильтр (`= ANY(...)` в search_query.py) — + мусорный bucket-токен там бесполезен и только засоряет список значений. + """ + if not raw: + return None + return HOUSE_CLASS_MAP.get(raw.strip().lower()) + + def _cast_expand_param(value: str, cast_type: str) -> Any: """Кастует строковое значение expandParams к нужному типу.""" if cast_type == "int": @@ -255,6 +287,8 @@ def _cast_expand_param(value: str, cast_type: str) -> Any: return value.strip().lower() == "да" if cast_type == "house_type": return _normalize_house_type(value) + if cast_type == "house_class": + return _normalize_house_class(value) # str return value @@ -374,6 +408,7 @@ def _parse_house_page(widget: dict[str, Any]) -> HouseInfo: year_built=parsed_params.get("year_built"), total_floors=parsed_params.get("total_floors"), house_type=parsed_params.get("house_type"), + house_class=parsed_params.get("house_class"), material_floors=parsed_params.get("material_floors"), hot_water=parsed_params.get("hot_water"), passenger_elevators=parsed_params.get("passenger_elevators"), @@ -447,6 +482,7 @@ def _parse_development_page(widget: dict[str, Any]) -> HouseInfo: year_built=parsed_params.get("year_built"), # обычно None — дом строится total_floors=parsed_params.get("total_floors"), house_type=parsed_params.get("house_type"), + house_class=parsed_params.get("house_class"), material_floors=parsed_params.get("material_floors"), hot_water=parsed_params.get("hot_water"), passenger_elevators=parsed_params.get("passenger_elevators"), @@ -958,6 +994,9 @@ def _persist_house(db: Session, h: HouseInfo, house_url: str) -> int | None: avito_id_hash) перезаписываются напрямую. - address/lat/lon/year_built — через COALESCE чтобы не затирать данные канонического дома от cian/кадастра. + - house_class (#2003) — COALESCE(new, existing): свежее avito-значение + (топ-приоритет источник) побеждает, но None не затирает значение от + более низкоприоритетного источника (см. HOUSE_FIELD_PRIORITY). 3. Не вызывает db.commit() — коммитит вызывающий save_house_catalog_enrichment (advisory lock xact-scoped, должен держаться до commit). @@ -995,6 +1034,12 @@ def _persist_house(db: Session, h: HouseInfo, house_url: str) -> int | None: full_address = CAST(:full_address AS text), avito_id_hash = CAST(:avito_id_hash AS text), house_type = CAST(:house_type AS text), + -- house_class (#2003): avito_houses_catalog топ-приоритет источник + -- (conflict_resolution.HOUSE_FIELD_PRIORITY) — свежее avito-значение + -- всегда побеждает, но None (вторичка не отдаёт "Класс жилья") не + -- затирает значение, уже проставленное более низкоприоритетным + -- источником (напр. yandex_realty_nb). + house_class = COALESCE(CAST(:house_class AS text), houses.house_class), total_floors = CAST(:total_floors AS int), passenger_elevators = CAST(:passenger_elevators AS int), cargo_elevators = CAST(:cargo_elevators AS int), @@ -1030,6 +1075,7 @@ def _persist_house(db: Session, h: HouseInfo, house_url: str) -> int | None: "full_address": h.full_address, "avito_id_hash": h.ext_id_hash, "house_type": h.house_type, + "house_class": h.house_class, "total_floors": h.total_floors, "passenger_elevators": h.passenger_elevators, "cargo_elevators": h.cargo_elevators, diff --git a/tradein-mvp/backend/app/tasks/yandex_newbuilding_sweep.py b/tradein-mvp/backend/app/tasks/yandex_newbuilding_sweep.py index 8d986540..5a967840 100644 --- a/tradein-mvp/backend/app/tasks/yandex_newbuilding_sweep.py +++ b/tradein-mvp/backend/app/tasks/yandex_newbuilding_sweep.py @@ -12,6 +12,9 @@ Context Через BrowserFetcher (tradein-browser camoufox) — единственный рабочий путь. 3. UPSERT в market.yandex_jk_enrichment (ON CONFLICT (ext_id) DO UPDATE). 4. UPDATE houses.yandex_jk_id WHERE yandex_jk_slug = slug. + 5. Если info.house_class распарсен — UPDATE houses.house_class (#2003, fill-only: + COALESCE не затирает уже проставленное более приоритетным источником значение; + yandex_realty_nb — низший приоритет в HOUSE_FIELD_PRIORITY для house_class). Idempotency ----------- @@ -135,6 +138,18 @@ _UPDATE_SLUG = """ WHERE id = CAST(:hid AS bigint) """ +# house_class (#2003): yandex_realty_nb — самый низкий приоритет источник для +# house_class (conflict_resolution.HOUSE_FIELD_PRIORITY: avito_houses_catalog > +# cian > yandex_realty_nb) — COALESCE(houses.house_class, ...) заполняет ТОЛЬКО +# пустое значение, никогда не перезаписывает уже проставленное avito/cian. +# Вызывается только когда info.house_class IS NOT NULL (см. caller) — лишний +# UPDATE с NULL никогда не выполняется. +_UPDATE_HOUSE_CLASS = """ + UPDATE houses + SET house_class = COALESCE(houses.house_class, CAST(:house_class AS text)) + WHERE id = CAST(:hid AS bigint) +""" + _UPSERT_ENRICHMENT = """ INSERT INTO market.yandex_jk_enrichment ( ext_id, name, developer_name, address, @@ -377,16 +392,26 @@ async def enrich_yandex_newbuilding_sweep( "raw_payload": json.dumps(info.raw_payload or {}, ensure_ascii=False), }, ) + # house_class (#2003): распарсен parse_house_class из description (yandex_newbuilding + # parse()), но раньше нигде не персистился (только market.yandex_jk_enrichment, + # без колонки house_class) — Bug 1 находка. Пишем в houses в ТОЙ ЖЕ savepoint, + # только если распарсили (иначе UPDATE не нужен — houses.house_class не трогаем). + if info.house_class is not None: + db.execute( + text(_UPDATE_HOUSE_CLASS), + {"hid": house_id, "house_class": info.house_class}, + ) sp.commit() db.commit() result.rows_inserted += 1 result.succeeded += 1 logger.info( - "enriched house_id=%s ext_id=%s slug=%s name=%r", + "enriched house_id=%s ext_id=%s slug=%s name=%r house_class=%s", house_id, info.ext_id, jk_slug, info.name, + info.house_class, ) except Exception as exc: sp.rollback() diff --git a/tradein-mvp/backend/tests/tasks/test_yandex_newbuilding_sweep.py b/tradein-mvp/backend/tests/tasks/test_yandex_newbuilding_sweep.py index a2e753cc..e6b2eddf 100644 --- a/tradein-mvp/backend/tests/tasks/test_yandex_newbuilding_sweep.py +++ b/tradein-mvp/backend/tests/tasks/test_yandex_newbuilding_sweep.py @@ -63,6 +63,8 @@ class FakeDB: self.enrichment: dict[str, dict] = {} # houses.yandex_jk_slug written during resolve self.slug_updates: dict[int, str] = {} + # houses.house_class written after successful enrichment (#2003) + self.house_class_updates: dict[int, str] = {} self.commits = 0 self.rollbacks = 0 self._sp_stack: list[_FakeSavepoint] = [] @@ -104,6 +106,11 @@ class FakeDB: self.slug_updates[params["hid"]] = params["slug"] return _FakeResult() + # UPDATE houses SET house_class (#2003) + if "UPDATE houses" in sql and "house_class" in sql: + self.house_class_updates[params["hid"]] = params["house_class"] + return _FakeResult() + # Idempotency check: SELECT 1 FROM market.yandex_jk_enrichment if "SELECT 1 FROM market.yandex_jk_enrichment" in sql: ext_id = params.get("ext_id") @@ -214,6 +221,48 @@ async def test_sweep_with_existing_slug_upserts_enrichment(): assert result.failed_resolve == 0 assert "999" in db.enrichment assert db.enrichment["999"]["name"] == "ЖК Татлин" + # house_class не распарсен в этой фикстуре (None) — UPDATE houses.house_class не должен + # выполниться вовсе (Bug 1 guard: лишний UPDATE с NULL пропускается). + assert 10 not in db.house_class_updates + + +@pytest.mark.asyncio +async def test_sweep_persists_house_class_when_parsed(): + """#2003: info.house_class распарсен → UPDATE houses.house_class выполняется. + + Ранее (Bug 1) house_class парсился в YandexNewbuildingInfo, но нигде не персистился — + только market.yandex_jk_enrichment (без колонки house_class). Регрессия на фикс. + """ + db = FakeDB( + [ + { + "house_id": 11, + "ext_id": "1000", + "yandex_jk_slug": "tatlin", + "yandex_jk_id": "1000", + } + ] + ) + + from app.services.scrapers.yandex_newbuilding import YandexNewbuildingInfo + + fake_info = YandexNewbuildingInfo( + ext_id="1000", + ext_slug="tatlin", + source_url="https://realty.yandex.ru/ekaterinburg/kupit/novostrojka/tatlin-1000/", + name="ЖК Татлин", + house_class="comfort_plus", + ) + + with patch("app.services.scrapers.yandex_newbuilding.YandexNewbuildingScraper") as mock_scraper: + scraper_instance = MagicMock() + scraper_instance.fetch_jk = AsyncMock(return_value=fake_info) + mock_scraper.return_value = scraper_instance + + result = await enrich_yandex_newbuilding_sweep(db, limit=5, request_delay_sec=0) + + assert result.succeeded == 1 + assert db.house_class_updates.get(11) == "comfort_plus" @pytest.mark.asyncio diff --git a/tradein-mvp/backend/tests/test_avito_houses_dedup.py b/tradein-mvp/backend/tests/test_avito_houses_dedup.py index fc91d89d..d32ad4b9 100644 --- a/tradein-mvp/backend/tests/test_avito_houses_dedup.py +++ b/tradein-mvp/backend/tests/test_avito_houses_dedup.py @@ -274,6 +274,54 @@ def test_persist_house_coalesce_fields_in_bind(): assert bind["year_built"] == h.year_built +# --------------------------------------------------------------------------- +# Test 7b: #2003 — house_class передаётся в UPDATE bind (COALESCE fill-only) +# --------------------------------------------------------------------------- + + +def test_persist_house_house_class_in_bind(): + """UPDATE передаёт house_class в bind для COALESCE(CAST(:house_class AS text), houses.*).""" + db = MagicMock() + db.execute.return_value = MagicMock() + + h = _make_house() + h.house_class = "comfort" + + with patch( + "app.services.scrapers.avito_houses.match_or_create_house", + return_value=(1, 1.0, "new"), + ): + _persist_house(db, h, "") + + bind = db.execute.call_args[0][1] + assert bind["house_class"] == "comfort" + + sql_obj = db.execute.call_args[0][0] + assert "house_class" in str(sql_obj) + + +def test_persist_house_house_class_none_does_not_null_via_coalesce(): + """house_class=None в bind — SQL использует COALESCE(new, existing), не голый overwrite.""" + db = MagicMock() + db.execute.return_value = MagicMock() + + h = _make_house() # house_class остаётся дефолтным None + + with patch( + "app.services.scrapers.avito_houses.match_or_create_house", + return_value=(1, 1.0, "new"), + ): + _persist_house(db, h, "") + + bind = db.execute.call_args[0][1] + assert bind["house_class"] is None + + sql_obj = str(db.execute.call_args[0][0]) + # SQL должен явно защищать существующее значение через COALESCE, а не + # безусловный overwrite (который занулил бы значение от другого источника). + assert "COALESCE(CAST(:house_class AS text), houses.house_class)" in sql_obj + + # --------------------------------------------------------------------------- # Test 8: #1820 — ext_id=0 graceful-skip гард # Два id-less URL (ext_id=0, без адреса) → match_or_create_house НЕ вызывается, diff --git a/tradein-mvp/backend/tests/test_avito_houses_parse.py b/tradein-mvp/backend/tests/test_avito_houses_parse.py index 26febb3d..69323072 100644 --- a/tradein-mvp/backend/tests/test_avito_houses_parse.py +++ b/tradein-mvp/backend/tests/test_avito_houses_parse.py @@ -551,6 +551,77 @@ def test_house_type_normalization() -> None: assert _normalize_house_type(None) is None +def test_house_class_normalization() -> None: + """Все варианты класса жилья нормализуются корректно (#2003).""" + from app.services.scrapers.avito_houses import _normalize_house_class + + assert _normalize_house_class("Комфорт") == "comfort" + assert _normalize_house_class("Бизнес") == "business" + assert _normalize_house_class("Премиум") == "premium" + assert _normalize_house_class("Эконом") == "economy" + assert _normalize_house_class("Комфорт+") == "comfort_plus" + assert _normalize_house_class("Элит") == "elite" + # Регистронезависимость + пробелы по краям + assert _normalize_house_class(" комфорт ") == "comfort" + # Неизвестное значение → None (НЕ "other" — house_class точный ANY(...) фильтр) + assert _normalize_house_class("Неизвестный класс") is None + assert _normalize_house_class(None) is None + assert _normalize_house_class("") is None + + +def test_house_class_parsed_from_novostroyka_expand_params() -> None: + """ "Класс жилья" из developmentPage expandParams → HouseInfo.house_class (#2003).""" + state: dict = { + "data": { + "data": { + "page": { + "placeholders": [ + { + "type": "developmentPage", + "props": { + "developmentPage": { + "developmentData": { + "avitoId": 42, + "coords": {"lat": 56.8, "lng": 60.6}, + "developer": {}, + "mapPreview": {}, + "aboutDevelopment": { + "tabs": [ + { + "expandParams": { + "items": [ + { + "params": [ + { + "type": "Класс жилья", + "value": "Комфорт", + }, + ] + } + ] + } + } + ] + }, + } + } + }, + }, + ] + } + } + } + } + e = parse_houses_state(state, "/catalog/novostroyki/ekaterinburg/test/42") + assert e.house.house_class == "comfort" + + +def test_house_class_absent_on_secondary_listing() -> None: + """Вторичка (housePage) не отдаёт "Класс жилья" → house_class остаётся None.""" + e = parse_houses_state(MINIMAL_STATE, "/catalog/houses/ekaterinburg/test/3171365") + assert e.house.house_class is None + + def test_strip_price() -> None: """_strip_price корректно извлекает числа из строк Avito.""" from app.services.scrapers.avito_houses import _strip_price diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/houses.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/houses.py index 87c024a5..a62f1755 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/houses.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/houses.py @@ -86,6 +86,10 @@ EXPAND_PARAMS_MAP: dict[str, tuple[str, str]] = { # вернёт None (хранится только в raw_characteristics). "Пассажирских лифтов": ("passenger_elevators", "int"), "Грузовых лифтов": ("cargo_elevators", "int"), + # Класс жилья (#2003) — ТОЛЬКО developmentPage (новостройки); housePage + # (вторичка) этот параметр не отдаёт. Сырое значение "Комфорт"/"Бизнес"/... → + # каноничный enum houses.house_class через _normalize_house_class. + "Класс жилья": ("house_class", "house_class"), } # URL-маркер новостройки: /catalog/novostroyki/... (vs вторичка /catalog/houses/...). @@ -104,6 +108,21 @@ HOUSE_TYPE_MAP: dict[str, str] = { "деревянный": "wood", } +# Нормализация класса жилья из Avito "Класс жилья" в canonical houses.house_class +# (#2003). Avito сам отдаёт только 4 значения (Комфорт/Бизнес/Премиум/Эконом — +# live-подтверждено), но карта дополнена "Комфорт+"/"Элит" для согласованности с +# вокабуляром yandex_helpers.parse_house_class (тот же столбец пишут оба источника). +HOUSE_CLASS_MAP: dict[str, str] = { + "эконом": "economy", + "комфорт": "comfort", + "комфорт+": "comfort_plus", + "комфорт плюс": "comfort_plus", + "бизнес": "business", + "премиум": "premium", + "элит": "elite", + "элитный": "elite", +} + # --------------------------------------------------------------------------- # Dataclasses @@ -122,6 +141,7 @@ class HouseInfo: year_built: int | None = None total_floors: int | None = None house_type: str | None = None # нормализован: monolith/panel/brick/... + house_class: str | None = None # нормализован: comfort/business/premium/economy/... (#2003) material_floors: str | None = None hot_water: str | None = None passenger_elevators: int | None = None @@ -244,6 +264,18 @@ def _normalize_house_type(raw: str | None) -> str | None: return HOUSE_TYPE_MAP.get(raw.lower(), "other") +def _normalize_house_class(raw: str | None) -> str | None: + """Нормализует класс жилья: "Комфорт" → "comfort" (#2003). + + В отличие от _normalize_house_type, неизвестное значение → None (НЕ "other"): + house_class используется как точный фильтр (`= ANY(...)` в search_query.py) — + мусорный bucket-токен там бесполезен и только засоряет список значений. + """ + if not raw: + return None + return HOUSE_CLASS_MAP.get(raw.strip().lower()) + + def _cast_expand_param(value: str, cast_type: str) -> Any: """Кастует строковое значение expandParams к нужному типу.""" if cast_type == "int": @@ -255,6 +287,8 @@ def _cast_expand_param(value: str, cast_type: str) -> Any: return value.strip().lower() == "да" if cast_type == "house_type": return _normalize_house_type(value) + if cast_type == "house_class": + return _normalize_house_class(value) # str return value @@ -374,6 +408,7 @@ def _parse_house_page(widget: dict[str, Any]) -> HouseInfo: year_built=parsed_params.get("year_built"), total_floors=parsed_params.get("total_floors"), house_type=parsed_params.get("house_type"), + house_class=parsed_params.get("house_class"), material_floors=parsed_params.get("material_floors"), hot_water=parsed_params.get("hot_water"), passenger_elevators=parsed_params.get("passenger_elevators"), @@ -447,6 +482,7 @@ def _parse_development_page(widget: dict[str, Any]) -> HouseInfo: year_built=parsed_params.get("year_built"), # обычно None — дом строится total_floors=parsed_params.get("total_floors"), house_type=parsed_params.get("house_type"), + house_class=parsed_params.get("house_class"), material_floors=parsed_params.get("material_floors"), hot_water=parsed_params.get("hot_water"), passenger_elevators=parsed_params.get("passenger_elevators"), @@ -964,6 +1000,9 @@ def _persist_house( avito_id_hash) перезаписываются напрямую. - address/lat/lon/year_built — через COALESCE чтобы не затирать данные канонического дома от cian/кадастра. + - house_class (#2003) — COALESCE(new, existing): свежее avito-значение + (топ-приоритет источник) побеждает, но None не затирает значение от + более низкоприоритетного источника (см. HOUSE_FIELD_PRIORITY). 3. Не вызывает db.commit() — коммитит вызывающий save_house_catalog_enrichment (advisory lock xact-scoped, должен держаться до commit). @@ -1001,6 +1040,12 @@ def _persist_house( full_address = CAST(:full_address AS text), avito_id_hash = CAST(:avito_id_hash AS text), house_type = CAST(:house_type AS text), + -- house_class (#2003): avito_houses_catalog топ-приоритет источник + -- (conflict_resolution.HOUSE_FIELD_PRIORITY) — свежее avito-значение + -- всегда побеждает, но None (вторичка не отдаёт "Класс жилья") не + -- затирает значение, уже проставленное более низкоприоритетным + -- источником (напр. yandex_realty_nb). + house_class = COALESCE(CAST(:house_class AS text), houses.house_class), total_floors = CAST(:total_floors AS int), passenger_elevators = CAST(:passenger_elevators AS int), cargo_elevators = CAST(:cargo_elevators AS int), @@ -1036,6 +1081,7 @@ def _persist_house( "full_address": h.full_address, "avito_id_hash": h.ext_id_hash, "house_type": h.house_type, + "house_class": h.house_class, "total_floors": h.total_floors, "passenger_elevators": h.passenger_elevators, "cargo_elevators": h.cargo_elevators,