"""Загрузчик адресных точек выхода газораспределительной сети (#2119 Phase B2, PR-2). Источник — раскрытие информации АО «Екатеринбурггаз» (ГРО), форма 6 приложения №4 к Приказу ФАС России №960 от 08.12.2022 «О порядке раскрытия информации…»: построчная выгрузка «точек выхода» ГРС (~1037 строк/мес) — объём заявок, удовлетворено, свободная мощность точки выхода (млн. м³/МЕСЯЦ — НЕ тыс. м³/ч как в gas_grs_capacity!). UPSERT-ит в ``gas_grs_outlet_points``. Источник ГЕО-БЛОКИРУЕТ non-RU IP → загрузчик РАБОТАЕТ НА ПРОДЕ (Celery monthly / manual docker exec) — как gas_grs_capacity (B1, ГТЕ) и rosseti-/vodokanal-/eias-лоадеры. Код не предполагает локальный сетевой доступ. ТОЛЬКО apex-домен ``ekgas.ru`` — ``www.ekgas.ru`` даёт ``SSLV3_ALERT_HANDSHAKE_FAILURE``. SSL через общий ``_ru_ssl_context`` из gazprom_grs_loader (Russian Trusted CA Минцифры), httpx с явным таймаутом. Данные лежат за 2 GET-переходами: 1. Каталог раскрытия (``/raskrytie-informacii/raskrytie-informatsii-1``, ~800KB HTML, 585 ссылок на файлы). Целевой — «Форма 6 приложения № 4 к Приказу ФАС России № 960 … (на|за) <месяц> <год>г..xlsx», путь ``/uploads/default//<имя>``. Hex меняется с каждым файлом → URL НЕ ЯКОРИМ: парсим каталог, вытаскиваем период «(за|на) <месяц> <год>» + дату публикации (asof, DD.MM.YYYY рядом со ссылкой). Годовой «Форма №6 (Приложение №2) … план на <год>» (.xls) — СКИП (не адресный, xlrd в стеке нет). Выбор свежайшего: приоритет ФАКТА над прогнозом. Берём максимальный (год, месяц) среди «за» (is_forecast=False); если файлов «за» НЕТ вообще — максимальный «на» (is_forecast=True, прогноз). ВНИМАНИЕ: даже если «на» новее по (год, месяц), чем любой «за», всё равно берём «за» (факт важнее прогноза). См. select_latest_file. 2. XLSX (~91КБ, 1 лист): подписи в row 10 (1-indexed), нумерация 1..7 в row 11, данные с row 12 (~1037 строк, последняя — сноска про «*», single-cell → скип). Колонки (1-indexed): 1=«Точка входа» (ГРС, block-marker — заполнена ТОЛЬКО в первой строке блока → forward-fill; 5 блоков), 2=«Точка выхода» (свободный текст: юрлицо/объект, иногда адрес внутри), 3=тип (котельная/ТЭЦ/…), 4=группа потребления, 5=объём заявок, 6=удовлетворено, 7=свободная мощность — float ИЛИ текст-маркер «0.000*» (НЕ ноль! → free_capacity_mln_m3=NULL, free_capacity_needs_calc=TRUE). grs_name_norm нормализуется ТЕМ ЖЕ ``normalize_grs_name`` из gazprom_grs_loader (B1) — иначе JOIN «точка выхода → свободная мощность ГРС» на gas_grs_capacity порвётся. Числовой парс — общий ``parse_reserve_number`` из rosseti_reserve_loader (запятая, «—» → None, знак сохраняется: дефицит −0.084 хранится знаком). Вся строка целиком → ``raw`` jsonb (будущие/неизвестные колонки не теряются). """ import argparse import io import json import logging import re import sys from datetime import date from urllib.parse import urljoin import httpx from bs4 import BeautifulSoup from openpyxl import load_workbook from sqlalchemy import text from sqlalchemy.orm import Session from app.core.db import SessionLocal from app.services.dadata_client import clean_address from app.services.site_finder.gazprom_grs_loader import _ru_ssl_context, normalize_grs_name from app.services.site_finder.rosseti_reserve_loader import parse_reserve_number logger = logging.getLogger(__name__) # ТОЛЬКО apex-домен — www.ekgas.ru отдаёт SSLV3_ALERT_HANDSHAKE_FAILURE (разведка # 2026-07-03). Каталог раскрытия информации со ссылками на помесячные формы 6. BASE_URL = "https://ekgas.ru" CATALOG_URL = "https://ekgas.ru/raskrytie-informacii/raskrytie-informatsii-1" _HTTP_TIMEOUT = 60 # ── Геокод address_extracted → geom (PR-3, #2119 B2) ────────────────────────── # Cap вызовов DaData за один прогон (адресов на проде ~164 → влезает; страхует # квоту от разбухшего файла). Тот же порядок, что GEO_DADATA_CALL_LIMIT в # objective_backfill. _GEO_DADATA_CAP = 200 # Префикс города: address_extracted — адресный ХВОСТ без города («ул. Стрелочников, # д. 2»). DaData на строке без города отдаёт qc_geo=5 (нет координат) — прод-урок # objective_backfill 2026-07-03. Точки выхода ЕКБ-газа привязаны к Екатеринбургу и # окрестностям → безопасно префиксуем городом, если его в строке нет. _EKB_PREFIX = "г Екатеринбург, " # Sanity-bbox Екатеринбурга и ближних окрестностей (Арамиль/Реж/Берёзовский/Кольцово): # DaData на кривом/усечённом адресе может «дорисовать» другой город — точку вне bbox # НЕ пишем (честность: лучше NULL geom, чем ложная координата в Москве). Границы # грубые с запасом: lat 56.5..57.2, lon 60.0..61.2. _EKB_BBOX_LAT = (56.5, 57.2) _EKB_BBOX_LON = (60.0, 61.2) # Целевой лист XLSX (единственный) — берём активный, если имя не совпало. # 1-indexed строки листа: подписи колонок = row 10, нумерация 1..7 = row 11, данные с 12. # Сетка НЕ хардкодится по номерам строк/колонок: живой файл за май 2026 имеет # header на row 11 (не 10 из разведки) и колонку «№ п/п» слева (данные в col 2-8, # сдвиг +1) — первый прод-прогон распарсил 5 строк вместо 1037. Урок воды/тепла: # опорные строки и колонки ищем ПО ПОДПИСЯМ (_find_layout), скан первых N строк. _LAYOUT_SCAN_ROWS = 25 # Колонки формы 6 (0-indexed для списка ячеек строки). # Подписи колонок header-строки → внутренние ключи (substring-матч, lower). _COLUMN_LABELS: dict[str, str] = { "точка входа": "entry", "точка выхода": "outlet", "наименование потребителя": "consumer_type", "номер группы": "group", "поступивш": "requested", "удовлетво": "approved", "свободная мощность": "free", } def _find_layout(rows: list[list[object]]) -> tuple[int, dict[str, int]] | None: """Ищет header-строку по подписям в первых _LAYOUT_SCAN_ROWS строках. Returns (data_start_idx, {key: col_idx}) — данные начинаются через строку после header (между ними строка-нумерация «1..7»). None — сетка не распознана (graceful: 0 записей + warning, не мусор — урок vodokanal). """ for i, row in enumerate(rows[:_LAYOUT_SCAN_ROWS]): cols: dict[str, int] = {} for j, cell in enumerate(row): text = _clean_text(cell).lower() if not text: continue for label, key in _COLUMN_LABELS.items(): if label in text and key not in cols: cols[key] = j if "entry" in cols and "outlet" in cols and "free" in cols: return (i + 2, cols) # +1 строка-нумерация, +1 → первая строка данных return None # «0.000*» (и вариации с запятой/пробелами) в колонке свободной мощности = НЕ ноль, а # «требуется гидравлический расчёт» → free_capacity_mln_m3=NULL, needs_calc=True. # Маркер = число, за которым идёт «*» (звёздочка). Настоящий 0 без «*» парсится как 0.0. _NEEDS_CALC_RE = re.compile(r"^\s*[\d.,\s\xa0]+\*\s*$") # Русские месяцы → номер (родительный падеж как в «за январЯ»? нет — в именах файлов # именительный: «за январь 2026»). Ключи в нижнем регистре. _MONTHS: dict[str, int] = { "январь": 1, "февраль": 2, "март": 3, "апрель": 4, "май": 5, "июнь": 6, "июль": 7, "август": 8, "сентябрь": 9, "октябрь": 10, "ноябрь": 11, "декабрь": 12, } _MONTHS_ALT = "|".join(_MONTHS) # Период в имени файла формы 6: «… (за|на) <месяц> <год>г..xlsx». «за» = факт # (is_forecast=False), «на» = прогноз (is_forecast=True). Год — 4 цифры. _PERIOD_RE = re.compile(rf"\b(за|на)\s+({_MONTHS_ALT})\s+(\d{{4}})", re.IGNORECASE) # Целевой файл — форма 6 приложения №4 (адресная, помесячная, .xlsx). Годовой «Форма №6 # (Приложение №2) … план на <год>» (.xls) сюда НЕ подходит: другое приложение + .xls. _FORM6_MARKER_RE = re.compile(r"форма\s*6.*приложени\w*\s*№?\s*4", re.IGNORECASE | re.DOTALL) _XLSX_RE = re.compile(r"\.xlsx(?:$|\?)", re.IGNORECASE) # Дата публикации рядом со ссылкой (DD.MM.YYYY) → asof. _ASOF_RE = re.compile(r"\b(\d{1,2})\.(\d{1,2})\.(\d{4})\b") # Адрес-экстрактор: улица-токен + номер дома. Город опционален. Консервативно — # нет уверенного адреса → None (не выдумываем). Паттерны из реальных строк: # «(г.Екатеринбург, Складской проезд,4а)» — в скобках; # «, пер. Базовый, 17» — хвостом; # «г.…, ул. К.Маркса,31». # Улица-токен: ул|пер|пр|просп|проезд|б-р|ш|тракт|наб (с точкой или без, регистронезав.). _STREET_TOKEN = r"(?:ул|пер|пр|просп|проспект|проезд|б-р|бульвар|ш|шоссе|тракт|наб)" # Опциональный город впереди: «г.Екатеринбург,» / «г. Реж,». _CITY_OPT = r"(?:г\.?\s*[А-ЯЁ][а-яё\-]+\s*,\s*)?" # Название улицы: слова с кириллицей/точками/дефисами (напр. «К.Маркса», «Базовый», # «Складской проезд») — до номера дома. Номер: цифры + опц. буква/дробь («4а», «17», # «31/2»). Токен улицы может стоять ПЕРЕД названием («ул. Базовый») или ПОСЛЕ # («Складской проезд»). _ADDRESS_RE = re.compile( rf"{_CITY_OPT}" rf"(?:{_STREET_TOKEN}\.?\s*)?" # ведущий токен («ул. …», «пер. …») — опционален r"[А-ЯЁ][А-Яа-яЁё.\- ]{1,40}?" # название улицы (капитализировано) rf"(?:\s+{_STREET_TOKEN})?" # хвостовой токен («… проезд», «… тракт») — опционален r"\s*,?\s*" r"(?:д\.?\s*)?" # опц. «д.» r"(\d+[а-яё]?(?:[/\-]\d+[а-яё]?)?)", # номер дома («4а», «17», «31/2») re.IGNORECASE, ) # Чтобы отсечь ложные срабатывания на юрлицах («ИП Рошканяну Владимир Антонович») — # требуем ЯВНЫЙ адресный сигнал: либо улица-токен, либо «г.<Город>» перед номером. _HAS_STREET_TOKEN_RE = re.compile(rf"\b{_STREET_TOKEN}\b\.?", re.IGNORECASE) _HAS_CITY_RE = re.compile(r"\bг\.?\s*[А-ЯЁ][а-яё\-]+", re.IGNORECASE) _MULTISPACE_RE = re.compile(r"\s+") def _clean_text(value: object) -> str: """Ячейка/текст → strip-строка со схлопнутыми пробелами (\\xa0→пробел). None → ''.""" if value is None: return "" return _MULTISPACE_RE.sub(" ", str(value).replace("\xa0", " ")).strip() def _parse_period(name: str) -> tuple[int, int, bool] | None: """Имя файла → (year, month, is_forecast). «за»→факт(False), «на»→прогноз(True). Нет распознанного «(за|на) <месяц> <год>» → None. Месяц вне словаря → None. """ m = _PERIOD_RE.search(name) if not m: return None kind = m.group(1).lower() month = _MONTHS.get(m.group(2).lower()) if month is None: return None year = int(m.group(3)) return (year, month, kind == "на") def _is_form6_monthly(link_text: str, href: str) -> bool: """Ссылка — помесячная адресная форма 6 приложения №4 (.xlsx)? Требуем: маркер «форма 6 … приложение № 4» (в тексте ссылки ИЛИ href), расширение .xlsx и распознаваемый период. Годовой «Форма №6 (Приложение №2) … план на <год>» (.xls) сюда НЕ проходит (другое приложение + не .xlsx). """ haystack = f"{link_text} {href}" if not _FORM6_MARKER_RE.search(haystack): return False if not _XLSX_RE.search(href) and not _XLSX_RE.search(link_text): return False return _parse_period(haystack) is not None def extract_form6_links(catalog_html: str) -> list[dict]: """Из HTML каталога → список кандидатов формы 6: [{url, period_month, is_forecast, asof}]. По каждой -ссылке, похожей на помесячную форму 6 приложения №4 (.xlsx), вытаскиваем период «(за|на) <месяц> <год>» из текста/href → period_month (первое число месяца) + is_forecast, и дату публикации (DD.MM.YYYY рядом со ссылкой) → asof. Годовые/не-xlsx/без периода ссылки игнорируются. URL резолвится к apex-домену. """ soup = BeautifulSoup(catalog_html, "html.parser") out: list[dict] = [] for a in soup.find_all("a", href=True): href = a["href"].strip() link_text = _clean_text(a.get_text(" ")) if not _is_form6_monthly(link_text, href): continue period = _parse_period(f"{link_text} {href}") if period is None: # защита (уже проверено в _is_form6_monthly) continue year, month, is_forecast = period # asof — дата публикации рядом со ссылкой (в тексте ссылки или соседних узлах). asof = _extract_asof_near(a, link_text) out.append( { "url": urljoin(BASE_URL, href), "period_month": date(year, month, 1), "is_forecast": is_forecast, "asof": asof, } ) logger.info("ekgas: кандидатов формы 6 в каталоге: %d", len(out)) return out def _extract_asof_near(anchor: object, link_text: str) -> date | None: """Дата публикации (DD.MM.YYYY) рядом со ссылкой → date. Нет — None. Ищем сначала в тексте самой ссылки, затем в тексте родительского контейнера (в каталоге дата обычно стоит в той же строке/ячейке, что и ссылка). """ candidates = [link_text] parent = getattr(anchor, "parent", None) if parent is not None: candidates.append(_clean_text(parent.get_text(" "))) for src in candidates: m = _ASOF_RE.search(src) if m: day, month, year = int(m.group(1)), int(m.group(2)), int(m.group(3)) try: return date(year, month, day) except ValueError: continue return None def select_latest_file(candidates: list[dict]) -> dict | None: """Выбирает свежайший файл: ПРИОРИТЕТ ФАКТА («за») над прогнозом («на»). Правило (см. модульный docstring): если есть хоть один файл «за» (is_forecast=False), берём максимальный (year, month) СРЕДИ «за» — даже если какой-то «на» новее по (year, month). Файлов «за» нет вообще → максимальный «на» (is_forecast=True). Пусто → None. """ if not candidates: return None facts = [c for c in candidates if not c["is_forecast"]] pool = facts if facts else candidates return max(pool, key=lambda c: (c["period_month"].year, c["period_month"].month)) def _parse_free_capacity(value: object) -> tuple[float | None, bool]: """Ячейка свободной мощности → (free_capacity_mln_m3, needs_calc). «0.000*» (число + звёздочка, любой вариант «0,000*»/«0.000 *») = НЕ ноль, а «требуется гидравлический расчёт» → (None, True). Иначе — обычный числовой парс: настоящий 0 (без «*») → (0.0, False); дефицит −0.084 сохраняется знаком; «—»/текст → (None, False). """ if isinstance(value, str) and _NEEDS_CALC_RE.match(value): return None, True return parse_reserve_number(value), False def extract_address(outlet_name: str) -> str | None: """Извлекает адресный хвост из «Точки выхода» для геокода DaData (PR-3). Нет — None. Консервативно: возвращаем адрес ТОЛЬКО при явном сигнале (улица-токен ул|пер|пр| проезд|б-р|ш|тракт|наб ИЛИ «г.<Город>») + номер дома. Юрлицо без адреса («ИП Рошканяну Владимир Антонович») → None (не выдумываем). Examples: «…(г.Екатеринбург, Складской проезд,4а)» → «г.Екатеринбург, Складской проезд,4а» «…, пер. Базовый, 17» → «пер. Базовый, 17» «ИП Рошканяну Владимир Антонович» → None """ if not outlet_name: return None # Требуем явный адресный сигнал во всей строке — иначе номер в названии юрлица/объекта # («ООО ТЭЦ-2», «цех №5») ложно распознался бы как дом. if not (_HAS_STREET_TOKEN_RE.search(outlet_name) or _HAS_CITY_RE.search(outlet_name)): return None m = _ADDRESS_RE.search(outlet_name) if not m: return None address = _MULTISPACE_RE.sub(" ", m.group(0)).strip().strip("(),; ") return address or None def _forward_fill_grs(rows: list[list[object]], cols: dict[str, int]) -> list[dict]: """Строки данных → записи с forward-fill «Точки входа» (block-marker ГРС). «Точка входа» заполнена ТОЛЬКО в первой строке блока ГРС → протягиваем последнее непустое значение вниз (5 блоков). Индексы колонок — из _find_layout (label-детект). Строки до первого block-marker (нет ГРС) и single-cell строки (сноска «*» в конце листа) пропускаются. """ def _cell(cells: list[object], key: str) -> object: idx = cols.get(key, -1) return cells[idx] if 0 <= idx < len(cells) else None records: list[dict] = [] current_grs: str | None = None for cells in rows: # Single-cell строка (сноска про «*» / служебная) → скип. non_empty = [c for c in cells if _clean_text(c)] if len(non_empty) <= 1: continue entry = _clean_text(_cell(cells, "entry")) if entry: current_grs = entry outlet = _clean_text(_cell(cells, "outlet")) if not current_grs or not outlet: continue # строка до первого block-marker ИЛИ без точки выхода → скип free_raw = _cell(cells, "free") free, needs_calc = _parse_free_capacity(free_raw) records.append( { "grs_name": current_grs, "outlet_name": outlet, "consumer_type": _clean_text(_cell(cells, "consumer_type")) or None, "consumption_group": _clean_text(_cell(cells, "group")) or None, "volume_requested_mln_m3": parse_reserve_number(_cell(cells, "requested")), "volume_approved_mln_m3": parse_reserve_number(_cell(cells, "approved")), "free_capacity_mln_m3": free, "free_capacity_needs_calc": needs_calc, "address_extracted": extract_address(outlet), "raw_cells": _cells_to_jsonable(cells), } ) return records def _cells_to_jsonable(cells: list[object]) -> list[object]: """Ячейки строки → JSON-совместимый вид (datetime/прочее → str, числа/None as-is).""" out: list[object] = [] for value in cells: if value is None or isinstance(value, bool | int | float | str): out.append(value) else: out.append(str(value)) return out def _dedup_outlet_names(records: list[dict]) -> list[dict]: """Дедуп повторов outlet_name В ПРЕДЕЛАХ файла: 2-му и далее — суффикс « (N)». Один и тот же outlet_name у одной ГРС может встречаться несколько раз (разные группы потребления / объекты одного юрлица). UPSERT по (grs_name_norm, outlet_name, period_month, is_forecast) схлопнул бы их с потерей данных (урок тепла/воды) — добавляем порядковый суффикс. Ключ дедупа — (grs_name_norm, outlet_name). """ seen: dict[tuple[str, str], int] = {} for rec in records: key = (normalize_grs_name(rec["grs_name"]), rec["outlet_name"]) seen[key] = seen.get(key, 0) + 1 if seen[key] > 1: rec["outlet_name"] = f"{rec['outlet_name']} ({seen[key]})" return records def parse_outlet_sheet(xlsx_bytes: bytes) -> list[dict]: """Парсит лист формы 6 → записи точек выхода (forward-fill ГРС + дедуп имён). Сетка детектится ПО ПОДПИСЯМ (_find_layout): живой файл за май 2026 — header row 11, слева колонка «№ п/п», данные с row 13 (разведка видела r10/r12 без «№ п/п» — формат плавает). Свободная мощность: float ИЛИ «0.000*» → needs_calc. Single-cell сноска про «*» в конце листа → скип. Сетка не распознана → 0 записей + warning (не мусор — урок vodokanal). Каждая запись: {grs_name, outlet_name, consumer_type, consumption_group, volume_requested_mln_m3, volume_approved_mln_m3, free_capacity_mln_m3, free_capacity_needs_calc, address_extracted, raw_cells}. outlet_name дедуплен суффиксом « (N)» в пределах файла. """ wb = load_workbook(io.BytesIO(xlsx_bytes), read_only=True, data_only=True) ws = wb.active try: all_rows: list[list[object]] = [list(row) for row in ws.iter_rows(values_only=True)] finally: wb.close() layout = _find_layout(all_rows) if layout is None: logger.warning( "ekgas: сетка листа не распознана (нет header с подписями) — 0 записей; " "первые 5 строк: %s", [[_clean_text(c) for c in r[:9]] for r in all_rows[:5]], ) return [] data_start, cols = layout records = _forward_fill_grs(all_rows[data_start:], cols) records = _dedup_outlet_names(records) logger.info("ekgas: распознано точек выхода=%d", len(records)) return records def fetch_catalog_html() -> str: """Тянет HTML каталога раскрытия. RUN-ON-PROD (гео-блок). httpx с таймаутом, apex-SSL.""" resp = httpx.get( CATALOG_URL, timeout=_HTTP_TIMEOUT, follow_redirects=True, verify=_ru_ssl_context() ) resp.raise_for_status() return resp.text def fetch_xlsx(url: str) -> bytes: """Тянет XLSX формы 6 по URL. RUN-ON-PROD (гео-блок). httpx с таймаутом, apex-SSL.""" resp = httpx.get(url, timeout=_HTTP_TIMEOUT, follow_redirects=True, verify=_ru_ssl_context()) resp.raise_for_status() return resp.content def _upsert_outlet_rows(db: Session, records: list[dict], meta: dict) -> dict[str, int]: """UPSERT точек выхода в gas_grs_outlet_points. Per-row SAVEPOINT (битая строка не валит батч). meta = {period_month: date, is_forecast: bool, asof: date|None, source_url: str}. ON CONFLICT (grs_name_norm, outlet_name, period_month, is_forecast) DO UPDATE — обновляем объёмы/free/needs_calc/address/raw/fetched_at. Returns счётчики inserted/updated/skipped/needs_calc/with_address. """ inserted = 0 updated = 0 skipped = 0 needs_calc = 0 with_address = 0 for rec in records: grs_name = rec["grs_name"] params = { "grs_name": grs_name, "grs_name_norm": normalize_grs_name(grs_name), "outlet_name": rec["outlet_name"], "consumer_type": rec["consumer_type"], "consumption_group": rec["consumption_group"], "requested": rec["volume_requested_mln_m3"], "approved": rec["volume_approved_mln_m3"], "free": rec["free_capacity_mln_m3"], "needs_calc": rec["free_capacity_needs_calc"], "address": rec["address_extracted"], "period_month": meta["period_month"], "is_forecast": meta["is_forecast"], "asof": meta.get("asof"), "source_url": meta.get("source_url"), "raw": json.dumps({"cells": rec["raw_cells"]}, ensure_ascii=False), } try: with db.begin_nested(): # SAVEPOINT — откат только этой строки result = db.execute( text(""" INSERT INTO gas_grs_outlet_points (grs_name, grs_name_norm, outlet_name, consumer_type, consumption_group, volume_requested_mln_m3, volume_approved_mln_m3, free_capacity_mln_m3, free_capacity_needs_calc, address_extracted, period_month, is_forecast, asof, source_url, raw, fetched_at) VALUES ( :grs_name, :grs_name_norm, :outlet_name, :consumer_type, :consumption_group, :requested, :approved, :free, :needs_calc, :address, :period_month, :is_forecast, :asof, :source_url, CAST(:raw AS jsonb), NOW() ) ON CONFLICT (grs_name_norm, outlet_name, period_month, is_forecast) DO UPDATE -- geom НАМЕРЕННО НЕ в SET: geocode_gas_outlets (PR-3) -- пишет его отдельным UPDATE. Помесячный рефреш INSERT-ит -- без geom → EXCLUDED.geom = NULL; включив его в SET, мы бы -- ЗАТЁРЛИ уже геокоженную точку в NULL. Оставляем как есть. SET grs_name = EXCLUDED.grs_name, consumer_type = EXCLUDED.consumer_type, consumption_group = EXCLUDED.consumption_group, volume_requested_mln_m3 = EXCLUDED.volume_requested_mln_m3, volume_approved_mln_m3 = EXCLUDED.volume_approved_mln_m3, free_capacity_mln_m3 = EXCLUDED.free_capacity_mln_m3, free_capacity_needs_calc = EXCLUDED.free_capacity_needs_calc, address_extracted = EXCLUDED.address_extracted, asof = EXCLUDED.asof, source_url = EXCLUDED.source_url, raw = EXCLUDED.raw, fetched_at = NOW() RETURNING (xmax = 0) AS is_insert """), params, ).scalar() if result: inserted += 1 else: updated += 1 if rec["free_capacity_needs_calc"]: needs_calc += 1 if rec["address_extracted"]: with_address += 1 except Exception as e: logger.warning("ekgas upsert failed for %r/%r: %s", grs_name, rec["outlet_name"], e) skipped += 1 return { "rows": len(records), "inserted": inserted, "updated": updated, "skipped": skipped, "needs_calc": needs_calc, "with_address": with_address, } def load_gas_outlet_points(db: Session | None = None) -> dict: """Тянет каталог ekgas → свежайшая форма 6 → парс XLSX → UPSERT точек выхода. RUN-ON-PROD (гео-блок источника). Owns-session как соседи (gas/rosseti/eias): ``db`` None → своя SessionLocal (fetch + запись sync). Выбор файла — приоритет факта («за») над прогнозом («на»), см. select_latest_file. Returns счётчики {rows, inserted, updated, skipped, needs_calc, with_address, period, is_forecast} или {"error": ...} если подходящий файл в каталоге не найден. """ catalog_html = fetch_catalog_html() candidates = extract_form6_links(catalog_html) chosen = select_latest_file(candidates) if chosen is None: logger.warning("ekgas: подходящая форма 6 в каталоге не найдена (0 кандидатов)") return {"error": "no form6 file found in catalog"} meta = { "period_month": chosen["period_month"], "is_forecast": chosen["is_forecast"], "asof": chosen["asof"], "source_url": chosen["url"], } logger.info( "ekgas: выбран файл period=%s is_forecast=%s asof=%s url=%s", chosen["period_month"], chosen["is_forecast"], chosen["asof"], chosen["url"], ) xlsx_bytes = fetch_xlsx(chosen["url"]) records = parse_outlet_sheet(xlsx_bytes) owns_session = db is None if db is None: db = SessionLocal() try: counts = _upsert_outlet_rows(db, records, meta) db.commit() except Exception as e: db.rollback() logger.exception("load_gas_outlet_points: outer tx rolled back: %s", e) raise finally: if owns_session: db.close() counts["period"] = chosen["period_month"].isoformat() counts["is_forecast"] = chosen["is_forecast"] logger.info("load_gas_outlet_points done: %s", counts) return counts # ── Геокод address_extracted → geom (PR-3) ──────────────────────────────────── # Кандидаты геокода: строки последнего period_month с адресом, но БЕЗ geom. # MAX(period_month) в подзапросе — работаем только по свежайшему снапшоту (старые # месяцы не трогаем). geom стабилен между месяцами (UPSERT его не затирает — см. # DO UPDATE выше), так что уже геокоженные строки прошлых месяцев не пере-геокодим. _GEOCODE_CANDIDATES_SQL = text(""" SELECT id, address_extracted FROM gas_grs_outlet_points WHERE address_extracted IS NOT NULL AND geom IS NULL AND period_month = (SELECT MAX(period_month) FROM gas_grs_outlet_points) ORDER BY id """) # UPDATE geom = точка WGS84. CAST(:lon/:lat AS float) — psycopg3-канон (backend.md: # НИКОГДА :lon::float). Пишем ТОЛЬКО если geom всё ещё NULL (idempotent при повторе). _GEOCODE_UPDATE_SQL = text(""" UPDATE gas_grs_outlet_points SET geom = ST_SetSRID( ST_MakePoint(CAST(:lon AS float), CAST(:lat AS float)), 4326 ) WHERE id = CAST(:id AS bigint) AND geom IS NULL """) def _ekb_prefixed(address: str) -> str: """address_extracted → строка для DaData с городом-префиксом при его отсутствии. «ул. Стрелочников, д. 2» → «г Екатеринбург, ул. Стрелочников, д. 2». Если город уже есть в строке («г.Екатеринбург, …» / «г. Реж, …») — не дублируем. """ if "екатеринбург" in address.lower() or _HAS_CITY_RE.search(address): return address return f"{_EKB_PREFIX}{address}" def _in_ekb_bbox(lat: float, lon: float) -> bool: """Точка в bbox Екатеринбурга и окрестностей? (sanity-гейт против чужого города).""" return ( _EKB_BBOX_LAT[0] <= lat <= _EKB_BBOX_LAT[1] and _EKB_BBOX_LON[0] <= lon <= _EKB_BBOX_LON[1] ) def geocode_gas_outlets(db: Session | None = None, *, apply: bool = False) -> dict: """Геокодит address_extracted точек выхода → geom (последний period_month). Дефолт dry-run (apply=False): считает счётчики + печатает первые 20 пар адрес→(lat,lon), НО В БД НЕ ПИШЕТ. apply=True — UPDATE geom. Алгоритм: 1. SELECT строки последнего period_month c address_extracted IS NOT NULL AND geom IS NULL (уже геокоженные не трогаем — geom стабилен между месяцами). 2. Каждый адрес: префикс «г Екатеринбург, » при отсутствии города → clean_address. 3. Sanity-bbox ЕКБ+окрестности: координата вне bbox → failed (DaData «дорисовал» чужой город на кривом адресе) — geom НЕ пишем (честность). 4. Кэш в памяти (адреса повторяются) + cap _GEO_DADATA_CAP вызовов DaData. Per-row SAVEPOINT (битая строка/UPDATE не валит батч, backend.md). Returns счётчики {candidates, geocoded, failed, dadata_calls, cap_hit, applied}. """ owns_session = db is None if db is None: db = SessionLocal() candidates: list[tuple[int, str]] = [ (int(row[0]), str(row[1])) for row in db.execute(_GEOCODE_CANDIDATES_SQL).all() ] counts = { "candidates": len(candidates), "geocoded": 0, "failed": 0, "dadata_calls": 0, "cap_hit": False, "applied": apply, } # Кэш прогона: address → (lat, lon) | None (None = «пробовали, DaData не дал/вне # bbox» — кэшируем и его, чтобы не жечь квоту на повтор одного адреса). geo_cache: dict[str, tuple[float, float] | None] = {} sample: list[tuple[str, tuple[float, float]]] = [] try: for row_id, address in candidates: if address in geo_cache: coords = geo_cache[address] elif counts["dadata_calls"] >= _GEO_DADATA_CAP: counts["cap_hit"] = True counts["failed"] += 1 continue else: counts["dadata_calls"] += 1 raw = clean_address(_ekb_prefixed(address)) # bbox-гейт ДО кэша: вне ЕКБ → трактуем как «нет координат» (None). if raw is not None and not _in_ekb_bbox(raw[0], raw[1]): logger.info( "ekgas geocode: %r вне bbox ЕКБ (lat=%.4f lon=%.4f) → failed", address[:60], raw[0], raw[1], ) raw = None coords = raw geo_cache[address] = coords if coords is None: counts["failed"] += 1 continue lat, lon = coords if len(sample) < 20: sample.append((address, (lat, lon))) if not apply: counts["geocoded"] += 1 continue try: with db.begin_nested(): # SAVEPOINT — откат только этой строки db.execute(_GEOCODE_UPDATE_SQL, {"id": row_id, "lat": lat, "lon": lon}) counts["geocoded"] += 1 except Exception as e: logger.warning("ekgas geocode UPDATE failed for id=%s %r: %s", row_id, address, e) counts["failed"] += 1 if apply: db.commit() except Exception as e: if apply: db.rollback() logger.exception("geocode_gas_outlets: rolled back: %s", e) raise finally: if owns_session: db.close() logger.info("geocode_gas_outlets done (apply=%s): %s", apply, counts) if not apply: for addr, (lat, lon) in sample: logger.info(" sample: %r → (%.6f, %.6f)", addr[:80], lat, lon) return counts def _main(argv: list[str] | None = None) -> int: """CLI: помесячная загрузка формы 6 (--load) и геокод адресов → geom (--geocode-outlets). RUN-ON-PROD (гео-блок источника + DaData-ключи на проде). Пример: python -m app.services.site_finder.ekgas_outlet_loader --geocode-outlets python -m app.services.site_finder.ekgas_outlet_loader --geocode-outlets --apply """ parser = argparse.ArgumentParser( description="Загрузчик точек выхода ГРС «Екатеринбурггаз» (#2119 B2): " "load формы 6 + геокод address_extracted → geom." ) parser.add_argument( "--load", action="store_true", help="Загрузить свежайшую форму 6 из каталога ekgas → UPSERT точек выхода.", ) parser.add_argument( "--geocode-outlets", action="store_true", help="Геокодить address_extracted (последний period_month, geom IS NULL) → geom.", ) parser.add_argument( "--apply", action="store_true", help="Реально писать в БД (для --geocode-outlets). Без флага — dry-run.", ) args = parser.parse_args(argv) logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s") if not args.load and not args.geocode_outlets: parser.error("нужен --load и/или --geocode-outlets") if args.load: logger.info("load result: %s", load_gas_outlet_points()) if args.geocode_outlets: logger.info("geocode result: %s", geocode_gas_outlets(apply=args.apply)) return 0 if __name__ == "__main__": # pragma: no cover sys.exit(_main())