) — всё содержимое раздела с площадками.
+ """
+ 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("", 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
diff --git a/backend/app/workers/beat_schedule.py b/backend/app/workers/beat_schedule.py
index 4c6031b2..dc888c2e 100644
--- a/backend/app/workers/beat_schedule.py
+++ b/backend/app/workers/beat_schedule.py
@@ -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)
+ # КРТ-площадки ЕКБ (#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
@@ -176,6 +184,14 @@ def _build_beat_schedule_fallback() -> dict:
except Exception as 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
@@ -369,4 +385,12 @@ def build_beat_schedule() -> dict:
"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
diff --git a/backend/app/workers/celery_app.py b/backend/app/workers/celery_app.py
index 3a97600e..44f49fe3 100644
--- a/backend/app/workers/celery_app.py
+++ b/backend/app/workers/celery_app.py
@@ -64,6 +64,7 @@ celery_app = Celery(
"app.workers.tasks.supply_layers_refresh",
"app.workers.tasks.forecast",
"app.workers.tasks.ird_harvest",
+ "app.workers.tasks.ekb_krt_sync",
"app.workers.tasks.gknspecial_harvest",
"app.workers.tasks.opportunity_harvest",
"app.workers.tasks.planning_harvest",
diff --git a/backend/app/workers/tasks/ekb_krt_sync.py b/backend/app/workers/tasks/ekb_krt_sync.py
new file mode 100644
index 00000000..5d0271a7
--- /dev/null
+++ b/backend/app/workers/tasks/ekb_krt_sync.py
@@ -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}
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index c3ead1c9..a0042e87 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -35,6 +35,7 @@ dependencies = [
"ijson>=3.2.0",
"pyyaml>=6.0.0", # RBAC roles.yaml loader (app/core/auth.py)
"pdfplumber>=0.10.0", # PDF-парсер постановлений об изъятии/резервировании (#1091)
+ "beautifulsoup4>=4.12.0", # HTML-парсер реестра КРТ-площадок ЕКБ (#1060)
]
[dependency-groups]
diff --git a/backend/tests/services/scrapers/test_ekb_krt_registry.py b/backend/tests/services/scrapers/test_ekb_krt_registry.py
new file mode 100644
index 00000000..2463fc8b
--- /dev/null
+++ b/backend/tests/services/scrapers/test_ekb_krt_registry.py
@@ -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(
+ """\
+
2022 год
+
2. КРТ жилой застройки в квартале улицы Донбасской – \
+переулков Черниговского – Суворовского:
+

+
Площадь территории: 2,79 га;
Градостроительный потенциал: 76 650 кв. м;
\
+Всего многоквартирных домов: 10;
\
+3. Издано Постановление Администрации города Екатеринбурга от 10.03.2022 № 621 \
+«О комплексном развитии территории жилой застройки в квартале улицы Донбасской»;\
+
\
+4. Постановление Администрации города Екатеринбурга от 19.05.2022 № 1334 \
+«О проведении аукциона»;
\
+5. Аукцион:
Начальная стоимость: 641 018,64 рублей;
\
+Цена по результатам торгов: 671 018 рублей.
\
+6. Заключен договор о комплексном развитии территории жилой застройки от 10.08.2022 \
+№ 1-2/21.7-14 (застройщик – ООО «Брусника. Специализированный застройщик»).\
+
\
+7. Постановление Администрации города Екатеринбурга от 28.12.2022 № 4051 \
+«Об утверждении проекта планировки территории».
+
Стадия реализации: строительство
+ """
+)
+
+
+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("
Привет
") == []
+
+ def test_no_krt_section(self):
+ html = "
Другой раздел
Текст
"
+ assert parse_krt_page(html) == []
+
+
+# ── Синтетический блок: площадка без застройщика / договора / цен ─────────────
+# Имитирует площадки «в работе» (только geography + технические параметры).
+# Не требует локального дампа — всегда исполняется в CI.
+_BABUSHKINA_BLOCK = textwrap.dedent(
+ """\
+
2024 год
+
1. КРТ жилой застройки в районе ул. Бабушкина:
+
Площадь территории: 5,10 га;
\
+Градостроительный потенциал: 120 000 кв. м;
\
+Всего многоквартирных домов: 18;
\
+3. Издано Постановление Администрации города Екатеринбурга от 15.01.2024 № 42 \
+«О комплексном развитии территории».
+
Стадия реализации: проектирование
+ """
+)
+
+
+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 в результате"
diff --git a/backend/uv.lock b/backend/uv.lock
index a30f6b6c..a4b36a00 100644
--- a/backend/uv.lock
+++ b/backend/uv.lock
@@ -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" },
]
+[[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]]
name = "billiard"
version = "4.2.4"
@@ -561,6 +574,7 @@ version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "alembic" },
+ { name = "beautifulsoup4" },
{ name = "celery" },
{ name = "ezdxf" },
{ name = "fastapi" },
@@ -606,6 +620,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "alembic", specifier = ">=1.13.0" },
+ { name = "beautifulsoup4", specifier = ">=4.12.0" },
{ name = "celery", specifier = ">=5.4.0" },
{ name = "ezdxf", specifier = ">=1.3.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" },
]
+[[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]]
name = "sqlalchemy"
version = "2.0.49"
diff --git a/data/sql/137_ekb_krt_sites.sql b/data/sql/137_ekb_krt_sites.sql
new file mode 100644
index 00000000..c96e0e2b
--- /dev/null
+++ b/data/sql/137_ekb_krt_sites.sql
@@ -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;