`UNIQUE (doc_group, doc_num)` вводился, чтобы склеивать ОДИН документ,
пришедший из двух схем портала. Замер 20.08.2026 показал, что задача,
ради которой ключ введён, почти отсутствует, а побочный эффект огромен:
docNum у ГИСОГД НЕ уникален — разрешение и изменения к нему носят один
номер.
группа документов различных key различных docNum схлопывается
DocRS 6098 6096 4305 1793
DocRV 5419 5415 4969 450
DocIZ 548 547 393 155
общих docNum между схемами (DocRS): 2 ← ради этого ключ и вводился
общих key между схемами (DocRS): 2 ← те же два
На проде 9182 строки против 12 065 документов на портале — нет 23.9 %
реестра. Пример 66-06-06-2026: портал отдаёт два документа (key …719586 —
само разрешение, key …752293 — изменения к нему), а UPSERT с
предпочтением позднего date_reg оставлял только изменение. Так вытеснено
598 из 4320 строк РНС (13.8 %) — в §6 на месте разрешения показывается
изменение к нему, без признака подмены.
Ключ стал `UNIQUE (source_key)`: разделяет разрешение и изменения (разные
key) и по-прежнему склеивает настоящие межсхемные дубли (у них key
ОБЩИЙ — ровно 7 записей по всем группам). Дедуп перед сменой не нужен:
source_key на проде уже уникален (9182 из 9182, NOT NULL).
Заодно группа DocIZ добавлена в GROUP_CODE — её не было вовсе, 548
документов не грузились. CHECK расширен значением 'IZ'.
§6 сужена до РНС/РВЭ ЯВНО: агрегат обещает total_count = rs_count +
rv_count, а строки 'IZ' попадали бы в total и ни в один счётчик.
Показывать ли изменения отдельной строкой — вопрос продуктовый (#2986);
до его решения сужение стоит в запросе, а не держится на том, что таких
строк «пока нет».
Проверки:
- два гейта на лоадер (GROUP_CODE и цель ON CONFLICT) — БЕЗ базы,
двусторонние: на origin/main дают конкретные неверные значения
({'DocRS','DocRV'} и старый ON CONFLICT в тексте запроса);
- гейт на §6 и контроль инварианта total = rs + rv на данных — красные
на origin/main;
- герметичная репетиция миграции на временной копии: со старым ключом
разрешение и изменение схлопываются в одну строку (и остаётся именно
изменение — как на проде), после миграции живут раздельно; межсхемный
дубль по-прежнему склеивается; CHECK принимает 'IZ' и отвергает мусор.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
141 lines
6.8 KiB
Python
141 lines
6.8 KiB
Python
"""Точный радиус-запрос разрешений на строительство/ввод рядом с участком (ГИСОГД-66).
|
||
|
||
Заменяет TODO-прокси quarter-prefix из analyze_parcel (#105 Phase 5): после того как
|
||
Phase 3 (geocoding → geom) наполнила `gisogd_permits.geom`, соседние РНС/РВЭ ищутся
|
||
точным `ST_DWithin(..., radius_m)` по геометрии, а не грубым совпадением префикса
|
||
кадастрового квартала. Старый quarter-prefix путь (`ekburg_construction_permits` →
|
||
`recent_permits_in_quarter` / `permits_summary`) НЕ трогается — он остаётся параллельно
|
||
как отдельный, более богатый полями источник; этот модуль лишь добавляет второй,
|
||
геометрически честный ключ payload.
|
||
|
||
`gisogd_permits`: id / doc_group ('RS' — разрешение на строительство, 'RV' — ввод в
|
||
эксплуатацию) / doc_num / doc_name / date_doc / date_reg / approved_organization /
|
||
cad_nums TEXT[] / geom geometry(MultiPolygon, 4326), GIST-индекс на geom.
|
||
|
||
PURE-по-данным (никаких выдуманных чисел): пустой радиус — честный ноль
|
||
(total_count=0, items=[]), НЕ ошибка и НЕ None. Вызывается из синхронного analyze_parcel
|
||
с той же non-fatal семантикой.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from typing import Any
|
||
|
||
from sqlalchemy import text
|
||
from sqlalchemy.orm import Session
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Максимум записей в списке items. Счётчики (total_count/rs_count/rv_count/
|
||
# nearest_distance_m) считаются по ПОЛНОЙ выборке радиуса — БЕЗ LIMIT в SQL, чтобы не было
|
||
# тихого капа (проектная конвенция «без тихих капов»: тот же паттерн чинили в дедупе
|
||
# объявлений и фрешнес-мониторе). LIMIT капает лишь выдачу items → items_truncated=True.
|
||
_ITEMS_LIMIT = 30
|
||
|
||
# Радиус-запрос по образцу POI-в-радиусе (osm_poi_ekb) из analyze_parcel: ST_DWithin по
|
||
# geography-центроиду участка. `::geography` приклеено к `)`, НЕ к bind-name → psycopg v3
|
||
# его не съедает (backend.md: исключение из :name::type-запрета). БЕЗ LIMIT: агрегаты честны
|
||
# по всей выборке; в радиусе 500м GIST-индекс делает выборку дешёвой, тысяч строк не будет.
|
||
_PERMITS_NEARBY_SQL = text("""
|
||
SELECT
|
||
doc_group,
|
||
doc_name,
|
||
doc_num,
|
||
date_doc,
|
||
date_reg,
|
||
approved_organization,
|
||
ST_Distance(
|
||
geom::geography,
|
||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
|
||
) AS distance_m
|
||
FROM gisogd_permits
|
||
-- Только РНС/РВЭ: агрегат обещает total_count = rs_count + rv_count, а с
|
||
-- #2986 в таблице появилась третья группа 'IZ' (изменения в разрешение).
|
||
-- Она попадала бы в total и не попадала ни в один из счётчиков — молчаливое
|
||
-- расхождение. Показывать ли изменения отдельной строкой в §6 — вопрос
|
||
-- продуктовый (см. #2986); до его решения выборка сужена явно, а не молча.
|
||
WHERE doc_group IN ('RS', 'RV')
|
||
AND geom IS NOT NULL
|
||
AND ST_DWithin(
|
||
geom::geography,
|
||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
||
:radius_m
|
||
)
|
||
ORDER BY distance_m ASC
|
||
""")
|
||
|
||
|
||
def _empty_result(radius_m: int) -> dict[str, Any]:
|
||
"""Честный нулевой агрегат: 0 разрешений в радиусе (НЕ ошибка, НЕ None)."""
|
||
return {
|
||
"radius_m": radius_m,
|
||
"total_count": 0,
|
||
"rs_count": 0,
|
||
"rv_count": 0,
|
||
"nearest_distance_m": None,
|
||
"items": [],
|
||
"items_truncated": False,
|
||
"source": "gisogd66",
|
||
}
|
||
|
||
|
||
def get_permits_nearby(db: Session, geom_wkt: str, radius_m: int = 500) -> dict[str, Any]:
|
||
"""РНС/РВЭ из ГИСОГД-66 в радиусе `radius_m` метров от участка (точный ST_DWithin).
|
||
|
||
Args:
|
||
db: активная сессия SQLAlchemy.
|
||
geom_wkt: WKT-геометрия анализируемого участка (та же переменная, что для POI).
|
||
radius_m: радиус поиска в метрах (по умолчанию 500).
|
||
|
||
Returns:
|
||
Агрегат с ЧЕСТНЫМИ счётчиками (см. `_empty_result` для пустой формы):
|
||
radius_m, total_count / rs_count ('RS') / rv_count ('RV') — по ВСЕЙ выборке радиуса
|
||
(без тихого капа), nearest_distance_m (round 1, None если пусто),
|
||
items (≤30 ближайших), items_truncated (True если total_count > len(items)),
|
||
source="gisogd66". Пустой радиус → честный ноль.
|
||
"""
|
||
rows = [
|
||
dict(r)
|
||
for r in db.execute(
|
||
_PERMITS_NEARBY_SQL,
|
||
{"wkt": geom_wkt, "radius_m": radius_m},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
]
|
||
|
||
if not rows:
|
||
return _empty_result(radius_m)
|
||
|
||
# Счётчики — по ПОЛНОЙ выборке (все rows), список items — только первые _ITEMS_LIMIT.
|
||
rs_count = sum(1 for r in rows if r["doc_group"] == "RS")
|
||
rv_count = sum(1 for r in rows if r["doc_group"] == "RV")
|
||
|
||
items: list[dict[str, Any]] = []
|
||
for r in rows[:_ITEMS_LIMIT]:
|
||
date_doc = r["date_doc"]
|
||
distance_m = r["distance_m"]
|
||
items.append(
|
||
{
|
||
"doc_group": r["doc_group"],
|
||
"doc_name": r["doc_name"],
|
||
"doc_num": r["doc_num"],
|
||
"date_doc": date_doc.isoformat() if date_doc is not None else None,
|
||
"approved_organization": r["approved_organization"],
|
||
"distance_m": round(distance_m, 1) if distance_m is not None else None,
|
||
}
|
||
)
|
||
|
||
nearest = items[0]["distance_m"] if items else None
|
||
total_count = len(rows)
|
||
return {
|
||
"radius_m": radius_m,
|
||
"total_count": total_count,
|
||
"rs_count": rs_count,
|
||
"rv_count": rv_count,
|
||
"nearest_distance_m": nearest,
|
||
"items": items,
|
||
"items_truncated": total_count > len(items),
|
||
"source": "gisogd66",
|
||
}
|