feat(site-finder): детерминированная атрибуция застройщика в analyze (#1088) #1302
8 changed files with 1138 additions and 0 deletions
|
|
@ -63,6 +63,7 @@ from app.services.site_finder.competitors import get_competitors
|
|||
from app.services.site_finder.custom_pois import (
|
||||
get_overlaps_for_scoring as _get_custom_poi_overlaps,
|
||||
)
|
||||
from app.services.site_finder.developer_attribution import get_developer_attribution
|
||||
from app.services.site_finder.gate_verdict import compute_gate_verdict
|
||||
from app.services.site_finder.ird_analyze import build_ird_analyze_block
|
||||
from app.services.site_finder.poi_score import PoiScoreResponse, compute_poi_weighted_top7
|
||||
|
|
@ -2856,6 +2857,24 @@ def analyze_parcel(
|
|||
exc_info=True,
|
||||
)
|
||||
|
||||
# Атрибуция застройщика (#1088 «GG-форсайт»): топ-1 застройщик участка + track-record +
|
||||
# nearby_developers (fn_developer_for_parcel / developer_registry, миграция 149). БЕЗ флага
|
||||
# (в отличие от ird-блока выше): чистый DB-резолвер по индексам (point-lookup ~0.06ms,
|
||||
# резолвер ~30ms), без live-geoportal в hot-пути — низкий риск. get_developer_attribution
|
||||
# сам graceful (None при пустом матче / нет геометрии / pre-migration), но обёрнут в
|
||||
# try/except как остальные блоки — сбой не меняет успех/форму ответа (extra="allow"). Ключ
|
||||
# добавляется ТОЛЬКО при наличии застройщика (None → ключ отсутствует, не null-шум).
|
||||
try:
|
||||
_dev_attr = get_developer_attribution(db, cad_num)
|
||||
if _dev_attr is not None:
|
||||
result_payload["developer_attribution"] = _dev_attr.model_dump()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"developer_attribution block failed for cad=%s — analyze response unaffected",
|
||||
cad_num,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return result_payload
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -225,6 +225,73 @@ class CompetitorsResponse(BaseModel):
|
|||
summary: CompetitorsSummary
|
||||
|
||||
|
||||
# ── Developer attribution (#1088 «GG-форсайт») ─────────────────────────────────
|
||||
# Детерминированная атрибуция «пятно земли ↔ застройщик»: канон-застройщик участка
|
||||
# + его track-record (РНС/РВЭ + домрф ЖК/квартиры) из fn_developer_for_parcel /
|
||||
# developer_registry (миграция 149). Источник истины формы — fn_developer_for_parcel
|
||||
# SETOF + developer_registry track-record; схемы зеркалят его поля 1:1.
|
||||
|
||||
|
||||
class DeveloperAttribution(BaseModel):
|
||||
"""Топ-1 застройщик участка + его track-record (#1088).
|
||||
|
||||
developer_inn — нормализованный ИНН (10/12-знач), стабильный кросс-источниковый
|
||||
ключ. match_method — лучшая сработавшая ступень резолвера
|
||||
(exact_cadastral > spatial_150m > quarter). track-record-поля денормализованы
|
||||
из developer_registry по ИНН (могут быть None для застройщика, известного только
|
||||
одной стороне). project/status/obj_class — из конкретного матча (РНС или домрф-ЖК).
|
||||
"""
|
||||
|
||||
developer_inn: str
|
||||
developer_name: str | None
|
||||
source: str | None # 'permits' | 'domrf'
|
||||
match_method: str | None # 'exact_cadastral' | 'spatial_150m' | 'quarter'
|
||||
distance_m: float | None
|
||||
project: str | None
|
||||
status: str | None
|
||||
obj_class: str | None
|
||||
# track-record (developer_registry, по норм-ИНН)
|
||||
permits_total: int | None
|
||||
domrf_objects_total: int | None
|
||||
domrf_active_objects: int | None
|
||||
in_both_sources: bool | None
|
||||
|
||||
|
||||
class NearbyDeveloper(BaseModel):
|
||||
"""Соседний застройщик участка (дедупнутый, match_method spatial_150m/quarter).
|
||||
|
||||
Контекст «кто ещё строит рядом / в квартале». Тот же набор полей, что primary,
|
||||
но без exact-кадастра (это всегда вторичный матч). Дедупнут по developer_inn —
|
||||
один застройщик в nearby представлен своим лучшим (первым по ORDER BY) матчем.
|
||||
"""
|
||||
|
||||
developer_inn: str
|
||||
developer_name: str | None
|
||||
source: str | None
|
||||
match_method: str | None
|
||||
distance_m: float | None
|
||||
project: str | None
|
||||
status: str | None
|
||||
obj_class: str | None
|
||||
permits_total: int | None
|
||||
domrf_objects_total: int | None
|
||||
domrf_active_objects: int | None
|
||||
in_both_sources: bool | None
|
||||
|
||||
|
||||
class DeveloperAttributionResult(BaseModel):
|
||||
"""Результат атрибуции застройщика участка (#1088).
|
||||
|
||||
primary — лучший матч (топ-1 по ORDER BY резолвера: exact dist=0 → spatial →
|
||||
quarter). nearby_developers — остальные дедупнутые застройщики (spatial/quarter),
|
||||
«кто ещё строит рядом». Сервис возвращает None при пустом результате (нет матча /
|
||||
нет геометрии / миграция не применена) — analyze получает ключ только при наличии.
|
||||
"""
|
||||
|
||||
primary: DeveloperAttribution
|
||||
nearby_developers: list[NearbyDeveloper]
|
||||
|
||||
|
||||
# ── Layout analysis (Issue #113) ───────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -570,6 +637,11 @@ class AnalyzeResponse(BaseModel):
|
|||
# functional_zone/krt (геопортал WFS) + zone_regulation (C8b). Отсутствует при флаге off —
|
||||
ird: dict[str, Any] | None = None
|
||||
|
||||
# — Атрибуция застройщика (#1088 «GG-форсайт»): топ-1 застройщик участка + track-record +
|
||||
# nearby_developers (fn_developer_for_parcel / developer_registry, миграция 149). Additive,
|
||||
# без флага (чистый DB-lookup, graceful). Отсутствует при пустом матче / pre-migration —
|
||||
developer_attribution: dict[str, Any] | None = None
|
||||
|
||||
# — 202 fetch-stub поля (#93): present ТОЛЬКО на 202-ответе, иначе отсутствуют —
|
||||
status: str | None = None
|
||||
job_id: int | None = None
|
||||
|
|
|
|||
207
backend/app/services/site_finder/developer_attribution.py
Normal file
207
backend/app/services/site_finder/developer_attribution.py
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
"""Детерминированная атрибуция застройщика для 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"]
|
||||
|
|
@ -264,6 +264,19 @@ def build_beat_schedule() -> dict:
|
|||
"options": {"queue": "celery"},
|
||||
}
|
||||
|
||||
# Developer registry refresh (#1088 «GG-форсайт») — ежемесячно 1-го числа в 05:30 МСК,
|
||||
# через 30 мин ПОСЛЕ ekburg-permits-monthly (05:00), чтобы permits-сторона реестра
|
||||
# обновлялась на свежих РНС/РВЭ. fn_refresh_developer_registry() → REFRESH MATERIALIZED
|
||||
# VIEW CONCURRENTLY developer_registry (миграция 149; не блокирует читателей analyze).
|
||||
# Celery conf.timezone=Europe/Moscow → crontab трактуется в МСК (#1233). Техническая
|
||||
# infra-задача (не в job_settings, как refresh-quarter-price-index). domrf-сторона
|
||||
# обновляется ежедневно (scrape_kn), но ИНН/track-record меняются редко — monthly хватает.
|
||||
schedule["developer-registry-refresh-monthly"] = {
|
||||
"task": "tasks.developer_registry_refresh.refresh_developer_registry",
|
||||
"schedule": _parse_cron("30 5 1 * *"), # 05:30 MSK, 1st of month
|
||||
"options": {"queue": "celery"},
|
||||
}
|
||||
|
||||
# ПЗЗ территориальных зон ЕКБ из PKK6 ArcGIS — ежемесячно 1-го числа в 03:00 МСК.
|
||||
# Celery conf.timezone=Europe/Moscow → crontab трактуется в МСК (#1233).
|
||||
# PKK6 нестабилен под нагрузкой, но данные ПЗЗ меняются редко — раз в месяц достаточно.
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ celery_app = Celery(
|
|||
"app.workers.tasks.okn_objects_sync",
|
||||
"app.workers.tasks.pat_subzones_load",
|
||||
"app.workers.tasks.izyatie_ocr_ingest",
|
||||
"app.workers.tasks.developer_registry_refresh",
|
||||
],
|
||||
)
|
||||
celery_app.conf.timezone = "Europe/Moscow"
|
||||
|
|
|
|||
58
backend/app/workers/tasks/developer_registry_refresh.py
Normal file
58
backend/app/workers/tasks/developer_registry_refresh.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"""Celery task: refresh developer_registry materialized view (#1088 «GG-форсайт»).
|
||||
|
||||
Вызывает ``fn_refresh_developer_registry()`` (миграция 149,
|
||||
``data/sql/149_developer_attribution.sql``) — REFRESH MATERIALIZED VIEW CONCURRENTLY
|
||||
``developer_registry`` (реестр застройщиков по норм-ИНН: канон-имя + track-record
|
||||
permits+domrf). CONCURRENTLY не блокирует SELECT-читателей (analyze) во время refresh.
|
||||
|
||||
Scheduled via hardcoded beat entry в workers/beat_schedule.py:
|
||||
'developer-registry-refresh-monthly' — ежемесячно 1-го числа в 05:30 МСК,
|
||||
через 30 мин после 'ekburg-permits-monthly' (05:00 МСК), чтобы permits-сторона
|
||||
реестра обновлялась на свежих РНС/РВЭ. domrf-сторона обновляется ежедневно
|
||||
(scrape_kn), но ИНН/track-record застройщиков меняются редко — monthly refresh
|
||||
реестра с запасом достаточно (резолвер всё равно читает domrf-quarter напрямую).
|
||||
|
||||
Issue: #1088.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
from app.workers.celery_app import celery_app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
bind=True,
|
||||
name="tasks.developer_registry_refresh.refresh_developer_registry",
|
||||
max_retries=2,
|
||||
)
|
||||
def refresh_developer_registry(self: Any) -> dict[str, Any]:
|
||||
"""REFRESH MATERIALIZED VIEW CONCURRENTLY developer_registry.
|
||||
|
||||
Лёгкая задача (реестр ~1024 застройщика). CONCURRENTLY — non-blocking для
|
||||
читателей analyze; требует unique-индекс uq_developer_registry_inn (создан
|
||||
миграцией 149). Возвращает row-count реестра после refresh для observability.
|
||||
|
||||
Returns:
|
||||
dict для Celery result store / логирования.
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.execute(text("SELECT fn_refresh_developer_registry()"))
|
||||
db.commit()
|
||||
row = db.execute(text("SELECT COUNT(*) FROM developer_registry")).first()
|
||||
count = int(row[0]) if row else 0
|
||||
logger.info("refresh_developer_registry: completed, developers=%d", count)
|
||||
return {"status": "ok", "developer_registry_rows": count}
|
||||
except Exception as e:
|
||||
logger.exception("refresh_developer_registry failed: %s", e)
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
291
backend/tests/services/site_finder/test_developer_attribution.py
Normal file
291
backend/tests/services/site_finder/test_developer_attribution.py
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
"""Unit-тесты атрибуции застройщика участка (#1088 «GG-форсайт»).
|
||||
|
||||
Тестируем get_developer_attribution с MagicMock-сессией (без живой БД, без app.main):
|
||||
• happy-path: rows резолвера → primary (топ-1) + nearby_developers;
|
||||
• дедуп по developer_inn: один застройщик в exact+spatial+quarter → один в выводе,
|
||||
сохраняется ЛУЧШИЙ (первый по ORDER BY резолвера) match_method;
|
||||
• nearby содержит только spatial_150m/quarter (не exact, не дубль primary);
|
||||
• пустой результат резолвера → None;
|
||||
• graceful-degrade при БД-ошибке (OperationalError/ProgrammingError/DataError —
|
||||
incl. ещё не задеплоенная миграция 149) → None, analyze не падает;
|
||||
• SQL использует CAST(:x AS type), НИКОГДА :x::type (psycopg v3).
|
||||
|
||||
Детерминированно, без LLM. rows мокаются как dict (RowMapping-совместимо через
|
||||
__getitem__, как реальный .mappings().all()).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.exc import DataError, OperationalError, ProgrammingError
|
||||
|
||||
from app.services.site_finder.developer_attribution import (
|
||||
_DEVELOPER_FOR_PARCEL_SQL,
|
||||
get_developer_attribution,
|
||||
)
|
||||
|
||||
# ── helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _mock_db(rows: list[dict[str, Any]]) -> MagicMock:
|
||||
"""Сессия с одним execute → .mappings().all() == rows (зеркало test_future_supply)."""
|
||||
db = MagicMock()
|
||||
result = MagicMock()
|
||||
result.mappings.return_value.all.return_value = rows
|
||||
db.execute.return_value = result
|
||||
return db
|
||||
|
||||
|
||||
def _row(
|
||||
inn: str,
|
||||
*,
|
||||
name: str | None = "Канон Девелопмент",
|
||||
source: str = "permits",
|
||||
match_method: str = "exact_cadastral",
|
||||
distance_m: float | None = 0.0,
|
||||
project: str | None = "ЖК Тест",
|
||||
status: str | None = "RNS",
|
||||
obj_class: str | None = "Комфорт",
|
||||
permits_total: int | None = 5,
|
||||
domrf_objects_total: int | None = 3,
|
||||
domrf_active_objects: int | None = 2,
|
||||
in_both_sources: bool | None = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Одна строка резолвера fn_developer_for_parcel (полный набор колонок)."""
|
||||
return {
|
||||
"developer_inn": inn,
|
||||
"developer_name": name,
|
||||
"source": source,
|
||||
"match_method": match_method,
|
||||
"distance_m": distance_m,
|
||||
"project": project,
|
||||
"status": status,
|
||||
"obj_class": obj_class,
|
||||
"permits_total": permits_total,
|
||||
"domrf_objects_total": domrf_objects_total,
|
||||
"domrf_active_objects": domrf_active_objects,
|
||||
"in_both_sources": in_both_sources,
|
||||
}
|
||||
|
||||
|
||||
# ── SQL-конвенция (psycopg v3) ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_sql_uses_cast_not_double_colon() -> None:
|
||||
"""psycopg v3: запрещён :name::type (SQLAlchemy 2.0 молча роняет его → SyntaxError)."""
|
||||
sql = str(_DEVELOPER_FOR_PARCEL_SQL)
|
||||
assert "::" not in sql, f"double-colon cast найден в SQL: {sql!r}"
|
||||
assert "CAST(:radius AS double precision)" in sql
|
||||
|
||||
|
||||
# ── happy-path ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPrimaryAttribution:
|
||||
def test_single_developer_becomes_primary(self) -> None:
|
||||
db = _mock_db([_row("6671138299", name="ЕКБ Девелопмент")])
|
||||
result = get_developer_attribution(db, "66:41:0303161:123")
|
||||
|
||||
assert result is not None
|
||||
assert result.primary.developer_inn == "6671138299"
|
||||
assert result.primary.developer_name == "ЕКБ Девелопмент"
|
||||
assert result.primary.match_method == "exact_cadastral"
|
||||
assert result.primary.distance_m == 0.0
|
||||
assert result.primary.permits_total == 5
|
||||
assert result.primary.in_both_sources is True
|
||||
assert result.nearby_developers == []
|
||||
|
||||
def test_radius_passed_to_resolver(self) -> None:
|
||||
db = _mock_db([_row("6671138299")])
|
||||
get_developer_attribution(db, "66:41:0303161:123", radius_m=250.0)
|
||||
|
||||
_args, _kwargs = db.execute.call_args
|
||||
params = _args[1]
|
||||
assert params == {"cad_num": "66:41:0303161:123", "radius": 250.0}
|
||||
|
||||
def test_default_radius_is_150(self) -> None:
|
||||
db = _mock_db([_row("6671138299")])
|
||||
get_developer_attribution(db, "66:41:0303161:123")
|
||||
params = db.execute.call_args[0][1]
|
||||
assert params["radius"] == 150.0
|
||||
|
||||
def test_first_row_is_primary_resolver_order_trusted(self) -> None:
|
||||
# Резолвер уже ORDER BY method (exact > spatial > quarter): первая строка = топ-1.
|
||||
rows = [
|
||||
_row("1111111111", match_method="exact_cadastral", distance_m=0.0),
|
||||
_row("2222222222", match_method="spatial_150m", distance_m=42.0),
|
||||
]
|
||||
result = get_developer_attribution(db := _mock_db(rows), "66:41:0303161:1")
|
||||
assert db is not None
|
||||
assert result is not None
|
||||
assert result.primary.developer_inn == "1111111111"
|
||||
assert result.primary.match_method == "exact_cadastral"
|
||||
|
||||
|
||||
# ── дедуп по developer_inn ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDedup:
|
||||
def test_same_inn_across_stages_dedups_keeping_best_method(self) -> None:
|
||||
# Один застройщик встречается в exact + spatial + quarter (резолвер не
|
||||
# взаимоисключающий). Дедуп оставляет ПЕРВЫЙ (лучший по ORDER BY) — exact.
|
||||
rows = [
|
||||
_row("6671138299", match_method="exact_cadastral", distance_m=0.0, project="ЖК A"),
|
||||
_row("6671138299", match_method="spatial_150m", distance_m=80.0, project="ЖК A-2"),
|
||||
_row("6671138299", match_method="quarter", distance_m=None, project="ЖК A-3"),
|
||||
]
|
||||
result = get_developer_attribution(_mock_db(rows), "66:41:0303161:1")
|
||||
|
||||
assert result is not None
|
||||
assert result.primary.developer_inn == "6671138299"
|
||||
# ЛУЧШИЙ метод сохранён (первый по ORDER BY), не перезаписан spatial/quarter.
|
||||
assert result.primary.match_method == "exact_cadastral"
|
||||
assert result.primary.project == "ЖК A"
|
||||
# Тот же ИНН не попадает в nearby (дедупнут целиком).
|
||||
assert result.nearby_developers == []
|
||||
|
||||
def test_distinct_developers_split_primary_and_nearby(self) -> None:
|
||||
rows = [
|
||||
_row("1111111111", match_method="exact_cadastral", distance_m=0.0),
|
||||
_row("2222222222", match_method="spatial_150m", distance_m=55.0),
|
||||
_row("3333333333", match_method="quarter", distance_m=None),
|
||||
]
|
||||
result = get_developer_attribution(_mock_db(rows), "66:41:0303161:1")
|
||||
|
||||
assert result is not None
|
||||
assert result.primary.developer_inn == "1111111111"
|
||||
nearby_inns = [d.developer_inn for d in result.nearby_developers]
|
||||
assert nearby_inns == ["2222222222", "3333333333"]
|
||||
|
||||
def test_nearby_developer_dedups_too(self) -> None:
|
||||
# Сосед, дважды встретившийся (spatial + quarter), в nearby один раз (лучший).
|
||||
rows = [
|
||||
_row("1111111111", match_method="exact_cadastral", distance_m=0.0),
|
||||
_row("2222222222", match_method="spatial_150m", distance_m=55.0),
|
||||
_row("2222222222", match_method="quarter", distance_m=None),
|
||||
]
|
||||
result = get_developer_attribution(_mock_db(rows), "66:41:0303161:1")
|
||||
|
||||
assert result is not None
|
||||
assert [d.developer_inn for d in result.nearby_developers] == ["2222222222"]
|
||||
assert result.nearby_developers[0].match_method == "spatial_150m"
|
||||
|
||||
|
||||
class TestNearbyFiltering:
|
||||
def test_nearby_excludes_secondary_exact_method(self) -> None:
|
||||
# Второй (другой ИНН) с exact (multi-value РНС на тот же ЗУ) — не primary, но
|
||||
# в nearby кладём только spatial/quarter-контекст («строят рядом/в квартале»).
|
||||
rows = [
|
||||
_row("1111111111", match_method="exact_cadastral", distance_m=0.0),
|
||||
_row("2222222222", match_method="exact_cadastral", distance_m=0.0),
|
||||
_row("3333333333", match_method="spatial_150m", distance_m=60.0),
|
||||
]
|
||||
result = get_developer_attribution(_mock_db(rows), "66:41:0303161:1")
|
||||
|
||||
assert result is not None
|
||||
assert result.primary.developer_inn == "1111111111"
|
||||
# Вторичный exact (2222) отфильтрован; остаётся только spatial-сосед (3333).
|
||||
assert [d.developer_inn for d in result.nearby_developers] == ["3333333333"]
|
||||
|
||||
def test_nearby_includes_both_spatial_and_quarter(self) -> None:
|
||||
rows = [
|
||||
_row("1111111111", match_method="exact_cadastral", distance_m=0.0),
|
||||
_row("2222222222", match_method="spatial_150m", distance_m=30.0),
|
||||
_row("3333333333", match_method="quarter", distance_m=None, source="domrf"),
|
||||
]
|
||||
result = get_developer_attribution(_mock_db(rows), "66:41:0303161:1")
|
||||
|
||||
assert result is not None
|
||||
methods = [d.match_method for d in result.nearby_developers]
|
||||
assert methods == ["spatial_150m", "quarter"]
|
||||
|
||||
|
||||
# ── nullable track-record (FULL OUTER JOIN → None-колонки) ───────────────────
|
||||
|
||||
|
||||
class TestNullableFields:
|
||||
def test_developer_known_only_to_one_source(self) -> None:
|
||||
# Застройщик из domrf без РНС: permits_total может быть 0, distance_m None.
|
||||
row = _row(
|
||||
"7777777777",
|
||||
source="domrf",
|
||||
match_method="quarter",
|
||||
distance_m=None,
|
||||
status="Строящиеся",
|
||||
permits_total=0,
|
||||
in_both_sources=False,
|
||||
)
|
||||
result = get_developer_attribution(_mock_db([row]), "66:41:0303161:1")
|
||||
|
||||
assert result is not None
|
||||
assert result.primary.distance_m is None
|
||||
assert result.primary.permits_total == 0
|
||||
assert result.primary.in_both_sources is False
|
||||
|
||||
def test_null_track_record_columns_pass_through(self) -> None:
|
||||
row = _row(
|
||||
"8888888888",
|
||||
name=None,
|
||||
domrf_objects_total=None,
|
||||
domrf_active_objects=None,
|
||||
in_both_sources=None,
|
||||
)
|
||||
result = get_developer_attribution(_mock_db([row]), "66:41:0303161:1")
|
||||
|
||||
assert result is not None
|
||||
assert result.primary.developer_name is None
|
||||
assert result.primary.domrf_objects_total is None
|
||||
assert result.primary.in_both_sources is None
|
||||
|
||||
|
||||
# ── пустой результат → None ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_empty_resolver_result_returns_none() -> None:
|
||||
result = get_developer_attribution(_mock_db([]), "66:41:0303161:1")
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_all_rows_null_inn_returns_none() -> None:
|
||||
# Резолвер гарантирует NOT NULL inn (WHERE c.inn IS NOT NULL), но защищаемся:
|
||||
# все строки с NULL-ИНН → нечего атрибутировать → None.
|
||||
rows = [_row("ignored")]
|
||||
rows[0]["developer_inn"] = None
|
||||
result = get_developer_attribution(_mock_db(rows), "66:41:0303161:1")
|
||||
assert result is None
|
||||
|
||||
|
||||
# ── graceful-degrade при БД-ошибке ───────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exc",
|
||||
[
|
||||
OperationalError("stmt", {}, Exception("connection lost")),
|
||||
ProgrammingError("stmt", {}, Exception("function fn_developer_for_parcel does not exist")),
|
||||
DataError("stmt", {}, Exception("invalid input syntax")),
|
||||
],
|
||||
ids=["operational", "programming_pre_migration", "data"],
|
||||
)
|
||||
def test_db_error_degrades_to_none(exc: Exception) -> None:
|
||||
# Миграция 149 не задеплоена (ProgrammingError) / коннект упал (OperationalError) /
|
||||
# битый ввод (DataError) → None, analyze не падает.
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = exc
|
||||
result = get_developer_attribution(db, "66:41:0303161:1")
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_unexpected_error_also_degrades_to_none() -> None:
|
||||
# Любая иная ошибка (не из перечисленных) тоже не валит analyze — None.
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = RuntimeError("boom")
|
||||
result = get_developer_attribution(db, "66:41:0303161:1")
|
||||
assert result is None
|
||||
477
data/sql/149_developer_attribution.sql
Normal file
477
data/sql/149_developer_attribution.sql
Normal file
|
|
@ -0,0 +1,477 @@
|
|||
-- 149_developer_attribution.sql
|
||||
-- #1088 (GG-форсайт) — детерминированный geo-join «пятно земли ↔ застройщик» для
|
||||
-- Site Finder analyze/competitors. Связывает РНС-сторону (ekburg_construction_permits)
|
||||
-- с домрф-стороной (domrf_kn_objects) через НОРМАЛИЗОВАННЫЙ ИНН — единственный
|
||||
-- стабильный кросс-источниковый ключ застройщика — и резолвит участок → застройщик
|
||||
-- (имя+ИНН), его проект/статус, и track-record застройщика по району/кварталу.
|
||||
--
|
||||
-- ── ЗАЧЕМ ────────────────────────────────────────────────────────────────────────
|
||||
-- До #1088 analyze/competitors «слепы» к РНС: domrf_kn_objects.dev_inn даёт застройщика
|
||||
-- для домрф-объектов, но НЕТ связки с РНС-разрешениями и НЕТ канон-имени застройщика
|
||||
-- (одно ИНН пишется по-разному: prod — 163 ИНН в permits имеют >1 написания имени;
|
||||
-- «ИП Хабибуллин О.В.» vs «ИП Хабибуллин Олег Вахалиевич»). Этот слой даёт:
|
||||
-- 1. fn_normalize_inn — детерминированная нормализация ИНН (10/12-знач, иначе NULL)
|
||||
-- 2. developer_registry — MATERIALIZED реестр застройщиков по норм-ИНН: канон-имя
|
||||
-- + track-record (refresh через fn_refresh_developer_registry, unique-idx по ИНН)
|
||||
-- 3. developer_attribution — алиас-VIEW с denormalized полями для analyze-блока
|
||||
-- 4. fn_developer_for_parcel(cad_num) — резолвер участок→застройщик (3-ступенчатый
|
||||
-- fallback: точный кадастр → spatial 150 м → кадастровый квартал)
|
||||
--
|
||||
-- ── PROD-ФАКТЫ (подтверждено EXPLAIN/COUNT, 2026-06-13) ───────────────────────────
|
||||
-- ekburg_construction_permits: 4059 строк; developer_inn TEXT not-null=3564 (87.8%),
|
||||
-- все pure-digit; 3401×10-знач (юрлица), 154×12-знач (ИП), 9×11-знач (СЛИПШИЙСЯ
|
||||
-- мусор → норм. отбрасывает в NULL). cadastral_number not-null=4054 (99.9%); 3439
|
||||
-- full-cad, 57 multi-value (запятая/«;» — несколько ЗУ в одной РНС), 2 quarter-only.
|
||||
-- geom GEOMETRY(Point,4326) not-null=3994 (98.4%), GIST gist_ekburg_permits_geom.
|
||||
-- НЕТ btree на cadastral_number → exact-lookup = Seq Scan 33мс (фикс ниже).
|
||||
-- domrf_kn_objects: 10699 строк = 1543 distinct obj_id × снапшоты (2026-04-27..06-10),
|
||||
-- ВСЕ region_cd=66. dev_inn TEXT, на latest snapshot not-null=1543 (100%), все
|
||||
-- 10-знач. ИНН СТАБИЛЕН между снапшотами (0 объектов меняют ИНН). cad_quarter —
|
||||
-- формат «66:41:NNNNNNN», заполнен на 1131/1543 объектах (73%), НА LATEST snapshot
|
||||
-- часто NULL → берём с ЛЮБОГО снапшота где NOT NULL (тоже стабилен, 0 изменений).
|
||||
-- cad_building_num — ВЕЗДЕ NULL (не используем). НЕТ колонки geom (только
|
||||
-- latitude/longitude NUMERIC, у всех 1543) → domrf spatial идёт через MakePoint.
|
||||
-- district_name заполнен на 1315/1543 (85%), 8 админ-районов ЕКБ.
|
||||
-- Кросс-матч норм-ИНН: 888 distinct в permits, 428 в domrf, СОВПАДАЮТ 292 (68.2%
|
||||
-- застройщиков domrf имеют РНС в ЕКБ). 1740 permit-строк (42.9%) принадлежат
|
||||
-- застройщику из domrf; 1240 domrf-объектов (80.4%) — застройщику из permits.
|
||||
--
|
||||
-- ── ИНН-НОРМАЛИЗАЦИЯ (детерминированная, edge-cases) ──────────────────────────────
|
||||
-- fn_normalize_inn: btrim → если результат matches '^[0-9]{10}$' ИЛИ '^[0-9]{12}$'
|
||||
-- → вернуть как есть; ИНАЧЕ NULL. Покрывает edge-cases:
|
||||
-- NULL / '' / '0' → NULL (495 permit-строк, 0 domrf)
|
||||
-- 11-знач '66711382990' → NULL (9 permit-строк — два слипшихся ИНН/ИП; чинить
|
||||
-- эвристикой НЕ беремся — детерминированно мусор)
|
||||
-- ' 6671138299 ' (пробелы)→ '6671138299' (btrim)
|
||||
-- буквы/дефисы/запятые → NULL (не pure-digit)
|
||||
-- Ведущие нули НЕ режем: российский ИНН — фикс-длины строка-идентификатор, '00…' —
|
||||
-- значимые цифры (срезка сломала бы матч). IMMUTABLE — годен для functional index.
|
||||
--
|
||||
-- ── КАНОН-ИМЯ (dedupe, детерминированный) ─────────────────────────────────────────
|
||||
-- Один ИНН → много написаний. Канон выбираем так (ORDER BY, без LLM/рандома):
|
||||
-- 1) приоритет источника: domrf-имя > permits-имя (domrf — юрлица из реестра дом.рф,
|
||||
-- чище и полнее, чем ручной ввод РНС);
|
||||
-- 2) внутри источника — самое ЧАСТОЕ написание (по числу строк);
|
||||
-- 3) tie-break — лексикографически наименьшее (btrim'нутое) → стабильно при ре-apply.
|
||||
--
|
||||
-- ── РЕЗОЛВЕР УЧАСТОК→ЗАСТРОЙЩИК (3 ступени, детерминированный приоритет) ───────────
|
||||
-- fn_developer_for_parcel(cad_num) возвращает атрибуцию с match_method:
|
||||
-- 'exact_cadastral' — permits.cadastral_number = cad_num (точное, max precision);
|
||||
-- 'spatial_150m' — permit.geom в 150 м от geom участка (GIST envelope + geography
|
||||
-- refine); берём ближайший permit;
|
||||
-- 'quarter' — по кадастровому кварталу: permits и domrf того же квартала.
|
||||
-- Ступени НЕ взаимоисключающи на уровне данных, но метод проставляется по ЛУЧШЕЙ
|
||||
-- сработавшей ступени (exact > spatial > quarter). Возвращаем ВСЕХ кандидатов с
|
||||
-- match_method и distance_m — caller (backend) решает, брать топ-1 или ранжировать.
|
||||
--
|
||||
-- ── SPATIAL GIST-TRAP (важно для перфа) ───────────────────────────────────────────
|
||||
-- ST_DWithin(geom::geography, …) НЕ использует GIST (индекс на geometry, не geography)
|
||||
-- → Seq Scan 91мс. Правильно: ST_DWithin(geom, geom, degrees) для GIST-envelope +
|
||||
-- ST_DWithin(::geography, ::geography, meters) для точной метрики. EXPLAIN: 91→12мс,
|
||||
-- 1689→132 буфера, Index Scan using gist_ekburg_permits_geom. 0.0025° envelope на
|
||||
-- широте ЕКБ (~56.8°N) безопасно перекрывает 150 м (1°lat≈111 км, 1°lon≈61 км).
|
||||
--
|
||||
-- ── DEPLOY ────────────────────────────────────────────────────────────────────────
|
||||
-- Auto-applied deploy.yml через _schema_migrations (ровно один раз, NN=149).
|
||||
-- Idempotent: CREATE OR REPLACE FUNCTION/VIEW + CREATE INDEX IF NOT EXISTS +
|
||||
-- DROP MATERIALIZED VIEW IF EXISTS ... CASCADE перед CREATE MATERIALIZED VIEW (matview
|
||||
-- не поддерживает CREATE OR REPLACE). CREATE MATERIALIZED VIEW наполняется данными
|
||||
-- СРАЗУ (без WITH NO DATA) → реестр готов к чтению по завершении миграции; первый
|
||||
-- плановый fn_refresh_developer_registry() просто обновит его при след. sync.
|
||||
-- Migration order: эта SQL-миграция ПЕРВОЙ; backend-сервис
|
||||
-- (app/services/site_finder/developer_attribution.py — рекомендация в отчёте) — следом.
|
||||
-- ЧИСТО ADDITIVE: ни одна существующая таблица/вью/колонка не меняется и не дропается.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────────────
|
||||
-- 1. fn_normalize_inn — детерминированная нормализация ИНН
|
||||
-- ──────────────────────────────────────────────────────────────────────────────────
|
||||
-- btrim + валидация длины/цифр. 10 (юрлицо) или 12 (физлицо/ИП) → как есть, иначе NULL.
|
||||
-- IMMUTABLE STRICT: STRICT → NULL-вход даёт NULL-выход без вызова тела; IMMUTABLE →
|
||||
-- можно использовать в functional index и планировщик кэширует результат.
|
||||
CREATE OR REPLACE FUNCTION fn_normalize_inn(raw text)
|
||||
RETURNS text
|
||||
LANGUAGE sql
|
||||
IMMUTABLE
|
||||
STRICT
|
||||
AS $$
|
||||
SELECT CASE
|
||||
WHEN btrim(raw) ~ '^[0-9]{10}$' OR btrim(raw) ~ '^[0-9]{12}$'
|
||||
THEN btrim(raw)
|
||||
ELSE NULL
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION fn_normalize_inn(text) IS
|
||||
'#1088: детерминированная нормализация ИНН → 10/12-значная pure-digit строка, '
|
||||
'иначе NULL. NULL/пусто/0/11-знач-мусор/буквы → NULL. Ведущие нули сохраняются '
|
||||
'(фикс-длины идентификатор). IMMUTABLE — годна для functional index.';
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────────────
|
||||
-- 2. Индексы для горячего пути резолвера (закрывают Seq Scan / ускоряют join по ИНН)
|
||||
-- ──────────────────────────────────────────────────────────────────────────────────
|
||||
-- 2a. btree на cadastral_number — exact-lookup (= cad_num) делал Seq Scan (4059 строк,
|
||||
-- 33мс). С индексом — Bitmap Index Scan (0.09мс). Equality collation-safe.
|
||||
CREATE INDEX IF NOT EXISTS idx_ekburg_permits_cadastral
|
||||
ON ekburg_construction_permits (cadastral_number);
|
||||
|
||||
-- 2a-bis. text_pattern_ops btree — для quarter-ступени LIKE 'QUARTER:%'. БД collation
|
||||
-- en_US.utf8 (НЕ C) → обычный btree НЕ обслуживает ни LIKE-prefix, ни range
|
||||
-- ['X:','X;') (collation сортирует ':'/';'/цифры не по байтам → range вернул 0
|
||||
-- строк, см. dry-run-баг). text_pattern_ops индексирует по C-семантике байтов →
|
||||
-- LIKE 'prefix%' даёт Bitmap Index Scan (0.74мс vs Seq Scan 6мс от regex '^').
|
||||
CREATE INDEX IF NOT EXISTS idx_ekburg_permits_cad_cpat
|
||||
ON ekburg_construction_permits (cadastral_number text_pattern_ops);
|
||||
|
||||
-- 2b. functional index на нормализованный ИНН — ускоряет join permits⋈domrf по
|
||||
-- fn_normalize_inn(developer_inn). Partial (WHERE norm IS NOT NULL) — мусорные
|
||||
-- ИНН (NULL после норм.) в индекс не попадают, держим его компактным.
|
||||
CREATE INDEX IF NOT EXISTS idx_ekburg_permits_inn_norm
|
||||
ON ekburg_construction_permits (fn_normalize_inn(developer_inn))
|
||||
WHERE fn_normalize_inn(developer_inn) IS NOT NULL;
|
||||
|
||||
-- 2c. functional index на норм-ИНН для domrf (join вторая сторона). domrf
|
||||
-- версионируется по snapshot_date → индекс по (norm_inn) хватает для матча
|
||||
-- застройщика (ИНН стабилен между снапшотами).
|
||||
CREATE INDEX IF NOT EXISTS idx_domrf_kn_objects_inn_norm
|
||||
ON domrf_kn_objects (fn_normalize_inn(dev_inn))
|
||||
WHERE fn_normalize_inn(dev_inn) IS NOT NULL;
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────────────
|
||||
-- 3. developer_registry — реестр застройщиков по нормализованному ИНН + track-record
|
||||
-- ──────────────────────────────────────────────────────────────────────────────────
|
||||
-- Канон-имя застройщика (dedupe вариантов) + агрегаты по ОБЕИМ сторонам:
|
||||
-- permits-сторона: число РНС/РВЭ, суммарные площади, диапазон лет, набор кварталов;
|
||||
-- domrf-сторона: число ЖК (latest snapshot per obj_id), суммарно квартир/в продаже,
|
||||
-- набор районов, набор классов.
|
||||
--
|
||||
-- MATERIALIZED (не plain VIEW): как VIEW point-lookup по ИНН стоил 52мс, а резолвер
|
||||
-- 142-184мс — потому что план КАЖДЫЙ РАЗ пересчитывал весь реестр (DISTINCT ON 10699
|
||||
-- domrf-строк + два FULL JOIN + dedupe-имя) ради джойна track-record к ~14-460
|
||||
-- кандидатам участка. Реестр мал (1024 застройщика) и меняется РЕДКО (permits —
|
||||
-- monthly sync #105; domrf — снапшоты ~раз в сутки), поэтому материализуем: резолвер
|
||||
-- и прямой lookup читают готовые 1024 строки через unique-индекс за микросекунды.
|
||||
-- Refresh — fn_refresh_developer_registry() по расписанию (см. ниже + рекомендация
|
||||
-- backend). DROP ... CASCADE для идемпотентности (matview не поддерживает CREATE OR
|
||||
-- REPLACE): уронит зависимый developer_attribution VIEW — он пересоздаётся ниже в §4.
|
||||
DROP MATERIALIZED VIEW IF EXISTS developer_registry CASCADE;
|
||||
CREATE MATERIALIZED VIEW developer_registry AS
|
||||
WITH
|
||||
-- 3.1 domrf latest snapshot per obj_id (ИНН/имя стабильны между снапшотами).
|
||||
-- Только поля, нужные registry-агрегатам (canonical_name + domrf_agg). cad_quarter
|
||||
-- здесь НЕ тянем (registry агрегирует по району/классу, не по кварталу — а резолвер
|
||||
-- quarter_domrf читает domrf_kn_objects напрямую). Это снимает O(N) коррелированный
|
||||
-- subplan (был 93k буферов / 10699 loops в EXPLAIN) — один проход DISTINCT ON.
|
||||
domrf_latest AS (
|
||||
SELECT DISTINCT ON (o.obj_id)
|
||||
o.obj_id,
|
||||
fn_normalize_inn(o.dev_inn) AS inn,
|
||||
btrim(o.dev_name) AS dev_name,
|
||||
o.obj_class,
|
||||
o.site_status,
|
||||
o.flat_count,
|
||||
o.free_flats,
|
||||
o.district_name
|
||||
FROM domrf_kn_objects o
|
||||
WHERE fn_normalize_inn(o.dev_inn) IS NOT NULL
|
||||
ORDER BY o.obj_id, o.snapshot_date DESC
|
||||
),
|
||||
-- 3.2 Канон-имя: domrf-имя приоритетнее permits-имени; внутри — самое частое;
|
||||
-- tie → min lexical. Собираем кандидаты обеих сторон с приоритетом источника.
|
||||
name_candidates AS (
|
||||
SELECT inn, dev_name AS name, 1 AS source_priority -- domrf > permits
|
||||
FROM domrf_latest
|
||||
WHERE dev_name IS NOT NULL AND dev_name <> ''
|
||||
UNION ALL
|
||||
SELECT fn_normalize_inn(developer_inn) AS inn, btrim(developer_name) AS name, 2 AS source_priority
|
||||
FROM ekburg_construction_permits
|
||||
WHERE fn_normalize_inn(developer_inn) IS NOT NULL
|
||||
AND developer_name IS NOT NULL AND btrim(developer_name) <> ''
|
||||
),
|
||||
canonical_name AS (
|
||||
SELECT DISTINCT ON (inn)
|
||||
inn,
|
||||
name AS developer_name
|
||||
FROM (
|
||||
SELECT inn, name, source_priority, count(*) AS freq
|
||||
FROM name_candidates
|
||||
GROUP BY inn, name, source_priority
|
||||
) ranked
|
||||
ORDER BY inn, source_priority ASC, freq DESC, name ASC
|
||||
),
|
||||
-- 3.3 permits track-record по застройщику (норм-ИНН)
|
||||
permits_agg AS (
|
||||
SELECT
|
||||
fn_normalize_inn(developer_inn) AS inn,
|
||||
count(*) AS permits_total,
|
||||
count(*) FILTER (WHERE permit_type = 'RNS') AS permits_rns,
|
||||
count(*) FILTER (WHERE permit_type = 'RVE') AS permits_rve,
|
||||
sum(total_area_sqm) AS permits_total_area_sqm,
|
||||
sum(living_area_sqm) AS permits_living_area_sqm,
|
||||
min(issue_date) AS permits_first_issue_date,
|
||||
max(issue_date) AS permits_last_issue_date,
|
||||
array_agg(DISTINCT substring(cadastral_number from '^(\d{2}:\d{2}:\d{6,7})'))
|
||||
FILTER (WHERE cadastral_number ~ '^\d{2}:\d{2}:\d{6,7}') AS permits_quarters
|
||||
FROM ekburg_construction_permits
|
||||
WHERE fn_normalize_inn(developer_inn) IS NOT NULL
|
||||
GROUP BY fn_normalize_inn(developer_inn)
|
||||
),
|
||||
-- 3.4 domrf track-record по застройщику (по latest-snapshot объектам)
|
||||
domrf_agg AS (
|
||||
SELECT
|
||||
inn,
|
||||
count(*) AS domrf_objects_total,
|
||||
sum(flat_count) AS domrf_flats_total,
|
||||
sum(free_flats) AS domrf_free_flats_total,
|
||||
count(*) FILTER (WHERE site_status = 'Строящиеся') AS domrf_active_objects,
|
||||
array_agg(DISTINCT district_name)
|
||||
FILTER (WHERE district_name IS NOT NULL) AS domrf_districts,
|
||||
array_agg(DISTINCT obj_class)
|
||||
FILTER (WHERE obj_class IS NOT NULL) AS domrf_classes
|
||||
FROM domrf_latest
|
||||
GROUP BY inn
|
||||
)
|
||||
SELECT
|
||||
COALESCE(p.inn, d.inn) AS developer_inn,
|
||||
cn.developer_name AS developer_name,
|
||||
-- track-record permits-сторона
|
||||
COALESCE(p.permits_total, 0) AS permits_total,
|
||||
COALESCE(p.permits_rns, 0) AS permits_rns,
|
||||
COALESCE(p.permits_rve, 0) AS permits_rve,
|
||||
p.permits_total_area_sqm,
|
||||
p.permits_living_area_sqm,
|
||||
p.permits_first_issue_date,
|
||||
p.permits_last_issue_date,
|
||||
p.permits_quarters,
|
||||
-- track-record domrf-сторона
|
||||
COALESCE(d.domrf_objects_total, 0) AS domrf_objects_total,
|
||||
COALESCE(d.domrf_active_objects, 0) AS domrf_active_objects,
|
||||
d.domrf_flats_total,
|
||||
d.domrf_free_flats_total,
|
||||
d.domrf_districts,
|
||||
d.domrf_classes,
|
||||
-- известен ли застройщик обеим сторонам (кросс-валидация)
|
||||
(p.inn IS NOT NULL AND d.inn IS NOT NULL) AS in_both_sources
|
||||
FROM permits_agg p
|
||||
FULL OUTER JOIN domrf_agg d ON p.inn = d.inn
|
||||
LEFT JOIN canonical_name cn ON cn.inn = COALESCE(p.inn, d.inn);
|
||||
|
||||
COMMENT ON MATERIALIZED VIEW developer_registry IS
|
||||
'#1088: реестр застройщиков по НОРМАЛИЗОВАННОМУ ИНН (fn_normalize_inn) — канон-имя '
|
||||
'(domrf>permits, most-frequent, tie→min-lexical) + track-record обеих сторон '
|
||||
'(permits РНС/РВЭ/площади/кварталы, domrf ЖК/квартиры/районы/классы). '
|
||||
'in_both_sources — кросс-валидация (prod: 292 ИНН). FULL OUTER JOIN — застройщик '
|
||||
'может быть только в одной стороне. MATERIALIZED — refresh через '
|
||||
'fn_refresh_developer_registry() (permits monthly / domrf daily).';
|
||||
|
||||
-- 3.5 UNIQUE индекс на developer_inn — обязателен для REFRESH ... CONCURRENTLY
|
||||
-- (иначе refresh блокирует читателей). Одновременно обслуживает point-lookup по ИНН
|
||||
-- (резолвер джойнит сюда, analyze тянет track-record по конкретному застройщику).
|
||||
-- developer_inn здесь уже НОРМАЛИЗОВАН и NOT NULL (COALESCE двух not-null норм-ИНН).
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_developer_registry_inn
|
||||
ON developer_registry (developer_inn);
|
||||
|
||||
-- 3.6 fn_refresh_developer_registry — обёртка REFRESH CONCURRENTLY (вызывает Celery-
|
||||
-- beat / sync-задачи permits+domrf после обновления источников). CONCURRENTLY — не
|
||||
-- блокирует SELECT-читателей (analyze) во время refresh; требует unique-индекс выше.
|
||||
CREATE OR REPLACE FUNCTION fn_refresh_developer_registry()
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
REFRESH MATERIALIZED VIEW CONCURRENTLY developer_registry;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION fn_refresh_developer_registry() IS
|
||||
'#1088: REFRESH MATERIALIZED VIEW CONCURRENTLY developer_registry. Вызывать после '
|
||||
'обновления источников (ekburg_permits_sync monthly / domrf_kn daily snapshot).';
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────────────
|
||||
-- 4. developer_attribution — алиас-VIEW для analyze-блока (стабильное имя контракта)
|
||||
-- ──────────────────────────────────────────────────────────────────────────────────
|
||||
-- ТЗ #1088 называет целевую сущность `developer_attribution`. Держим её тонким VIEW
|
||||
-- поверх materialized developer_registry: тот же набор строк, имя зафиксировано в
|
||||
-- контракте (backend ходит сюда). VIEW поверх matview читается так же дёшево (matview
|
||||
-- — физическая таблица с unique-индексом). Пересоздаётся ЗДЕСЬ после DROP ... CASCADE
|
||||
-- в §3 (CASCADE роняет этот зависимый VIEW при ре-apply).
|
||||
CREATE OR REPLACE VIEW developer_attribution AS
|
||||
SELECT * FROM developer_registry;
|
||||
|
||||
COMMENT ON VIEW developer_attribution IS
|
||||
'#1088: стабильное имя контракта для analyze-блока. Алиас materialized '
|
||||
'developer_registry (канон застройщик + track-record по норм-ИНН).';
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────────────
|
||||
-- 5. fn_developer_for_parcel — резолвер участок → застройщик (3-ступенчатый fallback)
|
||||
-- ──────────────────────────────────────────────────────────────────────────────────
|
||||
-- Вход: cad_num участка. Геометрию/квартал берём ВНУТРИ из cad_parcels_geom (view над
|
||||
-- cad_parcels, GIST cad_parcels_geom_gist) + _quarter_from_cad по строке номера.
|
||||
-- Выход: SETOF строк-кандидатов с источником/методом/дистанцией + denormalized
|
||||
-- track-record (join к developer_registry по ИНН). Caller (backend) ранжирует/берёт
|
||||
-- топ-1. p_radius_m — радиус spatial-ступени (default 150).
|
||||
--
|
||||
-- STABLE (не IMMUTABLE) — читает таблицы. Параметр geom не принимаем: cad_num —
|
||||
-- единственный вход (геометрия резолвится внутри), чтобы контракт был как у
|
||||
-- competitors.get_competitors(cad_num). Если участка нет в cad_parcels_geom —
|
||||
-- spatial/quarter-domrf по geom не сработают, но exact-cadastral и quarter-permits
|
||||
-- (по строке cad_num) всё равно отработают.
|
||||
CREATE OR REPLACE FUNCTION fn_developer_for_parcel(
|
||||
p_cad_num text,
|
||||
p_radius_m double precision DEFAULT 150.0
|
||||
)
|
||||
RETURNS TABLE (
|
||||
developer_inn text,
|
||||
developer_name text,
|
||||
source text, -- 'permits' | 'domrf'
|
||||
match_method text, -- 'exact_cadastral' | 'spatial_150m' | 'quarter'
|
||||
distance_m double precision,
|
||||
project text, -- permit object_name / domrf comm_name
|
||||
status text, -- permit_type / domrf site_status
|
||||
obj_class text,
|
||||
permits_total integer,
|
||||
domrf_objects_total integer,
|
||||
domrf_active_objects integer,
|
||||
in_both_sources bool
|
||||
)
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
AS $$
|
||||
WITH
|
||||
quarter AS (
|
||||
-- кадастровый квартал из номера участка: '66:41:0313010:2' → '66:41:0313010'
|
||||
SELECT substring(p_cad_num from '^(\d{2}:\d{2}:\d{6,7})') AS q
|
||||
),
|
||||
parcel AS (
|
||||
SELECT geom, ST_Centroid(geom) AS centroid
|
||||
FROM cad_parcels_geom
|
||||
WHERE cad_num = p_cad_num AND geom IS NOT NULL
|
||||
LIMIT 1
|
||||
),
|
||||
-- ── Ступень 1: точный кадастр (permits.cadastral_number = cad_num) ──────────────
|
||||
-- Две ветки, объединённые UNION (каждая sargable / узкая):
|
||||
-- 1a. РОВНОЕ равенство = cad_num → Bitmap Index Scan по idx_ekburg_permits_cadastral
|
||||
-- (0.09мс; покрывает single-value строки = 98.6% permits);
|
||||
-- 1b. multi-value строки РНС (несколько ЗУ через запятую/«;» — prod: 57 строк):
|
||||
-- cad_num как подстрока на границе. Regex применяется ТОЛЬКО к узкому
|
||||
-- подмножеству (LIKE '%,%' OR '%;%') — не Seq Scan по всем 4059.
|
||||
exact_match AS (
|
||||
SELECT
|
||||
fn_normalize_inn(e.developer_inn) AS inn,
|
||||
'permits'::text AS source,
|
||||
'exact_cadastral'::text AS match_method,
|
||||
0.0::double precision AS distance_m,
|
||||
e.object_name AS project,
|
||||
e.permit_type AS status,
|
||||
e.object_type AS obj_class
|
||||
FROM ekburg_construction_permits e
|
||||
WHERE e.cadastral_number = p_cad_num -- 1a: btree equality
|
||||
UNION
|
||||
SELECT
|
||||
fn_normalize_inn(e.developer_inn) AS inn,
|
||||
'permits'::text, 'exact_cadastral'::text, 0.0::double precision,
|
||||
e.object_name, e.permit_type, e.object_type
|
||||
FROM ekburg_construction_permits e
|
||||
WHERE (e.cadastral_number LIKE '%,%' OR e.cadastral_number LIKE '%;%') -- 1b: multi-value subset
|
||||
AND e.cadastral_number ~ ('(^|[,; ])' || replace(p_cad_num, '.', '\.') || '($|[,; ])')
|
||||
),
|
||||
-- ── Ступень 2: spatial 150 м (permit.geom рядом с geom участка) ─────────────────
|
||||
-- GIST-friendly: ST_DWithin(geom, geom, deg) даёт index-scan по
|
||||
-- gist_ekburg_permits_geom, затем geography-refine на точные метры. 0.0025° ⊃ 150м.
|
||||
spatial_match AS (
|
||||
SELECT
|
||||
fn_normalize_inn(e.developer_inn) AS inn,
|
||||
'permits'::text AS source,
|
||||
'spatial_150m'::text AS match_method,
|
||||
ST_Distance(e.geom::geography, pa.centroid::geography) AS distance_m,
|
||||
e.object_name AS project,
|
||||
e.permit_type AS status,
|
||||
e.object_type AS obj_class
|
||||
FROM ekburg_construction_permits e
|
||||
CROSS JOIN parcel pa
|
||||
WHERE e.geom IS NOT NULL
|
||||
AND ST_DWithin(e.geom, pa.geom, p_radius_m / 111000.0)
|
||||
AND ST_DWithin(e.geom::geography, pa.centroid::geography, p_radius_m)
|
||||
),
|
||||
-- ── Ступень 3a: кадастровый квартал — permits ───────────────────────────────────
|
||||
-- LIKE 'QUARTER:%' — sargable через text_pattern_ops индекс (idx_ekburg_permits_
|
||||
-- cad_cpat): все ЗУ квартала имеют вид 'QUARTER:NNN'. → Bitmap Index Scan (0.74мс),
|
||||
-- вместо Seq Scan от regex '^' (6мс). НЕ range ['X:','X;') — он collation-unsafe на
|
||||
-- en_US.utf8 (вернул 0 строк в dry-run); LIKE-prefix на text_pattern_ops надёжен.
|
||||
-- '%' в q.q невозможен (квартал — '\d{2}:\d{2}:\d{6,7}'), escaping не нужен.
|
||||
quarter_permits AS (
|
||||
SELECT
|
||||
fn_normalize_inn(e.developer_inn) AS inn,
|
||||
'permits'::text AS source,
|
||||
'quarter'::text AS match_method,
|
||||
NULL::double precision AS distance_m,
|
||||
e.object_name AS project,
|
||||
e.permit_type AS status,
|
||||
e.object_type AS obj_class
|
||||
FROM ekburg_construction_permits e, quarter q
|
||||
WHERE q.q IS NOT NULL
|
||||
AND e.cadastral_number LIKE q.q || ':%'
|
||||
),
|
||||
-- ── Ступень 3b: кадастровый квартал — domrf (latest snapshot per obj_id) ─────────
|
||||
quarter_domrf AS (
|
||||
SELECT DISTINCT ON (o.obj_id)
|
||||
fn_normalize_inn(o.dev_inn) AS inn,
|
||||
'domrf'::text AS source,
|
||||
'quarter'::text AS match_method,
|
||||
NULL::double precision AS distance_m,
|
||||
o.comm_name AS project,
|
||||
o.site_status AS status,
|
||||
o.obj_class
|
||||
FROM domrf_kn_objects o, quarter q
|
||||
WHERE q.q IS NOT NULL
|
||||
AND btrim(o.cad_quarter) = q.q
|
||||
ORDER BY o.obj_id, o.snapshot_date DESC
|
||||
),
|
||||
-- Объединяем кандидатов всех ступеней. Если сработала ступень exact — caller
|
||||
-- получит её первой (distance_m=0); spatial/quarter дополняют контекст соседей.
|
||||
all_candidates AS (
|
||||
SELECT inn, source, match_method, distance_m, project, status, obj_class FROM exact_match
|
||||
UNION ALL
|
||||
SELECT inn, source, match_method, distance_m, project, status, obj_class FROM spatial_match
|
||||
UNION ALL
|
||||
SELECT inn, source, match_method, distance_m, project, status, obj_class FROM quarter_permits
|
||||
UNION ALL
|
||||
SELECT inn, source, match_method, distance_m, project, status, obj_class FROM quarter_domrf
|
||||
)
|
||||
SELECT
|
||||
c.inn AS developer_inn,
|
||||
reg.developer_name,
|
||||
c.source,
|
||||
c.match_method,
|
||||
c.distance_m,
|
||||
c.project,
|
||||
c.status,
|
||||
c.obj_class,
|
||||
reg.permits_total,
|
||||
reg.domrf_objects_total,
|
||||
reg.domrf_active_objects,
|
||||
reg.in_both_sources
|
||||
FROM all_candidates c
|
||||
LEFT JOIN developer_registry reg ON reg.developer_inn = c.inn
|
||||
WHERE c.inn IS NOT NULL
|
||||
-- детерминированный порядок: лучший метод первым, ближе — раньше, затем ИНН
|
||||
ORDER BY
|
||||
CASE c.match_method
|
||||
WHEN 'exact_cadastral' THEN 1
|
||||
WHEN 'spatial_150m' THEN 2
|
||||
WHEN 'quarter' THEN 3
|
||||
ELSE 4
|
||||
END,
|
||||
c.distance_m ASC NULLS LAST,
|
||||
c.inn;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION fn_developer_for_parcel(text, double precision) IS
|
||||
'#1088: резолвер участок→застройщик. 3 ступени (exact_cadastral > spatial_150m > '
|
||||
'quarter), геометрия/квартал берутся из cad_parcels_geom по cad_num. Возвращает '
|
||||
'SETOF кандидатов с match_method/distance_m + track-record из developer_registry. '
|
||||
'Caller берёт топ-1 (ORDER BY method, distance) или ранжирует. p_radius_m=150 по '
|
||||
'умолчанию. GIST-friendly spatial (ST_DWithin geom degrees + geography refine).';
|
||||
|
||||
COMMIT;
|
||||
Loading…
Add table
Reference in a new issue