diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index b599c368..d619a1ef 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -260,10 +260,12 @@ jobs: # #295 weighted AVG) self-skip'ался connectivity-probe'ом — в CI эти проверки # не бежали ни разу с момента написания. # - # plain postgres:16, БЕЗ PostGIS: тесты tests/sql/ строят себе временные - # таблицы (CREATE TEMP TABLE) и не трогают ни geometry, ни реальную схему — - # проверено локально, 16 passed за 1.3с. Поэтому и bootstrap схемы здесь не - # нужен, в отличие от tradein-лэйна. + # postgis/postgis:16-3.4, схема НЕ собирается: тесты строят себе временные + # таблицы (CREATE TEMP TABLE), которые затеняют боевые, и гоняют настоящий SQL + # модулей. PostGIS нужен с #2962: SQL конкурентов ищет «тот же ЖК» через + # ST_DWithin по geography, без расширения его не исполнить. На plain + # postgres:16 тест #2962 молча пропускался, то есть мост gap-fill в CI не + # проверялся вовсе. Образ сам создаёт расширение в POSTGRES_DB. # # TEST_DATABASE_URL НАМЕРЕННО НЕ задаётся: на него завязан tests/integration/ # (phantom-column gate), которому нужна КОПИЯ ПРОДОВОЙ схемы через pg_dump по @@ -294,15 +296,14 @@ jobs: # ВРЕМЕННЫЙ сервер фазы initdb (listen_addresses=''), после которой БД # ещё перезапускается. Проба по TCP зеленеет только на настоящем сервере. # - # plain postgres:16, БЕЗ PostGIS: тесты tests/sql/ строят себе временные - # таблицы и не трогают ни geometry, ни реальную схему — bootstrap схемы - # здесь не нужен вовсе, в отличие от tradein-лэйна. + # postgis, а не plain postgres (#2962) — см. комментарий у job'а. Bootstrap + # схемы здесь не нужен вовсе, в отличие от tradein-лэйна. run: | set -u docker rm -fv "$CI_PG" >/dev/null 2>&1 || true docker run -d --name "$CI_PG" \ -e POSTGRES_DB=gendesign_ci -e POSTGRES_USER=gendesign -e POSTGRES_PASSWORD=gendesign \ - postgres:16 + postgis/postgis:16-3.4 ready="" for _ in $(seq 1 45); do diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py index 75fb6329..e24ec698 100644 --- a/backend/app/schemas/parcel.py +++ b/backend/app/schemas/parcel.py @@ -627,7 +627,7 @@ class LayoutDataQuality(BaseModel): """ objects_with_velocity_data: int # число комплексов с velocity-данными - objects_total_in_radius: int # число комплексов (сгруппированных obj_id) + objects_total_in_radius: int # число комплексов после exclude/filter (знаменатель coverage) raw_objects_total: int # сырое число obj_id в радиусе (до группировки) # Issue #2177 шаг 2b (core-attribution): сколько комплексов покрыто ИМЕННО по # нормализованному ядру имени (project_name-матч), а НЕ через mapping-obj_id — diff --git a/backend/app/scrapers/nspd_bulk_client.py b/backend/app/scrapers/nspd_bulk_client.py index c9dc89f3..9f16d7a2 100644 --- a/backend/app/scrapers/nspd_bulk_client.py +++ b/backend/app/scrapers/nspd_bulk_client.py @@ -587,19 +587,6 @@ class NSPDBulkClient: ) return results - async def get_territorial_zones_in_bbox( - self, - bbox: tuple[float, float, float, float], - *, - grid_n: int = 7, - ) -> list[dict]: - """Grid-walk WMS GetFeatureInfo для layer 875838 (ПЗЗ территориальные зоны). - - Returns: list of feature dicts с полями id, geometry, properties. - Дедуплицирует по feature id. - """ - return await self.get_features_in_bbox_grid(875838, bbox, grid_n=grid_n) - # ── 4. list_objects_in_building ─────────────────────────────────────────── # Q3 deferred — метод реализован, но не вызывается в bulk_harvest_quarter MVP. # Готов для per-building помещения/парковка фазы. diff --git a/backend/app/services/cadastre/bulk_harvest.py b/backend/app/services/cadastre/bulk_harvest.py index 8109fb04..87f79550 100644 --- a/backend/app/services/cadastre/bulk_harvest.py +++ b/backend/app/services/cadastre/bulk_harvest.py @@ -19,7 +19,6 @@ Resumable: phase_state в cadastre_jobs показывает прогресс. from __future__ import annotations -import hashlib import json import logging from collections.abc import Callable @@ -260,10 +259,8 @@ async def harvest_quarter( update_progress(done_progress) # ── Phase 4: quarter stats + auto-heal geom из snapshot ───────────────── - # Bug #1583: auto-heal geom выполняем ДО Phase 2.5 (territorial_zones). Иначе - # кварталы с broken/NULL geom дают quarter_bbox_3857() == None → ПЗЗ молча - # пропускаются, а на следующем harvest квартал отсекается skip_fresh_hours. - # Чиним geom здесь → Phase 2.5 ниже получит валидный bbox в этом же прогоне. + # Bug #1583: кварталы с broken/NULL geom дают quarter_bbox_3857() == None → + # grid-walk следующего прогона молча пропускается. Чиним geom из snapshot. stats_features = [f for f in snapshot.features if f.category_id == CAT_QUARTER_STATS] if stats_features: upsert_quarter_stats(db, quarter, stats_features[0]) @@ -278,25 +275,6 @@ async def harvest_quarter( logger.warning("harvest_quarter: geom auto-heal failed for %s: %s", quarter, e) db.commit() - # ── Phase 2.5: grid-walk для territorial_zones (ПЗЗ, layer 875838) ──────── - # Выполняем после основного grid-walk (Phase 2-3) И после Phase 4 geom - # auto-heal (см. Bug #1583) — так broken-geom кварталы, починенные выше, - # получают валидный bbox и ПЗЗ собираются в том же прогоне. Требует bbox квартала. - quarter_bbox = quarter_bbox_3857(db, quarter) - if quarter_bbox is not None: - update_progress({"phase": "territorial_zones_started", "quarter": quarter}) - try: - tz_features = await client.get_territorial_zones_in_bbox(quarter_bbox) - tz_count = _save_territorial_zones(db, quarter, tz_features) - logger.info( - "harvest_quarter: territorial_zones quarter=%s upserted=%d", quarter, tz_count - ) - except (NspdBulkWafError, NspdBulkRateLimitError): - # #2464-A: см. выше — бан пробрасываем, а не превращаем в «слой пуст». - raise - except Exception as e: - logger.warning("harvest_quarter: territorial_zones failed quarter=%s: %s", quarter, e) - # Issue #252: финальный phase_state несёт АГРЕГИРОВАННЫЙ harvest_meta по всем # сбойным слоям. progress_cb мержит phase_state через JSONB `||` (shallow) — # per-layer done-апдейты перетёрли бы harvest_meta друг друга, поэтому в @@ -679,7 +657,7 @@ def upsert_features( cat = feature.category_id # Per-feature SAVEPOINT (backend.md SAVEPOINT rule): один битый feature (edge-case # GeoJSON / NOT NULL violation / type mismatch) НЕ должен ронять весь snapshot-tx - # квартала. Зеркалит _grid_walk_category (~368) и _save_territorial_zones (~1234): + # квартала. Зеркалит _grid_walk_category: # begin_nested() rollback'ит только этот feature, остальные сохраняются. Счётчик # инкрементим ТОЛЬКО при успехе (внутри блока) → counts остаются точными. try: @@ -1476,114 +1454,6 @@ def upsert_quarter_stats( ) -# ── cad_territorial_zones upsert ──────────────────────────────────────────── - - -def _save_territorial_zones(db: Session, quarter_cad: str, features: list[dict]) -> int: - """UPSERT territorial_zones features в cad_territorial_zones по zone_id. - - Args: - db: SQLAlchemy session. - quarter_cad: кадастровый номер квартала (3 сегмента). - features: list of raw feature dicts от get_features_in_bbox_grid. - - Returns: - Количество успешно upserted строк. - """ - inserted = 0 - for f in features: - props: dict = f.get("properties") or {} - geom = f.get("geometry") - geom_geojson: str | None = json.dumps(geom) if geom else None - - # zone_id — NSPD feature id или стабильный fallback на основе md5 от properties. - # md5 гарантирует идемпотентность между runs (счётчик inserted сбрасывается). - _raw_id = props.get("id") or props.get("zone_id") or f.get("id") - if _raw_id: - zone_id = str(_raw_id) - else: - _props_hash = hashlib.md5( - json.dumps(props, sort_keys=True).encode("utf-8") - ).hexdigest()[:12] - zone_id = f"{quarter_cad}_{_props_hash}" - zone_code = ( - props.get("zone_code") or props.get("zone_index") or props.get("reg_numb_border") - ) - zone_name = props.get("zone_name") or props.get("zone_type_name") or props.get("type_zone") - permitted_use = props.get("permitted_use") or props.get("vri") - - # cad_territorial_zones.geom — geography(MultiPolygon, 4326). - # Polygon допустим (ST_Multi обернёт в SQL), но Point/LineString → - # geography-INSERT fail → SAVEPOINT откат → строка дропается молча. - # Зеркалит фильтр upsert_zouit (~1196). - geom_type = geom.get("type") if isinstance(geom, dict) else None - if geom_type not in ("Polygon", "MultiPolygon"): - if geom_type: - logger.info( - "_save_territorial_zones: zone_id=%s geom type=%s (не Polygon/MultiPolygon)" - " — geom=NULL", - zone_id, - geom_type, - ) - geom_geojson = None - - try: - # begin_nested() требует активной outer-транзакции для SAVEPOINT. - # SQLAlchemy Session (autobegin=True) автоматически начинает tx при первом - # db.execute() в этом loop — outer tx гарантирована. - with db.begin_nested(): - db.execute( - text(""" - INSERT INTO cad_territorial_zones - (quarter_cad, zone_id, zone_code, zone_name, - permitted_use, raw_props, geom) - VALUES ( - CAST(:quarter_cad AS text), - CAST(:zone_id AS text), - CAST(:zone_code AS text), - CAST(:zone_name AS text), - CAST(:permitted_use AS text), - CAST(:raw_props AS jsonb), - CASE WHEN CAST(:geom AS text) IS NOT NULL - THEN ST_Multi( - ST_Transform( - ST_SetSRID(ST_GeomFromGeoJSON(CAST(:geom AS text)), 3857), - 4326 - ) - )::geography - ELSE NULL - END - ) - ON CONFLICT (zone_id) DO UPDATE SET - zone_code = EXCLUDED.zone_code, - zone_name = EXCLUDED.zone_name, - permitted_use = EXCLUDED.permitted_use, - raw_props = EXCLUDED.raw_props, - geom = EXCLUDED.geom, - fetched_at = NOW() - """), - { - "quarter_cad": quarter_cad, - "zone_id": zone_id, - "zone_code": zone_code, - "zone_name": zone_name, - "permitted_use": permitted_use, - "raw_props": json.dumps(props, ensure_ascii=False), - "geom": geom_geojson, - }, - ) - inserted += 1 - except Exception as e: - logger.warning( - "_save_territorial_zones: upsert failed zone_id=%s quarter=%s: %s", - zone_id, - quarter_cad, - e, - ) - db.commit() - return inserted - - # ── Утилиты ────────────────────────────────────────────────────────────────── diff --git a/backend/app/services/site_finder/best_layouts.py b/backend/app/services/site_finder/best_layouts.py index ea7045e5..d2623571 100644 --- a/backend/app/services/site_finder/best_layouts.py +++ b/backend/app/services/site_finder/best_layouts.py @@ -1109,9 +1109,6 @@ def get_best_layouts( id_row_dicts: list[dict[str, Any]] = [dict(r) for r in id_rows] all_obj_ids: list[int] = [int(r["obj_id"]) for r in id_row_dicts] raw_objects_total_in_radius = len(all_obj_ids) - # Число комплексов ДО exclude/filter — знаменатель для empty-ответа (совместимо - # со старой семантикой: пустой ответ отражал полный радиус, а не post-filter 0). - groups_total_pre_filter = len(group_radius_objects(id_row_dicts)) # Применить exclude / filter из request (и к obj_ids, и к строкам для группировки) exclude_set = set(request.exclude_competitor_obj_ids) @@ -1132,13 +1129,16 @@ def get_best_layouts( all_obj_ids = [oid for oid in all_obj_ids if _keep(oid)] # Группируем отфильтрованные obj_id в комплексы — знаменатель coverage. + # objects_total_in_radius во ВСЕХ ветках = len(complex_groups) (после фильтра): + # UI и PDF печатают его как N в «покрытие P% (Y из N комплексов)», а P считается + # по отфильтрованным (#2464). Число до фильтра — в raw_objects_total. complex_groups = group_radius_objects(id_row_dicts) if not all_obj_ids: return _empty_response( radius_km=request.radius_km, time_window=request.time_window, - objects_total_in_radius=groups_total_pre_filter, + objects_total_in_radius=len(complex_groups), raw_objects_total=raw_objects_total_in_radius, ) diff --git a/backend/app/services/site_finder/competitors.py b/backend/app/services/site_finder/competitors.py index 8a19ef14..d5faccac 100644 --- a/backend/app/services/site_finder/competitors.py +++ b/backend/app/services/site_finder/competitors.py @@ -344,21 +344,26 @@ _COMPETITORS_SQL = text(""" -- важна — velocity питает forecast). Спатиальный join дёшев, т.к. -- complexes мал (~1.5k строк с geom) — НЕ потому что latest_obj -- пре-фильтрован (радиус участка применяется ниже, в финальном SELECT). + -- + -- #2962: complex → проект Объектива берём из complex_sources (source= + -- 'objective', строго 1:1), а НЕ из objective_lots.complex_id. Тот проставлен + -- один раз миграцией 76 на загрузке 10.05, а еженедельный + -- 70_parse_objective_raw.py UPSERT'ом по objective_lot_id переписывает + -- project_name и не трогает complex_id → под одним complex_id лежат чужие ЖК + -- (прод 17.09: 236 354 из 303 677 строк, у «ЖК Мичуринский» 129 проектов). SELECT DISTINCT ON (o.obj_id) o.obj_id, - c.id AS complex_id + cs.source_id AS project_name, + -- #2962: имя проекта совпадает с именем объекта ДОМ.РФ без регистра и + -- пунктуации, в любую сторону («Квартал "Татлин"» = «Квартал Татлин», + -- «Парковый» ⊂ «Парковый квартал») + ( k.comm_key LIKE '%' || k.project_key || '%' + OR k.project_key LIKE '%' || k.comm_key || '%') AS name_ok FROM latest_obj o JOIN complexes c ON c.latitude IS NOT NULL AND c.longitude IS NOT NULL AND c.canonical_name IS NOT NULL - -- #968: только complexes, у которых ЕСТЬ objective velocity-данные → - -- «ближайший» выбирается среди data-bearing. Иначе ближайший complex - -- без objective_lots съедал бы матч (prod: +37 наивно → +181 так). - AND EXISTS ( - SELECT 1 FROM objective_lots ol - WHERE ol.complex_id = c.id AND ol.project_name IS NOT NULL - ) AND ST_DWithin( ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography, @@ -369,6 +374,20 @@ _COMPETITORS_SQL = text(""" OR lower(btrim(c.canonical_name)) LIKE '%' || lower(btrim(o.comm_name)) || '%' OR lower(btrim(o.comm_name)) LIKE '%' || lower(btrim(c.canonical_name)) || '%' ) + JOIN complex_sources cs + ON cs.complex_id = c.id + AND cs.source = 'objective' + -- #968: только complexes, у которых ЕСТЬ objective-данные → + -- «ближайший» выбирается среди data-bearing. Иначе ближайший complex + -- без objective_lots съедал бы матч (prod: +37 наивно → +181 так). + AND EXISTS ( + SELECT 1 FROM objective_lots ol + WHERE ol.project_name = cs.source_id + ) + CROSS JOIN LATERAL ( + SELECT regexp_replace(lower(o.comm_name), '[^0-9a-zа-яё]', '', 'g') AS comm_key, + regexp_replace(lower(cs.source_id), '[^0-9a-zа-яё]', '', 'g') AS project_key + ) k WHERE o.comm_name IS NOT NULL AND btrim(o.comm_name) <> '' -- иначе LIKE '%%' матчит всё → spatial-only AND o.obj_id NOT IN (SELECT domrf_obj_id FROM objective_complex_mapping) @@ -376,7 +395,13 @@ _COMPETITORS_SQL = text(""" ST_Distance( ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography - ) ASC + ) ASC, + -- #2962: 1:1 complex → objective-проект держится только на данных + -- (UNIQUE(source, source_id) второй проект у complex не запрещает). + -- При двух проектах берём сверенный по имени, иначе DISTINCT ON + -- брал любой, сверка его отвергала, и верный терялся. + name_ok DESC, + cs.source_id ), mapped AS ( -- PRIMARY (unchanged): 148 явных маппингов — velocity для них byte-identical. @@ -384,15 +409,18 @@ _COMPETITORS_SQL = text(""" cm.objective_complex_name FROM objective_complex_mapping cm UNION - -- FALLBACK (gap-fill only): все project_name ОДНОГО ближайшего complex'а - -- (у комплекса может быть несколько корпус-project_name → velocity - -- легитимно суммируется по ним, но НЕ по нескольким комплексам). + -- FALLBACK (gap-fill only): ровно один project_name ближайшего complex'а + -- (корпуса у Объектива — corpus_name внутри проекта, не отдельные имена). SELECT nc.obj_id, - ol.project_name AS objective_complex_name + nc.project_name AS objective_complex_name FROM nearest_cx nc - JOIN objective_lots ol - ON ol.complex_id = nc.complex_id - AND ol.project_name IS NOT NULL + -- #2962: связь complex → проект в complex_sources почти вся fuzzy и не + -- проверена (у «ЖК VEER PARK» стоит 'Clever Park', у «ЖК Графит» — + -- 'Гранит'), поэтому имя проекта сверяем с именем объекта ДОМ.РФ ещё раз + -- (name_ok в nearest_cx). Фильтр стоит ПОСЛЕ DISTINCT ON: внутри join + -- планировщик считал regexp на всех парах объект × проект (383k) раньше + -- гео-фильтра — 3 с против 0.4 с. + WHERE nc.name_ok ), velocity AS ( SELECT @@ -605,9 +633,11 @@ _SOLD_COUNT_SQL = text(""" # fallback должен покрывать ОБА, иначе конкурент с velocity>0 из spatial-матча получает # avg_price=None и price_similarity падает в нейтраль. Зеркалим тот же мост obj→lots: # PRIMARY: objective_complex_mapping.objective_complex_name == objective_lots.project_name -# GAP-FILL: nearest_cx (≤ :velocity_match_radius_m м + tolerant-name) → complex_id → -# objective_lots по complex_id (тот же DISTINCT ON ближайший complex, что и -# velocity gap-fill — обязан совпадать, чтобы цена и velocity были про ОДИН ЖК). +# GAP-FILL: nearest_cx (≤ :velocity_match_radius_m м + tolerant-name) → complex_sources +# (source='objective', 1:1) → objective_lots по project_name (тот же DISTINCT ON +# ближайший complex, что и velocity gap-fill — обязан совпадать, чтобы цена и +# velocity были про ОДИН ЖК). НЕ по objective_lots.complex_id: он устарел и под +# ним лежат чужие ЖК (#2962, см. _COMPETITORS_SQL nearest_cx). # obj_id мапится в РОВНО один источник (mapping 1:1; gap-fill — только для obj_id ВНЕ # mapping, см. NOT IN ниже), поэтому пересечения нет и UNION ALL безопасен. _OBJECTIVE_PRICE_FALLBACK_SQL = text(""" @@ -624,16 +654,17 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text(""" nearest_cx AS ( SELECT DISTINCT ON (o.obj_id) o.obj_id, - c.id AS complex_id + cs.source_id AS project_name, + -- #2962: имя проекта совпадает с именем объекта ДОМ.РФ без регистра и + -- пунктуации, в любую сторону («Квартал "Татлин"» = «Квартал Татлин», + -- «Парковый» ⊂ «Парковый квартал») + ( k.comm_key LIKE '%' || k.project_key || '%' + OR k.project_key LIKE '%' || k.comm_key || '%') AS name_ok FROM domrf_kn_objects o JOIN complexes c ON c.latitude IS NOT NULL AND c.longitude IS NOT NULL AND c.canonical_name IS NOT NULL - AND EXISTS ( - SELECT 1 FROM objective_lots ol - WHERE ol.complex_id = c.id AND ol.project_name IS NOT NULL - ) AND ST_DWithin( ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography, @@ -644,6 +675,17 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text(""" OR lower(btrim(c.canonical_name)) LIKE '%' || lower(btrim(o.comm_name)) || '%' OR lower(btrim(o.comm_name)) LIKE '%' || lower(btrim(c.canonical_name)) || '%' ) + JOIN complex_sources cs + ON cs.complex_id = c.id + AND cs.source = 'objective' + AND EXISTS ( + SELECT 1 FROM objective_lots ol + WHERE ol.project_name = cs.source_id + ) + CROSS JOIN LATERAL ( + SELECT regexp_replace(lower(o.comm_name), '[^0-9a-zа-яё]', '', 'g') AS comm_key, + regexp_replace(lower(cs.source_id), '[^0-9a-zа-яё]', '', 'g') AS project_key + ) k WHERE o.obj_id = ANY(:obj_ids) AND o.latitude IS NOT NULL AND o.longitude IS NOT NULL @@ -654,7 +696,13 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text(""" ST_Distance( ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography - ) ASC + ) ASC, + -- #2962: 1:1 complex → objective-проект держится только на данных + -- (UNIQUE(source, source_id) второй проект у complex не запрещает). + -- При двух проектах берём сверенный по имени, иначе DISTINCT ON + -- брал любой, сверка его отвергала, и верный терялся. + name_ok DESC, + cs.source_id ), gapfill_price AS ( SELECT @@ -662,8 +710,10 @@ _OBJECTIVE_PRICE_FALLBACK_SQL = text(""" ol.price_per_m2_rub AS price_per_m2_rub FROM nearest_cx nc JOIN objective_lots ol - ON ol.complex_id = nc.complex_id + ON ol.project_name = nc.project_name AND ol.price_per_m2_rub IS NOT NULL + -- та же сверка имени, что в _COMPETITORS_SQL mapped (#2962) + WHERE nc.name_ok ) SELECT p.obj_id, diff --git a/backend/app/workers/beat_schedule.py b/backend/app/workers/beat_schedule.py index 35ebe25f..3e1a51f4 100644 --- a/backend/app/workers/beat_schedule.py +++ b/backend/app/workers/beat_schedule.py @@ -329,7 +329,7 @@ def build_beat_schedule() -> dict: # вывел PKK6-эндпоинт), таблица pzz_zones_ekb пуста (0 rows на prod) — задача ни # разу не наполнила её успешно. ПЗЗ-данные пришли в систему ИНЫМ путём: # zone_regulation_cache (#1059, beat zone-regulation-refresh-monthly) + NSPD - # territorial_zones dumps (Phase 2.5 bulk_harvest, layer 875838). Активный beat + # territorial_zones в nspd_quarter_dumps (nspd_sync, layer 875838). Активный beat # каждый месяц дёргал deprecated PKK6 → broad except в pzz_sync.py логировал # ошибку как error → рекуррентил GlitchTip BACKEND-1B. # diff --git a/backend/tests/api/v1/test_parcel_best_layouts.py b/backend/tests/api/v1/test_parcel_best_layouts.py index 2ccaa9ae..c1a96153 100644 --- a/backend/tests/api/v1/test_parcel_best_layouts.py +++ b/backend/tests/api/v1/test_parcel_best_layouts.py @@ -264,8 +264,10 @@ def test_exclude_competitor_obj_ids_filter() -> None: # После исключения obj_id=20 список пуст → пустой ответ assert body["top_layouts"] == [] assert body["data_quality"]["confidence"] == "low" - # objects_total_in_radius = 1 (до исключения) - assert body["data_quality"]["objects_total_in_radius"] == 1 + # objects_total_in_radius — знаменатель coverage, после исключения 0 (#2464); + # число obj_id в радиусе до исключения — raw_objects_total + assert body["data_quality"]["objects_total_in_radius"] == 0 + assert body["data_quality"]["raw_objects_total"] == 1 finally: app.dependency_overrides.clear() diff --git a/backend/tests/services/cadastre/test_bulk_harvest_territorial.py b/backend/tests/services/cadastre/test_bulk_harvest_territorial.py deleted file mode 100644 index df40b3ed..00000000 --- a/backend/tests/services/cadastre/test_bulk_harvest_territorial.py +++ /dev/null @@ -1,198 +0,0 @@ -"""Тесты для _save_territorial_zones (bulk_harvest.py) — mock-based. - -Проверяет: -- Успешный UPSERT 3 features → 3 строки вставлены -- Повторный вызов → ON CONFLICT обновляет, не дублирует -- Feature без geometry → строка вставлена с geom=NULL, без краша -- Feature без zone_id → синтетический fallback zone_id используется -""" - -from __future__ import annotations - -from typing import Any -from unittest.mock import MagicMock - -from app.services.cadastre.bulk_harvest import _save_territorial_zones - - -def _make_feature( - feature_id: Any = "zone_1", - zone_code: str = "Ж-1", - zone_name: str = "Жилая смешанная", - permitted_use: str = "ИЖС", - has_geometry: bool = True, -) -> dict: - """Создать raw feature dict в формате get_features_in_bbox_grid.""" - geom = ( - { - "type": "Polygon", - "coordinates": [ - [ - [6090000.0, 7590000.0], - [6090100.0, 7590000.0], - [6090100.0, 7590100.0], - [6090000.0, 7590100.0], - [6090000.0, 7590000.0], - ] - ], - } - if has_geometry - else None - ) - return { - "id": feature_id, - "geometry": geom, - "properties": { - "zone_code": zone_code, - "zone_name": zone_name, - "permitted_use": permitted_use, - }, - } - - -def _make_db_mock() -> MagicMock: - """Mock SQLAlchemy Session с begin_nested() savepoint support.""" - db = MagicMock() - # begin_nested() используется как context manager - savepoint_ctx = MagicMock() - savepoint_ctx.__enter__ = MagicMock(return_value=savepoint_ctx) - savepoint_ctx.__exit__ = MagicMock(return_value=False) - db.begin_nested.return_value = savepoint_ctx - return db - - -class TestSaveTerritorialZones: - """Тесты для _save_territorial_zones.""" - - def test_three_features_inserted(self) -> None: - """3 features → returned count == 3, execute вызван 3 раза.""" - db = _make_db_mock() - features = [ - _make_feature("z1", "Ж-1"), - _make_feature("z2", "ОД-1"), - _make_feature("z3", "П-1"), - ] - - result = _save_territorial_zones(db, "66:41:0204016", features) - - assert result == 3 - assert db.execute.call_count == 3 - db.commit.assert_called_once() - - def test_empty_features_list(self) -> None: - """Пустой список → 0 inserted, commit всё равно вызван.""" - db = _make_db_mock() - - result = _save_territorial_zones(db, "66:41:0204016", []) - - assert result == 0 - db.execute.assert_not_called() - db.commit.assert_called_once() - - def test_feature_without_geometry_no_crash(self) -> None: - """Feature без geometry → geom=NULL, строка вставлена без краша.""" - db = _make_db_mock() - features = [_make_feature("zone_no_geom", has_geometry=False)] - - result = _save_territorial_zones(db, "66:41:0204016", features) - - assert result == 1 - # Проверяем что geom параметр передан как None - call_kwargs: dict = db.execute.call_args[0][1] - assert call_kwargs["geom"] is None - - def test_feature_without_zone_id_uses_fallback(self) -> None: - """Feature без id → md5-based fallback zone_id (stable между runs).""" - db = _make_db_mock() - features = [ - { - "id": None, - "geometry": None, - "properties": {"zone_code": "Ж-2"}, - } - ] - - result = _save_territorial_zones(db, "66:41:0204016", features) - - assert result == 1 - call_kwargs = db.execute.call_args[0][1] - zone_id: str = call_kwargs["zone_id"] - # fallback zone_id содержит quarter_cad и стабильный hash (12 hex chars) - assert zone_id.startswith("66:41:0204016_") - suffix = zone_id.split("_", 3)[-1] - assert len(suffix) == 12 - assert all(c in "0123456789abcdef" for c in suffix) - - # Второй вызов с теми же данными → тот же zone_id (идемпотентность) - db2 = _make_db_mock() - _save_territorial_zones(db2, "66:41:0204016", features) - call_kwargs2 = db2.execute.call_args[0][1] - assert call_kwargs2["zone_id"] == zone_id - - def test_zone_id_from_props_id(self) -> None: - """Если feature.id=None, но props['id'] есть — используется props['id'].""" - db = _make_db_mock() - features = [ - { - "id": None, - "geometry": None, - "properties": {"id": "props_id_42", "zone_code": "Ж-3"}, - } - ] - - result = _save_territorial_zones(db, "66:41:0204016", features) - - assert result == 1 - call_kwargs = db.execute.call_args[0][1] - assert call_kwargs["zone_id"] == "props_id_42" - - def test_execute_error_logged_not_raised(self) -> None: - """Exception в execute → строка не вставлена, warning залогирован, не re-raise.""" - db = _make_db_mock() - db.execute.side_effect = RuntimeError("DB error") - features = [_make_feature("z_err")] - - # Не должен бросить исключение - result = _save_territorial_zones(db, "66:41:0204016", features) - - assert result == 0 - db.commit.assert_called_once() - - def test_savepoint_used_per_row(self) -> None: - """begin_nested() вызывается для каждой строки (SAVEPOINT паттерн).""" - db = _make_db_mock() - features = [_make_feature(f"z{i}") for i in range(3)] - - _save_territorial_zones(db, "66:41:0204016", features) - - assert db.begin_nested.call_count == 3 - - def test_quarter_cad_param_passed(self) -> None: - """quarter_cad правильно передаётся в SQL параметры.""" - db = _make_db_mock() - features = [_make_feature("zone_check")] - - _save_territorial_zones(db, "66:41:9999999", features) - - call_kwargs = db.execute.call_args[0][1] - assert call_kwargs["quarter_cad"] == "66:41:9999999" - - def test_raw_props_serialized(self) -> None: - """raw_props — JSON строка из properties dict.""" - import json - - db = _make_db_mock() - features = [ - { - "id": "z_props", - "geometry": None, - "properties": {"zone_code": "ОД-2", "extra": "value"}, - } - ] - - _save_territorial_zones(db, "66:41:0204016", features) - - call_kwargs = db.execute.call_args[0][1] - raw = json.loads(call_kwargs["raw_props"]) - assert raw["zone_code"] == "ОД-2" - assert raw["extra"] == "value" diff --git a/backend/tests/services/site_finder/test_2962_competitors_gapfill_bridge.py b/backend/tests/services/site_finder/test_2962_competitors_gapfill_bridge.py new file mode 100644 index 00000000..10905e7c --- /dev/null +++ b/backend/tests/services/site_finder/test_2962_competitors_gapfill_bridge.py @@ -0,0 +1,246 @@ +"""Gap-fill конкурентов берёт скорость и цену своего ЖК, а не всех ЖК под complex_id (#2962). + +`objective_lots.complex_id` проставлен один раз миграцией 76, а еженедельный +`70_parse_objective_raw.py` UPSERT'ом по objective_lot_id переписывает project_name и +не трогает complex_id. Прод 17.09.2026: из 303 677 строк с complex_id у 236 354 +проект чужой; у 185 gap-fill конкурентов своих лотов 23 %, скорость завышена в +медиане в 39 раз. + +Тест герметичный и прогоняет НАСТОЯЩИЕ `_COMPETITORS_SQL` и +`_OBJECTIVE_PRICE_FALLBACK_SQL`: временные таблицы затеняют боевые в пределах сессии. +Нужен Postgres с PostGIS (ST_DWithin по geography). В CI он есть +(.forgejo/workflows/ci.yml, образ postgis/postgis), и там тест не пропускается +никогда: без PostGIS он падает с настоящей причиной. Пропуск разрешён только вне CI, +на машине без базы, и объявлен в skip_allowlist.txt. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import pytest +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker + + +def _dsn() -> str: + raw = os.environ.get("TEST_DATABASE_URL") or os.environ["DATABASE_URL"] + return ( + raw + if raw.startswith("postgresql+") + else raw.replace("postgresql://", "postgresql+psycopg://") + ) + + +def _postgis_reachable() -> tuple[bool, str]: + try: + eng = create_engine(_dsn(), connect_args={"connect_timeout": 3}) + with eng.connect() as c: + if c.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'postgis'")).first(): + return True, "" + return False, "нет расширения postgis" + except Exception as exc: + return False, str(exc) + + +_DB_OK, _DB_ERR = _postgis_reachable() +_IN_CI = bool(os.environ.get("GITHUB_ACTIONS") or os.environ.get("CI")) +pytestmark = pytest.mark.skipif( + not _DB_OK and not _IN_CI, reason=f"Postgres/PostGIS недоступен: {_DB_ERR}" +) + +_SCHEMA = [ + """CREATE TEMP TABLE domrf_kn_objects ( + obj_id bigint, comm_name text, dev_name text, obj_class text, + obj_class_fallback text, latitude double precision, longitude double precision, + flat_count int, site_status text, snapshot_date date) ON COMMIT DROP""", + """CREATE TEMP TABLE objective_complex_mapping ( + domrf_obj_id bigint, objective_complex_name text) ON COMMIT DROP""", + """CREATE TEMP TABLE complexes ( + id bigint, canonical_name text, latitude double precision, + longitude double precision) ON COMMIT DROP""", + """CREATE TEMP TABLE complex_sources ( + complex_id bigint, source text, source_id text) ON COMMIT DROP""", + """CREATE TEMP TABLE objective_lots ( + project_name text, complex_id bigint, price_per_m2_rub numeric) ON COMMIT DROP""", + """CREATE TEMP TABLE objective_corpus_room_month ( + project_name text, report_month date, deals_total_count int) ON COMMIT DROP""", +] + +MICH, VEER, STUDIO, EXPLICIT, TATLIN, PARK, GROVE, PINES = 1, 2, 3, 4, 5, 6, 7, 8 + +_DATA = [ + # obj_id, comm_name, …, latitude, longitude — объекты дальше 200 м друг от друга + """INSERT INTO domrf_kn_objects VALUES + (1, 'ЖК Мичуринский', NULL, NULL, NULL, 56.840, 60.600, 500, 'Строящиеся', CURRENT_DATE), + (2, 'ЖК VEER PARK', NULL, NULL, NULL, 56.850, 60.620, 500, 'Строящиеся', CURRENT_DATE), + (3, 'Жилой комплекс "СтудияПарк"', NULL, NULL, NULL, 56.830, 60.580, 500, + 'Строящиеся', CURRENT_DATE), + (4, 'ЖК Явный', NULL, NULL, NULL, 56.845, 60.640, 500, 'Строящиеся', CURRENT_DATE), + (5, 'Квартал "Татлин"', NULL, NULL, NULL, 56.835, 60.610, 500, 'Строящиеся', + CURRENT_DATE), + (6, 'Парковый', NULL, NULL, NULL, 56.855, 60.590, 500, 'Строящиеся', CURRENT_DATE), + (7, 'ЖК Роща Парк', NULL, NULL, NULL, 56.825, 60.630, 500, 'Строящиеся', CURRENT_DATE), + (8, 'ЖК Сосны', NULL, NULL, NULL, 56.860, 60.650, 500, 'Строящиеся', CURRENT_DATE)""", + "INSERT INTO objective_complex_mapping VALUES (4, 'Явный')", + """INSERT INTO complexes VALUES + (10, 'ЖК Мичуринский', 56.840, 60.600), + (20, 'ЖК VEER PARK', 56.850, 60.620), + (30, 'СтудияПарк', 56.830, 60.580), + (50, 'Квартал "Татлин"', 56.835, 60.610), + (60, 'Парковый', 56.855, 60.590), + (70, 'Роща', 56.825, 60.630), + (71, 'Роща Парк', 56.8259, 60.630), + (80, 'ЖК Сосны', 56.860, 60.650)""", + # 20 → 'Clever Park': неверная fuzzy-связь, как на проде (complexes.id=1493). + # 70 → 'Роща': проект без лотов, complex ближе «Роща Парк» (71, в 100 м). + # 80 → два objective-проекта: UNIQUE(source, source_id) этого не запрещает. + """INSERT INTO complex_sources VALUES + (10, 'objective', 'Мичуринский'), + (20, 'objective', 'Clever Park'), + (30, 'objective', 'Студия Парк'), + (50, 'objective', 'Квартал Татлин'), + (60, 'objective', 'Парковый квартал'), + (70, 'objective', 'Роща'), + (71, 'objective', 'Роща Парк'), + (80, 'objective', 'Клён'), + (80, 'objective', 'Сосны')""", + # Под complex_id=10 лежит и свой лот, и три лота чужого «Малахита» с устаревшим + # complex_id; два своих лота новые — complex_id у них NULL. + """INSERT INTO objective_lots VALUES + ('Мичуринский', 10, 100000), ('Мичуринский', NULL, 110000), + ('Мичуринский', NULL, 120000), + ('Малахит', 10, 300000), ('Малахит', 10, 300000), ('Малахит', 10, 300000), + ('Clever Park', NULL, 150000), + ('Студия Парк', NULL, 90000), + ('Явный', NULL, 200000), + ('Квартал Татлин', NULL, 95000), + ('Парковый квартал', NULL, 130000), + ('Роща Парк', NULL, 105000), + ('Клён', NULL, 250000), + ('Сосны', NULL, 115000)""", + """INSERT INTO objective_corpus_room_month VALUES + ('Мичуринский', date_trunc('month', now())::date, 30), + ('Малахит', date_trunc('month', now())::date, 300), + ('Clever Park', date_trunc('month', now())::date, 90), + ('Студия Парк', date_trunc('month', now())::date, 15), + ('Явный', date_trunc('month', now())::date, 60), + ('Квартал Татлин', date_trunc('month', now())::date, 18), + ('Парковый квартал', date_trunc('month', now())::date, 12), + ('Роща Парк', date_trunc('month', now())::date, 21), + ('Клён', date_trunc('month', now())::date, 240), + ('Сосны', date_trunc('month', now())::date, 27)""", +] + + +@pytest.fixture(scope="module") +def result() -> tuple[dict[int, float], dict[int, float]]: + """(velocity по obj_id, медианная цена objective-fallback по obj_id).""" + from app.services.site_finder.competitors import ( + _COMPETITORS_SQL, + _OBJECTIVE_PRICE_FALLBACK_SQL, + _VELOCITY_MATCH_RADIUS_M, + ) + + session = sessionmaker(bind=create_engine(_dsn()))() + try: + for stmt in _SCHEMA + _DATA: + session.execute(text(stmt)) + vel_rows = session.execute( + _COMPETITORS_SQL, + { + "center_lon": 60.6, + "center_lat": 56.84, + "radius_m": 5000.0, + "time_window_months": 3.0, + "window_interval": "3 months", + "velocity_match_radius_m": _VELOCITY_MATCH_RADIUS_M, + }, + ).mappings() + velocity = {int(r["obj_id"]): float(r["velocity_per_month"]) for r in vel_rows} + price_rows = session.execute( + _OBJECTIVE_PRICE_FALLBACK_SQL, + { + "obj_ids": [MICH, VEER, STUDIO, EXPLICIT, TATLIN, PARK, GROVE, PINES], + "velocity_match_radius_m": _VELOCITY_MATCH_RADIUS_M, + }, + ).mappings() + price = {int(r["obj_id"]): float(r["median_price_per_m2"]) for r in price_rows} + return velocity, price + finally: + session.rollback() + session.close() + + +def test_gapfill_velocity_counts_only_own_project(result) -> None: + """30 сделок своего ЖК за 3 месяца = 10/мес; с чужим «Малахитом» было бы 110.""" + velocity, _ = result + assert velocity[MICH] == pytest.approx(10.0), velocity + + +def test_gapfill_price_is_median_of_own_lots(result) -> None: + """Медиана своих лотов 100/110/120 тыс. = 110 тыс.; по complex_id было бы 300 тыс.""" + _, price = result + assert price[MICH] == pytest.approx(110000.0), price + + +def test_wrong_fuzzy_link_gives_no_numbers(result) -> None: + """«ЖК VEER PARK» связан в complex_sources с 'Clever Park' — чужие цифры не берём.""" + velocity, price = result + assert velocity[VEER] == 0.0, velocity + assert VEER not in price, price + + +def test_space_difference_still_matches(result) -> None: + """«СтудияПарк» и «Студия Парк» — один ЖК: 15 сделок / 3 = 5, цена 90 тыс.""" + velocity, price = result + assert velocity[STUDIO] == pytest.approx(5.0), velocity + assert price[STUDIO] == pytest.approx(90000.0), price + + +def test_explicit_mapping_unchanged(result) -> None: + """Явный маппинг правка не трогает: 60 / 3 = 20, цена 200 тыс.""" + velocity, price = result + assert velocity[EXPLICIT] == pytest.approx(20.0), velocity + assert price[EXPLICIT] == pytest.approx(200000.0), price + + +def test_quotes_inside_name_still_match(result) -> None: + """«Квартал "Татлин"» и «Квартал Татлин»: кавычки посреди имени сверку не ломают. + + Убрать только пробелы мало: «квартал"татлин"» и «кварталтатлин» не входят друг в + друга. 18 сделок / 3 = 6, цена 95 тыс. + """ + velocity, price = result + assert velocity[TATLIN] == pytest.approx(6.0), velocity + assert price[TATLIN] == pytest.approx(95000.0), price + + +def test_project_name_longer_than_object_name_matches(result) -> None: + """Объект «Парковый», проект «Парковый квартал»: имя сверяется в обе стороны. 12 / 3 = 4.""" + velocity, price = result + assert velocity[PARK] == pytest.approx(4.0), velocity + assert price[PARK] == pytest.approx(130000.0), price + + +def test_nearest_complex_without_lots_does_not_eat_the_match(result) -> None: + """#968: ближайший complex «Роща» без лотов не забирает объект у «Роща Парк» в 100 м. + + Иначе у «ЖК Роща Парк» не было бы ни скорости, ни цены. 21 / 3 = 7, цена 105 тыс. + """ + velocity, price = result + assert velocity[GROVE] == pytest.approx(7.0), velocity + assert price[GROVE] == pytest.approx(105000.0), price + + +def test_complex_with_two_projects_takes_the_matching_one(result) -> None: + """У complex два objective-проекта, «Клён» и «Сосны»: берём сверенный по имени. + + Без этого DISTINCT ON брал любой из двух (здесь — «Клён»), сверка его отвергала, + и «Сосны» терялись. 27 / 3 = 9, цена 115 тыс. + """ + velocity, price = result + assert velocity[PINES] == pytest.approx(9.0), velocity + assert price[PINES] == pytest.approx(115000.0), price diff --git a/backend/tests/services/site_finder/test_best_layouts.py b/backend/tests/services/site_finder/test_best_layouts.py index 6ddc3906..8d571caa 100644 --- a/backend/tests/services/site_finder/test_best_layouts.py +++ b/backend/tests/services/site_finder/test_best_layouts.py @@ -433,7 +433,43 @@ def test_exclude_competitor_obj_ids() -> None: resp = get_best_layouts(db, CAD_NUM, req) assert resp.top_layouts == [] - assert resp.data_quality.objects_total_in_radius == 1 + # после исключения комплексов 0 (знаменатель coverage), в радиусе было 1 obj_id + assert resp.data_quality.objects_total_in_radius == 0 + assert resp.data_quality.raw_objects_total == 1 + + +@pytest.mark.parametrize( + ("filter_ids", "vel_obj_ids", "expected_total", "expected_with_data"), + [ + ([99], None, 0, 0), # фильтр не оставил никого → ранний пустой ответ + ([20], None, 1, 0), # остался 1 комплекс без velocity → пустой ответ + ([20], [20], 1, 1), # остался 1 комплекс с velocity → штатная ветка + ], + ids=["all-filtered", "no-velocity", "normal"], +) +def test_objects_total_is_coverage_denominator_in_every_branch( + filter_ids: list[int], + vel_obj_ids: list[int] | None, + expected_total: int, + expected_with_data: int, +) -> None: + """#2464 (best_layouts:1113): objects_total_in_radius значит одно во всех ветках. + + UI и PDF печатают «покрытие P% (Y из N комплексов)», значит N обязан быть + знаменателем P, а P считается по комплексам ПОСЛЕ filter. Раньше ранний пустой + ответ отдавал число ДО фильтра (2), две другие ветки — после. + """ + id_rows = [_obj_id_row(20), _obj_id_row(30)] + vel_rows = [_vel_row("2", deals_window=30.0, obj_ids=vel_obj_ids)] if vel_obj_ids else [] + db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows) + resp = get_best_layouts(db, CAD_NUM, _request(filter_competitor_obj_ids=filter_ids)) + + dq = resp.data_quality + assert dq.objects_total_in_radius == expected_total + assert dq.objects_with_velocity_data == expected_with_data + assert dq.raw_objects_total == 2 # число до фильтра — отдельным полем + expected_pct = expected_with_data / expected_total * 100.0 if expected_total else 0.0 + assert dq.velocity_coverage_pct == pytest.approx(expected_pct) # ── Тест: total_sold_in_window совпадает с deals_window ────────────────────── diff --git a/backend/tests/services/test_cadastre_bulk.py b/backend/tests/services/test_cadastre_bulk.py index d1af98da..c1861037 100644 --- a/backend/tests/services/test_cadastre_bulk.py +++ b/backend/tests/services/test_cadastre_bulk.py @@ -489,7 +489,7 @@ async def test_harvest_quarter_does_not_early_exit_on_shared_phase_done() -> Non db = MagicMock() # Симулируем shared phase_state с phase=done от ДРУГОГО quarter. # xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None → - # grid-walk + territorial_zones фазы корректно пропускаются (тест про + # grid-walk фаза корректно пропускается (тест про # snapshot/idempotency, не про geometry). Тот же dict возвращается на ВСЕ # db.execute().mappings().first() в этом тесте. db.execute = MagicMock( @@ -554,7 +554,7 @@ async def test_harvest_quarter_calls_upsert_features() -> None: db = MagicMock() # phase_state = None → начинаем с нуля # xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None → - # grid-walk + territorial_zones фазы пропускаются (тесты про snapshot / + # grid-walk фаза пропускается (тесты про snapshot / # per-cat-probe, не про geometry). Тот же dict на ВСЕ # db.execute().mappings().first() вызовы. db.execute = MagicMock( @@ -759,7 +759,7 @@ async def test_harvest_quarter_calls_per_cat_probe_for_zouit_when_meta_nonzero() db = MagicMock() # xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None → - # grid-walk + territorial_zones фазы пропускаются (тесты про snapshot / + # grid-walk фаза пропускается (тесты про snapshot / # per-cat-probe, не про geometry). Тот же dict на ВСЕ # db.execute().mappings().first() вызовы. db.execute = MagicMock( @@ -834,7 +834,7 @@ async def test_harvest_quarter_skips_per_cat_probe_when_meta_zero() -> None: db = MagicMock() # xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None → - # grid-walk + territorial_zones фазы пропускаются (тесты про snapshot / + # grid-walk фаза пропускается (тесты про snapshot / # per-cat-probe, не про geometry). Тот же dict на ВСЕ # db.execute().mappings().first() вызовы. db.execute = MagicMock( @@ -904,7 +904,7 @@ async def test_harvest_quarter_per_cat_probe_enk_called_when_meta_nonzero() -> N db = MagicMock() # xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None → - # grid-walk + territorial_zones фазы пропускаются (тесты про snapshot / + # grid-walk фаза пропускается (тесты про snapshot / # per-cat-probe, не про geometry). Тот же dict на ВСЕ # db.execute().mappings().first() вызовы. db.execute = MagicMock( @@ -1223,7 +1223,7 @@ async def test_harvest_quarter_geom_heal_failure_does_not_propagate() -> None: db = MagicMock() # xmin=None → quarter_bbox_3857 (grid-walk geometry helper) вернёт None → - # grid-walk + territorial_zones фазы пропускаются (тесты про snapshot / + # grid-walk фаза пропускается (тесты про snapshot / # per-cat-probe, не про geometry). Тот же dict на ВСЕ # db.execute().mappings().first() вызовы. db.execute = MagicMock( @@ -1518,7 +1518,6 @@ async def test_harvest_quarter_records_failed_layer_in_phase_state() -> None: db = _mock_db_grid_bbox() client = AsyncMock() client.search_by_quarter = AsyncMock(return_value=snapshot) - client.get_territorial_zones_in_bbox = AsyncMock(return_value=[]) progress_states: list[dict[str, Any]] = [] @@ -1557,6 +1556,42 @@ async def test_harvest_quarter_records_failed_layer_in_phase_state() -> None: assert progress_states[-1]["phase"] == "done" +@pytest.mark.asyncio +async def test_harvest_quarter_makes_no_territorial_zones_request_2985() -> None: + """#2985: Phase 2.5 удалена. Квартал без overflow с валидным bbox стоит ровно один + запрос к НСПД (search_by_quarter) — отдельного grid-walk за ПЗЗ больше нет.""" + from app.services.cadastre.bulk_harvest import harvest_quarter + + snapshot = QuarterSnapshot( + quarter="66:41:0303161", + fetched_at="2026-05-15T10:00:00+00:00", + features=[_make_parcel_feature()], + meta_counts={}, + ) + client = AsyncMock() + client.search_by_quarter = AsyncMock(return_value=snapshot) + progress_states: list[dict[str, Any]] = [] + + with ( + patch("app.services.cadastre.bulk_harvest.upsert_features") as mock_upsert, + patch("app.services.cadastre.bulk_harvest.quarter_bbox_3857") as mock_bbox, + ): + mock_bbox.return_value = (6735845.0, 8329000.0, 6736595.0, 8329750.0) + mock_upsert.return_value = dict.fromkeys( + ("parcels", "buildings", "constructions", "oncs", "enks", "zouit", "skipped"), 0 + ) + await harvest_quarter( + db=_mock_db_grid_bbox(), + client=client, + quarter="66:41:0303161", + job_id=1, + update_progress=progress_states.append, + ) + + assert [c[0] for c in client.mock_calls] == ["search_by_quarter"] + assert [s["phase"] for s in progress_states] == ["snapshot_started", "snapshot_done", "done"] + + # ── Issue #200: geom backfill для участков с geom IS NULL ───────────────────── diff --git a/backend/tests/skip_allowlist.txt b/backend/tests/skip_allowlist.txt index e77642d9..fe4b428e 100644 --- a/backend/tests/skip_allowlist.txt +++ b/backend/tests/skip_allowlist.txt @@ -144,6 +144,23 @@ tests/sql/test_2464_area_bin_unknown.py::test_lot_without_area_is_not_counted_as tests/sql/test_2464_area_bin_unknown.py::test_unknown_area_gets_its_own_bucket tests/sql/test_2464_area_bin_unknown.py::test_total_lots_preserved tests/sql/test_2464_area_bin_unknown.py::test_normal_buckets_unchanged + +# ── #2962: мост gap-fill конкурентов (complex_sources → project_name) ───────── +# Нужен Postgres С PostGIS: тесты затеняют боевые таблицы временными и гоняют +# НАСТОЯЩИЕ _COMPETITORS_SQL и _OBJECTIVE_PRICE_FALLBACK_SQL, а там ST_DWithin по +# geography. В CI ИДУТ (ci.yml поднимает postgis/postgis:16-3.4) и пропуститься +# там НЕ МОГУТ: при CI=true skipif выключен, без PostGIS тест падает. Записи нужны +# только для машины без базы. +tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_gapfill_velocity_counts_only_own_project +tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_gapfill_price_is_median_of_own_lots +tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_wrong_fuzzy_link_gives_no_numbers +tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_space_difference_still_matches +tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_explicit_mapping_unchanged +tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_quotes_inside_name_still_match +tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_project_name_longer_than_object_name_matches +tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_nearest_complex_without_lots_does_not_eat_the_match +tests/services/site_finder/test_2962_competitors_gapfill_bridge.py::test_complex_with_two_projects_takes_the_matching_one + # ── #2464: backfill act_date (миграция 191) ────────────────────────────────── # Нужен живой Postgres: тесты создают ВРЕМЕННУЮ копию land_reservation в прод-форме # (9+2 строки с датой Генплана + контрольные посторонние) и прогоняют ТЕЛО миграции diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index a6fe69f3..5f5ca292 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -31,6 +31,7 @@ from app.schemas.trade_in import ( CoverageProbeInput, CoverageProbeResponse, DkpCorridor, + ExposureWindow, HouseAnalyticsKpi, HouseAnalyticsResponse, HouseInfoForEstimate, @@ -390,6 +391,7 @@ async def _try_revive_dead_estimate( ), {"orig": str(estimate_id), "temp": str(temp_id)}, ) + w = result.exposure_window # #2898: окно целиком или NULL целиком db.execute( text( """ @@ -418,6 +420,10 @@ async def _try_revive_dead_estimate( ratio_basis = :ratio_basis, relaxations = CAST(:relaxations_json AS jsonb), reliability = :reliability, + est_days_p25 = :est_days_p25, + est_days_p50 = :est_days_p50, + est_days_p75 = :est_days_p75, + est_days_n = :est_days_n, revival_completed_at = NOW() WHERE id = CAST(:id AS uuid) """ @@ -452,6 +458,10 @@ async def _try_revive_dead_estimate( "ratio_basis": result.ratio_basis, "relaxations_json": json.dumps(result.relaxations, ensure_ascii=False), "reliability": result.reliability, + "est_days_p25": w.p25_days if w else None, + "est_days_p50": w.p50_days if w else None, + "est_days_p75": w.p75_days if w else None, + "est_days_n": w.n if w else None, }, ) db.execute( @@ -678,7 +688,8 @@ def load_estimate( expected_sold_price, expected_sold_range_low, expected_sold_range_high, expected_sold_per_m2, asking_to_sold_ratio, ratio_basis, created_by, created_at, - relaxations, reliability + relaxations, reliability, + est_days_p25, est_days_p50, est_days_p75, est_days_n FROM trade_in_estimates WHERE id = CAST(:id AS uuid) AND {ESTIMATE_READABLE_SQL} @@ -843,6 +854,19 @@ def load_estimate( # миграции 267) деградирует в None — «позиции не знаем», — а не роняет # ответ AttributeError'ом. market_percentile=getattr(row, "market_percentile", None), + # #2898: окно из колонок, а не пересчёт — по ссылке то же окно, что при оценке. + # getattr: строка без колонок (in-memory double) — окна нет, а не AttributeError. + exposure_window=( + ExposureWindow( + p25_days=row.est_days_p25, + p50_days=row.est_days_p50, + p75_days=row.est_days_p75, + n=row.est_days_n, + ) + if getattr(row, "est_days_n", None) is not None + else None + ), + est_days_on_market=getattr(row, "est_days_p50", None), period_months=12, analogs=analogs, actual_deals=actual_deals, diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index 922098c0..b23ab98c 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -230,6 +230,21 @@ class PriceTrendPoint(BaseModel): ppm2: int # медиана ₽/м² за месяц +class ExposureWindow(BaseModel): + """#2898: сколько дней похожие квартиры рядом висели до снятия объявления. + + Квартили `house_placement_history.exposure_days`: те же комнаты, площадь ±15%, + дома в радиусе подбора аналогов, снятые за последние 24 мес. Это срок ДО СНЯТИЯ + объявления, а не подтверждённой продажи: в данных снятие и продажа неразличимы. + Окно либо целиком, либо None (выборка < `EXPOSURE_WINDOW_MIN_N`). + """ + + p25_days: int + p50_days: int + p75_days: int + n: int # сколько снятых объявлений в выборке + + class AggregatedEstimate(BaseModel): estimate_id: UUID median_price_rub: int @@ -292,7 +307,11 @@ class AggregatedEstimate(BaseModel): data_freshness_minutes: int | None = None # сколько минут назад был самый свежий парсинг # абсолютный timestamp самого свежего парсинга аналогов last_scraped_at: datetime | None = None - est_days_on_market: int | None = None # прогноз срока продажи (медиана по аналогам) + # #2898: окно срока экспозиции (p25–p75) — показывать его, а не одно число. + exposure_window: ExposureWindow | None = None + # Устаревшее, оставлено для старых клиентов: = exposure_window.p50_days. Раньше — + # медиана возраста АКТИВНЫХ объявлений-аналогов (цензурированная выборка). + est_days_on_market: int | None = None cian_valuation: CianValuationSummary | None = None # ── Месячный ₽/м² тренд для целевого дома (web TREND chart) — ADDITIVE + OPTIONAL ── # ~12-24 точки. Источник: houses_price_dynamics (preferred, пока пуста в prod) → diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 8beb6cd1..61944307 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -34,6 +34,7 @@ from typing import Any, Literal from uuid import uuid4 from fastapi import HTTPException +from scraper_kit.orchestration.pipeline import CITY_DISPLAY_NAMES from scraper_kit.providers.avito.imv import ( IMVAddressNotFoundError, IMVAuthError, @@ -63,6 +64,7 @@ from app.schemas.trade_in import ( AvitoImvSummary, CianValuationSummary, DkpCorridor, + ExposureWindow, PriceTrendPoint, TradeInEstimateInput, ) @@ -2115,15 +2117,29 @@ def _sber_time_factor(series: dict[date, float], deal_month: date) -> float: return max(SBER_TIME_FACTOR_MIN, min(SBER_TIME_FACTOR_MAX, factor)) -# Word/phrase-boundary regex поверх geocoder.SVERDLOVSK_OBLAST_CITIES (~30 городов -# региона 66) — resolve target-города для city-scoping ДКП-коридора (см. -# _resolve_target_city). Длинные имена раньше коротких (сортировка по len desc), -# чтобы «нижний тагил» матчился целиком, а не как «тагил» после «нижний» отдельно -# (тут это не критично т.к. паттерн — alternation по ЦЕЛЫМ именам городов, но -# сохраняем ту же дисциплину сортировки, что и geocoder._OBLAST_CITY_RE). +# #2583 H1: словарь региона 66 (24 имени) не знал 23 города, по которым уже идут +# развёртки (Реж, Карпинск, Кушва, Лесной, Тавда…). Нераспознанный город снимал с +# ДКП-коридора городской фильтр: «Реж, Ленина, 12» получал коридор по «Ленина» всей +# области, где доминирует ЕКБ. Добираем имена городов развёрток (те же, что пишутся в +# listings.city), кроме городов других регионов (Москва). В общий REGIONS[66].cities +# НЕ кладём: на нём стоят гейты геокодера и токены ключа дома (_OBLAST_CITY_TOKENS +# вырезал бы «новая»/«лесной» из имён улиц). ё→е — вход нормализуется до поиска, а +# deals.city хранит е-формы («Артемовский»). +_TARGET_CITIES: frozenset[str] = SVERDLOVSK_OBLAST_CITIES | frozenset( + name.lower().replace("ё", "е") + for name in CITY_DISPLAY_NAMES.values() + if (region := regions_mod.region_by_city(name)) is None or region.code == 66 +) + +# Word/phrase-boundary regex поверх _TARGET_CITIES — resolve target-города для +# city-scoping ДКП-коридора (см. _resolve_target_city). Длинные имена раньше коротких +# (сортировка по len desc), чтобы «нижний тагил» матчился целиком, а не как «тагил» +# после «нижний» отдельно (тут это не критично т.к. паттерн — alternation по ЦЕЛЫМ +# именам городов, но сохраняем ту же дисциплину сортировки, что и +# geocoder._OBLAST_CITY_RE). _TARGET_CITY_RE = re.compile( r"\b(?:" - + "|".join(re.escape(c) for c in sorted(SVERDLOVSK_OBLAST_CITIES, key=len, reverse=True)) + + "|".join(re.escape(c) for c in sorted(_TARGET_CITIES, key=len, reverse=True)) + r")\b" ) @@ -2131,8 +2147,8 @@ _TARGET_CITY_RE = re.compile( def _resolve_target_city(address_text: str | None) -> str | None: """Извлекает город региона 66 из геокодированного адреса (best-effort). - Ищет ЛЮБОЕ известное имя города (geocoder.SVERDLOVSK_OBLAST_CITIES) по - границе слова/фразы — работает и для forward-формата («Екатеринбург, ул. + Ищет ЛЮБОЕ известное имя города (_TARGET_CITIES: словарь региона 66 + города + развёрток) по границе слова/фразы — работает и для forward-формата («Екатеринбург, ул. Ленина, 5»), и для reverse/Yandex raw-текста («Россия, Свердловская область, Нижний Тагил, улица Ленина, 50»), позиция города в строке не важна. ё→е нормализация ДО поиска (симметрично _normalize_building_key). @@ -5502,6 +5518,19 @@ async def estimate_quality( sources_used = _canonical_sources((lot.source for lot in analogs_lots), valuation_flags) source_counts = _source_counts([lot.source for lot in analogs_lots]) freshness_pre = _compute_freshness_minutes(metadata_lots) + # #2898: окно считается один раз здесь и персистится — отчёт по ссылке через месяц + # покажет то же окно, что в момент расчёта, а не пересчёт по новой истории. + exposure_window = _exposure_window( + await run_db_thread( + _fetch_exposure_days, + db, + lat=geo.lat, + lon=geo.lon, + rooms=payload.rooms, + area=payload.area_m2, + radius_m=search_radius_m, + ) + ) # DaData enrichment (PR Q1) — заполняется только если service отработал. # При DaData = None все колонки идут в DB как NULL (graceful). dadata_metro_json = ( @@ -5531,6 +5560,7 @@ async def estimate_quality( expected_sold_range_high, expected_sold_per_m2, asking_to_sold_ratio, ratio_basis, relaxations, reliability, + est_days_p25, est_days_p50, est_days_p75, est_days_n, created_by, expires_at, consent, client_ip, consent_policy_version, consent_text_snapshot @@ -5554,6 +5584,7 @@ async def estimate_quality( :expected_sold_range_high, :expected_sold_per_m2, :asking_to_sold_ratio, :ratio_basis, CAST(:relaxations_json AS jsonb), :reliability, + :est_days_p25, :est_days_p50, :est_days_p75, :est_days_n, :created_by, :expires_at, :consent, CAST(:client_ip AS inet), :consent_policy_version, @@ -5614,6 +5645,11 @@ async def estimate_quality( # пропадал при открытии оценки по сохранённой ссылке (?id=). "relaxations_json": json.dumps(relaxations, ensure_ascii=False), "reliability": reliability, + # #2898: окно пишем целиком или NULL целиком (n < порога). + "est_days_p25": exposure_window.p25_days if exposure_window else None, + "est_days_p50": exposure_window.p50_days if exposure_window else None, + "est_days_p75": exposure_window.p75_days if exposure_window else None, + "est_days_n": exposure_window.n if exposure_window else None, "created_by": created_by, "expires_at": expires_at, **_estimate_consent_persist_fields(require_consent, client_ip), @@ -5784,7 +5820,8 @@ async def estimate_quality( data_freshness_minutes=freshness_min, last_scraped_at=last_scraped_at, price_trend=price_trend, - est_days_on_market=_estimate_days_on_market(metadata_lots, deals), + exposure_window=exposure_window, + est_days_on_market=exposure_window.p50_days if exposure_window else None, cian_valuation=( CianValuationSummary( sale_price_rub=int(cian_val.sale_price_rub) if cian_val.sale_price_rub else None, @@ -5976,23 +6013,88 @@ def _manual_review( return (bool(reasons), reasons) -def _estimate_days_on_market( - listings: list[dict[str, Any]], deals: list[dict[str, Any]] -) -> int | None: - """Прогноз срока продажи — медиана days_on_market по аналогам/сделкам. +# #2898: окно срока экспозиции вместо одного числа. Раньше est_days_on_market был +# медианой days_on_market АКТИВНЫХ аналогов — возраст висящего объявления, а не срок +# до снятия (цензурированная выборка; deals.days_on_market пуст). Реальная экспозиция +# снятых объявлений — house_placement_history.exposure_days. +# +# Порог 30 — замер 17.09.2026 на 51 прод-когорте с n>=80 (радиус 1 км): подвыборка +# в 10 лотов даёт среднюю ошибку края окна 34-40%, 20 — 24%, 30 — 18%, 50 — 12%. +# При 30 окно есть у 94 из 217 недавних оценок (при 20 — у 109). +EXPOSURE_WINDOW_MIN_N = 30 +EXPOSURE_WINDOW_MONTHS = 24 - Возвращает None если ни у одного аналога нет данных о сроке экспозиции - (наши парсеры не всегда его отдают — честно показываем «нет данных»). + +def _exposure_window(days: list[int]) -> ExposureWindow | None: + """Квартили срока экспозиции; None, если выборка меньше EXPOSURE_WINDOW_MIN_N. + + method="inclusive" — та же линейная интерполяция, что percentile_cont в + sell-time-sensitivity, чтобы одно и то же окно не расходилось на единицу. """ - values = [ - int(lot["days_on_market"]) - for lot in (*listings, *deals) - if lot.get("days_on_market") and int(lot["days_on_market"]) > 0 - ] - if len(values) < 3: + if len(days) < EXPOSURE_WINDOW_MIN_N: return None - values.sort() - return values[len(values) // 2] + p25, p50, p75 = statistics.quantiles(days, n=4, method="inclusive") + return ExposureWindow( + p25_days=int(p25 + 0.5), + p50_days=int(p50 + 0.5), + p75_days=int(p75 + 0.5), + n=len(days), + ) + + +def _fetch_exposure_days( + db: Session, + *, + lat: float | None, + lon: float | None, + rooms: int, + area: float, + radius_m: int, +) -> list[int]: + """exposure_days снятых объявлений той же комнатности и площади ±AREA_TOLERANCE + в домах в радиусе подбора аналогов. Best-effort: ошибка → [] (окна нет). + + ponytail: без дедупа avito_imv/yandex_valuation — прод 17.09: у 926 из 9482 + yandex-строк есть avito-двойник (~3% всей выборки), на квартили не влияет. + """ + if lat is None or lon is None or not area: + return [] + try: + # SAVEPOINT на месте глушения: упавший SELECT не должен отравить транзакцию, + # в которой следом идёт INSERT оценки. + with db.begin_nested(): + rows = db.execute( + text( + """ + SELECT h.exposure_days + FROM house_placement_history h + JOIN houses ho ON ho.id = h.house_id + WHERE ST_DWithin( + ho.geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius + ) + AND h.rooms = :rooms + AND h.area_m2 BETWEEN :area_min AND :area_max + AND h.exposure_days IS NOT NULL + AND h.removed_date IS NOT NULL + AND h.removed_date <= CURRENT_DATE + AND h.removed_date > CURRENT_DATE + - make_interval(months => CAST(:months AS integer)) + """ + ), + { + "lat": lat, + "lon": lon, + "radius": radius_m, + "rooms": rooms, + "area_min": area * (1 - AREA_TOLERANCE), + "area_max": area * (1 + AREA_TOLERANCE), + "months": EXPOSURE_WINDOW_MONTHS, + }, + ).all() + except Exception as exc: + logger.warning("exposure_window lookup failed (graceful): %s", exc) + return [] + return [int(r[0]) for r in rows] def _compute_freshness_minutes(lots: list[dict[str, Any]]) -> int | None: diff --git a/tradein-mvp/backend/app/services/gar_flats_loader.py b/tradein-mvp/backend/app/services/gar_flats_loader.py index b7ae7ed9..059e5d73 100644 --- a/tradein-mvp/backend/app/services/gar_flats_loader.py +++ b/tradein-mvp/backend/app/services/gar_flats_loader.py @@ -603,11 +603,17 @@ def default_city_filter_for_region(region_code: str | None) -> str | None: # НЕ разрешается угадыванием: такой canon вообще не матчится (безопасная деградация — # пропущенный дом лучше неверно приписанного). Предикат `gar_flat_count IS DISTINCT FROM` → # повторный прогон не трогает уже совпавшие строки. +# +# #2583 H5: фильтры выше стоят только на стороне ГАР, а UPDATE houses шёл по одному канону +# по ВСЕМ домам всех регионов. После #3523 (матч для 77/50) прогон региона 50 проставил +# подмосковные guid екатеринбургским домам с тем же каноном (прод 2026-09-17: 578 домов +# региона 66 с guid региона 50, 340 — региона 77, 1371 дом 50 с guid 77, 497 домов 77 с +# guid 50). Дом получает guid только своего региона; канон ранжируется внутри региона. _MATCH_SQL = text( """ WITH gar_scope AS ( SELECT - tradein_canon_addr(norm_address) AS canon, house_guid, flat_count + tradein_canon_addr(norm_address) AS canon, house_guid, flat_count, region_code FROM gar_house_flats WHERE flat_count > 0 AND tradein_canon_addr(norm_address) <> '' @@ -622,15 +628,15 @@ _MATCH_SQL = text( ), gar_ranked AS ( SELECT - canon, house_guid, flat_count, + canon, house_guid, flat_count, region_code, ROW_NUMBER() OVER ( - PARTITION BY canon ORDER BY flat_count DESC, house_guid + PARTITION BY region_code, canon ORDER BY flat_count DESC, house_guid ) AS rn, - COUNT(*) OVER (PARTITION BY canon) AS canon_hits + COUNT(*) OVER (PARTITION BY region_code, canon) AS canon_hits FROM gar_scope ), gar_pick AS ( - SELECT canon, house_guid, flat_count + SELECT canon, house_guid, flat_count, region_code FROM gar_ranked WHERE rn = 1 AND (CAST(:city AS text) IS NOT NULL OR canon_hits = 1) @@ -644,10 +650,33 @@ _MATCH_SQL = text( WHERE tradein_canon_addr( COALESCE(h.short_address, h.full_address, h.address) ) = gp.canon + AND CAST(h.region_code AS text) = gp.region_code AND h.gar_flat_count IS DISTINCT FROM gp.flat_count """ ) +# #2583 H5: снять уже проставленные guid чужого региона (см. коммент у _MATCH_SQL). Сам +# _MATCH_SQL их не перезапишет, если у дома нет пары в своём регионе, — а по чужому guid +# ЖКХ/ФРТ/капремонт-загрузчики продолжат тянуть данные другого дома. Только canon_addr и +# только доказуемо чужие (регион дома известен и не совпадает) — дом без региона не трогаем. +_CLEAR_FOREIGN_REGION_SQL = text( + """ + UPDATE houses h + SET gar_house_guid = NULL, + gar_flat_count = NULL, + gar_matched_at = NULL, + gar_match_method = NULL + FROM gar_house_flats g + WHERE g.house_guid = h.gar_house_guid + AND h.gar_match_method = 'canon_addr' + AND CAST(h.region_code AS text) <> g.region_code + AND ( + CAST(:region AS text) IS NULL + OR CAST(h.region_code AS text) = CAST(:region AS text) + ) + """ +) + def match_houses_to_gar( db: Session, @@ -666,11 +695,14 @@ def match_houses_to_gar( """ if isinstance(city_filter, CityFilterAutoType): city_filter = default_city_filter_for_region(region_code) + cleared = db.execute(_CLEAR_FOREIGN_REGION_SQL, {"region": region_code}).rowcount result = db.execute(_MATCH_SQL, {"region": region_code, "city": city_filter}) matched = result.rowcount logger.info( - "gar match: проставлено gar_flat_count домам=%d (region=%s, city=%s)", + "gar match: проставлено gar_flat_count домам=%d, снято guid чужого региона=%d" + " (region=%s, city=%s)", matched, + cleared, region_code, city_filter, ) diff --git a/tradein-mvp/backend/app/services/geocoder.py b/tradein-mvp/backend/app/services/geocoder.py index 8f7412c9..6f38125f 100644 --- a/tradein-mvp/backend/app/services/geocoder.py +++ b/tradein-mvp/backend/app/services/geocoder.py @@ -204,9 +204,10 @@ def known_city_hint(value: str | None, region_code: int = 66) -> str | None: Словарь — `SVERDLOVSK_OBLAST_CITIES`, тот же, на котором стоят городские гейты самого геокодера. Отдельного списка сознательно НЕ заводим: город, отсутствующий в этом наборе, и так обрабатывается геокодером как незнакомый - (`_names_non_ekb_city` его не увидит, `estimator._resolve_target_city` не - резолвит) — т.е. новый город области в любом случае добавляется СЮДА, и - гейт хинта не создаёт новой связности. + (`_names_non_ekb_city` его не увидит) — т.е. новый город области в любом + случае добавляется СЮДА, и гейт хинта не создаёт новой связности. + (`estimator._resolve_target_city` дополнительно знает города развёрток, + #2583 H1 — ему город нужен для фильтра сделок, а не для гейта.) Цена решения (осознанная): легитимный, но не перечисленный населённый пункт («Реж», «Арамиль», сёла/посёлки) хинта не получит и вернётся к поведению «по diff --git a/tradein-mvp/backend/app/tasks/listing_source_snapshot.py b/tradein-mvp/backend/app/tasks/listing_source_snapshot.py index 81dcbf16..b745a5c2 100644 --- a/tradein-mvp/backend/app/tasks/listing_source_snapshot.py +++ b/tradein-mvp/backend/app/tasks/listing_source_snapshot.py @@ -1,7 +1,8 @@ """Daily per-source snapshot writer (#570). Берёт текущее состояние listing_sources (последний снимок на canonical listing × source) -и пишет ежедневный ряд в listing_source_snapshots + change-log price_change в +и раз в сутки пишет в listing_source_snapshots строку на КАЖДОЕ ИЗМЕНЕНИЕ (#2993, см. +_SNAPSHOT_SQL; раньше — полная копия каждые сутки) + change-log в listing_source_events. Так история per-source цены копится СРАЗУ, независимо от (сейчас DORMANT) скраперов — см. шапку data/sql/079_listing_source_history.sql. @@ -72,9 +73,28 @@ def _clamp_budget_sec(raw: Any) -> float: return max(_MIN_BUDGET_SEC, min(val, _MAX_BUDGET_SEC)) -# ── Daily snapshot upsert ───────────────────────────────────────────────────── -# Снимок на (listing_source_id, CURRENT_DATE). ON CONFLICT → last-write-wins за день -# (повторный прогон в те же сутки перезаписывает снимок свежими значениями). +# ── Snapshot upsert: строка только на изменение (#2993) ─────────────────────── +# Снимок на (listing_source_id, CURRENT_DATE) пишется, только если у источника ещё нет +# ни одного снимка ИЛИ хоть одно из четырёх значений (price_rub, is_active, last_seen_at, +# payload_hash) отличается от его ПОСЛЕДНЕГО снимка. Было: полная копия listing_sources +# каждые сутки, прод 14-17.09 — 290-293 тыс. строк в сутки, из них отличались от +# предыдущего снимка 7.7-11.4 тыс. (3-4 %). Решение владельца 2026-08-23. +# +# Состояние источника на дату D = его последний снимок с snapshot_date <= D. Для всех +# четырёх колонок это то же значение, что записала бы суточная модель: сутки, в которые +# ни одна из них не менялась, и есть пропущенные строки. Поэтому last_seen_at обязан +# быть в сравнении: пол переобхода (deactivate_stale_avito._revisit_floor_from_where_sql, +# PR-A #3056) берёт last_seen_at последнего снимка не позже якоря — без него пары +# «прошлое наблюдение → текущее» считались бы от устаревшей свежести. is_active там же: +# он выводится из now(), и переход «свежий → протух» при неизменном last_seen_at — тоже +# изменение состояния на дату. +# +# p — последний снимок ВКЛЮЧАЯ сегодняшний: повторный прогон в те же сутки сравнивает +# с тем, что уже записано сегодня, и перезаписывает строку только если значение снова +# сдвинулось (ON CONFLICT → last-write-wins, как раньше). Пер-строчный LATERAL +# point-lookup по PK, как в event-diff (#2607); прод 17.09: 293 602 lookup'а — 1.0 с. +# Старые суточные снимки не трогаются — история до перехода остаётся как есть. +# # is_active derived: last_seen_at в пределах окна свежести на момент снимка. # payload_hash = md5(raw_payload::text) — ::text на колонке допустим (это не bind-param). # run_id через CAST(:run_id AS bigint) — psycopg v3 (никогда :run_id::bigint). @@ -85,15 +105,33 @@ _SNAPSHOT_SQL = text( last_seen_at, payload_hash, observed_at, run_id ) SELECT - id, + cur.id, CURRENT_DATE, - price_rub, - (last_seen_at > now() - make_interval(days => :freshness_days)) AS is_active, - last_seen_at, - md5(raw_payload::text), + cur.price_rub, + cur.is_active, + cur.last_seen_at, + cur.payload_hash, now(), CAST(:run_id AS bigint) - FROM listing_sources + FROM ( + SELECT + id, + price_rub, + (last_seen_at > now() - make_interval(days => :freshness_days)) AS is_active, + last_seen_at, + md5(raw_payload::text) AS payload_hash + FROM listing_sources + ) cur + LEFT JOIN LATERAL ( + SELECT s.snapshot_date, s.price_rub, s.is_active, s.last_seen_at, s.payload_hash + FROM listing_source_snapshots s + WHERE s.listing_source_id = cur.id + ORDER BY s.snapshot_date DESC + LIMIT 1 + ) p ON true + WHERE p.snapshot_date IS NULL + OR (cur.price_rub, cur.is_active, cur.last_seen_at, cur.payload_hash) + IS DISTINCT FROM (p.price_rub, p.is_active, p.last_seen_at, p.payload_hash) ON CONFLICT (listing_source_id, snapshot_date) DO UPDATE SET price_rub = EXCLUDED.price_rub, is_active = EXCLUDED.is_active, @@ -108,6 +146,9 @@ _SNAPSHOT_SQL = text( # Для каждого источника сравниваем сегодняшний снимок (snapshot_date = CURRENT_DATE) с # самым свежим ПРЕДЫДУЩИМ (snapshot_date < CURRENT_DATE). # today — снимок за сегодня (только что записан _SNAPSHOT_SQL, в той же транзакции). +# С #2993 здесь только изменившиеся и новые источники: у остальных все четыре +# значения равны последнему снимку, а значит ни одно событие ниже сработать +# не могло бы — набор событий тот же, что при суточной копии. # p — последний снимок строго ДО сегодня, per-row LATERAL point-lookup (#2607). # # #2674: схема (079) знает пять типов событий, писатель умел один — price_change, @@ -249,7 +290,8 @@ def snapshot_listing_sources( Sync (вызывается scheduler-триггером в executor, как import_rosreestr_dkp). Два set-based statement'а в одной транзакции: - 1. upsert снимка на (listing_source_id, CURRENT_DATE) — last-write-wins. + 1. upsert снимка на (listing_source_id, CURRENT_DATE) только для источников, + изменившихся с последнего снимка (#2993) — last-write-wins. 2. diff сегодняшнего снимка против последнего предыдущего → три события, выводимые из наших данных (#2674). delisted/relisted схема разрешает, но они НЕ выводимы при покрытии обхода 10-35% — см. _EVENT_DIFF_SQL. diff --git a/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py b/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py index 6377ef5e..9e116a43 100644 --- a/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py @@ -52,6 +52,13 @@ non-200 ответ считается блоком, а его диагноз б размерный порог settings.yandex_detail_min_html_bytes) и считается исходом incomplete ⊆ failed — обогащения нет, значит следующий снапшот (detail_enriched_at IS NULL) возьмёт её снова. + +Вечный недогруз (#3191, 13.09–16.09): карточка, которая не догружается НИКОГДА, без +счётчика попыток возвращалась в голову каждого снапшота. Пять таких объявлений подряд +выбивали брейкер, и 16 прогонов закончились ровно attempted=5 incomplete=5. Теперь +недогруз пишется на объявление (миграция 321): сутки его не берём, после +INCOMPLETE_MAX_ATTEMPTS не берём совсем, а повторный недогруз уже известной карточки +не двигает брейкер — это сведения о карточке, а не о площадке. """ from __future__ import annotations @@ -137,6 +144,13 @@ OFFER_URL_PATTERN = "/offer/[0-9]+" OFFER_ID_PATTERN = "^[0-9]+$" CANONICAL_URL_SQL = "'https://realty.yandex.ru/offer/' || source_id || '/'" +# Недогруз на объявлении (#3191). Разовый недогруз (1,8 МБ вместо 3,9 в замере 28.08) +# с другого захода отдаётся целиком, поэтому пауза, а не приговор. Вечный — пустая +# SPA-оболочка ~381 КБ без INITIAL_STATE, одинаковая с разных IP (проба 17.09) — после +# трёх суточных попыток из очереди выходит и считается в incomplete_given_up. +INCOMPLETE_RETRY_AFTER_HOURS = 24 +INCOMPLETE_MAX_ATTEMPTS = 3 + @dataclass class YandexDetailBackfillResult: @@ -161,6 +175,8 @@ class YandexDetailBackfillResult: url_from_offer_id: int = 0 # Ждут обогащения и адресовать их НЕЧЕМ: ни offer-URL, ни числового source_id. unenrichable_pending: int = 0 + # Ждут обогащения, но вышли из очереди после INCOMPLETE_MAX_ATTEMPTS недогрузов. + incomplete_given_up: int = 0 duration_sec: float = field(default=0.0) def to_dict(self) -> dict[str, int]: @@ -172,6 +188,7 @@ class YandexDetailBackfillResult: "failed": self.failed, "url_from_offer_id": self.url_from_offer_id, "unenrichable_pending": self.unenrichable_pending, + "incomplete_given_up": self.incomplete_given_up, "duration_sec": int(self.duration_sec), } @@ -228,7 +245,8 @@ async def run_yandex_detail_backfill( WHEN source_url ~ CAST(:offer_url_pattern AS text) THEN source_url ELSE {CANONICAL_URL_SQL} - END AS source_url + END AS source_url, + detail_incomplete_count FROM listings WHERE source = 'yandex' AND detail_enriched_at IS NULL @@ -239,6 +257,15 @@ async def run_yandex_detail_backfill( ) OR source_id ~ CAST(:offer_id_pattern AS text) ) + -- #3191: недогруженную карточку не берём сутки и не берём + -- совсем после INCOMPLETE_MAX_ATTEMPTS. + AND detail_incomplete_count < CAST(:incomplete_max_attempts AS int) + AND ( + detail_incomplete_at IS NULL + OR detail_incomplete_at < now() - make_interval( + hours => CAST(:incomplete_retry_hours AS int) + ) + ) ORDER BY is_active DESC NULLS LAST, scraped_at DESC NULLS LAST LIMIT CAST(:batch_size AS int) """ @@ -249,6 +276,8 @@ async def run_yandex_detail_backfill( "batch_size": batch_size, "offer_url_pattern": OFFER_URL_PATTERN, "offer_id_pattern": OFFER_ID_PATTERN, + "incomplete_max_attempts": INCOMPLETE_MAX_ATTEMPTS, + "incomplete_retry_hours": INCOMPLETE_RETRY_AFTER_HOURS, }, ) .mappings() @@ -283,6 +312,19 @@ async def run_yandex_detail_backfill( ).one() counters.url_from_offer_id = int(pending.url_from_offer_id) counters.unenrichable_pending = int(pending.unenrichable_pending) + counters.incomplete_given_up = int( + db.execute( + text( + """ + SELECT count(*) FROM listings + WHERE source = 'yandex' + AND detail_enriched_at IS NULL + AND detail_incomplete_count >= CAST(:incomplete_max_attempts AS int) + """ + ), + {"incomplete_max_attempts": INCOMPLETE_MAX_ATTEMPTS}, + ).scalar_one() + ) if counters.url_from_offer_id: logger.info( "yandex_detail_backfill: run_id=%d — у %d объявлений сохранённый " @@ -443,26 +485,59 @@ async def run_yandex_detail_backfill( # объявление уедет в БД с detail_enriched_at, выбыв из очереди # навсегда. Здесь оно исхода 'enriched' не получает, значит в # следующем прогоне снова попадёт в снапшот (detail_enriched_at - # IS NULL). Серия таких страниц двигает consecutive_none — тот же - # брейкер, что у parse→None: вечно недогружаемая карточка упрётся - # в max_consecutive_blocks и оборвёт прогон, а не будет молотиться - # (per-listing счётчика попыток в схеме нет, см. отчёт #3191). + # IS NULL) — но не раньше чем через сутки: недогруз пишется на + # объявление, и после INCOMPLETE_MAX_ATTEMPTS снапшот его не берёт. incomplete_reason = detail_incomplete_reason( resp.text, min_html_bytes=settings.yandex_detail_min_html_bytes ) if incomplete_reason is not None: counters.incomplete += 1 counters.failed += 1 - consecutive_none += 1 + # Брейкер ловит СИСТЕМНЫЙ недогруз (площадка отдаёт оболочки + # всем). Повторный недогруз карточки, уже недогружавшейся + # раньше, — сведения о ней, а не о площадке: он брейкер не + # двигает, иначе вечные карточки, стоящие в очереди подряд, + # рвали бы прогон при каждом повторе (#3191, 16 прогонов + # attempted=5 incomplete=5). Первый недогруз двигает, как раньше. + prior_incomplete = int(row.get("detail_incomplete_count") or 0) + if prior_incomplete == 0: + consecutive_none += 1 # Площадка ОТВЕТИЛА (HTTP 200) — серии блоков нет (#3196). consecutive_blocks = 0 + try: + db.execute( + text( + """ + UPDATE listings + SET detail_incomplete_count = detail_incomplete_count + 1, + detail_incomplete_at = now() + WHERE id = CAST(:listing_id AS bigint) + """ + ), + {"listing_id": listing_id}, + ) + db.commit() + except Exception as mark_exc: + # Не записали — карточка просто вернётся в следующий + # снапшот без паузы (поведение до #3191-счётчика). + db.rollback() + logger.warning( + "yandex_detail_backfill: run_id=%d listing_id=%d " + "недогруз не записан: %s", + run_id, + listing_id, + mark_exc, + ) logger.warning( "yandex_detail_backfill: run_id=%d listing_id=%d source_url=%s " - "-> недогруженная карточка, отказ: %s (consecutive=%d)", + "-> недогруженная карточка, отказ: %s (попытка %d/%d, " + "consecutive=%d)", run_id, listing_id, source_url, incomplete_reason, + prior_incomplete + 1, + INCOMPLETE_MAX_ATTEMPTS, consecutive_none, ) if consecutive_none >= max_consecutive_blocks: diff --git a/tradein-mvp/backend/data/sql/321_listings_detail_incomplete_attempts.sql b/tradein-mvp/backend/data/sql/321_listings_detail_incomplete_attempts.sql new file mode 100644 index 00000000..3ee31df5 --- /dev/null +++ b/tradein-mvp/backend/data/sql/321_listings_detail_incomplete_attempts.sql @@ -0,0 +1,52 @@ +-- 321_listings_detail_incomplete_attempts.sql +-- Счётчик недогруженных карточек Яндекса на объявлении (#3191, вечный недогруз). +-- +-- Apply after: 310_yandex_seed_decimal_slips.sql +-- Deploy order: схема первой (деплой применяет миграции до пересоздания контейнеров); +-- старый код новые колонки не читает, поэтому окно между шагами безопасно. +-- +-- ── ЧТО НЕ ТАК ──────────────────────────────────────────────────────────────── +-- PR #3364 сделал недогруженную карточку отказом: detail_enriched_at не ставится, +-- объявление остаётся в очереди yandex_detail_backfill. Попыток на объявлении при +-- этом никто не считал, и карточка, которая не догружается НИКОГДА, возвращалась в +-- голову каждого снапшота. Прод 13.09 21:14 – 16.09 18:52: пять объявлений +-- (10776456, 10775964, 10775945, 10775747, 10775594; scraped_at 12.09 15:02, подряд +-- в порядке очереди) давали 381 КБ без encryptedPhones/redirectPhones в каждом +-- прогоне, пять недогрузов подряд выбивали брейкер consecutive_none=5, и 16 прогонов +-- закончились ровно attempted=5 incomplete=5, а остальные обрывались, дойдя до них. +-- Страница — пустая SPA-оболочка без INITIAL_STATE (id оффера в ней не встречается), +-- та же с другого IP (проба 17.09): это свойство объявления, а не сети. +-- +-- ── ЧТО ДЕЛАЕТ ФАЙЛ ─────────────────────────────────────────────────────────── +-- detail_incomplete_count — сколько раз карточка пришла недогруженной; +-- detail_incomplete_at — когда последний раз. Читает и пишет только +-- app/tasks/yandex_detail_backfill.py: снапшот не берёт карточку в течение суток +-- после недогруза и не берёт совсем после трёх. +-- +-- ── СТОИМОСТЬ ───────────────────────────────────────────────────────────────── +-- ADD COLUMN с константным DEFAULT в PostgreSQL 11+ не переписывает heap (значение +-- лежит в каталоге), поэтому 19 ГБ listings не копируются. ACCESS EXCLUSIVE берётся +-- на миллисекунды; ожидание лока ограничено lock_timeout (.claude/rules/sql.md). +-- +-- IDEMPOTENCY: ADD COLUMN IF NOT EXISTS. Данные существующих строк не трогаются. +-- +-- Критерий приёмки (записан ДО применения): +-- 1. Запись в _schema_migrations по имени этого файла. +-- 2. information_schema.columns: обе колонки у listings. +-- 3. После первого прогона yandex_detail_backfill, дошедшего до пяти объявлений: +-- у них detail_incomplete_count = 1, и следующий прогон их не запрашивает. + +BEGIN; + +SET LOCAL lock_timeout = '5s'; + +ALTER TABLE listings + ADD COLUMN IF NOT EXISTS detail_incomplete_count smallint NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS detail_incomplete_at timestamptz; + +COMMENT ON COLUMN listings.detail_incomplete_count IS + 'Сколько раз detail-страница приходила недогруженной (#3191). Пишет yandex_detail_backfill; после 3 объявление выходит из очереди добора.'; +COMMENT ON COLUMN listings.detail_incomplete_at IS + 'Время последнего недогруза detail-страницы (#3191). Суточная пауза перед повтором в yandex_detail_backfill.'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/324_trade_in_estimates_exposure_window.sql b/tradein-mvp/backend/data/sql/324_trade_in_estimates_exposure_window.sql new file mode 100644 index 00000000..988ecd92 --- /dev/null +++ b/tradein-mvp/backend/data/sql/324_trade_in_estimates_exposure_window.sql @@ -0,0 +1,31 @@ +-- #2898: окно срока экспозиции (p25/p50/p75 и размер выборки) вместо одного числа. +-- +-- ЗАЧЕМ КОЛОНКИ. Окно считается по house_placement_history на момент оценки, а +-- история пополняется каждый день: пересчёт на чтении показал бы по ссылке через +-- месяц другое окно, чем в момент покупки отчёта. Поэтому значение хранится. +-- +-- Все четыре либо заполнены, либо NULL: NULL — выборка меньше +-- EXPOSURE_WINDOW_MIN_N (30) или оценка посчитана до этой миграции. Бэкфилла нет: +-- историю размещений на дату старой оценки не восстановить. +-- +-- Старое поле ответа est_days_on_market не колонка и не удаляется: GET отдаёт в нём +-- est_days_p50, старые строки дают null, как и раньше. +-- +-- ПРО ЛОКИ (#2752): ADD COLUMN без DEFAULT не переписывает таблицу, но ждать +-- ACCESS EXCLUSIVE может долго и держать очередь запросов — lock_timeout. +BEGIN; + +SET LOCAL lock_timeout = '5s'; + +ALTER TABLE trade_in_estimates + ADD COLUMN IF NOT EXISTS est_days_p25 integer, + ADD COLUMN IF NOT EXISTS est_days_p50 integer, + ADD COLUMN IF NOT EXISTS est_days_p75 integer, + ADD COLUMN IF NOT EXISTS est_days_n integer; + +COMMENT ON COLUMN trade_in_estimates.est_days_p50 IS + '#2898: медиана дней до СНЯТИЯ объявления (не продажи) у похожих квартир рядом, ' + 'house_placement_history.exposure_days. p25/p75 — края окна, n — размер выборки. ' + 'NULL — выборка < 30.'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/325_listing_source_snapshots_change_only_comment.sql b/tradein-mvp/backend/data/sql/325_listing_source_snapshots_change_only_comment.sql new file mode 100644 index 00000000..ef1eac6b --- /dev/null +++ b/tradein-mvp/backend/data/sql/325_listing_source_snapshots_change_only_comment.sql @@ -0,0 +1,29 @@ +-- 325_listing_source_snapshots_change_only_comment.sql +-- #2993 — listing_source_snapshots переходит на «строку на изменение» +-- (app/tasks/listing_source_snapshot.py, _SNAPSHOT_SQL; решение владельца 2026-08-23). +-- +-- Схема не меняется, меняется смысл строк — поэтому только комментарии. Прежние +-- («Ежедневный снимок…», «на дату снимка») после перехода стали бы неправдой: +-- `WHERE snapshot_date = D` отдаёт теперь не состояние всех источников на D, а только +-- изменившиеся в D. Состояние на дату — последний снимок с snapshot_date <= D. +-- Суточные строки до перехода остаются в таблице как есть. +-- +-- COMMENT берёт ShareUpdateExclusiveLock (конфликтует с autovacuum/ANALYZE, не с +-- чтением и записью) — lock_timeout, чтобы деплой не повис за VACUUM 8-млн таблицы. + +BEGIN; + +SET LOCAL lock_timeout = '5s'; + +COMMENT ON TABLE listing_source_snapshots IS + 'Снимок listing_sources на источник (#570), строка только при изменении (#2993): ' + 'пишется, если price_rub/is_active/last_seen_at/payload_hash отличаются от последнего ' + 'снимка источника или снимка ещё нет. Состояние на дату D — последняя строка с ' + 'snapshot_date <= D. До перехода (#2993) — полная суточная копия. ' + 'PK (listing_source_id, snapshot_date), last-write-wins за день.'; + +COMMENT ON VIEW v_listing_source_price_on_date IS + 'Цена/активность источника с даты снимка (#570): listing_source_snapshots ⋈ listing_sources. ' + 'С #2993 строки только на даты изменений — цена на дату D = последняя строка с snapshot_date <= D.'; + +COMMIT; diff --git a/tradein-mvp/backend/scripts/backtest_estimator.py b/tradein-mvp/backend/scripts/backtest_estimator.py index 1a4b1645..0a6a0f88 100644 --- a/tradein-mvp/backend/scripts/backtest_estimator.py +++ b/tradein-mvp/backend/scripts/backtest_estimator.py @@ -1674,12 +1674,16 @@ class _HouseIdResolution: total: int = 0 # deals processed in the full-engine path under the flag resolved: int = 0 # deals where match_house_readonly returned an id imv_reachable: int = 0 # of resolved, those whose house_imv anchor fired + # #2862: сделкам, у которых тип дома подставлен из `houses` (deals.house_type пуст + # у всех строк). Ноль здесь = штраф за тип дома в прогоне не участвовал вовсе. + house_type_from_houses: int = 0 def as_json(self) -> dict[str, int]: return { "resolved": self.resolved, "total": self.total, "imv_reachable": self.imv_reachable, + "house_type_from_houses": self.house_type_from_houses, } @@ -2550,6 +2554,18 @@ def run_backtest_full( target_house_id = _resolve_house_id_for_deal(db, deal) if target_house_id is not None: resolution.resolved += 1 + # #2862: боевой estimate_quality (#3234) берёт тип дома из `houses`, когда + # его нет в форме — по резолвленному house_id, иначе ближайший дом в 60 м. + # deals.house_type пуст у всех сделок, так что без этого штраф за тип дома + # в прогоне не срабатывал ни разу. Только тип: год и этажность из `houses` + # сдвинули бы метрики по другим признакам и смешали бы замер. + if deal.house_type is None: + facts = est.m._lookup_house_facts( + db, target_house_id=target_house_id, lat=deal.lat, lon=deal.lon + ) + if facts is not None and facts.house_type is not None: + deal = dataclasses.replace(deal, house_type=facts.house_type) + resolution.house_type_from_houses += 1 try: pr = _predict_full_spine( db, @@ -2616,10 +2632,11 @@ def run_backtest_full( if resolution is not None: metrics["house_id_resolution"] = resolution.as_json() logger.info( - "house_id resolution: resolved=%d/%d imv_reachable=%d", + "house_id resolution: resolved=%d/%d imv_reachable=%d house_type_from_houses=%d", resolution.resolved, resolution.total, resolution.imv_reachable, + resolution.house_type_from_houses, ) if dump_fixture is not None and capture is not None: diff --git a/tradein-mvp/backend/tests/fixtures/cian_serp_ekb_2room_trimmed.html b/tradein-mvp/backend/tests/fixtures/cian_serp_ekb_2room_trimmed.html new file mode 100644 index 00000000..2a488670 --- /dev/null +++ b/tradein-mvp/backend/tests/fixtures/cian_serp_ekb_2room_trimmed.html @@ -0,0 +1,911 @@ + + +
+ + diff --git a/tradein-mvp/backend/tests/fixtures/yandex_gate_ekb_novostroyki_trimmed.json b/tradein-mvp/backend/tests/fixtures/yandex_gate_ekb_novostroyki_trimmed.json new file mode 100644 index 00000000..c509a138 --- /dev/null +++ b/tradein-mvp/backend/tests/fixtures/yandex_gate_ekb_novostroyki_trimmed.json @@ -0,0 +1,120 @@ +{ + "_comment": "#2422: живой ответ gate-API Яндекс Недвижимости, снят 2026-09-17 (curl_cffi impersonate=chrome, HTTP 200): rgid=559132 type=SELL category=APARTMENT newFlat=YES page=1. Урезано: 1 из 23 entities, у entity только читаемые _entity_to_lot ключи, author без agentName (PII), mainImages до 6. Значения полей не менялись.", + "response": { + "search": { + "offers": { + "entities": [ + { + "offerId": "6216493807134609867", + "url": "//realty.yandex.ru/offer/6216493807134609867", + "price": { + "currency": "RUR", + "value": 27620850, + "period": "WHOLE_LIFE", + "unit": "WHOLE_OFFER", + "trend": "UNCHANGED", + "hasPriceHistory": false, + "valuePerPart": 366859, + "unitPerPart": "SQUARE_METER", + "valueForWhole": 27620850, + "unitForWhole": "WHOLE_OFFER", + "price": { + "value": 27620850, + "currency": "RUB", + "priceType": "PER_OFFER", + "pricingPeriod": "WHOLE_LIFE" + }, + "pricePerPart": { + "value": 366859, + "currency": "RUB", + "priceType": "PER_METER", + "pricingPeriod": "WHOLE_LIFE" + }, + "priceForWhole": { + "value": 27620850, + "currency": "RUB", + "priceType": "PER_OFFER", + "pricingPeriod": "WHOLE_LIFE" + }, + "minMonthlyMortgagePayment": 282584, + "mortgageInfo": { + "bestMortgage": { + "id": "2974521", + "payment": "282584" + }, + "minPreferentialMortgageRate": 6 + } + }, + "area": { + "value": 75.29, + "unit": "SQUARE_METER" + }, + "livingSpace": { + "value": 29.03, + "unit": "SQUARE_METER" + }, + "roomsTotal": 2, + "floorsOffered": [ + 12 + ], + "floorsTotal": 16, + "building": { + "builtYear": 2026, + "siteId": 2006795, + "siteName": "Александровский сад" + }, + "location": { + "point": { + "latitude": 56.821068, + "longitude": 60.609924, + "precision": "EXACT" + }, + "geocoderAddress": "Россия, Свердловская область, Екатеринбург, жилой район Центральный, ЖК Александровский Сад", + "streetAddress": "Свердловская область, Екатеринбург, Декабристов", + "metro": { + "metroGeoId": 101986, + "metroCityRgid": 559132, + "name": "Геологическая", + "metroTransport": "ON_FOOT", + "timeToMetro": 9, + "latitude": 56.827534, + "longitude": 60.602486, + "minTimeToMetro": 8, + "lineColors": [ + "f03d2f" + ], + "rgbColor": "f03d2f" + }, + "metroList": [ + { + "name": "Геологическая", + "timeToMetro": 9 + } + ] + }, + "creationDate": "2026-03-17T11:04:44Z", + "description": "Александровский сад настоящий бизнес-класс в центре Екатеринбурга от девелопера Forum. Забудьте о суете города. Ваш двор это пейзажный сад, где царят умиротворение и гармония. Прогулочные дорожки с мягкой вечерней подсветкой проведут по уединенным", + "author": { + "category": "DEVELOPER" + }, + "mainImages": [ + "//avatars.mds.yandex.net/get-realty-offers/20177950/9476e26d-8cbc-4b69-a7bd-03fefa0a960a/main", + "//avatars.mds.yandex.net/get-realty-offers/16258130/d185b4dc-8386-4fa1-bdf9-0865da1f8e5d/main", + "//avatars.mds.yandex.net/get-realty-offers/13970442/da0f5415-d014-466b-b5e9-0fe70d5e8680/main", + "//avatars.mds.yandex.net/get-realty-offers/14824781/f882caeb-4d40-4775-9f0a-acb01177ff0e/main", + "//avatars.mds.yandex.net/get-realty-offers/20657783/1359e480-6c21-4c07-b1de-9e36149f83fd/main", + "//avatars.mds.yandex.net/get-realty-offers/14671802/38d6dc24-8366-4460-9e99-06a1605e1919/main" + ] + } + ], + "pager": { + "page": 0, + "pageSize": 20, + "sitesPageSize": 20, + "totalItems": 32234, + "totalPages": 25 + } + } + } + } +} diff --git a/tradein-mvp/backend/tests/fixtures/yandex_gate_ekb_vtorichka_trimmed.json b/tradein-mvp/backend/tests/fixtures/yandex_gate_ekb_vtorichka_trimmed.json new file mode 100644 index 00000000..e3123148 --- /dev/null +++ b/tradein-mvp/backend/tests/fixtures/yandex_gate_ekb_vtorichka_trimmed.json @@ -0,0 +1,355 @@ +{ + "_comment": "#2422: живой ответ gate-API Яндекс Недвижимости, снят 2026-09-17 (curl_cffi impersonate=chrome, HTTP 200): rgid=559132 type=SELL category=APARTMENT newFlat=NO page=1. Урезано: 3 из 23 entities, у entity только читаемые _entity_to_lot ключи, author без agentName (PII), mainImages до 6. Значения полей не менялись.", + "response": { + "search": { + "offers": { + "entities": [ + { + "offerId": "5228381943217465492", + "url": "https://novostroi-ekb.ru/catalog/sale/vtorichka/object/1064", + "price": { + "currency": "RUR", + "value": 4400000, + "period": "WHOLE_LIFE", + "unit": "WHOLE_OFFER", + "trend": "DECREASED", + "previous": 4450000, + "hasPriceHistory": true, + "valuePerPart": 129412, + "unitPerPart": "SQUARE_METER", + "valueForWhole": 4400000, + "unitForWhole": "WHOLE_OFFER", + "price": { + "value": 4400000, + "currency": "RUB", + "priceType": "PER_OFFER", + "pricingPeriod": "WHOLE_LIFE" + }, + "pricePerPart": { + "value": 129412, + "currency": "RUB", + "priceType": "PER_METER", + "pricingPeriod": "WHOLE_LIFE" + }, + "priceForWhole": { + "value": 4400000, + "currency": "RUB", + "priceType": "PER_OFFER", + "pricingPeriod": "WHOLE_LIFE" + } + }, + "area": { + "value": 34, + "unit": "SQUARE_METER" + }, + "kitchenSpace": { + "value": 7, + "unit": "SQUARE_METER" + }, + "roomsTotal": 1, + "floorsOffered": [ + 8 + ], + "floorsTotal": 9, + "ceilingHeight": 2.5, + "building": { + "builtYear": 1978, + "buildingType": "BRICK" + }, + "location": { + "point": { + "latitude": 56.903275, + "longitude": 60.6229, + "precision": "EXACT" + }, + "geocoderAddress": "Россия, Свердловская область, Екатеринбург, Шефская улица, 85", + "streetAddress": "Шефская улица, 85", + "metro": { + "metroGeoId": 101947, + "metroCityRgid": 559132, + "name": "Проспект Космонавтов", + "metroTransport": "ON_FOOT", + "timeToMetro": 7, + "latitude": 56.901844, + "longitude": 60.61394, + "minTimeToMetro": 5, + "lineColors": [ + "f03d2f" + ], + "rgbColor": "f03d2f" + }, + "metroList": [ + { + "name": "Проспект Космонавтов", + "timeToMetro": 7 + }, + { + "name": "Машиностроителей", + "timeToMetro": 14 + } + ] + }, + "creationDate": "2026-08-19T06:23:51Z", + "description": "1 комнатная квартира 34 кв .м с развитой инфраструктурой , станция метро «КОСМАНАВТОВ» в шаговой доступности . Фукциональная планировка правильной формы, дополнительное место хранения гардеробная , в квартире сделана качественная предчистовая отделка", + "author": { + "category": "AGENCY", + "organization": "САМОЛЕТ ПЛЮС" + }, + "predictions": { + "predictedPrice": { + "min": "3795000", + "max": "4638000", + "value": "4217000" + } + }, + "mainImages": [ + "//avatars.mds.yandex.net/get-realty-offers/14333116/89f721ff-ad4b-49f1-8cec-432ad11baeab/main", + "//avatars.mds.yandex.net/get-realty-offers/13853239/caf4740f-dca8-47a3-8005-56968d20a3b1/main", + "//avatars.mds.yandex.net/get-realty-offers/14244677/f1943334-731e-4539-98f6-9d38d4a725ce/main", + "//avatars.mds.yandex.net/get-realty-offers/12726755/346626db-ba69-4a49-8940-76f08d6aa924/main", + "//avatars.mds.yandex.net/get-realty-offers/20074534/021cc849-6554-4059-9423-19193e3006b9/main", + "//avatars.mds.yandex.net/get-realty-offers/13912669/b240dbf0-1adc-4dbd-9ce8-c0ca6c478fc8/main" + ] + }, + { + "offerId": "6282460993454547968", + "url": "//realty.yandex.ru/offer/6282460993454547968", + "price": { + "currency": "RUR", + "value": 10290000, + "period": "WHOLE_LIFE", + "unit": "WHOLE_OFFER", + "trend": "UNCHANGED", + "hasPriceHistory": false, + "valuePerPart": 294000, + "unitPerPart": "SQUARE_METER", + "valueForWhole": 10290000, + "unitForWhole": "WHOLE_OFFER", + "price": { + "value": 10290000, + "currency": "RUB", + "priceType": "PER_OFFER", + "pricingPeriod": "WHOLE_LIFE" + }, + "pricePerPart": { + "value": 294000, + "currency": "RUB", + "priceType": "PER_METER", + "pricingPeriod": "WHOLE_LIFE" + }, + "priceForWhole": { + "value": 10290000, + "currency": "RUB", + "priceType": "PER_OFFER", + "pricingPeriod": "WHOLE_LIFE" + } + }, + "area": { + "value": 35, + "unit": "SQUARE_METER" + }, + "livingSpace": { + "value": 11, + "unit": "SQUARE_METER" + }, + "kitchenSpace": { + "value": 14, + "unit": "SQUARE_METER" + }, + "roomsTotal": 1, + "floorsOffered": [ + 27 + ], + "floorsTotal": 27, + "ceilingHeight": 2.75, + "building": { + "builtYear": 2023, + "buildingType": "MONOLIT", + "siteId": 1668651, + "siteName": "Парковый квартал" + }, + "location": { + "point": { + "latitude": 56.815243, + "longitude": 60.62593, + "precision": "EXACT" + }, + "geocoderAddress": "Россия, Свердловская область, Екатеринбург, Машинная улица, 4", + "streetAddress": "Машинная улица, 4", + "metro": { + "metroGeoId": 116399, + "metroCityRgid": 559132, + "name": "Чкаловская", + "metroTransport": "ON_FOOT", + "timeToMetro": 17, + "latitude": 56.807995, + "longitude": 60.610184, + "minTimeToMetro": 12, + "lineColors": [ + "f03d2f" + ], + "rgbColor": "f03d2f" + }, + "metroList": [ + { + "name": "Чкаловская", + "timeToMetro": 17 + }, + { + "name": "Ботаническая", + "timeToMetro": 13 + }, + { + "name": "Площадь 1905 Года", + "timeToMetro": 16 + }, + { + "name": "Геологическая", + "timeToMetro": 20 + } + ] + }, + "creationDate": "2026-08-02T14:04:19Z", + "description": "Продам квартиру в 10 минутах от центра города. Рядом Парк Маяковского. Хорошая транспортная доступность: до метро можно дойти пешком. Великолепный вид из окна.Хороший обзор. Высокие потолки, 2,75.Нет соседей сверху. Качественный", + "author": { + "category": "OWNER" + }, + "predictions": { + "predictedPrice": { + "min": "8741000", + "max": "10683000", + "value": "9712000" + } + }, + "mainImages": [ + "//avatars.mds.yandex.net/get-realty-offers/10211679/add.69def35c26379a08babb742069d310dc.realty-api-vos/main", + "//avatars.mds.yandex.net/get-realty-offers/14373170/add.e2e2b76847d9b69e3abe0c64efee75c4.realty-api-vos/main", + "//avatars.mds.yandex.net/get-realty-offers/14717586/add.c65fd15bb15736448bac2a597ffd0bae.realty-api-vos/main", + "//avatars.mds.yandex.net/get-realty-offers/11512705/add.b894c4e448f696251deaed148ee9c30e.realty-api-vos/main", + "//avatars.mds.yandex.net/get-realty-offers/13847384/add.f13072669ebf54647b1682fc70fa9e99.realty-api-vos/main", + "//avatars.mds.yandex.net/get-realty-offers/14527622/add.1708ed4ada10c2d407f64b0e744d8047.realty-api-vos/main" + ] + }, + { + "offerId": "8530722463088927619", + "url": "//realty.yandex.ru/offer/8530722463088927619", + "price": { + "currency": "RUR", + "value": 5600000, + "period": "WHOLE_LIFE", + "unit": "WHOLE_OFFER", + "trend": "UNCHANGED", + "hasPriceHistory": false, + "valuePerPart": 269231, + "unitPerPart": "SQUARE_METER", + "valueForWhole": 5600000, + "unitForWhole": "WHOLE_OFFER", + "price": { + "value": 5600000, + "currency": "RUB", + "priceType": "PER_OFFER", + "pricingPeriod": "WHOLE_LIFE" + }, + "pricePerPart": { + "value": 269231, + "currency": "RUB", + "priceType": "PER_METER", + "pricingPeriod": "WHOLE_LIFE" + }, + "priceForWhole": { + "value": 5600000, + "currency": "RUB", + "priceType": "PER_OFFER", + "pricingPeriod": "WHOLE_LIFE" + } + }, + "area": { + "value": 20.8, + "unit": "SQUARE_METER" + }, + "livingSpace": { + "value": 12, + "unit": "SQUARE_METER" + }, + "floorsOffered": [ + 8 + ], + "floorsTotal": 11, + "ceilingHeight": 2.7, + "building": { + "builtYear": 2021, + "buildingType": "MONOLIT_BRICK", + "siteId": 1948687, + "siteName": "Северный квартал" + }, + "location": { + "point": { + "latitude": 56.89804, + "longitude": 60.6199, + "precision": "EXACT" + }, + "geocoderAddress": "Россия, Свердловская область, Екатеринбург, переулок Ритслянда, 15", + "streetAddress": "переулок Ритслянда, 15", + "metro": { + "metroGeoId": 101947, + "metroCityRgid": 559132, + "name": "Проспект Космонавтов", + "metroTransport": "ON_FOOT", + "timeToMetro": 5, + "latitude": 56.901844, + "longitude": 60.61394, + "minTimeToMetro": 5, + "lineColors": [ + "f03d2f" + ], + "rgbColor": "f03d2f" + }, + "metroList": [ + { + "name": "Проспект Космонавтов", + "timeToMetro": 5 + }, + { + "name": "Уралмаш", + "timeToMetro": 13 + }, + { + "name": "Машиностроителей", + "timeToMetro": 12 + } + ] + }, + "creationDate": "2026-09-15T06:17:51Z", + "description": "Продается прекрасная студия в ЖК Северные кварталы, застройщик Брусника. Дом комфорт класса Площадь студии 20,8 плюс теплая лоджия 4кв.м (убрана перегородка) -благодаря этому площадь студии увеличена до 24,8кв.м - компактно и функционально для одного", + "author": { + "category": "AGENCY", + "organization": "DOMOS CLUB" + }, + "predictions": { + "predictedPrice": { + "min": "4562000", + "max": "5576000", + "value": "5069000" + } + }, + "mainImages": [ + "//avatars.mds.yandex.net/get-realty-offers/14727395/1635cddb-a57c-4cce-a6c1-c70b9aef13cd/main", + "//avatars.mds.yandex.net/get-realty-offers/14621926/3ae41eaf-5b30-46ec-b844-b40af2bb697a/main", + "//avatars.mds.yandex.net/get-realty-offers/12719313/6921877e-51f6-4d02-93ef-0395b99776bb/main", + "//avatars.mds.yandex.net/get-realty-offers/13297592/8a8d583b-d205-460e-aec8-c9cb827eae73/main", + "//avatars.mds.yandex.net/get-realty-offers/10246351/8eee4902-aa24-42dc-aaa2-9259c82146d8/main", + "//avatars.mds.yandex.net/get-realty-offers/14621926/116dc1df-3288-4236-acfe-2f660fb2ac8b/main" + ] + } + ], + "pager": { + "page": 0, + "pageSize": 20, + "sitesPageSize": 20, + "totalItems": 4077, + "totalPages": 25 + } + } + } + } +} diff --git a/tradein-mvp/backend/tests/scrapers/test_2422_kit_no_app_imports.py b/tradein-mvp/backend/tests/scrapers/test_2422_kit_no_app_imports.py new file mode 100644 index 00000000..be520a23 --- /dev/null +++ b/tradein-mvp/backend/tests/scrapers/test_2422_kit_no_app_imports.py @@ -0,0 +1,45 @@ +"""scraper_kit не импортирует app.* (#2422 п.2). + +Инвариант держал test_kit_avito_has_no_app_imports, удалённый вместе с golden-parity +(#2419). Проверка обходит весь пакет, а не только providers: импорт app внутри kit — +цикл зависимостей, где бы он ни стоял. Смотрит AST, поэтому находит и ленивый импорт +внутри функции, и не срабатывает на слово «app» в комментарии. +""" + +from __future__ import annotations + +import ast +import os +from pathlib import Path + +import scraper_kit + +# conftest.py (autouse) тянет app.core.config, которому нужен DATABASE_URL; БД не нужна. +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +KIT_ROOT = Path(scraper_kit.__file__).parent + + +def _app_imports(path: Path) -> list[str]: + found: list[str] = [] + for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"), filename=str(path))): + if isinstance(node, ast.Import): + names = [alias.name for alias in node.names] + elif isinstance(node, ast.ImportFrom) and node.level == 0: + names = [node.module or ""] + else: + continue + found += [f"{n} (строка {node.lineno})" for n in names if n.split(".")[0] == "app"] + return found + + +def test_kit_has_no_app_imports() -> None: + files = sorted(KIT_ROOT.rglob("*.py")) + # Обход не пустой и дошёл до провайдеров — иначе тест зелёный по построению. + assert KIT_ROOT / "providers" / "avito" / "houses.py" in files + assert len(files) > 40 + + offenders = { + str(p.relative_to(KIT_ROOT)): imports for p in files if (imports := _app_imports(p)) + } + assert offenders == {} diff --git a/tradein-mvp/backend/tests/scrapers/test_2422_kit_parsers_on_fixtures.py b/tradein-mvp/backend/tests/scrapers/test_2422_kit_parsers_on_fixtures.py new file mode 100644 index 00000000..384674ed --- /dev/null +++ b/tradein-mvp/backend/tests/scrapers/test_2422_kit_parsers_on_fixtures.py @@ -0,0 +1,401 @@ +"""Разбор kit-парсеров на сохранённых страницах площадок: фикстура → лот (#2422, #2408). + +Golden-parity тесты сверяли kit с legacy-копией и ушли вместе с ней (#2419). После +этого разбор avito.houses, cian.serp и yandex.serp не проверял никто: правка +извлечения поля в kit проходила бы зелёной. Здесь ожидания записаны прямо значениями +из сохранённых страниц, без второй реализации в роли эталона. + +Фикстуры: + - avito_house_572538.html, avito_houses_sample.html — карточки домов Авито + (вторичка), рейтинг только в reviews.filterEntries (#1789); + - avito_novostroyka_12879380.html — карточка ЖК (developmentPage); + - avito_serp_sample.html — выдача Авито, собранная из живой страницы (#726); + - cian_serp_ekb_2room_trimmed.html, yandex_gate_ekb_*_trimmed.json — живые выдачи + 2026-09-17, урезанные до читаемых парсером полей (что и как урезано — в шапке + каждого файла; разбор урезанной и полной выдачи совпал поле в поле, кроме числа + фото). + +Не покрыто здесь, потому что уже покрыто на фикстурах в своих файлах: avito.detail +(test_avito_detail_fields_3047.py), cian.detail (test_cian_views_parse.py, +test_cian_bti_house_persist.py), domclick (test_domclick_sweep.py, +scrapers/test_domclick_detail.py), base (scrapers/test_base_clamp_date.py). +""" + +from __future__ import annotations + +import json +import os +from datetime import date +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest + +# conftest.py (autouse) тянет app.core.config, которому нужен DATABASE_URL; БД не нужна. +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from scraper_kit.providers.avito.houses import ( + HouseCatalogEnrichment, + _extract_preloaded_state, + parse_houses_state, +) +from scraper_kit.providers.avito.serp import AvitoScraper +from scraper_kit.providers.cian.serp import CianScraper +from scraper_kit.providers.yandex.serp import _extract_gate_data, _parse_gate_json + +FIXTURES = Path(__file__).parent.parent / "fixtures" + + +def _read(name: str) -> str: + return (FIXTURES / name).read_text(encoding="utf-8") + + +# ── avito.houses: карточка дома (вторичка) ───────────────────────────────────── + + +def _house(name: str, url: str) -> HouseCatalogEnrichment: + state = _extract_preloaded_state(_read(name)) + assert state is not None, name + return parse_houses_state(state, url) + + +@pytest.fixture(scope="module") +def house_572538() -> HouseCatalogEnrichment: + return _house("avito_house_572538.html", "https://www.avito.ru/catalog/houses/572538") + + +@pytest.fixture(scope="module") +def house_115188() -> HouseCatalogEnrichment: + return _house("avito_houses_sample.html", "https://www.avito.ru/catalog/houses/115188") + + +def test_avito_house_characteristics(house_572538: HouseCatalogEnrichment) -> None: + h = house_572538.house + assert h.ext_id == 572538 + assert h.ext_id_hash == "MTE3LjQ1Nzk0Mg" + assert h.full_address == "Свердловская область, Екатеринбург, ул. Сони Морозовой, 190" + assert (h.lat, h.lon) == (56.832359, 60.625583) + assert h.year_built == 1994 + assert h.total_floors == 10 + assert h.house_type == "brick" + assert h.material_floors == "Железобетонные" + assert h.hot_water == "Центральное" + assert h.passenger_elevators == 1 + assert h.cargo_elevators is None # параметра нет на странице — не 0 + assert h.has_concierge is True + assert h.has_playground is True + assert h.closed_yard is True + + +def test_avito_house_rating_from_reviews_filter_entries_1789( + house_572538: HouseCatalogEnrichment, house_115188: HouseCatalogEnrichment +) -> None: + """В обеих карточках housePage отдаёт бейдж без оценки (scoreString «—»), оценка + есть только в reviews.filterEntries[type=score]. Без запасного пути #1789 + рейтинг и число отзывов были бы None.""" + h = house_572538.house + assert h.rating_score == pytest.approx(14 / 3) + assert h.rating_string == "4,7" + assert h.reviews_count == 3 + assert [(r["score"], r["count"]) for r in h.rating_distribution] == [ + (5, 2), + (4, 1), + (3, 0), + (2, 0), + (1, 0), + ] + + h2 = house_115188.house + assert (h2.rating_score, h2.rating_string, h2.reviews_count) == (4.0, "4,0", 4) + + +def test_avito_house_reviews(house_572538: HouseCatalogEnrichment) -> None: + reviews = {r.ext_review_id: r for r in house_572538.reviews} + assert list(reviews) == [2360701, 2018777, 1757422] + + full = reviews[2018777] + assert full.author_name == "Иван" + assert full.score == 5 + assert full.rated_date == date(2025, 9, 14) + assert full.text_main is not None and full.text_main.startswith("Желательно добавить") + assert full.text_pros is not None and full.text_pros.startswith("Удобное расположение") + assert full.text_cons is not None and full.text_cons.startswith("Возможно, проблемы") + + # Секции без заголовка нет — основной текст пуст, плюсы/минусы на месте. + no_main = reviews[1757422] + assert no_main.rated_date == date(2025, 7, 9) + assert no_main.text_main is None + assert no_main.text_pros is not None and no_main.text_pros.startswith("Двор закрытый") + + # Отзыв 2360701 датирован «16 апреля» без года — такую дату разбор сейчас не + # читает (rated_date=None). Это дефект, а не контракт, поэтому здесь не закреплён. + + +def test_avito_house_reviews_next_page( + house_572538: HouseCatalogEnrichment, house_115188: HouseCatalogEnrichment +) -> None: + assert house_572538.reviews_next_page_url is None # запись pages без nextPageUrl + assert house_115188.reviews_next_page_url == ( + "/web/3/rating/model?categoryId=24&catalogId=283&paths[]=115188&limit=10&cursor=3" + "&sortBy[]=modelTypeId&sortBy[]=publishedAt&modelTypeId=14&developmentId=115188" + ) + + +def test_avito_house_placement_history(house_572538: HouseCatalogEnrichment) -> None: + history = house_572538.placement_history + assert len(history) == 8 + first = history[0] + assert first.ext_item_id == "2304054124" + assert first.title == "3-к. квартира, 108\xa0м², 5/10\xa0эт." + assert (first.start_price, first.last_price) == (11_000_000, 14_000_000) + assert first.start_price_date == date(2022, 1, 8) + assert first.last_price_date == date(2026, 1, 17) + assert first.exposure_days == 677 + assert first.removed_date is None # виджет даты снятия не отдаёт + assert first.raw_payload is not None and "itemImage" not in first.raw_payload + + +def test_avito_house_mini_serp_and_seller(house_572538: HouseCatalogEnrichment) -> None: + # Цена в виджете — строка «21 700 000 ₽» с неразрывными пробелами. + assert [(m.ext_item_id, m.price_rub) for m in house_572538.mini_serp] == [ + (8007602481, 21_700_000), + (8029678809, 21_600_000), + ] + first = house_572538.mini_serp[0] + assert first.metro_text == "Геологическая, 1,5 км" + assert first.metro_color == "#0A6F20" + assert first.seller is not None + assert first.seller.ext_seller_id == "i251003466" + assert first.seller.name == "Самолет Плюс Центральный" + assert first.seller.seller_type == "Компания" + + +def test_avito_house_recommendations(house_115188: HouseCatalogEnrichment) -> None: + recs = house_115188.recommendations + assert len(recs) == 14 + assert recs[0].title == "1-к. квартира, 30,3\xa0м², 3/5\xa0эт." + assert recs[0].price_text == "4\xa0000\xa0000 ₽" + assert recs[0].address == "Свердловская область, Екатеринбург, улица 40-летия Октября, 32А" + assert recs[0].image_url is not None and recs[0].image_url.startswith( + "https://50.img.avito.st/" + ) + + +# ── avito.houses: карточка ЖК (developmentPage) ──────────────────────────────── + + +def test_avito_novostroyka_development_page() -> None: + e = _house( + "avito_novostroyka_12879380.html", + "https://www.avito.ru/catalog/novostroyki/ekaterinburg/semeynyy_kvartal_beregovoy/12879380", + ) + h = e.house + assert h.ext_id == 12879380 + assert h.ext_id_hash == "MGDKHN70PQ" + assert h.title == "Семейный квартал «Береговой»" + assert h.short_address == "ул. Щербакова, жилые дома" + assert h.full_address is None # на странице пустая строка + assert (h.lat, h.lon) == (56.77005, 60.67244) + assert h.house_type == "monolith" + assert h.house_class == "comfort" + assert (h.passenger_elevators, h.cargo_elevators) == (42, 60) + assert (h.developer_name, h.developer_key) == ("Талан", "6749804656-3468412387") + assert h.year_built is None # дом строится: «Срок сдачи», а не год постройки + assert h.rating_score is None + assert e.reviews == [] and e.placement_history == [] + + +# ── avito.serp ───────────────────────────────────────────────────────────────── + + +def test_avito_serp_cards_to_lots() -> None: + scraper = AvitoScraper(SimpleNamespace(avito_serp_ekb_only=True)) # type: ignore[arg-type] + lots = scraper._parse_html( + _read("avito_serp_sample.html"), + source_url_base="https://www.avito.ru/ekaterinburg/kvartiry/prodam?p=1", + ) + got = [ + (lot.source_id, lot.price_rub, lot.rooms, lot.area_m2, lot.floor, lot.total_floors) + for lot in lots + ] + assert got == [ + ("8043936560", 5_500_000, 2, 54.0, 5, 9), + ("8163084615", 4_200_000, 1, 38.0, 3, 16), + ] + assert lots[0].source_url == ( + "https://www.avito.ru/ekaterinburg/kvartiry/2-k._kvartira_54m_59et._8043936560" + ) + # sortTimeStamp в миллисекундах, дата по Москве (#726). + assert [lot.listing_date for lot in lots] == [date(2023, 11, 15), date(2023, 11, 16)] + assert {lot.listing_segment for lot in lots} == {"vtorichka"} + assert scraper.parse_failures == 0 + + +# ── cian.serp ────────────────────────────────────────────────────────────────── + + +@pytest.fixture(scope="module") +def cian_lots() -> dict[str, Any]: + scraper = CianScraper(SimpleNamespace(glitchtip_dsn=None)) # type: ignore[arg-type] + html = _read("cian_serp_ekb_2room_trimmed.html") + lots = scraper._parse_serp_html(html) + assert scraper._extract_total_offers(html) == 13_895 + assert (scraper.state_extraction_attempts, scraper.state_extraction_failures) == (2, 0) + return {lot.source_id: lot for lot in lots} + + +def test_cian_serp_all_offers_parsed(cian_lots: dict[str, Any]) -> None: + assert list(cian_lots) == ["331661795", "333599038", "333279363", "321628098"] + + +def test_cian_serp_secondary_offer(cian_lots: dict[str, Any]) -> None: + lot = cian_lots["331661795"] + assert lot.source == "cian" + assert lot.source_url.startswith("https://ekb.cian.ru/sale/flat/331661795/") + assert lot.listing_segment == "vtorichka" + assert (lot.house_source, lot.house_ext_id) == ("cian", None) + assert lot.price_rub == 7_990_000 + assert (lot.rooms, lot.area_m2, lot.living_area_m2, lot.kitchen_area_m2) == ( + 2, + 47.2, + 28.0, + 7.5, + ) + assert (lot.floor, lot.total_floors, lot.year_built) == (2, 17, 1980) + assert lot.house_type == "panel" + assert lot.address == "р-н Октябрьский, мкр. Парковый, улица Большакова, 13" + assert (lot.lat, lot.lon) == (56.820035, 60.634287) + assert lot.listing_date == date(2026, 9, 14) + assert (lot.mortgage_available, lot.sale_type) == (True, "alternative") + assert (lot.is_homeowner, lot.is_pro_seller) == (False, True) + assert lot.phones == [] # #2217: телефоны не сохраняем + assert lot.metro_stations[0] == { + "name": "Геологическая", + "time": 5, + "mode": "transport", + "line_color": "00701A", + "line_id": 26, + "is_default": True, + } + # descriptionMinhash приходит списком — склеен через запятую в исходном порядке. + assert lot.description_minhash.startswith("16147461,5781654,4548911,") + assert len(lot.photo_urls) == 3 + + +def test_cian_serp_newbuilding_with_house(cian_lots: dict[str, Any]) -> None: + lot = cian_lots["333599038"] + assert lot.listing_segment == "novostroyki" + assert (lot.house_source, lot.house_ext_id) == ("cian_newbuilding", "5779142") + assert lot.address == "р-н Железнодорожный, мкр. Старая Сортировка, переулок Проходной, 1Б" + assert lot.year_built == 2028 # buildYear нет, берётся год сдачи из deadline + assert lot.house_type == "monolith" + assert (lot.price_rub, lot.area_m2, lot.floor, lot.total_floors) == (4_834_000, 36.04, 22, 32) + assert lot.sale_type == "fz214" + + +def test_cian_serp_newbuilding_decoration_and_balcony(cian_lots: dict[str, Any]) -> None: + lot = cian_lots["333279363"] + assert lot.repair_state == "good" # decoration=fine + assert lot.has_balcony is True # balconiesCount=1 + assert lot.house_type is None # materialType на странице null + assert lot.year_built == 2026 + + +def test_cian_serp_newbuilding_without_street_keeps_address_houseless( + cian_lots: dict[str, Any], +) -> None: + """В geo.address нет ни улицы, ни дома; в названии ЖК номера нет, кадастра нет. + Номер дома не выдумывается (#1773).""" + lot = cian_lots["321628098"] + assert lot.address == "р-н Чкаловский, мкр. Вторчермет" + assert (lot.house_source, lot.house_ext_id) == ("cian_newbuilding", "5705732") + + +# ── yandex.serp ──────────────────────────────────────────────────────────────── + + +def _gate(name: str) -> dict[str, Any]: + return json.loads(_read(name)) + + +@pytest.fixture(scope="module") +def yandex_secondary() -> dict[str, Any]: + lots = _parse_gate_json(_gate("yandex_gate_ekb_vtorichka_trimmed.json"), 1, "NO") + return {lot.source_id: lot for lot in lots} + + +def test_yandex_gate_pager() -> None: + extracted = _extract_gate_data(_gate("yandex_gate_ekb_vtorichka_trimmed.json")) + assert extracted is not None + entities, pager = extracted + assert len(entities) == 3 + assert (pager["totalItems"], pager["totalPages"]) == (4077, 25) + + +def test_yandex_agency_offer_with_partner_url(yandex_secondary: dict[str, Any]) -> None: + lot = yandex_secondary["5228381943217465492"] + # url в выдаче — сайт агентства; source_url строится из offerId (#2235). + assert lot.source_url == "https://realty.yandex.ru/offer/5228381943217465492/" + assert lot.yandex_offer_id == "5228381943217465492" + assert lot.listing_segment == "vtorichka" + assert (lot.price_rub, lot.price_per_m2) == (4_400_000, 129_412) + assert (lot.rooms, lot.area_m2, lot.living_area_m2, lot.kitchen_area_m2) == (1, 34.0, None, 7.0) + assert (lot.floor, lot.total_floors, lot.year_built) == (8, 9, 1978) + assert lot.house_type == "brick" # BRICK + assert lot.ceiling_height_m == 2.5 + assert lot.address == "Россия, Свердловская область, Екатеринбург, Шефская улица, 85" + assert (lot.lat, lot.lon) == (56.903275, 60.6229) + assert (lot.house_source, lot.house_ext_id) == (None, None) + assert lot.listing_date == lot.publish_date == date(2026, 8, 19) + assert (lot.is_homeowner, lot.is_pro_seller, lot.agency_name) == (None, True, "САМОЛЕТ ПЛЮС") + assert (lot.predicted_price_rub, lot.predicted_price_min, lot.predicted_price_max) == ( + 4_217_000, + 3_795_000, + 4_638_000, + ) + assert (lot.price_trend, lot.price_previous_rub) == ("DECREASED", 4_450_000) + assert lot.metro_stations[0] == {"name": "Проспект Космонавтов", "min": 7} + # В фикстуре 6 фото с «//»: берутся первые 5 со схемой. + assert len(lot.photo_urls) == 5 + assert all(u.startswith("https://avatars.mds.yandex.net/") for u in lot.photo_urls) + + +def test_yandex_owner_offer_in_complex(yandex_secondary: dict[str, Any]) -> None: + lot = yandex_secondary["6282460993454547968"] + assert lot.source_url == "https://realty.yandex.ru/offer/6282460993454547968" + assert (lot.is_homeowner, lot.is_pro_seller, lot.agency_name) == (True, None, None) + assert (lot.house_source, lot.house_ext_id) == ("yandex_realty_nb", "1668651") + assert lot.house_type == "monolith" # MONOLIT + assert (lot.floor, lot.total_floors) == (27, 27) + assert (lot.living_area_m2, lot.kitchen_area_m2) == (11.0, 14.0) + + +def test_yandex_studio_without_rooms_total(yandex_secondary: dict[str, Any]) -> None: + lot = yandex_secondary["8530722463088927619"] + assert lot.rooms == 0 # roomsTotal отсутствует — студия + assert lot.area_m2 == 20.8 + assert lot.kitchen_area_m2 is None + assert lot.house_type == "monolith_brick" # MONOLIT_BRICK + + +def test_yandex_newbuilding_without_valuation() -> None: + lots = _parse_gate_json(_gate("yandex_gate_ekb_novostroyki_trimmed.json"), 1, "YES") + assert len(lots) == 1 + lot = lots[0] + assert lot.source_id == "6216493807134609867" + assert lot.listing_segment == "novostroyki" + assert lot.price_rub == 27_620_850 + assert (lot.predicted_price_rub, lot.predicted_price_min, lot.predicted_price_max) == ( + None, + None, + None, + ) + assert lot.ceiling_height_m is None + assert lot.house_type is None + assert (lot.house_source, lot.house_ext_id) == ("yandex_realty_nb", "2006795") + assert (lot.is_homeowner, lot.is_pro_seller, lot.agency_name) == (None, None, None) + assert lot.address == ( + "Россия, Свердловская область, Екатеринбург, жилой район Центральный, " + "ЖК Александровский Сад" + ) diff --git a/tradein-mvp/backend/tests/skip_allowlist.txt b/tradein-mvp/backend/tests/skip_allowlist.txt index 2da609d4..bf342861 100644 --- a/tradein-mvp/backend/tests/skip_allowlist.txt +++ b/tradein-mvp/backend/tests/skip_allowlist.txt @@ -37,6 +37,7 @@ tests/test_2189_canon_keeps_slash.py::test_real_gar_match_picks_the_house_with_t tests/test_2189_canon_keeps_slash.py::test_migration_322_resets_only_matches_held_by_the_lost_slash tests/test_gar_flats_loader.py::test_upsert_and_canon_match_populates_gar_flat_count tests/test_gar_flats_loader.py::test_no_city_filter_ambiguous_canon_not_matched +tests/test_gar_flats_loader.py::test_house_gets_gar_guid_only_from_its_own_region tests/test_house_dedup_merge.py::test_real_canon_clusterkey_and_geo_guard_merge_semantics tests/test_house_dedup_merge.py::test_real_canon_strips_district_glued_to_house_number tests/test_house_dedup_merge.py::test_real_fias_pass_cross_guard_and_identity_carryover @@ -186,3 +187,8 @@ tests/test_3404_egress_run_attribution.py::test_attribution_does_not_commit_call # снятия подписи батча, сужения окна до ×10 и снятия порога — проверено вручную 17.09. tests/test_3385_migration_310_yandex_seed_slips.py::test_deletes_only_seed_off_by_order_and_is_idempotent tests/test_3385_migration_310_yandex_seed_slips.py::test_stops_and_rolls_back_when_too_many + +# Вечный недогруз Яндекса (#3191, миграция 321): снапшот-SELECT очереди добора с +# суточной паузой и выбыванием после трёх недогрузов судит только Postgres — на +# мок-лэйне БД нет. В ci-tradein.yml бежит по-настоящему (postgres-сервис, #2745). +tests/test_3191_yandex_perpetual_underloaded.py::test_perpetual_underloaded_cards_leave_queue_head diff --git a/tradein-mvp/backend/tests/test_2898_exposure_window.py b/tradein-mvp/backend/tests/test_2898_exposure_window.py new file mode 100644 index 00000000..2827b8ff --- /dev/null +++ b/tradein-mvp/backend/tests/test_2898_exposure_window.py @@ -0,0 +1,151 @@ +"""#2898: окно срока экспозиции (p25–p75) вместо одного числа и его персист. + +Раньше est_days_on_market был медианой возраста АКТИВНЫХ объявлений-аналогов и не +сохранялся: отчёт по ссылке отдавал null. Теперь квартили exposure_days снятых +объявлений считаются на POST, пишутся в trade_in_estimates и поднимаются на GET. + +Ожидаемые квартили посчитаны руками по формуле percentile_cont (позиция +(n-1)·q, линейная интерполяция, ::int округляет .5 вверх), а не той же функцией. +""" + +from __future__ import annotations + +import contextlib +import os +from typing import Any + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from unittest.mock import AsyncMock, MagicMock, patch + +import anyio + +from app.services.estimator import ( + EXPOSURE_WINDOW_MIN_N, + _exposure_window, + _fetch_exposure_days, +) + +# 2..31 (n=30): p25 на позиции 7.25 → 9.25 → 9; p50 на 14.5 → 16.5 → 17 (банковское +# округление дало бы 16); p75 на 21.75 → 23.75 → 24. +_DAYS_30 = list(range(2, 32)) + + +def test_quartiles_match_percentile_cont() -> None: + w = _exposure_window(_DAYS_30) + assert w is not None + assert (w.p25_days, w.p50_days, w.p75_days, w.n) == (9, 17, 24, 30) + + +def test_order_of_input_does_not_matter() -> None: + w = _exposure_window(list(reversed(_DAYS_30))) + assert w is not None + assert (w.p25_days, w.p50_days, w.p75_days) == (9, 17, 24) + + +def test_skewed_sample_window() -> None: + """Прод-подобная выборка: пачка ровно по 31 дню (срок размещения на Авито) и хвост.""" + days = [31] * 20 + [62] * 5 + [120, 180, 240, 300, 400] + w = _exposure_window(days) + assert w is not None + # позиции 7.25 / 14.5 / 21.75 — внутри пачки 31 и пачки 62 + assert (w.p25_days, w.p50_days, w.p75_days) == (31, 31, 62) + + +def test_below_threshold_no_window() -> None: + assert EXPOSURE_WINDOW_MIN_N == 30 + assert _exposure_window(_DAYS_30[:-1]) is None + assert _exposure_window([]) is None + + +def test_fetch_failure_is_graceful() -> None: + db = MagicMock() + db.execute.side_effect = RuntimeError("boom") + assert _fetch_exposure_days(db, lat=56.8, lon=60.6, rooms=1, area=40.0, radius_m=1000) == [] + + +def test_fetch_without_coords_does_not_query() -> None: + db = MagicMock() + assert _fetch_exposure_days(db, lat=None, lon=60.6, rooms=1, area=40.0, radius_m=1000) == [] + assert not db.execute.called + + +# ── POST: окно уходит и в ответ, и в INSERT ─────────────────────────────────── + + +def _run_estimate(exposure_days: list[int]) -> tuple[Any, dict[str, Any]]: + from app.schemas.trade_in import TradeInEstimateInput + from app.services.estimator import estimate_quality + from app.services.geocoder import GeocodeResult + + db = MagicMock() + payload = TradeInEstimateInput(address="ЕКБ, ул. Тестовая, 1", area_m2=40.0, rooms=1) + geo = GeocodeResult( + lat=56.838, lon=60.595, full_address="Екатеринбург, ул. Тестовая, 1", provider="nominatim" + ) + fetch = MagicMock(return_value=exposure_days) + patches = ( + 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=([], False, "W")), + patch("app.services.estimator._fetch_deals", return_value=[]), + 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=(None, None)), + patch("app.services.estimator._fetch_exposure_days", new=fetch), + ) + + async def _run() -> Any: + with contextlib.ExitStack() as stack: + for cm in patches: + stack.enter_context(cm) + return await estimate_quality(payload, db) + + result = anyio.run(_run) + # Выборка берётся по координатам и параметрам оцениваемой квартиры. + kwargs = fetch.call_args.kwargs + assert (kwargs["lat"], kwargs["lon"], kwargs["rooms"], kwargs["area"]) == ( + 56.838, + 60.595, + 1, + 40.0, + ) + for call in db.execute.call_args_list: + sql = str(getattr(call.args[0], "text", call.args[0])) + if "INSERT INTO trade_in_estimates" in sql: + return result, call.args[1] + raise AssertionError("INSERT INTO trade_in_estimates не вызван") + + +def test_post_persists_window_and_returns_it() -> None: + result, params = _run_estimate(_DAYS_30) + assert ( + params["est_days_p25"], + params["est_days_p50"], + params["est_days_p75"], + params["est_days_n"], + ) == (9, 17, 24, 30) + assert result.exposure_window is not None + assert (result.exposure_window.p25_days, result.exposure_window.p75_days) == (9, 24) + # Старое поле для старых клиентов — медиана того же окна, а не возраст объявлений. + assert result.est_days_on_market == 17 + + +def test_post_small_sample_persists_nulls() -> None: + result, params = _run_estimate(_DAYS_30[:-1]) + assert [params[k] for k in ("est_days_p25", "est_days_p50", "est_days_p75", "est_days_n")] == [ + None, + None, + None, + None, + ] + assert result.exposure_window is None + assert result.est_days_on_market is None diff --git a/tradein-mvp/backend/tests/test_2993_snapshot_change_only.py b/tradein-mvp/backend/tests/test_2993_snapshot_change_only.py new file mode 100644 index 00000000..947e505a --- /dev/null +++ b/tradein-mvp/backend/tests/test_2993_snapshot_change_only.py @@ -0,0 +1,246 @@ +"""#2993: listing_source_snapshots получает строку только при изменении источника. + +Проверки идут по ЗНАЧЕНИЯМ в живом Postgres: какие источники получили строку за +сегодня, с какими значениями, и какие события записал event-diff поверх них. +Без реальной БД файл self-skip'ается, как соседние live-тесты (в CI есть +postgres, см. ci-tradein.yml). Вся синтетика живёт в одной транзакции, очистка — +rollback. +""" + +from __future__ import annotations + +import os +import uuid +from typing import Any + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import pytest +from sqlalchemy import text + +from app.tasks import listing_source_snapshot as snap_mod + + +def _live_session() -> Any | None: + """Тот же контракт, что у test_revisit_floor_lateral_lookup.py.""" + 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) + conn = engine.connect() + conn.execute(text("SELECT 1")) + conn.close() + return sessionmaker(bind=engine, future=True)() + except Exception: + return None + + +pytestmark = pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") + +_PRICE = 5_000_000 + + +def _source(db: Any, tag: str, *, price: int, payload: str, seen_days_ago: int) -> int: + ext_id = f"zzz2993-{tag}-{uuid.uuid4().hex[:8]}" + listing_id = db.execute( + text( + """ + INSERT INTO listings + (source, source_url, source_id, dedup_hash, price_rub, + is_active, scraped_at, last_seen_at) + VALUES ('cian', :url, :ext_id, :ext_id, :price, true, now(), now()) + RETURNING id + """ + ), + {"url": f"https://example.test/2993/{ext_id}", "ext_id": ext_id, "price": price}, + ).scalar_one() + return int( + db.execute( + text( + """ + INSERT INTO listing_sources + (listing_id, ext_source, ext_id, confidence, matched_method, + price_rub, raw_payload, last_seen_at) + VALUES (:listing_id, 'cian', :ext_id, 1.0, 'test', + :price, CAST(:payload AS jsonb), + now() - make_interval(days => :seen)) + RETURNING id + """ + ), + { + "listing_id": listing_id, + "ext_id": ext_id, + "price": price, + "payload": payload, + "seen": seen_days_ago, + }, + ).scalar_one() + ) + + +def _snapshot( + db: Any, + lsid: int, + *, + days_ago: int, + price: int, + payload: str, + seen_days_ago: int, + is_active: bool = True, +) -> None: + # now() постоянен внутри транзакции — last_seen_at снимка совпадает с + # listing_sources.last_seen_at до микросекунды, если seen_days_ago тот же. + db.execute( + text( + """ + INSERT INTO listing_source_snapshots + (listing_source_id, snapshot_date, price_rub, is_active, + last_seen_at, payload_hash) + VALUES (:lsid, CURRENT_DATE - :days_ago, :price, :is_active, + now() - make_interval(days => :seen), + md5(CAST(:payload AS jsonb)::text)) + """ + ), + { + "lsid": lsid, + "days_ago": days_ago, + "price": price, + "is_active": is_active, + "seen": seen_days_ago, + "payload": payload, + }, + ) + + +def _run_writer(db: Any) -> None: + db.execute( + snap_mod._SNAPSHOT_SQL, + {"freshness_days": snap_mod.FRESHNESS_WINDOW_DAYS, "run_id": None}, + ) + + +def _today(db: Any, ids: list[int]) -> dict[int, tuple[int, bool]]: + rows = db.execute( + text( + """ + SELECT listing_source_id, price_rub, is_active + FROM listing_source_snapshots + WHERE snapshot_date = CURRENT_DATE AND listing_source_id = ANY(:ids) + """ + ), + {"ids": ids}, + ).fetchall() + return {int(r[0]): (int(r[1]), bool(r[2])) for r in rows} + + +def _seed(db: Any) -> dict[str, int]: + """Семь источников: по одному на каждую ветку условия записи.""" + p = '{"v": 1}' + ids = { + "same": _source(db, "same", price=_PRICE, payload=p, seen_days_ago=1), + "same_gap": _source(db, "same_gap", price=_PRICE, payload=p, seen_days_ago=1), + "price": _source(db, "price", price=_PRICE, payload=p, seen_days_ago=1), + "payload": _source(db, "payload", price=_PRICE, payload=p, seen_days_ago=1), + "seen": _source(db, "seen", price=_PRICE, payload=p, seen_days_ago=1), + "active": _source(db, "active", price=_PRICE, payload=p, seen_days_ago=10), + "new": _source(db, "new", price=_PRICE, payload=p, seen_days_ago=1), + } + _snapshot(db, ids["same"], days_ago=1, price=_PRICE, payload=p, seen_days_ago=1) + # Последний снимок пятидневной давности совпадает, а более старый — нет: + # сравнивать обязаны с ПОСЛЕДНИМ, а не с любым из прошлых. + _snapshot(db, ids["same_gap"], days_ago=9, price=_PRICE - 1, payload=p, seen_days_ago=9) + _snapshot(db, ids["same_gap"], days_ago=5, price=_PRICE, payload=p, seen_days_ago=1) + _snapshot(db, ids["price"], days_ago=1, price=4_900_000, payload=p, seen_days_ago=1) + _snapshot(db, ids["payload"], days_ago=1, price=_PRICE, payload='{"v": 0}', seen_days_ago=1) + _snapshot(db, ids["seen"], days_ago=1, price=_PRICE, payload=p, seen_days_ago=3) + # last_seen_at тот же, но 10 суток > окна свежести: вчера снимок был активным. + _snapshot(db, ids["active"], days_ago=1, price=_PRICE, payload=p, seen_days_ago=10) + return ids + + +def test_only_changed_or_new_sources_get_a_row_today() -> None: + db = _live_session() + try: + ids = _seed(db) + _run_writer(db) + today = _today(db, list(ids.values())) + by_tag = {tag: today.get(lsid) for tag, lsid in ids.items()} + + assert by_tag == { + "same": None, + "same_gap": None, + "price": (_PRICE, True), + "payload": (_PRICE, True), + "seen": (_PRICE, True), + "active": (_PRICE, False), + "new": (_PRICE, True), + } + finally: + db.rollback() + db.close() + + +def test_events_survive_change_only_writes() -> None: + """event-diff видит только записанные сегодня строки — и не теряет ни одного события.""" + db = _live_session() + try: + ids = _seed(db) + _run_writer(db) + db.execute(snap_mod._EVENT_DIFF_SQL).fetchall() + rows = db.execute( + text( + """ + SELECT listing_source_id, event_type, diff_percent + FROM listing_source_events + WHERE listing_source_id = ANY(:ids) + """ + ), + {"ids": list(ids.values())}, + ).fetchall() + tag_of = {lsid: tag for tag, lsid in ids.items()} + events = {(tag_of[int(r[0])], r[1], None if r[2] is None else float(r[2])) for r in rows} + + assert events == { + ("price", "price_change", 2.0408), # round((5.0-4.9)/4.9*100, 4) + ("payload", "edited", None), + ("new", "first_seen", None), + } + finally: + db.rollback() + db.close() + + +def test_rerun_same_day_compares_with_todays_row() -> None: + """Второй прогон в те же сутки сравнивает с уже записанной сегодня строкой. + + price вернулся к вчерашней цене — сегодняшняя строка обязана перезаписаться + (иначе на сегодня осталась бы цена, которой у источника больше нет); same + изменился после первого прогона — строка появляется; seen не менялся после + первого прогона — строка остаётся как была. + """ + db = _live_session() + try: + ids = _seed(db) + _run_writer(db) + db.execute( + text("UPDATE listing_sources SET price_rub = 4900000 WHERE id = :id"), + {"id": ids["price"]}, + ) + db.execute( + text("UPDATE listing_sources SET price_rub = 5100000 WHERE id = :id"), + {"id": ids["same"]}, + ) + _run_writer(db) + today = _today(db, list(ids.values())) + + assert today.get(ids["price"]) == (4_900_000, True) + assert today.get(ids["same"]) == (5_100_000, True) + assert today.get(ids["seen"]) == (_PRICE, True) + assert ids["same_gap"] not in today + finally: + db.rollback() + db.close() diff --git a/tradein-mvp/backend/tests/test_3191_yandex_perpetual_underloaded.py b/tradein-mvp/backend/tests/test_3191_yandex_perpetual_underloaded.py new file mode 100644 index 00000000..05fd84bc --- /dev/null +++ b/tradein-mvp/backend/tests/test_3191_yandex_perpetual_underloaded.py @@ -0,0 +1,230 @@ +"""Вечно недогружаемые карточки Яндекса не держат очередь добора (#3191). + +Прод 13.09 21:14 – 16.09 18:52: пять объявлений (10776456, 10775964, 10775945, +10775747, 10775594), стоящих в очереди подряд, в каждом прогоне приходили пустой +SPA-оболочкой ~381 КБ без блока контактов. Пять недогрузов подряд выбивали брейкер +consecutive_none=5: 16 прогонов закончились ровно attempted=5 incomplete=5, а +«успешные» обрывались, едва дойдя до них. За ними — 20 тыс. необогащённых объявлений. + +Проверки ПО ЗНАЧЕНИЮ: + * мок-часть — какие карточки запрошены и какие счётчики вышли; + * живой Postgres (в CI есть, локально skip) — что НАСТОЯЩИЙ снапшот-SELECT после + недогруза не берёт карточку сутки, берёт её после паузы и выбрасывает после трёх. +""" + +from __future__ import annotations + +import os +import sys +import uuid +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +_wp_mock = MagicMock() +sys.modules.setdefault("weasyprint", _wp_mock) + +import pytest # noqa: E402 +from sqlalchemy import text # noqa: E402 + +from app.tasks.yandex_detail_backfill import run_yandex_detail_backfill # noqa: E402 + +_ASYNC_SESSION = "app.tasks.yandex_detail_backfill.AsyncSession" +_PARSE = "app.tasks.yandex_detail_backfill.YandexDetailScraper.parse" +_SAVE = "app.tasks.yandex_detail_backfill.save_detail_enrichment" +_RUNS = "app.tasks.yandex_detail_backfill.runs_mod" +_SLEEP = "app.tasks.yandex_detail_backfill.asyncio.sleep" +_RESOLVE_PROXY_URL = "app.tasks.yandex_detail_backfill.resolve_proxy_url" + +# Оболочка как на проде: 381 КБ, контактов нет. Полная — 3,9 МБ с маркером. +SHELL_HTML = "" + "x" * 381_157 + "" +FULL_HTML = "" + "x" * 3_920_119 + '"encryptedPhones":["a"]' + +PERPETUAL = 5 +NORMAL = 3 + + +def _resp(html: str) -> MagicMock: + resp = MagicMock() + resp.status_code = 200 + resp.text = html + return resp + + +async def _run(db: Any, perpetual_urls: set[str]) -> tuple[Any, list[str]]: + """Один прогон; оболочку отдаём по URL, всё остальное — полная карточка.""" + requested: list[str] = [] + + async def get(url: str, **_kw: Any) -> MagicMock: + requested.append(url) + return _resp(SHELL_HTML if url in perpetual_urls else FULL_HTML) + + session = AsyncMock() + session.get = AsyncMock(side_effect=get) + ctx = MagicMock() + ctx.__aenter__ = AsyncMock(return_value=session) + ctx.__aexit__ = AsyncMock(return_value=None) + + with ( + patch(_ASYNC_SESSION, MagicMock(return_value=ctx)), + patch(_PARSE, return_value=MagicMock()), + patch(_SAVE, MagicMock(return_value=True)), + patch(_RUNS, MagicMock()), + patch(_SLEEP, new_callable=AsyncMock), + patch(_RESOLVE_PROXY_URL, MagicMock(return_value="http://proxy:3128")), + ): + counters = await run_yandex_detail_backfill( + db, run_id=3191, params={"batch_size": PERPETUAL + NORMAL, "budget_sec": 3600} + ) + return counters, requested + + +# ── 1. Брейкер: повтор известной карточки не рвёт прогон, первый недогруз — рвёт ── + + +def _mock_db(prior_incomplete: int) -> tuple[MagicMock, set[str], list[str]]: + rows = [ + { + "id": i + 1, + "source_url": f"https://realty.yandex.ru/offer/{i + 1}/", + "detail_incomplete_count": prior_incomplete if i < PERPETUAL else 0, + } + for i in range(PERPETUAL + NORMAL) + ] + db = MagicMock() + sel = MagicMock() + sel.mappings.return_value.all.return_value = rows + sel.one.return_value = MagicMock(url_from_offer_id=0, unenrichable_pending=0) + sel.scalar_one.return_value = 0 + db.execute.return_value = sel + urls = [r["source_url"] for r in rows] + return db, set(urls[:PERPETUAL]), urls[PERPETUAL:] + + +@pytest.mark.asyncio +async def test_retry_of_known_underloaded_cards_does_not_abort_run() -> None: + """Пять карточек, уже недогружавшихся раньше, в голове снапшота — прогон идёт дальше.""" + db, perpetual, normal = _mock_db(prior_incomplete=1) + + counters, requested = await _run(db, perpetual) + + assert set(normal) <= set(requested), ( + f"запрошено {len(requested)} из {PERPETUAL + NORMAL}: повторный недогруз известных " + "карточек оборвал прогон раньше, чем очередь дошла до следующих" + ) + assert (counters.attempted, counters.enriched, counters.incomplete) == (8, 3, 5) + + +@pytest.mark.asyncio +async def test_first_underload_series_still_aborts_run() -> None: + """Защита от системного недогруза жива: пять ПЕРВЫХ недогрузов подряд — обрыв.""" + db, perpetual, normal = _mock_db(prior_incomplete=0) + + counters, requested = await _run(db, perpetual) + + assert not set(normal) & set(requested), ( + "пять первых недогрузов подряд не оборвали прогон — брейкер системного " + "недогруза больше не срабатывает" + ) + assert (counters.attempted, counters.enriched, counters.incomplete) == (5, 0, 5) + + +# ── 2. Очередь на живом 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) + conn = engine.connect() + conn.execute(text("SELECT 1")) + conn.close() + return sessionmaker(bind=engine, future=True)() + except Exception: + return None + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +@pytest.mark.asyncio +async def test_perpetual_underloaded_cards_leave_queue_head() -> None: + db = _live_session() + tag = f"t3191-{uuid.uuid4().hex[:8]}" + try: + ids: list[int] = [] + urls: list[str] = [] + for i in range(PERPETUAL + NORMAL): + url = f"https://realty.yandex.ru/offer/3191{uuid.uuid4().int % 10**12}/" + # Вечные — самые свежие (голова очереди), как 12.09 15:02 на проде. + # Дата в будущем: чужие необогащённые строки тестовой БД не встанут впереди. + row_id = db.execute( + text( + """ + INSERT INTO listings (source, source_url, source_id, dedup_hash, + price_rub, is_active, scraped_at) + VALUES ('yandex', :url, :sid, :sid, 5000000, true, + TIMESTAMPTZ '2100-01-01' - make_interval(mins => :i)) + RETURNING id + """ + ), + {"url": url, "sid": f"{tag}-{i}", "i": i}, + ).scalar_one() + ids.append(row_id) + urls.append(url) + db.commit() + perpetual = set(urls[:PERPETUAL]) + normal = urls[PERPETUAL:] + + # Прогон 1: карточки ещё не недогружались — первый недогруз, обрыв на пятой. + _c1, req1 = await _run(db, perpetual) + assert set(req1) == perpetual, req1 + + # Прогон 2 сразу следом: голова очереди больше не держит — берутся следующие. + c2, req2 = await _run(db, perpetual) + assert not set(req2) & perpetual, ( + "недогруженные час назад карточки снова в снапшоте — прогон опять " + "упрётся в них, как 16 прогонов 13.09–16.09" + ) + assert set(normal) <= set(req2) + assert c2.enriched == NORMAL + + # Сутки спустя: повтор (разовый недогруз мог пройти) и прогон не обрывается. + def age(hours: int) -> None: + db.execute( + text( + "UPDATE listings SET detail_incomplete_at = now() - make_interval(hours => :h) " + "WHERE id = ANY(:ids)" + ), + {"h": hours, "ids": ids[:PERPETUAL]}, + ) + db.commit() + + age(25) + c3, req3 = await _run(db, perpetual) + assert perpetual <= set(req3), "после суток паузы карточки обязаны вернуться в очередь" + assert set(normal) <= set(req3), "повтор известных карточек оборвал прогон" + assert c3.incomplete == PERPETUAL + + age(25) + await _run(db, perpetual) # третья попытка + age(25) + c5, req5 = await _run(db, perpetual) + assert not set(req5) & perpetual, "после трёх недогрузов карточка всё ещё в очереди" + assert c5.incomplete_given_up >= PERPETUAL, ( + f"incomplete_given_up={c5.incomplete_given_up}: выбывшие из очереди не посчитаны" + ) + counts = db.execute( + text("SELECT detail_incomplete_count FROM listings WHERE id = ANY(:ids) ORDER BY id"), + {"ids": ids}, + ).scalars() + assert list(counts) == [3] * PERPETUAL + [0] * NORMAL + finally: + db.rollback() + db.execute(text("DELETE FROM listings WHERE source_id LIKE :p"), {"p": f"{tag}-%"}) + db.commit() + db.close() diff --git a/tradein-mvp/backend/tests/test_3412_browser_init_reaps_zombies.py b/tradein-mvp/backend/tests/test_3412_browser_init_reaps_zombies.py new file mode 100644 index 00000000..3c6eda6f --- /dev/null +++ b/tradein-mvp/backend/tests/test_3412_browser_init_reaps_zombies.py @@ -0,0 +1,24 @@ +"""#3412: у tradein-browser PID 1 — init, который собирает осиротевших детей camoufox. + +Без `init: true` PID 1 контейнера — `python server.py`; дочерние процессы закрытого +браузера переходят к нему и остаются зомби (прод 17.09.2026: 2296 процессов в Z за 14 ч). +Проверка по значению: разобранный compose, сервис с образом tradein-browser. +""" + +from pathlib import Path + +import yaml + +_COMPOSE = Path(__file__).resolve().parents[2] / "docker-compose.prod.yml" + + +def test_browser_service_runs_under_init() -> None: + services = yaml.safe_load(_COMPOSE.read_text())["services"] + browser = { + name: svc + for name, svc in services.items() + if "gendesign-tradein-browser" in svc.get("image", "") + } + # Контроль разбора: пустой словарь дал бы зелёный тест ни о чём. + assert set(browser) == {"browser"}, sorted(browser) + assert browser["browser"].get("init") is True diff --git a/tradein-mvp/backend/tests/test_backtest_estimator.py b/tradein-mvp/backend/tests/test_backtest_estimator.py index 7d5caf0f..afac780a 100644 --- a/tradein-mvp/backend/tests/test_backtest_estimator.py +++ b/tradein-mvp/backend/tests/test_backtest_estimator.py @@ -519,13 +519,23 @@ def test_argparse_resolve_house_id_override() -> None: def test_house_id_resolution_as_json_shape() -> None: - res = bt._HouseIdResolution(total=10, resolved=4, imv_reachable=2) - assert res.as_json() == {"resolved": 4, "total": 10, "imv_reachable": 2} + res = bt._HouseIdResolution(total=10, resolved=4, imv_reachable=2, house_type_from_houses=3) + assert res.as_json() == { + "resolved": 4, + "total": 10, + "imv_reachable": 2, + "house_type_from_houses": 3, + } def test_house_id_resolution_defaults_zero() -> None: res = bt._HouseIdResolution() - assert res.as_json() == {"resolved": 0, "total": 0, "imv_reachable": 0} + assert res.as_json() == { + "resolved": 0, + "total": 0, + "imv_reachable": 0, + "house_type_from_houses": 0, + } # --------------------------------------------------------------------------- # @@ -1006,3 +1016,108 @@ def test_predict_full_spine_passes_resolved_city_to_corridor( assert captured.get("called") is True assert captured.get("city") == "нижний тагил" assert pred is not None + + +# --------------------------------------------------------------------------- # +# #2862: тип дома из `houses` доезжает до отбора аналогов, как в estimate_quality. +# --------------------------------------------------------------------------- # + + +def _run_full_with_house_facts( + monkeypatch: pytest.MonkeyPatch, *, resolve_house_id: bool +) -> tuple[dict[int, set[object]], list[dict[str, object]], dict[str, object]]: + from types import SimpleNamespace + + from app.services import estimator as est_mod + + def _deal(deal_id: int, house_type: str | None) -> object: + return bt.DealSample( + id=deal_id, + lon=60.6, + lat=56.8, + rooms=2, + sold_ppm2=100_000.0, + deal_date=None, + area_m2=50.0, + address="Екатеринбург, ул. Ленина, 5", + floor=3, + year_built=None, + house_type=house_type, + ) + + # Сделка 1 — как все сделки на проде (house_type пуст); сделка 2 — тип уже есть. + monkeypatch.setattr(bt, "_load_sample", lambda *a, **kw: [_deal(1, None), _deal(2, "brick")]) + monkeypatch.setattr(bt, "_resolve_house_id_for_deal", lambda _db, _deal: 77) + + lookups: list[dict[str, object]] = [] + + def _fake_lookup(_db: object, **kw: object) -> object: + lookups.append(kw) + return est_mod._HouseFacts( + house_id=77, total_floors=16, year_built=1985, house_type="panel", material_walls=None + ) + + seen: dict[int, set[object]] = {} + current: dict[str, int] = {} + + def _fake_fetch_analogs(_db: object, **kw: object) -> tuple[list[object], bool, str]: + seen.setdefault(current["id"], set()).add(kw["house_type"]) + return [], False, "W" + + def _fake_price_from_inputs(**_kw: object) -> SimpleNamespace: + return SimpleNamespace( + median_price=5_000_000.0, + median_ppm2=100_000.0, + confidence="low", + anchor_tier=None, + expected_sold_per_m2=95_000.0, + expected_sold_price=4_750_000.0, + expected_sold_range_low=4_000_000.0, + expected_sold_range_high=5_500_000.0, + ) + + real_predict = bt._predict_full_spine + + def _tracking_predict(db: object, deal: object, est: object, **kw: object) -> object: + current["id"] = deal.id # type: ignore[attr-defined] + return real_predict(db, deal, est, **kw) # type: ignore[arg-type] + + monkeypatch.setattr(est_mod, "_lookup_house_facts", _fake_lookup) + monkeypatch.setattr(est_mod, "_fetch_analogs", _fake_fetch_analogs) + monkeypatch.setattr(est_mod, "_resolve_target_city", lambda _addr: None) + monkeypatch.setattr(est_mod, "_fetch_dkp_corridor", lambda *a, **kw: None) + monkeypatch.setattr(est_mod, "_fetch_anchor_comps", lambda *a, **kw: ([], None)) + monkeypatch.setattr(est_mod, "_fetch_house_imv_anchor", lambda *a, **kw: None) + monkeypatch.setattr(est_mod, "_price_from_inputs", _fake_price_from_inputs) + monkeypatch.setattr(bt, "_predict_full_spine", _tracking_predict) + + metrics = bt.run_backtest_full( + None, # type: ignore[arg-type] + sample=2, + since="2025-06-01", + resolve_house_id=resolve_house_id, + ) + return seen, lookups, metrics + + +def test_full_backtest_takes_house_type_from_houses_like_prod( + monkeypatch: pytest.MonkeyPatch, +) -> None: + seen, lookups, metrics = _run_full_with_house_facts(monkeypatch, resolve_house_id=True) + + # Пустой тип сделки заменён типом дома — именно он уходит в каждый отбор аналогов. + assert seen[1] == {"panel"} + # Свой тип сделки главнее справочника, как форма главнее houses на проде. + assert seen[2] == {"brick"} + assert lookups == [{"target_house_id": 77, "lat": 56.8, "lon": 60.6}] + assert metrics["house_id_resolution"]["house_type_from_houses"] == 1 # type: ignore[index] + + +def test_full_backtest_without_flag_keeps_house_type_untouched( + monkeypatch: pytest.MonkeyPatch, +) -> None: + seen, lookups, metrics = _run_full_with_house_facts(monkeypatch, resolve_house_id=False) + + assert seen[1] == {None} + assert lookups == [] + assert "house_id_resolution" not in metrics diff --git a/tradein-mvp/backend/tests/test_estimate_idor.py b/tradein-mvp/backend/tests/test_estimate_idor.py index 5ed999e1..90bed6d3 100644 --- a/tradein-mvp/backend/tests/test_estimate_idor.py +++ b/tradein-mvp/backend/tests/test_estimate_idor.py @@ -80,6 +80,11 @@ def _make_estimate_row(created_by: str | None, retain_until: object = None) -> S # #2899: колонка есть у всех строк после миграции 267; NULL у старых # (бэкфилла нет — позицию по сохранённому top-10 не восстановить). market_percentile=63, + # #2898: окно срока экспозиции (миграция 324); NULL у строк до неё. + est_days_p25=31, + est_days_p50=58, + est_days_p75=121, + est_days_n=44, analogs=[], actual_deals=[], sources_used=["avito"], @@ -801,3 +806,36 @@ def test_get_estimate_market_percentile_nullable(trade_in_app: FastAPI) -> None: ) assert resp.status_code == 200 assert resp.json()["market_percentile"] is None + + +def test_get_estimate_surfaces_exposure_window(trade_in_app: FastAPI) -> None: + """#2898: окно срока поднимается из колонок — по ссылке то же, что при оценке. + + До #2898 GET отдавал est_days_on_market=null всегда: значение жило только в POST. + """ + row = _make_estimate_row(created_by="kopylov") + 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 + body = resp.json() + assert body["exposure_window"] == {"p25_days": 31, "p50_days": 58, "p75_days": 121, "n": 44} + assert body["est_days_on_market"] == 58 + + +def test_get_estimate_old_row_without_window(trade_in_app: FastAPI) -> None: + """Контроль: строка до миграции 324 (все четыре NULL) — окна нет, старое поле null.""" + row = _make_estimate_row(created_by="kopylov") + row.est_days_p25 = row.est_days_p50 = row.est_days_p75 = row.est_days_n = None + 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()["exposure_window"] is None + assert resp.json()["est_days_on_market"] is None diff --git a/tradein-mvp/backend/tests/test_estimate_revival.py b/tradein-mvp/backend/tests/test_estimate_revival.py index 3957f26d..21959606 100644 --- a/tradein-mvp/backend/tests/test_estimate_revival.py +++ b/tradein-mvp/backend/tests/test_estimate_revival.py @@ -602,3 +602,32 @@ def test_empty_estimate_persists_relaxations_reliability() -> None: src = inspect.getsource(estimator._empty_estimate) assert "relaxations, reliability" in src assert "'very_low'" in src + + +def test_revival_persists_exposure_window( + trade_in_app: FastAPI, _estimator_stub: SimpleNamespace +) -> None: + """#2898: пересчитанное окно срока пишется в исходную строку — иначе следующий + GET по той же ссылке отдал бы окно null, хотя ответ ревайвла его показал.""" + from app.schemas.trade_in import ExposureWindow + + window = ExposureWindow(p25_days=31, p50_days=58, p75_days=121, n=44) + + async def _fake_estimate_quality(payload, db, **kwargs): + return _fake_revived_result(exposure_window=window, est_days_on_market=58) + + _estimator_stub.estimate_quality = _fake_estimate_quality + + db = _dispatch_db(_make_dead_row(), claim_result=SimpleNamespace(id=_ESTIMATE_ID)) + resp = _client_with(trade_in_app, db).get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + params = _calls_containing(db, "UPDATE trade_in_estimates SET")[0].args[1] + assert [params[k] for k in ("est_days_p25", "est_days_p50", "est_days_p75", "est_days_n")] == [ + 31, + 58, + 121, + 44, + ] diff --git a/tradein-mvp/backend/tests/test_estimator_target_city_sweep_2583.py b/tradein-mvp/backend/tests/test_estimator_target_city_sweep_2583.py new file mode 100644 index 00000000..fcd0ab3d --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimator_target_city_sweep_2583.py @@ -0,0 +1,115 @@ +"""#2583 H1 — город развёртки обязан попадать в городской фильтр ДКП-коридора. + +Словарь региона 66 (24 имени) не знал 23 города, по которым уже идут развёртки +(Реж, Карпинск, Кушва, Лесной, Тавда…). `_resolve_target_city` возвращал None, +`_fetch_dkp_corridor` собирал сделки по одной улице во всей области — «Ленина» там +в основном екатеринбургская, и коридор Режа получался ценой Екатеринбурга. + +Замер прода 2026-09-17 (tradein-postgres, deals source='rosreestr', region 66): по +всем 23 городам сделки есть под теми же е-формами (Реж 745, из них 189 за 12 мес.; +Карпинск 521/161; Лесной 1215/402), а среди сделок Екатеринбурга ни один адрес не +содержит этих имён отдельным словом — ложного городского фильтра на ЕКБ-адресах нет. +""" + +from __future__ import annotations + +import os +from datetime import date +from typing import Any +from unittest.mock import MagicMock + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from scraper_kit.orchestration.pipeline import CITY_DISPLAY_NAMES + +from app.services.estimator import _fetch_dkp_corridor, _resolve_target_city + +_REGION_66_SWEEP_CITIES = sorted( + name for slug, name in CITY_DISPLAY_NAMES.items() if slug != "moskva" +) + + +@pytest.mark.parametrize( + ("address", "city"), + [ + ("Реж, Ленина, 12", "реж"), + ("Карпинск, Ленина, 5", "карпинск"), + ("Россия, Свердловская область, Артёмовский, улица Ленина, 5", "артемовский"), + ("Свердловская обл., Нижнетуринский г.о., Нижняя Тура, ул. Ленина, 3", "нижняя тура"), + ], +) +def test_sweep_city_from_the_audit_is_resolved(address: str, city: str) -> None: + assert _resolve_target_city(address) == city + + +@pytest.mark.parametrize("name", _REGION_66_SWEEP_CITIES) +def test_every_region_66_sweep_city_is_resolved(name: str) -> None: + """Новый город развёртки не должен снова выпасть в «коридор по всей области».""" + assert _resolve_target_city(f"{name}, ул. Ленина, 12") == name.lower().replace("ё", "е") + + +def test_moscow_is_not_pulled_into_the_oblast_resolver() -> None: + """Москва — город развёртки, но другого региона: для неё прежний None.""" + assert _resolve_target_city("Москва, ул. Тверская, 1") is None + + +def test_ekb_district_named_like_a_city_still_not_a_city() -> None: + """Прежний гард «мкр Заречный» не сломан расширением словаря.""" + assert _resolve_target_city("р-н Верх-Исетский, мкр. Заречный, улица Опалихинская, 20А") is None + + +# ── коридор: значение, а не параметр ───────────────────────────────────────── + +_EKB_PPM2 = 150_000 +_REZH_PPM2 = 55_000 + + +def _deals_db() -> Any: + """Двойник сессии, исполняющий городской фильтр так же, как SQL коридора. + + Улица «Ленина» есть и в Екатеринбурге (10 сделок), и в Реже (4 сделки). Без + `LOWER(d.city) = :target_city` запрос отдаёт обе, с ним — только свой город. + """ + rows = [{"city": "екатеринбург", "ppm2": _EKB_PPM2}] * 10 + [ + {"city": "реж", "ppm2": _REZH_PPM2} + ] * 4 + + def _execute(query: Any, params: dict[str, Any] | None = None) -> MagicMock: + result = MagicMock() + sql = str(query) + picked: list[dict[str, Any]] = [] + if "FROM deals d" in sql: + city = (params or {}).get("target_city") + filtered = "LOWER(d.city) = CAST(:target_city AS text)" in sql + picked = [ + {"price_per_m2": r["ppm2"], "deal_date": date(2026, 1, 1)} + for r in rows + if not filtered or r["city"] == city + ] + result.mappings.return_value.all.return_value = picked + return result + + db = MagicMock() + db.execute.side_effect = _execute + return db + + +def test_rezh_corridor_is_built_from_rezh_deals_not_the_whole_oblast() -> None: + """«Реж, Ленина, 12»: медиана коридора — цена Режа, а не Екатеринбурга. + + На main город не распознан → фильтра нет → 10 екатеринбургских сделок из 14 + и медиана 150 000 ₽/м². + """ + address = "Реж, Ленина, 12" + raw = _fetch_dkp_corridor( + _deals_db(), + address=address, + rooms=2, + area=45.0, + city=_resolve_target_city(address), + ) + assert raw is not None + assert raw["count"] == 4 + assert raw["median_ppm2"] == _REZH_PPM2 diff --git a/tradein-mvp/backend/tests/test_gar_flats_loader.py b/tradein-mvp/backend/tests/test_gar_flats_loader.py index 11f6793c..a9d47744 100644 --- a/tradein-mvp/backend/tests/test_gar_flats_loader.py +++ b/tradein-mvp/backend/tests/test_gar_flats_loader.py @@ -236,7 +236,8 @@ def test_match_distinct_on_tiebreak_max_flat_count() -> None: # Канонический ключ (мигр. 144), а не точное равенство norm_address. assert "tradein_canon_addr(norm_address) AS canon" in flat # Tie-break на canon (PARTITION BY): строка с макс flat_count, затем меньший house_guid. - assert "PARTITION BY canon ORDER BY flat_count DESC, house_guid" in flat + # #2583 H5: ранжирование внутри региона — канон «ленина5» 66-го и 50-го не соревнуются. + assert "PARTITION BY region_code, canon ORDER BY flat_count DESC, house_guid" in flat # houses-сторона: тот же канон поверх COALESCE(short/full/address). houses_side = ( "tradein_canon_addr( COALESCE(h.short_address, h.full_address, h.address) ) = gp.canon" @@ -514,6 +515,7 @@ def test_upsert_and_canon_match_populates_gar_flat_count(gar_dir: str) -> None: text( "CREATE TEMP TABLE houses (" " id serial PRIMARY KEY, short_address text, full_address text, address text," + " region_code smallint," " gar_house_guid text, gar_flat_count int, gar_matched_at timestamptz," " gar_match_method text" ") ON COMMIT DROP" @@ -524,8 +526,8 @@ def test_upsert_and_canon_match_populates_gar_flat_count(gar_dir: str) -> None: # (flat=0) не матч. «ул. Мира,5» — для теста ЕКБ-фильтра (см. ниже). db.execute( text( - "INSERT INTO houses (short_address) VALUES " - "('ул. Шаумяна,10'), ('ул Ленина, 7'), ('ул. Мира,5')" + "INSERT INTO houses (short_address, region_code) VALUES " + "('ул. Шаумяна,10', 66), ('ул Ленина, 7', 66), ('ул. Мира,5', 66)" ) ) @@ -615,6 +617,7 @@ def test_no_city_filter_ambiguous_canon_not_matched() -> None: text( "CREATE TEMP TABLE houses (" " id serial PRIMARY KEY, short_address text, full_address text, address text," + " region_code smallint," " gar_house_guid text, gar_flat_count int, gar_matched_at timestamptz," " gar_match_method text" ") ON COMMIT DROP" @@ -635,7 +638,10 @@ def test_no_city_filter_ambiguous_canon_not_matched() -> None: ) ) db.execute( - text("INSERT INTO houses (short_address) VALUES ('ул. Ленина,5'), ('ул. Мира,1')") + text( + "INSERT INTO houses (short_address, region_code) VALUES " + "('ул. Ленина,5', 50), ('ул. Мира,1', 50)" + ) ) matched = gfl.match_houses_to_gar(db, region_code="50", city_filter=None) @@ -654,3 +660,90 @@ def test_no_city_filter_ambiguous_canon_not_matched() -> None: finally: db.rollback() db.close() + + +@pytest.mark.skipif(_live_session() is None, reason="нет доступной Postgres test-БД") +def test_house_gets_gar_guid_only_from_its_own_region() -> None: + """#2583 H5: UPDATE houses ограничен регионом дома, чужие guid снимаются. + + Прод 2026-09-17, после матча регионов 77/50 (#3523): 578 домов региона 66 несли guid + региона 50 и 340 — региона 77. Канон не несёт ни город, ни регион, поэтому + екатеринбургская «Ленина, 5» и подмосковная «Ленина, 5» — один ключ. + """ + from sqlalchemy import text + + db = _live_session() + assert db is not None + try: + raw = db.connection() + raw.exec_driver_sql(_NORMALIZER_FN) + raw.exec_driver_sql(_CANON_FN) + db.execute( + text( + "CREATE TEMP TABLE gar_house_flats (" + " house_guid text PRIMARY KEY, object_id bigint, region_code text NOT NULL," + " flat_count int, full_address text, norm_address text, street_name text," + " house_num text, loaded_at timestamptz NOT NULL DEFAULT now(), gar_version text" + ") ON COMMIT DROP" + ) + ) + db.execute( + text( + "CREATE TEMP TABLE houses (" + " id serial PRIMARY KEY, short_address text, full_address text, address text," + " region_code smallint," + " gar_house_guid text, gar_flat_count int, gar_matched_at timestamptz," + " gar_match_method text" + ") ON COMMIT DROP" + ) + ) + db.execute( + text( + "INSERT INTO gar_house_flats " + "(house_guid, region_code, flat_count, full_address, norm_address) VALUES " + "('g66-lenina', '66', 60, 'обл Свердловская, г Екатеринбург, ул Ленина, 5', " + "'ул Ленина, 5'), " + "('g50-lenina', '50', 400, 'обл Московская, г Химки, ул Ленина, 5', " + "'ул Ленина, 5'), " + "('g50-mira', '50', 250, 'обл Московская, г Химки, ул Мира, 7', 'ул Мира, 7')" + ) + ) + # ЕКБ «Ленина, 5» и «Мира, 7» уже несут подмосковные guid (как на проде после + # прогона региона 50); подмосковная «Ленина, 5» и дом без региона — не матчены. + db.execute( + text( + "INSERT INTO houses (short_address, region_code, gar_house_guid, gar_flat_count, " + "gar_match_method) VALUES " + "('ул. Ленина,5', 66, 'g50-lenina', 400, 'canon_addr'), " + "('ул. Мира,7', 66, 'g50-mira', 250, 'canon_addr'), " + "('ул. Ленина,5', 50, NULL, NULL, NULL), " + "('ул. Мира,7', NULL, 'g50-mira', 250, 'canon_addr')" + ) + ) + + def _state() -> set[tuple[Any, ...]]: + rows = db.execute( + text( + "SELECT short_address, region_code, gar_house_guid, gar_flat_count FROM houses" + ) + ).all() + return {tuple(r) for r in rows} + + assert gfl.match_houses_to_gar(db, region_code="66") == 1 + assert _state() == { + ("ул. Ленина,5", 66, "g66-lenina", 60), # свой ЕКБ-дом вместо подмосковного + ("ул. Мира,7", 66, None, None), # пары в 66 нет — чужой guid снят, не оставлен + ("ул. Ленина,5", 50, None, None), # прогон 66 не трогает дома 50 + ("ул. Мира,7", None, "g50-mira", 250), # регион дома неизвестен — не доказуемо + } + + assert gfl.match_houses_to_gar(db, region_code="50", city_filter=None) == 1 + assert _state() == { + ("ул. Ленина,5", 66, "g66-lenina", 60), # прогон 50 не перетирает ЕКБ + ("ул. Мира,7", 66, None, None), + ("ул. Ленина,5", 50, "g50-lenina", 400), + ("ул. Мира,7", None, "g50-mira", 250), + } + finally: + db.rollback() + db.close() diff --git a/tradein-mvp/backend/tests/test_public_mera_estimate.py b/tradein-mvp/backend/tests/test_public_mera_estimate.py index c2915bc1..b2188008 100644 --- a/tradein-mvp/backend/tests/test_public_mera_estimate.py +++ b/tradein-mvp/backend/tests/test_public_mera_estimate.py @@ -261,6 +261,7 @@ _PAID_FIELDS = { "actual_deals", "expected_sold_price_rub", "est_days_on_market", + "exposure_window", "price_trend", } diff --git a/tradein-mvp/browser/server.py b/tradein-mvp/browser/server.py index 4cbc3a8c..0f52a9ff 100644 --- a/tradein-mvp/browser/server.py +++ b/tradein-mvp/browser/server.py @@ -34,6 +34,9 @@ Per-provider модель (#1793): Переменные окружения: BROWSER_PORT — TCP-порт HTTP-сервера (default: 3000) + BROWSER_MAX_INSTANCES — потолок одновременно живых camoufox на весь сайдкар + (default: 3, #3412). Перед запуском нового инстанса + закрывается простаивающий чужой; занятый — никогда. BROWSER_RECYCLE_PAGES — страниц в одном сеансе браузера до перезапуска, глобальный дефолт для провайдеров без код-дефолта (default: 15). per-provider код-дефолт см. BROWSER_RECYCLE_PAGES_{PROVIDER}. @@ -141,6 +144,7 @@ Per-provider модель (#1793): import asyncio import base64 import gzip +import itertools import logging import os import random @@ -164,6 +168,14 @@ ENVIRONMENT: str = os.environ.get("ENVIRONMENT", "dev") IS_PROD: bool = ENVIRONMENT == "production" BROWSER_PORT: int = int(os.environ.get("BROWSER_PORT", "3000")) +# #3412: до потолка инстанс поднимался лениво на поставщика и не закрывался никогда — +# пять поставщиков давали пять вечных camoufox в контейнере с mem_limit 2560m. +# Откуда 3: cadvisor working_set tradein-browser за 41 ч (15.09 16:55 → 17.09 09:53), +# сопоставленный с числом живых инстансов по логу запусков/закрытий: медиана 1311 МиБ +# при 2 живых, 1494 при 3, 1956 при 4, 2108 при 5; 5 живых простояли 2.4 ч прямо перед +# резким рестартом контейнера 16.09 19:08. Потолок не тормозит работу: занятый инстанс +# не закрывается, лишний запуск идёт сверх потолка (см. _trim_instances). +BROWSER_MAX_INSTANCES: int = max(1, int(os.environ.get("BROWSER_MAX_INSTANCES", "3"))) # Глобальной константы recycle больше нет (#3205): порог стал поставщик-зависимым и # живёт в _RECYCLE_PAGES_BY_PROVIDER. Env BROWSER_RECYCLE_PAGES по-прежнему читается — # внутри _resolve_recycle_pages, как один из уровней приоритета. Константу убрали @@ -849,6 +861,14 @@ _last_nav_ms: dict[str, int | None] = {} # смене прокси» — camoufox берёт proxy на launch, релонч дорогой, поэтому не релончим, # если запрошенный proxy совпадает с текущим. None = запущен без прокси (прямое). _launched_proxy: dict[str, str | None] = {} +# #3412: поставщики, чей camoufox поднимается прямо сейчас. Считаются живыми для +# потолка: запуск длится секунды, и два параллельных запуска иначе оба увидели бы +# свободное место. +_launching: set[str] = set() +# #3412: provider → номер последнего использования (LRU выбора жертвы). Счётчик, а не +# часы: два обращения в один тик часов неразличимы. +_last_used: dict[str, int] = {} +_use_seq = itertools.count(1) # Guard на ленивое создание per-provider локов: setdefault на обычном dict из # разных корутин гонок не даёт (нет await между read-modify-write), но держим @@ -927,12 +947,20 @@ async def _launch_browser(provider: str, proxy_override: str | None = None) -> N proxy is not None, _RECYCLE_PAGES_BY_PROVIDER.get(provider, _RECYCLE_PAGES_DEFAULT_FALLBACK), ) - cm = AsyncCamoufox(**kwargs) # type: ignore[arg-type] - browser = await cm.__aenter__() + # #3412: потолок живых — здесь, а не в _ensure_browser: сюда сходятся ВСЕ пути + # запуска (первый запрос, смена прокси, recycle/краш, фоновый retry). + await _trim_instances(provider) + _launching.add(provider) + try: + cm = AsyncCamoufox(**kwargs) # type: ignore[arg-type] + browser = await cm.__aenter__() + finally: + _launching.discard(provider) _browser_cms[provider] = cm _browsers[provider] = browser _page_counters[provider] = 0 _launched_proxy[provider] = proxy_url + _last_used[provider] = next(_use_seq) # свежий инстанс — не первая жертва LRU logger.info( "tradein-browser[%s]: браузер запущен (proxy_override=%s)", provider, @@ -1050,9 +1078,22 @@ async def _close_reusable_context(provider: str) -> None: async def _close_browser(provider: str) -> None: - """Закрывает per-provider браузер + переиспользуемый context (если есть, #3118).""" - await _close_reusable_context(provider) - cm = _browser_cms.get(provider) + """Закрывает per-provider браузер + переиспользуемый context (если есть, #3118). + + Инстанс снимается со словарей ДО первого await (#3412): закрыть его может и чужой + запуск по потолку (_trim_instances), не держащий _locks[provider]. Запрос этого + поставщика, вошедший под лок во время close(), должен увидеть «инстанса нет» и + поднять свой, а не получить полузакрытый браузер. + + _last_goto_at НЕ трогает: пейсинг — ритм обращений к площадке, он переживает + инстанс. Сбрасывают его те, кто перезапускает инстанс сам (_relaunch_browser, + смена прокси в _ensure_browser), — ровно как было до #3412. + """ + cm = _browser_cms.pop(provider, None) + _browsers.pop(provider, None) + _page_counters[provider] = 0 + _launched_proxy.pop(provider, None) + await _close_reusable_context(provider) # context/якорь снимает до своего await if cm is not None: try: await cm.__aexit__(None, None, None) # type: ignore[attr-defined] @@ -1063,11 +1104,56 @@ async def _close_browser(provider: str) -> None: provider, type(exc).__name__, ) - _browsers.pop(provider, None) - _browser_cms.pop(provider, None) - _page_counters[provider] = 0 - _last_goto_at.pop(provider, None) - _launched_proxy.pop(provider, None) + + +def _pick_victim(provider: str) -> str | None: + """Чей простаивающий инстанс закрыть под новый инстанс ``provider`` (#3412). + + Кандидаты — живые инстансы ЧУЖИХ поставщиков со свободным _locks: вся работа + поставщика идёт под его локом, занятый лок = страница в работе. Первыми идут + инстансы без context'а и якорной вкладки: их перезапуск стоит только процесса. + С context'ом (avito/domclick) теряются прогретые куки площадки и вкладка выдачи — + 4-5 с на карточку против 17-52 с без неё (замер 29.08, см. _anchor_pages). Внутри + класса — давнее всех использованный. None — закрыть некого. + """ + idle = [ + p + for p in _browsers + if p != provider and not (p in _locks and _locks[p].locked()) + ] + if not idle: + return None + return min(idle, key=lambda p: (p in _contexts or p in _anchor_pages, _last_used.get(p, 0))) + + +async def _trim_instances(provider: str) -> None: + """Освобождает место под новый инстанс ``provider``, пока живых >= потолка (#3412). + + ponytail: потолок мягкий — если все прочие инстансы заняты, поднимаемся сверх него + с предупреждением, а не отказываем запросу и не рвём чужую навигацию. Очередь на + слот — если предупреждение станет регулярным. Цена мягкости и места проверки: запуск, + который потом упадёт (InvalidIP), успевает закрыть один простаивающий инстанс. + """ + while len(_browsers.keys() | _launching) >= BROWSER_MAX_INSTANCES: + victim = _pick_victim(provider) + if victim is None: + logger.warning( + "tradein-browser[%s]: живых инстансов %d при потолке %d, все заняты — " + "запуск сверх потолка", + provider, + len(_browsers.keys() | _launching), + BROWSER_MAX_INSTANCES, + ) + return + logger.info( + "tradein-browser[%s]: живых инстансов %d при потолке %d — закрываем " + "простаивающий %s", + provider, + len(_browsers.keys() | _launching), + BROWSER_MAX_INSTANCES, + victim, + ) + await _close_browser(victim) async def _relaunch_browser(provider: str, proxy_override: str | None = None) -> None: @@ -1083,6 +1169,7 @@ async def _relaunch_browser(provider: str, proxy_override: str | None = None) -> effective = proxy_override if proxy_override is not None else _launched_proxy.get(provider) logger.info("tradein-browser[%s]: перезапуск браузера", provider) await _close_browser(provider) + _last_goto_at.pop(provider, None) await _launch_browser(provider, proxy_override=effective) @@ -1137,6 +1224,7 @@ async def _ensure_browser(provider: str, proxy_override: str | None = None) -> b desired_proxy = proxy_override if proxy_override is not None else _provider_proxy(provider) if _browsers.get(provider) is not None: if _launched_proxy.get(provider) == desired_proxy: + _last_used[provider] = next(_use_seq) return True # Реальная смена прокси → пересобрать инстанс с новым прокси. logger.info( @@ -1145,6 +1233,7 @@ async def _ensure_browser(provider: str, proxy_override: str | None = None) -> b proxy_override is not None, ) await _close_browser(provider) + _last_goto_at.pop(provider, None) await _try_launch_browser(provider, proxy_override=proxy_override) if _browsers.get(provider) is not None: @@ -1211,6 +1300,7 @@ async def _on_startup(app: web.Application) -> None: logger.info("tradein-browser: page-intervals %s", _MIN_PAGE_INTERVAL_BY_PROVIDER) logger.info("tradein-browser: block-images %s", _BLOCK_IMAGES_BY_PROVIDER) logger.info("tradein-browser: recycle-pages %s", _RECYCLE_PAGES_BY_PROVIDER) + logger.info("tradein-browser: потолок живых инстансов %d", BROWSER_MAX_INSTANCES) _warn_retired_env() diff --git a/tradein-mvp/browser/test_server_instance_limit.py b/tradein-mvp/browser/test_server_instance_limit.py new file mode 100644 index 00000000..46527f03 --- /dev/null +++ b/tradein-mvp/browser/test_server_instance_limit.py @@ -0,0 +1,209 @@ +"""test_server_instance_limit.py — потолок живых camoufox и выбор жертвы (#3412). + +До #3412 инстанс поставщика поднимался лениво и не закрывался никогда: пять +поставщиков = пять вечных браузеров в контейнере с mem_limit 2560m. Каждый тест +ниже закрывает один дефект ревью прошлой попытки (ветка fix/3412-cian-instance- +relaunch) или сам потолок; проверка — по тому, какие инстансы ОСТАЛИСЬ живыми. + +camoufox не запускается: модуль camoufox.async_api подменён заглушкой. + +Запуск (из tradein-mvp/browser/):: + + python -m pytest test_server_instance_limit.py -q +""" + +from __future__ import annotations + +import asyncio +import importlib.util +import sys +import types +from pathlib import Path + +import pytest + +_SERVER_PATH = Path(__file__).resolve().parent / "server.py" +_spec = importlib.util.spec_from_file_location("tradein_browser_server", _SERVER_PATH) +assert _spec is not None and _spec.loader is not None +server = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(server) + + +class _FakeCM: + """AsyncCamoufox-заглушка: запуск по желанию медленный, закрытие пишет журнал.""" + + launch_delay = 0.0 + closed: list[str] = [] + # provider → был ли он ещё в server._browsers в момент СВОЕГО закрытия + visible_while_closing: dict[str, bool] = {} + + def __init__(self, **kwargs: object) -> None: + pass + + async def __aenter__(self) -> object: + await asyncio.sleep(_FakeCM.launch_delay) + return object() + + async def __aexit__(self, *args: object) -> None: + provider = next(p for p, cm in _cms_by_provider.items() if cm is self) + _FakeCM.visible_while_closing[provider] = provider in server._browsers + await asyncio.sleep(0) + _FakeCM.closed.append(provider) + + +_cms_by_provider: dict[str, _FakeCM] = {} + + +@pytest.fixture(autouse=True) +def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None: + for name in ("_browsers", "_browser_cms", "_contexts", "_anchor_pages", "_anchor_origins", + "_page_counters", "_locks", "_retry_tasks", "_last_goto_at", + "_launched_proxy", "_last_used"): + monkeypatch.setattr(server, name, {}) + monkeypatch.setattr(server, "_launching", set()) + monkeypatch.setattr(server, "_locks_guard", asyncio.Lock()) + monkeypatch.setattr(server, "BROWSER_MAX_INSTANCES", 2) + _FakeCM.launch_delay = 0.0 + _FakeCM.closed = [] + _FakeCM.visible_while_closing = {} + _cms_by_provider.clear() + + fake_mod = types.ModuleType("camoufox.async_api") + fake_mod.AsyncCamoufox = _FakeCM # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "camoufox", types.ModuleType("camoufox")) + monkeypatch.setitem(sys.modules, "camoufox.async_api", fake_mod) + + +async def _launch(provider: str) -> None: + await server._launch_browser(provider, proxy_override="http://u:p@pool:8080") + _cms_by_provider[provider] = server._browser_cms[provider] + + +def _live() -> set[str]: + return set(server._browsers) + + +def test_launch_at_cap_closes_idle_instance() -> None: + async def _run() -> None: + await _launch("cian") + await _launch("yandex") + await _launch("generic") + + asyncio.run(_run()) + + assert _live() == {"yandex", "generic"} + assert _FakeCM.closed == ["cian"] + + +def test_victim_is_cheap_instance_not_avito_with_anchor() -> None: + """Дефект ревью 1: LRU закрывал avito с якорной вкладкой, оставляя cian без context'а.""" + + async def _run() -> None: + await _launch("avito") # использован ДАВНЕЕ всех, но с прогретым context'ом + server._contexts["avito"] = types.SimpleNamespace(close=_noop) + server._anchor_pages["avito"] = object() + await _launch("cian") + await _launch("yandex") + + asyncio.run(_run()) + + assert _live() == {"avito", "yandex"} + assert _FakeCM.closed == ["cian"] + + +async def _noop() -> None: + return None + + +def test_eviction_keeps_victim_pacing() -> None: + """Дефект ревью 2: вытеснение сбрасывало _last_goto_at чужого поставщика.""" + + async def _run() -> None: + await _launch("cian") + server._last_goto_at["cian"] = 12345.0 + await _launch("yandex") + await _launch("generic") + + asyncio.run(_run()) + + assert "cian" not in _live() + assert server._last_goto_at.get("cian") == 12345.0 + + +def test_background_retry_path_respects_cap() -> None: + """Дефект ревью 3: retry-путь _try_launch_browser поднимал инстанс мимо потолка.""" + + async def _run() -> bool: + await _launch("cian") + await _launch("yandex") + return await server._try_launch_browser("avito") + + assert asyncio.run(_run()) is True + assert len(_live()) == 2 + assert "avito" in _live() + + +def test_fresh_instance_is_not_the_next_victim() -> None: + """Дефект ревью 4: свежий инстанс без отметки использования уходил первым.""" + + async def _run() -> None: + await _launch("cian") + assert await server._ensure_browser("cian", proxy_override="http://u:p@pool:8080") + await _launch("yandex") # свежий, после последнего обращения к cian + await _launch("generic") # должен закрыть давний cian, а не свежий yandex + + asyncio.run(_run()) + + assert _FakeCM.closed == ["cian"] + + +def test_use_refreshes_lru_order() -> None: + async def _run() -> None: + await _launch("cian") + await _launch("yandex") + assert await server._ensure_browser("cian", proxy_override="http://u:p@pool:8080") + await _launch("generic") + + asyncio.run(_run()) + + assert _FakeCM.closed == ["yandex"] + + +def test_busy_instance_is_never_closed() -> None: + async def _run() -> None: + await _launch("cian") + await _launch("yandex") + for provider in ("cian", "yandex"): + await (await server._lock_for(provider)).acquire() + await _launch("generic") + + asyncio.run(_run()) + + assert _FakeCM.closed == [] + assert _live() == {"cian", "yandex", "generic"} + + +def test_parallel_launches_reserve_their_slot() -> None: + """Два запуска разных поставщиков одновременно не должны оба увидеть свободное место.""" + + async def _run() -> None: + await _launch("cian") + _FakeCM.launch_delay = 0.01 + await asyncio.gather(_launch("avito"), _launch("yandex")) + + asyncio.run(_run()) + + assert _live() == {"avito", "yandex"} + + +def test_evicted_instance_disappears_before_close_awaits() -> None: + """Жертву закрывает чужая корутина без её лока: во время close() браузер уже снят.""" + + async def _run() -> None: + await _launch("cian") + await _launch("yandex") + await _launch("generic") + + asyncio.run(_run()) + + assert _FakeCM.visible_while_closing == {"cian": False} diff --git a/tradein-mvp/docker-compose.prod.yml b/tradein-mvp/docker-compose.prod.yml index 5aa3ef03..897b986c 100644 --- a/tradein-mvp/docker-compose.prod.yml +++ b/tradein-mvp/docker-compose.prod.yml @@ -82,6 +82,12 @@ services: # (грубо ~2.5g на каждую доп. параллельную страницу). mem_limit: 2560m memswap_limit: 3g + # init (#3412): tini как PID 1. Без него PID 1 — `python server.py`, который не + # собирает осиротевших детей: дочерние процессы закрытого camoufox (forkserver, + # Utility, Socket, RDD) переходят к PID 1 и висят зомби навсегда. Прод 17.09.2026 + # за 14 ч с рестарта: 2296 процессов в состоянии Z (по 574 каждого вида), + # pids.current 2722 при трёх живых браузерах. + init: true # stop_grace_period: browser/server.py — bare aiohttp web.run_app(), которое # само ловит SIGTERM (aiohttp.web.GracefulExit) и даёт себе внутренний # shutdown_timeout=60s (aiohttp default, здесь не переопределён) на закрытие diff --git a/tradein-mvp/docs/gar-flats-runbook.md b/tradein-mvp/docs/gar-flats-runbook.md index c7c97af4..06202b80 100644 --- a/tradein-mvp/docs/gar-flats-runbook.md +++ b/tradein-mvp/docs/gar-flats-runbook.md @@ -178,10 +178,14 @@ LIMIT 20; одного — canon НЕ матчится вовсе (пропущенный дом лучше неверно приписанного). С city-фильтром (регион 66 по умолчанию) это ограничение не действует — коллизия там уже закрыта сужением по городу, поведение byte-for-byte прежнее. +- **Регион дома (#2583 H5).** Дом получает guid только ГАР-строки своего региона + (`houses.region_code`), канон ранжируется внутри региона. Перед матчем снимаются уже + проставленные `canon_addr`-guid чужого региона (дом без `region_code` не трогаем) — лог + пишет «снято guid чужого региона=N». После выкатки прогнать `--match-only` для 66, 77 и 50. - **Tie-break.** Если несколько ГАР-строк дают один канон И (city-фильтр задан ИЛИ canon однозначен без фильтра), матчер берёт строку с **максимальным `flat_count`** (при равенстве — - лексикографически меньший `house_guid`), через `ROW_NUMBER() OVER (PARTITION BY canon ORDER - BY flat_count DESC, house_guid)`. + лексикографически меньший `house_guid`), через `ROW_NUMBER() OVER (PARTITION BY region_code, canon + ORDER BY flat_count DESC, house_guid)`. - **Дома с 0 квартир** грузятся (`flat_count=0`), но матчер их игнорирует (`flat_count > 0`). Под `tradein_canon_addr(norm_address) WHERE flat_count > 0` создан функциональный индекс `gar_house_flats_canon_idx` (мигр. 144) под JOIN матчера. diff --git a/tradein-mvp/frontend/src/app/mera-public/docs/page.tsx b/tradein-mvp/frontend/src/app/mera-public/docs/page.tsx index 5a3d0117..91b01680 100644 --- a/tradein-mvp/frontend/src/app/mera-public/docs/page.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/docs/page.tsx @@ -74,7 +74,7 @@ const TARIFFS: readonly { }, { name: "Полный отчёт", - note: "Цена, прогноз срока, диапазон, аналоги, факторы. Разовый платёж", + note: "Цена, диапазон, срок экспозиции похожих объявлений, аналоги, факторы. Разовый платёж", price: `${SERVICE_PRICE_RUB} ₽`, }, { diff --git a/tradein-mvp/frontend/src/app/ui-preview/estimate/fixture.ts b/tradein-mvp/frontend/src/app/ui-preview/estimate/fixture.ts index ec10ab75..47c1f655 100644 --- a/tradein-mvp/frontend/src/app/ui-preview/estimate/fixture.ts +++ b/tradein-mvp/frontend/src/app/ui-preview/estimate/fixture.ts @@ -139,6 +139,7 @@ export const FIXTURE_ESTIMATE: AggregatedEstimate = { sources_used: ["avito", "cian", "rosreestr"], data_freshness_minutes: 35, last_scraped_at: "2026-05-30T12:00:00Z", + exposure_window: { p25_days: 31, p50_days: 48, p75_days: 104, n: 57 }, est_days_on_market: 48, address_precision: "house", area_m2: 55.3, diff --git a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx index d03f2419..47abc2e2 100644 --- a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx @@ -246,7 +246,9 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals const houseType = estimate.house_type ?? input.house_type; const repairState = estimate.repair_state ?? input.repair_state; const hasBalcony = estimate.has_balcony ?? input.has_balcony; - const estDaysOnMarket = estimate.est_days_on_market; + // #2898: окно p25–p75, а не одно число. est_days_on_market не показываем: + // у старых оценок это возраст висящих объявлений, а не срок до снятия. + const exposure = estimate.exposure_window ?? null; function handleEnrichSubmit(e: React.FormEvent) { e.preventDefault(); @@ -403,9 +405,14 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals {estimate.n_analogs}