diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py
index 601b834b..43c67e85 100644
--- a/backend/app/api/v1/parcels.py
+++ b/backend/app/api/v1/parcels.py
@@ -3995,6 +3995,7 @@ def get_parcel_connection_capacity(
power_summary=data["power_summary"],
water=data["water"],
gas=data.get("gas"),
+ gas_outlet_points=data.get("gas_outlet_points") or [],
heat=data.get("heat"),
)
diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py
index d52677d9..c0492037 100644
--- a/backend/app/schemas/parcel.py
+++ b/backend/app/schemas/parcel.py
@@ -168,13 +168,38 @@ class GasGrsRow(BaseModel):
class GasCapacityBlock(BaseModel):
- """Газовый блок: городские ГРС ЕКБ со свободной мощностью. #2119 Фаза B1."""
+ """Газовый блок: городские ГРС ЕКБ со свободной мощностью. #2119 Фаза B1.
+
+ + счётчики точек выхода газосети (gas_grs_outlet_points, форма 6 ФАС №960 АО
+ «Екатеринбурггаз») за последний срез. #2119 Фаза B2 PR-4.
+ """
# Городские ГРС (Свердловск/Екатеринбург) — агрегация по grs_name. Пусто, если
# таблица gas_grs_capacity ещё не наполнена (weekly-sync, RUN-ON-PROD).
city_grs: list[GasGrsRow]
# Суммарная свободная мощность городских ГРС, тыс. м³/ч (None если данных нет).
total_free_th_m3_h: float | None
+ # Счётчики точек выхода последнего period_month (по ВСЕМ строкам среза, city-level —
+ # см. _query_gas_outlet_counts). ADDITIVE (default 0): 0, если таблица
+ # gas_grs_outlet_points ещё не наполнена (миграция 184 / monthly-sync). #2119 B2 PR-4.
+ # ВНИМАНИЕ: свободная мощность точек выхода — млн. м³/МЕС (НЕ тыс. м³/ч как у ГРС!).
+ outlets_total: int = 0 # всего точек выхода в последнем срезе
+ outlets_deficit: int = 0 # из них с дефицитом (free_capacity_mln_m3 < 0)
+ outlets_needs_calc: int = 0 # из них «0.000*» — нужен гидравлический расчёт
+
+
+class GasOutletPoint(BaseModel):
+ """Гео-точка выхода газосети в радиусе от участка (для слоя карты §3). #2119 B2 PR-4."""
+
+ outlet_name: str # «Точка выхода» — юрлицо/объект/промплощадка (полный текст)
+ consumer_type: str | None # котельная / ТЭЦ / площадка …
+ # Свободная мощность точки выхода, млн. м³/МЕС (НЕ тыс. м³/ч как у ГРС!). Может быть
+ # <0 (дефицит со знаком). NULL, когда в источнике «0.000*» (needs_calc=True).
+ free_capacity_mln_m3: float | None
+ needs_calc: bool # «0.000*» — свободная мощность не определена, нужен гидрорасчёт
+ distance_m: float # расстояние от центроида участка, м
+ lat: float | None
+ lon: float | None
class HeatSystemRow(BaseModel):
@@ -202,6 +227,9 @@ class ConnectionCapacityResponse(BaseModel):
water: list[WaterCapacityRow]
# Газовый блок аддитивен (Phase B1): None если ключ 'gas' не собран.
gas: GasCapacityBlock | None = None
+ # Гео-точки выхода газосети в радиусе от участка (слой карты §3). ADDITIVE (Phase B2
+ # PR-4): [] если таблица gas_grs_outlet_points пуста / нет geom в радиусе.
+ gas_outlet_points: list[GasOutletPoint] = []
# Тепловой блок аддитивен (Phase B2): None если ключ 'heat' не собран.
heat: HeatCapacityBlock | None = None
diff --git a/backend/app/services/site_finder/connection_capacity_lookup.py b/backend/app/services/site_finder/connection_capacity_lookup.py
index 91f6f87a..5fd57e08 100644
--- a/backend/app/services/site_finder/connection_capacity_lookup.py
+++ b/backend/app/services/site_finder/connection_capacity_lookup.py
@@ -9,6 +9,12 @@
- water: все строки water_supply_reserves за ПОСЛЕДНИЙ period (city-level).
- gas: городские ГРС «Газпром трансгаз Екатеринбург» со свободной мощностью
(gas_grs_capacity), агрегация по grs_name + суммарный свободный объём. #2119 B1.
+ + счётчики точек выхода (gas_grs_outlet_points, форма 6 ФАС №960 АО
+ «Екатеринбурггаз») последнего period_month: всего / дефицитных / «нужен
+ гидрорасчёт». #2119 B2 PR-4.
+ - gas_outlet_points: гео-точки выхода газосети (gas_grs_outlet_points) В РАДИУСЕ от
+ участка (ST_DWithin geom ~2000 м, latest period, LIMIT 40 по расстоянию) — для слоя
+ на карте §3. Свободная мощность в млн.куб.м/МЕС (НЕ тыс.м³/ч как у ГРС!). #2119 B2.
- heat: резервы тепловой мощности систем теплоснабжения (heat_system_reserves)
за последний период ПО КАЖДОЙ организации + суммарный свободный объём. #2119 B2.
@@ -26,6 +32,13 @@ logger = logging.getLogger(__name__)
_DEFAULT_RADIUS_M = 3000
_MAX_RADIUS_M = 10000
+# Радиус для гео-точек выхода газосети (gas_grs_outlet_points): фиксированный ~2000 м —
+# точки привязаны к конкретным объектам (котельные/промплощадки), не к магистральным
+# ЦП, поэтому свой узкий радиус независимо от radius_m ЦП. LIMIT — топ-N по расстоянию,
+# чтобы слой на карте §3 не разбухал (у ~19% строк есть geom после геокода). #2119 B2.
+_GAS_OUTLET_RADIUS_M = 2000
+_GAS_OUTLET_LIMIT = 40
+
# Пометка для ПС АО «ЕЭСК» (городская сеть ЕКБ) без раскрытого резерва: свободная
# мощность у ЕЭСК не публикуется в WFS/xlsx — уточняется по запросу. #2119 B1.
_EESK_RESERVE_NOTE = "Резерв — по запросу в АО «ЕЭСК» (город)"
@@ -45,7 +58,7 @@ def get_connection_capacity(
Returns:
{"power_points": [...], "power_summary": {...}, "water": [...],
- "gas": {...}|None, "heat": {...}|None}
+ "gas": {...}|None, "gas_outlet_points": [...], "heat": {...}|None}
Raises:
ValueError: участок не найден в БД (нет geom).
@@ -63,6 +76,7 @@ def get_connection_capacity(
power_summary = _build_power_summary(power_points)
water = _query_water_latest(db)
gas = _query_gas_city_grs(db)
+ gas_outlet_points = _query_gas_outlet_points(db, parcel_wkt)
heat = _query_heat_latest(db)
return {
@@ -70,6 +84,7 @@ def get_connection_capacity(
"power_summary": power_summary,
"water": water,
"gas": gas,
+ "gas_outlet_points": gas_outlet_points,
"heat": heat,
}
@@ -211,8 +226,17 @@ def _query_gas_city_grs(db: Session) -> dict:
→ city_grs=[] (не падаем — блок аддитивный, gas_grs_capacity может ещё не
существовать до применения миграции 181).
- Returns: {"city_grs": [...], "total_free_th_m3_h": float|None}.
+ Дополнительно (#2119 B2 PR-4): счётчики точек выхода газосети из
+ gas_grs_outlet_points (форма 6 ФАС №960 АО «Екатеринбурггаз») за последний
+ period_month — outlets_total / outlets_deficit (free<0) / outlets_needs_calc.
+ См. _query_gas_outlet_counts для обоснования «по ВСЕМ строкам без разбивки по ГРС».
+
+ Returns: {"city_grs": [...], "total_free_th_m3_h": float|None,
+ "outlets_total": int, "outlets_deficit": int, "outlets_needs_calc": int}.
+ Размерности: свободная мощность ГРС — тыс. м³/ЧАС; свободная мощность точек выхода
+ (в счётчиках/gas_outlet_points) — млн. м³/МЕС. НЕ путать при агрегации.
"""
+ outlet_counts = _query_gas_outlet_counts(db)
try:
rows = (
db.execute(
@@ -235,9 +259,10 @@ def _query_gas_city_grs(db: Session) -> dict:
)
except Exception as e:
# Таблица может не существовать до применения миграции 181 (schema-first).
- # Блок аддитивный — деградируем в пустой, не роняя весь эндпоинт.
+ # Блок аддитивный — деградируем в пустой, не роняя весь эндпоинт. Счётчики
+ # точек выхода (миграция 184, независимая) — оставляем, что удалось собрать.
logger.warning("gas_grs_capacity недоступна (миграция 181 не применена?): %s", e)
- return {"city_grs": [], "total_free_th_m3_h": None}
+ return {"city_grs": [], "total_free_th_m3_h": None, **outlet_counts}
city_grs: list[dict] = []
total_free = 0.0
@@ -262,9 +287,135 @@ def _query_gas_city_grs(db: Session) -> dict:
return {
"city_grs": city_grs,
"total_free_th_m3_h": round(total_free, 3) if has_free else None,
+ **outlet_counts,
}
+def _query_gas_outlet_counts(db: Session) -> dict:
+ """Счётчики точек выхода газосети (gas_grs_outlet_points) за последний period_month.
+
+ По ВСЕМ строкам последнего среза БЕЗ разбивки по ГРС (обоснование): grs_name_norm в
+ форме 6 АО «Екатеринбурггаз» — это block-marker «Точка входа» (5 блоков ГТЕ вроде
+ «грс-1 свердловск»), НЕ строгий ключ городской связки gas_grs_capacity (ГТЕ, другой
+ источник). Матч outlet→ГРС по grs_name_norm без FK ненадёжен на per-набор уровне
+ (имена ГРС в двух раскрытиях расходятся) → честнее агрегировать счётчики ПО ГОРОДУ
+ целиком, чем молча терять строки на непопавшем join. Это city-level индикатор §3
+ «сколько точек выхода в дефиците / требуют гидрорасчёта», не per-ГРС.
+
+ Считаем за MAX(period_month) (свежайший помесячный срез); is_forecast не фильтруем —
+ в проде факт/прогноз разведены, но для счётчика важен объём точек последнего среза.
+
+ Returns: {"outlets_total": int, "outlets_deficit": int, "outlets_needs_calc": int}.
+ - outlets_deficit — free_capacity_mln_m3 < 0 (дефицит со знаком, млн.куб.м/МЕС);
+ - outlets_needs_calc — free_capacity_needs_calc = TRUE («0.000*», нужен гидрорасчёт).
+ Таблицы может не быть (миграция 184 не применена) → все нули (graceful, аддитивно).
+ """
+ try:
+ row = (
+ db.execute(
+ text("""
+ SELECT COUNT(*) AS outlets_total,
+ COUNT(*) FILTER (
+ WHERE free_capacity_mln_m3 < 0
+ ) AS outlets_deficit,
+ COUNT(*) FILTER (
+ WHERE free_capacity_needs_calc
+ ) AS outlets_needs_calc
+ FROM gas_grs_outlet_points
+ WHERE period_month = (
+ SELECT MAX(period_month) FROM gas_grs_outlet_points
+ )
+ """)
+ )
+ .mappings()
+ .first()
+ )
+ except Exception as e:
+ # Таблица может не существовать до применения миграции 184 (schema-first).
+ # Счётчики аддитивны — деградируем в нули, не роняя весь эндпоинт.
+ logger.warning("gas_grs_outlet_points недоступна (миграция 184 не применена?): %s", e)
+ return {"outlets_total": 0, "outlets_deficit": 0, "outlets_needs_calc": 0}
+
+ if row is None:
+ return {"outlets_total": 0, "outlets_deficit": 0, "outlets_needs_calc": 0}
+ return {
+ "outlets_total": int(row["outlets_total"] or 0),
+ "outlets_deficit": int(row["outlets_deficit"] or 0),
+ "outlets_needs_calc": int(row["outlets_needs_calc"] or 0),
+ }
+
+
+def _query_gas_outlet_points(db: Session, parcel_wkt: str) -> list[dict]:
+ """Гео-точки выхода газосети (gas_grs_outlet_points) В РАДИУСЕ от участка — слой §3.
+
+ ST_DWithin(geom::geography, центроид::geography, _GAS_OUTLET_RADIUS_M) за последний
+ period_month, ORDER BY distance ASC, LIMIT _GAS_OUTLET_LIMIT. geom есть лишь у ~19%
+ строк (геокод DaData, PR-3) → в радиус попадает малое подмножество. Размерность
+ free_capacity_mln_m3 — млн. м³/МЕС (НЕ тыс. м³/ч как у ГРС!). Таблицы может не быть
+ (миграция 184) → [] (graceful, блок аддитивный).
+
+ План (EXPLAIN прод 2026-07-03): Seq Scan ~13 мс — geography-каст НЕ использует
+ geometry-GIST; при 1037 строках это норм. Вырастет 10-50× → функциональный GIST
+ по (CAST(geom AS geography)) или ST_DWithin по geometry с градусным радиусом.
+
+ Returns: [{outlet_name, consumer_type, free_capacity_mln_m3, needs_calc,
+ distance_m, lat, lon}] — отсортировано по distance ASC.
+ """
+ try:
+ rows = (
+ db.execute(
+ text("""
+ SELECT outlet_name, consumer_type, free_capacity_mln_m3,
+ free_capacity_needs_calc AS needs_calc,
+ ST_Distance(
+ geom::geography,
+ ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
+ ) AS distance_m,
+ ST_Y(geom) AS lat,
+ ST_X(geom) AS lon
+ FROM gas_grs_outlet_points
+ WHERE geom IS NOT NULL
+ AND period_month = (
+ SELECT MAX(period_month) FROM gas_grs_outlet_points
+ )
+ AND ST_DWithin(
+ geom::geography,
+ ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
+ CAST(:radius_m AS float)
+ )
+ ORDER BY distance_m ASC
+ LIMIT CAST(:limit AS int)
+ """),
+ {
+ "wkt": parcel_wkt,
+ "radius_m": _GAS_OUTLET_RADIUS_M,
+ "limit": _GAS_OUTLET_LIMIT,
+ },
+ )
+ .mappings()
+ .all()
+ )
+ except Exception as e:
+ # Таблица может не существовать до применения миграции 184 (schema-first).
+ # Слой аддитивный — деградируем в пустой, не роняя весь эндпоинт.
+ logger.warning("gas_grs_outlet_points (радиус) недоступна (миграция 184?): %s", e)
+ return []
+
+ return [
+ {
+ "outlet_name": r["outlet_name"],
+ "consumer_type": r["consumer_type"],
+ # млн. м³/МЕС (НЕ тыс. м³/ч как у ГРС!); может быть <0 — дефицит со знаком.
+ "free_capacity_mln_m3": _num(r["free_capacity_mln_m3"]),
+ "needs_calc": bool(r["needs_calc"]),
+ "distance_m": round(float(r["distance_m"]), 1),
+ "lat": round(float(r["lat"]), 6) if r["lat"] is not None else None,
+ "lon": round(float(r["lon"]), 6) if r["lon"] is not None else None,
+ }
+ for r in rows
+ ]
+
+
def _query_heat_latest(db: Session) -> dict:
"""Резервы тепловой мощности систем за ПОСЛЕДНИЙ период ПО КАЖДОЙ организации.
diff --git a/backend/app/workers/beat_schedule.py b/backend/app/workers/beat_schedule.py
index 649e8964..958d2783 100644
--- a/backend/app/workers/beat_schedule.py
+++ b/backend/app/workers/beat_schedule.py
@@ -310,6 +310,20 @@ def build_beat_schedule() -> dict:
"schedule": _parse_cron("0 5 * * tue"), # 05:00 МСК, вторник
"options": {"queue": "celery"},
}
+ # Точки выхода ГРС АО «Екатеринбурггаз» (форма 6 ФАС №960 → gas_grs_outlet_points +
+ # догеокод адресов → geom). Forgejo #2119 Фаза B2, PR-4. Источник ГЕО-БЛОКИРУЕТ
+ # non-RU IP + DaData-ключи на проде → RUN-ON-PROD. Источник публикуется ~5-го числа
+ # месяца, НО beat не поддерживает «раз в месяц по факту публикации» надёжно (jitter
+ # даты) → еженедельно вторник, идемпотентно: помесячный UPSERT + geom сохраняется,
+ # не-новый период просто пере-UPSERT-ится без эффекта. 05:15 МСК — в газовом суб-
+ # кластере сразу после gas-grs (05:00, тот же apex-родственник ekgas/ГТЕ), до ЕЭСК
+ # (05:30); намеренно вне monday-нагрузки. Целимся ~05:30-окно спеки, но 05:30 занят
+ # ЕЭСК → 05:15 (не бьём оба газовых источника ekgas одновременно).
+ schedule["gas-outlet-points-sync-weekly"] = {
+ "task": "tasks.connection_capacity_sync.sync_gas_outlet_points",
+ "schedule": _parse_cron("15 5 * * tue"), # 05:15 МСК, вторник
+ "options": {"queue": "celery"},
+ }
# ЕЭСК-резервы (Forgejo #2119 Фаза B2): ПС 35-220 (МВт) + ТП/РП «ниже 35» из
# xlsx-раскрытия eesk.ru. Обычный SSL БЕЗ гео-блока, но держим в том же вторник-
# кластере рядом с rosseti/gas/vodokanal. 05:30 МСК — через 30 мин после газа
diff --git a/backend/app/workers/tasks/connection_capacity_sync.py b/backend/app/workers/tasks/connection_capacity_sync.py
index 92c1750b..336fed7e 100644
--- a/backend/app/workers/tasks/connection_capacity_sync.py
+++ b/backend/app/workers/tasks/connection_capacity_sync.py
@@ -145,6 +145,52 @@ def sync_eesk_reserves() -> dict:
raise
+@celery_app.task(
+ name="tasks.connection_capacity_sync.sync_gas_outlet_points",
+ queue="celery",
+)
+def sync_gas_outlet_points() -> dict:
+ """Точки выхода ГРС АО «Екатеринбурггаз» (форма 6 ФАС №960) → gas_grs_outlet_points,
+ затем догеокод новых адресных строк → geom. #2119 Фаза B2, PR-4.
+
+ RUN-ON-PROD (гео-блок источника ekgas.ru + DaData-ключи на проде). Два шага:
+ 1. load_gas_outlet_points() — свежайшая форма 6 из каталога → UPSERT (идемпотентно:
+ помесячный UPSERT по (grs_name_norm, outlet_name, period_month, is_forecast),
+ geom НЕ затирается в DO UPDATE);
+ 2. geocode_gas_outlets(apply=True) — новые адресные строки последнего period_month
+ с geom IS NULL догеокодятся через DaData (in-memory кэш + cap внутри лоадера;
+ уже геокоженные строки прошлых месяцев не трогаются).
+
+ Источник обновляется ~5-го числа месяца, но расписание еженедельное (вторник) —
+ не-новый период просто пере-UPSERT-ится без эффекта (geom сохраняется). Геокод
+ graceful: сбой шага 2 не откатывает загруженные строки (шаг 1 уже закоммичен своей
+ сессией). Каждый шаг логируется breadcrumb-ом в nspd_geo_log.
+ """
+ _log_breadcrumb("gas_outlet_sync", "info", "task started")
+ result: dict = {}
+ try:
+ from app.services.site_finder.ekgas_outlet_loader import load_gas_outlet_points
+
+ result["load"] = load_gas_outlet_points()
+ except Exception as e:
+ logger.exception("sync_gas_outlet_points: load failed: %s", e)
+ _log_breadcrumb("gas_outlet_sync", "error", f"load {type(e).__name__}: {e}")
+ result["load"] = {"error": str(e)}
+
+ try:
+ from app.services.site_finder.ekgas_outlet_loader import geocode_gas_outlets
+
+ result["geocode"] = geocode_gas_outlets(apply=True)
+ except Exception as e:
+ logger.exception("sync_gas_outlet_points: geocode failed: %s", e)
+ _log_breadcrumb("gas_outlet_sync", "error", f"geocode {type(e).__name__}: {e}")
+ result["geocode"] = {"error": str(e)}
+
+ logger.info("sync_gas_outlet_points done: %s", result)
+ _log_breadcrumb("gas_outlet_sync", "info", f"done: {result}")
+ return result
+
+
@celery_app.task(
name="tasks.connection_capacity_sync.sync_heat_reserves",
queue="celery",
diff --git a/backend/tests/api/v1/test_parcel_connection_capacity.py b/backend/tests/api/v1/test_parcel_connection_capacity.py
index 322907d0..766c2c50 100644
--- a/backend/tests/api/v1/test_parcel_connection_capacity.py
+++ b/backend/tests/api/v1/test_parcel_connection_capacity.py
@@ -59,10 +59,52 @@ def _heat_block(
return {"systems": systems, "total_reserve_gcal_h": total}
+def _gas_block(
+ outlets_total: int = 1037,
+ outlets_deficit: int = 218,
+ outlets_needs_calc: int = 144,
+) -> dict[str, Any]:
+ return {
+ "city_grs": [
+ {
+ "grs_name": "ГРС Свердловская",
+ "design_capacity_th_m3_h": 210.0,
+ "free_capacity_th_m3_h": 40.5,
+ "free_capacity_pct": 20.2,
+ "upgrade_due": "2028",
+ "outputs_count": 3,
+ }
+ ],
+ "total_free_th_m3_h": 40.5,
+ "outlets_total": outlets_total,
+ "outlets_deficit": outlets_deficit,
+ "outlets_needs_calc": outlets_needs_calc,
+ }
+
+
+def _gas_outlet_point(
+ outlet_name: str = "Котельная (Складской проезд,4а)",
+ free: float | None = 0.512,
+ needs_calc: bool = False,
+ distance_m: float = 145.3,
+) -> dict[str, Any]:
+ return {
+ "outlet_name": outlet_name,
+ "consumer_type": "котельная",
+ "free_capacity_mln_m3": free,
+ "needs_calc": needs_calc,
+ "distance_m": distance_m,
+ "lat": 56.812345,
+ "lon": 60.611111,
+ }
+
+
def _make_response(
power_points: list[dict[str, Any]] | None = None,
water: list[dict[str, Any]] | None = None,
heat: dict[str, Any] | None = None,
+ gas: dict[str, Any] | None = None,
+ gas_outlet_points: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
if power_points is None:
power_points = [_power_point()]
@@ -89,6 +131,10 @@ def _make_response(
"nearest_with_reserve": nearest,
},
"water": water,
+ "gas": gas if gas is not None else _gas_block(),
+ "gas_outlet_points": (
+ gas_outlet_points if gas_outlet_points is not None else [_gas_outlet_point()]
+ ),
"heat": heat if heat is not None else _heat_block(),
}
@@ -281,3 +327,67 @@ def test_connection_capacity_reserve_note_null_when_reserve_filled() -> None:
assert r.status_code == 200, r.text
assert r.json()["power_points"][0]["reserve_note"] is None
+
+
+# ── Phase B2 PR-4: газовые точки выхода (счётчики в gas + радиус-слой) ─────────
+
+
+def test_connection_capacity_gas_outlet_counters() -> None:
+ """gas-блок несёт счётчики точек выхода последнего среза (city-level). #2119 B2 PR-4."""
+ with patch(
+ "app.api.v1.parcels.get_connection_capacity",
+ return_value=_make_response(),
+ ):
+ client = TestClient(app)
+ r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity")
+
+ assert r.status_code == 200, r.text
+ gas = r.json()["gas"]
+ assert gas["outlets_total"] == 1037
+ assert gas["outlets_deficit"] == 218
+ assert gas["outlets_needs_calc"] == 144
+ # ГРС-часть B1 не сломана.
+ assert gas["total_free_th_m3_h"] == pytest.approx(40.5)
+
+
+def test_connection_capacity_gas_outlet_points_layer() -> None:
+ """gas_outlet_points — гео-слой точек выхода: дистанция/needs_calc/дефицит. #2119 B2 PR-4."""
+ pts = [
+ _gas_outlet_point(distance_m=145.3, free=0.512, needs_calc=False),
+ # дефицит со знаком.
+ _gas_outlet_point(
+ outlet_name="Промплощадка №2", distance_m=980.4, free=-0.084, needs_calc=False
+ ),
+ # «0.000*» → NULL мощность + needs_calc=True.
+ _gas_outlet_point(outlet_name="ТЭЦ узел", distance_m=1500.0, free=None, needs_calc=True),
+ ]
+ with patch(
+ "app.api.v1.parcels.get_connection_capacity",
+ return_value=_make_response(gas_outlet_points=pts),
+ ):
+ client = TestClient(app)
+ r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity")
+
+ assert r.status_code == 200, r.text
+ outlets = r.json()["gas_outlet_points"]
+ assert len(outlets) == 3
+ assert outlets[0]["distance_m"] == pytest.approx(145.3)
+ assert outlets[0]["free_capacity_mln_m3"] == pytest.approx(0.512)
+ assert outlets[1]["free_capacity_mln_m3"] == pytest.approx(-0.084) # дефицит-знак
+ assert outlets[2]["free_capacity_mln_m3"] is None
+ assert outlets[2]["needs_calc"] is True
+
+
+def test_connection_capacity_gas_outlet_points_empty_default() -> None:
+ """gas_outlet_points пуст (нет geom в радиусе) → []; ключ может отсутствовать → []."""
+ resp = _make_response(gas_outlet_points=[])
+ del resp["gas_outlet_points"] # сервис может не собрать ключ (миграция 184 не применена)
+ with patch(
+ "app.api.v1.parcels.get_connection_capacity",
+ return_value=resp,
+ ):
+ client = TestClient(app)
+ r = client.get(f"/api/v1/parcels/{_VALID_CAD}/connection-capacity")
+
+ assert r.status_code == 200, r.text
+ assert r.json()["gas_outlet_points"] == []
diff --git a/backend/tests/test_gas_grs_loader.py b/backend/tests/test_gas_grs_loader.py
index 934288ec..622343cc 100644
--- a/backend/tests/test_gas_grs_loader.py
+++ b/backend/tests/test_gas_grs_loader.py
@@ -239,20 +239,32 @@ class _FakeResult:
def all(self) -> list[dict]:
return self._mappings_rows
+ def first(self) -> dict | None:
+ return self._mappings_rows[0] if self._mappings_rows else None
+
class _FakeSession:
- """Мини-заглушка Session: execute + begin_nested (как в loaders-тестах)."""
+ """Мини-заглушка Session: execute + begin_nested (как в loaders-тестах).
+
+ Один набор строк на все execute (mappings_rows) ИЛИ скрипт по вызовам
+ (results_script: список наборов строк, отдаётся по порядку execute-вызовов; после
+ исчерпания — последний). results_script нужен для функций с НЕСКОЛЬКИМИ разными
+ SELECT (напр. _query_gas_city_grs: счётчики точек выхода + агрегация ГРС).
+ """
def __init__(
self,
scalar_value: Any = True,
mappings_rows: list[dict] | None = None,
execute_raises: Exception | None = None,
+ results_script: list[list[dict]] | None = None,
) -> None:
self.calls: list[tuple[str, Any]] = []
self._scalar = scalar_value
self._mappings_rows = mappings_rows
self._execute_raises = execute_raises
+ self._results_script = results_script
+ self._call_idx = 0
self.committed = False
@contextmanager
@@ -263,6 +275,11 @@ class _FakeSession:
self.calls.append((str(stmt), params))
if self._execute_raises is not None:
raise self._execute_raises
+ if self._results_script is not None:
+ idx = min(self._call_idx, len(self._results_script) - 1)
+ rows = self._results_script[idx]
+ self._call_idx += 1
+ return _FakeResult(self._scalar, rows)
return _FakeResult(self._scalar, self._mappings_rows)
def commit(self) -> None:
@@ -291,6 +308,11 @@ def test_upsert_grs_rows_counts_and_savepoint() -> None:
# ── _query_gas_city_grs: агрегация ────────────────────────────────────────────
+# Счётчики точек выхода (первый execute в _query_gas_city_grs) — нейтральный дефолт для
+# тестов B1-агрегации, которым важна только ГРС-часть (второй execute).
+_OUTLET_COUNTS_ROW = {"outlets_total": 0, "outlets_deficit": 0, "outlets_needs_calc": 0}
+
+
def test_query_gas_city_grs_aggregation() -> None:
"""Агрегированные строки (per grs_name) маппятся в GasGrsRow-словари + total."""
rows = [
@@ -311,7 +333,8 @@ def test_query_gas_city_grs_aggregation() -> None:
"outputs_count": 1,
},
]
- db = _FakeSession(mappings_rows=rows)
+ # execute #1 = счётчики точек выхода, #2 = агрегация ГРС.
+ db = _FakeSession(results_script=[[_OUTLET_COUNTS_ROW], rows])
out = ccl._query_gas_city_grs(db) # type: ignore[arg-type]
assert len(out["city_grs"]) == 2
assert out["city_grs"][0]["grs_name"] == "ГРС Свердловская"
@@ -319,19 +342,144 @@ def test_query_gas_city_grs_aggregation() -> None:
assert out["city_grs"][0]["free_capacity_pct"] == 20.2 # round(20.25, 1)
assert out["city_grs"][0]["outputs_count"] == 3
assert out["total_free_th_m3_h"] == 50.5 # 40.5 + 10.0
+ # Счётчики точек выхода вплетены в тот же gas-блок (аддитивно).
+ assert out["outlets_total"] == 0
+ assert out["outlets_deficit"] == 0
+ assert out["outlets_needs_calc"] == 0
def test_query_gas_city_grs_empty_table() -> None:
- db = _FakeSession(mappings_rows=[])
+ db = _FakeSession(results_script=[[_OUTLET_COUNTS_ROW], []])
out = ccl._query_gas_city_grs(db) # type: ignore[arg-type]
- assert out == {"city_grs": [], "total_free_th_m3_h": None}
+ assert out == {
+ "city_grs": [],
+ "total_free_th_m3_h": None,
+ "outlets_total": 0,
+ "outlets_deficit": 0,
+ "outlets_needs_calc": 0,
+ }
def test_query_gas_city_grs_missing_table_degrades() -> None:
- """Таблица не существует (миграция 181 не применена) → пустой блок, не падаем."""
+ """Таблица gas_grs_capacity не существует (миграция 181) → пустой блок, не падаем.
+
+ _query_gas_outlet_counts (первый execute) тоже упадёт на той же raises-заглушке →
+ счётчики деградируют в нули, блок цел.
+ """
db = _FakeSession(execute_raises=RuntimeError('relation "gas_grs_capacity" does not exist'))
out = ccl._query_gas_city_grs(db) # type: ignore[arg-type]
- assert out == {"city_grs": [], "total_free_th_m3_h": None}
+ assert out == {
+ "city_grs": [],
+ "total_free_th_m3_h": None,
+ "outlets_total": 0,
+ "outlets_deficit": 0,
+ "outlets_needs_calc": 0,
+ }
+
+
+# ── _query_gas_outlet_counts: счётчики точек выхода (city-level) ───────────────
+
+
+def test_query_gas_outlet_counts_flags() -> None:
+ """COUNT + FILTER (дефицит / needs_calc) маппятся в счётчики (по одной row-агрегату)."""
+ row = {"outlets_total": 1037, "outlets_deficit": 218, "outlets_needs_calc": 144}
+ db = _FakeSession(mappings_rows=[row])
+ out = ccl._query_gas_outlet_counts(db) # type: ignore[arg-type]
+ assert out == {"outlets_total": 1037, "outlets_deficit": 218, "outlets_needs_calc": 144}
+
+
+def test_query_gas_outlet_counts_null_row_zeroes() -> None:
+ """Пустая таблица (агрегат вернул NULL/нет строки) → все нули."""
+ db = _FakeSession(mappings_rows=[]) # .first() → None
+ out = ccl._query_gas_outlet_counts(db) # type: ignore[arg-type]
+ assert out == {"outlets_total": 0, "outlets_deficit": 0, "outlets_needs_calc": 0}
+
+
+def test_query_gas_outlet_counts_missing_table_degrades() -> None:
+ """Таблица не существует (миграция 184 не применена) → нули, не падаем."""
+ db = _FakeSession(
+ execute_raises=RuntimeError('relation "gas_grs_outlet_points" does not exist')
+ )
+ out = ccl._query_gas_outlet_counts(db) # type: ignore[arg-type]
+ assert out == {"outlets_total": 0, "outlets_deficit": 0, "outlets_needs_calc": 0}
+
+
+# ── _query_gas_outlet_points: гео-точки выхода в радиусе (слой карты §3) ───────
+
+
+def test_query_gas_outlet_points_maps_and_flags() -> None:
+ """Строки радиус-запроса → точки: дистанция округлена, needs_calc/дефицит, lat/lon."""
+ rows = [
+ {
+ "outlet_name": "Котельная (г.Екатеринбург, Складской проезд,4а)",
+ "consumer_type": "котельная",
+ "free_capacity_mln_m3": 0.512,
+ "needs_calc": False,
+ "distance_m": 145.27,
+ "lat": 56.812345,
+ "lon": 60.611111,
+ },
+ {
+ # Дефицит со знаком сохраняется; needs_calc отдельным флагом.
+ "outlet_name": "Промплощадка №2",
+ "consumer_type": None,
+ "free_capacity_mln_m3": -0.084,
+ "needs_calc": False,
+ "distance_m": 980.4,
+ "lat": 56.80,
+ "lon": 60.60,
+ },
+ {
+ # «0.000*» → free_capacity_mln_m3=NULL, needs_calc=True.
+ "outlet_name": "ТЭЦ узел",
+ "consumer_type": "ТЭЦ",
+ "free_capacity_mln_m3": None,
+ "needs_calc": True,
+ "distance_m": 1500.0,
+ "lat": None,
+ "lon": None,
+ },
+ ]
+ db = _FakeSession(mappings_rows=rows)
+ out = ccl._query_gas_outlet_points(db, "POINT(60.6 56.81)") # type: ignore[arg-type]
+ assert len(out) == 3
+ # Порядок сохранён как пришёл из SQL (ORDER BY distance ASC) — ближайший первым.
+ assert out[0]["distance_m"] == 145.3 # round(145.27, 1)
+ assert out[0]["free_capacity_mln_m3"] == 0.512
+ assert out[0]["needs_calc"] is False
+ assert out[0]["lat"] == 56.812345
+ # дефицит сохраняет знак.
+ assert out[1]["free_capacity_mln_m3"] == -0.084
+ # «0.000*»: NULL мощность + needs_calc=True; координаты None → пробрасываются null.
+ assert out[2]["free_capacity_mln_m3"] is None
+ assert out[2]["needs_calc"] is True
+ assert out[2]["lat"] is None and out[2]["lon"] is None
+
+
+def test_query_gas_outlet_points_passes_radius_and_limit() -> None:
+ """Радиус-запрос параметризован _GAS_OUTLET_RADIUS_M и _GAS_OUTLET_LIMIT."""
+ db = _FakeSession(mappings_rows=[])
+ ccl._query_gas_outlet_points(db, "POINT(60.6 56.81)") # type: ignore[arg-type]
+ assert db.calls, "ожидался хотя бы один execute"
+ params = db.calls[0][1]
+ assert params["radius_m"] == ccl._GAS_OUTLET_RADIUS_M
+ assert params["limit"] == ccl._GAS_OUTLET_LIMIT
+ assert params["wkt"] == "POINT(60.6 56.81)"
+
+
+def test_query_gas_outlet_points_empty() -> None:
+ db = _FakeSession(mappings_rows=[])
+ out = ccl._query_gas_outlet_points(db, "POINT(60.6 56.81)") # type: ignore[arg-type]
+ assert out == []
+
+
+def test_query_gas_outlet_points_missing_table_degrades() -> None:
+ """Таблица не существует (миграция 184 не применена) → [], не падаем."""
+ db = _FakeSession(
+ execute_raises=RuntimeError('relation "gas_grs_outlet_points" does not exist')
+ )
+ out = ccl._query_gas_outlet_points(db, "POINT(60.6 56.81)") # type: ignore[arg-type]
+ assert out == []
# ── _query_heat_latest: per-org период + суммарный резерв + graceful ───────────
diff --git a/backend/tests/workers/tasks/test_gas_outlet_sync.py b/backend/tests/workers/tasks/test_gas_outlet_sync.py
new file mode 100644
index 00000000..4cd5c056
--- /dev/null
+++ b/backend/tests/workers/tasks/test_gas_outlet_sync.py
@@ -0,0 +1,106 @@
+"""Smoke-тесты Celery-обвязки точек выхода газосети (#2119 Фаза B2, PR-4).
+
+- sync_gas_outlet_points зарегистрирована (module в include-списке celery_app);
+- beat содержит gas-outlet-points-sync-weekly (еженедельно, вторник);
+- таск дважды дёргает лоадер: load_gas_outlet_points → geocode_gas_outlets(apply=True),
+ каждый шаг graceful (сбой одного не роняет второй) — mock без сети/БД.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import patch
+
+
+def test_task_registered_in_celery_include() -> None:
+ """Модуль connection_capacity_sync в include-списке celery_app."""
+ from app.workers.celery_app import celery_app
+
+ assert "app.workers.tasks.connection_capacity_sync" in celery_app.conf.include
+
+
+def test_beat_schedule_includes_gas_outlet_sync_weekly_tuesday() -> None:
+ """gas-outlet-points-sync-weekly зарегистрирована еженедельно во вторник."""
+ from app.workers import beat_schedule
+
+ # build_beat_schedule читает DB → мокаем _build_beat_schedule_from_db,
+ # чтобы тест не зависел от живого Postgres.
+ with patch.object(beat_schedule, "_build_beat_schedule_from_db", return_value={}):
+ schedule = beat_schedule.build_beat_schedule()
+
+ assert (
+ "gas-outlet-points-sync-weekly" in schedule
+ ), f"gas-outlet-points-sync-weekly отсутствует в beat: {sorted(schedule.keys())}"
+ entry = schedule["gas-outlet-points-sync-weekly"]
+ assert entry["task"] == "tasks.connection_capacity_sync.sync_gas_outlet_points"
+ # crontab day_of_week = вторник (2).
+ assert 2 in entry["schedule"].day_of_week
+
+
+def test_sync_gas_outlet_points_load_then_geocode() -> None:
+ """Таск: load_gas_outlet_points → geocode_gas_outlets(apply=True), оба результата в ответе."""
+ from app.workers.tasks import connection_capacity_sync as ccs
+
+ load_result = {"rows": 1037, "inserted": 10, "updated": 1027}
+ geo_result = {"candidates": 5, "geocoded": 3, "applied": True}
+ with (
+ patch(
+ "app.services.site_finder.ekgas_outlet_loader.load_gas_outlet_points",
+ return_value=load_result,
+ ),
+ patch(
+ "app.services.site_finder.ekgas_outlet_loader.geocode_gas_outlets",
+ return_value=geo_result,
+ ) as mock_geo,
+ patch.object(ccs, "_log_breadcrumb"),
+ ):
+ out = ccs.sync_gas_outlet_points()
+
+ assert out["load"] == load_result
+ assert out["geocode"] == geo_result
+ # геокод вызван С apply=True (реальная запись geom на проде).
+ assert mock_geo.call_args.kwargs.get("apply") is True
+
+
+def test_sync_gas_outlet_points_geocode_failure_graceful() -> None:
+ """Сбой geocode-шага не роняет таск: load-результат сохраняется, geocode → error-словарь."""
+ from app.workers.tasks import connection_capacity_sync as ccs
+
+ load_result = {"rows": 1037, "inserted": 0, "updated": 1037}
+ with (
+ patch(
+ "app.services.site_finder.ekgas_outlet_loader.load_gas_outlet_points",
+ return_value=load_result,
+ ),
+ patch(
+ "app.services.site_finder.ekgas_outlet_loader.geocode_gas_outlets",
+ side_effect=RuntimeError("DaData quota exhausted"),
+ ),
+ patch.object(ccs, "_log_breadcrumb"),
+ ):
+ out = ccs.sync_gas_outlet_points()
+
+ assert out["load"] == load_result # шаг 1 не откатывается сбоем шага 2
+ assert "error" in out["geocode"]
+ assert "DaData" in out["geocode"]["error"]
+
+
+def test_sync_gas_outlet_points_load_failure_still_attempts_geocode() -> None:
+ """Сбой load-шага не мешает попытке geocode (шаги независимы, каждый graceful)."""
+ from app.workers.tasks import connection_capacity_sync as ccs
+
+ geo_result = {"candidates": 0, "geocoded": 0, "applied": True}
+ with (
+ patch(
+ "app.services.site_finder.ekgas_outlet_loader.load_gas_outlet_points",
+ side_effect=RuntimeError("ekgas catalog 503"),
+ ),
+ patch(
+ "app.services.site_finder.ekgas_outlet_loader.geocode_gas_outlets",
+ return_value=geo_result,
+ ),
+ patch.object(ccs, "_log_breadcrumb"),
+ ):
+ out = ccs.sync_gas_outlet_points()
+
+ assert "error" in out["load"]
+ assert out["geocode"] == geo_result
diff --git a/frontend/src/components/site-finder/GasOutletPointsLayer.tsx b/frontend/src/components/site-finder/GasOutletPointsLayer.tsx
new file mode 100644
index 00000000..168c6621
--- /dev/null
+++ b/frontend/src/components/site-finder/GasOutletPointsLayer.tsx
@@ -0,0 +1,163 @@
+"use client";
+
+/**
+ * GasOutletPointsLayer — точки выхода газосети (форма 6 АО «Екатеринбурггаз»,
+ * ФАС №960) в радиусе от участка для §3 «Сети» (#2119 B2 PR-4). Отдельный слой
+ * поверх карты: CircleMarker per gas_outlet_point, цвет по ЗНАКУ свободной
+ * мощности (профицит → --success, дефицит → --danger, needs_calc → --warn).
+ *
+ * Меньший радиус (7) чем у центров питания Россетей (9) — точки выхода это
+ * низовой слой газосети, визуально второстепенный относительно ЦП. Свободная
+ * мощность здесь в млн м³/МЕС (НЕ тыс. м³/ч как у ГРС) — единица зашита в
+ * formatMlnM3Month, чтобы не перепутать.
+ *
+ * Только ~19% строк источника геокодированы → список часто пуст (это норма,
+ * слой не рисуется). Цвета/подписи/приведение чисел — из общего модуля
+ * connection-capacity.ts. Inline token-hex в popup — документированное
+ * исключение raw-Leaflet попапов (ui-tokens.md).
+ */
+
+import { CircleMarker, LayerGroup, Popup } from "react-leaflet";
+
+import type { GasOutletPoint } from "@/hooks/useConnectionCapacity";
+import {
+ classifyGasOutlet,
+ formatMeters,
+ formatMlnM3Month,
+ gasOutletColor,
+ toFiniteNumber,
+} from "@/components/site-finder/connection-capacity";
+
+// ── Point coordinate guard ─────────────────────────────────────────────────────
+// lat/lon могут прийти строкой (Decimal→str) — приводим через toFiniteNumber, а
+// не typeof-гвардом (иначе валидная точка молча не рисуется).
+function pointLatLon(p: GasOutletPoint): [number, number] | null {
+ const lat = toFiniteNumber(p.lat);
+ const lon = toFiniteNumber(p.lon);
+ if (lat === null || lon === null) return null;
+ return [lat, lon];
+}
+
+// ── Popup ──────────────────────────────────────────────────────────────────────
+
+function GasOutletPopup({ point }: { point: GasOutletPoint }) {
+ const state = classifyGasOutlet(point);
+ const color = gasOutletColor(point);
+ const consumer = point.consumer_type?.trim() || null;
+ const free = formatMlnM3Month(point.free_capacity_mln_m3);
+ const isDeficit = state === "deficit";
+ const distance = formatMeters(point.distance_m);
+
+ return (
+
+ Точки выхода газосети (город): {total.toLocaleString("ru-RU")} · дефицит{" "} + {deficit.toLocaleString("ru-RU")} · требуют гидрорасчёта{" "} + {needsCalc.toLocaleString("ru-RU")} + + ); +} + function GasBlock({ gas }: { gas: GasCapacityBlock | null | undefined }) { - if (!gas || gas.city_grs.length === 0) { + // Нет ни ГРС-строк, ни счётчиков точек выхода → данные ещё грузятся. + if (!gas || (gas.city_grs.length === 0 && (gas.outlets_total ?? 0) === 0)) { return (