689 lines
29 KiB
Python
689 lines
29 KiB
Python
"""Unit-тесты загрузчика точек выхода ГРС «Екатеринбурггаз» (ekgas.ru) (#2119 B2, PR-2).
|
||
|
||
Pure / mock-based — без реальной сети и БД. Покрывает:
|
||
- extract_form6_links + select_latest_file: выбор свежайшего файла из синтетического
|
||
каталога с ПРИОРИТЕТОМ ФАКТА («за») над прогнозом («на») — даже когда «на» новее по
|
||
(год, месяц) (отдельный тест); годовой .xls / не-форма-6 отфильтрованы;
|
||
- parse_outlet_sheet: РЕАЛЬНАЯ сетка (подписи row 10, нумерация row 11, данные с row 12),
|
||
forward-fill block-marker ГРС, «0.000*»→needs_calc (НЕ ноль), дефицит −0.084 сохранён,
|
||
сноска-скип single-cell;
|
||
- extract_address: скобочный/хвостовой адрес извлечён, юрлицо без адреса → None;
|
||
- _dedup_outlet_names: суффикс « (N)»; _upsert_outlet_rows: счётчики (mock-DB);
|
||
- geocode_gas_outlets (PR-3): префикс города, bbox-гейт (Москва → failed), кэш
|
||
повторного адреса, dry-run не пишет, apply пишет geom, cap вызовов DaData.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import io
|
||
from contextlib import contextmanager
|
||
from datetime import date
|
||
from typing import Any
|
||
|
||
import openpyxl
|
||
import pytest
|
||
|
||
from app.services.site_finder import ekgas_outlet_loader as ek
|
||
|
||
# ── Каталог: синтетический HTML со ссылками на формы 6 ────────────────────────
|
||
|
||
|
||
def _form6_name(kind: str, month: str, year: int) -> str:
|
||
"""Имя файла формы 6 приложения №4 в реальном формате ekgas."""
|
||
return (
|
||
f"Форма 6 приложения № 4 к Приказу ФАС России № 960 от 08.12.2022г. "
|
||
f"{kind} {month} {year}г..xlsx"
|
||
)
|
||
|
||
|
||
def _catalog_link(kind: str, month: str, year: int, hex_dir: str, asof: str = "") -> str:
|
||
"""<a> каталога: путь /uploads/default/<hex>/<имя> + опц. дата публикации рядом."""
|
||
name = _form6_name(kind, month, year)
|
||
href = f"/uploads/default/{hex_dir}/{name}"
|
||
asof_html = f"<span>{asof}</span>" if asof else ""
|
||
return f'<div class="row"><a href="{href}">{name}</a>{asof_html}</div>'
|
||
|
||
|
||
def test_extract_form6_links_parses_period_and_asof() -> None:
|
||
"""Период «(за|на) <месяц> <год>» + asof (DD.MM.YYYY рядом) извлекаются; URL к apex."""
|
||
html = (
|
||
"<html><body>"
|
||
+ _catalog_link("за", "май", 2026, "a1b2c3", asof="03.06.2026")
|
||
+ "</body></html>"
|
||
)
|
||
|
||
links = ek.extract_form6_links(html)
|
||
|
||
assert len(links) == 1
|
||
link = links[0]
|
||
assert link["period_month"] == date(2026, 5, 1)
|
||
assert link["is_forecast"] is False # «за» = факт
|
||
assert link["asof"] == date(2026, 6, 3)
|
||
assert link["url"].startswith("https://ekgas.ru/uploads/default/a1b2c3/")
|
||
|
||
|
||
def test_extract_form6_links_na_is_forecast() -> None:
|
||
"""«на <месяц>» → is_forecast=True (прогноз)."""
|
||
html = "<html>" + _catalog_link("на", "июль", 2026, "deadbeef") + "</html>"
|
||
links = ek.extract_form6_links(html)
|
||
assert len(links) == 1
|
||
assert links[0]["is_forecast"] is True
|
||
assert links[0]["period_month"] == date(2026, 7, 1)
|
||
|
||
|
||
def test_extract_form6_links_skips_yearly_xls_and_foreign() -> None:
|
||
"""Годовой «Форма №6 (Приложение №2) … план на <год>» (.xls) и прочие файлы — скип."""
|
||
html = (
|
||
"<html>"
|
||
# годовой план — приложение №2, .xls → НЕ форма 6 приложения №4
|
||
'<a href="/uploads/default/ff00/Форма №6 (Приложение №2) план на 2026г..xls">'
|
||
"годовой план</a>"
|
||
# посторонний файл
|
||
'<a href="/uploads/default/aa11/Прайс-лист.pdf">прайс</a>'
|
||
# валидная помесячная форма 6
|
||
+ _catalog_link("за", "апрель", 2026, "cc22")
|
||
+ "</html>"
|
||
)
|
||
|
||
links = ek.extract_form6_links(html)
|
||
|
||
assert len(links) == 1
|
||
assert links[0]["period_month"] == date(2026, 4, 1)
|
||
|
||
|
||
def test_select_latest_file_fact_beats_newer_forecast() -> None:
|
||
"""ПРИОРИТЕТ ФАКТА: «за май 2026» выбирается, хотя «на июль 2026» новее по (год,месяц).
|
||
|
||
Документирует правило select_latest_file: если есть хоть один файл «за» (факт),
|
||
берём максимальный (год, месяц) СРЕДИ «за», даже если прогноз «на» новее. Прогноз
|
||
берётся ТОЛЬКО когда файлов «за» нет вообще.
|
||
"""
|
||
html = (
|
||
"<html>"
|
||
+ _catalog_link("за", "апрель", 2026, "h1")
|
||
+ _catalog_link("за", "май", 2026, "h2") # свежайший ФАКТ
|
||
+ _catalog_link("на", "июль", 2026, "h3") # прогноз новее по (год,месяц)
|
||
+ "</html>"
|
||
)
|
||
|
||
chosen = ek.select_latest_file(ek.extract_form6_links(html))
|
||
|
||
assert chosen is not None
|
||
assert chosen["period_month"] == date(2026, 5, 1) # «за май», не «на июль»
|
||
assert chosen["is_forecast"] is False
|
||
|
||
|
||
def test_select_latest_file_forecast_only_fallback() -> None:
|
||
"""Файлов «за» нет вообще → берём свежайший «на» (прогноз, is_forecast=True)."""
|
||
html = (
|
||
"<html>"
|
||
+ _catalog_link("на", "июнь", 2026, "f1")
|
||
+ _catalog_link("на", "август", 2026, "f2") # свежайший прогноз
|
||
+ "</html>"
|
||
)
|
||
|
||
chosen = ek.select_latest_file(ek.extract_form6_links(html))
|
||
|
||
assert chosen is not None
|
||
assert chosen["period_month"] == date(2026, 8, 1)
|
||
assert chosen["is_forecast"] is True
|
||
|
||
|
||
def test_select_latest_file_empty_returns_none() -> None:
|
||
assert ek.select_latest_file([]) is None
|
||
|
||
|
||
# ── parse_outlet_sheet: РЕАЛЬНАЯ сетка (row 10 подписи, 11 нумерация, 12+ данные) ──
|
||
|
||
|
||
def _pad(cells: dict[int, object], width: int = 7) -> list[object]:
|
||
"""Строка листа из width колонок: dict{col_index: value} → выровненный ряд."""
|
||
row: list[object] = [None] * width
|
||
for idx, value in cells.items():
|
||
row[idx] = value
|
||
return row
|
||
|
||
|
||
def _outlet_xlsx_bytes() -> bytes:
|
||
"""XLSX формы 6 — сокращённая РЕАЛЬНАЯ сетка (собрана openpyxl'ом как на проде).
|
||
|
||
Строки 1-9 пустые/шапка, row 10 = подписи, row 11 = нумерация 1..7, данные с row 12:
|
||
* 2 блока ГРС (block-marker в col0 только в 1-й строке блока → forward-fill);
|
||
* «0.000*» в col6 → needs_calc (НЕ ноль);
|
||
* отрицательный free (дефицит −0.084) сохраняется знаком;
|
||
* адрес в «Точке выхода» → address_extracted; юрлицо без адреса → None;
|
||
* последняя строка — сноска про «*» (single-cell) → скип.
|
||
"""
|
||
wb = openpyxl.Workbook()
|
||
ws = wb.active
|
||
ws.title = "Форма 6"
|
||
|
||
# Строки 1-9: пустая шапка формы (title/период/пусто) — до подписей.
|
||
for _ in range(9):
|
||
ws.append(_pad({}))
|
||
# row 10 — подписи колонок.
|
||
ws.append(
|
||
_pad(
|
||
{
|
||
0: "Точка входа в газораспределительную сеть",
|
||
1: "Точка выхода из газораспределительной сети",
|
||
2: "Наименование потребителя",
|
||
3: "Номер группы газопотребления/ транзит",
|
||
4: "Объемы газа в соответствии с поступившими заявками, млн.куб.м",
|
||
5: "Объемы газа в соответствии с удовлетворенными заявками, млн.куб.м",
|
||
6: "Свободная мощность газораспределительной сети, млн.куб.м",
|
||
}
|
||
)
|
||
)
|
||
# row 11 — нумерация столбцов 1..7.
|
||
ws.append(_pad({0: 1, 1: 2, 2: 3, 3: 4, 4: 5, 5: 6, 6: 7}))
|
||
|
||
# row 12 — блок 1, первая строка (block-marker ГРС в col0).
|
||
ws.append(
|
||
_pad(
|
||
{
|
||
0: "ГРС Арамиль",
|
||
1: 'ООО "Тепло" (г.Екатеринбург, Складской проезд,4а)',
|
||
2: "котельная",
|
||
3: "5 - 0,1-1млн.куб.м",
|
||
4: 1.2,
|
||
5: 0.8,
|
||
6: 0.4,
|
||
}
|
||
)
|
||
)
|
||
# row 13 — блок 1, вторая строка (col0 ПУСТ → forward-fill «ГРС Арамиль»),
|
||
# свободная мощность «0.000*» → needs_calc.
|
||
ws.append(
|
||
_pad(
|
||
{
|
||
1: "ИП Рошканяну Владимир Антонович", # юрлицо без адреса → address None
|
||
2: "промплощадка",
|
||
4: 0.05,
|
||
5: 0.05,
|
||
6: "0.000*", # НЕ ноль — требует гидрорасчёта
|
||
}
|
||
)
|
||
)
|
||
# row 14 — блок 2, первая строка (новый block-marker), дефицит −0.084.
|
||
ws.append(
|
||
_pad(
|
||
{
|
||
0: "ГРС Кольцово",
|
||
1: 'ЗАО "Луч", пер. Базовый, 17', # адрес хвостом
|
||
2: "ТЭЦ",
|
||
3: "4",
|
||
4: 2.5,
|
||
5: 2.5,
|
||
6: -0.084, # дефицит — знак сохраняется
|
||
}
|
||
)
|
||
)
|
||
# row 15 — сноска про «*» (single-cell) → скип.
|
||
ws.append(_pad({0: "* требуется гидравлический расчёт свободной мощности"}))
|
||
|
||
buf = io.BytesIO()
|
||
wb.save(buf)
|
||
return buf.getvalue()
|
||
|
||
|
||
def test_parse_outlet_sheet_forward_fill_and_markers() -> None:
|
||
"""Forward-fill ГРС, «0.000*»→needs_calc, дефицит сохранён, адрес извлечён, сноска-скип."""
|
||
records = ek.parse_outlet_sheet(_outlet_xlsx_bytes())
|
||
|
||
# 3 строки данных (сноска single-cell отброшена).
|
||
assert len(records) == 3
|
||
|
||
r0, r1, r2 = records
|
||
|
||
# Блок 1, строка 1.
|
||
assert r0["grs_name"] == "ГРС Арамиль"
|
||
assert r0["free_capacity_mln_m3"] == 0.4
|
||
assert r0["free_capacity_needs_calc"] is False
|
||
assert r0["address_extracted"] == "г.Екатеринбург, Складской проезд,4а"
|
||
|
||
# Блок 1, строка 2 — forward-fill той же ГРС + «0.000*» → needs_calc, NULL free.
|
||
assert r1["grs_name"] == "ГРС Арамиль" # col0 был пуст → протянут
|
||
assert r1["free_capacity_mln_m3"] is None
|
||
assert r1["free_capacity_needs_calc"] is True # «0.000*» — НЕ ноль
|
||
assert r1["address_extracted"] is None # ИП без адреса
|
||
|
||
# Блок 2 — новый block-marker + дефицит −0.084 (знак сохранён) + адрес хвостом.
|
||
assert r2["grs_name"] == "ГРС Кольцово"
|
||
assert r2["free_capacity_mln_m3"] == -0.084
|
||
assert r2["free_capacity_needs_calc"] is False
|
||
assert r2["address_extracted"] == "пер. Базовый, 17"
|
||
|
||
# raw_cells — сырая строка списком (для jsonb raw).
|
||
assert r0["raw_cells"][0] == "ГРС Арамиль"
|
||
|
||
|
||
def _pad8(cells: dict[int, object]) -> list[object]:
|
||
"""Строка live-layout (8 колонок: № п/п + 7 данных)."""
|
||
return _pad(cells, width=8)
|
||
|
||
|
||
def test_parse_outlet_sheet_live_layout_num_column_shifted() -> None:
|
||
"""ЖИВОЙ прод-layout за май 2026: слева колонка «№ п/п», header на row 11,
|
||
данные с row 13 (сдвиг +1 строка и +1 колонка против разведки). Label-детект
|
||
обязан распарсить без хардкод-индексов — первый прогон дал 5 строк из 1037."""
|
||
wb = openpyxl.Workbook()
|
||
ws = wb.active
|
||
ws.title = "Май 2026"
|
||
for _ in range(10):
|
||
ws.append(_pad8({}))
|
||
ws.append(
|
||
_pad8(
|
||
{
|
||
0: "№ п/п",
|
||
1: "Точка входа в газораспределительную сеть",
|
||
2: "Точка выхода из газораспределительной сети",
|
||
3: "Наименование потребителя",
|
||
4: "Номер группы газопотребления/ транзит",
|
||
5: "Объемы газа в соответствии с поступившими заявками, млн.куб.м",
|
||
6: "Объемы газа в соответствии с удовлетворенными заявками, млн.куб.м",
|
||
7: "Свободная мощность газораспределительной сети, млн.куб.м",
|
||
}
|
||
)
|
||
)
|
||
ws.append(_pad8({1: "1", 2: "2", 3: "3", 4: "4", 5: "5", 6: "6", 7: "7"}))
|
||
ws.append(
|
||
_pad8(
|
||
{
|
||
0: "1",
|
||
1: "ГРС-1 Свердловск; ГРС-2 Екатеринбург",
|
||
2: "МПРО Приход (г.Екатеринбург, ул. Тверитина, 20)",
|
||
3: "котельная",
|
||
4: "8 - 0,01-500млн.куб.м",
|
||
5: 0.006,
|
||
6: 0.006,
|
||
7: 0,
|
||
}
|
||
)
|
||
)
|
||
ws.append(_pad8({2: "ИП Без Адреса", 3: "котельная", 5: 0.001, 6: 0.003, 7: -0.002}))
|
||
buf = io.BytesIO()
|
||
wb.save(buf)
|
||
|
||
records = ek.parse_outlet_sheet(buf.getvalue())
|
||
|
||
assert len(records) == 2
|
||
assert records[0]["grs_name"].startswith("ГРС-1")
|
||
assert records[0]["volume_requested_mln_m3"] == 0.006
|
||
assert records[0]["address_extracted"] is not None
|
||
assert records[1]["grs_name"].startswith("ГРС-1") # forward-fill через № п/п layout
|
||
assert records[1]["free_capacity_mln_m3"] == -0.002
|
||
|
||
|
||
def test_parse_outlet_sheet_volumes_and_meta() -> None:
|
||
"""Объёмы заявок/удовлетворено и тип/группа парсятся из своих колонок."""
|
||
records = ek.parse_outlet_sheet(_outlet_xlsx_bytes())
|
||
r0 = records[0]
|
||
assert r0["volume_requested_mln_m3"] == 1.2
|
||
assert r0["volume_approved_mln_m3"] == 0.8
|
||
assert r0["consumer_type"] == "котельная"
|
||
assert r0["consumption_group"] == "5 - 0,1-1млн.куб.м"
|
||
|
||
|
||
# ── extract_address: консервативный адрес-экстрактор ─────────────────────────
|
||
|
||
|
||
def test_extract_address_parenthetical() -> None:
|
||
"""«(г.Екатеринбург, Складской проезд,4а)» → адрес извлечён."""
|
||
name = 'ООО "Тепло" (г.Екатеринбург, Складской проезд,4а)'
|
||
assert ek.extract_address(name) == "г.Екатеринбург, Складской проезд,4а"
|
||
|
||
|
||
def test_extract_address_trailing() -> None:
|
||
"""«, пер. Базовый, 17» хвостом → адрес извлечён."""
|
||
assert ek.extract_address('ЗАО "Луч", пер. Базовый, 17') == "пер. Базовый, 17"
|
||
|
||
|
||
def test_extract_address_street_with_house() -> None:
|
||
"""«г.…, ул. К.Маркса,31» → адрес с улицей и домом."""
|
||
got = ek.extract_address("Котельная, г. Реж, ул. К.Маркса,31")
|
||
assert got is not None
|
||
assert "К.Маркса" in got
|
||
assert got.rstrip().endswith("31")
|
||
|
||
|
||
def test_extract_address_legal_entity_returns_none() -> None:
|
||
"""Юрлицо без адресного сигнала → None (не выдумываем)."""
|
||
assert ek.extract_address("ИП Рошканяну Владимир Антонович") is None
|
||
|
||
|
||
def test_extract_address_no_street_signal_returns_none() -> None:
|
||
"""Название с числом, но без улицы/города («ООО ТЭЦ-2») → None (число ≠ дом)."""
|
||
assert ek.extract_address('ООО "ТЭЦ-2"') is None
|
||
assert ek.extract_address("") is None
|
||
|
||
|
||
# ── _parse_free_capacity: «0.000*» vs настоящий 0 vs дефицит ─────────────────
|
||
|
||
|
||
def test_parse_free_capacity_needs_calc_marker() -> None:
|
||
"""«0.000*» → (None, True). Вариации с запятой/пробелом тоже маркер."""
|
||
assert ek._parse_free_capacity("0.000*") == (None, True)
|
||
assert ek._parse_free_capacity("0,000 *") == (None, True)
|
||
|
||
|
||
def test_parse_free_capacity_real_zero_and_deficit() -> None:
|
||
"""Настоящий 0 (без «*») → (0.0, False); дефицит −0.084 → (−0.084, False)."""
|
||
assert ek._parse_free_capacity(0.0) == (0.0, False)
|
||
assert ek._parse_free_capacity(-0.084) == (-0.084, False)
|
||
assert ek._parse_free_capacity("—") == (None, False)
|
||
|
||
|
||
# ── _dedup_outlet_names: повторы outlet_name внутри файла ─────────────────────
|
||
|
||
|
||
def test_dedup_outlet_names_suffix() -> None:
|
||
"""Одинаковые outlet_name у одной ГРС → 2-му и далее суффикс « (N)» (не схлопывать)."""
|
||
records = [
|
||
{"grs_name": "ГРС Арти", "outlet_name": "Котельная №1"},
|
||
{"grs_name": "ГРС Арти", "outlet_name": "Котельная №1"}, # дубль
|
||
{"grs_name": "ГРС Арти", "outlet_name": "Котельная №1"}, # ещё дубль
|
||
{"grs_name": "ГРС Кольцово", "outlet_name": "Котельная №1"}, # другая ГРС → не дубль
|
||
]
|
||
|
||
ek._dedup_outlet_names(records)
|
||
|
||
assert [r["outlet_name"] for r in records] == [
|
||
"Котельная №1",
|
||
"Котельная №1 (2)",
|
||
"Котельная №1 (3)",
|
||
"Котельная №1", # другая ГРС — суффикс не нужен
|
||
]
|
||
|
||
|
||
# ── Fake DB (SAVEPOINT + execute), как в test_eias_heat_loader.py ─────────────
|
||
|
||
|
||
class _FakeResult:
|
||
def __init__(self, scalar_value: Any = True) -> None:
|
||
self._scalar = scalar_value
|
||
|
||
def scalar(self) -> Any:
|
||
return self._scalar
|
||
|
||
|
||
class _FakeSession:
|
||
"""Мини-заглушка Session: собирает execute-параметры, поддерживает begin_nested."""
|
||
|
||
def __init__(self, scalar_value: Any = True) -> None:
|
||
self.calls: list[tuple[str, Any]] = []
|
||
self._scalar = scalar_value
|
||
|
||
@contextmanager
|
||
def begin_nested(self): # type: ignore[no-untyped-def]
|
||
yield
|
||
|
||
def execute(self, stmt: Any, params: Any = None) -> _FakeResult:
|
||
self.calls.append((str(stmt), params))
|
||
return _FakeResult(self._scalar)
|
||
|
||
|
||
def _record(
|
||
grs: str,
|
||
outlet: str,
|
||
free: float | None,
|
||
needs_calc: bool = False,
|
||
address: str | None = None,
|
||
) -> dict:
|
||
return {
|
||
"grs_name": grs,
|
||
"outlet_name": outlet,
|
||
"consumer_type": "котельная",
|
||
"consumption_group": "4",
|
||
"volume_requested_mln_m3": 1.0,
|
||
"volume_approved_mln_m3": 0.9,
|
||
"free_capacity_mln_m3": free,
|
||
"free_capacity_needs_calc": needs_calc,
|
||
"address_extracted": address,
|
||
"raw_cells": [grs, outlet, "котельная"],
|
||
}
|
||
|
||
|
||
_META = {
|
||
"period_month": date(2026, 5, 1),
|
||
"is_forecast": False,
|
||
"asof": date(2026, 6, 3),
|
||
"source_url": "https://ekgas.ru/uploads/default/h1/f.xlsx",
|
||
}
|
||
|
||
|
||
def test_upsert_outlet_rows_counters_and_params() -> None:
|
||
"""Счётчики inserted/needs_calc/with_address; params корректны; CAST jsonb + ON CONFLICT."""
|
||
db = _FakeSession(scalar_value=True) # xmax=0 → insert
|
||
records = [
|
||
_record("ГРС Арамиль", "ООО Тепло", 0.4, address="пер. Базовый, 17"),
|
||
_record("ГРС Арамиль", "ИП Рошканяну", None, needs_calc=True),
|
||
_record("ГРС Кольцово", "ЗАО Луч", -0.084), # дефицит: знак сохраняется
|
||
]
|
||
|
||
counts = ek._upsert_outlet_rows(db, records, {**_META, "source_url": _META["source_url"]})
|
||
|
||
assert counts == {
|
||
"rows": 3,
|
||
"inserted": 3,
|
||
"updated": 0,
|
||
"skipped": 0,
|
||
"needs_calc": 1,
|
||
"with_address": 1,
|
||
}
|
||
assert len(db.calls) == 3
|
||
|
||
sql, first = db.calls[0]
|
||
assert first["grs_name"] == "ГРС Арамиль"
|
||
assert first["grs_name_norm"] == "грс арамиль" # нормализация из B1
|
||
assert first["free"] == 0.4
|
||
assert first["period_month"] == date(2026, 5, 1)
|
||
assert first["is_forecast"] is False
|
||
assert first["address"] == "пер. Базовый, 17"
|
||
assert '"cells"' in first["raw"]
|
||
assert "CAST(:raw AS jsonb)" in sql
|
||
assert "ON CONFLICT (grs_name_norm, outlet_name, period_month, is_forecast)" in sql
|
||
# needs_calc-строка: free NULL, needs_calc True прокинуты.
|
||
assert db.calls[1][1]["free"] is None
|
||
assert db.calls[1][1]["needs_calc"] is True
|
||
# дефицит сохранён знаком.
|
||
assert db.calls[2][1]["free"] == -0.084
|
||
|
||
|
||
def test_upsert_outlet_rows_update_counter() -> None:
|
||
"""scalar()=False (xmax≠0) → строка учтена как update, не insert."""
|
||
db = _FakeSession(scalar_value=False)
|
||
counts = ek._upsert_outlet_rows(db, [_record("ГРС X", "outlet", 1.0)], _META)
|
||
assert counts["updated"] == 1
|
||
assert counts["inserted"] == 0
|
||
|
||
|
||
def test_upsert_outlet_rows_skip_on_error() -> None:
|
||
"""execute бросает → строка skipped, счётчики insert/update не растут."""
|
||
|
||
class _RaisingSession(_FakeSession):
|
||
def execute(self, stmt: Any, params: Any = None) -> _FakeResult:
|
||
raise RuntimeError("db boom")
|
||
|
||
db = _RaisingSession()
|
||
counts = ek._upsert_outlet_rows(db, [_record("ГРС Y", "outlet", 2.0)], _META)
|
||
assert counts == {
|
||
"rows": 1,
|
||
"inserted": 0,
|
||
"updated": 0,
|
||
"skipped": 1,
|
||
"needs_calc": 0,
|
||
"with_address": 0,
|
||
}
|
||
|
||
|
||
# ── Геокод address_extracted → geom (PR-3) ────────────────────────────────────
|
||
|
||
# Координаты внутри bbox ЕКБ (центр — пл. 1905 года ≈ 56.838, 60.597).
|
||
_EKB_COORDS = (56.838, 60.597)
|
||
# Москва — заведомо вне bbox ЕКБ (DaData «дорисовал» чужой город на кривом адресе).
|
||
_MSK_COORDS = (55.7558, 37.6173)
|
||
|
||
|
||
class _GeocodeSession:
|
||
"""Fake Session для geocode_gas_outlets: отдаёт кандидатов на SELECT, собирает UPDATE.
|
||
|
||
execute(SELECT) → .all() = список (id, address); execute(UPDATE) → пишет params
|
||
в updates. begin_nested — no-op SAVEPOINT, commit — считает вызовы.
|
||
"""
|
||
|
||
def __init__(self, candidates: list[tuple[int, str]]) -> None:
|
||
self._candidates = candidates
|
||
self.updates: list[dict] = []
|
||
self.commits = 0
|
||
|
||
@contextmanager
|
||
def begin_nested(self): # type: ignore[no-untyped-def]
|
||
yield
|
||
|
||
def execute(self, stmt: Any, params: Any = None) -> Any:
|
||
sql = str(stmt)
|
||
if "UPDATE gas_grs_outlet_points" in sql:
|
||
self.updates.append(params)
|
||
return _FakeResult(None)
|
||
# SELECT кандидатов.
|
||
rows = [(cid, addr) for cid, addr in self._candidates]
|
||
return _SelectResult(rows)
|
||
|
||
def commit(self) -> None:
|
||
self.commits += 1
|
||
|
||
def rollback(self) -> None: # pragma: no cover - defensive
|
||
pass
|
||
|
||
|
||
class _SelectResult:
|
||
def __init__(self, rows: list[tuple[int, str]]) -> None:
|
||
self._rows = rows
|
||
|
||
def all(self) -> list[tuple[int, str]]:
|
||
return self._rows
|
||
|
||
|
||
def _patch_geocoder(monkeypatch: pytest.MonkeyPatch, mapping: dict) -> list[str]:
|
||
"""Мокает clean_address: query→coords по mapping (default _EKB_COORDS).
|
||
|
||
Возвращает список реально переданных в DaData query-строк (для проверки префикса
|
||
города и cap-счётчика вызовов).
|
||
"""
|
||
calls: list[str] = []
|
||
|
||
def _fake(query: str) -> tuple[float, float] | None:
|
||
calls.append(query)
|
||
if query in mapping:
|
||
return mapping[query]
|
||
return _EKB_COORDS
|
||
|
||
monkeypatch.setattr(ek, "clean_address", _fake)
|
||
return calls
|
||
|
||
|
||
def test_geocode_prefixes_city(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Адрес без города → DaData-запрос префиксован «г Екатеринбург, »."""
|
||
calls = _patch_geocoder(monkeypatch, {})
|
||
db = _GeocodeSession([(1, "ул. Стрелочников, д. 2")])
|
||
|
||
counts = ek.geocode_gas_outlets(db, apply=True)
|
||
|
||
assert calls == ["г Екатеринбург, ул. Стрелочников, д. 2"]
|
||
assert counts["geocoded"] == 1
|
||
assert counts["failed"] == 0
|
||
|
||
|
||
def test_geocode_keeps_existing_city(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Адрес с городом («г.Екатеринбург, …») → префикс НЕ дублируется."""
|
||
calls = _patch_geocoder(monkeypatch, {})
|
||
db = _GeocodeSession([(1, "г.Екатеринбург, Складской проезд, 4а")])
|
||
|
||
ek.geocode_gas_outlets(db, apply=True)
|
||
|
||
assert calls == ["г.Екатеринбург, Складской проезд, 4а"]
|
||
|
||
|
||
def test_geocode_bbox_gate_rejects_moscow(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Координата вне bbox ЕКБ (Москва) → failed, geom НЕ пишем."""
|
||
query = "г Екатеринбург, ул. Кривая, 1"
|
||
_patch_geocoder(monkeypatch, {query: _MSK_COORDS})
|
||
db = _GeocodeSession([(1, "ул. Кривая, 1")])
|
||
|
||
counts = ek.geocode_gas_outlets(db, apply=True)
|
||
|
||
assert counts["geocoded"] == 0
|
||
assert counts["failed"] == 1
|
||
assert db.updates == [] # geom не тронут
|
||
|
||
|
||
def test_geocode_caches_repeated_address(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Один и тот же адрес в 2 строках → DaData дёрнут ОДИН раз, geom у обеих."""
|
||
calls = _patch_geocoder(monkeypatch, {})
|
||
db = _GeocodeSession([(1, "ул. Общая, 5"), (2, "ул. Общая, 5")])
|
||
|
||
counts = ek.geocode_gas_outlets(db, apply=True)
|
||
|
||
assert len(calls) == 1 # кэш: второй адрес не жёг квоту
|
||
assert counts["dadata_calls"] == 1
|
||
assert counts["geocoded"] == 2
|
||
assert {u["id"] for u in db.updates} == {1, 2}
|
||
|
||
|
||
def test_geocode_dry_run_does_not_write(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Dry-run (apply=False): считает geocoded, но НЕ UPDATE-ит и НЕ commit-ит."""
|
||
_patch_geocoder(monkeypatch, {})
|
||
db = _GeocodeSession([(1, "ул. Стрелочников, д. 2")])
|
||
|
||
counts = ek.geocode_gas_outlets(db, apply=False)
|
||
|
||
assert counts["geocoded"] == 1
|
||
assert counts["applied"] is False
|
||
assert db.updates == []
|
||
assert db.commits == 0
|
||
|
||
|
||
def test_geocode_apply_writes_geom(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""apply=True: UPDATE с CAST(:lon/:lat AS float) + ST_SetSRID(ST_MakePoint...)."""
|
||
_patch_geocoder(monkeypatch, {})
|
||
db = _GeocodeSession([(7, "ул. Стрелочников, д. 2")])
|
||
|
||
counts = ek.geocode_gas_outlets(db, apply=True)
|
||
|
||
assert counts["geocoded"] == 1
|
||
assert db.commits == 1
|
||
assert len(db.updates) == 1
|
||
upd = db.updates[0]
|
||
assert upd["id"] == 7
|
||
assert upd["lat"] == _EKB_COORDS[0]
|
||
assert upd["lon"] == _EKB_COORDS[1]
|
||
|
||
|
||
def test_geocode_update_sql_uses_cast_and_postgis() -> None:
|
||
"""SQL UPDATE: CAST(:lon/:lat AS float) (psycopg3-канон), ST_SetSRID+ST_MakePoint, 4326."""
|
||
sql = str(ek._GEOCODE_UPDATE_SQL)
|
||
assert "CAST(:lon AS float)" in sql
|
||
assert "CAST(:lat AS float)" in sql
|
||
assert "ST_SetSRID" in sql
|
||
assert "ST_MakePoint" in sql
|
||
assert "4326" in sql
|
||
assert "geom IS NULL" in sql # idempotent — не переписываем уже геокоженное
|
||
# никакого :param::type (psycopg3 игнорит) в этом файле.
|
||
assert "::" not in sql
|
||
|
||
|
||
def test_geocode_cap_limits_dadata_calls(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Превышение cap вызовов DaData → cap_hit, остаток failed без обращения к DaData."""
|
||
calls = _patch_geocoder(monkeypatch, {})
|
||
monkeypatch.setattr(ek, "_GEO_DADATA_CAP", 2)
|
||
# 3 УНИКАЛЬНЫХ адреса, cap=2 → 3-й не геокодится.
|
||
db = _GeocodeSession([(1, "ул. A, 1"), (2, "ул. B, 2"), (3, "ул. C, 3")])
|
||
|
||
counts = ek.geocode_gas_outlets(db, apply=True)
|
||
|
||
assert counts["dadata_calls"] == 2
|
||
assert counts["geocoded"] == 2
|
||
assert counts["failed"] == 1
|
||
assert counts["cap_hit"] is True
|
||
assert len(calls) == 2
|