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
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:
commit
479f01ae1a
33 changed files with 4510 additions and 191 deletions
|
|
@ -22,6 +22,7 @@ from app.schemas.parcel import (
|
||||||
AnalyzeResponse,
|
AnalyzeResponse,
|
||||||
BestLayoutsRequest,
|
BestLayoutsRequest,
|
||||||
BestLayoutsResponse,
|
BestLayoutsResponse,
|
||||||
|
CompetitorsParkingResponse,
|
||||||
CompetitorsRequest,
|
CompetitorsRequest,
|
||||||
CompetitorsResponse,
|
CompetitorsResponse,
|
||||||
ConnectionPointsResponse,
|
ConnectionPointsResponse,
|
||||||
|
|
@ -58,7 +59,7 @@ from app.services.site_finder.cadastre_fetch import (
|
||||||
from app.services.site_finder.cadastre_fetch import (
|
from app.services.site_finder.cadastre_fetch import (
|
||||||
fetch_status as _fetch_status,
|
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 (
|
from app.services.site_finder.custom_pois import (
|
||||||
get_overlaps_for_scoring as _get_custom_poi_overlaps,
|
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,
|
get_quarter_dump_data,
|
||||||
make_empty_result,
|
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.velocity import compute_velocity
|
||||||
from app.services.site_finder.weight_profiles import (
|
from app.services.site_finder.weight_profiles import (
|
||||||
_SYSTEM_POI_WEIGHTS as _POI_WEIGHTS,
|
_SYSTEM_POI_WEIGHTS as _POI_WEIGHTS,
|
||||||
|
|
@ -1599,7 +1601,23 @@ def analyze_parcel(
|
||||||
WHERE latitude IS NOT NULL
|
WHERE latitude IS NOT NULL
|
||||||
ORDER BY obj_id, snapshot_date DESC NULLS LAST
|
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 (
|
obj_pricing AS (
|
||||||
|
-- Pushdown: агрегируем objective_lots ТОЛЬКО для ближних obj_id.
|
||||||
|
-- AVG/COUNT per domrf_obj_id независимы между группами, поэтому
|
||||||
|
-- ограничение scope даёт байт-идентичные значения для тех obj_id,
|
||||||
|
-- что реально джойнятся ниже (далёкие медианы раньше считались зря).
|
||||||
SELECT
|
SELECT
|
||||||
cm.domrf_obj_id,
|
cm.domrf_obj_id,
|
||||||
ROUND(AVG(ol.price_per_m2_rub)::numeric, 0) AS avg_price_per_m2_rub,
|
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
|
) AS lots_with_price
|
||||||
FROM objective_complex_mapping cm
|
FROM objective_complex_mapping cm
|
||||||
JOIN objective_lots ol ON ol.project_name = cm.objective_complex_name
|
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
|
GROUP BY cm.domrf_obj_id
|
||||||
)
|
)
|
||||||
SELECT o.obj_id,
|
SELECT o.obj_id,
|
||||||
|
|
@ -1632,13 +1651,8 @@ def analyze_parcel(
|
||||||
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
||||||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
|
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
|
||||||
) AS distance_m
|
) AS distance_m
|
||||||
FROM latest_obj o
|
FROM nearby_obj o
|
||||||
LEFT JOIN obj_pricing p ON p.domrf_obj_id = o.obj_id
|
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,
|
ORDER BY CASE o.site_status WHEN 'Строящиеся' THEN 0 ELSE 1 END,
|
||||||
distance_m ASC
|
distance_m ASC
|
||||||
LIMIT 20
|
LIMIT 20
|
||||||
|
|
@ -2700,11 +2714,31 @@ def analyze_parcel(
|
||||||
"top_sellers": _top_sellers_list,
|
"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] = {
|
result_payload: dict[str, Any] = {
|
||||||
"cad_num": cad_num,
|
"cad_num": cad_num,
|
||||||
"source": source,
|
"source": source,
|
||||||
"geom_geojson": json.loads(geom_geojson) if geom_geojson else None,
|
"geom_geojson": json.loads(geom_geojson) if geom_geojson else None,
|
||||||
"district": dict(district_row) if district_row 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": round(score_final, 2),
|
||||||
"score_without_center": round(score, 2),
|
"score_without_center": round(score, 2),
|
||||||
"score_label": _score_label(score_final),
|
"score_label": _score_label(score_final),
|
||||||
|
|
@ -3161,6 +3195,36 @@ async def get_parcel_competitors(
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{cad_num}/competitors-parking", response_model=CompetitorsParkingResponse)
|
||||||
|
def get_parcel_competitors_parking(
|
||||||
|
cad_num: str,
|
||||||
|
body: CompetitorsRequest,
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
) -> CompetitorsParkingResponse:
|
||||||
|
"""parking_ratio top-N конкурентов (#96 wiring) — ЛЕНИВЫЙ путь, вне analyze.
|
||||||
|
|
||||||
|
Резолвит top-3 конкурента (relevance_weight DESC) domrf→cad_buildings через
|
||||||
|
PostGIS geom-match, затем on-demand НСПД premises-lookup → parking_ratio.
|
||||||
|
Намеренно ОТДЕЛЬНЫЙ от analyze/forecast: N×НСПД (~0.6-2с каждый) добавил бы
|
||||||
|
секунды в хот-путь и убил p95 — фронт дёргает этот эндпоинт лениво после
|
||||||
|
отрисовки списка конкурентов.
|
||||||
|
|
||||||
|
sync `def` (не async): premises_lookup изолирует НСПД-async через
|
||||||
|
asyncio.run() — его нельзя вызвать из async-handler (running loop). FastAPI
|
||||||
|
исполняет sync-handler в threadpool, не блокируя event loop.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return get_competitors_parking(db=db, cad_num=cad_num, request=body)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("competitors-parking endpoint failed for %s: %s", cad_num, exc)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail="Ошибка расчёта паркинга конкурентов",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{cad_num}/best-layouts", response_model=BestLayoutsResponse)
|
@router.post("/{cad_num}/best-layouts", response_model=BestLayoutsResponse)
|
||||||
async def get_parcel_best_layouts(
|
async def get_parcel_best_layouts(
|
||||||
cad_num: str,
|
cad_num: str,
|
||||||
|
|
|
||||||
|
|
@ -198,6 +198,11 @@ class Competitor(BaseModel):
|
||||||
sold_pct: float | None
|
sold_pct: float | None
|
||||||
velocity_per_month: float
|
velocity_per_month: float
|
||||||
avg_price_per_m2: float | None
|
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
|
is_active: bool
|
||||||
# #949 PR B (ТЗ §9.1, §16): детерминированный relevance_weight ∈ [0,1] —
|
# #949 PR B (ТЗ §9.1, §16): детерминированный relevance_weight ∈ [0,1] —
|
||||||
# взвешенная комбинация geo_proximity / class_similarity / price_similarity /
|
# взвешенная комбинация geo_proximity / class_similarity / price_similarity /
|
||||||
|
|
@ -220,6 +225,43 @@ class CompetitorsResponse(BaseModel):
|
||||||
summary: CompetitorsSummary
|
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-форсайт») ─────────────────────────────────
|
# ── Developer attribution (#1088 «GG-форсайт») ─────────────────────────────────
|
||||||
# Детерминированная атрибуция «пятно земли ↔ застройщик»: канон-застройщик участка
|
# Детерминированная атрибуция «пятно земли ↔ застройщик»: канон-застройщик участка
|
||||||
# + его track-record (РНС/РВЭ + домрф ЖК/квартиры) из fn_developer_for_parcel /
|
# + его track-record (РНС/РВЭ + домрф ЖК/квартиры) из fn_developer_for_parcel /
|
||||||
|
|
|
||||||
|
|
@ -24,14 +24,27 @@ from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.schemas.parcel import (
|
from app.schemas.parcel import (
|
||||||
Competitor,
|
Competitor,
|
||||||
|
CompetitorParking,
|
||||||
|
CompetitorsParkingResponse,
|
||||||
CompetitorsRequest,
|
CompetitorsRequest,
|
||||||
CompetitorsResponse,
|
CompetitorsResponse,
|
||||||
CompetitorsSummary,
|
CompetitorsSummary,
|
||||||
)
|
)
|
||||||
from app.services.forecast_request_cache import cached
|
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__)
|
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, ...]:
|
def _competitors_key(db: Session, cad_num: str, request: CompetitorsRequest) -> tuple[Any, ...]:
|
||||||
"""Ключ кэша §22-форсайта для get_competitors (#1129) — что РЕАЛЬНО влияет на вывод.
|
"""Ключ кэша §22-форсайта для get_competitors (#1129) — что РЕАЛЬНО влияет на вывод.
|
||||||
|
|
@ -50,6 +63,7 @@ def _competitors_key(db: Session, cad_num: str, request: CompetitorsRequest) ->
|
||||||
tuple(sorted(request.exclude_obj_ids)),
|
tuple(sorted(request.exclude_obj_ids)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Маппинг time_window → число месяцев (float для деления velocity)
|
# Маппинг time_window → число месяцев (float для деления velocity)
|
||||||
_TIME_WINDOW_MONTHS: dict[str, float] = {
|
_TIME_WINDOW_MONTHS: dict[str, float] = {
|
||||||
"last_month": 1.0,
|
"last_month": 1.0,
|
||||||
|
|
@ -456,6 +470,37 @@ _AVG_PRICE_SQL = text("""
|
||||||
GROUP BY f.obj_id
|
GROUP BY f.obj_id
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
# OBJ-3 (#307): additive fallback цены м² из objective_lots, когда domrf-цены нет.
|
||||||
|
# Замер прода 2026-06: на ПОСЛЕДНЕМ snapshot domrf_kn_flats.price_per_m2 = 0 строк
|
||||||
|
# (скрапер перестал отдавать per-flat цены), поэтому _AVG_PRICE_SQL выше даёт
|
||||||
|
# avg_price_per_m2=None почти всем конкурентам. objective_lots.price_per_m2_rub
|
||||||
|
# покрывает ~80% (919795/1123771 строк) и мостится к domrf-obj_id через
|
||||||
|
# objective_complex_mapping (то же звено, что _INLINE_VELOCITY_SQL в best_layouts).
|
||||||
|
#
|
||||||
|
# Мост: domrf_kn_objects.obj_id == objective_complex_mapping.domrf_obj_id
|
||||||
|
# → objective_complex_mapping.objective_complex_name == objective_lots.project_name.
|
||||||
|
# Маппинг 1:1 (прод: 263 строки, 263 distinct obj_id, 0 obj_id с >1 именем), поэтому
|
||||||
|
# на obj_id приходится РОВНО один project_name; корпуса проекта агрегируются в один
|
||||||
|
# проект естественно. Берём МЕДИАНУ price_per_m2_rub по всем лотам проекта
|
||||||
|
# (percentile_cont 0.5) — устойчива к хвостовым выбросам пентхаусов/студий, в отличие
|
||||||
|
# от AVG. Прод-санити: медиана медиан 136k руб/м² (диапазон 80k–250k для ЕКБ ✓).
|
||||||
|
#
|
||||||
|
# Additive-контракт: возвращаем ТОЛЬКО obj_id, у которых objective-цена есть; в Python
|
||||||
|
# заполняем пробелы (domrf-цена приоритетна, objective — fallback). Существующие
|
||||||
|
# непустые domrf-выводы НЕ меняются. price_source делает источник прозрачным.
|
||||||
|
_OBJECTIVE_PRICE_FALLBACK_SQL = text("""
|
||||||
|
SELECT
|
||||||
|
cm.domrf_obj_id AS obj_id,
|
||||||
|
percentile_cont(0.5) WITHIN GROUP (ORDER BY ol.price_per_m2_rub)
|
||||||
|
AS median_price_per_m2
|
||||||
|
FROM objective_complex_mapping cm
|
||||||
|
JOIN objective_lots ol
|
||||||
|
ON ol.project_name = cm.objective_complex_name
|
||||||
|
WHERE cm.domrf_obj_id = ANY(:obj_ids)
|
||||||
|
AND ol.price_per_m2_rub IS NOT NULL
|
||||||
|
GROUP BY cm.domrf_obj_id
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
def _quarter_from_cad(cad_num: str) -> str:
|
def _quarter_from_cad(cad_num: str) -> str:
|
||||||
"""Извлечь кадастровый квартал из номера участка/здания.
|
"""Извлечь кадастровый квартал из номера участка/здания.
|
||||||
|
|
@ -579,6 +624,7 @@ def get_competitors(
|
||||||
# Читаем через _row_get: существующие тесты мокируют только avg_price_per_m2,
|
# Читаем через _row_get: существующие тесты мокируют только avg_price_per_m2,
|
||||||
# отсутствующий ключ flats_sold → None (не KeyError) → нейтральный stage.
|
# отсутствующий ключ flats_sold → None (не KeyError) → нейтральный stage.
|
||||||
avg_price_map: dict[int, float] = {}
|
avg_price_map: dict[int, float] = {}
|
||||||
|
price_source_map: dict[int, str] = {}
|
||||||
sold_count_map: dict[int, int] = {}
|
sold_count_map: dict[int, int] = {}
|
||||||
try:
|
try:
|
||||||
price_rows = db.execute(_AVG_PRICE_SQL, {"obj_ids": obj_ids}).mappings().all()
|
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")
|
price = _row_get(r, "avg_price_per_m2")
|
||||||
if price is not None:
|
if price is not None:
|
||||||
avg_price_map[oid] = float(price)
|
avg_price_map[oid] = float(price)
|
||||||
|
price_source_map[oid] = "domrf"
|
||||||
sold = _row_get(r, "flats_sold")
|
sold = _row_get(r, "flats_sold")
|
||||||
if sold is not None:
|
if sold is not None:
|
||||||
sold_count_map[oid] = int(sold)
|
sold_count_map[oid] = int(sold)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("competitors: avg_price query failed, continuing without prices")
|
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 (медиана цены, доминирующий класс) ─
|
# ── 4b. Локальные эталоны для relevance (медиана цены, доминирующий класс) ─
|
||||||
radius_m = request.radius_km * 1000.0
|
radius_m = request.radius_km * 1000.0
|
||||||
local_median_ppm2 = _median(list(avg_price_map.values()))
|
local_median_ppm2 = _median(list(avg_price_map.values()))
|
||||||
|
|
@ -656,6 +726,7 @@ def get_competitors(
|
||||||
sold_pct=sold_pct,
|
sold_pct=sold_pct,
|
||||||
velocity_per_month=velocity,
|
velocity_per_month=velocity,
|
||||||
avg_price_per_m2=avg_price_map.get(obj_id),
|
avg_price_per_m2=avg_price_map.get(obj_id),
|
||||||
|
price_source=price_source_map.get(obj_id),
|
||||||
is_active=is_active,
|
is_active=is_active,
|
||||||
relevance_weight=round(relevance_weight, 3),
|
relevance_weight=round(relevance_weight, 3),
|
||||||
relevance_breakdown=relevance_breakdown,
|
relevance_breakdown=relevance_breakdown,
|
||||||
|
|
@ -692,3 +763,80 @@ def get_competitors(
|
||||||
)
|
)
|
||||||
|
|
||||||
return CompetitorsResponse(competitors=competitors, summary=summary)
|
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)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,11 @@ from __future__ import annotations
|
||||||
|
|
||||||
from typing import Literal, TypedDict
|
from typing import Literal, TypedDict
|
||||||
|
|
||||||
|
from app.services.site_finder.network_obremenenie import (
|
||||||
|
classify_network_zone,
|
||||||
|
network_kind_label,
|
||||||
|
)
|
||||||
|
|
||||||
# ── Zone matching ─────────────────────────────────────────────────────────────
|
# ── Zone matching ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Residential zone codes — точные ПЗЗ Свердловска (Ж-1..Ж-5)
|
# Residential zone codes — точные ПЗЗ Свердловска (Ж-1..Ж-5)
|
||||||
|
|
@ -155,9 +160,17 @@ def compute_gate_verdict(
|
||||||
# cad_zouit fallback path: classify by type_zone keywords (#232).
|
# cad_zouit fallback path: classify by type_zone keywords (#232).
|
||||||
# subcategory = NULL в cad_zouit, поэтому subcategory-based logic не применяется.
|
# subcategory = NULL в cad_zouit, поэтому subcategory-based logic не применяется.
|
||||||
type_zone_lower = (overlap.get("type_zone") or overlap.get("layer") or "").lower()
|
type_zone_lower = (overlap.get("type_zone") or overlap.get("layer") or "").lower()
|
||||||
# СЗЗ-маркер специфичнее широких blocker-подстрок ('газ'/'электр'),
|
# #1070: классификация «сетевого обременения» (охранная зона инж.сети).
|
||||||
# поэтому проверяем warning-keywords ПЕРВЫМИ (#1341). СЗЗ газораспределительной
|
# Флаг приходит из _get_cad_zouit_overlaps (is_network_zone/network_kind);
|
||||||
# станции/электроподстанции — ограничение, а не hard-blocker.
|
# если 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):
|
if any(kw in type_zone_lower for kw in WARNING_TYPE_ZONE_KEYWORDS):
|
||||||
warnings.append(
|
warnings.append(
|
||||||
Warning(
|
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):
|
elif any(kw in type_zone_lower for kw in BLOCKER_TYPE_ZONE_KEYWORDS):
|
||||||
blockers.append(
|
blockers.append(
|
||||||
Blocker(
|
Blocker(
|
||||||
|
|
|
||||||
129
backend/app/services/site_finder/network_obremenenie.py
Normal file
129
backend/app/services/site_finder/network_obremenenie.py
Normal 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",
|
||||||
|
]
|
||||||
|
|
@ -30,6 +30,10 @@ from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.schemas.nspd_bulk import ObjectsListing
|
from app.schemas.nspd_bulk import ObjectsListing
|
||||||
from app.scrapers.nspd_bulk_client import NSPDBulkClient, NspdBulkError
|
from app.scrapers.nspd_bulk_client import NSPDBulkClient, NspdBulkError
|
||||||
|
|
@ -41,6 +45,123 @@ logger = logging.getLogger(__name__)
|
||||||
# docstring: 1 ЗУ / 2 квартал / 5 здание). Для cad_num здания сужает выдачу.
|
# docstring: 1 ЗУ / 2 квартал / 5 здание). Для cad_num здания сужает выдачу.
|
||||||
_THEMATIC_BUILDING = 5
|
_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:
|
def _resolve_objdoc_id(cad_num: str, client: NSPDClient) -> int | None:
|
||||||
"""objdoc_id здания по cad_num через NSPD geoportal search.
|
"""objdoc_id здания по cad_num через NSPD geoportal search.
|
||||||
|
|
@ -127,4 +248,38 @@ def get_building_premises(cad_num: str) -> ObjectsListing | None:
|
||||||
return listing
|
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",
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,11 @@ from sqlalchemy import text
|
||||||
from sqlalchemy.exc import DataError, OperationalError, ProgrammingError
|
from sqlalchemy.exc import DataError, OperationalError, ProgrammingError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.services.site_finder.network_obremenenie import (
|
||||||
|
classify_network_zone,
|
||||||
|
network_kind_label,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Порог свежести дампа — 180 дней. Совпадает с beat-расписанием (раз в квартал
|
# Порог свежести дампа — 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]] = []
|
result: list[dict[str, Any]] = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
type_zone: str = r[0] or ""
|
type_zone: str = r[0] or ""
|
||||||
|
# #1070: выделяем «сетевые обременения» — охранные зоны инж.сетей
|
||||||
|
# (тепло/электро/газ/вода/связь/трубопровод/общие инж.коммуникации).
|
||||||
|
# Pure-function классификация по type_zone; None если зона не сетевая.
|
||||||
|
net_kind = classify_network_zone(type_zone)
|
||||||
result.append(
|
result.append(
|
||||||
{
|
{
|
||||||
"group_key": "cad_zouit",
|
"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",
|
"source": "cad_zouit",
|
||||||
"type_zone": type_zone,
|
"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>).
|
# #255: geom для ZouitLayer на Leaflet (frontend JSON.parse → <GeoJSON>).
|
||||||
# geom — GEOGRAPHY, ST_AsGeoJSON требует geometry → CAST(... AS geometry).
|
# geom — GEOGRAPHY, ST_AsGeoJSON требует geometry → CAST(... AS geometry).
|
||||||
"geom_geojson": r[5],
|
"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]
|
harvest_error: str | None = dump_row[2]
|
||||||
is_stale = False
|
is_stale = False
|
||||||
if fetched_at is not None and getattr(fetched_at, "tzinfo", "missing") != "missing":
|
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(
|
fetched_at_aware = (
|
||||||
tzinfo=UTC
|
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)
|
is_stale = (datetime.now(UTC) - fetched_at_aware) > timedelta(days=_DUMP_MAX_AGE_DAYS)
|
||||||
if harvest_error is not None or is_stale:
|
if harvest_error is not None or is_stale:
|
||||||
|
|
|
||||||
297
backend/app/services/site_finder/saturation.py
Normal file
297
backend/app/services/site_finder/saturation.py
Normal 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.5–0.85 — дефицит | 0.85–1.3 — норма | > 1.3 — профицит.
|
||||||
|
_SEVERITY_ACUTE_DEFICIT = 0.5
|
||||||
|
_SEVERITY_DEFICIT = 0.85
|
||||||
|
_SEVERITY_SURPLUS = 1.3
|
||||||
|
|
||||||
|
# ── POI-score множитель (acceptance #42): дефицит → boost, профицит → штраф ───
|
||||||
|
_MULT_DEFICIT = 1.2 # дефицитный район: incentive строить такую соцобъект-adjacent
|
||||||
|
_MULT_SURPLUS = 0.5 # перенасыщенный район: сигнал «ещё одна школа мало что даёт»
|
||||||
|
_MULT_NEUTRAL = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def classify_severity(provision_ratio: float | None) -> str | None:
|
||||||
|
"""provision_ratio (факт/норматив) → ярлык severity. PURE.
|
||||||
|
|
||||||
|
None → None (нет данных: нет когорты / нет норматива — НЕ «острый дефицит»).
|
||||||
|
"""
|
||||||
|
if provision_ratio is None:
|
||||||
|
return None
|
||||||
|
if provision_ratio < _SEVERITY_ACUTE_DEFICIT:
|
||||||
|
return "острый дефицит"
|
||||||
|
if provision_ratio < _SEVERITY_DEFICIT:
|
||||||
|
return "дефицит"
|
||||||
|
if provision_ratio <= _SEVERITY_SURPLUS:
|
||||||
|
return "норма"
|
||||||
|
return "профицит"
|
||||||
|
|
||||||
|
|
||||||
|
def saturation_multiplier(category: str, provision_ratio: float | None) -> float:
|
||||||
|
"""Множитель для POI-score по обеспеченности района категорией. PURE.
|
||||||
|
|
||||||
|
Acceptance #42: дефицитный район → ×1.2 (стимул строить школо-adjacent),
|
||||||
|
перенасыщенный → ×0.5. Норма / нет данных / категория без норматива → ×1.0
|
||||||
|
(нейтрально: не наказываем за отсутствие сигнала).
|
||||||
|
"""
|
||||||
|
if category not in _NORMS or provision_ratio is None:
|
||||||
|
return _MULT_NEUTRAL
|
||||||
|
if provision_ratio < _SEVERITY_DEFICIT:
|
||||||
|
return _MULT_DEFICIT
|
||||||
|
if provision_ratio > _SEVERITY_SURPLUS:
|
||||||
|
return _MULT_SURPLUS
|
||||||
|
return _MULT_NEUTRAL
|
||||||
|
|
||||||
|
|
||||||
|
def compute_provision_ratio(
|
||||||
|
*,
|
||||||
|
poi_count: int,
|
||||||
|
population: int,
|
||||||
|
age_share: float | None,
|
||||||
|
category: str,
|
||||||
|
) -> float | None:
|
||||||
|
"""Коэффициент обеспеченности района категорией = факт / норматив. PURE.
|
||||||
|
|
||||||
|
факт_мест_на_1000 = poi_count × object_capacity / (cohort_pop / 1000)
|
||||||
|
норматив_мест_на_1000 = _NORMS[category].capacity_per_1000
|
||||||
|
provision_ratio = факт / норматив
|
||||||
|
|
||||||
|
cohort_pop = population × age_share (для 'all' — age_share=1.0).
|
||||||
|
None если: неизвестная категория / нет населения / нулевая когорта.
|
||||||
|
"""
|
||||||
|
norm = _NORMS.get(category)
|
||||||
|
if norm is None or population <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
share = 1.0 if norm.cohort == "all" else age_share
|
||||||
|
if share is None or share <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cohort_pop = population * share
|
||||||
|
if cohort_pop <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
object_capacity = _OBJECT_CAPACITY.get(category, 1.0)
|
||||||
|
actual_per_1000 = (poi_count * object_capacity) / (cohort_pop / 1000.0)
|
||||||
|
if norm.capacity_per_1000 <= 0:
|
||||||
|
return None
|
||||||
|
return actual_per_1000 / norm.capacity_per_1000
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# DB-зависимый слой: spatial POI-count по району + join демографии.
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# POI-count категорий в полигоне района + демография района. ekb_districts.geom —
|
||||||
|
# полигон (миграция 56/74), osm_poi_ekb.geom — Point. ST_Contains(poly, point).
|
||||||
|
# LEFT JOIN osm_poi_ekb, чтобы район без POI давал 0, а не отсутствие строки.
|
||||||
|
_DISTRICT_SATURATION_SQL = text(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
d.population,
|
||||||
|
d.area_km2,
|
||||||
|
d.age_share_preschool,
|
||||||
|
d.age_share_school,
|
||||||
|
d.age_share_elderly,
|
||||||
|
d.age_cohorts_estimated,
|
||||||
|
d.source,
|
||||||
|
d.as_of_date,
|
||||||
|
COUNT(*) FILTER (WHERE p.category = 'school') AS n_school,
|
||||||
|
COUNT(*) FILTER (WHERE p.category = 'kindergarten') AS n_kindergarten,
|
||||||
|
COUNT(*) FILTER (WHERE p.category = 'hospital') AS n_hospital
|
||||||
|
FROM ekb_district_demographics d
|
||||||
|
JOIN ekb_districts g
|
||||||
|
ON g.district_name = d.district_name
|
||||||
|
AND g.geom IS NOT NULL
|
||||||
|
LEFT JOIN osm_poi_ekb p
|
||||||
|
ON p.category IN ('school', 'kindergarten', 'hospital')
|
||||||
|
AND ST_Contains(g.geom, p.geom)
|
||||||
|
WHERE d.district_name = CAST(:dn AS text)
|
||||||
|
GROUP BY
|
||||||
|
d.population, d.area_km2, d.age_share_preschool, d.age_share_school,
|
||||||
|
d.age_share_elderly, d.age_cohorts_estimated, d.source, d.as_of_date
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Какую возрастную долю брать для категории (для отчёта по когорте).
|
||||||
|
_CATEGORY_SHARE_COLUMN: dict[str, str] = {
|
||||||
|
"school": "age_share_school",
|
||||||
|
"kindergarten": "age_share_preschool",
|
||||||
|
"hospital": "age_share_elderly", # информативно; норматив hospital — на всё население
|
||||||
|
}
|
||||||
|
_CATEGORY_COUNT_COLUMN: dict[str, str] = {
|
||||||
|
"school": "n_school",
|
||||||
|
"kindergarten": "n_kindergarten",
|
||||||
|
"hospital": "n_hospital",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _round_or_none(value: float | None, digits: int = 3) -> float | None:
|
||||||
|
return round(value, digits) if value is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def compute_district_saturation(db: Session, district_name: str) -> dict[str, Any] | None:
|
||||||
|
"""Saturation per capita района — блок для analyze (`infra.saturation`).
|
||||||
|
|
||||||
|
Возвращает dict (JSON-готовый) либо None, если у района нет демографии
|
||||||
|
(нет строки в ekb_district_demographics / нет geom-полигона).
|
||||||
|
|
||||||
|
Структура:
|
||||||
|
{
|
||||||
|
"district": "Чкаловский",
|
||||||
|
"population": 286277,
|
||||||
|
"as_of_date": "2025-01-01",
|
||||||
|
"source": "...",
|
||||||
|
"cohorts_estimated": true, # возрастные доли — оценка, НЕ факт
|
||||||
|
"categories": {
|
||||||
|
"school": {
|
||||||
|
"poi_count": 12,
|
||||||
|
"cohort_population": 32922, # оценка (population × age_share)
|
||||||
|
"cohort_estimated": true,
|
||||||
|
"provision_ratio": 0.68, # факт/норматив
|
||||||
|
"severity": "дефицит",
|
||||||
|
"score_multiplier": 1.2, # влияние на POI-score (#42)
|
||||||
|
"norm": {"capacity_per_1000": 92.0, "unit": "мест/1000 школьников"}
|
||||||
|
}, ...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Graceful: на ошибке БД логирует и возвращает None (не роняет analyze).
|
||||||
|
psycopg v3: bind через CAST(:dn AS text). Read-only.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
row = db.execute(_DISTRICT_SATURATION_SQL, {"dn": district_name}).mappings().first()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("saturation: query failed (district=%s)", district_name)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if row is None or row["population"] is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
population = int(row["population"])
|
||||||
|
cohorts_estimated = bool(row["age_cohorts_estimated"])
|
||||||
|
|
||||||
|
categories: dict[str, Any] = {}
|
||||||
|
for category, norm in _NORMS.items():
|
||||||
|
poi_count = int(row[_CATEGORY_COUNT_COLUMN[category]] or 0)
|
||||||
|
share_col = _CATEGORY_SHARE_COLUMN[category]
|
||||||
|
raw_share = row[share_col]
|
||||||
|
age_share = float(raw_share) if raw_share is not None else None
|
||||||
|
|
||||||
|
provision = compute_provision_ratio(
|
||||||
|
poi_count=poi_count,
|
||||||
|
population=population,
|
||||||
|
age_share=age_share,
|
||||||
|
category=category,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Когорта-знаменатель для отчёта: для hospital норматив на всё население.
|
||||||
|
if norm.cohort == "all":
|
||||||
|
cohort_pop: float | None = float(population)
|
||||||
|
cohort_is_estimate = False # всё население — факт
|
||||||
|
elif age_share is not None:
|
||||||
|
cohort_pop = population * age_share
|
||||||
|
cohort_is_estimate = cohorts_estimated
|
||||||
|
else:
|
||||||
|
cohort_pop = None
|
||||||
|
cohort_is_estimate = cohorts_estimated
|
||||||
|
|
||||||
|
categories[category] = {
|
||||||
|
"poi_count": poi_count,
|
||||||
|
"cohort_population": int(cohort_pop) if cohort_pop is not None else None,
|
||||||
|
"cohort_estimated": cohort_is_estimate,
|
||||||
|
"provision_ratio": _round_or_none(provision),
|
||||||
|
"severity": classify_severity(provision),
|
||||||
|
"score_multiplier": saturation_multiplier(category, provision),
|
||||||
|
"norm": {
|
||||||
|
"capacity_per_1000": norm.capacity_per_1000,
|
||||||
|
"unit": norm.unit,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
as_of = row["as_of_date"]
|
||||||
|
return {
|
||||||
|
"district": district_name,
|
||||||
|
"population": population,
|
||||||
|
"as_of_date": as_of.isoformat() if as_of is not None else None,
|
||||||
|
"source": row["source"],
|
||||||
|
"cohorts_estimated": cohorts_estimated,
|
||||||
|
"categories": categories,
|
||||||
|
}
|
||||||
|
|
@ -5,13 +5,18 @@ Mock-based — не требуют живой БД.
|
||||||
- поля site_status и ready_dt присутствуют в каждом элементе competitors
|
- поля site_status и ready_dt присутствуют в каждом элементе competitors
|
||||||
- первые позиции занимают строящиеся ЖК (site_status='Строящиеся')
|
- первые позиции занимают строящиеся ЖК (site_status='Строящиеся')
|
||||||
- сданные ЖК идут после строящихся
|
- сданные ЖК идут после строящихся
|
||||||
|
- obj_pricing CTE ограничен ближними obj_id (pushdown perf-guard, #70/#307)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
import inspect
|
||||||
|
import re
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from app.api.v1 import parcels as parcels_module
|
||||||
|
|
||||||
# ── Вспомогательные фабрики ───────────────────────────────────────────────────
|
# ── Вспомогательные фабрики ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -120,3 +125,61 @@ class TestCompetitorsSortOrder:
|
||||||
# Старая логика: первым шёл ЖК с flat_count=800 (Сданные)
|
# Старая логика: первым шёл ЖК с flat_count=800 (Сданные)
|
||||||
assert first_old["flat_count"] == 800
|
assert first_old["flat_count"] == 800
|
||||||
assert first_old["site_status"] == "Сданные"
|
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
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,13 @@ def _price_row(obj_id: int, price: float) -> MagicMock:
|
||||||
return r
|
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 ────────────────────────────────────────────────────────
|
# ── Построение mock DB ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -60,13 +67,17 @@ def _make_db(
|
||||||
coord: MagicMock | None = None,
|
coord: MagicMock | None = None,
|
||||||
obj_rows: list[MagicMock] | None = None,
|
obj_rows: list[MagicMock] | None = None,
|
||||||
price_rows: list[MagicMock] | None = None,
|
price_rows: list[MagicMock] | None = None,
|
||||||
|
obj_price_rows: list[MagicMock] | None = None,
|
||||||
) -> MagicMock:
|
) -> MagicMock:
|
||||||
"""Сконструировать mock Session.
|
"""Сконструировать mock Session.
|
||||||
|
|
||||||
Порядок вызовов execute:
|
Порядок вызовов execute:
|
||||||
1. centroid query → coord
|
1. centroid query → coord
|
||||||
2. competitors query → obj_rows
|
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()
|
db = MagicMock()
|
||||||
|
|
||||||
|
|
@ -75,6 +86,7 @@ def _make_db(
|
||||||
(coord, True),
|
(coord, True),
|
||||||
(obj_rows or [], False),
|
(obj_rows or [], False),
|
||||||
(price_rows or [], False),
|
(price_rows or [], False),
|
||||||
|
(obj_price_rows or [], False),
|
||||||
]:
|
]:
|
||||||
result = MagicMock()
|
result = MagicMock()
|
||||||
if is_first:
|
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), (
|
assert comp["avg_price_per_m2"] == pytest.approx(150_000.0), (
|
||||||
"avg_price_per_m2 должен быть не None — регрессия #227 status='sold' filter"
|
"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:
|
finally:
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
|
||||||
286
backend/tests/services/site_finder/test_competitors_parking.py
Normal file
286
backend/tests/services/site_finder/test_competitors_parking.py
Normal 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")
|
||||||
|
|
@ -124,12 +124,17 @@ def test_pzz_unknown_warning_when_no_zoning():
|
||||||
|
|
||||||
|
|
||||||
def test_cad_zouit_ohranaya_zona_truboprovodov_blocks():
|
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": "Жилая"}
|
nspd_zoning = {"zone_code": "Ж-2", "zone_name": "Жилая"}
|
||||||
overlaps = [
|
overlaps = [
|
||||||
{
|
{
|
||||||
"source": "cad_zouit",
|
"source": "cad_zouit",
|
||||||
"type_zone": "Охранная зона трубопроводов",
|
"type_zone": "Охранная зона трубопроводов (газопроводов, нефтепроводов)",
|
||||||
"layer": "Охранная зона трубопроводов",
|
"layer": "Охранная зона трубопроводов",
|
||||||
"name": "Газопровод высокого давления",
|
"name": "Газопровод высокого давления",
|
||||||
}
|
}
|
||||||
|
|
@ -137,11 +142,11 @@ def test_cad_zouit_ohranaya_zona_truboprovodov_blocks():
|
||||||
verdict = compute_gate_verdict(nspd_zoning, overlaps, [], {"available": True})
|
verdict = compute_gate_verdict(nspd_zoning, overlaps, [], {"available": True})
|
||||||
assert verdict["can_build_mkd"] is False
|
assert verdict["can_build_mkd"] is False
|
||||||
assert verdict["verdict_label"] == "Нельзя"
|
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():
|
def test_cad_zouit_elektr_blocks():
|
||||||
"""cad_zouit overlap с 'электр' substring в type_zone → blocker."""
|
"""cad_zouit электросетевая охранная зона → сетевое обременение (электро) → blocker (#1070)."""
|
||||||
nspd_zoning = {"zone_code": "Ж-3", "zone_name": "Жилая"}
|
nspd_zoning = {"zone_code": "Ж-3", "zone_name": "Жилая"}
|
||||||
overlaps = [
|
overlaps = [
|
||||||
{
|
{
|
||||||
|
|
@ -153,7 +158,7 @@ def test_cad_zouit_elektr_blocks():
|
||||||
]
|
]
|
||||||
verdict = compute_gate_verdict(nspd_zoning, overlaps, [], {"available": True})
|
verdict = compute_gate_verdict(nspd_zoning, overlaps, [], {"available": True})
|
||||||
assert verdict["can_build_mkd"] is False
|
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():
|
def test_cad_zouit_szz_is_warning_not_blocker():
|
||||||
|
|
|
||||||
141
backend/tests/test_network_obremenenie.py
Normal file
141
backend/tests/test_network_obremenenie.py
Normal 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
|
||||||
241
backend/tests/test_saturation.py
Normal file
241
backend/tests/test_saturation.py
Normal 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)
|
||||||
107
data/sql/154_ekb_district_demographics.sql
Normal file
107
data/sql/154_ekb_district_demographics.sql
Normal 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;
|
||||||
259
data/sql/155_objective_mapping_trgm_backfill.sql
Normal file
259
data/sql/155_objective_mapping_trgm_backfill.sql
Normal 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;
|
||||||
270
data/sql/156_objective_mapping_corroborated_v4.sql
Normal file
270
data/sql/156_objective_mapping_corroborated_v4.sql
Normal 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;
|
||||||
122
data/sql/157_analyze_hotpath_indexes.sql
Normal file
122
data/sql/157_analyze_hotpath_indexes.sql
Normal 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;
|
||||||
|
|
@ -409,6 +409,36 @@ export interface paths {
|
||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/v1/parcels/{cad_num}/competitors-parking": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Get Parcel Competitors Parking
|
||||||
|
* @description parking_ratio top-N конкурентов (#96 wiring) — ЛЕНИВЫЙ путь, вне analyze.
|
||||||
|
*
|
||||||
|
* Резолвит top-3 конкурента (relevance_weight DESC) domrf→cad_buildings через
|
||||||
|
* PostGIS geom-match, затем on-demand НСПД premises-lookup → parking_ratio.
|
||||||
|
* Намеренно ОТДЕЛЬНЫЙ от analyze/forecast: N×НСПД (~0.6-2с каждый) добавил бы
|
||||||
|
* секунды в хот-путь и убил p95 — фронт дёргает этот эндпоинт лениво после
|
||||||
|
* отрисовки списка конкурентов.
|
||||||
|
*
|
||||||
|
* sync `def` (не async): premises_lookup изолирует НСПД-async через
|
||||||
|
* asyncio.run() — его нельзя вызвать из async-handler (running loop). FastAPI
|
||||||
|
* исполняет sync-handler в threadpool, не блокируя event loop.
|
||||||
|
*/
|
||||||
|
post: operations["get_parcel_competitors_parking_api_v1_parcels__cad_num__competitors_parking_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/v1/parcels/{cad_num}/best-layouts": {
|
"/api/v1/parcels/{cad_num}/best-layouts": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
@ -3040,6 +3070,8 @@ export interface components {
|
||||||
velocity_per_month: number;
|
velocity_per_month: number;
|
||||||
/** Avg Price Per M2 */
|
/** Avg Price Per M2 */
|
||||||
avg_price_per_m2: number | null;
|
avg_price_per_m2: number | null;
|
||||||
|
/** Price Source */
|
||||||
|
price_source?: string | null;
|
||||||
/** Is Active */
|
/** Is Active */
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
/** Relevance Weight */
|
/** Relevance Weight */
|
||||||
|
|
@ -3049,6 +3081,44 @@ export interface components {
|
||||||
[key: string]: number;
|
[key: string]: number;
|
||||||
} | null;
|
} | 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 */
|
||||||
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: {
|
get_parcel_best_layouts_api_v1_parcels__cad_num__best_layouts_post: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
|
||||||
|
|
@ -1470,8 +1470,9 @@ async def scrape_yandex_newbuilding(
|
||||||
|
|
||||||
URL: /{city}/kupit/novostrojka/<slug>-<id>/
|
URL: /{city}/kupit/novostrojka/<slug>-<id>/
|
||||||
"""
|
"""
|
||||||
async with YandexNewbuildingScraper() as scraper:
|
# fetch_jk использует внутренний BrowserFetcher, httpx-клиент BaseScraper не нужен
|
||||||
result = await scraper.fetch_jk(jk_slug=slug, jk_id=id, city=city)
|
scraper = YandexNewbuildingScraper()
|
||||||
|
result = await scraper.fetch_jk(jk_slug=slug, jk_id=id, city=city)
|
||||||
if result is None:
|
if result is None:
|
||||||
raise HTTPException(404, f"Could not parse Yandex JK: {slug}-{id} in {city}")
|
raise HTTPException(404, f"Could not parse Yandex JK: {slug}-{id} in {city}")
|
||||||
return YandexNewbuildingTriggerResp(
|
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):
|
class YandexValuationTriggerResp(BaseModel):
|
||||||
ok: bool
|
ok: bool
|
||||||
address: str
|
address: str
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,12 @@ Sources:
|
||||||
Idempotent: skips houses already enriched, per-house SAVEPOINT, so
|
Idempotent: skips houses already enriched, per-house SAVEPOINT, so
|
||||||
each fire drains the next `limit` pending houses; window 00:00-01:00
|
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)
|
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
|
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 import scrape_runs as runs_mod
|
||||||
from app.services.scrape_pipeline import (
|
from app.services.scrape_pipeline import (
|
||||||
run_avito_city_sweep,
|
run_avito_city_sweep,
|
||||||
|
run_cian_city_sweep,
|
||||||
run_n1_city_sweep,
|
run_n1_city_sweep,
|
||||||
run_yandex_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
|
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(
|
async def _execute_cian_backfill(
|
||||||
db: Session,
|
db: Session,
|
||||||
*,
|
*,
|
||||||
|
|
@ -459,8 +501,7 @@ async def _execute_cian_backfill(
|
||||||
}
|
}
|
||||||
runs_mod.mark_done(db, run_id, counters)
|
runs_mod.mark_done(db, run_id, counters)
|
||||||
logger.info(
|
logger.info(
|
||||||
"scheduler: cian_history_backfill run_id=%d done — "
|
"scheduler: cian_history_backfill run_id=%d done — listings=%d/%d houses=%d/%d %.1fs",
|
||||||
"listings=%d/%d houses=%d/%d %.1fs",
|
|
||||||
run_id,
|
run_id,
|
||||||
result.listings_succeeded,
|
result.listings_succeeded,
|
||||||
result.listings_total,
|
result.listings_total,
|
||||||
|
|
@ -769,6 +810,55 @@ async def trigger_newbuilding_enrich_run(db: Session, schedule_row: dict[str, An
|
||||||
return run_id
|
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:
|
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).
|
"""Создать 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)
|
await trigger_n1_city_sweep_run(db, sch)
|
||||||
elif source == "cian_history_backfill":
|
elif source == "cian_history_backfill":
|
||||||
await trigger_cian_backfill_run(db, sch)
|
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":
|
elif source == "rosreestr_dkp_import":
|
||||||
await trigger_rosreestr_dkp_run(db, sch)
|
await trigger_rosreestr_dkp_run(db, sch)
|
||||||
elif source == "listing_source_snapshot":
|
elif source == "listing_source_snapshot":
|
||||||
|
|
@ -1097,6 +1189,8 @@ async def scheduler_loop() -> None:
|
||||||
await trigger_rosreestr_quarter_poll_run(db, sch)
|
await trigger_rosreestr_quarter_poll_run(db, sch)
|
||||||
elif source == "newbuilding_enrich":
|
elif source == "newbuilding_enrich":
|
||||||
await trigger_newbuilding_enrich_run(db, sch)
|
await trigger_newbuilding_enrich_run(db, sch)
|
||||||
|
elif source == "yandex_newbuilding_sweep":
|
||||||
|
await trigger_yandex_newbuilding_sweep_run(db, sch)
|
||||||
else:
|
else:
|
||||||
logger.warning("scheduler: unknown source=%s, skip", source)
|
logger.warning("scheduler: unknown source=%s, skip", source)
|
||||||
finally:
|
finally:
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ from typing import Any
|
||||||
from curl_cffi.requests import AsyncSession # type: ignore[import-untyped]
|
from curl_cffi.requests import AsyncSession # type: ignore[import-untyped]
|
||||||
|
|
||||||
from app.core.config import settings
|
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
|
from app.services.scrapers.cian_state_parser import extract_all_states, extract_state
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -120,116 +121,98 @@ class NewbuildingEnrichment:
|
||||||
async def fetch_newbuilding(
|
async def fetch_newbuilding(
|
||||||
zhk_url: str,
|
zhk_url: str,
|
||||||
*,
|
*,
|
||||||
session: AsyncSession | None = None,
|
session: AsyncSession | None = None, # kept for backward-compat; unused for page fetch
|
||||||
) -> NewbuildingEnrichment | None:
|
) -> NewbuildingEnrichment | None:
|
||||||
"""Fetch ЖК catalog page и extract все containers.
|
"""Fetch ЖК catalog page и extract все containers.
|
||||||
|
|
||||||
|
HTML страница ЖК теперь тянется через BrowserFetcher (camoufox), потому что
|
||||||
|
curl_cffi получает страницу без initialState под анти-ботом Cian (#972).
|
||||||
|
tradein-browser верифицирован живым: 1.17 MB, initialState присутствует.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
zhk_url: e.g. 'https://zhk-ekaterininskiy-park-ekb-i.cian.ru/'
|
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.
|
Returns: NewbuildingEnrichment, or None если fetch / parse failed.
|
||||||
"""
|
"""
|
||||||
close_session = False
|
async with BrowserFetcher() as browser:
|
||||||
if session is None:
|
html = await browser.fetch(zhk_url)
|
||||||
# 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
|
|
||||||
|
|
||||||
try:
|
mfe = "newbuilding-card-desktop-fichering-frontend"
|
||||||
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"
|
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")
|
# Primary newbuilding object: state.newbuilding (60 fields per schema sec 15.4)
|
||||||
if nb_state is None:
|
nb = nb_state.get("newbuilding") or {}
|
||||||
logger.warning("Cian newbuilding %s: initialState extraction failed", zhk_url)
|
if not isinstance(nb, dict):
|
||||||
return None
|
nb = {}
|
||||||
|
|
||||||
# Primary newbuilding object: state.newbuilding (60 fields per schema sec 15.4)
|
result = NewbuildingEnrichment(
|
||||||
nb = nb_state.get("newbuilding") or {}
|
cian_internal_house_id=nb.get("id"),
|
||||||
if not isinstance(nb, dict):
|
cian_zhk_url=zhk_url,
|
||||||
nb = {}
|
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(
|
# Sister containers — all live under the same MFE initialState top-level keys
|
||||||
cian_internal_house_id=nb.get("id"),
|
# (not separate push() entries) per schema sec 15.3
|
||||||
cian_zhk_url=zhk_url,
|
all_states = extract_all_states(html)
|
||||||
name=nb.get("name") or nb.get("displayName"),
|
mfe_states = all_states.get(mfe, {})
|
||||||
address=_extract_address(nb),
|
# initialState top-level keys contain sister data: realtyValuation, reliability, offers
|
||||||
year_built=nb.get("yearBuilt") or nb.get("buildYear"),
|
top = nb_state
|
||||||
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
|
# realtyValuation 7-month chart (Stage 6 main deliverable)
|
||||||
# (not separate push() entries) per schema sec 15.3
|
# Located at initialState.realtyValuation (sec 15.5)
|
||||||
all_states = extract_all_states(html)
|
rv_state = top.get("realtyValuation") or mfe_states.get("realtyValuation")
|
||||||
mfe_states = all_states.get(mfe, {})
|
if rv_state and isinstance(rv_state, dict):
|
||||||
# initialState top-level keys contain sister data: realtyValuation, reliability, offers
|
result.realty_valuation_chart = _extract_chart(rv_state)
|
||||||
top = nb_state
|
result.raw_sister_states["realtyValuation"] = rv_state
|
||||||
|
|
||||||
# realtyValuation 7-month chart (Stage 6 main deliverable)
|
# reliability (наш.дом.рф) — located at initialState.reliability (sec 15.6)
|
||||||
# Located at initialState.realtyValuation (sec 15.5)
|
rel_state = top.get("reliability") or mfe_states.get("reliability")
|
||||||
rv_state = top.get("realtyValuation") or mfe_states.get("realtyValuation")
|
if rel_state and isinstance(rel_state, dict):
|
||||||
if rv_state and isinstance(rv_state, dict):
|
result.reliability_checks = _extract_reliability_checks(rel_state)
|
||||||
result.realty_valuation_chart = _extract_chart(rv_state)
|
result.raw_sister_states["reliability"] = rel_state
|
||||||
result.raw_sister_states["realtyValuation"] = rv_state
|
|
||||||
|
|
||||||
# reliability (наш.дом.рф) — located at initialState.reliability (sec 15.6)
|
# reviews — located at initialState or separate state
|
||||||
rel_state = top.get("reliability") or mfe_states.get("reliability")
|
reviews_state = top.get("reviews") or mfe_states.get("reviews")
|
||||||
if rel_state and isinstance(rel_state, dict):
|
if reviews_state and isinstance(reviews_state, dict):
|
||||||
result.reliability_checks = _extract_reliability_checks(rel_state)
|
items = reviews_state.get("items") or reviews_state.get("data") or []
|
||||||
result.raw_sister_states["reliability"] = rel_state
|
result.reviews = items if isinstance(items, list) else []
|
||||||
|
result.raw_sister_states["reviews"] = reviews_state
|
||||||
|
|
||||||
# reviews — located at initialState or separate state
|
# offers (grouped by roomType) — located at initialState.offers (sec 15.7)
|
||||||
reviews_state = top.get("reviews") or mfe_states.get("reviews")
|
offers_state = top.get("offers") or mfe_states.get("offers") or mfe_states.get("miniSerp")
|
||||||
if reviews_state and isinstance(reviews_state, dict):
|
if offers_state and isinstance(offers_state, dict):
|
||||||
items = reviews_state.get("items") or reviews_state.get("data") or []
|
result.nested_offers = _extract_nested_offers(offers_state)
|
||||||
result.reviews = items if isinstance(items, list) else []
|
result.raw_sister_states["offers"] = offers_state
|
||||||
result.raw_sister_states["reviews"] = reviews_state
|
|
||||||
|
|
||||||
# offers (grouped by roomType) — located at initialState.offers (sec 15.7)
|
logger.info(
|
||||||
offers_state = top.get("offers") or mfe_states.get("offers") or mfe_states.get("miniSerp")
|
"Cian newbuilding %s parsed: id=%s chart=%d reliability=%d offers=%d",
|
||||||
if offers_state and isinstance(offers_state, dict):
|
zhk_url,
|
||||||
result.nested_offers = _extract_nested_offers(offers_state)
|
result.cian_internal_house_id,
|
||||||
result.raw_sister_states["offers"] = offers_state
|
len(result.realty_valuation_chart),
|
||||||
|
len(result.reliability_checks),
|
||||||
logger.info(
|
len(result.nested_offers),
|
||||||
"Cian newbuilding %s parsed: id=%s chart=%d reliability=%d offers=%d",
|
)
|
||||||
zhk_url,
|
return result
|
||||||
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()
|
|
||||||
|
|
||||||
|
|
||||||
# ---- private extractors ----
|
# ---- private extractors ----
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,12 @@
|
||||||
URL pattern: /{city}/kupit/novostrojka/<slug>-<id>/
|
URL pattern: /{city}/kupit/novostrojka/<slug>-<id>/
|
||||||
Reference target: ЖК Татлин (id=1592987, slug=tatlin) — comfort+, June 2023,
|
Reference target: ЖК Татлин (id=1592987, slug=tatlin) — comfort+, June 2023,
|
||||||
PRINZIP, rating 4.3, 1505 ratings, 353 text reviews, coords (56.855312, 60.576668).
|
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
|
from __future__ import annotations
|
||||||
|
|
@ -98,6 +104,12 @@ RE_METRO_INLINE = re.compile(
|
||||||
r"([А-ЯЁ][А-Яа-яё\s-]{2,30}?)\s+(\d+)\s*мин",
|
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)
|
# Ekb-specific coord ranges (extend per-city later)
|
||||||
LAT_RANGE = (55.5, 57.5)
|
LAT_RANGE = (55.5, 57.5)
|
||||||
LON_RANGE = (59.5, 61.5)
|
LON_RANGE = (59.5, 61.5)
|
||||||
|
|
@ -129,9 +141,7 @@ class YandexNewbuildingScraper(BaseScraper):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.request_delay_sec = get_scraper_delay(self.name)
|
self.request_delay_sec = get_scraper_delay(self.name)
|
||||||
|
|
||||||
async def fetch_around(
|
async def fetch_around(self, lat: float, lon: float, radius_m: int = 1000) -> list: # type: ignore[override]
|
||||||
self, lat: float, lon: float, radius_m: int = 1000
|
|
||||||
) -> list: # type: ignore[override]
|
|
||||||
raise NotImplementedError(
|
raise NotImplementedError(
|
||||||
"YandexNewbuildingScraper is JK-slug-based; use fetch_jk(slug, id) instead."
|
"YandexNewbuildingScraper is JK-slug-based; use fetch_jk(slug, id) instead."
|
||||||
)
|
)
|
||||||
|
|
@ -139,22 +149,31 @@ class YandexNewbuildingScraper(BaseScraper):
|
||||||
async def fetch_jk(
|
async def fetch_jk(
|
||||||
self, jk_slug: str, jk_id: str, city: str = "ekaterinburg"
|
self, jk_slug: str, jk_id: str, city: str = "ekaterinburg"
|
||||||
) -> YandexNewbuildingInfo | None:
|
) -> 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}/"
|
url = f"{self.base_url}/{city}/kupit/novostrojka/{jk_slug}-{jk_id}/"
|
||||||
try:
|
try:
|
||||||
response = await self._http_get(url)
|
async with BrowserFetcher() as fetcher:
|
||||||
|
html = await fetcher.fetch(url)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("yandex nb fetch failed: %s", url)
|
logger.exception("yandex nb browser fetch failed: %s", url)
|
||||||
return None
|
return None
|
||||||
if response.status_code != 200:
|
if not html or len(html) < 500:
|
||||||
logger.warning("yandex nb returned %d for %s", response.status_code, url)
|
logger.warning(
|
||||||
|
"yandex nb browser returned empty/tiny HTML (%d bytes): %s",
|
||||||
|
len(html) if html else 0,
|
||||||
|
url,
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
result = self.parse(response.text, jk_slug=jk_slug, jk_id=jk_id, source_url=url)
|
result = self.parse(html, jk_slug=jk_slug, jk_id=jk_id, source_url=url)
|
||||||
await self.sleep_between_requests()
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def parse(
|
def parse(self, html: str, jk_slug: str, jk_id: str, source_url: str) -> YandexNewbuildingInfo:
|
||||||
self, html: str, jk_slug: str, jk_id: str, source_url: str
|
|
||||||
) -> YandexNewbuildingInfo:
|
|
||||||
tree = HTMLParser(html)
|
tree = HTMLParser(html)
|
||||||
body = tree.body
|
body = tree.body
|
||||||
body_text = body.text(strip=True) if body else ""
|
body_text = body.text(strip=True) if body else ""
|
||||||
|
|
@ -208,9 +227,7 @@ class YandexNewbuildingScraper(BaseScraper):
|
||||||
href = dev_link.attributes.get("href", "")
|
href = dev_link.attributes.get("href", "")
|
||||||
if href:
|
if href:
|
||||||
developer_url = (
|
developer_url = (
|
||||||
href
|
href if href.startswith("http") else f"https://realty.yandex.ru{href}"
|
||||||
if href.startswith("http")
|
|
||||||
else f"https://realty.yandex.ru{href}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Developer's other JKs
|
# 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 ───────────────────────────────────────────────────────────────────
|
# ── helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -354,4 +425,5 @@ __all__ = [
|
||||||
"JKMetroStation",
|
"JKMetroStation",
|
||||||
"YandexNewbuildingInfo",
|
"YandexNewbuildingInfo",
|
||||||
"YandexNewbuildingScraper",
|
"YandexNewbuildingScraper",
|
||||||
|
"resolve_yandex_jk_slug",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
423
tradein-mvp/backend/app/tasks/yandex_newbuilding_sweep.py
Normal file
423
tradein-mvp/backend/app/tasks/yandex_newbuilding_sweep.py
Normal 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 используется для resolve→enrich gap внутри одного дома.
|
||||||
|
"""
|
||||||
|
if delay <= 0:
|
||||||
|
return
|
||||||
|
if not force and idx >= total - 1:
|
||||||
|
return
|
||||||
|
await asyncio.sleep(delay * random.uniform(0.8, 1.2))
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
397
tradein-mvp/backend/tests/tasks/test_yandex_newbuilding_sweep.py
Normal file
397
tradein-mvp/backend/tests/tasks/test_yandex_newbuilding_sweep.py
Normal 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
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
"""Tests for cian_newbuilding scraper (Stage 6)."""
|
"""Tests for cian_newbuilding scraper (Stage 6)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -247,42 +248,72 @@ def test_dataclass_defaults():
|
||||||
|
|
||||||
|
|
||||||
# ---- async integration-style tests ----
|
# ---- 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
|
@pytest.mark.asyncio
|
||||||
async def test_fetch_newbuilding_returns_none_on_no_state(monkeypatch):
|
async def test_fetch_newbuilding_returns_none_on_no_state(monkeypatch):
|
||||||
"""Если state extraction fails → return None gracefully."""
|
"""Если state extraction fails → return None gracefully."""
|
||||||
|
_make_browser_fetcher_mock("<html></html>", monkeypatch)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"app.services.scrapers.cian_newbuilding.extract_state",
|
"app.services.scrapers.cian_newbuilding.extract_state",
|
||||||
lambda html, mfe, key: None,
|
lambda html, mfe, key: None,
|
||||||
)
|
)
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
|
||||||
|
|
||||||
session = MagicMock()
|
result = await fetch_newbuilding("https://zhk-test-ekb-i.cian.ru/")
|
||||||
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)
|
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_fetch_newbuilding_returns_none_on_http_error(monkeypatch):
|
async def test_fetch_newbuilding_browser_error_propagates(monkeypatch):
|
||||||
"""HTTP non-200 → return None gracefully."""
|
"""BrowserFetcher.fetch raises → fetch_newbuilding propagates the exception."""
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
session = MagicMock()
|
mock_fetcher = MagicMock()
|
||||||
response = MagicMock()
|
mock_fetcher.fetch = AsyncMock(side_effect=RuntimeError("browser timeout"))
|
||||||
response.status_code = 403
|
mock_fetcher.__aenter__ = AsyncMock(return_value=mock_fetcher)
|
||||||
response.text = "Forbidden"
|
mock_fetcher.__aexit__ = AsyncMock(return_value=None)
|
||||||
session.get = AsyncMock(return_value=response)
|
monkeypatch.setattr(
|
||||||
session.close = AsyncMock()
|
"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
|
assert result is None
|
||||||
|
sentinel_session.get.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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(
|
monkeypatch.setattr(
|
||||||
"app.services.scrapers.cian_newbuilding.extract_state",
|
"app.services.scrapers.cian_newbuilding.extract_state",
|
||||||
lambda html, mfe, key: fake_state,
|
lambda html, mfe, key: fake_state,
|
||||||
|
|
@ -327,16 +359,7 @@ async def test_fetch_newbuilding_parses_state(monkeypatch):
|
||||||
lambda html: {},
|
lambda html: {},
|
||||||
)
|
)
|
||||||
|
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
result = await fetch_newbuilding("https://zhk-test-ekb-i.cian.ru/")
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result.cian_internal_house_id == 54016
|
assert result.cian_internal_house_id == 54016
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
"""Offline smoke for scheduler logic."""
|
"""Offline smoke for scheduler logic."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import os
|
import os
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
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 _sched._claim_run(db, _SCHED_ROW) is None
|
||||||
assert calls["n"] == 0
|
assert calls["n"] == 0
|
||||||
assert db.rollbacks == 1 # лок освобождён
|
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
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,8 @@ Covers:
|
||||||
- None/empty → direct connection (no proxies dict)
|
- None/empty → direct connection (no proxies dict)
|
||||||
- _avito_proxies() helper returns correct dict shape
|
- _avito_proxies() helper returns correct dict shape
|
||||||
- per-scraper proxy wiring: YandexRealty, YandexValuation, CianValuation,
|
- 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
|
from __future__ import annotations
|
||||||
|
|
@ -267,59 +268,81 @@ async def test_yandex_valuation_session_no_proxies_when_none():
|
||||||
|
|
||||||
|
|
||||||
# ── CianNewbuilding — fetch_newbuilding ───────────────────────────────────────
|
# ── CianNewbuilding — fetch_newbuilding ───────────────────────────────────────
|
||||||
|
# После #972 fetch_newbuilding тянет страницу ЖК через BrowserFetcher (camoufox),
|
||||||
|
# а НЕ через curl_cffi AsyncSession. Прокси применяются на уровне браузер-контейнера,
|
||||||
|
# а не через proxies= kwarg. AsyncSession для page-fetch больше НЕ создаётся.
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_cian_newbuilding_own_session_receives_proxies():
|
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
|
from app.services.scrapers import cian_newbuilding
|
||||||
|
|
||||||
mock_session = MagicMock()
|
zhk_url = "https://zhk-test.cian.ru/"
|
||||||
mock_resp = MagicMock()
|
fetch_calls: list[str] = []
|
||||||
mock_resp.status_code = 200
|
session_ctor_calls: list[dict] = []
|
||||||
mock_resp.text = "" # empty HTML → extract_state returns None → result None (ok)
|
|
||||||
mock_session.get = AsyncMock(return_value=mock_resp)
|
|
||||||
mock_session.close = AsyncMock()
|
|
||||||
|
|
||||||
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):
|
def _fake_async_session(*args, **kwargs):
|
||||||
captured_kwargs.update(kwargs)
|
session_ctor_calls.append(kwargs)
|
||||||
return mock_session
|
return MagicMock()
|
||||||
|
|
||||||
with patch.object(cian_newbuilding, "AsyncSession", _fake_session):
|
with patch.object(cian_newbuilding, "BrowserFetcher", lambda: mock_fetcher):
|
||||||
with patch.object(cian_newbuilding, "settings", _mock_settings(_PROXY_URL)):
|
with patch.object(cian_newbuilding, "AsyncSession", _fake_async_session):
|
||||||
await cian_newbuilding.fetch_newbuilding("https://zhk-test.cian.ru/")
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_cian_newbuilding_shared_session_not_recreated():
|
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
|
from app.services.scrapers import cian_newbuilding
|
||||||
|
|
||||||
mock_session = MagicMock()
|
zhk_url = "https://zhk-test.cian.ru/"
|
||||||
mock_resp = MagicMock()
|
fetch_calls: list[str] = []
|
||||||
mock_resp.status_code = 200
|
session_ctor_calls: list[dict] = []
|
||||||
mock_resp.text = ""
|
|
||||||
mock_session.get = AsyncMock(return_value=mock_resp)
|
|
||||||
mock_session.close = AsyncMock()
|
|
||||||
|
|
||||||
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)
|
session_ctor_calls.append(kwargs)
|
||||||
return MagicMock()
|
return MagicMock()
|
||||||
|
|
||||||
with patch.object(cian_newbuilding, "AsyncSession", _fake_session):
|
sentinel_session = MagicMock(name="external_session")
|
||||||
with patch.object(cian_newbuilding, "settings", _mock_settings(_PROXY_URL)):
|
|
||||||
await cian_newbuilding.fetch_newbuilding(
|
|
||||||
"https://zhk-test.cian.ru/", session=mock_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 == []
|
assert session_ctor_calls == []
|
||||||
|
sentinel_session.get.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
# ── AvitoDetail — fetch_detail own-session ───────────────────────────────────
|
# ── AvitoDetail — fetch_detail own-session ───────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,15 @@ _browser_cm: object | None = None # AsyncCamoufox context manager (для
|
||||||
_page_counter: int = 0 # страниц с момента последнего (пере)запуска
|
_page_counter: int = 0 # страниц с момента последнего (пере)запуска
|
||||||
_fetch_lock: asyncio.Lock | None = None # сериализация fetch-запросов
|
_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:
|
async def _launch_browser() -> None:
|
||||||
"""Запускает AsyncCamoufox и сохраняет browser + CM в модульных переменных."""
|
"""Запускает AsyncCamoufox и сохраняет browser + CM в модульных переменных."""
|
||||||
|
|
@ -147,16 +156,105 @@ async def _relaunch_browser() -> None:
|
||||||
await _launch_browser()
|
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 ────────────────────────────────────────────────────
|
# ── aiohttp lifecycle hooks ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
async def _on_startup(app: web.Application) -> None:
|
async def _on_startup(app: web.Application) -> None:
|
||||||
global _fetch_lock
|
global _fetch_lock
|
||||||
_fetch_lock = asyncio.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:
|
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()
|
await _close_browser()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -164,7 +262,12 @@ async def _on_cleanup(app: web.Application) -> None:
|
||||||
|
|
||||||
|
|
||||||
async def health_handler(request: web.Request) -> web.Response:
|
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:
|
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"
|
assert _fetch_lock is not None, "_fetch_lock not initialised"
|
||||||
|
|
||||||
async with _fetch_lock:
|
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:
|
try:
|
||||||
html = await _do_fetch(url)
|
html = await _do_fetch(url)
|
||||||
except Exception as exc:
|
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"
|
assert _fetch_lock is not None, "_fetch_lock not initialised"
|
||||||
|
|
||||||
async with _fetch_lock:
|
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:
|
try:
|
||||||
cookies = await _do_login(body)
|
cookies = await _do_login(body)
|
||||||
except LoginError as exc:
|
except LoginError as exc:
|
||||||
|
|
|
||||||
124
tradein-mvp/browser/test_server_smoke.py
Normal file
124
tradein-mvp/browser/test_server_smoke.py
Normal 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
|
||||||
Loading…
Add table
Reference in a new issue