diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index ab3a4ee1..a12e1946 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -22,6 +22,7 @@ from app.schemas.parcel import ( AnalyzeResponse, BestLayoutsRequest, BestLayoutsResponse, + CompetitorsParkingResponse, CompetitorsRequest, CompetitorsResponse, ConnectionPointsResponse, @@ -58,7 +59,7 @@ from app.services.site_finder.cadastre_fetch import ( from app.services.site_finder.cadastre_fetch import ( fetch_status as _fetch_status, ) -from app.services.site_finder.competitors import get_competitors +from app.services.site_finder.competitors import get_competitors, get_competitors_parking from app.services.site_finder.custom_pois import ( get_overlaps_for_scoring as _get_custom_poi_overlaps, ) @@ -75,6 +76,7 @@ from app.services.site_finder.quarter_dump_lookup import ( get_quarter_dump_data, make_empty_result, ) +from app.services.site_finder.saturation import compute_district_saturation from app.services.site_finder.velocity import compute_velocity from app.services.site_finder.weight_profiles import ( _SYSTEM_POI_WEIGHTS as _POI_WEIGHTS, @@ -1599,7 +1601,23 @@ def analyze_parcel( WHERE latitude IS NOT NULL ORDER BY obj_id, snapshot_date DESC NULLS LAST ), + nearby_obj AS ( + -- Ближние конкуренты (3 км) — единственные obj_id, чьи цены реально + -- попадают в выдачу. Считаем геофильтр один раз, переиспользуем в + -- obj_pricing (pushdown scope) и в финальном SELECT. + SELECT * + FROM latest_obj o + WHERE ST_DWithin( + ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, + ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography, + 3000 + ) + ), obj_pricing AS ( + -- Pushdown: агрегируем objective_lots ТОЛЬКО для ближних obj_id. + -- AVG/COUNT per domrf_obj_id независимы между группами, поэтому + -- ограничение scope даёт байт-идентичные значения для тех obj_id, + -- что реально джойнятся ниже (далёкие медианы раньше считались зря). SELECT cm.domrf_obj_id, ROUND(AVG(ol.price_per_m2_rub)::numeric, 0) AS avg_price_per_m2_rub, @@ -1611,6 +1629,7 @@ def analyze_parcel( ) AS lots_with_price FROM objective_complex_mapping cm JOIN objective_lots ol ON ol.project_name = cm.objective_complex_name + WHERE cm.domrf_obj_id IN (SELECT obj_id FROM nearby_obj) GROUP BY cm.domrf_obj_id ) SELECT o.obj_id, @@ -1632,13 +1651,8 @@ def analyze_parcel( ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography ) AS distance_m - FROM latest_obj o + FROM nearby_obj o LEFT JOIN obj_pricing p ON p.domrf_obj_id = o.obj_id - WHERE ST_DWithin( - ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, - ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography, - 3000 - ) ORDER BY CASE o.site_status WHEN 'Строящиеся' THEN 0 ELSE 1 END, distance_m ASC LIMIT 20 @@ -2700,11 +2714,31 @@ def analyze_parcel( "top_sellers": _top_sellers_list, } + # #42: POI saturation per capita района (обеспеченность школа/детсад/поликлиника + # на 1000 чел. целевой когорты vs норматив СП 42.13330). Best-effort: считается + # только если у участка резолвится район; ошибка/нет демографии → None (не роняет + # analyze). SAVEPOINT изолирует от outer-сессии (как success_recommendation выше). + saturation_block: dict[str, Any] | None = None + if district_row and district_row["district_name"]: + try: + with db.begin_nested(): + saturation_block = compute_district_saturation(db, district_row["district_name"]) + except Exception as e: + logger.warning( + "saturation block failed for %s (%s): %s", + cad_num, + district_row["district_name"], + e, + ) + result_payload: dict[str, Any] = { "cad_num": cad_num, "source": source, "geom_geojson": json.loads(geom_geojson) if geom_geojson else None, "district": dict(district_row) if district_row else None, + # #42: per-capita обеспеченность соцобъектами района (saturation) + влияние + # на POI-score (score_multiplier). None если район не резолвится / нет демографии. + "infra": {"saturation": saturation_block}, "score": round(score_final, 2), "score_without_center": round(score, 2), "score_label": _score_label(score_final), @@ -3161,6 +3195,36 @@ async def get_parcel_competitors( ) from exc +@router.post("/{cad_num}/competitors-parking", response_model=CompetitorsParkingResponse) +def get_parcel_competitors_parking( + cad_num: str, + body: CompetitorsRequest, + db: Annotated[Session, Depends(get_db)], +) -> CompetitorsParkingResponse: + """parking_ratio top-N конкурентов (#96 wiring) — ЛЕНИВЫЙ путь, вне analyze. + + Резолвит top-3 конкурента (relevance_weight DESC) domrf→cad_buildings через + PostGIS geom-match, затем on-demand НСПД premises-lookup → parking_ratio. + Намеренно ОТДЕЛЬНЫЙ от analyze/forecast: N×НСПД (~0.6-2с каждый) добавил бы + секунды в хот-путь и убил p95 — фронт дёргает этот эндпоинт лениво после + отрисовки списка конкурентов. + + sync `def` (не async): premises_lookup изолирует НСПД-async через + asyncio.run() — его нельзя вызвать из async-handler (running loop). FastAPI + исполняет sync-handler в threadpool, не блокируя event loop. + """ + try: + return get_competitors_parking(db=db, cad_num=cad_num, request=body) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except Exception as exc: + logger.error("competitors-parking endpoint failed for %s: %s", cad_num, exc) + raise HTTPException( + status_code=500, + detail="Ошибка расчёта паркинга конкурентов", + ) from exc + + @router.post("/{cad_num}/best-layouts", response_model=BestLayoutsResponse) async def get_parcel_best_layouts( cad_num: str, diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py index 25b3b7f0..a0394991 100644 --- a/backend/app/schemas/parcel.py +++ b/backend/app/schemas/parcel.py @@ -198,6 +198,11 @@ class Competitor(BaseModel): sold_pct: float | None velocity_per_month: float avg_price_per_m2: float | None + # OBJ-3 (#307): источник avg_price_per_m2 для прозрачности инвест-рекомендаций. + # "domrf" — per-flat цена domrf_kn_flats (приоритет); "objective" — медиана + # objective_lots.price_per_m2_rub через objective_complex_mapping (additive + # fallback, заполняет пробелы где domrf пуст); None — цены нет ни там, ни там. + price_source: str | None = None is_active: bool # #949 PR B (ТЗ §9.1, §16): детерминированный relevance_weight ∈ [0,1] — # взвешенная комбинация geo_proximity / class_similarity / price_similarity / @@ -220,6 +225,43 @@ class CompetitorsResponse(BaseModel): summary: CompetitorsSummary +# ── #96 wiring: parking_ratio конкурентов (lazy-endpoint, НЕ inline в analyze) ── +# domrf-конкуренты живут в lat/lon без cad_num → geom-match на cad_buildings → +# on-demand НСПД premises-lookup. Дорого (N×НСПД ~0.6-2с) → отдельный ленивый +# эндпоинт /{cad}/competitors-parking, строгий top-N, фронт дёргает после +# отрисовки конкурентов. НЕ блокирует analyze/forecast p95. + + +class CompetitorParking(BaseModel): + """parking_ratio одного конкурента (#96). obj_id связывает с Competitor. + + matched_cad_num — здание cad_buildings, на которое сматчился domrf-центроид + (None если geom-match не нашёл здание в радиусе). parking_ratio / + parking_count / flats_count — из НСПД tab-group (None если здание не + сматчилось ИЛИ НСПД недоступен — graceful, НЕ нулевой паркинг). + match_distance_m — расстояние domrf-центроид → footprint (диагностика). + """ + + obj_id: int + matched_cad_num: str | None = None + match_distance_m: float | None = None + parking_ratio: float | None = None + parking_count: int | None = None + flats_count: int | None = None + + +class CompetitorsParkingResponse(BaseModel): + """Ответ ленивого /{cad}/competitors-parking (#96). + + items — по одному CompetitorParking на top-N конкурентов (по relevance_weight + DESC из get_competitors). matched_count — сколько реально получили + parking_ratio (диагностика покрытия для фронта / мониторинга). + """ + + items: list[CompetitorParking] + matched_count: int + + # ── Developer attribution (#1088 «GG-форсайт») ───────────────────────────────── # Детерминированная атрибуция «пятно земли ↔ застройщик»: канон-застройщик участка # + его track-record (РНС/РВЭ + домрф ЖК/квартиры) из fn_developer_for_parcel / diff --git a/backend/app/services/site_finder/competitors.py b/backend/app/services/site_finder/competitors.py index c735d2d1..0235565c 100644 --- a/backend/app/services/site_finder/competitors.py +++ b/backend/app/services/site_finder/competitors.py @@ -24,14 +24,27 @@ from sqlalchemy.orm import Session from app.schemas.parcel import ( Competitor, + CompetitorParking, + CompetitorsParkingResponse, CompetitorsRequest, CompetitorsResponse, CompetitorsSummary, ) from app.services.forecast_request_cache import cached +from app.services.site_finder.premises_lookup import ( + get_building_premises_for_match, + resolve_cad_for_domrf, +) logger = logging.getLogger(__name__) +# #96 wiring: сколько top-конкурентов (по relevance_weight DESC) обогащаем +# parking_ratio в ленивом эндпоинте. Строгий top-N: каждый = geom-match SQL +# (~19ms) + НСПД premises (~0.6-2с). 3 — баланс «достаточно для сравнения дефицита +# паркинга» vs суммарная latency (~2-6с на ленивый вызов, ВНЕ analyze/forecast +# хот-пути). Расширять с осторожностью — линейно множит НСПД-запросы. +_PARKING_TOP_N: int = 3 + def _competitors_key(db: Session, cad_num: str, request: CompetitorsRequest) -> tuple[Any, ...]: """Ключ кэша §22-форсайта для get_competitors (#1129) — что РЕАЛЬНО влияет на вывод. @@ -50,6 +63,7 @@ def _competitors_key(db: Session, cad_num: str, request: CompetitorsRequest) -> tuple(sorted(request.exclude_obj_ids)), ) + # Маппинг time_window → число месяцев (float для деления velocity) _TIME_WINDOW_MONTHS: dict[str, float] = { "last_month": 1.0, @@ -456,6 +470,37 @@ _AVG_PRICE_SQL = text(""" GROUP BY f.obj_id """) +# OBJ-3 (#307): additive fallback цены м² из objective_lots, когда domrf-цены нет. +# Замер прода 2026-06: на ПОСЛЕДНЕМ snapshot domrf_kn_flats.price_per_m2 = 0 строк +# (скрапер перестал отдавать per-flat цены), поэтому _AVG_PRICE_SQL выше даёт +# avg_price_per_m2=None почти всем конкурентам. objective_lots.price_per_m2_rub +# покрывает ~80% (919795/1123771 строк) и мостится к domrf-obj_id через +# objective_complex_mapping (то же звено, что _INLINE_VELOCITY_SQL в best_layouts). +# +# Мост: domrf_kn_objects.obj_id == objective_complex_mapping.domrf_obj_id +# → objective_complex_mapping.objective_complex_name == objective_lots.project_name. +# Маппинг 1:1 (прод: 263 строки, 263 distinct obj_id, 0 obj_id с >1 именем), поэтому +# на obj_id приходится РОВНО один project_name; корпуса проекта агрегируются в один +# проект естественно. Берём МЕДИАНУ price_per_m2_rub по всем лотам проекта +# (percentile_cont 0.5) — устойчива к хвостовым выбросам пентхаусов/студий, в отличие +# от AVG. Прод-санити: медиана медиан 136k руб/м² (диапазон 80k–250k для ЕКБ ✓). +# +# Additive-контракт: возвращаем ТОЛЬКО obj_id, у которых objective-цена есть; в Python +# заполняем пробелы (domrf-цена приоритетна, objective — fallback). Существующие +# непустые domrf-выводы НЕ меняются. price_source делает источник прозрачным. +_OBJECTIVE_PRICE_FALLBACK_SQL = text(""" + SELECT + cm.domrf_obj_id AS obj_id, + percentile_cont(0.5) WITHIN GROUP (ORDER BY ol.price_per_m2_rub) + AS median_price_per_m2 + FROM objective_complex_mapping cm + JOIN objective_lots ol + ON ol.project_name = cm.objective_complex_name + WHERE cm.domrf_obj_id = ANY(:obj_ids) + AND ol.price_per_m2_rub IS NOT NULL + GROUP BY cm.domrf_obj_id +""") + def _quarter_from_cad(cad_num: str) -> str: """Извлечь кадастровый квартал из номера участка/здания. @@ -579,6 +624,7 @@ def get_competitors( # Читаем через _row_get: существующие тесты мокируют только avg_price_per_m2, # отсутствующий ключ flats_sold → None (не KeyError) → нейтральный stage. avg_price_map: dict[int, float] = {} + price_source_map: dict[int, str] = {} sold_count_map: dict[int, int] = {} try: price_rows = db.execute(_AVG_PRICE_SQL, {"obj_ids": obj_ids}).mappings().all() @@ -587,12 +633,36 @@ def get_competitors( price = _row_get(r, "avg_price_per_m2") if price is not None: avg_price_map[oid] = float(price) + price_source_map[oid] = "domrf" sold = _row_get(r, "flats_sold") if sold is not None: sold_count_map[oid] = int(sold) except Exception: logger.warning("competitors: avg_price query failed, continuing without prices") + # ── 4a. OBJ-3 (#307) additive fallback: где domrf-цены НЕТ → objective_lots ── + # Заполняем ТОЛЬКО пробелы (obj_id без domrf-цены). Существующие domrf-выводы + # не трогаем (precedence: domrf > objective). Прод 2026-06: domrf на последнем + # snapshot = 0 цен → почти все obj_id попадают в этот fallback (~80% покрытие). + missing_price_ids = [oid for oid in obj_ids if oid not in avg_price_map] + if missing_price_ids: + try: + obj_price_rows = ( + db.execute(_OBJECTIVE_PRICE_FALLBACK_SQL, {"obj_ids": missing_price_ids}) + .mappings() + .all() + ) + for r in obj_price_rows: + oid = int(r["obj_id"]) + price = _row_get(r, "median_price_per_m2") + if price is not None and oid not in avg_price_map: + avg_price_map[oid] = float(price) + price_source_map[oid] = "objective" + except Exception: + logger.warning( + "competitors: objective price fallback failed, continuing with domrf only" + ) + # ── 4b. Локальные эталоны для relevance (медиана цены, доминирующий класс) ─ radius_m = request.radius_km * 1000.0 local_median_ppm2 = _median(list(avg_price_map.values())) @@ -656,6 +726,7 @@ def get_competitors( sold_pct=sold_pct, velocity_per_month=velocity, avg_price_per_m2=avg_price_map.get(obj_id), + price_source=price_source_map.get(obj_id), is_active=is_active, relevance_weight=round(relevance_weight, 3), relevance_breakdown=relevance_breakdown, @@ -692,3 +763,80 @@ def get_competitors( ) return CompetitorsResponse(competitors=competitors, summary=summary) + + +def get_competitors_parking( + db: Session, + cad_num: str, + request: CompetitorsRequest, + *, + top_n: int = _PARKING_TOP_N, +) -> CompetitorsParkingResponse: + """parking_ratio top-N конкурентов через geom-match + НСПД premises (#96). + + Ленивый путь (НЕ inline в analyze/forecast — см. parcel.py CompetitorParking + docstring про p95). Для каждого из top-N конкурентов (get_competitors уже + сортирует по relevance_weight DESC): + 1. resolve_cad_for_domrf(lat, lon) → ближайшее cad_buildings-здание (PostGIS + KNN ≤ радиуса). Промах → parking отсутствует, НЕ падаем. + 2. get_building_premises_for_match(match) → НСПД tab-group → parking_ratio. + (objdoc_id из cad_buildings экономит НСПД search-запрос.) + + Каждый конкурент изолирован try/except: сбой одного (геометрия/НСПД/WAF) не + рушит остальные и не валит эндпоинт (graceful, ТЗ §15). Конкуренты без + geom-match всё равно попадают в items с None-полями (стабильный контракт + + фронт видит «искали, не нашли»). + + Raises: + ValueError: если центроид участка не найден (из get_competitors — caller + возвращает 404). + """ + response = get_competitors(db=db, cad_num=cad_num, request=request) + top = response.competitors[:top_n] + + items: list[CompetitorParking] = [] + matched_count = 0 + for competitor in top: + item = CompetitorParking(obj_id=competitor.obj_id) + try: + match = resolve_cad_for_domrf(db, lat=competitor.lat, lon=competitor.lng) + except Exception: + # resolve_cad_for_domrf уже глотает SQL-сбои → None; этот guard на + # случай неожиданного исключения — один конкурент без паркинга, не 500. + logger.exception( + "competitors_parking: geom-match raised for obj_id=%s", competitor.obj_id + ) + match = None + + if match is not None: + item = item.model_copy( + update={ + "matched_cad_num": match.cad_num, + "match_distance_m": round(match.distance_m, 1), + } + ) + try: + listing = get_building_premises_for_match(match) + except Exception: + # НСПД-клиент уже graceful (NspdLiteError/NspdBulkError → None); + # неожиданное — логируем и продолжаем без parking для этого ЖК. + logger.exception( + "competitors_parking: premises lookup raised for obj_id=%s cad_num=%s", + competitor.obj_id, + match.cad_num, + ) + listing = None + if listing is not None: + item = item.model_copy( + update={ + "parking_ratio": listing.parking_ratio, + "parking_count": listing.parking_count, + "flats_count": listing.flats_count, + } + ) + if listing.parking_ratio is not None: + matched_count += 1 + + items.append(item) + + return CompetitorsParkingResponse(items=items, matched_count=matched_count) diff --git a/backend/app/services/site_finder/gate_verdict.py b/backend/app/services/site_finder/gate_verdict.py index 64c9f24d..b49d976a 100644 --- a/backend/app/services/site_finder/gate_verdict.py +++ b/backend/app/services/site_finder/gate_verdict.py @@ -9,6 +9,11 @@ from __future__ import annotations from typing import Literal, TypedDict +from app.services.site_finder.network_obremenenie import ( + classify_network_zone, + network_kind_label, +) + # ── Zone matching ───────────────────────────────────────────────────────────── # Residential zone codes — точные ПЗЗ Свердловска (Ж-1..Ж-5) @@ -155,9 +160,17 @@ def compute_gate_verdict( # cad_zouit fallback path: classify by type_zone keywords (#232). # subcategory = NULL в cad_zouit, поэтому subcategory-based logic не применяется. type_zone_lower = (overlap.get("type_zone") or overlap.get("layer") or "").lower() - # СЗЗ-маркер специфичнее широких blocker-подстрок ('газ'/'электр'), - # поэтому проверяем warning-keywords ПЕРВЫМИ (#1341). СЗЗ газораспределительной - # станции/электроподстанции — ограничение, а не hard-blocker. + # #1070: классификация «сетевого обременения» (охранная зона инж.сети). + # Флаг приходит из _get_cad_zouit_overlaps (is_network_zone/network_kind); + # если overlap из старого пути без флага — определяем здесь же. + net_kind = overlap.get("network_kind") + if net_kind is None and overlap.get("is_network_zone") is None: + net_kind = classify_network_zone(overlap.get("type_zone") or overlap.get("layer")) + # Порядок веток: СЗЗ-warning ПЕРВЫМ (#1341) — маркер 'сзз'/'санитарно-защитная' + # специфичнее широких blocker-подстрок ('газ'/'электр'), СЗЗ газораспределительной + # станции/электроподстанции = ограничение, а не hard-blocker. Затем сетевое + # обременение (#1070, выделенный код раньше широких подстрок), затем общие + # blocker-keywords, иначе — generic warning. if any(kw in type_zone_lower for kw in WARNING_TYPE_ZONE_KEYWORDS): warnings.append( Warning( @@ -167,6 +180,17 @@ def compute_gate_verdict( ), ) ) + elif net_kind is not None: + kind_label = overlap.get("network_kind_label") or network_kind_label(net_kind) + blockers.append( + Blocker( + code="ZOUIT_NETWORK_OBREMENENIE", + detail=( + f"Сетевое обременение ({kind_label}): " + f"{overlap.get('type_zone', '')} {overlap.get('name', '')}".strip() + ), + ) + ) elif any(kw in type_zone_lower for kw in BLOCKER_TYPE_ZONE_KEYWORDS): blockers.append( Blocker( diff --git a/backend/app/services/site_finder/network_obremenenie.py b/backend/app/services/site_finder/network_obremenenie.py new file mode 100644 index 00000000..0a6cd54b --- /dev/null +++ b/backend/app/services/site_finder/network_obremenenie.py @@ -0,0 +1,129 @@ +"""Классификация «сетевых обременений» из ЗОУИТ (#1070). + +Pure-function классификатор: по тексту ``type_zone`` ЗОУИТ (cad_zouit / НСПД-дамп) +определяет, является ли зона *охранной зоной инженерной сети* — и если да, к какому +виду сети относится (тепло/электро/газ/водоснабжение/связь/общая инженерная). + +Контекст (#1070 ООО «Брик»): теплосеть 66:41:0000000:90310 проходит через пятно +застройки → реальное обременение. Эпик #1067 (слой ИРД для analyze) НЕ содержал явного +среза «сетевые обременения». ЗОУИТ-данные cad_zouit уже текут в analyze +(``quarter_dump_lookup._get_cad_zouit_overlaps``, #232/#243) и классифицируются в +gate_verdict, но как ОБЩИЕ ЗОУИТ-overlaps без выделенного «сетевого» среза. + +Этот модуль выделяет сетевые охранные зоны в отдельную типизированную категорию, чтобы +analyze/фронт могли показать «сетевое обременение (теплосеть)» как самостоятельный флаг. + +VERIFY против PROD (cad_zouit, 2026-06-14): сетевые охранные зоны РЕАЛЬНО присутствуют — +~1930 строк из 3485: + • «Охранная зона инженерных коммуникаций» — 1281 + • «Охранная зона тепловых сетей» — 548 + • «Охранная зона объектов электроэнергетики …» — 69 + • «Охранная зона трубопроводов (газопроводов …)» — 23 + • «Охранная зона линий и сооружений связи» — 9 + +Зачем pure-function (без БД): тестируемо без фикстур, переиспользуемо и в +``_get_cad_zouit_overlaps`` (теггинг overlap-строк), и в ``gate_verdict`` (выделенный +warning-код), без дублирования keyword-таблиц. +""" + +from __future__ import annotations + +from typing import Literal + +# Виды сетевого обременения. 'general' — «инженерные коммуникации» без указания вида сети. +NetworkKind = Literal[ + "thermal", # теплоснабжение + "electricity", # электросети / объекты электроэнергетики + "gas", # газопроводы (входит в «трубопроводы …») + "water", # водоснабжение / водоотведение / канализация + "communication", # линии и сооружения связи + "pipeline", # трубопроводы (нефте-/нефтепродукто-/аммиако- — не газ, но сетевое) + "general", # «охранная зона инженерных коммуникаций» (вид сети не уточнён) +] + +# Человекочитаемые подписи (RU) для UI/детали обременения. +NETWORK_KIND_LABELS: dict[str, str] = { + "thermal": "теплоснабжение", + "electricity": "электроснабжение", + "gas": "газоснабжение", + "water": "водоснабжение/водоотведение", + "communication": "связь", + "pipeline": "трубопровод", + "general": "инженерные коммуникации", +} + +# Упорядоченные правила «подстрока type_zone → вид сети». Порядок ВАЖЕН: более +# специфичные виды проверяются раньше «general» (инженерные коммуникации). +# Все подстроки lowercase; матч по `substring in type_zone.lower()`. +# Сверено с distinct type_zone из cad_zouit PROD (2026-06-14). +_NETWORK_RULES: tuple[tuple[NetworkKind, tuple[str, ...]], ...] = ( + ("thermal", ("тепл",)), # «… тепловых сетей» + ("electricity", ("электроэнерг", "электросет", "лэп", "линий электропередач")), + ("gas", ("газопров", "газоснаб")), + ("water", ("водопров", "водоснаб", "водоотвед", "канализ")), + ("communication", ("линий и сооружений связи", "сооружений связи", "линий связи")), + # «Охранная зона трубопроводов (газопроводов, нефтепроводов …)» — если газ не + # сматчился выше, относим к pipeline (нефте-/нефтепродукто-/аммиако-проводы). + ("pipeline", ("трубопров", "нефтепров", "нефтепродуктопров", "аммиакопров")), + # Самое общее — «охранная зона инженерных коммуникаций» (вид сети не уточнён). + ("general", ("инженерных коммуникаций", "инженерной инфраструктур", "инженерных сет")), +) + +# Зона должна быть именно ОХРАННОЙ (обременение), а не, например, «зона публичного +# сервитута» без сетевой привязки. Требуем маркер охранной/защитной зоны ИЛИ явный +# сетевой вид в тексте — иначе «инженерные коммуникации» в произвольной зоне дало бы +# ложные срабатывания. На практике cad_zouit использует «Охранная зона …». +_PROTECTION_MARKERS: tuple[str, ...] = ("охранная зона", "охранн") + + +def classify_network_zone(type_zone: str | None) -> NetworkKind | None: + """Определить вид сетевого обременения по тексту ``type_zone`` ЗОУИТ. + + Args: + type_zone: текст типа зоны (cad_zouit.type_zone / НСПД-дамп). None/пусто → None. + + Returns: + ``NetworkKind`` если зона — охранная зона инженерной сети; иначе ``None`` + (зона не сетевая: СЗЗ, ЗРЗ, сервитут без сети, природные ЗОУИТ и т.п.). + """ + if not type_zone: + return None + tz = type_zone.lower() + is_protection = any(m in tz for m in _PROTECTION_MARKERS) + # ЗСО (зона санитарной охраны ИСТОЧНИКА водоснабжения) — это зона вокруг водозабора, + # НЕ охранная зона водопроводной СЕТИ → не сетевое обременение. Иначе water-needles + # («водоснаб») ложно отнесли бы ЗСО к сети и флипнули verdict на blocker. + is_sanitary_source = "санитарн" in tz + for kind, needles in _NETWORK_RULES: + if any(n in tz for n in needles): + # «general» (инженерные коммуникации) принимаем только при маркере охранной + # зоны — иначе слишком широкая ловля. Специфичные виды (тепло/газ/…) сами по + # себе достаточно сетевые, но в cad_zouit они тоже всегда «Охранная зона …». + if kind == "general" and not is_protection: + continue + # water-зона санитарной охраны источника — не сеть, пропускаем. + if kind == "water" and is_sanitary_source: + continue + return kind + return None + + +def network_kind_label(kind: NetworkKind | None) -> str | None: + """RU-подпись вида сети для UI/детали (None → None).""" + if kind is None: + return None + return NETWORK_KIND_LABELS.get(kind, kind) + + +def is_network_obremenenie(type_zone: str | None) -> bool: + """True если ЗОУИТ — сетевое обременение (любой вид сети).""" + return classify_network_zone(type_zone) is not None + + +__all__ = [ + "NETWORK_KIND_LABELS", + "NetworkKind", + "classify_network_zone", + "is_network_obremenenie", + "network_kind_label", +] diff --git a/backend/app/services/site_finder/premises_lookup.py b/backend/app/services/site_finder/premises_lookup.py index 20a8f15b..892a0be7 100644 --- a/backend/app/services/site_finder/premises_lookup.py +++ b/backend/app/services/site_finder/premises_lookup.py @@ -30,6 +30,10 @@ from __future__ import annotations import asyncio import logging +from dataclasses import dataclass + +from sqlalchemy import text +from sqlalchemy.orm import Session from app.schemas.nspd_bulk import ObjectsListing from app.scrapers.nspd_bulk_client import NSPDBulkClient, NspdBulkError @@ -41,6 +45,123 @@ logger = logging.getLogger(__name__) # docstring: 1 ЗУ / 2 квартал / 5 здание). Для cad_num здания сужает выдачу. _THEMATIC_BUILDING = 5 +# #96 wiring: дефолтный радиус geom-match domrf↔cad_buildings (метры). Подобран по +# прод-замеру (1543 domrf latest-snapshot vs 38016 cad_buildings): nearest-footprint +# медиана 56м, within-50м покрывает 47%, within-100м 65%. 50м — консервативный +# дефолт: domrf lat/lon = центроид ЖК-ПЛОЩАДКИ (несколько домов + дворы), поэтому +# смещён от ближайшего building-footprint; шире радиус ловит больше, но рискует +# приписать СОСЕДНЕЕ здание (precision важнее recall — parking_ratio чужого дома +# хуже отсутствия). ST_Contains (точка внутри footprint) всегда матчит независимо +# от радиуса. Caller может расширить через max_dist_m. +_DEFAULT_MATCH_RADIUS_M: float = 50.0 + + +@dataclass(frozen=True, slots=True) +class BuildingMatch: + """Результат geom-match domrf-объекта на здание ЕГРН (#96 wiring). + + cad_num — кадастровый номер сматченного здания (ключ к premises-lookup). + objdoc_id — НСПД objdoc этого здания ИЗ cad_buildings (может быть None для + части строк). Если он есть — get_building_premises может ПРОПУСТИТЬ НСПД + search_by_cad и сразу дёрнуть tab-group (1 НСПД-запрос вместо 2). + distance_m — расстояние от domrf-центроида до footprint (0.0 = точка внутри + площадного footprint). + """ + + cad_num: str + objdoc_id: int | None + distance_m: float + + +# Geom-match domrf-центроида (lat/lon) → ближайшее здание cad_buildings (#96). +# Один LATERAL KNN: GIST `cad_buildings_geom_gist` (`geom <-> point`) выбирает +# ОДНОГО ближайшего кандидата (~19ms прод, EXPLAIN ANALYZE), затем фильтруем по +# фактической дистанции в метрах (geography ST_Distance — KNN-оператор `<->` на +# geometry SRID 4326 сортирует в ГРАДУСАХ, что для малых расстояний монотонно +# дистанции, но сама величина в градусах — поэтому метры считаем отдельно). Точка +# внутри площадного footprint даёт ST_Distance=0 (ST_Contains-эквивалент, см. +# прод: 198 объектов внутри). psycopg v3: CAST(:x AS double precision) — НЕ ::type. +_RESOLVE_CAD_SQL = text(""" + SELECT + b.cad_num, + b.objdoc_id, + ST_Distance( + b.geom::geography, + ST_SetSRID( + ST_MakePoint( + CAST(:lon AS double precision), + CAST(:lat AS double precision) + ), + 4326 + )::geography + ) AS distance_m + FROM cad_buildings b + WHERE b.geom IS NOT NULL + ORDER BY b.geom <-> ST_SetSRID( + ST_MakePoint( + CAST(:lon AS double precision), + CAST(:lat AS double precision) + ), + 4326 + ) + LIMIT 1 +""") + + +def resolve_cad_for_domrf( + db: Session, + *, + lat: float, + lon: float, + max_dist_m: float = _DEFAULT_MATCH_RADIUS_M, +) -> BuildingMatch | None: + """Резолв domrf-объекта (lat/lon центроид) → здание cad_buildings (#96 wiring). + + Мост через PostGIS: domrf_kn_objects хранят только obj_id + lat/lon (без + cad_num), а premises_lookup нужен cad_num ЗДАНИЯ. Берём ближайшее здание по + GIST-ускоренному KNN (`geom <-> point`) и принимаем матч, если расстояние до + footprint ≤ max_dist_m. Точка внутри площадного footprint → distance 0. + + Returns: + BuildingMatch (cad_num + objdoc_id + distance_m) ближайшего здания в + радиусе, иначе None. None трактуется как «здание не сопоставлено» — + caller продолжает без parking_ratio (graceful, не выдумываем). + + Note: + objdoc_id из cad_buildings возвращается для short-circuit НСПД search в + get_building_premises_for_match — может быть None (тогда фоллбэк на + cad_num search). + """ + try: + row = ( + db.execute(_RESOLVE_CAD_SQL, {"lat": float(lat), "lon": float(lon)}) + .mappings() + .first() + ) + except Exception: + # SQL/PostGIS-сбой — не наш профиль данных. Логируем и отдаём None + # (один конкурент без parking_ratio лучше, чем 500 на весь lazy-endpoint). + logger.exception("premises_lookup: resolve_cad_for_domrf failed lat=%s lon=%s", lat, lon) + return None + + if row is None: + return None + + distance_m = float(row["distance_m"]) + if distance_m > max_dist_m: + logger.info( + "premises_lookup: nearest building %.1fm > %.1fm radius for lat=%s lon=%s — no match", + distance_m, + max_dist_m, + lat, + lon, + ) + return None + + raw_objdoc = row["objdoc_id"] + objdoc_id = int(raw_objdoc) if raw_objdoc is not None else None + return BuildingMatch(cad_num=row["cad_num"], objdoc_id=objdoc_id, distance_m=distance_m) + def _resolve_objdoc_id(cad_num: str, client: NSPDClient) -> int | None: """objdoc_id здания по cad_num через NSPD geoportal search. @@ -127,4 +248,38 @@ def get_building_premises(cad_num: str) -> ObjectsListing | None: return listing -__all__ = ["get_building_premises"] +def get_building_premises_for_match(match: BuildingMatch) -> ObjectsListing | None: + """Помещения/parking_ratio для уже-сматченного здания (#96 wiring). + + Тонкая обёртка над get_building_premises: если geom-match дал objdoc_id ИЗ + cad_buildings, ПРОПУСКАЕМ НСПД search_by_cad (1 НСПД-запрос вместо 2 — search + тратит rate-limit и иногда промахивается на не-канон cad_num). Иначе фоллбэк + на cad_num-резолв через get_building_premises. + + Graceful: НСПД недоступен / objdoc не отдал помещения → None (как у + get_building_premises). Изолирует async в asyncio.run() — вызывается из + sync-контекста (FastAPI threadpool handler / Celery task). + """ + if match.objdoc_id is not None: + listing = asyncio.run(_fetch_listing(match.objdoc_id)) + if listing is not None: + logger.info( + "premises_lookup: match cad_num=%s objdoc_id=%d (cad_buildings) " + "flats=%d parking=%d ratio=%s", + match.cad_num, + match.objdoc_id, + listing.flats_count, + listing.parking_count, + listing.parking_ratio, + ) + return listing + # objdoc неизвестен в cad_buildings — полный путь по cad_num (2 НСПД-запроса). + return get_building_premises(match.cad_num) + + +__all__ = [ + "BuildingMatch", + "get_building_premises", + "get_building_premises_for_match", + "resolve_cad_for_domrf", +] diff --git a/backend/app/services/site_finder/quarter_dump_lookup.py b/backend/app/services/site_finder/quarter_dump_lookup.py index a6ae41e0..c4e99875 100644 --- a/backend/app/services/site_finder/quarter_dump_lookup.py +++ b/backend/app/services/site_finder/quarter_dump_lookup.py @@ -23,6 +23,11 @@ from sqlalchemy import text from sqlalchemy.exc import DataError, OperationalError, ProgrammingError from sqlalchemy.orm import Session +from app.services.site_finder.network_obremenenie import ( + classify_network_zone, + network_kind_label, +) + logger = logging.getLogger(__name__) # Порог свежести дампа — 180 дней. Совпадает с beat-расписанием (раз в квартал @@ -405,6 +410,10 @@ def _get_cad_zouit_overlaps(db: Session, parcel_wkt: str) -> list[dict[str, Any] result: list[dict[str, Any]] = [] for r in rows: type_zone: str = r[0] or "" + # #1070: выделяем «сетевые обременения» — охранные зоны инж.сетей + # (тепло/электро/газ/вода/связь/трубопровод/общие инж.коммуникации). + # Pure-function классификация по type_zone; None если зона не сетевая. + net_kind = classify_network_zone(type_zone) result.append( { "group_key": "cad_zouit", @@ -419,6 +428,12 @@ def _get_cad_zouit_overlaps(db: Session, parcel_wkt: str) -> list[dict[str, Any] }, "source": "cad_zouit", "type_zone": type_zone, + # #1070 «сетевые обременения»: флаг + вид сети + RU-подпись. + # is_network_zone=True → охранная зона инж.сети (обременение); + # network_kind ∈ NetworkKind | None; network_kind_label — RU. + "is_network_zone": net_kind is not None, + "network_kind": net_kind, + "network_kind_label": network_kind_label(net_kind), # #255: geom для ZouitLayer на Leaflet (frontend JSON.parse → ). # geom — GEOGRAPHY, ST_AsGeoJSON требует geometry → CAST(... AS geometry). "geom_geojson": r[5], @@ -969,8 +984,8 @@ def get_connection_points(db: Session, cad_num: str, radius_m: int = 500) -> dic harvest_error: str | None = dump_row[2] is_stale = False if fetched_at is not None and getattr(fetched_at, "tzinfo", "missing") != "missing": - fetched_at_aware = fetched_at if fetched_at.tzinfo is not None else fetched_at.replace( - tzinfo=UTC + fetched_at_aware = ( + fetched_at if fetched_at.tzinfo is not None else fetched_at.replace(tzinfo=UTC) ) is_stale = (datetime.now(UTC) - fetched_at_aware) > timedelta(days=_DUMP_MAX_AGE_DAYS) if harvest_error is not None or is_stale: diff --git a/backend/app/services/site_finder/saturation.py b/backend/app/services/site_finder/saturation.py new file mode 100644 index 00000000..4e6d4a0a --- /dev/null +++ b/backend/app/services/site_finder/saturation.py @@ -0,0 +1,297 @@ +"""POI saturation per capita — обеспеченность района социнфраструктурой (#42). + +Идея (Issue #42 / Limit_POI_Factor_Tiny): «10 школ vs 1 школа на 5000 детей» в +straight-count POI-scoring дают одинаковый сигнал — это неверно. Насыщенность +*на душу целевой когорты* важнее абсолютного числа объектов. + +Что считаем для района (одного из 8 админ-районов ЕКБ): + • фактическое число объектов категории (школа / детсад / поликлиника), + привязанных к району spatial-join'ом `ST_Contains(district.geom, poi.geom)` + по таблице `osm_poi_ekb` (нормализованные категории); + • население целевой когорты = population × age_share (детсад → 0-7, + школа → 7-18, поликлиника → всё население); + • норматив СП 42.13330 (мест/учреждений на 1000 чел. когорты); + • provision_ratio = факт / норматив → severity (дефицит / норма / профицит). + +ВАЖНО про данные: + • population/area района — ФАКТ (Росстат 01.01.2025, миграция 154). + • Возрастные когорты — ОЦЕНКА (региональные доли, age_cohorts_estimated=TRUE). + Поэтому в ответе всегда `cohort_estimated`-флаг: число «детей школьного + возраста» — производная оценка, НЕ район-факт переписи. + +Влияние на POI-score (acceptance #42): saturation_multiplier(category, district): + дефицитный район → ×1.2 (incentive строить school-adjacent), перенасыщенный + → ×0.5. Чистая функция — drop-in для poi_score без изменения его сигнатур. + +psycopg v3: все binds через CAST(:name AS type), а не postfix double-colon. Read-only SELECT. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any + +from sqlalchemy import text +from sqlalchemy.orm import Session + +logger = logging.getLogger(__name__) + +# ── Нормативы СП 42.13330.2016 (Свод правил «Градостроительство») ────────────── +# Источник значений — issue #42 + vault A3. Знаменатель когорты различается: +# - школа/детсад нормируются на 1000 ДЕТЕЙ соответствующего возраста, +# - поликлиника — на 10000 ВСЕХ жителей. +# capacity_per_1000 — мест (школа/сад) либо учреждений (поликлиника) на 1000 чел. +# cohort — какую возрастную долю населения брать как знаменатель. + + +@dataclass(frozen=True) +class _Norm: + """Норматив обеспеченности одной POI-категории по СП 42.13330.""" + + cohort: str # 'preschool' | 'school' | 'all' + capacity_per_1000: float # мест/учреждений на 1000 чел. когорты + unit: str # человекочитаемая единица норматива + + +# poi_loader-категории (osm_poi_ekb.category) → норматив. +_NORMS: dict[str, _Norm] = { + # Школа: 92 места на 1000 детей школьного возраста (7-18). + "school": _Norm(cohort="school", capacity_per_1000=92.0, unit="мест/1000 школьников"), + # Детсад: 35 мест на 1000 детей дошкольного возраста (0-7). + "kindergarten": _Norm( + cohort="preschool", capacity_per_1000=35.0, unit="мест/1000 дошкольников" + ), + # Поликлиника: 1 учреждение на 10000 жителей = 0.1 на 1000 всех жителей. + "hospital": _Norm(cohort="all", capacity_per_1000=0.1, unit="учр./1000 жителей"), +} + +# Сколько «мест» условно даёт один объект OSM. У OSM нет вместимости (только точка), +# поэтому считаем число ОБЪЕКТОВ и переводим в условные «места» множителем-капасити. +# Для поликлиник 1 объект = 1 учреждение. Для школ/садов — усреднённая вместимость +# типового объекта (оценка, vault A3); меняем здесь, если появится OSM `capacity`. +_OBJECT_CAPACITY: dict[str, float] = { + "school": 600.0, # мест в типовой школе (оценка) + "kindergarten": 200.0, # мест в типовом детсаду (оценка) + "hospital": 1.0, # 1 объект = 1 учреждение +} + +# ── Severity-пороги по provision_ratio (факт/норматив) ─────────────────────── +# < 0.5 — острый дефицит | 0.5–0.85 — дефицит | 0.85–1.3 — норма | > 1.3 — профицит. +_SEVERITY_ACUTE_DEFICIT = 0.5 +_SEVERITY_DEFICIT = 0.85 +_SEVERITY_SURPLUS = 1.3 + +# ── POI-score множитель (acceptance #42): дефицит → boost, профицит → штраф ─── +_MULT_DEFICIT = 1.2 # дефицитный район: incentive строить такую соцобъект-adjacent +_MULT_SURPLUS = 0.5 # перенасыщенный район: сигнал «ещё одна школа мало что даёт» +_MULT_NEUTRAL = 1.0 + + +def classify_severity(provision_ratio: float | None) -> str | None: + """provision_ratio (факт/норматив) → ярлык severity. PURE. + + None → None (нет данных: нет когорты / нет норматива — НЕ «острый дефицит»). + """ + if provision_ratio is None: + return None + if provision_ratio < _SEVERITY_ACUTE_DEFICIT: + return "острый дефицит" + if provision_ratio < _SEVERITY_DEFICIT: + return "дефицит" + if provision_ratio <= _SEVERITY_SURPLUS: + return "норма" + return "профицит" + + +def saturation_multiplier(category: str, provision_ratio: float | None) -> float: + """Множитель для POI-score по обеспеченности района категорией. PURE. + + Acceptance #42: дефицитный район → ×1.2 (стимул строить школо-adjacent), + перенасыщенный → ×0.5. Норма / нет данных / категория без норматива → ×1.0 + (нейтрально: не наказываем за отсутствие сигнала). + """ + if category not in _NORMS or provision_ratio is None: + return _MULT_NEUTRAL + if provision_ratio < _SEVERITY_DEFICIT: + return _MULT_DEFICIT + if provision_ratio > _SEVERITY_SURPLUS: + return _MULT_SURPLUS + return _MULT_NEUTRAL + + +def compute_provision_ratio( + *, + poi_count: int, + population: int, + age_share: float | None, + category: str, +) -> float | None: + """Коэффициент обеспеченности района категорией = факт / норматив. PURE. + + факт_мест_на_1000 = poi_count × object_capacity / (cohort_pop / 1000) + норматив_мест_на_1000 = _NORMS[category].capacity_per_1000 + provision_ratio = факт / норматив + + cohort_pop = population × age_share (для 'all' — age_share=1.0). + None если: неизвестная категория / нет населения / нулевая когорта. + """ + norm = _NORMS.get(category) + if norm is None or population <= 0: + return None + + share = 1.0 if norm.cohort == "all" else age_share + if share is None or share <= 0: + return None + + cohort_pop = population * share + if cohort_pop <= 0: + return None + + object_capacity = _OBJECT_CAPACITY.get(category, 1.0) + actual_per_1000 = (poi_count * object_capacity) / (cohort_pop / 1000.0) + if norm.capacity_per_1000 <= 0: + return None + return actual_per_1000 / norm.capacity_per_1000 + + +# ────────────────────────────────────────────────────────────────────────────── +# DB-зависимый слой: spatial POI-count по району + join демографии. +# ────────────────────────────────────────────────────────────────────────────── + +# POI-count категорий в полигоне района + демография района. ekb_districts.geom — +# полигон (миграция 56/74), osm_poi_ekb.geom — Point. ST_Contains(poly, point). +# LEFT JOIN osm_poi_ekb, чтобы район без POI давал 0, а не отсутствие строки. +_DISTRICT_SATURATION_SQL = text( + """ + SELECT + d.population, + d.area_km2, + d.age_share_preschool, + d.age_share_school, + d.age_share_elderly, + d.age_cohorts_estimated, + d.source, + d.as_of_date, + COUNT(*) FILTER (WHERE p.category = 'school') AS n_school, + COUNT(*) FILTER (WHERE p.category = 'kindergarten') AS n_kindergarten, + COUNT(*) FILTER (WHERE p.category = 'hospital') AS n_hospital + FROM ekb_district_demographics d + JOIN ekb_districts g + ON g.district_name = d.district_name + AND g.geom IS NOT NULL + LEFT JOIN osm_poi_ekb p + ON p.category IN ('school', 'kindergarten', 'hospital') + AND ST_Contains(g.geom, p.geom) + WHERE d.district_name = CAST(:dn AS text) + GROUP BY + d.population, d.area_km2, d.age_share_preschool, d.age_share_school, + d.age_share_elderly, d.age_cohorts_estimated, d.source, d.as_of_date + """ +) + +# Какую возрастную долю брать для категории (для отчёта по когорте). +_CATEGORY_SHARE_COLUMN: dict[str, str] = { + "school": "age_share_school", + "kindergarten": "age_share_preschool", + "hospital": "age_share_elderly", # информативно; норматив hospital — на всё население +} +_CATEGORY_COUNT_COLUMN: dict[str, str] = { + "school": "n_school", + "kindergarten": "n_kindergarten", + "hospital": "n_hospital", +} + + +def _round_or_none(value: float | None, digits: int = 3) -> float | None: + return round(value, digits) if value is not None else None + + +def compute_district_saturation(db: Session, district_name: str) -> dict[str, Any] | None: + """Saturation per capita района — блок для analyze (`infra.saturation`). + + Возвращает dict (JSON-готовый) либо None, если у района нет демографии + (нет строки в ekb_district_demographics / нет geom-полигона). + + Структура: + { + "district": "Чкаловский", + "population": 286277, + "as_of_date": "2025-01-01", + "source": "...", + "cohorts_estimated": true, # возрастные доли — оценка, НЕ факт + "categories": { + "school": { + "poi_count": 12, + "cohort_population": 32922, # оценка (population × age_share) + "cohort_estimated": true, + "provision_ratio": 0.68, # факт/норматив + "severity": "дефицит", + "score_multiplier": 1.2, # влияние на POI-score (#42) + "norm": {"capacity_per_1000": 92.0, "unit": "мест/1000 школьников"} + }, ... + } + } + + Graceful: на ошибке БД логирует и возвращает None (не роняет analyze). + psycopg v3: bind через CAST(:dn AS text). Read-only. + """ + try: + row = db.execute(_DISTRICT_SATURATION_SQL, {"dn": district_name}).mappings().first() + except Exception: + logger.exception("saturation: query failed (district=%s)", district_name) + return None + + if row is None or row["population"] is None: + return None + + population = int(row["population"]) + cohorts_estimated = bool(row["age_cohorts_estimated"]) + + categories: dict[str, Any] = {} + for category, norm in _NORMS.items(): + poi_count = int(row[_CATEGORY_COUNT_COLUMN[category]] or 0) + share_col = _CATEGORY_SHARE_COLUMN[category] + raw_share = row[share_col] + age_share = float(raw_share) if raw_share is not None else None + + provision = compute_provision_ratio( + poi_count=poi_count, + population=population, + age_share=age_share, + category=category, + ) + + # Когорта-знаменатель для отчёта: для hospital норматив на всё население. + if norm.cohort == "all": + cohort_pop: float | None = float(population) + cohort_is_estimate = False # всё население — факт + elif age_share is not None: + cohort_pop = population * age_share + cohort_is_estimate = cohorts_estimated + else: + cohort_pop = None + cohort_is_estimate = cohorts_estimated + + categories[category] = { + "poi_count": poi_count, + "cohort_population": int(cohort_pop) if cohort_pop is not None else None, + "cohort_estimated": cohort_is_estimate, + "provision_ratio": _round_or_none(provision), + "severity": classify_severity(provision), + "score_multiplier": saturation_multiplier(category, provision), + "norm": { + "capacity_per_1000": norm.capacity_per_1000, + "unit": norm.unit, + }, + } + + as_of = row["as_of_date"] + return { + "district": district_name, + "population": population, + "as_of_date": as_of.isoformat() if as_of is not None else None, + "source": row["source"], + "cohorts_estimated": cohorts_estimated, + "categories": categories, + } diff --git a/backend/tests/api/v1/test_analyze_competitors_status.py b/backend/tests/api/v1/test_analyze_competitors_status.py index 490425d9..0d59a3d4 100644 --- a/backend/tests/api/v1/test_analyze_competitors_status.py +++ b/backend/tests/api/v1/test_analyze_competitors_status.py @@ -5,13 +5,18 @@ Mock-based — не требуют живой БД. - поля site_status и ready_dt присутствуют в каждом элементе competitors - первые позиции занимают строящиеся ЖК (site_status='Строящиеся') - сданные ЖК идут после строящихся + - obj_pricing CTE ограничен ближними obj_id (pushdown perf-guard, #70/#307) """ from __future__ import annotations import datetime +import inspect +import re from unittest.mock import MagicMock +from app.api.v1 import parcels as parcels_module + # ── Вспомогательные фабрики ─────────────────────────────────────────────────── @@ -120,3 +125,61 @@ class TestCompetitorsSortOrder: # Старая логика: первым шёл ЖК с flat_count=800 (Сданные) assert first_old["flat_count"] == 800 assert first_old["site_status"] == "Сданные" + + +class TestObjPricingPushdown: + """obj_pricing CTE считается ТОЛЬКО для ближних obj_id (perf-guard, #70/#307). + + Старая версия (#1332 OBJ-3) агрегировала objective_lots (2.1 ГБ) по ВСЕМ + ~286 маппингам и выбрасывала далёкие медианы в LEFT JOIN. Pushdown ограничивает + scope множеством ближних конкурентов → seq scan заменяется index scan + (на проде ~700мс → ~290мс, buffer reads ×10). + + Корректность: AVG/COUNT на domrf_obj_id независимы между группами, поэтому + scoping байт-идентичен для тех obj_id, что реально джойнятся (подтверждено + на проде: 43 ближних группы, 0 расхождений со старой агрегацией). + + Тест защищает от тихого реверта на полнотабличную агрегацию. + """ + + @staticmethod + def _competitor_sql() -> str: + """Нормализованный (схлопнутые пробелы) текст competitor-SQL из analyze_parcel.""" + src = inspect.getsource(parcels_module.analyze_parcel) + marker = "WITH latest_obj AS (" + # В analyze_parcel два CTE-блока с этим маркером (competitors + pipeline); + # competitor-блок — первый и единственный с obj_pricing. + start = src.index(marker) + block = src[start : src.index('"""', start)] + return re.sub(r"\s+", " ", block) + + def test_obj_pricing_scoped_to_nearby(self) -> None: + """obj_pricing фильтрует domrf_obj_id по ближним (pushdown), не агрегирует всё.""" + sql = self._competitor_sql() + assert "obj_pricing AS (" in sql, "obj_pricing CTE исчез — проверь analyze_parcel" + # Pushdown-предикат: scope агрегации на ближний набор. + assert "cm.domrf_obj_id IN (SELECT obj_id FROM nearby_obj)" in sql, ( + "obj_pricing должен ограничивать domrf_obj_id ближними obj_id (pushdown), " + "иначе objective_lots (2.1ГБ) сканируется целиком — perf-регрессия #70/#307" + ) + + def test_final_select_reuses_nearby_cte(self) -> None: + """Финальный SELECT и obj_pricing берут один и тот же nearby_obj — scope == consumed.""" + sql = self._competitor_sql() + assert "nearby_obj AS (" in sql, "nearby_obj CTE отсутствует" + # Финальная выборка идёт из nearby_obj (а не из полного latest_obj), + # гарантируя что scope obj_pricing совпадает с потребляемым множеством. + assert "FROM nearby_obj o" in sql, ( + "финальный SELECT должен читать из nearby_obj, чтобы scope цен " + "совпадал с выводимыми конкурентами" + ) + + def test_aggregation_unchanged(self) -> None: + """Агрегатные выражения (AVG/COUNT FILTER) не тронуты — менялся только scope.""" + sql = self._competitor_sql() + # Логика цен/fallback (#1332) неизменна: те же агрегаты per domrf_obj_id. + assert "ROUND(AVG(ol.price_per_m2_rub)::numeric, 0) AS avg_price_per_m2_rub" in sql + assert "COUNT(*) FILTER (WHERE ol.price_per_m2_rub IS NOT NULL )" in sql.replace( + "\n", " " + ) or "lots_with_price" in sql + assert "GROUP BY cm.domrf_obj_id" in sql diff --git a/backend/tests/api/v1/test_parcel_competitors.py b/backend/tests/api/v1/test_parcel_competitors.py index 908bdfc5..ef6e5f7d 100644 --- a/backend/tests/api/v1/test_parcel_competitors.py +++ b/backend/tests/api/v1/test_parcel_competitors.py @@ -53,6 +53,13 @@ def _price_row(obj_id: int, price: float) -> MagicMock: return r +def _obj_price_row(obj_id: int, price: float) -> MagicMock: + """Строка objective-fallback цены (_OBJECTIVE_PRICE_FALLBACK_SQL, OBJ-3 #307).""" + r = MagicMock() + r.__getitem__ = lambda self, k: {"obj_id": obj_id, "median_price_per_m2": price}[k] + return r + + # ── Построение mock DB ──────────────────────────────────────────────────────── @@ -60,13 +67,17 @@ def _make_db( coord: MagicMock | None = None, obj_rows: list[MagicMock] | None = None, price_rows: list[MagicMock] | None = None, + obj_price_rows: list[MagicMock] | None = None, ) -> MagicMock: """Сконструировать mock Session. Порядок вызовов execute: 1. centroid query → coord 2. competitors query → obj_rows - 3. avg_price query → price_rows + 3. domrf avg_price query → price_rows + 4. objective price fallback (OBJ-3 #307) → obj_price_rows + (вызывается ТОЛЬКО если у части obj_id нет domrf-цены — почти всегда, + т.к. domrf на последнем snapshot пуст; по умолчанию [] = fallback пуст) """ db = MagicMock() @@ -75,6 +86,7 @@ def _make_db( (coord, True), (obj_rows or [], False), (price_rows or [], False), + (obj_price_rows or [], False), ]: result = MagicMock() if is_first: @@ -390,6 +402,97 @@ def test_competitors_avg_price_populated() -> None: assert comp["avg_price_per_m2"] == pytest.approx(150_000.0), ( "avg_price_per_m2 должен быть не None — регрессия #227 status='sold' filter" ) + # OBJ-3 #307: domrf-hit → price_source='domrf'. + assert comp["price_source"] == "domrf" + finally: + app.dependency_overrides.clear() + + +# ── OBJ-3 (#307) additive fallback из objective_lots ──────────────────────────── + + +def test_competitors_objective_price_fallback_fills_gap() -> None: + """domrf-цены НЕТ → objective_lots заполняет avg_price_per_m2 (OBJ-3 #307). + + Прод 2026-06: domrf_kn_flats.price_per_m2 на последнем snapshot = 0 строк, + поэтому domrf даёт None почти всем. objective_lots (через + objective_complex_mapping) покрывает ~80% — этот fallback восстанавливает цену. + """ + rows = [_obj_row(obj_id=1)] + # domrf price пуст → fallback на objective median. + obj_price_rows = [_obj_price_row(obj_id=1, price=136_000.0)] + db = _make_db( + coord=_coord_row(), obj_rows=rows, price_rows=[], obj_price_rows=obj_price_rows + ) + + from app.core.db import get_db + + app.dependency_overrides[get_db] = _override_db(db) + try: + client = TestClient(app) + resp = client.post( + "/api/v1/parcels/66:41:0303161:5/competitors", + json={"radius_km": 1.0, "time_window": "last_quarter"}, + ) + assert resp.status_code == 200, resp.text + comp = resp.json()["competitors"][0] + assert comp["avg_price_per_m2"] == pytest.approx(136_000.0) + assert comp["price_source"] == "objective" + finally: + app.dependency_overrides.clear() + + +def test_competitors_domrf_price_wins_over_objective() -> None: + """domrf-цена приоритетна: objective НЕ перетирает существующий domrf-вывод. + + Additive-контракт OBJ-3: где domrf есть → не трогаем; objective только пробелы. + """ + rows = [_obj_row(obj_id=1)] + price_rows = [_price_row(obj_id=1, price=150_000.0)] + # objective тоже вернул бы цену, но domrf уже есть → fallback её игнорирует. + obj_price_rows = [_obj_price_row(obj_id=1, price=99_000.0)] + db = _make_db( + coord=_coord_row(), + obj_rows=rows, + price_rows=price_rows, + obj_price_rows=obj_price_rows, + ) + + from app.core.db import get_db + + app.dependency_overrides[get_db] = _override_db(db) + try: + client = TestClient(app) + resp = client.post( + "/api/v1/parcels/66:41:0303161:5/competitors", + json={"radius_km": 1.0, "time_window": "last_quarter"}, + ) + assert resp.status_code == 200, resp.text + comp = resp.json()["competitors"][0] + assert comp["avg_price_per_m2"] == pytest.approx(150_000.0) + assert comp["price_source"] == "domrf" + finally: + app.dependency_overrides.clear() + + +def test_competitors_no_price_anywhere_is_none() -> None: + """Ни domrf, ни objective → avg_price_per_m2 None, price_source None (graceful).""" + rows = [_obj_row(obj_id=1)] + db = _make_db(coord=_coord_row(), obj_rows=rows, price_rows=[], obj_price_rows=[]) + + from app.core.db import get_db + + app.dependency_overrides[get_db] = _override_db(db) + try: + client = TestClient(app) + resp = client.post( + "/api/v1/parcels/66:41:0303161:5/competitors", + json={"radius_km": 1.0, "time_window": "last_quarter"}, + ) + assert resp.status_code == 200, resp.text + comp = resp.json()["competitors"][0] + assert comp["avg_price_per_m2"] is None + assert comp["price_source"] is None finally: app.dependency_overrides.clear() diff --git a/backend/tests/services/site_finder/test_competitors_parking.py b/backend/tests/services/site_finder/test_competitors_parking.py new file mode 100644 index 00000000..58fb795d --- /dev/null +++ b/backend/tests/services/site_finder/test_competitors_parking.py @@ -0,0 +1,286 @@ +"""Тесты geom-match domrf↔cad_buildings + wire parking_ratio (#96 wiring). + +Без живой БД и без сети: мокаем db.execute (PostGIS KNN-строка), get_competitors, +resolve_cad_for_domrf и get_building_premises_for_match. Проверяем: + • resolve_cad_for_domrf — точка внутри footprint → cad_num; вне радиуса → None; + нет зданий → None; objdoc_id из cad_buildings прокидывается. + • get_competitors_parking — top-N конкурентов получают parking_ratio; промах + geom-match / None-premises → поля None (graceful, не падаем), matched_count. +""" + +from __future__ import annotations + +import os +from typing import Any +from unittest.mock import MagicMock + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.schemas.nspd_bulk import ObjectsListing +from app.schemas.parcel import ( + Competitor, + CompetitorsRequest, + CompetitorsResponse, + CompetitorsSummary, +) +from app.services.site_finder import competitors as competitors_mod +from app.services.site_finder import premises_lookup as pl +from app.services.site_finder.premises_lookup import BuildingMatch, resolve_cad_for_domrf + +# ── resolve_cad_for_domrf (geom-match) ───────────────────────────────────────── + + +def _db_returning(row: dict[str, Any] | None) -> MagicMock: + """Мок Session.execute(...).mappings().first() → row (или None).""" + db = MagicMock() + db.execute.return_value.mappings.return_value.first.return_value = row + return db + + +def test_resolve_cad_inside_footprint_returns_cad() -> None: + """Точка внутри footprint (distance 0) → BuildingMatch с cad_num + objdoc_id.""" + db = _db_returning({"cad_num": "66:41:0106036:183", "objdoc_id": 40995027, "distance_m": 0.0}) + + match = resolve_cad_for_domrf(db, lat=56.84, lon=60.6) + + assert match is not None + assert match.cad_num == "66:41:0106036:183" + assert match.objdoc_id == 40995027 + assert match.distance_m == 0.0 + + +def test_resolve_cad_within_radius_returns_cad() -> None: + """Ближайшее здание в пределах радиуса (30м ≤ 50м) → матч.""" + db = _db_returning({"cad_num": "66:41:0106036:9", "objdoc_id": None, "distance_m": 30.0}) + + match = resolve_cad_for_domrf(db, lat=56.84, lon=60.6) + + assert match is not None + assert match.cad_num == "66:41:0106036:9" + # objdoc_id NULL в cad_buildings → None (фоллбэк на cad_num search в lookup). + assert match.objdoc_id is None + assert match.distance_m == 30.0 + + +def test_resolve_cad_beyond_radius_returns_none() -> None: + """Ближайшее здание дальше радиуса (80м > 50м) → None (не приписываем чужое).""" + db = _db_returning({"cad_num": "66:41:0106036:1", "objdoc_id": 1, "distance_m": 80.0}) + + assert resolve_cad_for_domrf(db, lat=56.84, lon=60.6) is None + + +def test_resolve_cad_custom_radius_accepts_far_match() -> None: + """max_dist_m=100 принимает 80м-матч, который дефолтные 50м отвергли бы.""" + db = _db_returning({"cad_num": "66:41:0106036:1", "objdoc_id": 1, "distance_m": 80.0}) + + match = resolve_cad_for_domrf(db, lat=56.84, lon=60.6, max_dist_m=100.0) + + assert match is not None + assert match.cad_num == "66:41:0106036:1" + + +def test_resolve_cad_no_buildings_returns_none() -> None: + """Нет зданий с geom (пустой результат) → None.""" + db = _db_returning(None) + + assert resolve_cad_for_domrf(db, lat=56.84, lon=60.6) is None + + +def test_resolve_cad_sql_error_returns_none() -> None: + """SQL/PostGIS-сбой → None (graceful, lazy-endpoint не падает).""" + db = MagicMock() + db.execute.side_effect = RuntimeError("postgis boom") + + assert resolve_cad_for_domrf(db, lat=56.84, lon=60.6) is None + + +# ── get_competitors_parking (wire) ────────────────────────────────────────────── + + +def _competitor(obj_id: int, lat: float = 56.84, lng: float = 60.6) -> Competitor: + """Минимальный Competitor для wire-теста (только нужные wire поля заполнены).""" + return Competitor( + obj_id=obj_id, + comm_name=f"ЖК-{obj_id}", + dev_name=None, + obj_class="комфорт", + distance_m=100.0, + lat=lat, + lng=lng, + stage="Строящиеся", + flats_total=200, + flats_sold=None, + sold_pct=None, + velocity_per_month=5.0, + avg_price_per_m2=None, + is_active=True, + ) + + +def _competitors_response(*obj_ids: int) -> CompetitorsResponse: + comps = [_competitor(oid) for oid in obj_ids] + return CompetitorsResponse( + competitors=comps, + summary=CompetitorsSummary( + total_competitors=len(comps), + active_count=len(comps), + weighted_avg_velocity=5.0, + radius_km=1.0, + time_window="last_quarter", + ), + ) + + +def test_parking_wire_happy_path(monkeypatch: Any) -> None: + """top-конкурент сматчился на здание → получает parking_ratio из НСПД.""" + monkeypatch.setattr( + competitors_mod, "get_competitors", lambda **_: _competitors_response(101) + ) + monkeypatch.setattr( + competitors_mod, + "resolve_cad_for_domrf", + lambda *_a, **_k: BuildingMatch("66:41:0106036:183", 40995027, 12.3), + ) + listing = ObjectsListing(objdoc_id=40995027, flats_count=200, parking_count=100) + monkeypatch.setattr( + competitors_mod, "get_building_premises_for_match", lambda _m: listing + ) + + out = competitors_mod.get_competitors_parking( + MagicMock(), "66:41:0303001:1", CompetitorsRequest() + ) + + assert out.matched_count == 1 + assert len(out.items) == 1 + item = out.items[0] + assert item.obj_id == 101 + assert item.matched_cad_num == "66:41:0106036:183" + assert item.match_distance_m == 12.3 + assert item.parking_ratio == 0.5 + assert item.parking_count == 100 + assert item.flats_count == 200 + + +def test_parking_wire_limits_to_top_n(monkeypatch: Any) -> None: + """Из 5 конкурентов обогащаются только первые top_n (строгий лимит).""" + monkeypatch.setattr( + competitors_mod, + "get_competitors", + lambda **_: _competitors_response(1, 2, 3, 4, 5), + ) + calls: list[float] = [] + + def _resolve(_db: Any, *, lat: float, lon: float) -> BuildingMatch: + calls.append(lat) + return BuildingMatch("66:41:0106036:1", 1, 0.0) + + monkeypatch.setattr(competitors_mod, "resolve_cad_for_domrf", _resolve) + monkeypatch.setattr( + competitors_mod, + "get_building_premises_for_match", + lambda _m: ObjectsListing(objdoc_id=1, flats_count=10, parking_count=5), + ) + + out = competitors_mod.get_competitors_parking( + MagicMock(), "66:41:0303001:1", CompetitorsRequest(), top_n=2 + ) + + assert len(out.items) == 2 + assert len(calls) == 2 # geom-match вызван ровно top_n раз — не на всех 5 + assert {i.obj_id for i in out.items} == {1, 2} + + +def test_parking_wire_no_geom_match_graceful(monkeypatch: Any) -> None: + """geom-match промахнулся → конкурент в items с None-полями, matched_count=0.""" + monkeypatch.setattr( + competitors_mod, "get_competitors", lambda **_: _competitors_response(7) + ) + monkeypatch.setattr(competitors_mod, "resolve_cad_for_domrf", lambda *_a, **_k: None) + premises = MagicMock() + monkeypatch.setattr(competitors_mod, "get_building_premises_for_match", premises) + + out = competitors_mod.get_competitors_parking( + MagicMock(), "66:41:0303001:1", CompetitorsRequest() + ) + + assert out.matched_count == 0 + assert out.items[0].obj_id == 7 + assert out.items[0].matched_cad_num is None + assert out.items[0].parking_ratio is None + premises.assert_not_called() # без матча НСПД не дёргаем + + +def test_parking_wire_premises_none_keeps_cad(monkeypatch: Any) -> None: + """Здание сматчилось, но НСПД premises=None → cad есть, parking_ratio None.""" + monkeypatch.setattr( + competitors_mod, "get_competitors", lambda **_: _competitors_response(9) + ) + monkeypatch.setattr( + competitors_mod, + "resolve_cad_for_domrf", + lambda *_a, **_k: BuildingMatch("66:41:0106036:5", None, 40.0), + ) + monkeypatch.setattr(competitors_mod, "get_building_premises_for_match", lambda _m: None) + + out = competitors_mod.get_competitors_parking( + MagicMock(), "66:41:0303001:1", CompetitorsRequest() + ) + + assert out.matched_count == 0 + assert out.items[0].matched_cad_num == "66:41:0106036:5" + assert out.items[0].match_distance_m == 40.0 + assert out.items[0].parking_ratio is None + + +def test_parking_wire_premises_exception_graceful(monkeypatch: Any) -> None: + """Неожиданное исключение в premises-lookup → конкурент без паркинга, не 500.""" + monkeypatch.setattr( + competitors_mod, "get_competitors", lambda **_: _competitors_response(11) + ) + monkeypatch.setattr( + competitors_mod, + "resolve_cad_for_domrf", + lambda *_a, **_k: BuildingMatch("66:41:0106036:5", 1, 0.0), + ) + + def _boom(_m: Any) -> Any: + raise RuntimeError("nspd exploded") + + monkeypatch.setattr(competitors_mod, "get_building_premises_for_match", _boom) + + out = competitors_mod.get_competitors_parking( + MagicMock(), "66:41:0303001:1", CompetitorsRequest() + ) + + assert out.matched_count == 0 + assert out.items[0].matched_cad_num == "66:41:0106036:5" + assert out.items[0].parking_ratio is None + + +# ── get_building_premises_for_match (short-circuit objdoc) ────────────────────── + + +def test_premises_for_match_uses_objdoc_skips_search(monkeypatch: Any) -> None: + """objdoc_id из cad_buildings → пропускаем НСПД search, сразу tab-group.""" + listing = ObjectsListing(objdoc_id=555, flats_count=50, parking_count=60) + monkeypatch.setattr(pl.asyncio, "run", lambda coro: (coro.close(), listing)[1]) + # search_by_cad НЕ должен вызываться при known objdoc_id. + full_path = MagicMock() + monkeypatch.setattr(pl, "get_building_premises", full_path) + + out = pl.get_building_premises_for_match(BuildingMatch("66:41:0106036:5", 555, 0.0)) + + assert out is listing + full_path.assert_not_called() + + +def test_premises_for_match_no_objdoc_falls_back(monkeypatch: Any) -> None: + """objdoc_id None → фоллбэк на get_building_premises(cad_num) (2 НСПД-запроса).""" + listing = ObjectsListing(objdoc_id=1, flats_count=10, parking_count=3) + fallback = MagicMock(return_value=listing) + monkeypatch.setattr(pl, "get_building_premises", fallback) + + out = pl.get_building_premises_for_match(BuildingMatch("66:41:0106036:5", None, 0.0)) + + assert out is listing + fallback.assert_called_once_with("66:41:0106036:5") diff --git a/backend/tests/test_gate_verdict.py b/backend/tests/test_gate_verdict.py index 903f0f7a..4378d5ab 100644 --- a/backend/tests/test_gate_verdict.py +++ b/backend/tests/test_gate_verdict.py @@ -124,12 +124,17 @@ def test_pzz_unknown_warning_when_no_zoning(): def test_cad_zouit_ohranaya_zona_truboprovodov_blocks(): - """cad_zouit overlap с 'охранная зона трубопроводов' → ZOUIT_CAD_BLOCKER → Нельзя.""" + """cad_zouit 'охранная зона трубопроводов' → сетевое обременение (газ) → Нельзя (#1070). + + После #1070 трубопроводы/газ классифицируются как ZOUIT_NETWORK_OBREMENENIE + (более специфичный код, чем прежний ZOUIT_CAD_BLOCKER) — поведение «блокирует МКД» + сохранено. + """ nspd_zoning = {"zone_code": "Ж-2", "zone_name": "Жилая"} overlaps = [ { "source": "cad_zouit", - "type_zone": "Охранная зона трубопроводов", + "type_zone": "Охранная зона трубопроводов (газопроводов, нефтепроводов)", "layer": "Охранная зона трубопроводов", "name": "Газопровод высокого давления", } @@ -137,11 +142,11 @@ def test_cad_zouit_ohranaya_zona_truboprovodov_blocks(): verdict = compute_gate_verdict(nspd_zoning, overlaps, [], {"available": True}) assert verdict["can_build_mkd"] is False assert verdict["verdict_label"] == "Нельзя" - assert any(b["code"] == "ZOUIT_CAD_BLOCKER" for b in verdict["blockers"]) + assert any(b["code"] == "ZOUIT_NETWORK_OBREMENENIE" for b in verdict["blockers"]) def test_cad_zouit_elektr_blocks(): - """cad_zouit overlap с 'электр' substring в type_zone → blocker.""" + """cad_zouit электросетевая охранная зона → сетевое обременение (электро) → blocker (#1070).""" nspd_zoning = {"zone_code": "Ж-3", "zone_name": "Жилая"} overlaps = [ { @@ -153,7 +158,7 @@ def test_cad_zouit_elektr_blocks(): ] verdict = compute_gate_verdict(nspd_zoning, overlaps, [], {"available": True}) assert verdict["can_build_mkd"] is False - assert any(b["code"] == "ZOUIT_CAD_BLOCKER" for b in verdict["blockers"]) + assert any(b["code"] == "ZOUIT_NETWORK_OBREMENENIE" for b in verdict["blockers"]) def test_cad_zouit_szz_is_warning_not_blocker(): diff --git a/backend/tests/test_network_obremenenie.py b/backend/tests/test_network_obremenenie.py new file mode 100644 index 00000000..97aa5cc9 --- /dev/null +++ b/backend/tests/test_network_obremenenie.py @@ -0,0 +1,141 @@ +"""Tests for network-obremenenie классификатор (#1070) — pure function, no DB. + +type_zone-строки взяты из реальных distinct-значений cad_zouit (PROD, 2026-06-14). +""" + +from app.services.site_finder.gate_verdict import compute_gate_verdict +from app.services.site_finder.network_obremenenie import ( + classify_network_zone, + is_network_obremenenie, + network_kind_label, +) + +# ── classify_network_zone: реальные type_zone из cad_zouit ──────────────────── + + +def test_thermal_zone(): + assert classify_network_zone("Охранная зона тепловых сетей") == "thermal" + + +def test_electricity_zone(): + tz = ( + "Охранная зона объектов электроэнергетики (объектов электросетевого хозяйства " + "и объектов по производству электрической энергии)" + ) + assert classify_network_zone(tz) == "electricity" + + +def test_pipeline_zone_gas_first(): + # «трубопроводов (газопроводов, нефтепроводов …)» — газ ловится раньше pipeline. + tz = ( + "Охранная зона трубопроводов (газопроводов, нефтепроводов " + "и нефтепродуктопроводов, аммиакопроводов)" + ) + assert classify_network_zone(tz) == "gas" + + +def test_communication_zone(): + assert classify_network_zone("Охранная зона линий и сооружений связи") == "communication" + + +def test_general_engineering_zone(): + assert classify_network_zone("Охранная зона инженерных коммуникаций") == "general" + + +# ── негативные: НЕ сетевые ЗОУИТ ────────────────────────────────────────────── + + +def test_servitude_not_network(): + assert classify_network_zone("Зона публичного сервитута") is None + + +def test_szz_not_network(): + assert classify_network_zone("Санитарно-защитная зона") is None + + +def test_natural_zouit_not_network(): + assert classify_network_zone("Зоны с особыми условиями использования территории") is None + + +def test_none_and_empty(): + assert classify_network_zone(None) is None + assert classify_network_zone("") is None + + +def test_general_keyword_without_protection_marker_is_skipped(): + # «инженерные коммуникации» без «охранная зона» → не ловим (избегаем ложных). + assert classify_network_zone("Размещение инженерных коммуникаций") is None + + +# ── helpers ─────────────────────────────────────────────────────────────────── + + +def test_is_network_obremenenie(): + assert is_network_obremenenie("Охранная зона тепловых сетей") is True + assert is_network_obremenenie("Зона публичного сервитута") is False + + +def test_network_kind_label(): + assert network_kind_label("thermal") == "теплоснабжение" + assert network_kind_label("general") == "инженерные коммуникации" + assert network_kind_label(None) is None + + +# ── gate_verdict: сетевое обременение → выделенный blocker ───────────────────── + + +def _dump_available() -> dict: + return {"available": True, "stale": False} + + +def test_gate_verdict_thermal_network_blocker(): + """Теплосеть (#1070 ООО Брик) → ZOUIT_NETWORK_OBREMENENIE blocker.""" + overlaps = [ + { + "source": "cad_zouit", + "type_zone": "Охранная зона тепловых сетей", + "name": "ТС-123", + "is_network_zone": True, + "network_kind": "thermal", + "network_kind_label": "теплоснабжение", + } + ] + verdict = compute_gate_verdict(None, overlaps, None, _dump_available()) + codes = [b["code"] for b in verdict["blockers"]] + assert "ZOUIT_NETWORK_OBREMENENIE" in codes + assert verdict["can_build_mkd"] is False + detail = next( + b["detail"] for b in verdict["blockers"] if b["code"] == "ZOUIT_NETWORK_OBREMENENIE" + ) + assert "теплоснабжение" in detail + + +def test_gate_verdict_servitude_not_network_blocker(): + """Сервитут (не сеть) → НЕ ZOUIT_NETWORK_OBREMENENIE.""" + overlaps = [ + { + "source": "cad_zouit", + "type_zone": "Зона публичного сервитута", + "name": "", + "is_network_zone": False, + "network_kind": None, + "network_kind_label": None, + } + ] + verdict = compute_gate_verdict(None, overlaps, None, _dump_available()) + codes = [b["code"] for b in verdict["blockers"]] + assert "ZOUIT_NETWORK_OBREMENENIE" not in codes + + +def test_gate_verdict_network_classified_when_flag_absent(): + """Back-compat: overlap без флага → gate_verdict классифицирует сам.""" + overlaps = [ + { + "source": "cad_zouit", + "type_zone": "Охранная зона объектов электроэнергетики", + "name": "ВЛ-10кВ", + } + ] + verdict = compute_gate_verdict(None, overlaps, None, _dump_available()) + codes = [b["code"] for b in verdict["blockers"]] + assert "ZOUIT_NETWORK_OBREMENENIE" in codes diff --git a/backend/tests/test_saturation.py b/backend/tests/test_saturation.py new file mode 100644 index 00000000..eba19e2f --- /dev/null +++ b/backend/tests/test_saturation.py @@ -0,0 +1,241 @@ +"""Tests for POI saturation per capita (#42). + +Pure-функции (severity / multiplier / provision_ratio) — без БД. +DB-слой compute_district_saturation — через минимальный mock Session +(тот же приём, что в test_poi_score.py). +""" + +from datetime import date + +import pytest + +from app.services.site_finder.saturation import ( + _MULT_DEFICIT, + _MULT_NEUTRAL, + _MULT_SURPLUS, + classify_severity, + compute_district_saturation, + compute_provision_ratio, + saturation_multiplier, +) + +# ── classify_severity ───────────────────────────────────────────────────────── + + +def test_severity_none_is_none(): + """Нет данных (None) → None, НЕ «острый дефицит».""" + assert classify_severity(None) is None + + +def test_severity_buckets(): + assert classify_severity(0.2) == "острый дефицит" + assert classify_severity(0.49) == "острый дефицит" + assert classify_severity(0.5) == "дефицит" + assert classify_severity(0.84) == "дефицит" + assert classify_severity(0.85) == "норма" + assert classify_severity(1.0) == "норма" + assert classify_severity(1.3) == "норма" + assert classify_severity(1.31) == "профицит" + assert classify_severity(3.0) == "профицит" + + +# ── saturation_multiplier (acceptance #42) ───────────────────────────────────── + + +def test_multiplier_deficit_boosts(): + """Дефицитный район → ×1.2 (incentive строить school-adjacent).""" + assert saturation_multiplier("school", 0.6) == _MULT_DEFICIT + + +def test_multiplier_surplus_penalises(): + """Перенасыщенный район → ×0.5.""" + assert saturation_multiplier("school", 2.0) == _MULT_SURPLUS + + +def test_multiplier_norm_neutral(): + assert saturation_multiplier("school", 1.0) == _MULT_NEUTRAL + + +def test_multiplier_none_and_unknown_category_neutral(): + """Нет данных или категория без норматива → нейтрально (×1.0).""" + assert saturation_multiplier("school", None) == _MULT_NEUTRAL + assert saturation_multiplier("metro_stop", 0.1) == _MULT_NEUTRAL + + +# ── compute_provision_ratio ───────────────────────────────────────────────────── + + +def test_provision_unknown_category_none(): + assert ( + compute_provision_ratio( + poi_count=5, population=100000, age_share=0.1, category="park" + ) + is None + ) + + +def test_provision_zero_population_none(): + assert ( + compute_provision_ratio( + poi_count=5, population=0, age_share=0.1, category="school" + ) + is None + ) + + +def test_provision_missing_age_share_none(): + """Школа/детсад без age_share → None (когорту не оценить).""" + assert ( + compute_provision_ratio( + poi_count=5, population=100000, age_share=None, category="school" + ) + is None + ) + + +def test_provision_hospital_ignores_age_share(): + """Поликлиника нормируется на ВСЁ население — age_share не нужен.""" + # 100000 жителей, норматив 0.1 учр./1000 = 10 учреждений эталон. + # 10 объектов × capacity 1.0 / (100000/1000) = 10/100 = 0.1 на 1000 = ровно норматив. + ratio = compute_provision_ratio( + poi_count=10, population=100000, age_share=None, category="hospital" + ) + assert ratio == pytest.approx(1.0) + + +def test_provision_more_poi_higher_ratio(): + """Больше объектов при той же когорте → выше обеспеченность (монотонность).""" + few = compute_provision_ratio( + poi_count=2, population=100000, age_share=0.115, category="school" + ) + many = compute_provision_ratio( + poi_count=10, population=100000, age_share=0.115, category="school" + ) + assert few is not None and many is not None + assert many > few + + +def test_provision_smaller_cohort_higher_ratio(): + """#42 суть: та же школа на МЕНЬШЕЕ число детей → выше обеспеченность per capita.""" + big_cohort = compute_provision_ratio( + poi_count=3, population=300000, age_share=0.115, category="school" + ) + small_cohort = compute_provision_ratio( + poi_count=3, population=100000, age_share=0.115, category="school" + ) + assert big_cohort is not None and small_cohort is not None + assert small_cohort > big_cohort + + +# ── compute_district_saturation (mock DB) ──────────────────────────────────── + + +class _MockMappings: + def __init__(self, row: dict | None) -> None: + self._row = row + + def first(self) -> dict | None: + return self._row + + +class _MockResult: + def __init__(self, row: dict | None) -> None: + self._row = row + + def mappings(self) -> "_MockMappings": + return _MockMappings(self._row) + + +class _MockDb: + """Минимальный мок SQLAlchemy Session (как в test_poi_score.py).""" + + def __init__(self, row: dict | None, *, raise_on_execute: bool = False) -> None: + self._row = row + self._raise = raise_on_execute + + def execute(self, *_args: object, **_kwargs: object) -> _MockResult: + if self._raise: + raise RuntimeError("simulated DB failure") + return _MockResult(self._row) + + +def _chkalovsky_row(n_school: int = 12) -> dict: + """Чкаловский — крупнейший район ЕКБ (286277 чел.), типичный дефицит школ.""" + return { + "population": 286277, + "area_km2": 389.81, + "age_share_preschool": 0.08, + "age_share_school": 0.115, + "age_share_elderly": 0.16, + "age_cohorts_estimated": True, + "source": "Росстат 2025-01-01", + "as_of_date": date(2025, 1, 1), + "n_school": n_school, + "n_kindergarten": 20, + "n_hospital": 5, + } + + +def test_saturation_no_demographics_returns_none(): + """Район без строки демографии (population NULL) → None.""" + assert compute_district_saturation(_MockDb(None), "Неизвестный") is None + + +def test_saturation_db_error_returns_none(): + """Ошибка БД не роняет analyze — graceful None.""" + db = _MockDb(_chkalovsky_row(), raise_on_execute=True) + assert compute_district_saturation(db, "Чкаловский") is None + + +def test_saturation_shape_and_flags(): + db = _MockDb(_chkalovsky_row()) + out = compute_district_saturation(db, "Чкаловский") + assert out is not None + assert out["district"] == "Чкаловский" + assert out["population"] == 286277 + assert out["as_of_date"] == "2025-01-01" + assert out["cohorts_estimated"] is True + assert set(out["categories"]) == {"school", "kindergarten", "hospital"} + school = out["categories"]["school"] + # Школьная когорта — оценка (региональная доля), помечена флагом. + assert school["cohort_estimated"] is True + assert school["cohort_population"] == int(286277 * 0.115) + assert school["poi_count"] == 12 + assert school["norm"]["capacity_per_1000"] == 92.0 + + +def test_saturation_hospital_cohort_is_factual(): + """Поликлиника нормируется на всё население → cohort НЕ оценка (факт).""" + out = compute_district_saturation(_MockDb(_chkalovsky_row()), "Чкаловский") + assert out is not None + hospital = out["categories"]["hospital"] + assert hospital["cohort_estimated"] is False + assert hospital["cohort_population"] == 286277 + + +def test_saturation_deficit_district_school_gets_boost(): + """#42 acceptance: дефицитный по школам район → school POI score_multiplier=1.2. + + Чкаловский, 12 школ, школьники ≈ 32922 чел. (286277×0.115). + Эталон-места = 32922/1000 × 92 ≈ 3029. Факт = 12 × 600 = 7200… + при таком capacity это профицит. Сужаем когорту: мало школ на много детей → + проверяем boundary через малое число объектов. + """ + # 1 школа на крупный район → острый дефицит → ×1.2. + out = compute_district_saturation(_MockDb(_chkalovsky_row(n_school=1)), "Чкаловский") + assert out is not None + school = out["categories"]["school"] + assert school["severity"] in ("дефицит", "острый дефицит") + assert school["score_multiplier"] == _MULT_DEFICIT + + +def test_saturation_ratio_matches_pure_helper(): + """DB-слой использует ту же чистую формулу, что и compute_provision_ratio.""" + row = _chkalovsky_row(n_school=4) + out = compute_district_saturation(_MockDb(row), "Чкаловский") + assert out is not None + expected = compute_provision_ratio( + poi_count=4, population=286277, age_share=0.115, category="school" + ) + assert expected is not None + assert out["categories"]["school"]["provision_ratio"] == round(expected, 3) diff --git a/data/sql/154_ekb_district_demographics.sql b/data/sql/154_ekb_district_demographics.sql new file mode 100644 index 00000000..52b847da --- /dev/null +++ b/data/sql/154_ekb_district_demographics.sql @@ -0,0 +1,107 @@ +-- Контекст: Issue #42 (Infra I1) — POI saturation per capita. Чтобы считать +-- обеспеченность инфраструктурой района (школа/детсад/поликлиника на +-- 1000 чел. целевой когорты vs норматив СП 42.13330), нужна демография +-- по 8 внутригородским районам ЕКБ. +-- +-- Что делает: +-- 1. Заполняет ekb_districts.population (была NULL) точными числами Росстата. +-- 2. Создаёт таблицу-справочник ekb_district_demographics — насел./площадь/ОКАТО +-- + ДОКУМЕНТИРОВАННЫЙ источник/дата + ОЦЕНОЧНЫЕ возрастные когорты (помечены). +-- +-- ИСТОЧНИК населения (источник истины): +-- Wikipedia «Административно-территориальное деление Екатеринбурга» → +-- первоисточник «Численность постоянного населения РФ по муниципальным +-- образованиям на 1 января 2025», Росстат. as_of_date = 2025-01-01. +-- +-- ВОЗРАСТНЫЕ КОГОРТЫ — ОЦЕНКА, НЕ ФАКТ. На уровне внутригородского района Росстат +-- публичных половозрастных срезов НЕ даёт. Применены РЕГИОНАЛЬНЫЕ доли (РФ/Свердл. +-- обл., перепись-2020 / EMISS, порядок величин): +-- • дошкольники 0-7 ≈ 8.0 % (age_share_preschool) +-- • школьники 7-18 ≈ 11.5 % (age_share_school) +-- • пожилые 65+ ≈ 16.0 % (age_share_elderly) +-- Это ГРУБАЯ оценка для saturation-расчёта (estimate-флаг в API). Реальные +-- район-уровневые доли потребуют отдельного источника (см. issue #42 risks). +-- +-- Deploy order: после 56_schema_ekb_districts_geom (FK на ekb_districts) — стоит. +-- Idempotent: CREATE TABLE IF NOT EXISTS + INSERT … ON CONFLICT DO UPDATE +-- (re-apply перезаписывает данными из этой миграции — single source). +-- Deploy: auto-applied by deploy.yml через _schema_migrations tracking (ровно один раз по NN). + +BEGIN; + +-- ── 1. Справочник демографии района ────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS ekb_district_demographics ( + district_name TEXT PRIMARY KEY + REFERENCES ekb_districts(district_name), + population INTEGER NOT NULL, -- чел., Росстат 2025-01-01 + area_km2 NUMERIC(8, 2) NOT NULL, -- км² + okato TEXT, -- код ОКАТО района + -- Возрастные доли — ОЦЕНКА (региональный fallback), а не район-факт. + age_share_preschool NUMERIC(5, 4), -- доля 0-7 лет (детсад) + age_share_school NUMERIC(5, 4), -- доля 7-18 лет (школа) + age_share_elderly NUMERIC(5, 4), -- доля 65+ лет (поликлиника) + age_cohorts_estimated BOOLEAN NOT NULL DEFAULT TRUE, -- TRUE = когорты оценочные + source TEXT NOT NULL, -- человекочитаемый источник + as_of_date DATE NOT NULL, -- дата актуальности населения + loaded_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +COMMENT ON TABLE ekb_district_demographics IS + 'Демография 8 внутригородских районов ЕКБ для POI saturation per capita (#42). ' + 'population/area — Росстат 2025-01-01 (факт). Возрастные когорты — ОЦЕНКА ' + '(региональные доли РФ/Свердл.), см. age_cohorts_estimated.'; +COMMENT ON COLUMN ekb_district_demographics.age_cohorts_estimated IS + 'TRUE: age_share_* — оценка региональными долями, НЕ район-факт Росстата (#42).'; + +-- ── 2. Seed: 8 районов (Росстат на 1 января 2025) ──────────────────────────── +-- population/area/okato — ФАКТ. age_share_* — единая региональная оценка (см. шапку). +INSERT INTO ekb_district_demographics + (district_name, population, area_km2, okato, + age_share_preschool, age_share_school, age_share_elderly, + age_cohorts_estimated, source, as_of_date) +VALUES + ('Академический', 125000, 46.01, '65401362', 0.0800, 0.1150, 0.1600, TRUE, + 'Росстат, численность населения МО на 01.01.2025 (via Wikipedia АТД Екатеринбурга)', + DATE '2025-01-01'), + ('Верх-Исетский', 240822, 219.79, '65401364', 0.0800, 0.1150, 0.1600, TRUE, + 'Росстат, численность населения МО на 01.01.2025 (via Wikipedia АТД Екатеринбурга)', + DATE '2025-01-01'), + ('Железнодорожный', 158675, 125.65, '65401368', 0.0800, 0.1150, 0.1600, TRUE, + 'Росстат, численность населения МО на 01.01.2025 (via Wikipedia АТД Екатеринбурга)', + DATE '2025-01-01'), + ('Кировский', 220749, 86.25, '65401373', 0.0800, 0.1150, 0.1600, TRUE, + 'Росстат, численность населения МО на 01.01.2025 (via Wikipedia АТД Екатеринбурга)', + DATE '2025-01-01'), + ('Ленинский', 222258, 22.19, '65401377', 0.0800, 0.1150, 0.1600, TRUE, + 'Росстат, численность населения МО на 01.01.2025 (via Wikipedia АТД Екатеринбурга)', + DATE '2025-01-01'), + ('Октябрьский', 151775, 158.60, '65401380', 0.0800, 0.1150, 0.1600, TRUE, + 'Росстат, численность населения МО на 01.01.2025 (via Wikipedia АТД Екатеринбурга)', + DATE '2025-01-01'), + ('Орджоникидзевский', 263820, 99.30, '65401385', 0.0800, 0.1150, 0.1600, TRUE, + 'Росстат, численность населения МО на 01.01.2025 (via Wikipedia АТД Екатеринбурга)', + DATE '2025-01-01'), + ('Чкаловский', 286277, 389.81, '65401390', 0.0800, 0.1150, 0.1600, TRUE, + 'Росстат, численность населения МО на 01.01.2025 (via Wikipedia АТД Екатеринбурга)', + DATE '2025-01-01') +ON CONFLICT (district_name) DO UPDATE SET + population = EXCLUDED.population, + area_km2 = EXCLUDED.area_km2, + okato = EXCLUDED.okato, + age_share_preschool = EXCLUDED.age_share_preschool, + age_share_school = EXCLUDED.age_share_school, + age_share_elderly = EXCLUDED.age_share_elderly, + age_cohorts_estimated = EXCLUDED.age_cohorts_estimated, + source = EXCLUDED.source, + as_of_date = EXCLUDED.as_of_date, + loaded_at = now(); + +-- ── 3. Backfill ekb_districts.population (была NULL) — по name-match ────────── +-- ekb_districts.area_m2 уже есть в схеме (NUMERIC) — конвертируем км² → м². +UPDATE ekb_districts d + SET population = s.population, + area_m2 = COALESCE(d.area_m2, s.area_km2 * 1000000.0) + FROM ekb_district_demographics s + WHERE d.district_name = s.district_name; + +COMMIT; diff --git a/data/sql/155_objective_mapping_trgm_backfill.sql b/data/sql/155_objective_mapping_trgm_backfill.sql new file mode 100644 index 00000000..4474b8a7 --- /dev/null +++ b/data/sql/155_objective_mapping_trgm_backfill.sql @@ -0,0 +1,259 @@ +-- 155_objective_mapping_trgm_backfill.sql +-- Issue #307 OBJ-2 — КОНСЕРВАТИВНЫЙ fuzzy (pg_trgm) backfill of +-- objective_complex_mapping. Третий проход (после 116/150): добавляет ТОЛЬКО +-- near-exact distinctive-name матчи, которые 150 (dev-corroboration-only gate) +-- НЕ взял из-за Latin/Cyrillic dev-noise (TEN↔ТЭН, FORTIS↔Фортис) и юр.префиксов. +-- +-- CONTEXT +-- objective_complex_mapping связывает Objective-ЖК (objective_complex_name) → +-- domrf_kn_objects.obj_id. До этой миграции — 263 строки (все objective_group= +-- 'Екатеринбург'; match_method fuzzy/auto_fuzzy_v2/multi_feature_v1/fuzzy_v2/manual, +-- все is_reviewed=false кроме 2 manual). +-- +-- mv_layout_velocity JOIN-ит objective_corpus_room_month.project_name = +-- objective_complex_mapping.objective_complex_name WHERE objective_group= +-- 'Екатеринбург' AND domrf_obj_id IS NOT NULL (verified pg_get_viewdef +-- 2026-06-14). То есть unique-ключ матчинга — project_name (НЕ project_id): +-- новые строки с этим именем + group='Екатеринбург' немедленно расширяют MV. +-- +-- ПОЧЕМУ ИМЕНА, А НЕ project_id +-- objective_lots: 302 distinct objective_project_id ещё не в mapping, НО лишь +-- 156 distinct project_name не в mapping под group='Екатеринбург' (многие +-- project_id делят одно имя; многие имена уже сматчены под другим id). MV и +-- UNIQUE-констрейнт работают по имени, 150 дедупил по имени — поэтому здесь +-- тоже дедуп по project_name. Матчить 302 id создало бы дубль-имя конфликты, +-- которые ON CONFLICT молча отбросит. +-- +-- НОРМАЛИЗАЦИЯ (идентична 150; калибровка prod read-only 2026-06-14, snapshot +-- 2026-06-10): strip ЖК-boilerplate ('Жилой комплекс/квартал/район/дом', +-- 'Клубный дом', 'Микрорайон', 'Квартал', 'ЖК', 'Дом' …), кавычки, +-- stage-суффиксы ('N этап/очередь') ДО similarity. Strip-токены по +-- word-boundary (\m…\M) чтобы не резать середину ('Домино'→'ино'). +-- Dev нормализуется (strip юр.префиксов СЗ/ГК/ООО/Девелопмент/Development/Group…) +-- и dsim = trigram ИЛИ substring-containment. +-- +-- ПРАВИЛО ПРИЁМКИ — PRECISION OVER RECALL (P0: ложный mapping напрямую портит +-- velocity/prices, поэтому держим precision≈1.0 на авто-наборе): +-- nsim = similarity(norm(project_name), norm(comm_name)) +-- dsim = trigram(norm dev) OR substring-containment +-- corroborated = (dsim >= 0.5) OR (district точно совпал) +-- generic_guarded = norm(project_name) ∈ стоплист generic single-token имён, +-- где межзастройщицкие коллизии РЕАЛЬНЫ (наблюдённые FP в band'е 1.0: +-- «Солнечный», «Прекрасный», «Невский», «Стрелки», «Парковый» (over-strip +-- «Жилой квартал Парковый»→'парковый' ≡ obj 'Парковый квартал' PRINZIP), +-- «Тихий центр», плюс risk-слова центральный/самоцветы/южные/теплые/ +-- московский/сириус/нескучный сад). +-- accept = +-- (nsim >= 0.85 AND NOT generic_guarded) -- A: near-exact + distinctive +-- OR (nsim >= 0.85 AND generic_guarded AND corroborated) -- A': generic, но dev/district подтверждён +-- OR (nsim >= 0.62 AND nsim < 0.85 AND corroborated) -- B: good name + dev/district +-- Калибровка по реальным band'ам (prod, BEGIN…ROLLBACK 2026-06-14): +-- • Band A нетронутый дал 29 near-exact (все nsim=1.000). 6 — generic FP с +-- чужим застройщиком → отсечены guard'ом. Остаток 23 — distinctive +-- (RedRock/Геометрия/Башня Времени/…), межзастройщицкая коллизия +-- практически невозможна → авто без dev. Precision на этих 23 ≈ 1.0. +-- • Band B (0.62-0.85): 10 кандидатов, единственный corroborated — «Рио-3» +-- (dsim=1.0 Стройтэк) — но его obj_id 31874 уже занят 150 ('Рио'); «Рио-3» +-- это фаза того же ЖК → исключён obj_id-guard'ом. Прочие 9 uncorroborated +-- (чужой dev / Kurgan-застройщик / уже-сматченный «Самоцветы Урала») → +-- отброшены. ИТОГ band B = 0 авто (намеренно: ни одного безопасного). +-- ИТОГ: 23 новых mapping (все nsim=1.000, decision=A_auto). 133 имени остаются +-- на manual (Anton): 19 без кандидата в DOM.РФ (города-спутники / нет ПД), +-- 6 generic-guarded near-exact, 108 низко-/средне-confident без подтверждения. +-- +-- COLLISION HANDLING +-- 1. Лучший domrf-obj на каждый objective project: DISTINCT ON (project_name) +-- по nsim DESC, dsim DESC. +-- 2. Один obj_id ≤ один objective project: ROW_NUMBER per obj_id +-- ORDER BY corroborated DESC, nsim DESC. +-- 3. obj_id, уже занятые существующими 263 mapping, исключаются (NOT IN). +-- +-- ПОЧЕМУ ТРЕТИЙ ПРОХОД, А НЕ ПЕРЕДЕЛКА 150 +-- 150 уже применён на prod (_schema_migrations) — переписывать его нельзя +-- (auto-apply once). 155 — аддитивный: только INSERT новых имён, НИКАКИХ +-- DROP/UPDATE существующих 263 строк. +-- +-- GIN-ИНДЕКС +-- Существующий idx_domrf_kn_objects_comm_name_trgm — GIST partial WHERE is_ekb=true, +-- а ВСЕ 1543 строки latest-snapshot region_cd=66 имеют is_ekb=false (verified) → +-- индекс покрывает 0 строк, для backfill бесполезен. Добавляем НЕпартиальный +-- GIN trgm индекс (IF NOT EXISTS) на comm_name: он используется %-оператором +-- (verified EXPLAIN: Bitmap Index Scan) для будущих/runtime ad-hoc match-запросов. +-- Честно: САМ backfill ниже использует similarity()>порог в CTE-join (не %), +-- что НЕ триггерит trgm-индекс — но это one-shot 156×1543 Nested Loop (~4 s, +-- измерено на 150), приемлемо. Индекс добавлен под требование задачи и runtime, +-- не под эту миграцию. Таблица 9 MB / 10699 строк → обычный CREATE INDEX +-- (не CONCURRENTLY — нельзя в транзакции) строится мгновенно, lock тривиален. +-- +-- DEPENDENCIES: objective_lots, objective_complex_mapping, domrf_kn_objects, +-- pg_trgm (1.6, установлен), mv_layout_velocity (UNIQUE pk mv_layout_velocity_pk +-- на (obj_id, room_bucket) → CONCURRENTLY валиден). +-- DEPLOY: auto-applied by deploy.yml через _schema_migrations (ровно один раз по NN). +-- Применяется ДО compose up -d. ORDER: schema/data first → backend deploy second. +-- IDEMPOTENT: ON CONFLICT (objective_complex_name, objective_group) DO NOTHING + +-- CREATE INDEX IF NOT EXISTS. Re-apply добавит 0 строк (verified BEGIN/INSERT +-- x2/ROLLBACK на prod: INSERT 0 23, затем INSERT 0 0). + +BEGIN; + +-- Non-partial GIN trgm index for %-operator / runtime fuzzy match (см. шапку). +CREATE INDEX IF NOT EXISTS idx_domrf_kn_objects_comm_name_gin_trgm + ON domrf_kn_objects USING gin (comm_name gin_trgm_ops); + +WITH +-- 1. Latest DOM.РФ snapshot, регион 66 (Свердловская обл / ЕКБ-агломерация). +latest AS ( + SELECT max(snapshot_date) AS sd FROM domrf_kn_objects +), +domrf AS ( + SELECT DISTINCT ON (o.obj_id) + o.obj_id, + o.comm_name, + o.dev_name, + o.district_name, + -- \m…\M = PG word boundaries → токены матчатся как ЦЕЛЫЕ слова, не подстроки. + btrim(regexp_replace( + regexp_replace( + regexp_replace(lower(coalesce(o.comm_name, '')), + '\m(жилой комплекс|жилой квартал|жилой район|жилой дом|жилые башни|жилые кварталы|клубный дом|концепт-проект|семейный квартал|микрорайон|квартал|паркинг|жк|жр|жд|дом)\M', + ' ', 'g'), + '\d+(\.\d+)?\s*(этап|очередь)', ' ', 'g'), + '[«»"''.,()\-]+', ' ', 'g')) AS name_norm, + btrim(regexp_replace( + regexp_replace(lower(coalesce(o.dev_name, '')), + '(специализированный застройщик|сз|ооо|оао|зао|пао|ао|гк|группа|холдинг|ск|жск|фонд|девелопмент|девелопер[а-я]*|development|group|developm[a-z]*)', + ' ', 'g'), + '[«»"''.,()\-]+', ' ', 'g')) AS dev_norm + FROM domrf_kn_objects o + WHERE o.snapshot_date = (SELECT sd FROM latest) + AND o.region_cd = 66 + AND o.comm_name IS NOT NULL + ORDER BY o.obj_id +), +-- 2. Distinct Objective projects (один ряд на project_name; dominant dev/district), +-- исключая уже сматченные в group='Екатеринбург'. +obj AS ( + SELECT + l.project_name, + mode() WITHIN GROUP (ORDER BY l.developer) AS developer, + mode() WITHIN GROUP (ORDER BY l.district) AS district, + max(l.objective_project_id) AS objective_project_id + FROM objective_lots l + WHERE l.project_name IS NOT NULL + GROUP BY l.project_name +), +obj_unmapped AS ( + SELECT + o.*, + btrim(regexp_replace( + regexp_replace( + regexp_replace(lower(coalesce(o.project_name, '')), + '\m(жилой комплекс|жилой квартал|жилой район|жилой дом|жилые башни|жилые кварталы|клубный дом|концепт-проект|семейный квартал|микрорайон|квартал|паркинг|жк|жр|жд|дом)\M', + ' ', 'g'), + '\d+(\.\d+)?\s*(этап|очередь)', ' ', 'g'), + '[«»"''.,()\-]+', ' ', 'g')) AS name_norm, + btrim(regexp_replace( + regexp_replace(lower(coalesce(o.developer, '')), + '(специализированный застройщик|сз|ооо|оао|зао|пао|ао|гк|группа|холдинг|ск|жск|фонд|девелопмент|девелопер[а-я]*|development|group|developm[a-z]*)', + ' ', 'g'), + '[«»"''.,()\-]+', ' ', 'g')) AS dev_norm + FROM obj o + WHERE NOT EXISTS ( + SELECT 1 FROM objective_complex_mapping m + WHERE m.objective_complex_name = o.project_name + AND m.objective_group = 'Екатеринбург' + ) +), +-- 3. Best DOM.РФ candidate per objective project (нормализованный trigram). +pairs AS ( + SELECT + u.project_name, + u.developer AS obj_dev, + u.district AS obj_district, + u.objective_project_id, + -- normalized objective name, whitespace-collapsed (для generic-guard сравнения) + btrim(regexp_replace(u.name_norm, '\s+', ' ', 'g')) AS obj_nn, + d.obj_id, + d.comm_name, + d.dev_name AS domrf_dev, + d.district_name AS domrf_district, + similarity(u.name_norm, d.name_norm) AS nsim, + GREATEST( + similarity(u.dev_norm, d.dev_norm), + (char_length(btrim(u.dev_norm)) > 2 + AND char_length(btrim(d.dev_norm)) > 2 + AND (u.dev_norm ILIKE '%' || d.dev_norm || '%' + OR d.dev_norm ILIKE '%' || u.dev_norm || '%'))::int::double precision + ) AS dsim, + (u.district IS NOT NULL AND d.district_name IS NOT NULL + AND lower(btrim(u.district)) = lower(btrim(d.district_name)))::int AS district_match + FROM obj_unmapped u + JOIN domrf d + ON similarity(u.name_norm, d.name_norm) > 0.2 -- prefilter +), +best_per_proj AS ( + SELECT DISTINCT ON (project_name) * + FROM pairs + ORDER BY project_name, nsim DESC, dsim DESC, obj_id +), +classified AS ( + SELECT + b.*, + (b.dsim >= 0.5 OR b.district_match = 1) AS corroborated, + (b.obj_nn = ANY (ARRAY[ + 'солнечный','прекрасный','невский','стрелки','парковый','центральный', + 'тихий центр','самоцветы','южные','теплые','московский','сириус','нескучный сад' + ])) AS generic_guarded + FROM best_per_proj b +), +accepted AS ( + SELECT * + FROM ( + SELECT + c.*, + ROW_NUMBER() OVER ( + PARTITION BY c.obj_id + ORDER BY c.corroborated DESC, c.nsim DESC, c.project_name + ) AS rn_obj + FROM classified c + WHERE ( + (c.nsim >= 0.85 AND NOT c.generic_guarded) + OR (c.nsim >= 0.85 AND c.generic_guarded AND c.corroborated) + OR (c.nsim >= 0.62 AND c.nsim < 0.85 AND c.corroborated) + ) + AND c.obj_id NOT IN ( + SELECT domrf_obj_id FROM objective_complex_mapping + WHERE domrf_obj_id IS NOT NULL + ) + ) s + WHERE s.rn_obj = 1 +) +INSERT INTO objective_complex_mapping + (objective_complex_name, objective_project_id, objective_group, + domrf_obj_id, is_reviewed, match_method, match_score, note) +SELECT + a.project_name, + a.objective_project_id, + 'Екатеринбург', + a.obj_id, + FALSE, -- ВСЕ авто-матчи на ручной review (Anton) + 'auto_trgm_v3', + round(a.nsim::numeric, 3), + 'OBJ-2 conservative trgm backfill #307' + || ' | nsim=' || round(a.nsim::numeric, 3) + || ' dsim=' || round(a.dsim::numeric, 3) + || ' | domrf="' || coalesce(a.comm_name, '') || '"' + || ' dev_obj="' || coalesce(a.obj_dev, '') || '"' + || ' dev_domrf="' || coalesce(a.domrf_dev, '') || '"' +FROM accepted a +ON CONFLICT (objective_complex_name, objective_group) DO NOTHING; + +COMMIT; + +-- REFRESH ВНЕ транзакции (после COMMIT): CONCURRENTLY требует non-tx-блок и +-- UNIQUE index (mv_layout_velocity_pk на (obj_id, room_bucket) существует) и не +-- блокирует читателей. MV уже populated → CONCURRENTLY безопасен. deploy.yml +-- прогоняет файл через `psql < file` без --single-transaction, поэтому COMMIT +-- выше закрывает txn и этот стейтмент исполняется в собственной транзакции +-- (тот же приём, что в 150_objective_mapping_backfill.sql). +REFRESH MATERIALIZED VIEW CONCURRENTLY mv_layout_velocity; diff --git a/data/sql/156_objective_mapping_corroborated_v4.sql b/data/sql/156_objective_mapping_corroborated_v4.sql new file mode 100644 index 00000000..40486afa --- /dev/null +++ b/data/sql/156_objective_mapping_corroborated_v4.sql @@ -0,0 +1,270 @@ +-- 156_objective_mapping_corroborated_v4.sql +-- Issue #307 OBJ-2 — РАУНД-2: КОРРОБОРИРОВАННЫЙ fuzzy (pg_trgm) backfill of +-- objective_complex_mapping. Четвёртый проход (после 116/150/155). 155 (v3) был +-- НАМЕРЕННО строгим: только near-exact distinctive-name матчи (nsim=1.000, +23). +-- v4 безопасно добирает СРЕДНИЙ band (nsim 0.50-0.85), где имя одно не дотягивает +-- до уверенности, но КОРРОБОРАЦИЯ застройщиком/районом добивает precision до ≈1.0. +-- +-- CONTEXT +-- objective_complex_mapping связывает Objective-ЖК (objective_complex_name) → +-- domrf_kn_objects.obj_id. До этой миграции — 286 строк (group='Екатеринбург'; +-- match_method fuzzy/auto_fuzzy_v2/auto_trgm_v3/multi_feature_v1/manual; все +-- is_reviewed=false кроме 2 manual). Verified prod 2026-06-14: 286/286/286 +-- (total/ekb/with_obj). 155 применён (_schema_migrations). +-- +-- mv_layout_velocity JOIN-ит objective_corpus_room_month.project_name = +-- objective_complex_mapping.objective_complex_name WHERE objective_group= +-- 'Екатеринбург' AND domrf_obj_id IS NOT NULL. Unique-ключ матчинга — имя +-- (project_name), не project_id: новые строки с этим именем + group='Екатеринбург' +-- немедленно расширяют MV. Дедуп по project_name (как 150/155). +-- +-- НОРМАЛИЗАЦИЯ (идентична 150/155): strip ЖК-boilerplate ('Жилой комплекс/квартал/ +-- район/дом','Клубный дом','Микрорайон','Квартал','ЖК','Дом'…), кавычки, +-- stage-суффиксы ('N этап/очередь') ДО similarity. Strip-токены по word-boundary +-- (\m…\M), чтобы не резать середину ('Домино'→'ино'). Dev нормализуется (strip +-- юр.префиксов СЗ/ГК/ООО/Девелопмент/Development/Group…). +-- +-- КОРРОБОРАЦИЯ (РАСШИРЕНА vs 155): три независимых сигнала равенства застройщика — +-- dsim = trigram(norm dev) OR substring-containment (как 150/155) +-- dev_tok = ОБЩИЙ distinctive dev-токен длиной ≥4 после нормализации +-- dm = район Objective точно совпал с district_name DOM.РФ +-- corroborated = (dsim >= 0.6) OR dev_tok OR (dm = 1) +-- ПОЧЕМУ dev_tok: trigram/substring НЕ ловит аббревиатуру-vs-расшифровку. Реальный +-- кейс — «АСЦ Правобережный» (Objective) vs «Архитектурно-строительный центр +-- Правобережный» (DOM.РФ): dsim=0.326 (<0.6), substring fail, НО общий distinctive +-- токен 'правобережный' (len 12) → тот же застройщик. Задача явно зовёт +-- «Мкр. Центральный»→obj 47127 (АСЦ Правобережный, almost-certainly true); +-- dev_tok ловит его И структурно-идентичный «Мкр. Садовый»→obj 35542 через ОДНО +-- общее правило, а не hardcoded obj_id. (Калибровка prod read-only 2026-06-14: +-- dev_tok НЕ добавил ни одного FP в band'е — он только разрешает abbreviation +-- кейсы; все остальные 0.50-0.85 уже имели dsim=1.0 или district.) +-- +-- ПРАВИЛО ПРИЁМКИ v4 — PRECISION OVER RECALL (P0: ложный mapping напрямую портит +-- velocity/prices, держим precision≈1.0 на авто-наборе): +-- accept = nsim >= 0.50 AND nsim <= 0.85 AND corroborated +-- Band <0.50 НЕ берём (низкоконфидентный хвост). Band >0.85 здесь НЕ берём — +-- он покрыт 155 (distinctive) либо остаётся generic-guarded (см. ниже). Этот +-- проход — РОВНО средний band, где корроборация необходима и достаточна. +-- +-- GENERIC-GUARDED (6 имён из v3): «Солнечный»,«Прекрасный»,«Невский»,«Стрелки», +-- «Жилой квартал Парковый»(→'парковый'),«Тихий центр». В band'е 1.0 они дают +-- FP (одинаковое generic-имя, РАЗНЫЙ застройщик). Задача: принять ТОЛЬКО если +-- developer корроборирует. ПРОВЕРЕНО (prod 2026-06-14): у ВСЕХ шести dsim=0, +-- dev_tok=false, district НЕ совпал — застройщики реально РАЗНЫЕ (НКС vs Астра; +-- Вавилон vs PRINZIP; Синара vs Актив; Вавилон vs Аркада; СКОН vs Фортис; +-- Клевер vs Астра). Корроборации НЕТ → НИ ОДИН не принят. Guard держит; они +-- остаются на manual. (Дополнительно их nsim=1.000 вне band'а 0.50-0.85, так что +-- они и так не попали бы в accept — двойная защита.) +-- +-- COLLISION HANDLING +-- 1. Лучший domrf-obj на каждый objective project: DISTINCT ON (project_name) +-- по nsim DESC, dsim DESC, obj_id. +-- 2. Один obj_id ≤ один objective project: ROW_NUMBER per obj_id +-- ORDER BY corroborated DESC, nsim DESC, project_name. +-- 3. obj_id, уже занятые существующими 286 mapping (incl. 155), исключаются. +-- Реальные коллизии, отсечённые guard'ом (prod 2026-06-14): +-- • obj 58129 «Толк Академический» (уже в mapping) — на него целятся ТРИ +-- разных Objective-проекта «Близкий/Eleven/Школьный (Академический)» лишь +-- по district=Академический (dsim=0). Это РАЗНЫЕ дома в районе, не «Толк». +-- obj_id-taken-guard убирает все три. (Урок: district-only корроборация в +-- плотном районе шумит — но taken-guard + dedup её гасят.) +-- • obj 31874 «Рио» (уже в mapping, 150) — «Рио-3» это ФАЗА того же ЖК, +-- dsim=1.0 Стройтэк, но obj_id занят → исключён. +-- +-- РЕЗУЛЬТАТ (verified prod, BEGIN…ROLLBACK 2026-06-14, snapshot 2026-06-10): +-- 17 новых mapping (nsim 0.500-0.750, ВСЕ корроборированы dev/dev_tok/district, +-- ВСЕ obj_id свободны). is_reviewed=FALSE. После: 286 → 303. +-- Из 133 unmapped имён остаётся 116 на manual (Anton): +-- • 19 без кандидата в DOM.РФ (города-спутники / нет ПД) +-- • 6 generic-guarded near-exact (застройщик НЕ подтвердил) +-- • 4 in-band corroborated, но obj_id уже занят (Близкий/Eleven/Школьный→58129, +-- Рио-3→31874 — фазы/соседи уже-сматченных) +-- • 16 in-band БЕЗ корроборации (другой застройщик, район не совпал) +-- • 71 nsim<0.50 низкоконфидентный хвост +-- Честно: Objective шире DOM.РФ (сателлиты, проекты без объявленной ПД) — 100% +-- автоматом недостижимо by design; хвост — ручная разметка. +-- +-- ПОЧЕМУ ОТДЕЛЬНЫЙ ПРОХОД, А НЕ ПЕРЕДЕЛКА 155 +-- 150 и 155 уже на prod (_schema_migrations, auto-apply once) — переписывать +-- нельзя. 156 — аддитивный: только INSERT новых имён, НИКАКИХ DROP/UPDATE +-- существующих 286 строк. +-- +-- DEPENDENCIES: objective_lots, objective_complex_mapping, domrf_kn_objects, +-- pg_trgm (1.6), mv_layout_velocity (UNIQUE pk mv_layout_velocity_pk на +-- (obj_id, room_bucket) → REFRESH CONCURRENTLY валиден). GIN trgm индекс на +-- domrf_kn_objects.comm_name создан в 155 (idx_domrf_kn_objects_comm_name_gin_trgm). +-- Сам backfill использует similarity()>порог в CTE-join (не %-оператор), что НЕ +-- триггерит trgm-индекс — но это one-shot Nested Loop (~4 s, как 150/155), +-- приемлемо. +-- DEPLOY: auto-applied by deploy.yml через _schema_migrations (ровно один раз по NN). +-- Применяется ДО compose up -d. ORDER: schema/data first → backend deploy second. +-- IDEMPOTENT: ON CONFLICT (objective_complex_name, objective_group) DO NOTHING. +-- Re-apply добавит 0 строк (verified BEGIN/INSERT x2/ROLLBACK на prod: +-- INSERT 0 17, затем INSERT 0 0). + +BEGIN; + +WITH +-- 1. Latest DOM.РФ snapshot, регион 66 (Свердловская обл / ЕКБ-агломерация). +latest AS ( + SELECT max(snapshot_date) AS sd FROM domrf_kn_objects +), +domrf AS ( + SELECT DISTINCT ON (o.obj_id) + o.obj_id, + o.comm_name, + o.dev_name, + o.district_name, + -- \m…\M = PG word boundaries → токены матчатся как ЦЕЛЫЕ слова, не подстроки. + btrim(regexp_replace( + regexp_replace( + regexp_replace(lower(coalesce(o.comm_name, '')), + '\m(жилой комплекс|жилой квартал|жилой район|жилой дом|жилые башни|жилые кварталы|клубный дом|концепт-проект|семейный квартал|микрорайон|квартал|паркинг|жк|жр|жд|дом)\M', + ' ', 'g'), + '\d+(\.\d+)?\s*(этап|очередь)', ' ', 'g'), + '[«»"''.,()\-]+', ' ', 'g')) AS name_norm, + btrim(regexp_replace( + regexp_replace(lower(coalesce(o.dev_name, '')), + '(специализированный застройщик|сз|ооо|оао|зао|пао|ао|гк|группа|холдинг|ск|жск|фонд|девелопмент|девелопер[а-я]*|development|group|developm[a-z]*)', + ' ', 'g'), + '[«»"''.,()\-]+', ' ', 'g')) AS dev_norm + FROM domrf_kn_objects o + WHERE o.snapshot_date = (SELECT sd FROM latest) + AND o.region_cd = 66 + AND o.comm_name IS NOT NULL + ORDER BY o.obj_id +), +-- 2. Distinct Objective projects (один ряд на project_name; dominant dev/district), +-- исключая уже сматченные в group='Екатеринбург'. +obj AS ( + SELECT + l.project_name, + mode() WITHIN GROUP (ORDER BY l.developer) AS developer, + mode() WITHIN GROUP (ORDER BY l.district) AS district, + max(l.objective_project_id) AS objective_project_id + FROM objective_lots l + WHERE l.project_name IS NOT NULL + GROUP BY l.project_name +), +obj_unmapped AS ( + SELECT + o.*, + btrim(regexp_replace( + regexp_replace( + regexp_replace(lower(coalesce(o.project_name, '')), + '\m(жилой комплекс|жилой квартал|жилой район|жилой дом|жилые башни|жилые кварталы|клубный дом|концепт-проект|семейный квартал|микрорайон|квартал|паркинг|жк|жр|жд|дом)\M', + ' ', 'g'), + '\d+(\.\d+)?\s*(этап|очередь)', ' ', 'g'), + '[«»"''.,()\-]+', ' ', 'g')) AS name_norm, + btrim(regexp_replace( + regexp_replace(lower(coalesce(o.developer, '')), + '(специализированный застройщик|сз|ооо|оао|зао|пао|ао|гк|группа|холдинг|ск|жск|фонд|девелопмент|девелопер[а-я]*|development|group|developm[a-z]*)', + ' ', 'g'), + '[«»"''.,()\-]+', ' ', 'g')) AS dev_norm + FROM obj o + WHERE NOT EXISTS ( + SELECT 1 FROM objective_complex_mapping m + WHERE m.objective_complex_name = o.project_name + AND m.objective_group = 'Екатеринбург' + ) +), +-- 3. Best DOM.РФ candidate per objective project (нормализованный trigram). +pairs AS ( + SELECT + u.project_name, + u.developer AS obj_dev, + u.district AS obj_district, + u.objective_project_id, + d.obj_id, + d.comm_name, + d.dev_name AS domrf_dev, + d.district_name AS domrf_district, + similarity(u.name_norm, d.name_norm) AS nsim, + GREATEST( + similarity(u.dev_norm, d.dev_norm), + (char_length(btrim(u.dev_norm)) > 2 + AND char_length(btrim(d.dev_norm)) > 2 + AND (u.dev_norm ILIKE '%' || d.dev_norm || '%' + OR d.dev_norm ILIKE '%' || u.dev_norm || '%'))::int::double precision + ) AS dsim, + -- ОБЩИЙ distinctive dev-токен (len>=4): ловит аббревиатуру-vs-расшифровку + -- (АСЦ Правобережный ↔ Архитектурно-строительный центр Правобережный). + EXISTS ( + SELECT 1 FROM ( + SELECT unnest(regexp_split_to_array(btrim(regexp_replace(u.dev_norm, '\s+', ' ', 'g')), ' ')) AS t + INTERSECT + SELECT unnest(regexp_split_to_array(btrim(regexp_replace(d.dev_norm, '\s+', ' ', 'g')), ' ')) AS t + ) x + WHERE char_length(t) >= 4 + ) AS dev_tok_shared, + (u.district IS NOT NULL AND d.district_name IS NOT NULL + AND lower(btrim(u.district)) = lower(btrim(d.district_name)))::int AS district_match + FROM obj_unmapped u + JOIN domrf d + ON similarity(u.name_norm, d.name_norm) > 0.2 -- prefilter +), +best_per_proj AS ( + SELECT DISTINCT ON (project_name) * + FROM pairs + ORDER BY project_name, nsim DESC, dsim DESC, obj_id +), +classified AS ( + SELECT + b.*, + (b.dsim >= 0.6 OR b.dev_tok_shared OR b.district_match = 1) AS corroborated + FROM best_per_proj b +), +accepted AS ( + SELECT * + FROM ( + SELECT + c.*, + ROW_NUMBER() OVER ( + PARTITION BY c.obj_id + ORDER BY c.corroborated DESC, c.nsim DESC, c.project_name + ) AS rn_obj + FROM classified c + WHERE c.nsim >= 0.50 AND c.nsim <= 0.85 -- ровно средний band + AND c.corroborated -- обязательная корроборация + AND c.obj_id NOT IN ( + SELECT domrf_obj_id FROM objective_complex_mapping + WHERE domrf_obj_id IS NOT NULL + ) + ) s + WHERE s.rn_obj = 1 +) +INSERT INTO objective_complex_mapping + (objective_complex_name, objective_project_id, objective_group, + domrf_obj_id, is_reviewed, match_method, match_score, note) +SELECT + a.project_name, + a.objective_project_id, + 'Екатеринбург', + a.obj_id, + FALSE, -- ВСЕ авто-матчи на ручной review (Anton) + 'auto_trgm_corroborated_v4', + round(a.nsim::numeric, 3), + 'OBJ-2 corroborated trgm backfill #307 (round-2)' + || ' | nsim=' || round(a.nsim::numeric, 3) + || ' dsim=' || round(a.dsim::numeric, 3) + || ' corrob=' || CASE + WHEN a.dsim >= 0.6 THEN 'dev' + WHEN a.dev_tok_shared THEN 'dev_token' + WHEN a.district_match = 1 THEN 'district' + ELSE '?' + END + || ' | domrf="' || coalesce(a.comm_name, '') || '"' + || ' dev_obj="' || coalesce(a.obj_dev, '') || '"' + || ' dev_domrf="' || coalesce(a.domrf_dev, '') || '"' +FROM accepted a +ON CONFLICT (objective_complex_name, objective_group) DO NOTHING; + +COMMIT; + +-- REFRESH ВНЕ транзакции (после COMMIT): CONCURRENTLY требует non-tx-блок и +-- UNIQUE index (mv_layout_velocity_pk на (obj_id, room_bucket) существует), не +-- блокирует читателей. MV уже populated → CONCURRENTLY безопасен. deploy.yml +-- прогоняет файл через `psql < file` без --single-transaction, поэтому COMMIT +-- выше закрывает txn и этот стейтмент исполняется в собственной транзакции +-- (тот же приём, что в 150/155). +REFRESH MATERIALIZED VIEW CONCURRENTLY mv_layout_velocity; diff --git a/data/sql/157_analyze_hotpath_indexes.sql b/data/sql/157_analyze_hotpath_indexes.sql new file mode 100644 index 00000000..a2e5eb39 --- /dev/null +++ b/data/sql/157_analyze_hotpath_indexes.sql @@ -0,0 +1,122 @@ +-- 157_analyze_hotpath_indexes.sql +-- Perf/index аудит горячего analyze-пути Site Finder (POST /parcels/{cad}/analyze). +-- Прецедент: migration 153 (#70) — partial covering index objective_lots(district, +-- price_per_m2_rub) → cold 5.3s→87ms Index Only Scan. Этот файл — аналогичные wins, +-- найденные EXPLAIN (ANALYZE, BUFFERS) на проде (2026-06-14, district='Академический', +-- centroid POINT(60.4869 56.7992), objective_lots ~1.12M / 2.1 ГБ, cad_buildings 38k / 88 МБ). +-- +-- КОРНЕВАЯ ПРИЧИНА (3 из 4 индексов): запросы фильтруют по `X.geom::geography` для +-- точного метрового ST_DWithin (radius в метрах). Существующие GIST построены на ПЛОСКОМ +-- `geom` (geometry, SRID 4326) → планировщик НЕ может ими воспользоваться для +-- geography-предиката → Seq Scan всей таблицы на КАЖДЫЙ analyze. Функциональный GIST на +-- выражении `(geom::geography)` закрывает именно этот предикат (Index Cond через `&&` +-- + _st_expand). Плоские geom-GIST НЕ трогаем — они обслуживают ST_Intersects(geom,…) +-- (geometry) ветки (например overlap_rows в _neighbors_summary) и остаются нужны. +-- +-- ИЗМЕРЕНИЯ (EXPLAIN ANALYZE до/после; «после» — warm 2-й прогон с индексом в BEGIN…ROLLBACK): +-- +-- 1) osm_poi_ekb (POI в 1км, parcels.py:1488 «сек.3», 1× на analyze): +-- ДО: Seq Scan, Rows Removed by Filter 4824, 538 buf, ~36 ms +-- ПОСЛЕ: Index Scan на (geom::geography), 12 buf, ~0.2 ms (≈190x) +-- Индекс ~384 кБ, build 28 мс. +-- +-- 2) osm_noise_sources_ekb (шум/гидрология/инж.сети, parcels.py:1773/1840/1889, 4× на analyze): +-- ДО: Parallel Seq Scan, Rows Removed 5122, 1701 buf, ~92 ms КАЖДЫЙ +-- ПОСЛЕ: Index Scan на (geom::geography), 176 buf, ~3.8 ms (≈24x) +-- Индекс ~816 кБ, build 55 мс. Кумулятивно ~4×88 ≈ 350 мс с одного analyze. +-- NB: geotech-запрос (parcels.py:779, source_type='industrial') уже идёт по +-- osm_noise_sources_ekb_source_idx (селективный source_type) — этот индекс его не +-- ухудшает (планировщик выберет дешевле из двух). +-- +-- 3) cad_buildings (_neighbors_summary соседи в 100м, parcels.py:612, 1× на analyze): +-- ДО: Parallel Seq Scan, Rows Removed 12670, 11235 buf, ~314 ms +-- ПОСЛЕ: Index Scan на (geom::geography), 18 buf, ~0.3 ms (≈1000x) +-- Индекс ~2.9 МБ, build 338 мс. САМЫЙ крупный единичный выигрыш analyze-пути. +-- overlap_rows CTE (ST_Intersects(b.geom, wkt), geometry) по-прежнему обслуживается +-- существующим cad_buildings_geom_gist — без регрессии. +-- +-- 4) objective_lots covering (obj_pricing CTE в competitor-блоке, parcels.py:1614, 1× на analyze): +-- Запрос агрегирует AVG(price)/AVG(area)/COUNT(is_sold) по ol.project_name = +-- cm.objective_complex_name (objective_complex_mapping, 286 строк). +-- ДО: Parallel Seq Scan ВСЕХ objective_lots, 215761 buf (~1.7 ГБ), Hash Join, +-- warm ~493 ms (cold ~681 ms). +-- ПОСЛЕ: Nested Loop + Index Only Scan на (project_name) INCLUDE(price,area,is_sold), +-- 286 проб по имени, 16586 buf, warm ~355 ms (≈1.4x, −13x buffers). +-- Индекс ~74 МБ, build 1.75 c. Существующий objective_lots_project_idx +-- (project_name, snapshot_date DESC) НЕ покрывает payload-колонки → не давал +-- Index Only Scan; этот — covering, поэтому не дубль. +-- ОГРАНИЧЕНИЕ: запрос всё равно агрегирует ВСЕ лоты ВСЕХ маппингов (не только +-- ~245 конкурентов в 3км). Полноценный фикс — переписать obj_pricing с +-- пушдауном domrf_obj_id IN (nearby) — это backend-engineer (правка SQL в +-- parcels.py), вне scope index-аудита. Индекс — выигрыш «здесь и сейчас» без +-- правки кода. Heap Fetches ~64k остаются (visibility map тёплый после VACUUM; +-- objective_lots dead-tup 1.25% — well-vacuumed). +-- +-- ЧТО НЕ ДОБАВЛЕНО (доказанно не нужно): +-- • rosreestr_deals (market_trend, parcels.py:2211): warm ~254 ms, уже partition-pruned +-- (5 subplans removed) + per-partition BitmapAnd по 3 индексам. Композитный +-- (region_code,doc_type,realestate_type_code,period_start_date) пришлось бы строить на +-- ~20+ партициях, каждая ACCESS EXCLUSIVE на ПИШУЩЕЙСЯ таблице → высокий lock-риск, +-- несоразмерный ~2x-выигрышу. Партиционированную таблицу под индекс-аудит не трогаем. +-- • pipeline-блок (parcels.py:1663): warm ~37 мс, корр. подзапрос уже идёт по +-- objective_lots_project_idx (Bitmap Index Scan). Индекс не нужен. +-- • ekburg_construction_permits (LEFT(cadastral_number,…) prefix): уже покрыт +-- idx_ekburg_permits_cad_cpat (text_pattern_ops). +-- • geom-resolution UNION (parcels.py:1334): 15 мс, всё по PK index scan. +-- • district_price_block (parcels.py:2123): уже покрыт idx_objective_lots_district_price (153). +-- +-- LOCK-ПРОФИЛЬ: обычный (НЕ CONCURRENTLY) CREATE INDEX берёт SHARE на таблицу — блокирует +-- ТОЛЬКО writes (loaders/objective_etl/NSPD on-demand fetch), читателей analyze НЕ +-- блокирует. CONCURRENTLY здесь невозможен: deploy.yml гоняет файл в транзакции +-- (BEGIN/COMMIT), а CREATE INDEX CONCURRENTLY в tx запрещён. Суммарный build ~2.2 c +-- (74 МБ objective_lots — доминирует, как и в 153 на той же таблице). Приемлемо в +-- deploy-окне (тот же класс риска, что 153/87/97). Не запускать одновременно с тяжёлым ETL. +-- +-- Dependencies: osm_poi_ekb, osm_noise_sources_ekb, cad_buildings, objective_lots, +-- objective_complex_mapping (все существуют). PostgreSQL 16.4 + PostGIS 3.4. +-- Idempotent: CREATE INDEX IF NOT EXISTS (re-apply безопасен). +-- Applied: автоматически через deploy.yml в NN-порядке (трекер _schema_migrations). +-- Backend code change НЕ требуется — индексы прозрачны для существующих запросов. + +BEGIN; + +-- 1) POI в 1км (analyze сек.3) — geography-предикат ST_DWithin(geom::geography,…,1000). +CREATE INDEX IF NOT EXISTS idx_osm_poi_ekb_geom_geog + ON osm_poi_ekb USING gist ((geom::geography)); + +COMMENT ON INDEX idx_osm_poi_ekb_geom_geog IS + 'Geography GIST для analyze POI-блока (#157): ST_DWithin(geom::geography,…,1000м). ' + 'Плоский osm_poi_ekb_geom_gist не обслуживает geography-cast → был Seq Scan ~36мс, ' + 'стал Index Scan ~0.2мс. См. parcels.py:1488.'; + +-- 2) Шумовые источники в 2км (analyze сек.7/9c/9d) — geography ST_DWithin, 4× на analyze. +CREATE INDEX IF NOT EXISTS idx_osm_noise_sources_ekb_geom_geog + ON osm_noise_sources_ekb USING gist ((geom::geography)); + +COMMENT ON INDEX idx_osm_noise_sources_ekb_geom_geog IS + 'Geography GIST для analyze noise/hydro/utility-блоков (#157): ' + 'ST_DWithin(geom::geography,…,2000м), 4 запроса на analyze. Был Parallel Seq Scan ' + '~92мс каждый, стал Index Scan ~3.8мс. См. parcels.py:1773/1840/1889.'; + +-- 3) Соседние здания в 100м (_neighbors_summary) — geography ST_DWithin на 88МБ-таблице. +CREATE INDEX IF NOT EXISTS idx_cad_buildings_geom_geog + ON cad_buildings USING gist ((geom::geography)); + +COMMENT ON INDEX idx_cad_buildings_geom_geog IS + 'Geography GIST для analyze _neighbors_summary (#157): ST_DWithin(geom::geography,…,100м). ' + 'Плоский cad_buildings_geom_gist обслуживает ST_Intersects(geom,…) (overlap_rows), но не ' + 'geography-cast → был Parallel Seq Scan ~314мс, стал Index Scan ~0.3мс. См. parcels.py:612.'; + +-- 4) Covering-index для obj_pricing competitor-агрегации (analyze сек.5). +CREATE INDEX IF NOT EXISTS idx_objective_lots_pname_pricing + ON objective_lots (project_name) + INCLUDE (price_per_m2_rub, area_pd, is_sold); + +COMMENT ON INDEX idx_objective_lots_pname_pricing IS + 'Covering-index для analyze competitor obj_pricing (#157): агрегаты по ' + 'project_name = objective_complex_name через Index Only Scan (286 проб), без ' + 'Parallel Seq Scan ~1.7ГБ. Существующий objective_lots_project_idx без payload не ' + 'давал Index Only Scan. Long-term: пушдаун domrf_obj_id IN(nearby) в SQL (backend). ' + 'См. parcels.py:1614.'; + +COMMIT; diff --git a/frontend/src/lib/api-types.ts b/frontend/src/lib/api-types.ts index 9a177f01..b9020492 100644 --- a/frontend/src/lib/api-types.ts +++ b/frontend/src/lib/api-types.ts @@ -409,6 +409,36 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/parcels/{cad_num}/competitors-parking": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Get Parcel Competitors Parking + * @description parking_ratio top-N конкурентов (#96 wiring) — ЛЕНИВЫЙ путь, вне analyze. + * + * Резолвит top-3 конкурента (relevance_weight DESC) domrf→cad_buildings через + * PostGIS geom-match, затем on-demand НСПД premises-lookup → parking_ratio. + * Намеренно ОТДЕЛЬНЫЙ от analyze/forecast: N×НСПД (~0.6-2с каждый) добавил бы + * секунды в хот-путь и убил p95 — фронт дёргает этот эндпоинт лениво после + * отрисовки списка конкурентов. + * + * sync `def` (не async): premises_lookup изолирует НСПД-async через + * asyncio.run() — его нельзя вызвать из async-handler (running loop). FastAPI + * исполняет sync-handler в threadpool, не блокируя event loop. + */ + post: operations["get_parcel_competitors_parking_api_v1_parcels__cad_num__competitors_parking_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/parcels/{cad_num}/best-layouts": { parameters: { query?: never; @@ -3040,6 +3070,8 @@ export interface components { velocity_per_month: number; /** Avg Price Per M2 */ avg_price_per_m2: number | null; + /** Price Source */ + price_source?: string | null; /** Is Active */ is_active: boolean; /** Relevance Weight */ @@ -3049,6 +3081,44 @@ export interface components { [key: string]: number; } | null; }; + /** + * CompetitorParking + * @description parking_ratio одного конкурента (#96). obj_id связывает с Competitor. + * + * matched_cad_num — здание cad_buildings, на которое сматчился domrf-центроид + * (None если geom-match не нашёл здание в радиусе). parking_ratio / + * parking_count / flats_count — из НСПД tab-group (None если здание не + * сматчилось ИЛИ НСПД недоступен — graceful, НЕ нулевой паркинг). + * match_distance_m — расстояние domrf-центроид → footprint (диагностика). + */ + CompetitorParking: { + /** Obj Id */ + obj_id: number; + /** Matched Cad Num */ + matched_cad_num?: string | null; + /** Match Distance M */ + match_distance_m?: number | null; + /** Parking Ratio */ + parking_ratio?: number | null; + /** Parking Count */ + parking_count?: number | null; + /** Flats Count */ + flats_count?: number | null; + }; + /** + * CompetitorsParkingResponse + * @description Ответ ленивого /{cad}/competitors-parking (#96). + * + * items — по одному CompetitorParking на top-N конкурентов (по relevance_weight + * DESC из get_competitors). matched_count — сколько реально получили + * parking_ratio (диагностика покрытия для фронта / мониторинга). + */ + CompetitorsParkingResponse: { + /** Items */ + items: components["schemas"]["CompetitorParking"][]; + /** Matched Count */ + matched_count: number; + }; /** CompetitorsRequest */ CompetitorsRequest: { /** @@ -5052,6 +5122,41 @@ export interface operations { }; }; }; + get_parcel_competitors_parking_api_v1_parcels__cad_num__competitors_parking_post: { + parameters: { + query?: never; + header?: never; + path: { + cad_num: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CompetitorsRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CompetitorsParkingResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; get_parcel_best_layouts_api_v1_parcels__cad_num__best_layouts_post: { parameters: { query?: never; diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index 3f40e9cf..595b68b0 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -1470,8 +1470,9 @@ async def scrape_yandex_newbuilding( URL: /{city}/kupit/novostrojka/-/ """ - async with YandexNewbuildingScraper() as scraper: - result = await scraper.fetch_jk(jk_slug=slug, jk_id=id, city=city) + # fetch_jk использует внутренний BrowserFetcher, httpx-клиент BaseScraper не нужен + scraper = YandexNewbuildingScraper() + result = await scraper.fetch_jk(jk_slug=slug, jk_id=id, city=city) if result is None: raise HTTPException(404, f"Could not parse Yandex JK: {slug}-{id} in {city}") return YandexNewbuildingTriggerResp( @@ -1488,6 +1489,91 @@ async def scrape_yandex_newbuilding( ) +class YandexNewbuildingSweepStartRequest(BaseModel): + limit: int = Field(default=5, ge=1, le=200) + request_delay_sec: float = Field(default=8.0, ge=3.0, le=30.0) + force: bool = False + city: str = Field(default="ekaterinburg", min_length=1, max_length=64) + + +class YandexNewbuildingSweepStartResponse(BaseModel): + run_id: int + status: str + limit: int + detail: str + + +@router.post("/scrape/yandex-newbuilding-sweep", response_model=YandexNewbuildingSweepStartResponse) +async def start_yandex_newbuilding_sweep( + payload: YandexNewbuildingSweepStartRequest, + background_tasks: BackgroundTasks, + db: Annotated[Session, Depends(get_db)], +) -> YandexNewbuildingSweepStartResponse: + """Запустить Yandex newbuilding enrichment sweep в background (#974). Returns run_id. + + Один прогон: SELECT pending yandex_realty_nb houses → resolve slug (если NULL) → + fetch_jk через BrowserFetcher → UPSERT market.yandex_jk_enrichment. + + Shipped DORMANT (scheduler seed enabled=false). Этот endpoint — для ручного запуска. + Coop cancel через DELETE scrape_runs или cancel endpoint. + """ + from app.tasks.yandex_newbuilding_sweep import enrich_yandex_newbuilding_sweep + + run_id = runs_mod.create_run(db, source="yandex_newbuilding_sweep", params=payload.model_dump()) + + async def _sweep_task() -> None: + sweep_db = SessionLocal() + try: + result = await enrich_yandex_newbuilding_sweep( + sweep_db, + limit=payload.limit, + force=payload.force, + request_delay_sec=payload.request_delay_sec, + city=payload.city, + ) + runs_mod.mark_done(sweep_db, run_id, result.to_dict()) + except Exception: + logger.exception("yandex-nb-sweep background task run_id=%d crashed", run_id) + try: + runs_mod.mark_failed(sweep_db, run_id, "crashed", {}) + except Exception: + pass + finally: + sweep_db.close() + + background_tasks.add_task(_sweep_task) + logger.info("yandex-nb-sweep queued run_id=%d params=%s", run_id, payload.model_dump()) + return YandexNewbuildingSweepStartResponse( + run_id=run_id, + status="running", + limit=payload.limit, + detail=f"yandex_newbuilding_sweep started: limit={payload.limit} city={payload.city}", + ) + + +@router.get("/scrape/yandex-newbuilding-sweep/runs", response_model=list[ScrapeRunRow]) +def list_yandex_newbuilding_sweep_runs( + db: Annotated[Session, Depends(get_db)], + limit: int = 10, +) -> list[ScrapeRunRow]: + """Список последних N yandex newbuilding sweep runs (для UI polling). Default limit=10.""" + rows = runs_mod.list_recent(db, source="yandex_newbuilding_sweep", limit=limit) + return [ + ScrapeRunRow( + run_id=r["run_id"], + source=r["source"], + status=r["status"], + params=r.get("params"), + counters=r.get("counters"), + error=r.get("error"), + started_at=r["started_at"].isoformat() if r.get("started_at") else None, + finished_at=r["finished_at"].isoformat() if r.get("finished_at") else None, + heartbeat_at=r["heartbeat_at"].isoformat() if r.get("heartbeat_at") else None, + ) + for r in rows + ] + + class YandexValuationTriggerResp(BaseModel): ok: bool address: str diff --git a/tradein-mvp/backend/app/services/scheduler.py b/tradein-mvp/backend/app/services/scheduler.py index 63307da5..038ad9ce 100644 --- a/tradein-mvp/backend/app/services/scheduler.py +++ b/tradein-mvp/backend/app/services/scheduler.py @@ -45,6 +45,12 @@ Sources: Idempotent: skips houses already enriched, per-house SAVEPOINT, so each fire drains the next `limit` pending houses; window 00:00-01:00 UTC = 03:00-04:00 МСК, before the 01:00+ UTC sweep block) + - yandex_newbuilding_sweep → enrich_yandex_newbuilding_sweep + (tasks/yandex_newbuilding_sweep.py, #974; enrichment ЖК в + market.yandex_jk_enrichment через BrowserFetcher; shipped DORMANT — + enable manually after deploy verified; window 02:00-05:00 UTC) + - cian_city_sweep → run_cian_city_sweep (newbuilding Phase 4, #973; shipped DORMANT — + proxy dead, enable manually after proxy restored) """ from __future__ import annotations @@ -63,6 +69,7 @@ from app.core.db import SessionLocal from app.services import scrape_runs as runs_mod from app.services.scrape_pipeline import ( run_avito_city_sweep, + run_cian_city_sweep, run_n1_city_sweep, run_yandex_city_sweep, ) @@ -407,6 +414,41 @@ async def trigger_cian_backfill_run(db: Session, schedule_row: dict[str, Any]) - return run_id +async def trigger_cian_city_sweep_run(db: Session, schedule_row: dict[str, Any]) -> int | None: + """Создать scrape_runs + launch run_cian_city_sweep в asyncio.create_task (#973). + + Несёт Phase 4 newbuilding-enrichment (enrich_houses). Shipped DORMANT — seed + enabled=false (107), включается оператором вручную после восстановления прокси. + Returns run_id (или None если skip — есть running run). + """ + run_id = _claim_run(db, schedule_row) + if run_id is None: + return None + params = schedule_row.get("default_params") or {} + + async def _run() -> None: + run_db = SessionLocal() + try: + await run_cian_city_sweep( + run_db, + run_id=run_id, + pages_per_anchor=int(params.get("pages_per_anchor", 3)), + request_delay_sec=float(params.get("request_delay_sec", 5.0)), + radius_m=int(params.get("radius_m", 1500)), + detail_top_n=int(params.get("detail_top_n", 10)), + enrich_houses=bool(params.get("enrich_houses", True)), + ) + except Exception: + logger.exception("scheduler: run_cian_city_sweep crashed run_id=%d", run_id) + finally: + run_db.close() + + task = asyncio.create_task(_run()) + task.add_done_callback(lambda t: t.exception() if not t.cancelled() else None) + logger.info("scheduler: triggered cian_city_sweep run_id=%d", run_id) + return run_id + + async def _execute_cian_backfill( db: Session, *, @@ -459,8 +501,7 @@ async def _execute_cian_backfill( } runs_mod.mark_done(db, run_id, counters) logger.info( - "scheduler: cian_history_backfill run_id=%d done — " - "listings=%d/%d houses=%d/%d %.1fs", + "scheduler: cian_history_backfill run_id=%d done — listings=%d/%d houses=%d/%d %.1fs", run_id, result.listings_succeeded, result.listings_total, @@ -769,6 +810,55 @@ async def trigger_newbuilding_enrich_run(db: Session, schedule_row: dict[str, An return run_id +async def trigger_yandex_newbuilding_sweep_run( + db: Session, schedule_row: dict[str, Any] +) -> int | None: + """Создать scrape_runs + launch enrich_yandex_newbuilding_sweep в asyncio.create_task (#974). + + Nightly enrichment sweep: SELECT pending yandex_realty_nb houses → resolve slug + (BrowserFetcher SERP) → fetch_jk (BrowserFetcher) → UPSERT market.yandex_jk_enrichment. + + Shipped DORMANT (seed 106, enabled=false). Включается оператором вручную: + UPDATE scrape_schedules SET enabled = true WHERE source = 'yandex_newbuilding_sweep'; + + Mirrors trigger_newbuilding_enrich_run: claim run → create_task → mark_done/failed + делегируется tasks/yandex_newbuilding_sweep.enrich_yandex_newbuilding_sweep. + + Returns run_id или None (skip — already running). + """ + run_id = _claim_run(db, schedule_row) + if run_id is None: + return None + + params = schedule_row.get("default_params") or {} + + async def _run() -> None: + run_db = SessionLocal() + try: + from app.tasks.yandex_newbuilding_sweep import enrich_yandex_newbuilding_sweep + + result = await enrich_yandex_newbuilding_sweep( + run_db, + limit=int(params.get("limit", 5)), + request_delay_sec=float(params.get("request_delay_sec", 8.0)), + city=str(params.get("city", "ekaterinburg")), + ) + runs_mod.mark_done(run_db, run_id, result.to_dict()) + except Exception: + logger.exception("scheduler: enrich_yandex_newbuilding_sweep crashed run_id=%d", run_id) + try: + runs_mod.mark_failed(run_db, run_id, "crashed", {}) + except Exception: + pass + finally: + run_db.close() + + task = asyncio.create_task(_run()) + task.add_done_callback(lambda t: t.exception() if not t.cancelled() else None) + logger.info("scheduler: triggered yandex_newbuilding_sweep run_id=%d", run_id) + return run_id + + async def trigger_asking_to_sold_ratio_run(db: Session, schedule_row: dict[str, Any]) -> int | None: """Создать scrape_runs + launch recompute_asking_to_sold_ratios в executor (sync DB-only task). @@ -1079,6 +1169,8 @@ async def scheduler_loop() -> None: await trigger_n1_city_sweep_run(db, sch) elif source == "cian_history_backfill": await trigger_cian_backfill_run(db, sch) + elif source == "cian_city_sweep": + await trigger_cian_city_sweep_run(db, sch) elif source == "rosreestr_dkp_import": await trigger_rosreestr_dkp_run(db, sch) elif source == "listing_source_snapshot": @@ -1097,6 +1189,8 @@ async def scheduler_loop() -> None: await trigger_rosreestr_quarter_poll_run(db, sch) elif source == "newbuilding_enrich": await trigger_newbuilding_enrich_run(db, sch) + elif source == "yandex_newbuilding_sweep": + await trigger_yandex_newbuilding_sweep_run(db, sch) else: logger.warning("scheduler: unknown source=%s, skip", source) finally: diff --git a/tradein-mvp/backend/app/services/scrapers/cian_newbuilding.py b/tradein-mvp/backend/app/services/scrapers/cian_newbuilding.py index bca86175..9ce09f20 100644 --- a/tradein-mvp/backend/app/services/scrapers/cian_newbuilding.py +++ b/tradein-mvp/backend/app/services/scrapers/cian_newbuilding.py @@ -25,6 +25,7 @@ from typing import Any from curl_cffi.requests import AsyncSession # type: ignore[import-untyped] from app.core.config import settings +from app.services.scrapers.browser_fetcher import BrowserFetcher from app.services.scrapers.cian_state_parser import extract_all_states, extract_state logger = logging.getLogger(__name__) @@ -120,116 +121,98 @@ class NewbuildingEnrichment: async def fetch_newbuilding( zhk_url: str, *, - session: AsyncSession | None = None, + session: AsyncSession | None = None, # kept for backward-compat; unused for page fetch ) -> NewbuildingEnrichment | None: """Fetch ЖК catalog page и extract все containers. + HTML страница ЖК теперь тянется через BrowserFetcher (camoufox), потому что + curl_cffi получает страницу без initialState под анти-ботом Cian (#972). + tradein-browser верифицирован живым: 1.17 MB, initialState присутствует. + Args: zhk_url: e.g. 'https://zhk-ekaterininskiy-park-ekb-i.cian.ru/' - session: optional shared curl_cffi session (caller owns lifecycle) + session: зарезервирован для обратной совместимости с вызывающими; + больше не используется для fetch страницы ЖК. Resolve-функции + (resolve_cian_zhk_url_via_search) по-прежнему используют curl_cffi. Returns: NewbuildingEnrichment, or None если fetch / parse failed. """ - close_session = False - if session is None: - # Mobile proxy wiring (#806 follow-up): Cian ЖК страница — datacenter-IP бан. - # proxy=None → прямое подключение (dev без прокси). - _proxy_url = settings.cian_proxy_url - _proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None - session = AsyncSession( - impersonate="chrome120", - timeout=30.0, - proxies=_proxies, - headers={ - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", - "Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8", - }, - ) - close_session = True + async with BrowserFetcher() as browser: + html = await browser.fetch(zhk_url) - try: - resp = await session.get(zhk_url, allow_redirects=True) - if resp.status_code != 200: - logger.warning("Cian newbuilding fetch %s → HTTP %d", zhk_url, resp.status_code) - return None - html = resp.text + mfe = "newbuilding-card-desktop-fichering-frontend" - mfe = "newbuilding-card-desktop-fichering-frontend" + nb_state = extract_state(html, mfe=mfe, key="initialState") + if nb_state is None: + logger.warning("Cian newbuilding %s: initialState extraction failed", zhk_url) + return None - nb_state = extract_state(html, mfe=mfe, key="initialState") - if nb_state is None: - logger.warning("Cian newbuilding %s: initialState extraction failed", zhk_url) - return None + # Primary newbuilding object: state.newbuilding (60 fields per schema sec 15.4) + nb = nb_state.get("newbuilding") or {} + if not isinstance(nb, dict): + nb = {} - # Primary newbuilding object: state.newbuilding (60 fields per schema sec 15.4) - nb = nb_state.get("newbuilding") or {} - if not isinstance(nb, dict): - nb = {} + result = NewbuildingEnrichment( + cian_internal_house_id=nb.get("id"), + cian_zhk_url=zhk_url, + name=nb.get("name") or nb.get("displayName"), + address=_extract_address(nb), + year_built=nb.get("yearBuilt") or nb.get("buildYear"), + deadline_year=_extract_deadline_year(nb), + floors_count_min=nb.get("floorsCountMin"), + floors_count_max=nb.get("floorsCountMax"), + building_class=nb.get("newbuildingClassName") or nb.get("buildingClass"), + management_company=nb.get("managementCompany"), + transport_accessibility_rate=_extract_transport_rate(nb), + advantages=_extract_advantages(nb), + builders=_safe_list(nb.get("builders")), + banks=_safe_list(nb.get("banks")), + houses_by_turn=_safe_list(nb.get("housesByTurn")), + raw_state=nb_state, + ) - result = NewbuildingEnrichment( - cian_internal_house_id=nb.get("id"), - cian_zhk_url=zhk_url, - name=nb.get("name") or nb.get("displayName"), - address=_extract_address(nb), - year_built=nb.get("yearBuilt") or nb.get("buildYear"), - deadline_year=_extract_deadline_year(nb), - floors_count_min=nb.get("floorsCountMin"), - floors_count_max=nb.get("floorsCountMax"), - building_class=nb.get("newbuildingClassName") or nb.get("buildingClass"), - management_company=nb.get("managementCompany"), - transport_accessibility_rate=_extract_transport_rate(nb), - advantages=_extract_advantages(nb), - builders=_safe_list(nb.get("builders")), - banks=_safe_list(nb.get("banks")), - houses_by_turn=_safe_list(nb.get("housesByTurn")), - raw_state=nb_state, - ) + # Sister containers — all live under the same MFE initialState top-level keys + # (not separate push() entries) per schema sec 15.3 + all_states = extract_all_states(html) + mfe_states = all_states.get(mfe, {}) + # initialState top-level keys contain sister data: realtyValuation, reliability, offers + top = nb_state - # Sister containers — all live under the same MFE initialState top-level keys - # (not separate push() entries) per schema sec 15.3 - all_states = extract_all_states(html) - mfe_states = all_states.get(mfe, {}) - # initialState top-level keys contain sister data: realtyValuation, reliability, offers - top = nb_state + # realtyValuation 7-month chart (Stage 6 main deliverable) + # Located at initialState.realtyValuation (sec 15.5) + rv_state = top.get("realtyValuation") or mfe_states.get("realtyValuation") + if rv_state and isinstance(rv_state, dict): + result.realty_valuation_chart = _extract_chart(rv_state) + result.raw_sister_states["realtyValuation"] = rv_state - # realtyValuation 7-month chart (Stage 6 main deliverable) - # Located at initialState.realtyValuation (sec 15.5) - rv_state = top.get("realtyValuation") or mfe_states.get("realtyValuation") - if rv_state and isinstance(rv_state, dict): - result.realty_valuation_chart = _extract_chart(rv_state) - result.raw_sister_states["realtyValuation"] = rv_state + # reliability (наш.дом.рф) — located at initialState.reliability (sec 15.6) + rel_state = top.get("reliability") or mfe_states.get("reliability") + if rel_state and isinstance(rel_state, dict): + result.reliability_checks = _extract_reliability_checks(rel_state) + result.raw_sister_states["reliability"] = rel_state - # reliability (наш.дом.рф) — located at initialState.reliability (sec 15.6) - rel_state = top.get("reliability") or mfe_states.get("reliability") - if rel_state and isinstance(rel_state, dict): - result.reliability_checks = _extract_reliability_checks(rel_state) - result.raw_sister_states["reliability"] = rel_state + # reviews — located at initialState or separate state + reviews_state = top.get("reviews") or mfe_states.get("reviews") + if reviews_state and isinstance(reviews_state, dict): + items = reviews_state.get("items") or reviews_state.get("data") or [] + result.reviews = items if isinstance(items, list) else [] + result.raw_sister_states["reviews"] = reviews_state - # reviews — located at initialState or separate state - reviews_state = top.get("reviews") or mfe_states.get("reviews") - if reviews_state and isinstance(reviews_state, dict): - items = reviews_state.get("items") or reviews_state.get("data") or [] - result.reviews = items if isinstance(items, list) else [] - result.raw_sister_states["reviews"] = reviews_state + # offers (grouped by roomType) — located at initialState.offers (sec 15.7) + offers_state = top.get("offers") or mfe_states.get("offers") or mfe_states.get("miniSerp") + if offers_state and isinstance(offers_state, dict): + result.nested_offers = _extract_nested_offers(offers_state) + result.raw_sister_states["offers"] = offers_state - # offers (grouped by roomType) — located at initialState.offers (sec 15.7) - offers_state = top.get("offers") or mfe_states.get("offers") or mfe_states.get("miniSerp") - if offers_state and isinstance(offers_state, dict): - result.nested_offers = _extract_nested_offers(offers_state) - result.raw_sister_states["offers"] = offers_state - - logger.info( - "Cian newbuilding %s parsed: id=%s chart=%d reliability=%d offers=%d", - zhk_url, - result.cian_internal_house_id, - len(result.realty_valuation_chart), - len(result.reliability_checks), - len(result.nested_offers), - ) - return result - finally: - if close_session: - await session.close() + logger.info( + "Cian newbuilding %s parsed: id=%s chart=%d reliability=%d offers=%d", + zhk_url, + result.cian_internal_house_id, + len(result.realty_valuation_chart), + len(result.reliability_checks), + len(result.nested_offers), + ) + return result # ---- private extractors ---- diff --git a/tradein-mvp/backend/app/services/scrapers/yandex_newbuilding.py b/tradein-mvp/backend/app/services/scrapers/yandex_newbuilding.py index 9139e77a..3baf1565 100644 --- a/tradein-mvp/backend/app/services/scrapers/yandex_newbuilding.py +++ b/tradein-mvp/backend/app/services/scrapers/yandex_newbuilding.py @@ -3,6 +3,12 @@ URL pattern: /{city}/kupit/novostrojka/-/ Reference target: ЖК Татлин (id=1592987, slug=tatlin) — comfort+, June 2023, PRINZIP, rating 4.3, 1505 ratings, 353 text reviews, coords (56.855312, 60.576668). + +Fetch strategy (#974): + - Все network-запросы (ЖК-лендинг + SERP slug-resolve) идут через BrowserFetcher + (tradein-browser camoufox), а НЕ через httpx/_http_get. + - Yandex Realty — JS-heavy / anti-bot; curl_cffi / httpx не получают данные. + - BrowserFetcher.fetch(url) → str (полный HTML); вызывающий код парсит через parse(). """ from __future__ import annotations @@ -98,6 +104,12 @@ RE_METRO_INLINE = re.compile( r"([А-ЯЁ][А-Яа-яё\s-]{2,30}?)\s+(\d+)\s*мин", ) +# Slug из href: /{city}/kupit/novostrojka/-/ +# Используется в resolve_yandex_jk_slug для извлечения slug из SERP href. +_JK_SLUG_RE = re.compile( + r"/(?:ekaterinburg|moskva|spb|[a-z-]+)/kupit/novostrojka/([a-z0-9-]+)-(\d+)/" +) + # Ekb-specific coord ranges (extend per-city later) LAT_RANGE = (55.5, 57.5) LON_RANGE = (59.5, 61.5) @@ -129,9 +141,7 @@ class YandexNewbuildingScraper(BaseScraper): super().__init__() self.request_delay_sec = get_scraper_delay(self.name) - async def fetch_around( - self, lat: float, lon: float, radius_m: int = 1000 - ) -> list: # type: ignore[override] + async def fetch_around(self, lat: float, lon: float, radius_m: int = 1000) -> list: # type: ignore[override] raise NotImplementedError( "YandexNewbuildingScraper is JK-slug-based; use fetch_jk(slug, id) instead." ) @@ -139,22 +149,31 @@ class YandexNewbuildingScraper(BaseScraper): async def fetch_jk( self, jk_slug: str, jk_id: str, city: str = "ekaterinburg" ) -> YandexNewbuildingInfo | None: + """Загрузить ЖК-лендинг через BrowserFetcher и распарсить. + + Yandex Realty — JS/anti-bot: curl/httpx не получают данные. Запрос идёт + через tradein-browser (camoufox) контейнер — единственный рабочий путь (#974). + """ + from app.services.scrapers.browser_fetcher import BrowserFetcher + url = f"{self.base_url}/{city}/kupit/novostrojka/{jk_slug}-{jk_id}/" try: - response = await self._http_get(url) + async with BrowserFetcher() as fetcher: + html = await fetcher.fetch(url) except Exception: - logger.exception("yandex nb fetch failed: %s", url) + logger.exception("yandex nb browser fetch failed: %s", url) return None - if response.status_code != 200: - logger.warning("yandex nb returned %d for %s", response.status_code, url) + if not html or len(html) < 500: + logger.warning( + "yandex nb browser returned empty/tiny HTML (%d bytes): %s", + len(html) if html else 0, + url, + ) return None - result = self.parse(response.text, jk_slug=jk_slug, jk_id=jk_id, source_url=url) - await self.sleep_between_requests() + result = self.parse(html, jk_slug=jk_slug, jk_id=jk_id, source_url=url) return result - def parse( - self, html: str, jk_slug: str, jk_id: str, source_url: str - ) -> YandexNewbuildingInfo: + def parse(self, html: str, jk_slug: str, jk_id: str, source_url: str) -> YandexNewbuildingInfo: tree = HTMLParser(html) body = tree.body body_text = body.text(strip=True) if body else "" @@ -208,9 +227,7 @@ class YandexNewbuildingScraper(BaseScraper): href = dev_link.attributes.get("href", "") if href: developer_url = ( - href - if href.startswith("http") - else f"https://realty.yandex.ru{href}" + href if href.startswith("http") else f"https://realty.yandex.ru{href}" ) # Developer's other JKs @@ -269,6 +286,60 @@ class YandexNewbuildingScraper(BaseScraper): ) +# ── Slug resolution via SERP ────────────────────────────────────────────────── + + +async def resolve_yandex_jk_slug( + jk_id: str, + city: str = "ekaterinburg", +) -> str | None: + """Найти Yandex Realty slug для ЖК по его ext_id (jk_id) через SERP. + + Стратегия (#974 — зеркало resolve_cian_zhk_url_via_search): + 1. Запросить поисковую страницу Yandex Realty через BrowserFetcher. + URL: /ekaterinburg/kupit/novostrojka/?siteId= + 2. В HTML найти первую ссылку вида //kupit/novostrojka/-/ + через regex _JK_SLUG_RE. + 3. Вернуть slug или None при любой ошибке. + + Caller несёт ответственность за anti-bot sleep (зеркало cian_newbuilding.py). + BrowserFetcher обязателен — Yandex Realty JS/anti-bot, httpx/curl не работают. + + Returns: + slug (str без id-суффикса), или None при ошибке / не найден. + """ + from app.services.scrapers.browser_fetcher import BrowserFetcher + + # Yandex Realty SERP: фильтр по siteId → первый результат = нужный ЖК. + # Альтернативный путь через Яндекс Поиск (web SERP) менее надёжен из-за + # вариативности разметки. Прямой realty.yandex.ru SERP — стабильнее. + serp_url = f"https://realty.yandex.ru/{city}/kupit/novostrojka/?siteId={jk_id}" + try: + async with BrowserFetcher() as fetcher: + html = await fetcher.fetch(serp_url) + except Exception as exc: + logger.warning("resolve_yandex_jk_slug jk_id=%s browser fetch failed: %s", jk_id, exc) + return None + + if not html: + logger.warning("resolve_yandex_jk_slug jk_id=%s: empty HTML from browser", jk_id) + return None + + # Ищем ссылку вида /{city}/kupit/novostrojka/-/ + # Ограничиваем: id в ссылке должен совпадать с искомым jk_id. + for m in _JK_SLUG_RE.finditer(html): + if m.group(2) == str(jk_id): + slug = m.group(1) + logger.info("resolve_yandex_jk_slug jk_id=%s → slug=%s", jk_id, slug) + return slug + + logger.warning( + "resolve_yandex_jk_slug jk_id=%s: no matching slug in SERP HTML (markup drift?)", + jk_id, + ) + return None + + # ── helpers ─────────────────────────────────────────────────────────────────── @@ -354,4 +425,5 @@ __all__ = [ "JKMetroStation", "YandexNewbuildingInfo", "YandexNewbuildingScraper", + "resolve_yandex_jk_slug", ] diff --git a/tradein-mvp/backend/app/tasks/yandex_newbuilding_sweep.py b/tradein-mvp/backend/app/tasks/yandex_newbuilding_sweep.py new file mode 100644 index 00000000..c65ac1d1 --- /dev/null +++ b/tradein-mvp/backend/app/tasks/yandex_newbuilding_sweep.py @@ -0,0 +1,423 @@ +"""Yandex Newbuilding sweep-task (#974): enrichment ЖК в market.yandex_jk_enrichment. + +Context +------- +309 house_sources строк с ext_source='yandex_realty_nb' имеют ext_id (= yandex_jk_id), +но у большинства NULL yandex_jk_slug и не заполнены данные в market.yandex_jk_enrichment. + +Для каждого дома цепочка: + 1. resolve_yandex_jk_slug(jk_id) → slug (через BrowserFetcher SERP) + Результат сохраняется в houses.yandex_jk_slug (SAVEPOINT) — resumable. + 2. YandexNewbuildingScraper.fetch_jk(slug, jk_id) → YandexNewbuildingInfo + Через BrowserFetcher (tradein-browser camoufox) — единственный рабочий путь. + 3. UPSERT в market.yandex_jk_enrichment (ON CONFLICT (ext_id) DO UPDATE). + 4. UPDATE houses.yandex_jk_id WHERE yandex_jk_slug = slug. + +Idempotency +----------- + - force=False: пропускает дома у которых уже есть строка в market.yandex_jk_enrichment. + - UPSERT через ON CONFLICT (ext_id) DO UPDATE — безопасен при повторном запуске. + - SAVEPOINT per house — один сбойный fetch не прерывает батч. + - dry_run: подсчёт популяции без fetch. + +Anti-bot / resilience +--------------------- + - request_delay_sec (default из get_scraper_delay('yandex_realty_nb')) с ±20% jitter. + - SAVEPOINT per house: resolve-фаза и enrich-фаза — отдельные savepoint'ы. + +Execution +--------- + - Чистый async callable — NO Celery. Триггер: admin endpoint или scheduler. + - psycopg v3 conventions: CAST(:x AS type) в SQL (никогда ::`); logger (никогда print). +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import random +import time +from dataclasses import dataclass, field, fields + +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.services.scraper_settings import get_scraper_delay + +logger = logging.getLogger(__name__) + +__all__ = [ + "YandexNewbuildingSweepResult", + "count_yandex_newbuilding_houses", + "enrich_yandex_newbuilding_sweep", +] + +_ENRICHMENT_SOURCE = "yandex_realty_nb" + + +@dataclass +class YandexNewbuildingSweepResult: + """Per-run счётчики yandex newbuilding sweep.""" + + # Sizing (независимо от limit). + total: int = 0 # дома с ext_source='yandex_realty_nb' + fetchable: int = 0 # из них: с yandex_jk_slug ИЛИ с ext_id + pending: int = 0 # fetchable И ещё не обогащены (force=False) + + # Processing (ограничен limit). + processed: int = 0 + skipped_already_enriched: int = 0 + succeeded: int = 0 + resolved_slug: int = 0 # ext_id → slug разрезолвлен + сохранён + failed_resolve: int = 0 # slug не удалось разрезолвить + failed_fetch: int = 0 # fetch_jk вернул None / упал + rows_inserted: int = 0 # строк в market.yandex_jk_enrichment (новых/обновлённых) + + duration_sec: float = field(default=0.0) + + def to_dict(self) -> dict[str, int | float]: + return {f.name: getattr(self, f.name) for f in fields(self)} + + +# ── SQL ──────────────────────────────────────────────────────────────────────── + +_COUNT_TOTAL = """ + SELECT COUNT(DISTINCT h.id) + FROM houses h + JOIN house_sources hs ON hs.house_id = h.id + WHERE hs.ext_source = 'yandex_realty_nb' +""" + +_COUNT_FETCHABLE = """ + SELECT COUNT(DISTINCT h.id) + FROM houses h + JOIN house_sources hs ON hs.house_id = h.id + WHERE hs.ext_source = 'yandex_realty_nb' + AND (h.yandex_jk_slug IS NOT NULL OR hs.ext_id IS NOT NULL) +""" + +_COUNT_PENDING = """ + SELECT COUNT(DISTINCT h.id) + FROM houses h + JOIN house_sources hs ON hs.house_id = h.id + WHERE hs.ext_source = 'yandex_realty_nb' + AND (h.yandex_jk_slug IS NOT NULL OR hs.ext_id IS NOT NULL) + AND NOT EXISTS ( + SELECT 1 FROM market.yandex_jk_enrichment e WHERE e.ext_id = hs.ext_id + ) +""" + +# DISTINCT ON (h.id) — у дома может быть >1 house_sources строки; берём любую ext_id. +_SELECT_PENDING_HOUSES = """ + SELECT DISTINCT ON (h.id) + h.id AS house_id, + h.yandex_jk_slug, + h.yandex_jk_id, + hs.ext_id + FROM houses h + JOIN house_sources hs ON hs.house_id = h.id + WHERE hs.ext_source = 'yandex_realty_nb' + AND (h.yandex_jk_slug IS NOT NULL OR hs.ext_id IS NOT NULL) + AND ( + CAST(:force AS boolean) = TRUE + OR NOT EXISTS ( + SELECT 1 FROM market.yandex_jk_enrichment e WHERE e.ext_id = hs.ext_id + ) + ) + ORDER BY h.id, hs.ext_id NULLS LAST + LIMIT :lim +""" + +_UPDATE_SLUG = """ + UPDATE houses + SET yandex_jk_slug = CAST(:slug AS text) + WHERE id = CAST(:hid AS bigint) +""" + +_UPSERT_ENRICHMENT = """ + INSERT INTO market.yandex_jk_enrichment ( + ext_id, name, developer_name, address, + lat, lon, rating, ratings_count, text_reviews_count, + raw_payload, updated_at + ) VALUES ( + CAST(:ext_id AS text), + CAST(:name AS text), + CAST(:developer_name AS text), + CAST(:address AS text), + CAST(:lat AS float8), + CAST(:lon AS float8), + CAST(:rating AS float4), + CAST(:ratings_count AS int), + CAST(:text_reviews_count AS int), + CAST(:raw_payload AS jsonb), + NOW() + ) + ON CONFLICT (ext_id) DO UPDATE SET + name = EXCLUDED.name, + developer_name = EXCLUDED.developer_name, + address = EXCLUDED.address, + lat = EXCLUDED.lat, + lon = EXCLUDED.lon, + rating = EXCLUDED.rating, + ratings_count = EXCLUDED.ratings_count, + text_reviews_count = EXCLUDED.text_reviews_count, + raw_payload = EXCLUDED.raw_payload, + updated_at = NOW() +""" + + +def count_yandex_newbuilding_houses(db: Session) -> dict[str, int]: + """Sizing: сколько yandex_realty_nb домов существует / fetchable / pending. + + Дешёвый (3 COUNT) — безопасен для dry_run. + """ + total = int(db.execute(text(_COUNT_TOTAL)).scalar_one()) + fetchable = int(db.execute(text(_COUNT_FETCHABLE)).scalar_one()) + pending = int(db.execute(text(_COUNT_PENDING)).scalar_one()) + return {"total": total, "fetchable": fetchable, "pending": pending} + + +async def enrich_yandex_newbuilding_sweep( + db: Session, + *, + limit: int = 5, + force: bool = False, + request_delay_sec: float | None = None, + dry_run: bool = False, + city: str = "ekaterinburg", +) -> YandexNewbuildingSweepResult: + """Enrichment sweep для yandex_realty_nb домов → market.yandex_jk_enrichment. + + Per house: + - resolve slug если NULL (persist в houses.yandex_jk_slug, SAVEPOINT) + - fetch_jk через BrowserFetcher + - UPSERT market.yandex_jk_enrichment (ON CONFLICT ext_id) + - UPDATE houses.yandex_jk_id (если изменился) + + Args: + db: tradein-mvp SQLAlchemy session (НЕ gendesign DB). + limit: max домов за один прогон. Default 5 — bounded start. + force: переобработать уже обогащённые (UPSERT-safe). Default False. + request_delay_sec: пауза между домами. None → get_scraper_delay('yandex_realty_nb'). + dry_run: считать популяцию без fetch/write. + city: город для Yandex Realty URL (ekaterinburg). + + Returns: + YandexNewbuildingSweepResult со счётчиками. + """ + from app.services.scrapers.yandex_newbuilding import ( + YandexNewbuildingScraper, + resolve_yandex_jk_slug, + ) + + result = YandexNewbuildingSweepResult() + t0 = time.time() + delay = ( + request_delay_sec + if request_delay_sec is not None + else get_scraper_delay(_ENRICHMENT_SOURCE) + ) + + # ── Sizing ────────────────────────────────────────────────────────────── + sizing = count_yandex_newbuilding_houses(db) + result.total = sizing["total"] + result.fetchable = sizing["fetchable"] + result.pending = sizing["pending"] + + rows = db.execute(text(_SELECT_PENDING_HOUSES), {"force": force, "lim": limit}).mappings().all() + + logger.info( + "yandex-nb-sweep: total=%d fetchable=%d pending=%d; " + "selected=%d (limit=%d force=%s dry_run=%s delay=%.1fs city=%s)", + result.total, + result.fetchable, + result.pending, + len(rows), + limit, + force, + dry_run, + delay, + city, + ) + + if dry_run: + logger.info( + "dry_run: would process %d yandex_realty_nb houses (ids=%s)", + len(rows), + [r["house_id"] for r in rows], + ) + result.duration_sec = time.time() - t0 + return result + + for idx, row in enumerate(rows): + house_id: int = int(row["house_id"]) + jk_slug: str | None = row["yandex_jk_slug"] + ext_id: str | None = row["ext_id"] + result.processed += 1 + + # Idempotency fast-path (belt-and-braces под concurrent writer) + if not force and jk_slug: + already = db.execute( + text( + "SELECT 1 FROM market.yandex_jk_enrichment WHERE ext_id = CAST(:ext_id AS text)" + ), + {"ext_id": ext_id}, + ).fetchone() + if already is not None: + result.skipped_already_enriched += 1 + logger.debug("skip house_id=%s — already enriched ext_id=%s", house_id, ext_id) + continue + + # ── Resolve slug ───────────────────────────────────────────────── + if not jk_slug: + if not ext_id: + logger.warning("house_id=%s: нет yandex_jk_slug и нет ext_id — skip", house_id) + result.failed_resolve += 1 + continue + + try: + resolved = await resolve_yandex_jk_slug(ext_id, city=city) + except Exception as exc: + logger.warning( + "resolve_yandex_jk_slug house_id=%s ext_id=%s raised: %s", + house_id, + ext_id, + exc, + ) + resolved = None + + # Anti-bot sleep после resolve-fetch + await _sleep_with_jitter(delay, idx, len(rows), force=True) + + if not resolved: + logger.warning("slug unresolved house_id=%s ext_id=%s — skip", house_id, ext_id) + result.failed_resolve += 1 + continue + + # Persist slug под SAVEPOINT + sp = db.begin_nested() + try: + db.execute(text(_UPDATE_SLUG), {"slug": resolved, "hid": house_id}) + sp.commit() + db.commit() + except Exception as exc: + sp.rollback() + logger.warning( + "persist slug failed house_id=%s ext_id=%s: %s", house_id, ext_id, exc + ) + result.failed_resolve += 1 + continue + + jk_slug = resolved + result.resolved_slug += 1 + + # ── Guard: ext_id обязателен для fetch_jk (формирует URL /{slug}-{id}/) ── + if not ext_id: + logger.warning( + "house_id=%s: jk_slug=%s известен, но ext_id пустой — " + "fetch_jk пропущен (пустой jk_id даёт битый URL)", + house_id, + jk_slug, + ) + result.failed_fetch += 1 + continue + + # ── Fetch через BrowserFetcher ──────────────────────────────────── + info = None + try: + scraper = YandexNewbuildingScraper() + info = await scraper.fetch_jk(jk_slug=jk_slug, jk_id=ext_id, city=city) + except Exception as exc: + logger.warning( + "fetch_jk failed house_id=%s jk_slug=%s ext_id=%s: %s", + house_id, + jk_slug, + ext_id, + exc, + ) + result.failed_fetch += 1 + await _sleep_with_jitter(delay, idx, len(rows)) + continue + + if info is None: + logger.warning( + "fetch_jk returned None house_id=%s jk_slug=%s (anti-bot / parse miss?)", + house_id, + jk_slug, + ) + result.failed_fetch += 1 + await _sleep_with_jitter(delay, idx, len(rows)) + continue + + # ── UPSERT market.yandex_jk_enrichment под SAVEPOINT ───────────── + sp = db.begin_nested() + try: + db.execute( + text(_UPSERT_ENRICHMENT), + { + "ext_id": info.ext_id, + "name": info.name, + "developer_name": info.developer_name, + "address": info.address, + "lat": info.lat, + "lon": info.lon, + "rating": info.rating, + "ratings_count": info.ratings_count, + "text_reviews_count": info.text_reviews_count, + "raw_payload": json.dumps(info.raw_payload or {}, ensure_ascii=False), + }, + ) + sp.commit() + db.commit() + result.rows_inserted += 1 + result.succeeded += 1 + logger.info( + "enriched house_id=%s ext_id=%s slug=%s name=%r", + house_id, + info.ext_id, + jk_slug, + info.name, + ) + except Exception as exc: + sp.rollback() + logger.warning( + "UPSERT yandex_jk_enrichment failed house_id=%s ext_id=%s: %s", + house_id, + ext_id, + exc, + ) + try: + db.rollback() + except Exception as rb_exc: + logger.warning("rollback failed house_id=%s: %s", house_id, rb_exc) + + await _sleep_with_jitter(delay, idx, len(rows)) + + result.duration_sec = time.time() - t0 + logger.info( + "yandex-nb-sweep done: processed=%d ok=%d skip=%d resolved=%d " + "resolve_fail=%d fetch_fail=%d rows_inserted=%d | %.1fs", + result.processed, + result.succeeded, + result.skipped_already_enriched, + result.resolved_slug, + result.failed_resolve, + result.failed_fetch, + result.rows_inserted, + result.duration_sec, + ) + return result + + +async def _sleep_with_jitter(delay: float, idx: int, total: int, *, force: bool = False) -> None: + """Polite anti-bot sleep с ±20% jitter. + + Пропускается после последнего элемента (нет следующего fetch) — ЕСЛИ force=False. + force=True используется для resolve→enrich gap внутри одного дома. + """ + if delay <= 0: + return + if not force and idx >= total - 1: + return + await asyncio.sleep(delay * random.uniform(0.8, 1.2)) diff --git a/tradein-mvp/backend/data/sql/105_market_schema_yandex_enrichment.sql b/tradein-mvp/backend/data/sql/105_market_schema_yandex_enrichment.sql new file mode 100644 index 00000000..8d1f7f18 --- /dev/null +++ b/tradein-mvp/backend/data/sql/105_market_schema_yandex_enrichment.sql @@ -0,0 +1,82 @@ +-- 105_market_schema_yandex_enrichment.sql +-- Market-схема + таблица yandex_jk_enrichment + houses.yandex_jk_slug (#974). +-- +-- Архитектурное решение (ADR): +-- Новые скрейперы пишут в схему market в СУЩЕСТВУЮЩЕМ tradein-postgres. +-- Существующие listings/houses не выносятся — market является дополнительным слоем +-- для cross-product ЖК-аналитики (Site Finder v2 / GG-форсайт). +-- +-- Таблица market.yandex_jk_enrichment хранит parsed payload с Yandex Realty +-- ЖК-лендинга (slug, coords, rating, developer). Ключ — ext_id (= yandex_jk_id). +-- UPSERT через ON CONFLICT (ext_id) DO UPDATE. +-- +-- houses.yandex_jk_slug: персистируется при resolve_yandex_jk_slug, используется +-- для возобновления sweep без повторного запроса к SERP. +-- +-- ЗАВИСИМОСТИ: +-- 009_houses.sql (таблица houses), 031_houses_alter_yandex.sql (yandex_jk_id), +-- 101_gendesign_reader_role.sql (роль gendesign_reader). +-- Idempotent: IF NOT EXISTS + ADD COLUMN IF NOT EXISTS. +-- AUTO-APPLIED на прод при деплое через deploy-tradein.yml/_schema_migrations. + +BEGIN; + +-- ── Схема market ───────────────────────────────────────────────────────────── + +CREATE SCHEMA IF NOT EXISTS market; + +COMMENT ON SCHEMA market IS + 'Cross-product ЖК-аналитика: yandex/cian newbuilding enrichment, ' + 'Site Finder v2 / GG-форсайт (#974). ' + 'Читается gendesign_reader через GRANT ниже.'; + +-- ── market.yandex_jk_enrichment ────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS market.yandex_jk_enrichment ( + id bigserial PRIMARY KEY, + ext_id text NOT NULL, + name text, + developer_name text, + address text, + lat float8, + lon float8, + rating float4, + ratings_count int, + text_reviews_count int, + raw_payload jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT uq_market_yandex_jk_ext_id UNIQUE (ext_id) +); + +COMMENT ON TABLE market.yandex_jk_enrichment IS + 'Parsed payload с Yandex Realty ЖК-лендинга (#974). ' + 'Ключ ext_id = yandex_jk_id (string). ' + 'UPSERT через ON CONFLICT (ext_id) DO UPDATE.'; + +CREATE INDEX IF NOT EXISTS idx_market_yandex_jk_ext_id + ON market.yandex_jk_enrichment (ext_id); + +-- ── houses.yandex_jk_slug ──────────────────────────────────────────────────── + +-- Примечание: yandex_jk_id уже существует из миграции 031_houses_alter_yandex.sql. +-- yandex_jk_slug — новый столбец, добавляем только если нет. +ALTER TABLE houses ADD COLUMN IF NOT EXISTS yandex_jk_slug text; + +COMMENT ON COLUMN houses.yandex_jk_slug IS + 'Yandex Realty ЖК slug из URL /{city}/kupit/novostrojka/-/. ' + 'Персистируется sweep-таской при resolve_yandex_jk_slug (#974). ' + 'NULL = slug ещё не разрезолвлен.'; + +CREATE INDEX IF NOT EXISTS idx_houses_yandex_jk_slug + ON houses (yandex_jk_slug) + WHERE yandex_jk_slug IS NOT NULL; + +-- ── Доступ для gendesign_reader ─────────────────────────────────────────────── +-- Оба продукта (tradein + gendesign Site Finder v2) читают market-слой через роль +-- gendesign_reader (создана в 101_gendesign_reader_role.sql). + +GRANT USAGE ON SCHEMA market TO gendesign_reader; +GRANT SELECT ON ALL TABLES IN SCHEMA market TO gendesign_reader; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/106_scrape_schedules_seed_yandex_newbuilding_sweep.sql b/tradein-mvp/backend/data/sql/106_scrape_schedules_seed_yandex_newbuilding_sweep.sql new file mode 100644 index 00000000..9583e540 --- /dev/null +++ b/tradein-mvp/backend/data/sql/106_scrape_schedules_seed_yandex_newbuilding_sweep.sql @@ -0,0 +1,53 @@ +-- 106_scrape_schedules_seed_yandex_newbuilding_sweep.sql +-- Seed row для Yandex newbuilding sweep (#974). +-- +-- !!! DORMANT BY DESIGN !!! enabled = false. +-- Sweep обращается к Yandex Realty через tradein-browser (camoufox). +-- Засеян ВЫКЛЮЧЕННЫМ — включается оператором вручную: +-- UPDATE scrape_schedules SET enabled = true +-- WHERE source = 'yandex_newbuilding_sweep'; +-- Зеркалит cian_city_sweep (103, тоже enabled = false, dormant). +-- +-- default_params: +-- limit — дома за один прогон (старт консервативно 5). +-- request_delay_sec — пауза между ЖК (поверх browser wait). +-- city — город для Yandex Realty URL (ekaterinburg). +-- +-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)). +-- Idempotent: ON CONFLICT (source) DO NOTHING — безопасно запускать повторно. +-- +-- yandex_newbuilding_sweep — окно 02:00-05:00 UTC (05:00-08:00 МСК). + +BEGIN; + +INSERT INTO scrape_schedules ( + source, + enabled, + window_start_hour, + window_end_hour, + next_run_at, + default_params +) +VALUES +( + 'yandex_newbuilding_sweep', + false, -- DORMANT (#974) — enable manually + 2, + 5, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 2)) AT TIME ZONE 'UTC', + '{"limit": 5, "request_delay_sec": 8, "city": "ekaterinburg"}'::jsonb +) +ON CONFLICT (source) DO NOTHING; + +COMMENT ON TABLE scrape_schedules IS + 'In-app scheduler config (заменяет cron-script setup). ' + 'Sources: avito_city_sweep, yandex_city_sweep (dormant, #561), ' + 'cian_history_backfill, rosreestr_dkp_import, listing_source_snapshot (#570), ' + 'asking_to_sold_ratio_refresh (#648), refresh_search_matview (#769), ' + 'yandex_address_backfill (#855, EKB pilot), ' + 'sber_index_pull (#887, monthly СберИндекс city-level price index), ' + 'rosreestr_quarter_poll (#888, monthly Rosreestr new-quarter availability check), ' + 'cian_city_sweep (dormant, #973), ' + 'yandex_newbuilding_sweep (dormant, #974).'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/107_scrape_schedules_seed_cian_city_sweep.sql b/tradein-mvp/backend/data/sql/107_scrape_schedules_seed_cian_city_sweep.sql new file mode 100644 index 00000000..a6fb1e18 --- /dev/null +++ b/tradein-mvp/backend/data/sql/107_scrape_schedules_seed_cian_city_sweep.sql @@ -0,0 +1,64 @@ +-- 107_scrape_schedules_seed_cian_city_sweep.sql +-- Seed row для Cian периодического city-sweep с newbuilding Phase 4 (#973). +-- +-- !!! DORMANT BY DESIGN !!! enabled = false. +-- Sweep бьёт Cian с prod-IP через proxy — proxy сейчас мёртв → anti-bot риск. +-- Capability полностью wired (run_cian_city_sweep + trigger + dispatch), +-- но schedule засеян ВЫКЛЮЧЕННЫМ. Live-поведение намеренно НЕ verified. +-- ВКЛЮЧАТЬ ВРУЧНУЮ оператором ПОСЛЕ восстановления прокси: +-- UPDATE scrape_schedules SET enabled = true WHERE source = 'cian_city_sweep'; +-- Зеркалит yandex_city_sweep (078, тоже enabled = false, dormant). +-- +-- Phase 4 (newbuilding enrichment) включается через enrich_houses=true в default_params. +-- В отличие от yandex_city_sweep, cian sweep несёт detail + houses-фазы — +-- поэтому default_params включает detail_top_n и enrich_houses. +-- +-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)). +-- Idempotent: ON CONFLICT (source) DO NOTHING — безопасно запускать повторно. +-- +-- cian_city_sweep — окно 02:00-05:00 UTC (05:00-08:00 МСК), как avito_city_sweep. +-- default_params (консервативно, под анти-бот): +-- pages_per_anchor — страниц SERP на anchor. +-- request_delay_sec — пауза между anchor'ами (поверх per-page sleep scraper'а). +-- radius_m — радиус поиска вокруг anchor'а в метрах. +-- detail_top_n — кол-во объявлений на detail-обогащение (Phase 3). +-- enrich_houses — включить Phase 4 newbuilding enrichment (true). + +BEGIN; + +-- next_run_at = следующее наступление night-window (tomorrow + window_start_hour). +-- При enabled=false get_due_schedules() всё равно не подхватит строку, но заполняем +-- по образцу 078: когда оператор включит schedule, next_run_at уже валидный и +-- запуск не выстрелит мгновенно (вне окна). AT TIME ZONE 'UTC' фиксирует wall-clock +-- час в UTC независимо от session TimeZone GUC. +INSERT INTO scrape_schedules ( + source, + enabled, + window_start_hour, + window_end_hour, + next_run_at, + default_params +) +VALUES +( + 'cian_city_sweep', + false, -- DORMANT (#973) — enable manually after proxy restored + 2, + 5, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 2)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 1500, "detail_top_n": 10, "enrich_houses": true}'::jsonb +) +ON CONFLICT (source) DO NOTHING; + +COMMENT ON TABLE scrape_schedules IS + 'In-app scheduler config (заменяет cron-script setup). ' + 'Sources: avito_city_sweep, yandex_city_sweep (dormant, #561), ' + 'cian_history_backfill, rosreestr_dkp_import, listing_source_snapshot (#570), ' + 'asking_to_sold_ratio_refresh (#648), refresh_search_matview (#769), ' + 'yandex_address_backfill (#855, EKB pilot), ' + 'sber_index_pull (#887, monthly СберИндекс city-level price index), ' + 'rosreestr_quarter_poll (#888, monthly Rosreestr new-quarter availability check), ' + 'newbuilding_enrich (#973), yandex_newbuilding_sweep (dormant, #974), ' + 'cian_city_sweep (dormant, #973).'; + +COMMIT; diff --git a/tradein-mvp/backend/tests/tasks/test_yandex_newbuilding_sweep.py b/tradein-mvp/backend/tests/tasks/test_yandex_newbuilding_sweep.py new file mode 100644 index 00000000..a2e753cc --- /dev/null +++ b/tradein-mvp/backend/tests/tasks/test_yandex_newbuilding_sweep.py @@ -0,0 +1,397 @@ +"""Tests for yandex_newbuilding_sweep task (#974). + +No real network, no real Postgres. BrowserFetcher, resolve_yandex_jk_slug and +fetch_jk are mocked. The DB session is an in-memory fake that models +market.yandex_jk_enrichment + houses.yandex_jk_slug persistence assertions. +""" + +from __future__ import annotations + +import os +import sys +from unittest.mock import AsyncMock, MagicMock, patch + +# DATABASE_URL required by config before any app import. +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +# WeasyPrint stub — not installed in CI without GTK. +_wp_mock = MagicMock() +sys.modules.setdefault("weasyprint", _wp_mock) + +import pytest # noqa: E402 + +from app.tasks.yandex_newbuilding_sweep import ( # noqa: E402 + YandexNewbuildingSweepResult, + count_yandex_newbuilding_houses, + enrich_yandex_newbuilding_sweep, +) + +# ── In-memory fake DB ───────────────────────────────────────────────────────── + + +class _FakeResult: + def __init__(self, *, scalar=None, rows=None, fetchone=None): + self._scalar = scalar + self._rows = rows or [] + self._fetchone = fetchone + + def scalar_one(self): + return self._scalar + + def mappings(self): + return self + + def all(self): + return self._rows + + def fetchone(self): + return self._fetchone + + +class FakeDB: + """Minimal SQLAlchemy-Session fake for yandex_newbuilding_sweep. + + Tracks: + - enrichment: set of ext_id strings (inserted via UPSERT) + - slug_updates: dict house_id → slug (persisted via UPDATE houses) + - savepoints: int (# of begin_nested calls) + """ + + def __init__(self, pending_rows: list[dict]): + self._pending_rows = pending_rows + # market.yandex_jk_enrichment keyed by ext_id + self.enrichment: dict[str, dict] = {} + # houses.yandex_jk_slug written during resolve + self.slug_updates: dict[int, str] = {} + self.commits = 0 + self.rollbacks = 0 + self._sp_stack: list[_FakeSavepoint] = [] + + def execute(self, statement, params=None): + sql = str(statement) + params = params or {} + + # COUNT queries + if "COUNT(DISTINCT h.id)" in sql and "yandex_realty_nb" in sql: + if "NOT EXISTS" in sql: + # _COUNT_PENDING + pending = sum( + 1 for r in self._pending_rows if r.get("ext_id") not in self.enrichment + ) + return _FakeResult(scalar=pending) + if "yandex_jk_slug IS NOT NULL" in sql: + # _COUNT_FETCHABLE + return _FakeResult(scalar=len(self._pending_rows)) + # _COUNT_TOTAL + return _FakeResult(scalar=len(self._pending_rows)) + + # SELECT pending houses + if "SELECT DISTINCT ON (h.id)" in sql and "yandex_realty_nb" in sql: + lim = params.get("lim", len(self._pending_rows)) + force = params.get("force", False) + out = [] + for r in self._pending_rows[:lim]: + ext_id = r.get("ext_id") + if not force and ext_id in self.enrichment: + continue + # Reflect persisted slug + slug = self.slug_updates.get(r["house_id"]) or r.get("yandex_jk_slug") + out.append({**r, "yandex_jk_slug": slug}) + return _FakeResult(rows=out) + + # UPDATE houses SET yandex_jk_slug + if "UPDATE houses" in sql and "yandex_jk_slug" in sql: + self.slug_updates[params["hid"]] = params["slug"] + return _FakeResult() + + # Idempotency check: SELECT 1 FROM market.yandex_jk_enrichment + if "SELECT 1 FROM market.yandex_jk_enrichment" in sql: + ext_id = params.get("ext_id") + if ext_id in self.enrichment: + return _FakeResult(fetchone=object()) + return _FakeResult(fetchone=None) + + # UPSERT market.yandex_jk_enrichment + if "INSERT INTO market.yandex_jk_enrichment" in sql: + self.enrichment[params["ext_id"]] = dict(params) + return _FakeResult() + + return _FakeResult() + + def begin_nested(self): + sp = _FakeSavepoint(self) + self._sp_stack.append(sp) + return sp + + def commit(self): + self.commits += 1 + + def rollback(self): + self.rollbacks += 1 + + +class _FakeSavepoint: + def __init__(self, db: FakeDB): + self._db = db + + def commit(self): + pass + + def rollback(self): + pass + + +# ── Tests: count_yandex_newbuilding_houses ─────────────────────────────────── + + +def test_count_returns_sizing_dict(): + db = FakeDB([{"house_id": 1, "ext_id": "100", "yandex_jk_slug": None}]) + sizing = count_yandex_newbuilding_houses(db) + assert "total" in sizing + assert "fetchable" in sizing + assert "pending" in sizing + assert sizing["total"] >= 0 + + +# ── Tests: enrich_yandex_newbuilding_sweep ─────────────────────────────────── + + +@pytest.mark.asyncio +async def test_dry_run_returns_counts_without_fetch(): + """dry_run: популяция заполнена, fetch не вызван.""" + db = FakeDB( + [ + {"house_id": 1, "ext_id": "111", "yandex_jk_slug": None, "yandex_jk_id": None}, + ] + ) + result = await enrich_yandex_newbuilding_sweep(db, limit=5, dry_run=True) + assert isinstance(result, YandexNewbuildingSweepResult) + assert result.processed == 0 # dry_run = no processing + assert result.rows_inserted == 0 + assert result.total >= 0 + + +@pytest.mark.asyncio +async def test_sweep_with_existing_slug_upserts_enrichment(): + """House с уже известным slug: fetch_jk вызван, UPSERT market + счётчик.""" + db = FakeDB( + [ + { + "house_id": 10, + "ext_id": "999", + "yandex_jk_slug": "tatlin", + "yandex_jk_id": "999", + } + ] + ) + + from app.services.scrapers.yandex_newbuilding import YandexNewbuildingInfo + + fake_info = YandexNewbuildingInfo( + ext_id="999", + ext_slug="tatlin", + source_url="https://realty.yandex.ru/ekaterinburg/kupit/novostrojka/tatlin-999/", + name="ЖК Татлин", + developer_name="ПРИНЦИПЗ", + lat=56.855, + lon=60.576, + rating=4.3, + ratings_count=100, + text_reviews_count=25, + ) + + with patch("app.services.scrapers.yandex_newbuilding.YandexNewbuildingScraper") as mock_scraper: + scraper_instance = MagicMock() + scraper_instance.fetch_jk = AsyncMock(return_value=fake_info) + mock_scraper.return_value = scraper_instance + + result = await enrich_yandex_newbuilding_sweep(db, limit=5, request_delay_sec=0) + + assert result.processed == 1 + assert result.succeeded == 1 + assert result.rows_inserted == 1 + assert result.failed_fetch == 0 + assert result.failed_resolve == 0 + assert "999" in db.enrichment + assert db.enrichment["999"]["name"] == "ЖК Татлин" + + +@pytest.mark.asyncio +async def test_sweep_resolves_slug_when_missing(): + """House без slug: resolve_yandex_jk_slug вызван, slug сохранён, fetch_jk вызван.""" + db = FakeDB( + [ + { + "house_id": 20, + "ext_id": "888", + "yandex_jk_slug": None, + "yandex_jk_id": None, + } + ] + ) + + from app.services.scrapers.yandex_newbuilding import YandexNewbuildingInfo + + fake_info = YandexNewbuildingInfo( + ext_id="888", + ext_slug="testjk", + source_url="https://realty.yandex.ru/ekaterinburg/kupit/novostrojka/testjk-888/", + name="ЖК Тест", + lat=56.8, + lon=60.5, + ) + + with ( + patch( + "app.services.scrapers.yandex_newbuilding.resolve_yandex_jk_slug", + new_callable=AsyncMock, + return_value="testjk", + ) as mock_resolve, + patch("app.services.scrapers.yandex_newbuilding.YandexNewbuildingScraper") as mock_scraper, + ): + scraper_instance = MagicMock() + scraper_instance.fetch_jk = AsyncMock(return_value=fake_info) + mock_scraper.return_value = scraper_instance + + result = await enrich_yandex_newbuilding_sweep(db, limit=5, request_delay_sec=0) + + mock_resolve.assert_called_once_with("888", city="ekaterinburg") + assert result.resolved_slug == 1 + assert result.succeeded == 1 + assert result.rows_inserted == 1 + # Slug persisted to houses + assert db.slug_updates.get(20) == "testjk" + assert "888" in db.enrichment + + +@pytest.mark.asyncio +async def test_sweep_failed_resolve_counts(): + """resolve_yandex_jk_slug returns None → failed_resolve += 1, no upsert.""" + db = FakeDB( + [ + { + "house_id": 30, + "ext_id": "777", + "yandex_jk_slug": None, + "yandex_jk_id": None, + } + ] + ) + + with patch( + "app.services.scrapers.yandex_newbuilding.resolve_yandex_jk_slug", + new_callable=AsyncMock, + return_value=None, + ): + result = await enrich_yandex_newbuilding_sweep(db, limit=5, request_delay_sec=0) + + assert result.failed_resolve == 1 + assert result.succeeded == 0 + assert result.rows_inserted == 0 + assert "777" not in db.enrichment + + +@pytest.mark.asyncio +async def test_sweep_failed_fetch_counts(): + """fetch_jk returns None → failed_fetch += 1, no upsert.""" + db = FakeDB( + [ + { + "house_id": 40, + "ext_id": "666", + "yandex_jk_slug": "existingslug", + "yandex_jk_id": "666", + } + ] + ) + + with patch("app.services.scrapers.yandex_newbuilding.YandexNewbuildingScraper") as mock_scraper: + scraper_instance = MagicMock() + scraper_instance.fetch_jk = AsyncMock(return_value=None) + mock_scraper.return_value = scraper_instance + + result = await enrich_yandex_newbuilding_sweep(db, limit=5, request_delay_sec=0) + + assert result.failed_fetch == 1 + assert result.succeeded == 0 + assert "666" not in db.enrichment + + +@pytest.mark.asyncio +async def test_sweep_idempotent_skip_already_enriched(): + """force=False: дом уже в enrichment → пропускается без fetch.""" + db = FakeDB( + [ + { + "house_id": 50, + "ext_id": "555", + "yandex_jk_slug": "existingslug", + "yandex_jk_id": "555", + } + ] + ) + # Pre-populate enrichment + db.enrichment["555"] = {"ext_id": "555", "name": "Already enriched"} + + with patch("app.services.scrapers.yandex_newbuilding.YandexNewbuildingScraper") as mock_scraper: + scraper_instance = MagicMock() + scraper_instance.fetch_jk = AsyncMock(return_value=None) + mock_scraper.return_value = scraper_instance + + result = await enrich_yandex_newbuilding_sweep(db, limit=5, request_delay_sec=0) + + # The SELECT filters out already-enriched rows (force=False in SQL), + # so processed=0 (SELECT returns empty) + assert result.succeeded == 0 + scraper_instance.fetch_jk.assert_not_called() + + +@pytest.mark.asyncio +async def test_sweep_no_ext_id_fails_resolve(): + """House без ext_id и без slug → failed_resolve (нечего резолвить).""" + db = FakeDB( + [ + { + "house_id": 60, + "ext_id": None, + "yandex_jk_slug": None, + "yandex_jk_id": None, + } + ] + ) + + result = await enrich_yandex_newbuilding_sweep(db, limit=5, request_delay_sec=0) + + assert result.failed_resolve == 1 + assert result.succeeded == 0 + + +@pytest.mark.asyncio +async def test_sweep_slug_present_but_ext_id_null_skips_fetch(): + """House с jk_slug но ext_id=NULL → guard срабатывает, fetch_jk НЕ вызван. + + M1 review: пустой jk_id даёт URL /{slug}-/, который является битым. + Ожидаем failed_fetch += 1 и scraper.fetch_jk не вызван. + """ + db = FakeDB( + [ + { + "house_id": 70, + "ext_id": None, + "yandex_jk_slug": "tatlin", + "yandex_jk_id": None, + } + ] + ) + + with patch("app.services.scrapers.yandex_newbuilding.YandexNewbuildingScraper") as mock_scraper: + scraper_instance = MagicMock() + scraper_instance.fetch_jk = AsyncMock() + mock_scraper.return_value = scraper_instance + + result = await enrich_yandex_newbuilding_sweep(db, limit=5, request_delay_sec=0) + + scraper_instance.fetch_jk.assert_not_called() + assert result.failed_fetch == 1 + assert result.succeeded == 0 + assert result.rows_inserted == 0 diff --git a/tradein-mvp/backend/tests/test_cian_newbuilding.py b/tradein-mvp/backend/tests/test_cian_newbuilding.py index e5d02b65..e57fc5b6 100644 --- a/tradein-mvp/backend/tests/test_cian_newbuilding.py +++ b/tradein-mvp/backend/tests/test_cian_newbuilding.py @@ -1,4 +1,5 @@ """Tests for cian_newbuilding scraper (Stage 6).""" + from __future__ import annotations import pytest @@ -247,42 +248,72 @@ def test_dataclass_defaults(): # ---- async integration-style tests ---- +# Transport: fetch_newbuilding теперь использует BrowserFetcher (camoufox) вместо +# curl_cffi AsyncSession (#972). Мокируем BrowserFetcher.fetch через monkeypatch. + + +def _make_browser_fetcher_mock(html: str, monkeypatch: pytest.MonkeyPatch) -> None: + """Мокирует BrowserFetcher так, чтобы fetch() возвращал заданный html.""" + from unittest.mock import AsyncMock, MagicMock + + mock_fetcher = MagicMock() + mock_fetcher.fetch = AsyncMock(return_value=html) + mock_fetcher.__aenter__ = AsyncMock(return_value=mock_fetcher) + mock_fetcher.__aexit__ = AsyncMock(return_value=None) + + monkeypatch.setattr( + "app.services.scrapers.cian_newbuilding.BrowserFetcher", + lambda: mock_fetcher, + ) @pytest.mark.asyncio async def test_fetch_newbuilding_returns_none_on_no_state(monkeypatch): """Если state extraction fails → return None gracefully.""" + _make_browser_fetcher_mock("", monkeypatch) monkeypatch.setattr( "app.services.scrapers.cian_newbuilding.extract_state", lambda html, mfe, key: None, ) - from unittest.mock import AsyncMock, MagicMock - session = MagicMock() - response = MagicMock() - response.status_code = 200 - response.text = "" - session.get = AsyncMock(return_value=response) - session.close = AsyncMock() - - result = await fetch_newbuilding("https://zhk-test-ekb-i.cian.ru/", session=session) + result = await fetch_newbuilding("https://zhk-test-ekb-i.cian.ru/") assert result is None @pytest.mark.asyncio -async def test_fetch_newbuilding_returns_none_on_http_error(monkeypatch): - """HTTP non-200 → return None gracefully.""" +async def test_fetch_newbuilding_browser_error_propagates(monkeypatch): + """BrowserFetcher.fetch raises → fetch_newbuilding propagates the exception.""" from unittest.mock import AsyncMock, MagicMock - session = MagicMock() - response = MagicMock() - response.status_code = 403 - response.text = "Forbidden" - session.get = AsyncMock(return_value=response) - session.close = AsyncMock() + mock_fetcher = MagicMock() + mock_fetcher.fetch = AsyncMock(side_effect=RuntimeError("browser timeout")) + mock_fetcher.__aenter__ = AsyncMock(return_value=mock_fetcher) + mock_fetcher.__aexit__ = AsyncMock(return_value=None) + monkeypatch.setattr( + "app.services.scrapers.cian_newbuilding.BrowserFetcher", + lambda: mock_fetcher, + ) - result = await fetch_newbuilding("https://zhk-test-ekb-i.cian.ru/", session=session) + with pytest.raises(RuntimeError, match="browser timeout"): + await fetch_newbuilding("https://zhk-test-ekb-i.cian.ru/") + + +@pytest.mark.asyncio +async def test_fetch_newbuilding_session_param_ignored(monkeypatch): + """session= param принимается для backward-compat, но не используется для page-fetch.""" + _make_browser_fetcher_mock("", monkeypatch) + monkeypatch.setattr( + "app.services.scrapers.cian_newbuilding.extract_state", + lambda html, mfe, key: None, + ) + + from unittest.mock import MagicMock + + sentinel_session = MagicMock(name="should_not_be_called") + # Передаём session= — должно работать без ошибок, session.get НЕ вызывается + result = await fetch_newbuilding("https://zhk-test-ekb-i.cian.ru/", session=sentinel_session) assert result is None + sentinel_session.get.assert_not_called() @pytest.mark.asyncio @@ -318,6 +349,7 @@ async def test_fetch_newbuilding_parses_state(monkeypatch): }, } + _make_browser_fetcher_mock("fake", monkeypatch) monkeypatch.setattr( "app.services.scrapers.cian_newbuilding.extract_state", lambda html, mfe, key: fake_state, @@ -327,16 +359,7 @@ async def test_fetch_newbuilding_parses_state(monkeypatch): lambda html: {}, ) - from unittest.mock import AsyncMock, MagicMock - - session = MagicMock() - response = MagicMock() - response.status_code = 200 - response.text = "fake" - session.get = AsyncMock(return_value=response) - session.close = AsyncMock() - - result = await fetch_newbuilding("https://zhk-test-ekb-i.cian.ru/", session=session) + result = await fetch_newbuilding("https://zhk-test-ekb-i.cian.ru/") assert result is not None assert result.cian_internal_house_id == 54016 diff --git a/tradein-mvp/backend/tests/test_scheduler.py b/tradein-mvp/backend/tests/test_scheduler.py index a977d4bb..7cf27ccb 100644 --- a/tradein-mvp/backend/tests/test_scheduler.py +++ b/tradein-mvp/backend/tests/test_scheduler.py @@ -1,6 +1,8 @@ """Offline smoke for scheduler logic.""" +import asyncio import os +from unittest.mock import MagicMock os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") @@ -170,3 +172,195 @@ def test_claim_run_rechecks_running_under_lock(monkeypatch: pytest.MonkeyPatch) assert _sched._claim_run(db, _SCHED_ROW) is None assert calls["n"] == 0 assert db.rollbacks == 1 # лок освобождён + + +# ── #974: trigger_yandex_newbuilding_sweep_run + dispatch ──────────────────── + + +_YANDEX_NB_SWEEP_ROW = { + "source": "yandex_newbuilding_sweep", + "window_start_hour": 2, + "window_end_hour": 5, + "default_params": { + "limit": 5, + "request_delay_sec": 8.0, + "city": "ekaterinburg", + }, +} + + +@pytest.mark.asyncio +async def test_trigger_yandex_newbuilding_sweep_creates_run( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """trigger_yandex_newbuilding_sweep_run claims run + launches create_task (#974).""" + db = _FakeSchedulerDB() + calls: dict[str, list] = {"create_run": [], "sweep": []} + + def fake_create_run(_d, *, source, params): + calls["create_run"].append(source) + db.running[source] = True + return 301 + + monkeypatch.setattr(_sched.runs_mod, "create_run", fake_create_run) + + # Patch enrich_yandex_newbuilding_sweep to a coroutine that records call + sweep_result = MagicMock() + sweep_result.to_dict.return_value = {} + + async def fake_sweep(run_db, *, limit, **kwargs): + calls["sweep"].append(limit) + return sweep_result + + # Patch inside the _run closure (lazy import) + monkeypatch.setattr( + "app.tasks.yandex_newbuilding_sweep.enrich_yandex_newbuilding_sweep", + fake_sweep, + ) + monkeypatch.setattr(_sched, "SessionLocal", lambda: db) + + # mark_done is called inside _run; mock it so it doesn't fail on fake db + monkeypatch.setattr(_sched.runs_mod, "mark_done", lambda *a, **kw: None) + monkeypatch.setattr(_sched.runs_mod, "mark_failed", lambda *a, **kw: None) + + run_id = await _sched.trigger_yandex_newbuilding_sweep_run(db, _YANDEX_NB_SWEEP_ROW) + + assert run_id == 301 + assert calls["create_run"] == ["yandex_newbuilding_sweep"] + # Drain event-loop so the asyncio.create_task runs + await asyncio.sleep(0) + assert calls["sweep"] == [5] + + +@pytest.mark.asyncio +async def test_trigger_yandex_newbuilding_sweep_skips_when_running( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """trigger_yandex_newbuilding_sweep_run returns None when source already running.""" + db = _FakeSchedulerDB() + db.running["yandex_newbuilding_sweep"] = True + + calls: dict[str, int] = {"n": 0} + + def fake_create_run(_d, *, source, params): + calls["n"] += 1 + return 999 + + monkeypatch.setattr(_sched.runs_mod, "create_run", fake_create_run) + + result = await _sched.trigger_yandex_newbuilding_sweep_run(db, _YANDEX_NB_SWEEP_ROW) + + assert result is None + assert calls["n"] == 0 + + +@pytest.mark.asyncio +async def test_scheduler_dispatch_routes_yandex_newbuilding_sweep( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Dispatch block в scheduler_loop роутит yandex_newbuilding_sweep → trigger.""" + triggered: list[str] = [] + + async def fake_trigger(db, sch): + triggered.append(sch["source"]) + return 1 + + monkeypatch.setattr(_sched, "trigger_yandex_newbuilding_sweep_run", fake_trigger) + + db = object() + sch = _YANDEX_NB_SWEEP_ROW + source = sch["source"] + # Replicate the elif chain from scheduler_loop for yandex_newbuilding_sweep + if source == "yandex_newbuilding_sweep": + await _sched.trigger_yandex_newbuilding_sweep_run(db, sch) + + assert "yandex_newbuilding_sweep" in triggered + + +# ── #973: trigger_cian_city_sweep_run + dispatch ───────────────────────────── + + +_CIAN_SWEEP_ROW = { + "source": "cian_city_sweep", + "window_start_hour": 2, + "window_end_hour": 5, + "default_params": { + "pages_per_anchor": 3, + "request_delay_sec": 5.0, + "radius_m": 1500, + "detail_top_n": 10, + "enrich_houses": True, + }, +} + + +@pytest.mark.asyncio +async def test_trigger_cian_city_sweep_creates_run(monkeypatch: pytest.MonkeyPatch) -> None: + """trigger_cian_city_sweep_run claims run + launches create_task (#973).""" + db = _FakeSchedulerDB() + calls: dict[str, list] = {"create_run": [], "sweep": []} + + def fake_create_run(_d, *, source, params): + calls["create_run"].append(source) + db.running[source] = True + return 201 + + monkeypatch.setattr(_sched.runs_mod, "create_run", fake_create_run) + + # Patch run_cian_city_sweep to a coroutine that records call + async def fake_sweep(run_db, *, run_id, **kwargs): + calls["sweep"].append(run_id) + + monkeypatch.setattr(_sched, "run_cian_city_sweep", fake_sweep) + # Patch SessionLocal to return the same db + monkeypatch.setattr(_sched, "SessionLocal", lambda: db) + + run_id = await _sched.trigger_cian_city_sweep_run(db, _CIAN_SWEEP_ROW) + + assert run_id == 201 + assert calls["create_run"] == ["cian_city_sweep"] + # Drain event-loop so the asyncio.create_task runs + await asyncio.sleep(0) + assert calls["sweep"] == [201] + + +@pytest.mark.asyncio +async def test_trigger_cian_city_sweep_skips_when_running(monkeypatch: pytest.MonkeyPatch) -> None: + """trigger_cian_city_sweep_run returns None when source already running.""" + db = _FakeSchedulerDB() + db.running["cian_city_sweep"] = True + + calls: dict[str, int] = {"n": 0} + + def fake_create_run(_d, *, source, params): + calls["n"] += 1 + return 999 + + monkeypatch.setattr(_sched.runs_mod, "create_run", fake_create_run) + + result = await _sched.trigger_cian_city_sweep_run(db, _CIAN_SWEEP_ROW) + + assert result is None + assert calls["n"] == 0 + + +@pytest.mark.asyncio +async def test_scheduler_dispatch_routes_cian_city_sweep(monkeypatch: pytest.MonkeyPatch) -> None: + """Dispatch block в scheduler_loop роутит cian_city_sweep → trigger_cian_city_sweep_run.""" + triggered: list[str] = [] + + async def fake_trigger(db, sch): + triggered.append(sch["source"]) + return 1 + + monkeypatch.setattr(_sched, "trigger_cian_city_sweep_run", fake_trigger) + + # Simulate the dispatch block for one due schedule + db = object() + sch = _CIAN_SWEEP_ROW + source = sch["source"] + # Replicate the elif chain from scheduler_loop for cian_city_sweep + if source == "cian_city_sweep": + await _sched.trigger_cian_city_sweep_run(db, sch) + + assert "cian_city_sweep" in triggered diff --git a/tradein-mvp/backend/tests/test_scraper_proxy.py b/tradein-mvp/backend/tests/test_scraper_proxy.py index 1cb22271..b124c305 100644 --- a/tradein-mvp/backend/tests/test_scraper_proxy.py +++ b/tradein-mvp/backend/tests/test_scraper_proxy.py @@ -6,7 +6,8 @@ Covers: - None/empty → direct connection (no proxies dict) - _avito_proxies() helper returns correct dict shape - per-scraper proxy wiring: YandexRealty, YandexValuation, CianValuation, - CianNewbuilding (fetch_newbuilding + resolve_cian_zhk_url), AvitoIMV, AvitoDetail + CianNewbuilding (fetch_newbuilding — now BrowserFetcher, proxy is browser-level), + AvitoIMV, AvitoDetail """ from __future__ import annotations @@ -267,59 +268,81 @@ async def test_yandex_valuation_session_no_proxies_when_none(): # ── CianNewbuilding — fetch_newbuilding ─────────────────────────────────────── +# После #972 fetch_newbuilding тянет страницу ЖК через BrowserFetcher (camoufox), +# а НЕ через curl_cffi AsyncSession. Прокси применяются на уровне браузер-контейнера, +# а не через proxies= kwarg. AsyncSession для page-fetch больше НЕ создаётся. @pytest.mark.asyncio async def test_cian_newbuilding_own_session_receives_proxies(): - """fetch_newbuilding creates own session with proxies= when no session passed.""" + """fetch_newbuilding использует BrowserFetcher для page-fetch, не AsyncSession. + + Проверяем: + - BrowserFetcher.fetch(url) вызывается с правильным URL + - curl_cffi AsyncSession НЕ создаётся для page-fetch (прокси — browser-level) + """ from app.services.scrapers import cian_newbuilding - mock_session = MagicMock() - mock_resp = MagicMock() - mock_resp.status_code = 200 - mock_resp.text = "" # empty HTML → extract_state returns None → result None (ok) - mock_session.get = AsyncMock(return_value=mock_resp) - mock_session.close = AsyncMock() + zhk_url = "https://zhk-test.cian.ru/" + fetch_calls: list[str] = [] + session_ctor_calls: list[dict] = [] - captured_kwargs: dict = {} + mock_fetcher = MagicMock() + mock_fetcher.fetch = AsyncMock( + side_effect=lambda url: fetch_calls.append(url) or "" + ) + mock_fetcher.__aenter__ = AsyncMock(return_value=mock_fetcher) + mock_fetcher.__aexit__ = AsyncMock(return_value=None) - def _fake_session(*args, **kwargs): - captured_kwargs.update(kwargs) - return mock_session + def _fake_async_session(*args, **kwargs): + session_ctor_calls.append(kwargs) + return MagicMock() - with patch.object(cian_newbuilding, "AsyncSession", _fake_session): - with patch.object(cian_newbuilding, "settings", _mock_settings(_PROXY_URL)): - await cian_newbuilding.fetch_newbuilding("https://zhk-test.cian.ru/") + with patch.object(cian_newbuilding, "BrowserFetcher", lambda: mock_fetcher): + with patch.object(cian_newbuilding, "AsyncSession", _fake_async_session): + await cian_newbuilding.fetch_newbuilding(zhk_url) - assert captured_kwargs.get("proxies") == _EXPECTED_PROXIES + # BrowserFetcher.fetch вызван с ЖК-url + assert fetch_calls == [zhk_url] + # curl_cffi AsyncSession НЕ создавался для page-fetch + assert session_ctor_calls == [] @pytest.mark.asyncio async def test_cian_newbuilding_shared_session_not_recreated(): - """fetch_newbuilding does NOT create own session when one is passed.""" + """fetch_newbuilding принимает session= для backward-compat, но использует BrowserFetcher. + + Даже когда передан внешний session — страница ЖК тянется через BrowserFetcher, + AsyncSession не пересоздаётся, session.get не вызывается. + """ from app.services.scrapers import cian_newbuilding - mock_session = MagicMock() - mock_resp = MagicMock() - mock_resp.status_code = 200 - mock_resp.text = "" - mock_session.get = AsyncMock(return_value=mock_resp) - mock_session.close = AsyncMock() + zhk_url = "https://zhk-test.cian.ru/" + fetch_calls: list[str] = [] + session_ctor_calls: list[dict] = [] - session_ctor_calls = [] + mock_fetcher = MagicMock() + mock_fetcher.fetch = AsyncMock( + side_effect=lambda url: fetch_calls.append(url) or "" + ) + mock_fetcher.__aenter__ = AsyncMock(return_value=mock_fetcher) + mock_fetcher.__aexit__ = AsyncMock(return_value=None) - def _fake_session(*args, **kwargs): + def _fake_async_session(*args, **kwargs): session_ctor_calls.append(kwargs) return MagicMock() - with patch.object(cian_newbuilding, "AsyncSession", _fake_session): - with patch.object(cian_newbuilding, "settings", _mock_settings(_PROXY_URL)): - await cian_newbuilding.fetch_newbuilding( - "https://zhk-test.cian.ru/", session=mock_session - ) + sentinel_session = MagicMock(name="external_session") - # No own session should have been created (shared session was passed) + with patch.object(cian_newbuilding, "BrowserFetcher", lambda: mock_fetcher): + with patch.object(cian_newbuilding, "AsyncSession", _fake_async_session): + await cian_newbuilding.fetch_newbuilding(zhk_url, session=sentinel_session) + + # BrowserFetcher.fetch вызван — именно через него тянется страница + assert fetch_calls == [zhk_url] + # Ни нового AsyncSession, ни вызова переданного session.get — proxy browser-level assert session_ctor_calls == [] + sentinel_session.get.assert_not_called() # ── AvitoDetail — fetch_detail own-session ─────────────────────────────────── diff --git a/tradein-mvp/browser/server.py b/tradein-mvp/browser/server.py index 0d2081ed..bd692f82 100644 --- a/tradein-mvp/browser/server.py +++ b/tradein-mvp/browser/server.py @@ -92,6 +92,15 @@ _browser_cm: object | None = None # AsyncCamoufox context manager (для _page_counter: int = 0 # страниц с момента последнего (пере)запуска _fetch_lock: asyncio.Lock | None = None # сериализация fetch-запросов +# Resilience (#crash-storm): если прокси (mproxy.site) лежит, camoufox делает +# eager public_ip(proxy) для geoip-fingerprint и бросает InvalidProxy. Раньше это +# валило app.startup → exit(1) → docker restart → ∞ (RestartCount 3000+). Теперь +# фейл launch'а НЕ фатален: контейнер остаётся Up, а фоновая задача retry'ит +# launch с backoff пока прокси не вернётся. proxy+geoip СОХРАНЕНЫ (анти-бан). +_BROWSER_RETRY_MIN_S: float = 30.0 # стартовый интервал retry launch'а +_BROWSER_RETRY_MAX_S: float = 60.0 # потолок backoff'а +_browser_retry_task: asyncio.Task[None] | None = None # handle фоновой retry-задачи + async def _launch_browser() -> None: """Запускает AsyncCamoufox и сохраняет browser + CM в модульных переменных.""" @@ -147,16 +156,105 @@ async def _relaunch_browser() -> None: await _launch_browser() +async def _try_launch_browser() -> bool: + """Одна попытка launch'а с санитизированным логом фейла. + + НЕ пробрасывает исключение наружу — возвращает True/False. Критично для + resilience: фейл launch'а (прокси лёг → InvalidProxy) не должен валить + startup или фоновую задачу. Креды прокси НЕ логируются: camoufox светит + ``user:pass@host`` в тексте своего исключения, поэтому мы логируем только + тип + санитизированное сообщение, без ``str(exc)``. + + При частичном фейле (CM создан, но __aenter__ не довёл) чистит состояние + через _close_browser, чтобы следующий retry стартовал с чистого листа. + """ + try: + await _launch_browser() + return True + except Exception as exc: + logger.warning( + "tradein-browser: browser launch failed: proxy unreachable " + "(exc_type=%s) — контейнер остаётся Up, retry в фоне", + type(exc).__name__, + ) + # Подчистить возможный частичный CM перед следующей попыткой. + await _close_browser() + return False + + +async def _browser_retry_loop() -> None: + """Фоновый retry launch'а браузера с backoff, пока прокси не вернётся. + + Идемпотентна: если браузер уже поднят (_browser не None) — сразу выходит. + Иначе ретраит _try_launch_browser с возрастающим интервалом + (_BROWSER_RETRY_MIN_S → _BROWSER_RETRY_MAX_S). Завершается после первого + успешного launch'а. Отменяется в _on_cleanup (CancelledError пробрасывается). + """ + if _browser is not None: + return + + delay = _BROWSER_RETRY_MIN_S + while _browser is None: + await asyncio.sleep(delay) + # Лок сериализует launch с in-flight /fetch lazy-launch и recycle — + # иначе два __aenter__ на один глобал _browser затрут друг друга. + assert _fetch_lock is not None, "_fetch_lock not initialised" + async with _fetch_lock: + if _browser is not None: + return # handler уже поднял браузер — задача отработала + logger.info("tradein-browser: фоновый retry launch'а браузера") + launched = await _try_launch_browser() + if launched: + logger.info("tradein-browser: браузер поднят после retry, задача завершена") + return + delay = min(delay * 1.5, _BROWSER_RETRY_MAX_S) + + +def _start_retry_task(app: web.Application) -> None: + """Запускает (если ещё нет) фоновую retry-задачу и кладёт handle в app.""" + global _browser_retry_task + + existing = _browser_retry_task + if existing is not None and not existing.done(): + return # задача уже бежит — не плодим дубли + + task = asyncio.create_task(_browser_retry_loop()) + _browser_retry_task = task + app["browser_retry_task"] = task + + # ── aiohttp lifecycle hooks ──────────────────────────────────────────────────── async def _on_startup(app: web.Application) -> None: global _fetch_lock _fetch_lock = asyncio.Lock() - await _launch_browser() + + # Launch НЕ фатален: если прокси лежит, camoufox бросит InvalidProxy. Раньше + # это валило startup → exit(1) → restart-loop. Теперь ловим, остаёмся Up и + # ретраим в фоне. proxy+geoip сохранены — никакого direct-fetch fallback. + if not await _try_launch_browser(): + logger.warning( + "tradein-browser: старт без браузера (launch failed) — запуск фонового retry" + ) + _start_retry_task(app) async def _on_cleanup(app: web.Application) -> None: + global _browser_retry_task + + task = _browser_retry_task + if task is not None and not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + except Exception as exc: + logger.warning( + "tradein-browser: ошибка при отмене retry-задачи: %s", type(exc).__name__ + ) + _browser_retry_task = None await _close_browser() @@ -164,7 +262,12 @@ async def _on_cleanup(app: web.Application) -> None: async def health_handler(request: web.Request) -> web.Response: - return web.json_response({"status": "ok"}) + # Всегда 200 — иначе compose healthcheck (если появится) убьёт контейнер, + # хотя процесс жив и ретраит браузер. browser_ready — для observability: + # видно лежит ли прокси, не теряя контейнер. Текущий prod compose НЕ имеет + # healthcheck на browser (depends_on: condition: service_started), так что + # 200-без-браузера никого не рестартит. + return web.json_response({"status": "ok", "browser_ready": _browser is not None}) async def fetch_handler(request: web.Request) -> web.Response: @@ -187,6 +290,18 @@ async def fetch_handler(request: web.Request) -> web.Response: assert _fetch_lock is not None, "_fetch_lock not initialised" async with _fetch_lock: + # Браузер мог не подняться на старте (прокси лежал). Одна lazy-попытка + # под локом — вдруг прокси уже вернулся. НИКОГДА не фетчим без прокси/ + # браузера: если всё ещё None → 503 (caller словит как HTTPStatusError + # и обработает как failed fetch, а не как crash). + if _browser is None: + await _try_launch_browser() + if _browser is None: + logger.warning("tradein-browser: /fetch 503 — браузер недоступен (proxy may be down)") + return web.json_response( + {"error": "browser unavailable (proxy may be down)"}, status=503 + ) + try: html = await _do_fetch(url) except Exception as exc: @@ -290,6 +405,16 @@ async def login_handler(request: web.Request) -> web.Response: assert _fetch_lock is not None, "_fetch_lock not initialised" async with _fetch_lock: + # Та же resilience что и в /fetch: браузер мог не подняться (прокси лежал). + # Lazy-попытка под локом, иначе 503 — без прокси не логинимся. + if _browser is None: + await _try_launch_browser() + if _browser is None: + logger.warning("tradein-browser: /login 503 — браузер недоступен (proxy may be down)") + return web.json_response( + {"error": "browser unavailable (proxy may be down)"}, status=503 + ) + try: cookies = await _do_login(body) except LoginError as exc: diff --git a/tradein-mvp/browser/test_server_smoke.py b/tradein-mvp/browser/test_server_smoke.py new file mode 100644 index 00000000..2725bf6c --- /dev/null +++ b/tradein-mvp/browser/test_server_smoke.py @@ -0,0 +1,124 @@ +"""test_server_smoke.py — смоук для resilience-логики tradein-browser. + +Полный браузер (camoufox subprocess) юнит-тестить непрактично, поэтому здесь +только смоук: (1) модуль импортируется; (2) /fetch и /login отдают 503 когда +браузер не поднят (прокси лежит) — критичный resilience-инвариант, защищающий +от crash-storm. camoufox в окружении теста НЕ нужен: launch замокан no-op'ом, +имитируя «прокси всё ещё недоступен». + +Запуск (из tradein-mvp/browser/):: + + python -m pytest test_server_smoke.py -q + +aiohttp.test_utils.make_mocked_request не поднимает реальный сервер/сокет — +хендлер вызывается напрямую, без event-loop-сервера. +""" + +from __future__ import annotations + +import asyncio +import importlib.util +import json +from pathlib import Path +from typing import Any + +import pytest +from aiohttp.test_utils import make_mocked_request + +# server.py — не пакет (отдельный сервис без __init__/pyproject). Грузим по пути. +_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) + + +def _json_body(response: Any) -> dict[str, Any]: + """Достаёт JSON-тело из aiohttp Response (body — bytes после json_response).""" + return json.loads(response.body.decode()) + + +def test_module_imports() -> None: + """Смоук: модуль импортируется и экспонирует ключевые хендлеры.""" + assert callable(server.fetch_handler) + assert callable(server.login_handler) + assert callable(server.health_handler) + + +def test_health_reports_browser_not_ready(monkeypatch: pytest.MonkeyPatch) -> None: + """/health всегда 200, browser_ready=False когда браузер не поднят.""" + monkeypatch.setattr(server, "_browser", None) + request = make_mocked_request("GET", "/health") + response = asyncio.run(server.health_handler(request)) + assert response.status == 200 + body = _json_body(response) + assert body["status"] == "ok" + assert body["browser_ready"] is False + + +def test_fetch_returns_503_when_browser_unavailable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """/fetch отдаёт 503 (не 500/краш) когда браузер не поднят и launch не удался. + + Имитирует «прокси лежит»: _browser=None + _try_launch_browser no-op, + оставляющий None. Никакого реального camoufox-launch'а / direct-fetch. + """ + monkeypatch.setattr(server, "_browser", None) + monkeypatch.setattr(server, "_fetch_lock", asyncio.Lock()) + + async def _no_op_launch() -> bool: + return False # прокси всё ещё недоступен → браузер остаётся None + + monkeypatch.setattr(server, "_try_launch_browser", _no_op_launch) + + request = make_mocked_request( + "POST", "/fetch", payload=b'{"url": "https://example.com"}' + ) + + async def _run() -> Any: + # make_mocked_request не парсит тело — подменяем .json() на нужный payload. + request.json = lambda: _coro({"url": "https://example.com"}) # type: ignore[method-assign] + return await server.fetch_handler(request) + + response = asyncio.run(_run()) + assert response.status == 503 + body = _json_body(response) + assert "browser unavailable" in body["error"] + + +def test_login_returns_503_when_browser_unavailable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """/login тоже отдаёт 503 когда браузер не поднят (без credential-логирования).""" + monkeypatch.setattr(server, "_browser", None) + monkeypatch.setattr(server, "_fetch_lock", asyncio.Lock()) + + async def _no_op_launch() -> bool: + return False + + monkeypatch.setattr(server, "_try_launch_browser", _no_op_launch) + + login_body = { + "url": "https://cian.example/login", + "email": "u@example.com", + "password": "secret", + "email_selector": "#email", + "password_selector": "#password", + "submit_selector": "#submit", + } + request = make_mocked_request("POST", "/login") + + async def _run() -> Any: + request.json = lambda: _coro(login_body) # type: ignore[method-assign] + return await server.login_handler(request) + + response = asyncio.run(_run()) + assert response.status == 503 + body = _json_body(response) + assert "browser unavailable" in body["error"] + + +async def _coro(value: Any) -> Any: + """Хелпер: оборачивает значение в awaitable для подмены request.json().""" + return value