Merge remote-tracking branch 'forgejo/main' into HEAD
Some checks failed
CI / changes (push) Successful in 7s
CI / changes (pull_request) Successful in 8s
CI / frontend-tests (push) Successful in 1m7s
CI / openapi-codegen-check (push) Failing after 2m9s
CI / frontend-tests (pull_request) Successful in 1m4s
CI / openapi-codegen-check (pull_request) Failing after 1m37s
CI / backend-tests (pull_request) Failing after 9m7s
CI / backend-tests (push) Failing after 9m11s

# Conflicts:
#	backend/app/services/site_finder/gate_verdict.py
This commit is contained in:
bot-backend 2026-06-15 21:07:05 +03:00
commit 479f01ae1a
33 changed files with 4510 additions and 191 deletions

View file

@ -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) domrfcad_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,

View file

@ -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 /

View file

@ -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 руб/м² (диапазон 80k250k для ЕКБ ✓).
#
# 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)

View file

@ -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(

View file

@ -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",
]

View file

@ -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",
]

View file

@ -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 → <GeoJSON>).
# 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:

View file

@ -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.50.85 — дефицит | 0.851.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,
}

View file

@ -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

View file

@ -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()

View file

@ -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")

View file

@ -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():

View file

@ -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

View file

@ -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)

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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) domrfcad_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;

View file

@ -1470,8 +1470,9 @@ async def scrape_yandex_newbuilding(
URL: /{city}/kupit/novostrojka/<slug>-<id>/
"""
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

View file

@ -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:

View file

@ -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 ----

View file

@ -3,6 +3,12 @@
URL pattern: /{city}/kupit/novostrojka/<slug>-<id>/
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/<slug>-<id>/
# Используется в 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=<jk_id>
2. В HTML найти первую ссылку вида /<city>/kupit/novostrojka/<slug>-<jk_id>/
через 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/<slug>-<id>/
# Ограничиваем: 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",
]

View file

@ -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 используется для resolveenrich gap внутри одного дома.
"""
if delay <= 0:
return
if not force and idx >= total - 1:
return
await asyncio.sleep(delay * random.uniform(0.8, 1.2))

View file

@ -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/<slug>-<id>/. '
'Персистируется 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;

View file

@ -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;

View file

@ -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;

View file

@ -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

View file

@ -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("<html></html>", 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 = "<html></html>"
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("<html></html>", 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("<html>fake</html>", 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 = "<html>fake</html>"
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

View file

@ -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

View file

@ -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 "<html></html>"
)
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 "<html></html>"
)
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 ───────────────────────────────────

View file

@ -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:

View file

@ -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