gendesign/backend/app/services/site_finder/developer_attribution.py
Light1YT b211183940
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / changes (push) Successful in 8s
CI / frontend-tests (push) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m33s
CI / backend-tests (pull_request) Successful in 6m33s
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m44s
Deploy / build-worker (push) Successful in 5m24s
Deploy / deploy (push) Successful in 1m30s
feat(site-finder): детерминированная атрибуция застройщика в analyze (#1088)
Сервис get_developer_attribution поверх fn_developer_for_parcel (миграция 149):
топ-1 застройщик участка + track-record (РНС/РВЭ + домрф) + nearby_developers.
Дедуп по норм-ИНН (лучший match_method первым). Wire в analyze additive без флага
(чистый DB-резолвер по индексам, graceful → None). Beat-refresh developer_registry
ежемесячно 05:30 МСК после ekburg-permits. Pydantic-схемы + 18 тестов.

Источники: ekburg_construction_permits (РНС, ИНН) ⋈ domrf_kn_objects по
нормализованному ИНН + spatial/quarter fallback. 68% domrf-застройщиков имеют РНС.
EXPLAIN: point-lookup 0.06ms, резолвер ~30ms (functional/GIST индексы в миграции).

Closes #1088
2026-06-13 16:13:45 +05:00

207 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Детерминированная атрибуция застройщика для analyze_parcel (#1088 «GG-форсайт»).
Вызывает резолвер ``fn_developer_for_parcel(cad_num, radius_m)`` (миграция 149,
``data/sql/149_developer_attribution.sql``) и собирает атрибуцию «пятно земли ↔
застройщик»: топ-1 канон-застройщик участка (имя + норм-ИНН) с его проектом/статусом
и track-record (РНС/РВЭ + домрф ЖК/квартиры из ``developer_registry``), плюс список
``nearby_developers`` — «кто ещё строит рядом / в квартале».
Резолвер — 3-ступенчатый fallback (exact_cadastral > spatial_150m > quarter), уже
отсортированный лучшим матчем вперёд (ORDER BY method, distance). Один застройщик
может встретиться в нескольких ступенях (exact+spatial+quarter) — дедупим по
норм-ИНН, оставляя ЛУЧШИЙ match_method (первый по ORDER BY резолвера). Топ-1
дедупнутый застройщик → primary, остальные → nearby_developers.
Связка с ``competitors.py``: иная ответственность. competitors отдаёт ЖК-конкурентов
в радиусе (domrf, velocity), здесь — кто ЗАСТРОЙЩИК конкретного участка и его послужной
список по РНС+домрф. Отдельный сервис (НЕ метод в competitors).
Graceful: пустой результат / нет геометрии участка / БД-ошибка (incl. миграция 149 ещё
не задеплоена) → None — analyze не падает. Зеркалит стиль ``ird_overlay_lookup.py`` /
``okn_lookup.py``.
"""
from __future__ import annotations
import logging
from typing import Any
from sqlalchemy import text
from sqlalchemy.exc import DataError, OperationalError, ProgrammingError
from sqlalchemy.orm import Session
from app.schemas.parcel import (
DeveloperAttribution,
DeveloperAttributionResult,
NearbyDeveloper,
)
logger = logging.getLogger(__name__)
# Резолвер участок→застройщик (миграция 149). Возвращает SETOF кандидатов, УЖЕ
# отсортированный лучшим матчем вперёд: exact_cadastral (dist=0) → spatial_150m →
# quarter, внутри метода — по distance_m ASC, затем ИНН. Caller (этот сервис) берёт
# топ-1 дедупнутый и дополняет nearby. CAST psycopg v3 — НИКОГДА :param::type
# (vault Pattern_CAST_AS_Type / backend.md).
_DEVELOPER_FOR_PARCEL_SQL = text(
"""
SELECT
developer_inn,
developer_name,
source,
match_method,
distance_m,
project,
status,
obj_class,
permits_total,
domrf_objects_total,
domrf_active_objects,
in_both_sources
FROM fn_developer_for_parcel(:cad_num, CAST(:radius AS double precision))
"""
)
# match_method, считающиеся «соседними» (вторичными) для nearby_developers. exact —
# это сам застройщик участка (primary), он в nearby не дублируется.
_NEARBY_METHODS = frozenset({"spatial_150m", "quarter"})
def _row_get(row: Any, key: str) -> Any:
"""Безопасно прочитать ключ из row-mapping → None если ключа нет.
Только __getitem__: и SQLAlchemy RowMapping, и тестовые MagicMock-строки
реализуют его корректно и кидают KeyError на отсутствующий ключ. (Не .get():
на MagicMock это авто-атрибут-заглушка, возвращающая MagicMock вместо значения.)
Зеркалит competitors._row_get — backward-safe чтение колонок из моков.
"""
try:
return row[key]
except (KeyError, TypeError, IndexError):
return None
def _as_int(value: Any) -> int | None:
"""None-safe int() — track-record-колонки могут быть NULL (FULL OUTER JOIN)."""
return int(value) if value is not None else None
def _as_float(value: Any) -> float | None:
"""None-safe float() — distance_m NULL для quarter-ступени."""
return float(value) if value is not None else None
def _attribution_fields(row: Any) -> dict[str, Any]:
"""Извлечь общий набор полей атрибуции из row резолвера (через _row_get).
Используется и для primary (DeveloperAttribution), и для nearby (NearbyDeveloper)
у них идентичный набор полей. developer_inn — единственное обязательное (NOT NULL
в резолвере, гарантирован WHERE c.inn IS NOT NULL); остальное nullable.
"""
return {
"developer_inn": str(_row_get(row, "developer_inn")),
"developer_name": _row_get(row, "developer_name"),
"source": _row_get(row, "source"),
"match_method": _row_get(row, "match_method"),
"distance_m": _as_float(_row_get(row, "distance_m")),
"project": _row_get(row, "project"),
"status": _row_get(row, "status"),
"obj_class": _row_get(row, "obj_class"),
"permits_total": _as_int(_row_get(row, "permits_total")),
"domrf_objects_total": _as_int(_row_get(row, "domrf_objects_total")),
"domrf_active_objects": _as_int(_row_get(row, "domrf_active_objects")),
"in_both_sources": _row_get(row, "in_both_sources"),
}
def get_developer_attribution(
db: Session,
cad_num: str,
radius_m: float = 150.0,
) -> DeveloperAttributionResult | None:
"""Атрибуция застройщика участка: топ-1 + track-record + соседи (#1088).
Шаги:
1. Вызвать ``fn_developer_for_parcel(cad_num, radius_m)`` — SETOF кандидатов,
уже отсортированный лучшим матчем вперёд (резолвер: exact > spatial > quarter).
2. Дедупнуть по нормализованному developer_inn, сохраняя ЛУЧШИЙ (первый по
ORDER BY) match_method каждого застройщика.
3. Топ-1 дедупнутый → primary; остальные (match_method spatial/quarter) →
nearby_developers.
Args:
db: SQLAlchemy-сессия.
cad_num: кадастровый номер участка.
radius_m: радиус spatial-ступени резолвера (default 150 м).
Returns:
``DeveloperAttributionResult`` (primary + nearby_developers) либо None при:
- пустом результате резолвера (нет матча застройщика);
- отсутствии геометрии участка (spatial/quarter-domrf не сработали, а
exact/quarter-permits ничего не нашли);
- БД-ошибке, включая ещё не задеплоенную миграцию 149 (analyze не падает).
"""
try:
rows = (
db.execute(
_DEVELOPER_FOR_PARCEL_SQL,
{"cad_num": cad_num, "radius": radius_m},
)
.mappings()
.all()
)
except (OperationalError, ProgrammingError, DataError) as exc:
# Миграция 149 ещё не задеплоена / БД-ошибка — graceful degrade.
logger.warning(
"get_developer_attribution: fn_developer_for_parcel недоступна для cad=%s, skip: %s",
cad_num,
exc,
)
return None
except Exception as exc:
logger.warning(
"get_developer_attribution: неожиданная ошибка для cad=%s, skip: %s",
cad_num,
exc,
)
return None
if not rows:
return None
# ── Дедуп по норм-ИНН, сохраняя ПЕРВЫЙ матч (он же лучший — резолвер ORDER BY) ──
# rows УЖЕ отсортированы (exact > spatial > quarter, distance ASC), поэтому первое
# вхождение ИНН — его лучший match_method. dict сохраняет порядок вставки (3.7+),
# значит первый ключ — топ-1 застройщик участка.
deduped: dict[str, Any] = {}
for row in rows:
inn = _row_get(row, "developer_inn")
if inn is None:
# Резолвер гарантирует NOT NULL (WHERE c.inn IS NOT NULL), но защищаемся.
continue
inn_key = str(inn)
if inn_key not in deduped:
deduped[inn_key] = row
if not deduped:
return None
deduped_rows = list(deduped.values())
primary_row = deduped_rows[0]
primary = DeveloperAttribution(**_attribution_fields(primary_row))
# ── nearby: остальные дедупнутые застройщики (вторичные spatial/quarter матчи) ──
# primary уже исключён (срез [1:]). Доп. фильтр по _NEARBY_METHODS: если у соседа
# как-то оказался exact (другой ИНН, multi-value РНС на тот же ЗУ) — он всё равно
# «другой застройщик участка», но в nearby кладём только spatial/quarter-контекст,
# чтобы семантика nearby = «строят рядом/в квартале» оставалась чистой.
nearby: list[NearbyDeveloper] = []
for row in deduped_rows[1:]:
if _row_get(row, "match_method") not in _NEARBY_METHODS:
continue
nearby.append(NearbyDeveloper(**_attribution_fields(row)))
return DeveloperAttributionResult(primary=primary, nearby_developers=nearby)
__all__ = ["get_developer_attribution"]