feat(site-finder): Celery + §3 точки выхода газосети (#2119 B2, PR-4/4)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m46s
CI / openapi-codegen-check (pull_request) Successful in 2m38s
CI / backend-tests (pull_request) Successful in 14m58s

This commit is contained in:
bot-backend 2026-07-03 09:10:24 +05:00
parent 55b13e2b13
commit e63cd8ac10
15 changed files with 1004 additions and 14 deletions

View file

@ -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"),
)

View file

@ -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

View file

@ -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:
"""Резервы тепловой мощности систем за ПОСЛЕДНИЙ период ПО КАЖДОЙ организации.

View file

@ -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 мин после газа

View file

@ -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",

View file

@ -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"] == []

View file

@ -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 ───────────

View file

@ -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

View file

@ -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 (
<Popup>
<div style={{ fontSize: 12, lineHeight: 1.55, minWidth: 200 }}>
<div
style={{
fontWeight: 700,
marginBottom: 4,
display: "flex",
alignItems: "center",
gap: 5,
}}
>
<span
aria-hidden
style={{
display: "inline-block",
width: 10,
height: 10,
borderRadius: "50%",
background: color,
flexShrink: 0,
}}
/>
Точка выхода газосети
</div>
<div style={{ marginBottom: 4 }}>
<strong>{point.outlet_name}</strong>
</div>
{consumer && (
<div style={{ color: "#374151", marginBottom: 2 }}>
Тип потребителя: {consumer}
</div>
)}
{state === "calc" ? (
<div style={{ color: "#9A6700", marginBottom: 4 }}>
Свободная мощность: требует гидравлического расчёта
</div>
) : (
free && (
<div
style={{
color: isDeficit ? "#b91c1c" : "#374151",
marginBottom: 4,
}}
>
{isDeficit ? "Дефицит" : "Свободная мощность"}:{" "}
<strong style={{ color: isDeficit ? "#b91c1c" : "#111827" }}>
{free}
</strong>
</div>
)
)}
{distance && (
<div style={{ marginTop: 4, color: "#374151" }}>
До участка: {distance}
</div>
)}
<div
style={{
marginTop: 4,
fontSize: 11,
color: "#9ca3af",
borderTop: "1px solid #f3f4f6",
paddingTop: 4,
}}
>
Форма 6 АО «Екатеринбурггаз» · млн м³/мес
</div>
</div>
</Popup>
);
}
// ── Layer ──────────────────────────────────────────────────────────────────────
interface Props {
points: GasOutletPoint[];
}
export function GasOutletPointsLayer({ points }: Props) {
return (
<LayerGroup>
{points.map((point, idx) => {
const latLon = pointLatLon(point);
if (!latLon) return null;
const color = gasOutletColor(point);
return (
<CircleMarker
key={`gas-outlet-${point.outlet_name}-${idx}`}
center={latLon}
radius={7}
pathOptions={{
color: "#ffffff",
weight: 2,
fillColor: color,
fillOpacity: 0.85,
}}
>
<GasOutletPopup point={point} />
</CircleMarker>
);
})}
</LayerGroup>
);
}
// Число рисуемых точек (с валидными координатами) — для легенды/чекбокса.
export function countMappableGasOutlets(
points: GasOutletPoint[] | undefined,
): number {
if (!points) return 0;
return points.filter((p) => pointLatLon(p) !== null).length;
}

View file

@ -57,10 +57,19 @@ import {
countMappablePowerPoints,
} from "@/components/site-finder/PowerConnectionPointsLayer";
import {
GasOutletPointsLayer,
countMappableGasOutlets,
} from "@/components/site-finder/GasOutletPointsLayer";
import {
GAS_OUTLET_MARKER_COLOR,
LOAD_INDEX_MARKER_COLOR,
LOAD_INDEX_NULL_COLOR,
pluralizeOutletPoints,
} from "@/components/site-finder/connection-capacity";
import type { PowerConnectionPoint } from "@/hooks/useConnectionCapacity";
import type {
GasOutletPoint,
PowerConnectionPoint,
} from "@/hooks/useConnectionCapacity";
import { CustomPoiLayer } from "@/components/site-finder/CustomPoiLayer";
import { CustomPoiToggleButton } from "@/components/site-finder/CustomPoiToggleButton";
import { CustomPoiAddModal } from "@/components/site-finder/CustomPoiAddModal";
@ -155,6 +164,9 @@ interface Props {
// Центры питания Россетей со свободной мощностью для ТП (#connection-capacity).
// Отдельный слой (не OSM): цвет по индексу загрузки.
powerConnectionPoints?: PowerConnectionPoint[];
// Точки выхода газосети (форма 6 Екатеринбурггаза) в радиусе от участка
// (#2119 B2 PR-4). Отдельный слой: цвет по знаку свободной мощности.
gasOutletPoints?: GasOutletPoint[];
customPois?: CustomPoi[];
parcelCad?: string;
// #999 (958-B4) — рыночные слои (опциональны: SiteMap используется и без них).
@ -201,6 +213,7 @@ export function SiteMap({
connectionPoints,
utilityInfrastructure,
powerConnectionPoints,
gasOutletPoints,
customPois,
parcelCad,
competitors,
@ -294,6 +307,11 @@ export function SiteMap({
);
}
// Точки выхода газосети (#2119 B2 PR-4) — единичный слой, тумблер-чекбокс
// под картой. По умолчанию виден (как ЦП Россетей): точек мало (LIMIT 40,
// только геокодированные), карту не зашумляет.
const [showGasOutlets, setShowGasOutlets] = useState(true);
// Custom POI add mode state
const [addMode, setAddMode] = useState(false);
const [pendingCoords, setPendingCoords] = useState<{
@ -416,6 +434,12 @@ export function SiteMap({
const powerPointMappable = countMappablePowerPoints(powerPointList);
const hasPowerPoints = powerPointMappable > 0;
// Точки выхода газосети (#2119 B2 PR-4). Только ~19% строк источника
// геокодированы → список часто пуст (норма). Считаем рисуемые точки.
const gasOutletList = gasOutletPoints ?? [];
const gasOutletMappable = countMappableGasOutlets(gasOutletList);
const hasGasOutlets = gasOutletMappable > 0;
return (
<div>
{/* Map */}
@ -644,6 +668,13 @@ export function SiteMap({
<PowerConnectionPointsLayer points={powerPointList} />
)}
{/* Точки выхода газосети (форма 6 Екатеринбурггаза, #2119 B2 PR-4).
Отдельный слой поверх ЦП: цвет по знаку свободной мощности,
меньший радиус (низовой слой газосети). Тумблер-чекбокс под картой. */}
{hasGasOutlets && showGasOutlets && (
<GasOutletPointsLayer points={gasOutletList} />
)}
{/* Custom POI markers — topmost layer */}
{customPois && customPois.length > 0 && (
<CustomPoiLayer
@ -815,6 +846,68 @@ export function SiteMap({
</div>
)}
{/* Точки выхода газосети (#2119 B2 PR-4). Чекбокс-тумблер + легенда цветов
по знаку свободной мощности. Показываем только при непустом списке
рисуемых точек (LIMIT 40, ~19% геокодировано). */}
{hasGasOutlets && (
<div
style={{
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: "6px 12px",
padding: "10px 4px 0",
fontSize: 12,
color: "#374151",
}}
>
<label
style={{
display: "flex",
alignItems: "center",
gap: 6,
fontWeight: 600,
cursor: "pointer",
}}
>
<input
type="checkbox"
checked={showGasOutlets}
onChange={(e) => setShowGasOutlets(e.target.checked)}
/>
Точки выхода газа ({pluralizeOutletPoints(gasOutletMappable)})
</label>
{(
[
["profit", "профицит"],
["deficit", "дефицит"],
["calc", "нужен гидрорасчёт"],
] as const
).map(([state, label]) => (
<span
key={state}
style={{ display: "flex", alignItems: "center", gap: 5 }}
>
<span
style={{
width: 12,
height: 12,
borderRadius: "50%",
background: GAS_OUTLET_MARKER_COLOR[state],
border: "2px solid #ffffff",
boxShadow: "0 0 0 1px #d1d5db",
flexShrink: 0,
}}
/>
{label}
</span>
))}
<span style={{ color: "#9ca3af" }}>
Форма 6 АО «Екатеринбурггаз» · млн м³/мес
</span>
</div>
)}
{!data.geom_geojson && (
<p
style={{

View file

@ -330,8 +330,31 @@ function GasGrsLine({ row }: { row: GasGrsRow }) {
);
}
// Строка-сводка точек выхода газосети по городу (форма 6 Екатеринбурггаза,
// последний срез). Отдельная единица (млн м³/мес) от ГРС (тыс. м³/ч) — поэтому
// caveat про источник/единицу; счётчики city-level, не в радиусе участка.
function GasOutletsSummaryLine({ gas }: { gas: GasCapacityBlock }) {
const total = gas.outlets_total ?? 0;
if (total === 0) return null;
const deficit = gas.outlets_deficit ?? 0;
const needsCalc = gas.outlets_needs_calc ?? 0;
return (
<span
title="Форма 6 АО «Екатеринбурггаз» (ФАС №960), млн м³/мес за последний месяц"
style={{ fontSize: 12, color: "var(--fg-secondary)", cursor: "help" }}
>
Точки выхода газосети (город): {total.toLocaleString("ru-RU")} · дефицит{" "}
{deficit.toLocaleString("ru-RU")} · требуют гидрорасчёта{" "}
{needsCalc.toLocaleString("ru-RU")}
</span>
);
}
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 (
<div style={{ fontSize: 13, color: "var(--fg-secondary)" }}>
Данные о загрузке ГРС загружаются.
@ -354,6 +377,8 @@ function GasBlock({ gas }: { gas: GasCapacityBlock | null | undefined }) {
</span>
)}
<GasOutletsSummaryLine gas={gas} />
<span style={{ fontSize: 12, color: "var(--fg-tertiary)" }}>
Газпром трансгаз Екатеринбург · техвозможность ГРС
</span>

View file

@ -581,7 +581,12 @@ function ConnectionPointsMap({
!!utilityInfrastructure && utilityInfrastructure.features.length > 0;
const powerPoints = connectionCapacity?.power_points ?? [];
const hasPowerPoints = powerPoints.length > 0;
const hasAnyLayer = hasPoints || hasUtility || hasPowerPoints;
// Точки выхода газосети (#2119 B2 PR-4) — геокодированы только ~19% строк,
// список часто пуст (это норма).
const gasOutletPoints = connectionCapacity?.gas_outlet_points ?? [];
const hasGasOutlets = gasOutletPoints.length > 0;
const hasAnyLayer =
hasPoints || hasUtility || hasPowerPoints || hasGasOutlets;
return (
<div style={{ marginTop: 16 }}>
@ -648,6 +653,7 @@ function ConnectionPointsMap({
connectionPoints={hasPoints ? connectionPoints : undefined}
utilityInfrastructure={hasUtility ? utilityInfrastructure : undefined}
powerConnectionPoints={hasPowerPoints ? powerPoints : undefined}
gasOutletPoints={hasGasOutlets ? gasOutletPoints : undefined}
mapHeight={CP_MAP_HEIGHT}
/>
) : (

View file

@ -7,6 +7,7 @@
*/
import type {
GasOutletPoint,
PowerConnectionPoint,
WaterCapacityRow,
} from "@/hooks/useConnectionCapacity";
@ -142,6 +143,21 @@ export function formatThousandM3Hour(
})} тыс. м³/ч`;
}
// млн. м³/мес (свободная мощность точки выхода газосети, форма 6 Екатеринбурггаза —
// ВНИМАНИЕ: единица отличается от ГРС, где тыс. м³/ч). Может быть ОТРИЦАТЕЛЬНОЙ
// (дефицит со знаком) — знак сохраняем. Три знака после запятой: источник даёт
// «0.000». Приводит строку→число (Decimal→str).
export function formatMlnM3Month(
v: number | string | null | undefined,
): string | null {
const n = toFiniteNumber(v);
if (n === null) return null;
return `${n.toLocaleString("ru-RU", {
minimumFractionDigits: 3,
maximumFractionDigits: 3,
})} млн м³/мес`;
}
// Гкал/ч (резерв тепловой мощности системы теплоснабжения). Может быть
// ОТРИЦАТЕЛЬНЫМ (система исчерпана) — знак сохраняем. Приводит строку→число.
export function formatGcalHour(
@ -197,3 +213,42 @@ export function powerPointReserveIsNegative(p: PowerConnectionPoint): boolean {
const n = toFiniteNumber(p.reserve_mva);
return n !== null && n <= 0;
}
// ── Точки выхода газосети (форма 6 Екатеринбурггаза) ───────────────────────────
//
// Свободная мощность точки выхода классифицируется по ЗНАКУ:
// profit — free_capacity_mln_m3 > 0 (профицит), зелёный;
// deficit — < 0 (дефицит со знаком), красный;
// calc — needs_calc=true / null (нужен гидравлический расчёт), жёлтый.
// Маркеры карты используют hex токенов --success/--danger/--warn (документированное
// исключение raw-Leaflet попапов, ui-tokens.md), карточка — CSS-переменные напрямую.
export type GasOutletState = "profit" | "deficit" | "calc";
// Hex-зеркала токенов (--success/--danger/--warn) для CircleMarker fillColor.
export const GAS_OUTLET_MARKER_COLOR: Record<GasOutletState, string> = {
profit: "#0A7A3A", // --success
deficit: "#B3261E", // --danger
calc: "#9A6700", // --warn
};
export function classifyGasOutlet(p: GasOutletPoint): GasOutletState {
if (p.needs_calc) return "calc";
const n = toFiniteNumber(p.free_capacity_mln_m3);
if (n === null) return "calc";
return n < 0 ? "deficit" : "profit";
}
export function gasOutletColor(p: GasOutletPoint): string {
return GAS_OUTLET_MARKER_COLOR[classifyGasOutlet(p)];
}
// Множественное число «точка» для «Точки выхода газа (N)» / счётчиков.
export function pluralizeOutletPoints(n: number): string {
const mod10 = n % 10;
const mod100 = n % 100;
if (mod10 === 1 && mod100 !== 11) return `${n} точка`;
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20))
return `${n} точки`;
return `${n} точек`;
}

View file

@ -15,6 +15,7 @@ export type PowerCapacitySummary =
export type WaterCapacityRow = components["schemas"]["WaterCapacityRow"];
export type GasCapacityBlock = components["schemas"]["GasCapacityBlock"];
export type GasGrsRow = components["schemas"]["GasGrsRow"];
export type GasOutletPoint = components["schemas"]["GasOutletPoint"];
export type HeatCapacityBlock = components["schemas"]["HeatCapacityBlock"];
export type HeatSystemRow = components["schemas"]["HeatSystemRow"];

View file

@ -3527,6 +3527,11 @@ export interface components {
/** Water */
water: components["schemas"]["WaterCapacityRow"][];
gas?: components["schemas"]["GasCapacityBlock"] | null;
/**
* Gas Outlet Points
* @default []
*/
gas_outlet_points: components["schemas"]["GasOutletPoint"][];
heat?: components["schemas"]["HeatCapacityBlock"] | null;
};
/** ConnectionPointsResponse */
@ -3925,12 +3930,30 @@ export interface components {
/**
* GasCapacityBlock
* @description Газовый блок: городские ГРС ЕКБ со свободной мощностью. #2119 Фаза B1.
*
* + счётчики точек выхода газосети (gas_grs_outlet_points, форма 6 ФАС 960 АО
* «Екатеринбурггаз») за последний срез. #2119 Фаза B2 PR-4.
*/
GasCapacityBlock: {
/** City Grs */
city_grs: components["schemas"]["GasGrsRow"][];
/** Total Free Th M3 H */
total_free_th_m3_h: number | null;
/**
* Outlets Total
* @default 0
*/
outlets_total: number;
/**
* Outlets Deficit
* @default 0
*/
outlets_deficit: number;
/**
* Outlets Needs Calc
* @default 0
*/
outlets_needs_calc: number;
};
/**
* GasGrsRow
@ -3950,6 +3973,26 @@ export interface components {
/** Outputs Count */
outputs_count: number;
};
/**
* GasOutletPoint
* @description Гео-точка выхода газосети в радиусе от участка (для слоя карты §3). #2119 B2 PR-4.
*/
GasOutletPoint: {
/** Outlet Name */
outlet_name: string;
/** Consumer Type */
consumer_type: string | null;
/** Free Capacity Mln M3 */
free_capacity_mln_m3: number | null;
/** Needs Calc */
needs_calc: boolean;
/** Distance M */
distance_m: number;
/** Lat */
lat: number | null;
/** Lon */
lon: number | null;
};
/**
* GroundedIn
* @description Привязка ответа к источнику какой ран/схема/секции легли в основу ответа.