feat(sf): scraper реестра КРТ-площадок ЕКБ → ekb_krt_sites (#1060) #1126
8 changed files with 846 additions and 0 deletions
350
backend/app/services/scrapers/ekb_krt_registry.py
Normal file
350
backend/app/services/scrapers/ekb_krt_registry.py
Normal file
|
|
@ -0,0 +1,350 @@
|
||||||
|
"""Scraper реестра КРТ-площадок Екатеринбурга (#1060, GG-форсайт).
|
||||||
|
|
||||||
|
Источник: https://xn--80acgfbsl1azdqr.xn--p1ai/дляработы/гиз/крт
|
||||||
|
(екатеринбург.рф, SSR-страница без auth и JS, обычный TLS GET).
|
||||||
|
|
||||||
|
На странице ~15 площадок по годам (2022–2025). Каждая площадка = текстовый
|
||||||
|
блок прозой с нумерованными пунктами. Граница блока — тег
|
||||||
|
<p>...<strong>N. КРТ (жилой|нежилой) застройки...</strong>...</p>.
|
||||||
|
|
||||||
|
Публичный API:
|
||||||
|
fetch_krt_page() -> str (HTTP GET, сеть)
|
||||||
|
parse_krt_page(html) -> list[KRTSite] (чистая функция, без сети)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date
|
||||||
|
from html import unescape
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
KRT_URL = (
|
||||||
|
"https://xn--80acgfbsl1azdqr.xn--p1ai"
|
||||||
|
"/%D0%B4%D0%BB%D1%8F%D1%80%D0%B0%D0%B1%D0%BE%D1%82%D1%8B"
|
||||||
|
"/%D0%B3%D0%B8%D0%B7/%D0%BA%D1%80%D1%82"
|
||||||
|
)
|
||||||
|
|
||||||
|
_HEADERS = {
|
||||||
|
"User-Agent": ("Mozilla/5.0 (compatible; GenDesign-SF/1.0; +https://gendsgn.ru/)"),
|
||||||
|
"Accept-Language": "ru-RU,ru;q=0.9",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# -- dataclass ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class KRTSite:
|
||||||
|
"""Одна КРТ-площадка из реестра екатеринбург.рф/гиз/крт."""
|
||||||
|
|
||||||
|
site_name: str
|
||||||
|
district: str | None
|
||||||
|
krt_kind: str | None
|
||||||
|
developer_name: str | None
|
||||||
|
contract_number: str | None
|
||||||
|
contract_date: date | None
|
||||||
|
auction_start_price: float | None
|
||||||
|
auction_final_price: float | None
|
||||||
|
gradpotential_sqm: float | None
|
||||||
|
area_ha: float | None
|
||||||
|
mkd_count: int | None
|
||||||
|
paga_decisions: list[dict[str, Any]]
|
||||||
|
raw_block: str
|
||||||
|
source_url: str = KRT_URL
|
||||||
|
|
||||||
|
|
||||||
|
# -- HTTP fetch ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_krt_page() -> str:
|
||||||
|
"""Загружает HTML страницы реестра КРТ-площадок ЕКБ.
|
||||||
|
|
||||||
|
Сеть отделена от парсинга для testability.
|
||||||
|
Raises: httpx.HTTPError при недоступности сервера.
|
||||||
|
"""
|
||||||
|
with httpx.Client(
|
||||||
|
headers=_HEADERS,
|
||||||
|
timeout=30,
|
||||||
|
follow_redirects=True,
|
||||||
|
verify=False, # CA Минцифры РФ — нет в certifi. Аналог ekburg_permits.py #242.
|
||||||
|
) as client:
|
||||||
|
resp = client.get(KRT_URL)
|
||||||
|
resp.raise_for_status()
|
||||||
|
logger.info(
|
||||||
|
"ekb_krt_registry: fetched %s bytes from %s (status=%s)",
|
||||||
|
len(resp.content),
|
||||||
|
KRT_URL,
|
||||||
|
resp.status_code,
|
||||||
|
)
|
||||||
|
return resp.text
|
||||||
|
|
||||||
|
|
||||||
|
# -- Parse helpers ------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_num(raw: str) -> float | None:
|
||||||
|
"""Нормализует числовую строку: убирает пробелы/NBSP, запятая -> точка."""
|
||||||
|
cleaned = raw.replace("\xa0", "").replace(" ", "").replace(",", ".")
|
||||||
|
cleaned = re.sub(r"[^\d.\-]", "", cleaned)
|
||||||
|
try:
|
||||||
|
return float(cleaned)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_kind(title: str) -> str | None:
|
||||||
|
"""Извлекает «жилой застройки» / «нежилой застройки» из названия блока."""
|
||||||
|
m = re.search(r"((?:не)?жилой\s+застройки)", title, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
return m.group(1).strip().lower()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_district(title: str) -> str | None:
|
||||||
|
"""Извлекает район/квартал из названия блока.
|
||||||
|
|
||||||
|
Примеры:
|
||||||
|
«в районе ул. Краснофлотцев» -> «ул. Краснофлотцев»
|
||||||
|
«в квартале улицы Донбасской — переулков...» -> «улицы Донбасской...»
|
||||||
|
"""
|
||||||
|
m = re.search(r"(?:в\s+районе|в\s+квартале)\s+(.+?)(?::|$)", title)
|
||||||
|
if m:
|
||||||
|
return m.group(1).strip().rstrip(":")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_developer(block_text: str) -> str | None:
|
||||||
|
"""Застройщик из текста вида «(застройщик — ООО «Брусника...»)»."""
|
||||||
|
m = re.search(
|
||||||
|
r"застройщик\s*[–—\-]\s*(.+?)\s*\)",
|
||||||
|
block_text,
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
if m:
|
||||||
|
raw = m.group(1).strip().replace("«", "").replace("»", "").strip()
|
||||||
|
return raw or None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_contract(block_text: str) -> tuple[str | None, date | None]:
|
||||||
|
"""Номер и дата договора КРТ.
|
||||||
|
|
||||||
|
Образец: «Заключен договор о комплексном развитии ... от 10.08.2022 N 1-2/21.7-14»
|
||||||
|
|
||||||
|
Ищем «Заключен договор», чтобы не путать с «в соответствии с договором» (родительный
|
||||||
|
падеж «договора») — они не содержат «Заключен» и дают ложное совпадение.
|
||||||
|
"""
|
||||||
|
m = re.search(
|
||||||
|
r"[Зз]аключен\s+договор[^.]*?\bот\s+(\d{2}\.\d{2}\.\d{4})\s*№\s*([\w/.\-]+)",
|
||||||
|
block_text,
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
if not m:
|
||||||
|
return None, None
|
||||||
|
raw_date, number = m.group(1), m.group(2).strip()
|
||||||
|
try:
|
||||||
|
day, month, year = raw_date.split(".")
|
||||||
|
contract_dt: date | None = date(int(year), int(month), int(day))
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
contract_dt = None
|
||||||
|
return number or None, contract_dt
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_auction_prices(
|
||||||
|
block_text: str,
|
||||||
|
) -> tuple[float | None, float | None]:
|
||||||
|
"""Начальная стоимость и цена по результатам торгов аукциона.
|
||||||
|
|
||||||
|
Образец:
|
||||||
|
«Начальная стоимость: 641 018,64 рублей;
|
||||||
|
Цена по результатам торгов: 671 018 рублей»
|
||||||
|
Также встречается «Начальная цена:».
|
||||||
|
"""
|
||||||
|
start: float | None = None
|
||||||
|
final: float | None = None
|
||||||
|
|
||||||
|
m_start = re.search(
|
||||||
|
r"Начальная\s+(?:стоимость|цена)\s*:\s*([\d\s\xa0]+[,.]?\d*)\s*руб",
|
||||||
|
block_text,
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
if m_start:
|
||||||
|
start = _normalize_num(m_start.group(1))
|
||||||
|
|
||||||
|
m_final = re.search(
|
||||||
|
r"Цена\s+по\s+результатам\s+торгов\s*:\s*([\d\s\xa0]+[,.]?\d*)\s*руб",
|
||||||
|
block_text,
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
if m_final:
|
||||||
|
final = _normalize_num(m_final.group(1))
|
||||||
|
|
||||||
|
return start, final
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_area_ha(block_text: str) -> float | None:
|
||||||
|
"""Площадь территории в га."""
|
||||||
|
m = re.search(
|
||||||
|
r"[Пп]лощадь\s+территории\s*:\s*([\d\s\xa0]+[,.]?\d*)\s*га",
|
||||||
|
block_text,
|
||||||
|
)
|
||||||
|
return _normalize_num(m.group(1)) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_gradpotential(block_text: str) -> float | None:
|
||||||
|
"""Градостроительный потенциал в кв. м."""
|
||||||
|
m = re.search(
|
||||||
|
r"[Гг]радостроительный\s+потенциал\s*:\s*([\d\s\xa0]+[,.]?\d*)\s*кв",
|
||||||
|
block_text,
|
||||||
|
)
|
||||||
|
return _normalize_num(m.group(1)) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_mkd_count(block_text: str) -> int | None:
|
||||||
|
"""Общее количество МКД в блоке.
|
||||||
|
|
||||||
|
Варианты: «Всего многоквартирных домов: N» или «Количество домов: N».
|
||||||
|
"""
|
||||||
|
m = re.search(
|
||||||
|
r"(?:Всего\s+многоквартирных\s+домов|Количество\s+домов)\s*:\s*(\d+)",
|
||||||
|
block_text,
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
if m:
|
||||||
|
try:
|
||||||
|
return int(m.group(1))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_PAGA_PATTERN = re.compile(
|
||||||
|
r"Постановление\s+Администрации\s+города\s+Екатеринбурга\s+"
|
||||||
|
r"от\s+(\d{2}\.\d{2}\.\d{4})\s+№\s+(\d+)\s*(?:«([^»]*)»)?",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_paga_decisions(block_text: str) -> list[dict[str, Any]]:
|
||||||
|
"""Собирает все постановления ПАГЕ из блока в список словарей."""
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
for m in _PAGA_PATTERN.finditer(block_text):
|
||||||
|
entry: dict[str, Any] = {
|
||||||
|
"date": m.group(1),
|
||||||
|
"number": m.group(2),
|
||||||
|
}
|
||||||
|
if m.group(3):
|
||||||
|
entry["title"] = m.group(3).strip()
|
||||||
|
results.append(entry)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# -- Block extractor ----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
# Граница блока — <p> с (необязательными) пробелами/ перед <strong>,
|
||||||
|
# в котором первый текст — «N. КРТ» (не img-title). Паттерн НЕ совпадает
|
||||||
|
# с <p><strong><img ...></strong></p> (img-блоки с фото площадки).
|
||||||
|
_BLOCK_HEADING_RE = re.compile(
|
||||||
|
r"<p[^>]*>\s*(?: )?\s*<strong[^>]*>(?!<img)\s*(?: )?\s*"
|
||||||
|
r"(\d+\.(?: )?\s*КРТ(?:(?!<strong).)*?)</strong>",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_krt_section(soup: BeautifulSoup) -> str:
|
||||||
|
"""Возвращает HTML содержимого раздела КРТ.
|
||||||
|
|
||||||
|
Ищет первый <h2> с текстом «202X год» и возвращает HTML его родительского
|
||||||
|
контейнера (<section> или <div>) — всё содержимое раздела с площадками.
|
||||||
|
"""
|
||||||
|
for tag in soup.find_all("h2"):
|
||||||
|
text = tag.get_text()
|
||||||
|
if re.search(r"202\d\s*год", text):
|
||||||
|
parent = tag.find_parent("section") or tag.find_parent("div")
|
||||||
|
if parent:
|
||||||
|
return str(parent)
|
||||||
|
full_html = str(soup)
|
||||||
|
idx = full_html.find(str(tag))
|
||||||
|
if idx >= 0:
|
||||||
|
end = full_html.find("</section>", idx)
|
||||||
|
return full_html[idx : end if end > 0 else idx + 100_000]
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def parse_krt_page(html: str) -> list[KRTSite]:
|
||||||
|
"""Парсит HTML страницы реестра КРТ-площадок ЕКБ.
|
||||||
|
|
||||||
|
Чистая функция (без сети). Возвращает список KRTSite.
|
||||||
|
Пустой/мусорный HTML -> [].
|
||||||
|
"""
|
||||||
|
if not html or not html.strip():
|
||||||
|
return []
|
||||||
|
|
||||||
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
|
section_html = _extract_krt_section(soup)
|
||||||
|
if not section_html:
|
||||||
|
logger.warning("ekb_krt_registry: КРТ-раздел не найден в HTML")
|
||||||
|
return []
|
||||||
|
|
||||||
|
heading_matches = list(_BLOCK_HEADING_RE.finditer(section_html))
|
||||||
|
if not heading_matches:
|
||||||
|
logger.warning("ekb_krt_registry: не найдено ни одного блока площадки")
|
||||||
|
return []
|
||||||
|
|
||||||
|
sites: list[KRTSite] = []
|
||||||
|
block_starts = [m.start() for m in heading_matches]
|
||||||
|
block_ends = [*block_starts[1:], len(section_html)]
|
||||||
|
|
||||||
|
for match, block_end in zip(heading_matches, block_ends, strict=True):
|
||||||
|
block_html = section_html[match.start() : block_end]
|
||||||
|
raw_title_html = match.group(1)
|
||||||
|
title = unescape(re.sub(r"<[^>]+>", " ", raw_title_html))
|
||||||
|
title = title.replace("\xa0", " ").strip().rstrip(":")
|
||||||
|
|
||||||
|
block_text = unescape(re.sub(r"<[^>]+>", " ", block_html))
|
||||||
|
block_text = re.sub(r"\s+", " ", block_text).strip()
|
||||||
|
|
||||||
|
developer = _extract_developer(block_text)
|
||||||
|
contract_number, contract_date = _extract_contract(block_text)
|
||||||
|
start_price, final_price = _extract_auction_prices(block_text)
|
||||||
|
area_ha = _extract_area_ha(block_text)
|
||||||
|
gradpotential = _extract_gradpotential(block_text)
|
||||||
|
mkd_count = _extract_mkd_count(block_text)
|
||||||
|
paga = _extract_paga_decisions(block_text)
|
||||||
|
krt_kind = _extract_kind(title)
|
||||||
|
district = _extract_district(title)
|
||||||
|
|
||||||
|
site = KRTSite(
|
||||||
|
site_name=title.strip(),
|
||||||
|
district=district,
|
||||||
|
krt_kind=krt_kind,
|
||||||
|
developer_name=developer,
|
||||||
|
contract_number=contract_number,
|
||||||
|
contract_date=contract_date,
|
||||||
|
auction_start_price=start_price,
|
||||||
|
auction_final_price=final_price,
|
||||||
|
gradpotential_sqm=gradpotential,
|
||||||
|
area_ha=area_ha,
|
||||||
|
mkd_count=mkd_count,
|
||||||
|
paga_decisions=paga,
|
||||||
|
raw_block=block_text[:4000],
|
||||||
|
)
|
||||||
|
sites.append(site)
|
||||||
|
logger.debug(
|
||||||
|
"ekb_krt_registry: parsed '%s' developer=%s contract=%s",
|
||||||
|
title[:60],
|
||||||
|
developer,
|
||||||
|
contract_number,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("ekb_krt_registry: parsed %d sites", len(sites))
|
||||||
|
return sites
|
||||||
|
|
@ -129,6 +129,14 @@ def _build_beat_schedule_from_db() -> dict:
|
||||||
}
|
}
|
||||||
logger.info("build_beat_schedule: %s cron=%r queue=%s", jt, cron_str, queue)
|
logger.info("build_beat_schedule: %s cron=%r queue=%s", jt, cron_str, queue)
|
||||||
|
|
||||||
|
# КРТ-площадки ЕКБ (#1060): реестр обновляется редко (квартальные решения ПАГЕ),
|
||||||
|
# раз в неделю достаточно. Среда 05:00 МСК — не пересекается с permits (вт/ср 02:00).
|
||||||
|
schedule["ekb-krt-sync-weekly"] = {
|
||||||
|
"task": "tasks.ekb_krt_sync.sync_krt_sites",
|
||||||
|
"schedule": _parse_cron("0 5 * * wed"),
|
||||||
|
"options": {"queue": "celery"},
|
||||||
|
}
|
||||||
|
|
||||||
return schedule
|
return schedule
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -176,6 +184,14 @@ def _build_beat_schedule_fallback() -> dict:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("beat_schedule fallback: refresh_analytics failed: %s", e)
|
logger.warning("beat_schedule fallback: refresh_analytics failed: %s", e)
|
||||||
|
|
||||||
|
# КРТ-площадки ЕКБ (#1060): реестр обновляется редко (квартальные решения ПАГЕ),
|
||||||
|
# раз в неделю достаточно. Среда 05:00 МСК — не пересекается с permits (вт/ср 02:00).
|
||||||
|
schedule["ekb-krt-sync-weekly"] = {
|
||||||
|
"task": "tasks.ekb_krt_sync.sync_krt_sites",
|
||||||
|
"schedule": _parse_cron("0 5 * * wed"),
|
||||||
|
"options": {"queue": "celery"},
|
||||||
|
}
|
||||||
|
|
||||||
return schedule
|
return schedule
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -369,4 +385,12 @@ def build_beat_schedule() -> dict:
|
||||||
"options": {"queue": "celery"},
|
"options": {"queue": "celery"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# КРТ-площадки ЕКБ (#1060): реестр обновляется редко (квартальные решения ПАГЕ),
|
||||||
|
# раз в неделю достаточно. Среда 05:00 МСК — не пересекается с permits (вт/ср 02:00).
|
||||||
|
schedule["ekb-krt-sync-weekly"] = {
|
||||||
|
"task": "tasks.ekb_krt_sync.sync_krt_sites",
|
||||||
|
"schedule": _parse_cron("0 5 * * wed"),
|
||||||
|
"options": {"queue": "celery"},
|
||||||
|
}
|
||||||
|
|
||||||
return schedule
|
return schedule
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ celery_app = Celery(
|
||||||
"app.workers.tasks.supply_layers_refresh",
|
"app.workers.tasks.supply_layers_refresh",
|
||||||
"app.workers.tasks.forecast",
|
"app.workers.tasks.forecast",
|
||||||
"app.workers.tasks.ird_harvest",
|
"app.workers.tasks.ird_harvest",
|
||||||
|
"app.workers.tasks.ekb_krt_sync",
|
||||||
"app.workers.tasks.gknspecial_harvest",
|
"app.workers.tasks.gknspecial_harvest",
|
||||||
"app.workers.tasks.opportunity_harvest",
|
"app.workers.tasks.opportunity_harvest",
|
||||||
"app.workers.tasks.planning_harvest",
|
"app.workers.tasks.planning_harvest",
|
||||||
|
|
|
||||||
122
backend/app/workers/tasks/ekb_krt_sync.py
Normal file
122
backend/app/workers/tasks/ekb_krt_sync.py
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
"""Celery task: еженедельная синхронизация реестра КРТ-площадок ЕКБ (#1060).
|
||||||
|
|
||||||
|
Fetch → parse → UPSERT в ekb_krt_sites (ON CONFLICT по site_name).
|
||||||
|
SAVEPOINT per-row: одна упавшая запись не откатывает всю транзакцию.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.core.db import SessionLocal
|
||||||
|
from app.services.scrapers.ekb_krt_registry import (
|
||||||
|
KRT_URL,
|
||||||
|
KRTSite,
|
||||||
|
fetch_krt_page,
|
||||||
|
parse_krt_page,
|
||||||
|
)
|
||||||
|
from app.workers.celery_app import celery_app
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_UPSERT_SQL = text("""
|
||||||
|
INSERT INTO ekb_krt_sites (
|
||||||
|
site_name, district, krt_kind,
|
||||||
|
developer_name, contract_number, contract_date,
|
||||||
|
auction_start_price, auction_final_price,
|
||||||
|
gradpotential_sqm, area_ha, mkd_count,
|
||||||
|
paga_decisions, raw_block, source_url, fetched_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
:site_name, :district, :krt_kind,
|
||||||
|
:developer_name, :contract_number, CAST(:contract_date AS date),
|
||||||
|
CAST(:auction_start_price AS numeric), CAST(:auction_final_price AS numeric),
|
||||||
|
CAST(:gradpotential_sqm AS numeric), CAST(:area_ha AS numeric),
|
||||||
|
CAST(:mkd_count AS integer),
|
||||||
|
CAST(:paga_decisions AS jsonb), :raw_block, :source_url, now()
|
||||||
|
)
|
||||||
|
ON CONFLICT (site_name) DO UPDATE SET
|
||||||
|
district = EXCLUDED.district,
|
||||||
|
krt_kind = EXCLUDED.krt_kind,
|
||||||
|
developer_name = EXCLUDED.developer_name,
|
||||||
|
contract_number = EXCLUDED.contract_number,
|
||||||
|
contract_date = EXCLUDED.contract_date,
|
||||||
|
auction_start_price = EXCLUDED.auction_start_price,
|
||||||
|
auction_final_price = EXCLUDED.auction_final_price,
|
||||||
|
gradpotential_sqm = EXCLUDED.gradpotential_sqm,
|
||||||
|
area_ha = EXCLUDED.area_ha,
|
||||||
|
mkd_count = EXCLUDED.mkd_count,
|
||||||
|
paga_decisions = EXCLUDED.paga_decisions,
|
||||||
|
raw_block = EXCLUDED.raw_block,
|
||||||
|
source_url = EXCLUDED.source_url,
|
||||||
|
fetched_at = now()
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def _site_to_params(site: KRTSite) -> dict:
|
||||||
|
return {
|
||||||
|
"site_name": site.site_name,
|
||||||
|
"district": site.district,
|
||||||
|
"krt_kind": site.krt_kind,
|
||||||
|
"developer_name": site.developer_name,
|
||||||
|
"contract_number": site.contract_number,
|
||||||
|
"contract_date": site.contract_date.isoformat() if site.contract_date else None,
|
||||||
|
"auction_start_price": site.auction_start_price,
|
||||||
|
"auction_final_price": site.auction_final_price,
|
||||||
|
"gradpotential_sqm": site.gradpotential_sqm,
|
||||||
|
"area_ha": site.area_ha,
|
||||||
|
"mkd_count": site.mkd_count,
|
||||||
|
"paga_decisions": json.dumps(site.paga_decisions, ensure_ascii=False),
|
||||||
|
"raw_block": site.raw_block,
|
||||||
|
"source_url": site.source_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(name="tasks.ekb_krt_sync.sync_krt_sites")
|
||||||
|
def sync_krt_sites() -> dict[str, int]:
|
||||||
|
"""Fetch + parse + UPSERT реестра КРТ-площадок ЕКБ.
|
||||||
|
|
||||||
|
Returns: {"sites": N} — число обработанных площадок.
|
||||||
|
"""
|
||||||
|
logger.info("ekb_krt_sync: start (url=%s)", KRT_URL)
|
||||||
|
|
||||||
|
html = fetch_krt_page()
|
||||||
|
sites = parse_krt_page(html)
|
||||||
|
|
||||||
|
if not sites:
|
||||||
|
logger.warning("ekb_krt_sync: parse returned 0 sites — страница изменилась?")
|
||||||
|
return {"sites": 0}
|
||||||
|
|
||||||
|
upserted = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
for site in sites:
|
||||||
|
try:
|
||||||
|
with db.begin_nested():
|
||||||
|
db.execute(_UPSERT_SQL, _site_to_params(site))
|
||||||
|
upserted += 1
|
||||||
|
except Exception as exc:
|
||||||
|
failed += 1
|
||||||
|
logger.warning(
|
||||||
|
"ekb_krt_sync: UPSERT failed for '%s': %s",
|
||||||
|
site.site_name[:60],
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"ekb_krt_sync: done — upserted=%d failed=%d",
|
||||||
|
upserted,
|
||||||
|
failed,
|
||||||
|
)
|
||||||
|
return {"sites": upserted}
|
||||||
|
|
@ -35,6 +35,7 @@ dependencies = [
|
||||||
"ijson>=3.2.0",
|
"ijson>=3.2.0",
|
||||||
"pyyaml>=6.0.0", # RBAC roles.yaml loader (app/core/auth.py)
|
"pyyaml>=6.0.0", # RBAC roles.yaml loader (app/core/auth.py)
|
||||||
"pdfplumber>=0.10.0", # PDF-парсер постановлений об изъятии/резервировании (#1091)
|
"pdfplumber>=0.10.0", # PDF-парсер постановлений об изъятии/резервировании (#1091)
|
||||||
|
"beautifulsoup4>=4.12.0", # HTML-парсер реестра КРТ-площадок ЕКБ (#1060)
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|
|
||||||
228
backend/tests/services/scrapers/test_ekb_krt_registry.py
Normal file
228
backend/tests/services/scrapers/test_ekb_krt_registry.py
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
"""Тесты парсера реестра КРТ-площадок ЕКБ (#1060).
|
||||||
|
|
||||||
|
Все тесты — без живой сети.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import textwrap
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services.scrapers.ekb_krt_registry import parse_krt_page
|
||||||
|
|
||||||
|
# ── Фикстура: реальный фрагмент блока площадки 2 (Донбасская — Брусника) ────
|
||||||
|
# Вставлен из krt_page_raw.html (beksburg.rf/дляработы/гиз/крт).
|
||||||
|
# Охватывает обе цены аукциона, договор, застройщика, 2 постановления ПАГЕ.
|
||||||
|
_BRUSNIKA_BLOCK = textwrap.dedent(
|
||||||
|
"""\
|
||||||
|
<h2><strong>2022 год</strong></h2>
|
||||||
|
<p> <strong>2. КРТ жилой застройки в квартале улицы Донбасской – \
|
||||||
|
переулков Черниговского – Суворовского:</strong></p>
|
||||||
|
<p><strong><img class="js-has-preview img-float-left" \
|
||||||
|
title="2.	КРТ жилой застройки" src="/media/gallery/fe.jpg" width="100%" /></strong></p>
|
||||||
|
<p>Площадь территории: 2,79 га;<br />Градостроительный потенциал: 76 650 кв. м;<br />\
|
||||||
|
Всего многоквартирных домов: 10;<br />\
|
||||||
|
3. Издано Постановление Администрации города Екатеринбурга от 10.03.2022 № 621 \
|
||||||
|
«О комплексном развитии территории жилой застройки в квартале улицы Донбасской»;\
|
||||||
|
<br />\
|
||||||
|
4. Постановление Администрации города Екатеринбурга от 19.05.2022 № 1334 \
|
||||||
|
«О проведении аукциона»;<br />\
|
||||||
|
5. Аукцион:<br />Начальная стоимость: 641 018,64 рублей;<br />\
|
||||||
|
Цена по результатам торгов: 671 018 рублей.<br />\
|
||||||
|
6. Заключен договор о комплексном развитии территории жилой застройки от 10.08.2022 \
|
||||||
|
№ 1-2/21.7-14 (застройщик – ООО «Брусника. Специализированный застройщик»).\
|
||||||
|
<br />\
|
||||||
|
7. Постановление Администрации города Екатеринбурга от 28.12.2022 № 4051 \
|
||||||
|
«Об утверждении проекта планировки территории».</p>
|
||||||
|
<p><strong>Стадия реализации: строительство</strong></p>
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseBrusnikaBlock:
|
||||||
|
"""Тесты парсинга блока площадки Донбасская (Брусника)."""
|
||||||
|
|
||||||
|
@pytest.fixture(scope="class")
|
||||||
|
def site(self):
|
||||||
|
sites = parse_krt_page(_BRUSNIKA_BLOCK)
|
||||||
|
assert sites, "parse_krt_page вернул пустой список"
|
||||||
|
return sites[0]
|
||||||
|
|
||||||
|
def test_krt_kind(self, site):
|
||||||
|
assert site.krt_kind == "жилой застройки"
|
||||||
|
|
||||||
|
def test_developer(self, site):
|
||||||
|
assert site.developer_name is not None
|
||||||
|
assert "Брусника" in site.developer_name
|
||||||
|
|
||||||
|
def test_auction_start_price(self, site):
|
||||||
|
"""641 018,64 → 641018.64"""
|
||||||
|
assert site.auction_start_price == pytest.approx(641018.64, rel=1e-4)
|
||||||
|
|
||||||
|
def test_auction_final_price(self, site):
|
||||||
|
"""671 018 → 671018.0"""
|
||||||
|
assert site.auction_final_price == pytest.approx(671018.0, rel=1e-4)
|
||||||
|
|
||||||
|
def test_contract_number(self, site):
|
||||||
|
assert site.contract_number == "1-2/21.7-14"
|
||||||
|
|
||||||
|
def test_contract_date(self, site):
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
assert site.contract_date == date(2022, 8, 10)
|
||||||
|
|
||||||
|
def test_paga_decisions_at_least_one(self, site):
|
||||||
|
assert len(site.paga_decisions) >= 1
|
||||||
|
# первое постановление — 10.03.2022 № 621
|
||||||
|
dates = [d["date"] for d in site.paga_decisions]
|
||||||
|
assert "10.03.2022" in dates
|
||||||
|
|
||||||
|
def test_area_ha(self, site):
|
||||||
|
assert site.area_ha == pytest.approx(2.79, rel=1e-3)
|
||||||
|
|
||||||
|
def test_gradpotential(self, site):
|
||||||
|
# «76 650 кв. м» → 76650.0
|
||||||
|
assert site.gradpotential_sqm == pytest.approx(76650.0, rel=1e-3)
|
||||||
|
|
||||||
|
def test_mkd_count(self, site):
|
||||||
|
assert site.mkd_count == 10
|
||||||
|
|
||||||
|
def test_site_name_contains_number(self, site):
|
||||||
|
assert "2." in site.site_name
|
||||||
|
assert "Донбасской" in site.site_name
|
||||||
|
|
||||||
|
def test_raw_block_nonempty(self, site):
|
||||||
|
assert len(site.raw_block) > 50
|
||||||
|
|
||||||
|
|
||||||
|
class TestNumericNormalization:
|
||||||
|
"""Тесты числовой нормализации (NBSP, запятая как десятичный разделитель)."""
|
||||||
|
|
||||||
|
def test_nbsp_price(self):
|
||||||
|
"""«641\xa0018,64» → 641018.64"""
|
||||||
|
from app.services.scrapers.ekb_krt_registry import _normalize_num
|
||||||
|
|
||||||
|
assert _normalize_num("641\xa0018,64") == pytest.approx(641018.64, rel=1e-4)
|
||||||
|
|
||||||
|
def test_space_price(self):
|
||||||
|
"""«671 018» → 671018.0"""
|
||||||
|
from app.services.scrapers.ekb_krt_registry import _normalize_num
|
||||||
|
|
||||||
|
assert _normalize_num("671 018") == pytest.approx(671018.0)
|
||||||
|
|
||||||
|
def test_plain_decimal(self):
|
||||||
|
from app.services.scrapers.ekb_krt_registry import _normalize_num
|
||||||
|
|
||||||
|
assert _normalize_num("3,65") == pytest.approx(3.65)
|
||||||
|
|
||||||
|
def test_invalid_returns_none(self):
|
||||||
|
from app.services.scrapers.ekb_krt_registry import _normalize_num
|
||||||
|
|
||||||
|
assert _normalize_num("нет данных") is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestEmptyAndGarbage:
|
||||||
|
"""Пустой / мусорный HTML → []."""
|
||||||
|
|
||||||
|
def test_empty_string(self):
|
||||||
|
assert parse_krt_page("") == []
|
||||||
|
|
||||||
|
def test_whitespace_only(self):
|
||||||
|
assert parse_krt_page(" \n\t ") == []
|
||||||
|
|
||||||
|
def test_irrelevant_html(self):
|
||||||
|
assert parse_krt_page("<html><body><p>Привет</p></body></html>") == []
|
||||||
|
|
||||||
|
def test_no_krt_section(self):
|
||||||
|
html = "<html><body><h2>Другой раздел</h2><p>Текст</p></body></html>"
|
||||||
|
assert parse_krt_page(html) == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── Синтетический блок: площадка без застройщика / договора / цен ─────────────
|
||||||
|
# Имитирует площадки «в работе» (только geography + технические параметры).
|
||||||
|
# Не требует локального дампа — всегда исполняется в CI.
|
||||||
|
_BABUSHKINA_BLOCK = textwrap.dedent(
|
||||||
|
"""\
|
||||||
|
<h2><strong>2024 год</strong></h2>
|
||||||
|
<p> <strong>1. КРТ жилой застройки в районе ул. Бабушкина:</strong></p>
|
||||||
|
<p>Площадь территории: 5,10 га;<br />\
|
||||||
|
Градостроительный потенциал: 120 000 кв. м;<br />\
|
||||||
|
Всего многоквартирных домов: 18;<br />\
|
||||||
|
3. Издано Постановление Администрации города Екатеринбурга от 15.01.2024 № 42 \
|
||||||
|
«О комплексном развитии территории».</p>
|
||||||
|
<p><strong>Стадия реализации: проектирование</strong></p>
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseBabushkinaBlock:
|
||||||
|
"""Синтетический тест: площадка без застройщика, договора и цен аукциона.
|
||||||
|
|
||||||
|
Не зависит от локального дампа — всегда запускается в CI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.fixture(scope="class")
|
||||||
|
def site(self):
|
||||||
|
sites = parse_krt_page(_BABUSHKINA_BLOCK)
|
||||||
|
assert sites, "parse_krt_page вернул пустой список для синтетического блока"
|
||||||
|
return sites[0]
|
||||||
|
|
||||||
|
def test_parses_exactly_one_site(self):
|
||||||
|
"""Синтетический блок содержит ровно одну площадку."""
|
||||||
|
assert len(parse_krt_page(_BABUSHKINA_BLOCK)) == 1
|
||||||
|
|
||||||
|
def test_developer_is_none(self, site):
|
||||||
|
"""Застройщик не указан → None."""
|
||||||
|
assert site.developer_name is None
|
||||||
|
|
||||||
|
def test_contract_number_is_none(self, site):
|
||||||
|
"""Договор не заключён → None."""
|
||||||
|
assert site.contract_number is None
|
||||||
|
|
||||||
|
def test_area_ha_filled(self, site):
|
||||||
|
"""Площадь территории заполнена."""
|
||||||
|
assert site.area_ha == pytest.approx(5.10, rel=1e-3)
|
||||||
|
|
||||||
|
def test_gradpotential_filled(self, site):
|
||||||
|
"""Градостроительный потенциал заполнен."""
|
||||||
|
assert site.gradpotential_sqm == pytest.approx(120000.0, rel=1e-3)
|
||||||
|
|
||||||
|
def test_district_filled(self, site):
|
||||||
|
"""Район извлечён из названия блока."""
|
||||||
|
assert site.district is not None
|
||||||
|
assert "Бабушкина" in site.district
|
||||||
|
|
||||||
|
def test_krt_kind(self, site):
|
||||||
|
assert site.krt_kind == "жилой застройки"
|
||||||
|
|
||||||
|
|
||||||
|
class TestMultiSiteCountFromDump:
|
||||||
|
"""Проверка количества площадок из реального дампа страницы."""
|
||||||
|
|
||||||
|
@pytest.fixture(scope="class")
|
||||||
|
def dump_html(self):
|
||||||
|
"""Загружает локальный дамп страницы.
|
||||||
|
|
||||||
|
Тест пропускается если файл отсутствует (CI без дампа).
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
dump_path = os.path.join(
|
||||||
|
os.path.dirname(__file__),
|
||||||
|
"../../../../.playwright-mcp/ekb-docs/krt_page_raw.html",
|
||||||
|
)
|
||||||
|
if not os.path.exists(dump_path):
|
||||||
|
pytest.skip("krt_page_raw.html dump not found — skip real-dump test")
|
||||||
|
with open(dump_path, encoding="utf-8") as fh:
|
||||||
|
return fh.read()
|
||||||
|
|
||||||
|
def test_site_count_from_dump(self, dump_html):
|
||||||
|
"""Из дампа должно распарситься ≥ 14 площадок (реальных на странице 15)."""
|
||||||
|
sites = parse_krt_page(dump_html)
|
||||||
|
assert len(sites) >= 14, f"Expected ≥ 14 sites, got {len(sites)}"
|
||||||
|
|
||||||
|
def test_no_duplicate_site_names(self, dump_html):
|
||||||
|
sites = parse_krt_page(dump_html)
|
||||||
|
names = [s.site_name for s in sites]
|
||||||
|
assert len(names) == len(set(names)), "Дубликаты site_name в результате"
|
||||||
24
backend/uv.lock
generated
24
backend/uv.lock
generated
|
|
@ -73,6 +73,19 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
|
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "beautifulsoup4"
|
||||||
|
version = "4.14.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "soupsieve" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "billiard"
|
name = "billiard"
|
||||||
version = "4.2.4"
|
version = "4.2.4"
|
||||||
|
|
@ -561,6 +574,7 @@ version = "0.1.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "alembic" },
|
{ name = "alembic" },
|
||||||
|
{ name = "beautifulsoup4" },
|
||||||
{ name = "celery" },
|
{ name = "celery" },
|
||||||
{ name = "ezdxf" },
|
{ name = "ezdxf" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
|
|
@ -606,6 +620,7 @@ dev = [
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "alembic", specifier = ">=1.13.0" },
|
{ name = "alembic", specifier = ">=1.13.0" },
|
||||||
|
{ name = "beautifulsoup4", specifier = ">=4.12.0" },
|
||||||
{ name = "celery", specifier = ">=5.4.0" },
|
{ name = "celery", specifier = ">=5.4.0" },
|
||||||
{ name = "ezdxf", specifier = ">=1.3.0" },
|
{ name = "ezdxf", specifier = ">=1.3.0" },
|
||||||
{ name = "fastapi", specifier = ">=0.115.0" },
|
{ name = "fastapi", specifier = ">=0.115.0" },
|
||||||
|
|
@ -2234,6 +2249,15 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "soupsieve"
|
||||||
|
version = "2.8.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sqlalchemy"
|
name = "sqlalchemy"
|
||||||
version = "2.0.49"
|
version = "2.0.49"
|
||||||
|
|
|
||||||
96
data/sql/137_ekb_krt_sites.sql
Normal file
96
data/sql/137_ekb_krt_sites.sql
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
-- 137_ekb_krt_sites.sql
|
||||||
|
-- #1060 (Site Finder v2 / GG-форсайт) — реестр КРТ-площадок ЕКБ.
|
||||||
|
--
|
||||||
|
-- Источник: https://xn--80acgfbsl1azdqr.xn--p1ai/дляработы/гиз/крт
|
||||||
|
-- (екатеринбург.рф, SSR-страница, 15+ площадок).
|
||||||
|
--
|
||||||
|
-- Реквизиты каждой площадки: застройщик, вид застройки (жилая/нежилая),
|
||||||
|
-- параметры договора КРТ (номер/дата/цена аукциона), градпотенциал, площадь,
|
||||||
|
-- список постановлений ПАГЕ. Геометрия отсутствует в источнике — join к
|
||||||
|
-- opportunity_krt запланирован отдельно.
|
||||||
|
--
|
||||||
|
-- Ключ: contract_number — присутствует у площадок с заключённым договором КРТ
|
||||||
|
-- (большинство). Площадки без договора (ранние стадии «подготовки к аукциону»)
|
||||||
|
-- используют site_name как резервный ключ (UNIQUE(site_name) + partial UNIQUE
|
||||||
|
-- по contract_number WHERE contract_number IS NOT NULL реализован через два
|
||||||
|
-- раздельных индекса + DO-блоки ниже).
|
||||||
|
--
|
||||||
|
-- Deploy: auto-applied deploy.yml через _schema_migrations (ровно один раз по NN).
|
||||||
|
-- Idempotent (CREATE TABLE/INDEX IF NOT EXISTS + guarded DO-блоки по pg_constraint).
|
||||||
|
-- Без INSERT — данные приходят из ekb_krt_sync Celery-воркера (#1060).
|
||||||
|
--
|
||||||
|
-- Параллельный PR берёт номер 138 — этот файл 137 зарезервирован для этого PR.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- ── 1. Таблица ekb_krt_sites ─────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS ekb_krt_sites (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
site_name TEXT NOT NULL, -- «N. КРТ жилой/нежилой застройки в районе ул. X»
|
||||||
|
district TEXT, -- район/квартал (извлечён из названия best-effort)
|
||||||
|
krt_kind TEXT, -- «жилой застройки» | «нежилой застройки»
|
||||||
|
developer_name TEXT, -- застройщик из текста договора
|
||||||
|
contract_number TEXT, -- «1-2/21.7-14» (NULL у незавершённых площадок)
|
||||||
|
contract_date DATE, -- дата договора КРТ
|
||||||
|
auction_start_price NUMERIC, -- начальная стоимость аукциона, руб.
|
||||||
|
auction_final_price NUMERIC, -- цена по результатам торгов, руб.
|
||||||
|
gradpotential_sqm NUMERIC, -- градостроительный потенциал, кв. м
|
||||||
|
area_ha NUMERIC, -- площадь территории, га
|
||||||
|
mkd_count INTEGER, -- всего многоквартирных домов (если есть)
|
||||||
|
paga_decisions JSONB, -- [{date, number, title}] постановления ПАГЕ
|
||||||
|
raw_block TEXT, -- исходный текст блока (для отладки/re-parse)
|
||||||
|
source_url TEXT, -- URL страницы-источника
|
||||||
|
fetched_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE ekb_krt_sites IS
|
||||||
|
'Реестр КРТ-площадок Екатеринбурга (#1060, GG-форсайт). '
|
||||||
|
'Источник: екатеринбург.рф/дляработы/гиз/крт. '
|
||||||
|
'Обновляется еженедельно воркером ekb_krt_sync. '
|
||||||
|
'Геометрия отсутствует — join к opportunity_krt планируется отдельно.';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN ekb_krt_sites.contract_number IS
|
||||||
|
'Номер договора КРТ («1-2/21.7-14»). NULL у площадок на стадии подготовки к аукциону.';
|
||||||
|
COMMENT ON COLUMN ekb_krt_sites.paga_decisions IS
|
||||||
|
'JSON-массив постановлений ПАГЕ: [{date: «DD.MM.YYYY», number: «NNN», title: «...»}]. '
|
||||||
|
'Все постановления из блока, включая об утверждении ППТ/ПМТ.';
|
||||||
|
COMMENT ON COLUMN ekb_krt_sites.krt_kind IS
|
||||||
|
'«жилой застройки» или «нежилой застройки» — вид КРТ из названия площадки.';
|
||||||
|
|
||||||
|
-- ── 2. UNIQUE по site_name — надёжный ключ для уникальности (присутствует всегда) ──
|
||||||
|
-- Служит ON CONFLICT target для UPSERT воркера. contract_number NULL у ранних стадий,
|
||||||
|
-- поэтому site_name выбран основным дедуп-ключом. Дополнительно: уникальный индекс
|
||||||
|
-- по contract_number WHERE NOT NULL — для быстрого lookup по номеру договора.
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'uq_ekb_krt_sites_site_name'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE ekb_krt_sites
|
||||||
|
ADD CONSTRAINT uq_ekb_krt_sites_site_name UNIQUE (site_name);
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ── 3. Partial unique index по contract_number (где не NULL) ─────────────────
|
||||||
|
-- Не через DO-блок + ALTER (partial UNIQUE через ALTER ADD CONSTRAINT не поддерживает
|
||||||
|
-- WHERE clause в PostgreSQL). Вместо этого — CREATE UNIQUE INDEX IF NOT EXISTS с WHERE.
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uix_ekb_krt_sites_contract_number
|
||||||
|
ON ekb_krt_sites (contract_number)
|
||||||
|
WHERE contract_number IS NOT NULL;
|
||||||
|
|
||||||
|
-- ── 4. Индексы ───────────────────────────────────────────────────────────────
|
||||||
|
-- Поиск по виду застройки (жилая/нежилая).
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ekb_krt_sites_krt_kind
|
||||||
|
ON ekb_krt_sites (krt_kind);
|
||||||
|
|
||||||
|
-- Поиск по застройщику (JOIN с domrf_kn / анализ по девелоперу).
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ekb_krt_sites_developer
|
||||||
|
ON ekb_krt_sites (developer_name)
|
||||||
|
WHERE developer_name IS NOT NULL;
|
||||||
|
|
||||||
|
-- Свежесть данных / ORDER BY fetched_at для мониторинга.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ekb_krt_sites_fetched_at
|
||||||
|
ON ekb_krt_sites (fetched_at DESC);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
Loading…
Add table
Reference in a new issue